diff --git a/.changeset/quick-nonces-heal.md b/.changeset/quick-nonces-heal.md new file mode 100644 index 00000000000..fe2efa36523 --- /dev/null +++ b/.changeset/quick-nonces-heal.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/live-common": minor +--- + +Prevent "nonce too low" errors on rapid consecutive sends by deriving the next sequence from both the network source and locally-tracked pending operations. diff --git a/libs/ledger-live-common/src/bridge/generic-coin-framework/signOperation.ts b/libs/ledger-live-common/src/bridge/generic-coin-framework/signOperation.ts index fe1b13b17e5..89ad39983be 100644 --- a/libs/ledger-live-common/src/bridge/generic-coin-framework/signOperation.ts +++ b/libs/ledger-live-common/src/bridge/generic-coin-framework/signOperation.ts @@ -3,7 +3,12 @@ import { SignerContext } from "@ledgerhq/ledger-wallet-framework/signer"; import type { Account, DeviceId, SignOperationEvent, AccountBridge } from "@ledgerhq/types-live"; import { getCoinModuleApi } from "./api"; import { getBridgeApi } from "./bridge"; -import { bigNumberToBigIntDeep, buildOptimisticOperation, transactionToIntent } from "./utils"; +import { + bigNumberToBigIntDeep, + buildOptimisticOperation, + nextSequenceWithPending, + transactionToIntent, +} from "./utils"; import { FeeNotLoaded } from "@ledgerhq/ledger-wallet-framework/errors"; import { type GetAddressResult } from "@ledgerhq/ledger-wallet-framework/derivation"; import { log } from "@ledgerhq/logs"; @@ -58,9 +63,13 @@ export const genericSignOperation = transactionIntent.senderPublicKey = publicKey; if (typeof transactionIntent.sequence !== "bigint" || transactionIntent.sequence < 0n) { - // TODO: should compute it and pass it down to craftTransaction (duplicate call right now) - const sequenceNumber = await coinModuleApi.getNextSequence(transactionIntent.sender); - transactionIntent.sequence = sequenceNumber; + // The network sequence source lags behind a just-broadcast tx, so combine it with + // locally-tracked pending operations to avoid reusing a nonce on rapid consecutive sends. + const networkSequence = await coinModuleApi.getNextSequence(transactionIntent.sender); + transactionIntent.sequence = nextSequenceWithPending( + account.pendingOperations ?? [], + networkSequence, + ); } /* Craft unsigned blob via coin-framework */ diff --git a/libs/ledger-live-common/src/bridge/generic-coin-framework/utils.test.ts b/libs/ledger-live-common/src/bridge/generic-coin-framework/utils.test.ts index d8bbf5a5dee..f787d6e9a76 100644 --- a/libs/ledger-live-common/src/bridge/generic-coin-framework/utils.test.ts +++ b/libs/ledger-live-common/src/bridge/generic-coin-framework/utils.test.ts @@ -7,13 +7,14 @@ import { extractBalance, extractBalances, findCryptoCurrencyByNetwork, + nextSequenceWithPending, toGasOptionsFromUnknown, transactionToIntent, } from "./utils"; import { addPendingOperation } from "@ledgerhq/ledger-wallet-framework/account/index"; import BigNumber from "bignumber.js"; import type { Operation as CoreOperation } from "@ledgerhq/coin-module-framework/api/types"; -import { Account } from "@ledgerhq/types-live"; +import { Account, Operation } from "@ledgerhq/types-live"; import { GenericTransaction, GenericTransactionMode, OperationCommon } from "./types"; import * as craftTransactionDataModule from "@ledgerhq/coin-module-framework/logic/craftTransactionData"; @@ -1342,4 +1343,42 @@ describe("coin-framework utils", () => { }); }); }); + + describe("nextSequenceWithPending", () => { + const pendingOp = (seq: number | null): Operation => + ({ + transactionSequenceNumber: seq === null ? undefined : new BigNumber(seq), + }) as Operation; + + it("uses the network sequence when there are no pending operations", () => { + expect(nextSequenceWithPending([], 22n)).toBe(22n); + }); + + it("bumps past a pending op the network source hasn't caught up to yet", () => { + // Just broadcast nonce 22 (still pending); network source still reports 22 -> next must be 23. + expect(nextSequenceWithPending([pendingOp(22)], 22n)).toBe(23n); + }); + + it("takes the highest pending sequence + 1 across several pending ops", () => { + expect(nextSequenceWithPending([pendingOp(22), pendingOp(24), pendingOp(23)], 22n)).toBe(25n); + }); + + it("prefers the network sequence once it has moved ahead of pending ops", () => { + // Network source caught up (26 > 24+1) -> it wins, self-correcting. + expect(nextSequenceWithPending([pendingOp(24)], 26n)).toBe(26n); + }); + + it("ignores pending ops without a sequence number", () => { + expect(nextSequenceWithPending([pendingOp(null), pendingOp(22)], 22n)).toBe(23n); + }); + + it("ignores non-integer pending sequence numbers", () => { + expect(nextSequenceWithPending([pendingOp(22.5), pendingOp(22)], 22n)).toBe(23n); + }); + + it("handles a large pending sequence without throwing (fixed-point, not exponential)", () => { + // BigNumber(1e21).toString() is "1e+21", which BigInt() cannot parse; .toFixed() must be used. + expect(nextSequenceWithPending([pendingOp(1e21)], 5n)).toBe(1000000000000000000001n); + }); + }); }); diff --git a/libs/ledger-live-common/src/bridge/generic-coin-framework/utils.ts b/libs/ledger-live-common/src/bridge/generic-coin-framework/utils.ts index 33b39c921e9..376d793e1ab 100644 --- a/libs/ledger-live-common/src/bridge/generic-coin-framework/utils.ts +++ b/libs/ledger-live-common/src/bridge/generic-coin-framework/utils.ts @@ -142,6 +142,33 @@ export function getNativeSpendableAfterPending(account: Account): BigNumber { return BigNumber.max(0, account.spendableBalance.minus(pendingSpent)); } +/** + * Next transaction sequence (nonce) that accounts for locally-known pending operations. + * + * The network sequence source (indexer or a load-balanced RPC) can lag behind a + * just-broadcast transaction and briefly return an already-used nonce, causing a + * "nonce too low" on the next send. Taking the max with the highest pending + * sequence self-corrects once the network source catches up, without waiting for a sync. + */ +export function nextSequenceWithPending( + pendingOperations: Operation[], + networkSequence: bigint, +): bigint { + let highestPending = -1n; + for (const op of pendingOperations) { + const rawSequence = op.transactionSequenceNumber; + // Skip missing or non-integer sequences; `.toFixed()` (not `.toString()`) avoids the + // exponential notation that BigInt() cannot parse. + if (rawSequence === undefined || rawSequence === null || !rawSequence.isInteger()) { + continue; + } + const seq = BigInt(rawSequence.toFixed()); + if (seq > highestPending) highestPending = seq; + } + const pendingFloor = highestPending >= 0n ? highestPending + 1n : 0n; + return networkSequence > pendingFloor ? networkSequence : pendingFloor; +} + // Used for token sub-accounts: lock `op.value` for pending ops that should reduce token spendable. // This includes OUT-family and STAKE-family types (stake-family is fee-only on native; see getOperationAmountNumber). function isOutgoingOperation(op: Operation): boolean {