Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/quick-nonces-heal.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,33 @@
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()) {

Check warning on line 162 in libs/ledger-live-common/src/bridge/generic-coin-framework/utils.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=LedgerHQ_ledger-live&issues=AZ-4omTC1YqbgLo4uagi&open=AZ-4omTC1YqbgLo4uagi&pullRequest=20289
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 {
Expand Down
Loading