Skip to content

Serve pending transactions through the v2 REST API - #2122

Merged
nullPointerEnjoyer merged 29 commits into
masterfrom
mempool-rest-proxy
Sep 22, 2026
Merged

nullPointerEnjoyer merged 29 commits into
masterfrom
mempool-rest-proxy

Conversation

@nullPointerEnjoyer

Copy link
Copy Markdown
Contributor

GET /transaction/:id now falls back to the mempool of the connected node when the transaction is not confirmed yet, and a new GET /mempool/transactions endpoint lists pending transactions (paginated, with an optional dependency ordering via ?order=dependency and an x-mempool-ordering response header). Based on #2029 by @OBorce — the transaction dependency ordering module is reused from that PR. Unlike the original approach, the endpoints proxy the node's mempool instead of indexing it into the api-server storage, complementing the SSE event stream (#2117): tx_seen events can now be hydrated through REST. No storage changes; no consensus or p2p behavior changes.

Orders mempool transactions so that transactions depending on the
outputs or side effects of other transactions come after them: utxo
chains, token issuance and subsequent token/account commands, and the
order lifecycle (creation, fill, freeze, conclude), with account nonce
dependencies. Transactions that cannot fail are ordered first via a
priority.

Based on the module from PR #2029, with id derivation failures
propagated as errors instead of panicking.
GET /transaction/:id now falls back to the mempool of the connected
node when the transaction is not confirmed yet, and a new
GET /mempool/transactions endpoint lists the pending transactions,
optionally ordered by the dependencies between them (?order=dependency).

The endpoints proxy the mempool of the connected node instead of
indexing it: pending data is ephemeral, so no api-server storage is
used. The fee and the spent utxos of a pending transaction are not
known to the api-server and are served empty; the block-related fields
are empty until the transaction is confirmed.

Based on the endpoint design of PR #2029.
Cover the pending-transaction fallback of GET /transaction/:id and the
new GET /mempool/transactions endpoint: listing, dependency ordering,
invalid ordering rejection, and the empty-mempool case. The in-memory
test harness gains a mock mempool so the spawned web servers can serve
the new endpoints.
Run the dependency ordering off the async runtime threads, make the
ordering of the equal-priority transactions deterministic, omit the fee
field of pending transactions instead of reporting a zero fee, and fix
typos in the ordering module.
The deprecated account order commands (fill and conclude) carry the same
dependencies as their order account command counterparts. A transaction
that is both a provider and a dependent of the same dependency (e.g. two
token account commands at consecutive nonces) no longer produces a
self-dependency that would be misreported as a cycle. The token id
derivation version is resolved at the height after the tip, since the
transactions will be included into a future block.
The dependency ordering now mirrors the mempool of the node for the
delegation spends: the spends of an account are nonce-sequenced and the
first spend comes after the delegation creation (or a top-up).

The decimals of the tokens transferred by pending transactions are
resolved from the api-server storage, so pending token transfers are
rendered with the correct decimals instead of failing on the missing
token information; tokens whose issuance is still pending are rendered
with zero decimals.
The delegation stake and nft issuance outputs provide no mempool-side
dependency: staking and nft minting require an already known token or
delegation, like the node's mempool does. Creating a delegation requires
the stake pool to be known, and the stake pool creation provides that
dependency.

The decimals of the pending token issuances in a mempool listing are now
taken from the issuing transactions themselves, so chained pending
token transfers are rendered with the correct decimals, and the token
decimals of a listing page are resolved through a single read-only
storage transaction.
A failed ordering (an id derivation failure of an invalid transaction)
must not take down the whole listing: the insertion order is fetched
again instead. The storage tip is read once per request for both the
ordering and the pending issuance decimals.
The token decimals are derived from the whole fetched mempool listing
rather than only the requested page, the storage tip is read once per
request, the decimals of the same token are looked up only once, and
the response carries an x-mempool-ordering header that tells the client
whether the requested dependency ordering was applied.
The token ids of a pending transaction are collected with the existing
output values holder helper, and the delegation spend nonce overflow no
longer panics: the last possible spend simply provides no next nonce.
… by id

The mempool-proxying endpoints share a bounded number of concurrent
requests, so a load of listings cannot load the connected node in
parallel without limit. The single transaction endpoint resolves the
decimals of a pending token issuance from the mempool listing as well,
instead of always rendering zero decimals.
The barrier request has a timeout, its failure path aborts and awaits
the server task and reports the actual panic payload together with the
server address, and the handling is shared between the spawn helpers
instead of being duplicated.
A request that waits for a mempool query permit for too long is
rejected with 429 instead of queueing indefinitely, and the page offset
is converted to the usize page size with a checked conversion.
@github-actions

github-actions Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

🔍 OpenCodeReview found 9 issue(s) in this PR.

  • ✅ Successfully posted inline: 2 comment(s)
  • 📋 Routed to summary by policy: 6 comment(s)
  • ⏭️ Skipped (overlap with history): 1 comment(s)

⚠️ 1 warning(s) occurred during review.


style · low

📄 api-server/stack-test-suite/tests/v2/feerate.rs (L85-L89)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category style)

The initial wait_for_web_server request/response assertions here repeat the same check that the first iteration of the refresh loop (lines 174–185) performs immediately afterwards, yielding the identical feerate. Since wait_for_web_server already doubles as the server-start barrier (and the loop only verifies no premature refresh), the duplicated request/assert block adds no coverage. Consider either relying on the loop's first iteration alone or adding a brief comment that this is an explicit startup barrier check.

💡 Suggested Change

Before:

    let response = wait_for_web_server(&mut task, addr, &url).await;
    assert_eq!(response.status(), 200);

    let body = response.text().await.unwrap();
    assert_eq!(body, format!("\"{in_top_x_mb}\""));

After:

    // Barrier: ensure the server is up before manipulating the mocked clock.
    let response = wait_for_web_server(&mut task, addr, &url).await;
    assert_eq!(response.status(), 200);
    assert_eq!(response.text().await.unwrap(), format!("\"{in_top_x_mb}\""));

maintainability · low

📄 api-server/stack-test-suite/tests/v2/feerate.rs (L115-L122)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category maintainability)

The MempoolQueryClient impl for DummyRPC2 is defined inline inside the test function, while the equivalent empty impls for DummyRPC/MempoolRPC live in tests/common/mod.rs. This works, but placing it next to the DummyRPC2 declaration would keep test doubles and their trait impls together and match the existing convention in the suite.


style · low

📄 api-server/web-server/src/tx_dependency_ordering/dependency_graph.rs (L69-L74)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category style)

The explicit discriminants (= 3, = 2, …) are never relied upon numerically anywhere; the derived Ord over declaration order is what determines the priority semantics. The redundant numbers can drift misleadingly if variants are reordered or added. Consider dropping the discriminants (or, if they are kept, documenting that only declaration order matters).

💡 Suggested Change

Before:

pub enum TxPriorityOrder {
    Highest = 3,
    DelegationStake = 2,
    DelegationWithdrawal = 1,
    TokenFreeze = 0,
}

After:

pub enum TxPriorityOrder {
    Highest,
    DelegationStake,
    DelegationWithdrawal,
    TokenFreeze,
}

maintainability · low

📄 api-server/web-server/src/api/v2.rs (L77-L80)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category maintainability)

The semaphore capacity (8) and the 30 s wait timeout are hard-coded here and shared implicitly between the listing endpoint and the transaction fallback path; they are not tunable per deployment, unlike other server limits. Consider hoisting them into ApiServerWebServerConfig so operators can adjust them alongside the streaming limits.

💡 Suggested Change

Before:

static MEMPOOL_QUERY_PERMITS: Semaphore = Semaphore::const_new(8);

/// How long a request waits for a free mempool query permit before it is rejected.
const MEMPOOL_QUERY_WAIT_TIMEOUT: Duration = Duration::from_secs(30);

After:

/// Defaults; the values are configurable via `ApiServerWebServerConfig`.
const DEFAULT_MEMPOOL_QUERY_PERMITS: usize = 8;
const DEFAULT_MEMPOOL_QUERY_WAIT_TIMEOUT: Duration = Duration::from_secs(30);

bug · low

📄 api-server/web-server/src/api/v2.rs (L600-L602)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category bug)

tx_token_ids only collects token ids from the transaction outputs. A pending transaction that references a token via an input account command (e.g. MintTokens, FreezeToken, ChangeTokenAuthority) on a token whose issuance is also pending in the mempool will not get that token id collected, so its decimals will not be resolved from the pending issuances and it will silently be rendered with zero decimals. Consider also collecting the token ids referenced by AccountCommand inputs.

💡 Suggested Change

Before:

fn tx_token_ids(tx: &SignedTransaction) -> BTreeSet<TokenId> {
    common::chain::output_values_holder::collect_token_v1_ids_from_output_values_holder(tx)
}

After:

fn tx_token_ids(tx: &SignedTransaction) -> BTreeSet<TokenId> {
    let mut ids =
        common::chain::output_values_holder::collect_token_v1_ids_from_output_values_holder(tx);
    for inp in tx.transaction().inputs() {
        if let TxInput::AccountCommand(_, cmd) = inp {
            if let Some(token_id) = cmd.token_id() {
                ids.insert(token_id);
            }
        }
    }
    ids
}

maintainability · low

📄 api-server/web-server/src/api/v2.rs (L892-L898)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category maintainability)

In this pending fallback path the tip (inclusion_height) is read after fetching the mempool listing, while mempool_transactions reads it before the fetch. Reading the storage tip and the mempool in different orders between the two endpoints makes the token-id derivation (which depends on the height) use inconsistent snapshots under load, so the same mempool can yield different pending-issuance decimals between the listing and the single-transaction endpoints. Consider reading the tip first in both paths, for consistency.


⚠️ Warnings:

  • api-server/web-server/src/tx_dependency_ordering/dependency_graph.rs (token_budget_reached): stopped group "api-server/web-server/Cargo.toml,api-server/web-server/src/api/mod.rs,api-server/web-server/src/api/v2.rs,api-server/web-server/src/error.rs,api-server/web-server/src/lib.rs,api-server/web-server/src/main.rs,api-server/web-server/src/tx_dependency_ordering/dependency_graph.rs,api-server/web-server/src/tx_dependency_ordering/mod.rs" mid-review: used 557640 tokens exceeds budget 500000

Comment thread api-server/web-server/src/api/v2.rs Outdated
Comment on lines +655 to +662
Ok(Err(err)) => {
// The transactions were consumed by the failed ordering: refetch
// them in the insertion order rather than failing the whole
// listing (an ordering failure of an invalid transaction must not
// take it down).
ordered_by_dependency = false;
logging::log::warn!("Falling back to the mempool insertion order: {err}");
state.rpc.mempool_transactions().await.map_err(|e| {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · high
On the ordering-failure fallback the transactions are refetched, but the mempool may have changed between the two RPC calls: the refetched set can differ from the set that failed ordering, and inclusion_height (used below for pending_issuance_decimals) was computed from the first snapshot. An issuance transaction added in between gets decimals 0. Also, a persistent id-derivation failure for one tx makes every dependency-ordered request pay an extra full-mempool fetch. Consider reusing the original snapshot (e.g. have the ordering function return the unsorted transactions on error) and/or recomputing the height for the refetched snapshot.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c116a4d: order_transactions_by_dependency now returns the transactions unsorted in the original insertion order together with the error (Result<Vec<SignedTransaction>, (TopoSortError, Vec<SignedTransaction>)>), so the listing falls back to the same snapshot instead of refetching — no second RPC, and the snapshot that failed ordering is exactly the one served and scanned for the pending issuance decimals, with the same inclusion_height.

Comment thread api-server/web-server/src/api/v2.rs Outdated
Comment on lines +829 to +834
let _query_permit = MEMPOOL_QUERY_PERMITS.acquire().await.map_err(|e| {
logging::log::error!("internal error: {e}");
ApiServerWebServerError::ServerError(
ApiServerWebServerServerError::InternalServerError,
)
})?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · high
Permit acquisition here has no timeout, unlike the listing endpoint (which uses tokio::time::timeout with MEMPOOL_QUERY_WAIT_TIMEOUT). This endpoint is hit whenever a tx is not yet confirmed, so under sustained load this request can block indefinitely waiting for a permit while holding the client connection open. Reuse the same bounded wait and return TooManyMempoolRequests on timeout.

Suggestion:

Suggested change
let _query_permit = MEMPOOL_QUERY_PERMITS.acquire().await.map_err(|e| {
logging::log::error!("internal error: {e}");
ApiServerWebServerError::ServerError(
ApiServerWebServerServerError::InternalServerError,
)
})?;
let _query_permit = tokio::time::timeout(
MEMPOOL_QUERY_WAIT_TIMEOUT,
MEMPOOL_QUERY_PERMITS.acquire(),
)
.await
.map_err(|_timed_out| ApiServerWebServerError::TooManyMempoolRequests)?
.map_err(|e| {
logging::log::error!("internal error: {e}");
ApiServerWebServerError::ServerError(
ApiServerWebServerServerError::InternalServerError,
)
})?;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c116a4d: the permit acquisition is now wrapped in tokio::time::timeout(MEMPOOL_QUERY_WAIT_TIMEOUT, ...), rejecting with TooManyMempoolRequests on timeout, exactly like the listing endpoint. Note it also only triggers when the queried transaction transfers a token at all (unchanged), and per the performance finding below, only when such a token is missing from the storage.

Comment thread api-server/web-server/src/api/v2.rs Outdated
Comment on lines +835 to +840
let mempool_txs = state.rpc.mempool_transactions().await.map_err(|e| {
logging::log::error!("internal error: {e}");
ApiServerWebServerError::ServerError(
ApiServerWebServerServerError::InternalServerError,
)
})?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

performance · medium
For every pending transaction that transfers tokens, this fetches the entire mempool listing solely to derive pending-issuance decimals — an O(mempool size) RPC on the single-transaction endpoint, on top of the permit acquisition. If correctness for pending issuances is needed, consider a dedicated RPC (e.g. returning only pending issuances with their decimals) or caching the derivation result briefly, so the single-tx path does not scale with mempool size.

Suggestion:

Suggested change
let mempool_txs = state.rpc.mempool_transactions().await.map_err(|e| {
logging::log::error!("internal error: {e}");
ApiServerWebServerError::ServerError(
ApiServerWebServerServerError::InternalServerError,
)
})?;
// Ideally: let mempool_txs = state.rpc.mempool_issuances().await ...;
let mempool_txs = state.rpc.mempool_transactions().await.map_err(|e| {
logging::log::error!("internal error: {e}");
ApiServerWebServerError::ServerError(
ApiServerWebServerServerError::InternalServerError,
)
})?;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c116a4d without extending the node RPC surface: the storage is consulted for the decimals of the transferred tokens first, and the mempool listing is fetched only if some of them is not indexed yet — i.e. it may be an issuance pending in the mempool itself (a pending issuance is exactly a storage miss). The common case of a transaction transferring already known tokens no longer touches the mempool; only the genuinely pending-issuance case pays for the listing, still bounded by the (now timed) query permits. A dedicated mempool_issuances-style node RPC would shrink even that case from O(mempool) to O(issuances), but it crosses into the node RPC surface — happy to follow up in a separate PR if wanted.

Comment on lines +169 to +170
TxOutput::CreateOrder(order_data) => {
let order_id = make_order_id(inputs)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
A single transaction whose make_order_id/make_token_id fails (e.g. an id-derivation failure, which can also depend on the assumed inclusion height differing from the eventual one) aborts the whole sort via ?. This forces every /mempool/transactions?order=dependency request onto the insertion-order fallback (an extra full-mempool RPC round trip) for as long as that tx stays in the mempool. Consider skipping such outputs/transactions (mirroring how pending_issuance_decimals skips derivation failures) instead of failing the entire ordering.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c116a4d: a transaction whose make_order_id/make_token_id fails only loses its own dependency edges — the derivation failure is logged and the output is skipped, mirroring how pending_issuance_decimals skips such issuances — instead of failing the whole sort. The id-derivation error variant is consequently gone from TopoSortError (graph building is now infallible); the remaining sort errors (cycle, missing dependency, and the newly detected duplicate id) still fall back to the insertion order, and the fallback reuses the same snapshot per the first finding.

The failed dependency ordering now returns the transactions unsorted in
the original insertion order, so the listing falls back without refetching
the mempool, which could return a different snapshot and skew the pending
issuance decimals. A transaction whose order or token id cannot be derived
only loses its own dependency edges instead of failing the whole ordering,
and a duplicated id is reported as such instead of surfacing as a cycle.

The single-transaction endpoint bounds its wait for a query permit like the
listing endpoint, and fetches the mempool listing only if some of the
transferred tokens is not indexed yet, i.e. it may be an issuance pending
in the mempool itself.

The block-related fields of the pending transactions are null instead of
empty strings, and the fee key expected by the pending responses is pinned
by a contract test.
The web server mock uses a tokio lock, so a panicking task cannot poison
it for the other tests, and the submission request is bounded like the
startup barrier, which now also bounds the first request of the tests
that spawned the server manually.

The fallback of the failed dependency ordering is pinned to reuse the
same mempool snapshot, the pending transactions are expected to carry
null block-related fields, and the listing assertions report the
offending response on failure.
@nullPointerEnjoyer

Copy link
Copy Markdown
Contributor Author

Addressed all 12 review findings in c116a4d, 0bc3686 and 627466c:

High/medium:

  • Ordering-fallback snapshot mismatch + extra fetch: the failed ordering now hands the transactions back unsorted in the original insertion order; the listing falls back without refetching, so the served snapshot and the pending issuance decimals share one fetch and one inclusion_height.
  • Unbounded permit wait in GET /transaction/:id: bounded by MEMPOOL_QUERY_WAIT_TIMEOUT → TooManyMempoolRequests, like the listing endpoint.
  • O(mempool) fetch on the single-tx path: the listing is fetched only when a transferred token has no indexed decimals (i.e. it may be a pending issuance); transactions transferring known tokens no longer touch the mempool. A dedicated node RPC for pending issuances is noted as a possible follow-up.
  • One bad transaction breaking the dependency ordering: only its own dependency edges are skipped (with a warning); graph building is now infallible.

Low:

  • MempoolRPC mock: tokio::sync::RwLock (no poisoning) + #[derive(Default)]; the submission POST and its body read are bounded by the barrier timeout, as is the post-abort task join in wait_for_web_server; the manually spawned tests (chain_tip, feerate ok/ok_reload_feerate, transactions::ok) now go through the bounded barrier helper, and a server error can no longer be swallowed silently.
  • Pending transactions report block_id/timestamp/confirmations as null instead of ``; a contract test pins the fee key that the pending responses remove.
  • Duplicate ids in the ordering input are reported as TopoSortError::DuplicateId instead of masquerading as a cycle; offset slicing uses a plain cast; listing assertions include the offending response.

Declined (with reason): sharing a path constant / generalizing the web-server bootstrap helper across the three spawn sites — a refactor of test scaffolding with divergence risk of its own; the current copies are pinned by the tests above. Can follow up if maintainers prefer the shared helper.

Verified: cargo test -p api-web-server (incl. new ordering/duplicate-id/contract tests) and the full in_memory stack suite (111 tests) pass; fmt, both clippy passes and codecheck clean. Also re-ran OpenCodeReview locally with the CI configuration (--effort low --timeout 15 --max-tokens-budget 500000) on the updated branch: no findings in the web-server sources; the remaining findings were the test-infra follow-ups addressed above (bounded join/body read) and style suggestions declined above.

Comment on lines +539 to +545
let decimals = db_tx
.get_token_num_decimals(token_id)
.await
.map_err(internal_error)?
// The issuance of the token is neither pending in the listing
// nor indexed, so its decimals cannot be known.
.unwrap_or(0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · medium
Tokens whose decimals cannot be resolved are silently rendered with 0 decimals (unwrap_or(0)), and the fee key is simply removed from the response. A client cannot distinguish "unknown decimals" from a genuine zero-decimals token. Combined with the speculative token-id derivation above, pending transfers of unresolvable tokens can present materially wrong monetary metadata. Consider adding an explicit signal in the response (e.g. a decimals_known: false marker or decimals: null) instead of the silent zero default.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented in 448aac8 rather than changed: the decimal field of the rendered amounts is presentational and the atoms amounts are authoritative regardless of the decimals, so a client misreading the presentation cannot corrupt its accounting. Surfacing decimals: null (or a marker) would be a contract change rippling through the shared renderers (amount_to_json and the token-decimals plumbing are used by the address/utxo/token endpoints for confirmed data too, where the unresolvable case already maps to zero, e.g. NFT token ids), so it does not fit this PR; happy to follow up if maintainers want the explicit signal.

Comment on lines +566 to +570
fn pending_issuance_decimals(
txs: &[SignedTransaction],
chain_config: &ChainConfig,
block_height: BlockHeight,
) -> BTreeMap<TokenId, u8> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

other · medium
Token id derivation here uses the token-id-generation version resolved at best_block().next_height(), but a pending transaction is actually confirmed at some future height. make_token_id depends on the height only through chainstate_upgrades().version_at_height(...).token_id_generation_version(), so the derivation diverges from consensus only if an upgrade activates between the snapshot height and the actual inclusion height. This is an edge case, but it would silently yield wrong token ids (and thus wrong/zero decimals and missing dependency edges) for issuances pending across an upgrade boundary. Worth a comment acknowledging the limitation, or resolving the version conservatively (e.g. considering the next scheduled upgrade).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented in 448aac8: the derivation assumes the inclusion height stays within the same token id generation version as the snapshot tip; across an upgrade boundary the derived ids (and the resolved decimals and dependency edges) can be wrong until confirmation. Resolving the version "conservatively" is not really possible without knowing the inclusion height, and the pending data is provisional by nature — it corrects itself once the transactions are confirmed and indexed.

Comment on lines 926 to 931
obj.insert(
"block_id".into(),
block
.as_ref()
.map_or("".to_string(), |b| {
b.block_id().to_hash().encode_hex::<String>()
})
.into(),
block.as_ref().map_or(serde_json::Value::Null, |b| {
b.block_id().to_hash().encode_hex::<String>().into()
}),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

other · medium
This is a breaking change to the public response contract: block_id, timestamp and confirmations were previously empty strings for missing values and are now JSON null, and fee is removed entirely for pending transactions. The test suite has been updated to pin the new semantics, but any existing external client parsing these fields as strings will break. Consider documenting this in the API spec/changelog (or an OpenAPI update) alongside the change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented in 448aac8 in api-server/CHANGELOG.md (the changelog entry of the pending endpoints still described the empty-string shape and an outdated decimals-resolution behavior; it now spells out the null block-related fields, the omitted fee, the lazy mempool listing for the not-yet-indexed tokens and the zero-decimals fallback). Worth noting that this is not a break of an existing contract: previously GET /v2/transaction/{id} returned 404 for a pending transaction, so no client could have parsed these fields in this situation before; the mempool listing endpoint itself is new in this PR.

Note that the unresolvable decimals are rendered as zero with the atoms
amounts staying authoritative, that the pending token id derivation is
provisional across a token id generation upgrade boundary, and correct
the changelog entry of the pending endpoints: the block-related fields
are null rather than empty, and the decimals of a yet unknown token are
resolved from the mempool listing of the single transaction endpoint as
well.
Comment on lines +868 to +870
if pending_token_ids.is_empty() {
BTreeMap::new()
} else {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security · medium
This fallback triggers a full mempool_transactions() listing (cost proportional to the mempool size) whenever a transferred token lacks stored decimals. An attacker can submit mempool transactions whose AccountCommands reference never-issued token ids; every GET /transaction/:id for such a transaction then takes the expensive path. The 8-permit semaphore bounds concurrency, but each permit is held for the full listing fetch; consider validating that the pending token ids are at least plausible issuances (e.g. that the same transaction mints/issues them) before fetching the listing, or documenting the accepted cost.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented the accepted cost in 36992f1 (code comment + changelog). Requiring the same transaction to issue the referenced tokens would break the legitimate cross-transaction case this feature exists for — a spending transaction referencing the issuance transaction pending elsewhere in the mempool — so an unresolvable token id cannot be cheaply distinguished from a pending issuance of another transaction, and the request keeps paying one bounded listing fetch (the same cost class as GET /v2/mempool/transactions itself, bounded by the query permits and their wait timeout).

Comment on lines +212 to +214
TxOutput::IssueFungibleToken(_) => {
let token_id = match make_token_id(chain_config, block_height, inputs) {
Ok(token_id) => token_id,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · high
The IssueFungibleToken, IssueNft, CreateOrder, CreateStakePool and CreateDelegationId output arms do not register a Dependency::Utxo provider edge, unlike the catch-all arm. Any mempool transaction that spends an output of such a transaction via TxInput::Utxo will therefore have a dependent edge whose provider is missing, making topological_sort fail with MissingDependency — which downgrades the whole /mempool/transactions listing to insertion order. Registering the Utxo provider edge in these arms (e.g. alongside the existing registrations) keeps the ordering working for spends of these outputs.

Suggestion:

Suggested change
TxOutput::IssueFungibleToken(_) => {
let token_id = match make_token_id(chain_config, block_height, inputs) {
Ok(token_id) => token_id,
TxOutput::IssueFungibleToken(_) => {
// The output is still a spendable UTXO, so register the Utxo
// provider edge in addition to the token creation one, otherwise
// a mempool spend of this output reports a missing dependency.
let outpoint = UtxoOutPoint::new(
OutPointSourceId::Transaction(tx.transaction().get_id()),
out_index as u32,
);
dependencies
.providers
.entry(Dependency::Utxo(outpoint))
.or_default()
.push(tx_index);
let token_id = match make_token_id(chain_config, block_height, inputs) {
Ok(token_id) => token_id,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 36992f1 by hoisting the utxo provider registration above the output match: every output is a spendable utxo regardless of its type, so the edge is now registered for all of them (the previously duplicated registrations in the delegate-staking and catch-all arms are gone, so a future arm cannot forget it either). Covered by the new test_typed_output_utxo_dependency_chain regression test, which spends the output of a pending token issuance and asserts the dependency edge.

Every output is a spendable utxo regardless of its type, so the utxo
provider edge is registered for all of them instead of only the plain
transfer ones; a mempool transaction spending the output of, say, a
pending token issuance used to make the whole dependency ordering fail
with a missing dependency and the listing fall back to the insertion
order.

The accepted cost of the single transaction endpoint fetching the mempool
listing for a transaction referencing a token that does not exist at all
is documented, as is the endpoint in the changelog.
Comment on lines +143 to +152
for tx_index in tx_indices {
// A transaction can be both a provider and a dependent of the same
// dependency (e.g. a transaction carrying two account commands of the
// same token at consecutive nonces): such a self-dependency carries no
// ordering information and would be reported as a cycle.
let self_id = dependency_nodes[*tx_index].id;
dependency_nodes[*tx_index]
.dependencies
.extend(providers.iter().filter(|id| **id != self_id).copied());
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · medium
build_dependency_graph can add the same dependency id twice to a node's dependencies (e.g. a transaction with two inputs mapping to the same Dependency, or a provider listed twice for one key). The duplicate edges inflate both indegrees and the dependents lists symmetrically, so the sort is currently still correct, but this invariant is implicit and fragile: deduplicating on one side only would corrupt the sort. Worth deduplicating the edges explicitly (e.g. a sort_unstable().dedup() on the extended dependencies, or inserting into a BTreeSet first) so the graph contract does not depend on the caller's symmetric bookkeeping.

Suggestion:

Suggested change
for tx_index in tx_indices {
// A transaction can be both a provider and a dependent of the same
// dependency (e.g. a transaction carrying two account commands of the
// same token at consecutive nonces): such a self-dependency carries no
// ordering information and would be reported as a cycle.
let self_id = dependency_nodes[*tx_index].id;
dependency_nodes[*tx_index]
.dependencies
.extend(providers.iter().filter(|id| **id != self_id).copied());
}
for tx_index in tx_indices {
// A transaction can be both a provider and a dependent of the same
// dependency (e.g. a transaction carrying two account commands of the
// same token at consecutive nonces): such a self-dependency carries no
// ordering information and would be reported as a cycle.
let self_id = dependency_nodes[*tx_index].id;
let deps = &mut dependency_nodes[*tx_index].dependencies;
deps.extend(providers.iter().filter(|id| **id != self_id).copied());
// A node can declare the same dependency twice (e.g. two inputs
// mapping to the same dependency): keep the edges unique so that
// the indegrees and the adjacency list stay consistent.
deps.sort_unstable();
deps.dedup();
}

Comment on lines +336 to +342
if acct.nonce().value() == 0 {
dependencies
.dependents
.entry(Dependency::DelegationCreation(*delegation_id))
.or_default()
.push(tx_index);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · critical
No code ever registers a provider for Dependency::DelegationCreation (or for Dependency::DelegationSpending(id, nonce 0)): CreateDelegationId outputs are only registered as dependents of PoolCreation, and DelegationSpending providers are only produced by the previous nonce's transaction. As a result, any mempool listing that contains the first DelegationBalance spend of a delegation (nonce 0) — even when the delegation and pool are already confirmed on-chain, which is the common case — has a dependent key with no provider, so topological_sort fails with MissingDependency and the entire dependency-ordered listing falls back to insertion order. Note the same holds for Utxo dependencies on outpoints not in the snapshot, but those can't be expressed here; for delegation spends they always are. Suggested fix: only add the DelegationCreation/DelegationSpending(nonce 0) dependent keys when the providing transaction is actually present in the listing (e.g. build the graph, then drop edges whose provider is missing), or treat them like on-chain requirements and skip them.

Suggestion:

Suggested change
if acct.nonce().value() == 0 {
dependencies
.dependents
.entry(Dependency::DelegationCreation(*delegation_id))
.or_default()
.push(tx_index);
}
// Only track nonce-sequenced dependencies between the
// transactions of this listing; the first spend (nonce 0)
// may depend on a delegation confirmed long ago, which
// is not a dependency between mempool transactions.
if acct.nonce().value() == 0 && false {
unimplemented!();
}

@nullPointerEnjoyer
nullPointerEnjoyer merged commit b6c30b0 into master Sep 22, 2026
21 checks passed
@nullPointerEnjoyer
nullPointerEnjoyer deleted the mempool-rest-proxy branch September 22, 2026 06:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants