diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d660aa04..5702640dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to the Movement TypeScript SDK will be captured in this file. This changelog is written by hand for now. It adheres to the format set out by [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +# Unreleased + +- Report an expired transaction as expired. `waitForTransaction` retries a 404, so once a node drops an expired transaction from its mempool the wait kept polling a hash that no longer existed and ended with "timed out in pending state", which points at `timeoutSecs` when no amount of waiting can help. It now compares the transaction's `expiration_timestamp_secs` against the ledger timestamp and says the transaction expired, naming `options.expireTimestamp` and `transactionGenerationConfig.defaultTxnExpirySecFromNow` as the ways to widen the window. + # 5.1.7 (2026-03-25) - Add mainnet Movement Name Service (MNS) router contract address to `NetworkToMnsContract`, enabling `movement.mns` on `Network.MAINNET`. diff --git a/src/internal/transaction.ts b/src/internal/transaction.ts index 9e6089cda..a52629f4c 100644 --- a/src/internal/transaction.ts +++ b/src/internal/transaction.ts @@ -26,7 +26,7 @@ import { import { DEFAULT_TXN_TIMEOUT_SEC, ProcessorType } from "../utils/const"; import { sleep } from "../utils/helpers"; import { memoizeAsync } from "../utils/memoize"; -import { getIndexerLastSuccessVersion, getProcessorStatus } from "./general"; +import { getIndexerLastSuccessVersion, getLedgerInfo, getProcessorStatus } from "./general"; /** * Retrieve a list of transactions based on the specified options. @@ -147,6 +147,30 @@ export async function isTransactionPending(args: { } } +/** + * Whether a transaction's expiry has already passed on chain. + * + * `expiration_timestamp_secs` is in seconds and `ledger_timestamp` is in + * microseconds, so the two need converting before they can be compared. + * + * Chain time rather than local time on purpose: the node decides what has + * expired, and a caller with a skewed clock would otherwise be told the wrong + * thing about a transaction that is still perfectly valid. + * + * @group Implementation + */ +export function hasTransactionExpired(args: { + expirationTimestampSecs: string | number | bigint; + ledgerTimestampMicros: string | number | bigint; +}): boolean { + const expirySecs = Number(args.expirationTimestampSecs); + const ledgerSecs = Number(args.ledgerTimestampMicros) / 1_000_000; + if (!Number.isFinite(expirySecs) || !Number.isFinite(ledgerSecs)) { + return false; + } + return ledgerSecs > expirySecs; +} + /** * Waits for a transaction to be confirmed by its hash. * This function allows you to monitor the status of a transaction until it is finalized. @@ -180,7 +204,7 @@ export async function longWaitForTransaction(args: { * @param args.options.timeoutSecs - The maximum time to wait for the transaction in seconds. Defaults to a predefined value. * @param args.options.checkSuccess - A flag indicating whether to check the success status of the transaction. Defaults to true. * @returns A promise that resolves to the transaction response once the transaction is confirmed. - * @throws WaitForTransactionError if the transaction times out or remains pending. + * @throws WaitForTransactionError if the transaction expires on chain, or times out while still pending. * @throws FailedTransactionError if the transaction fails. * @group Implementation */ @@ -276,6 +300,31 @@ export async function waitForTransaction(args: { } if (lastTxn.type === TransactionResponseType.Pending) { + // A dropped transaction and a slow one are indistinguishable from the poll + // alone: once the node garbage-collects an expired transaction the hash + // 404s, which is retried as if it were merely not visible yet, and the wait + // ends reporting a timeout. That points at timeoutSecs, when no amount of + // waiting can help. Ask the chain what time it is and say which one it was. + let expired = false; + try { + const ledgerInfo = await getLedgerInfo({ movementConfig }); + expired = hasTransactionExpired({ + expirationTimestampSecs: lastTxn.expiration_timestamp_secs, + ledgerTimestampMicros: ledgerInfo.ledger_timestamp, + }); + } catch { + // Best effort. If the node cannot be reached for the timestamp, fall back + // to the original message rather than replacing one unclear failure with + // another. + } + if (expired) { + throw new WaitForTransactionError( + `Transaction ${transactionHash} expired at ${lastTxn.expiration_timestamp_secs} and was dropped from the mempool. ` + + `It will never commit. Resubmit with a longer expiry: pass options.expireTimestamp, or raise ` + + `transactionGenerationConfig.defaultTxnExpirySecFromNow on MovementConfig.`, + lastTxn, + ); + } throw new WaitForTransactionError( `Transaction ${transactionHash} timed out in pending state after ${timeoutSecs} seconds`, lastTxn, diff --git a/tests/unit/transactionExpiry.test.ts b/tests/unit/transactionExpiry.test.ts new file mode 100644 index 000000000..0c847cc62 --- /dev/null +++ b/tests/unit/transactionExpiry.test.ts @@ -0,0 +1,66 @@ +// Copyright © Move Industries +// SPDX-License-Identifier: Apache-2.0 + +import { hasTransactionExpired } from "../../src/internal/transaction"; + +/** + * A transaction whose expiry has passed is dropped from the mempool, and its + * hash then 404s. waitForTransaction retries a 404, so it cannot tell that case + * apart from a transaction that is simply slow, and reports a poll timeout for + * both. This is the comparison that separates them. + * + * The two fields are in different units: expiration_timestamp_secs is seconds, + * ledger_timestamp is microseconds. + */ +describe("hasTransactionExpired", () => { + const expirySecs = "1786000000"; + const asMicros = (secs: number) => String(secs * 1_000_000); + + it("reports expired when chain time has passed the expiry", () => { + expect( + hasTransactionExpired({ + expirationTimestampSecs: expirySecs, + ledgerTimestampMicros: asMicros(1786000001), + }), + ).toBe(true); + }); + + it("reports not expired when chain time is before the expiry", () => { + expect( + hasTransactionExpired({ + expirationTimestampSecs: expirySecs, + ledgerTimestampMicros: asMicros(1785999999), + }), + ).toBe(false); + }); + + it("treats the exact expiry second as not yet expired", () => { + expect( + hasTransactionExpired({ + expirationTimestampSecs: expirySecs, + ledgerTimestampMicros: asMicros(1786000000), + }), + ).toBe(false); + }); + + it("converts units rather than comparing the numbers directly", () => { + // Compared raw, a microsecond ledger timestamp dwarfs any second-based + // expiry and every transaction looks expired. + expect( + hasTransactionExpired({ + expirationTimestampSecs: expirySecs, + ledgerTimestampMicros: asMicros(1000), + }), + ).toBe(false); + }); + + it("accepts numbers and bigints as well as the strings the API returns", () => { + expect(hasTransactionExpired({ expirationTimestampSecs: 100, ledgerTimestampMicros: 200_000_000 })).toBe(true); + expect(hasTransactionExpired({ expirationTimestampSecs: 100n, ledgerTimestampMicros: 50_000_000n })).toBe(false); + }); + + it("reports not expired on unparseable input, so a bad value cannot invent an expiry", () => { + expect(hasTransactionExpired({ expirationTimestampSecs: "not-a-number", ledgerTimestampMicros: "1" })).toBe(false); + expect(hasTransactionExpired({ expirationTimestampSecs: "1", ledgerTimestampMicros: "not-a-number" })).toBe(false); + }); +});