Skip to content

fix: report an expired transaction as expired - #10

Draft
ganymedio wants to merge 2 commits into
mainfrom
fix/report-expired-transaction
Draft

fix: report an expired transaction as expired#10
ganymedio wants to merge 2 commits into
mainfrom
fix/report-expired-transaction

Conversation

@ganymedio

Copy link
Copy Markdown
Collaborator

A transaction that expires is reported as a poll timeout, which names the wrong cause and sends you to the wrong lever.

What happens today

waitForTransaction treats a 404 as retryable, in handleAPIError. That is correct while a transaction is merely not visible yet, but it is also what happens after a node garbage-collects an expired transaction: the hash stops resolving. The loop keeps polling something that no longer exists until timeoutSecs runs out, then throws:

Transaction 0x… timed out in pending state after 20 seconds

The natural reading is "poll for longer", so the reader reaches for timeoutSecs. No amount of waiting can help, because the transaction is gone and will never commit. Nothing in the loop compares expiration_timestamp_secs against ledger time, so a slow transaction and a dropped one are indistinguishable.

The defaults hide it further: DEFAULT_TXN_TIMEOUT_SEC and DEFAULT_TXN_EXP_SEC_FROM_NOW are both 20, so the wait gives up at almost exactly the moment the transaction expires.

What this changes

Only the failure path. Before choosing which error to throw, the wait asks the node for the ledger timestamp and compares it against the expiry it already holds on lastTxn, which is retained precisely for this kind of post-loop decision. If chain time is past the expiry:

Transaction 0x… expired at 1786000000 and was dropped from the mempool. It will
never commit. Resubmit with a longer expiry: pass options.expireTimestamp, or
raise transactionGenerationConfig.defaultTxnExpirySecFromNow on MovementConfig.

Otherwise the previous message is unchanged.

Three deliberate choices:

  • Chain time, not local time. The node decides what has expired. A client with a skewed clock would otherwise be told a still-valid transaction had expired.
  • Failure path only. The extra request costs nothing on the success path, and it runs at most once.
  • Best effort. If the node cannot be reached for the timestamp, it falls back to the original message rather than replacing one unclear failure with another.

No behaviour changes: the same calls fail and succeed as before, and the same error type is thrown. Only the message differs, and only when the transaction actually expired.

Scope

This is deliberately not a change to DEFAULT_TXN_EXP_SEC_FROM_NOW. The complaint that motivated it is that the failure is unexplained, and that is a diagnosis problem. Raising the default is a separate proposal with its own tradeoff, since sequence-number ordering means a submitted transaction that never commits blocks later transactions from the same account until it expires, so a longer default turns a short stall into a long one for every consumer.

Also out of scope: neither WaitForTransactionError nor FailedTransactionError is on the public surface today, so callers cannot branch on the type and have to read the message. A distinct error class would be the better interface, but src/errors/index.ts is the natural home and internal/transaction.ts already imports MovementApiError from it, so re-exporting there introduces a cycle. Worth doing separately.

Verification

The comparison is extracted as hasTransactionExpired, a pure function, so it is unit tested without a network. That matches how tests/unit is written, since nothing there mocks the client layer. Cases covered: expired, not expired, the exact expiry second, the unit conversion between seconds and microseconds, string, number and bigint inputs, and unparseable input.

I have not run the suite. This checkout has no node_modules, so pnpm install is needed first. Both changed files were syntax-checked with the TypeScript parser, which is not a substitute. Worth running pnpm unit-test before this leaves draft.

waitForTransaction retries a 404, so once a node drops an expired
transaction from its mempool the wait keeps polling a hash that no longer
exists and ends with "timed out in pending state after 20 seconds". That
names the wrong cause: it points at timeoutSecs, when no amount of waiting
can help, and it is indistinguishable from a transaction that is merely
slow.

The wait now compares the transaction's expiration_timestamp_secs against
the ledger timestamp before deciding which failure to report, and says the
transaction expired and was dropped, naming the two ways to widen the
window.

Chain time rather than local time, because the node decides what has
expired and a skewed client clock would otherwise misreport a transaction
that is still valid. The extra request runs only on the failure path, and a
failure to reach the node falls back to the previous message rather than
replacing one unclear error with another.

The comparison is a pure exported function so it can be unit tested without
a network, matching how the rest of tests/unit is written.
pnpm _fmt --check is a CI job and the new file was not formatted.
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