Skip to content
Draft
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
53 changes: 51 additions & 2 deletions src/internal/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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,
Expand Down
66 changes: 66 additions & 0 deletions tests/unit/transactionExpiry.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading