Skip to content

fix(sentinel-api-service): gasCost is a decimal string on the wire, not a number - #10050

Open
gomesalexandre wants to merge 3 commits into
MetaMask:mainfrom
gomesalexandre:fix_sentinel_gascost_type_string
Open

fix(sentinel-api-service): gasCost is a decimal string on the wire, not a number#10050
gomesalexandre wants to merge 3 commits into
MetaMask:mainfrom
gomesalexandre:fix_sentinel_gascost_type_string

Conversation

@gomesalexandre

@gomesalexandre gomesalexandre commented Sep 1, 2026

Copy link
Copy Markdown

Type-correctness fix, not a bugfix — nothing crashes today, but a public type was lying about the shape of the value it describes.

The mismatch

gasCost is declared number in two places:

  • packages/sentinel-api-service/src/types.ts:455 (SentinelSimulationResponseTransaction, publicly exported)
  • packages/transaction-controller/src/api/simulation-api.ts:231 (SimulationResponseTransaction, internal to the package)

The live Sentinel API actually returns it as a quoted decimal string. Verified with a live, unauthenticated, read-only simulation call:

$ curl -s -X POST 'https://tx-sentinel-ethereum-mainnet.api.cx.metamask.io/' \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":"1","method":"infura_simulateTransactions","params":[{"transactions":[{"from":"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045","to":"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045","value":"0x0"}],"withGas":true}]}'

Response (trimmed):

"feeEstimate":49349685978978,"baseFeePerGas":243067418,"gasCost":"5119485957916"

feeEstimate (the adjacent field carrying the same kind of value) is a bare JSON number. gasCost is quoted. That contrast isn't an accident — the API deliberately string-encodes this specific field, almost certainly because the value can exceed Number.MAX_SAFE_INTEGER (any transaction with a wei-scale gas cost ≥ ~0.009 ETH, trivially crossed on Polygon or at moderate mainnet gas prices).

Why it's worth fixing

SentinelSimulationResponseTransaction is public exported API of @metamask/sentinel-api-service. A downstream consumer typing against number and doing arithmetic (gasCost + 1) gets silent string concatenation at runtime ("51194859579161") with no type error, because TypeScript trusts the wrong declared type.

The one internal consumer today, getSimulationBalanceChange in packages/transaction-controller/src/utils/balance-changes.ts, passes the value straight to new BN(offset), which happens to accept both a JS number and a base-10 string — which is exactly why this has never surfaced as a visible bug. The type was still wrong; nothing has hit the footgun yet.

Every sibling numeric-ish field on the same type (gas, gasLimit, gasUsed, maxFeePerGas, transferEstimate) is typed Hex. gasCost being number is the lone outlier — a hand-written declaration that was never checked against a real response. Confirmed there are no recorded/VCR-style HTTP fixtures anywhere in the repo for this response shape; the only existing test fixtures are TS-typed builders (balance-changes.test.ts), so the suite has only ever exercised a wire shape the API doesn't actually emit.

Fix

  • gasCost?: numbergasCost?: string in both type declarations (+ corrected doc comments, "decimal number" → "decimal string").
  • getSimulationBalanceChange's offset parameter: number = 0string = '0' (matches how new BN already consumed it at runtime).
  • Test fixture builder createNativeBalanceResponse (balance-changes.test.ts) updated to build a string, matching the corrected type; one call site passing a numeric literal (2) updated to '2'.
  • Added a new test case with a gas cost above Number.MAX_SAFE_INTEGER (10500000000000000, realistic for Polygon), asserting it round-trips exactly through BN with no precision loss.

Grepped the whole monorepo for any other reader of gasCost (or the offset param) doing numeric arithmetic, comparisons, or JSON round-tripping — found none. This is scoped to the two type declarations and their one real consumer.

Scope note

Out of scope, flagged for awareness only: the real response also carries several fields undeclared in the TS type (status, feeEstimate, baseFeePerGas, blockNumber, id, fees[].balanceNeeded/currentBalance). Left for a separate, narrower follow-up rather than scope-creeping this into a full response-shape audit.

receipts

$ yarn workspace @metamask/transaction-controller run jest
Test Suites: 44 passed, 44 total
Tests:       1144 passed, 1144 total   (was 1143 before this PR; +1 new)

$ yarn workspace @metamask/sentinel-api-service run jest
Test Suites: 1 passed, 1 total
Tests:       45 passed, 45 total

$ yarn eslint <changed files>
(clean, exit 0)

$ yarn lint:tsc   # tsc --build tsconfig.lint.json, monorepo-wide
(clean, exit 0)

Ran a synchronous Codex adversarial review before opening. It confirmed the BN semantics are correct for the string path (including large values and the '0' default), found no missed consumers or stale numeric fixtures, and flagged two real issues which are both applied in this PR: the sentinel-api-service changelog entry needed a **BREAKING:** marker with migration guidance since the changed type is publicly exported (fixed), and the transaction-controller changelog entry described a non-exported internal type with no consumer-facing behavior change and should be dropped per this repo's changelog conventions (removed).

risk

Low for transaction-controller (internal type, one consumer, behavior-preserving at runtime since new BN already accepted strings). The sentinel-api-service change is a breaking TS type change for any external consumer of SentinelSimulationResponseTransaction.gasCost — called out with **BREAKING:** in that package's changelog with migration guidance.


Note

Medium Risk
Breaking TypeScript contract for @metamask/sentinel-api-service consumers of gasCost; runtime behavior in transaction-controller is largely unchanged because BN already accepted strings.

Overview
Aligns simulation response types with the Sentinel API: gasCost is now string (wei as a decimal string) instead of number on SentinelSimulationResponseTransaction and the internal SimulationResponseTransaction, with docs updated accordingly.

@metamask/sentinel-api-service documents this as a breaking public type change; consumers must parse with BN/BigInt before arithmetic. transaction-controller passes gasCost into native balance math via getSimulationBalanceChange, whose optional offset is now a string defaulting to '0' (still fed to new BN). Tests and fixtures use string gasCost, including a case above Number.MAX_SAFE_INTEGER so large wei values do not lose precision.

Reviewed by Cursor Bugbot for commit 2a3c37e. Bugbot is set up for automated code reviews on this repo. Configure here.

…l string on the wire, not a number

The live Sentinel API (tx-sentinel-{network}.api.cx.metamask.io,
infura_simulateTransactions) emits gasCost as a quoted decimal string,
not a JSON number. Both TS declarations claimed number. Verified with
a live unauthenticated read-only simulation call - see PR body for the
raw response bytes.

SentinelSimulationResponseTransaction is public exported API, so a
downstream consumer typing against number and doing arithmetic on
gasCost gets silent string concatenation at runtime with no type
error. The one internal consumer (balance-changes.ts, new BN(offset))
happens to tolerate both a number and a base-10 string, which is why
this never surfaced as a visible bug.
@gomesalexandre
gomesalexandre marked this pull request as ready for review September 1, 2026 13:29
@gomesalexandre
gomesalexandre requested review from a team as code owners September 1, 2026 13:29
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.

1 participant