Skip to content

Commit 720969a

Browse files
authored
feat(cli): add --nonce flag for transaction nonce override (#110)
1 parent efe5d25 commit 720969a

File tree

8 files changed

+83
-31
lines changed

8 files changed

+83
-31
lines changed

packages/cli/src/commands/compute/app/deploy.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Command, Flags } from "@oclif/core";
22
import { getEnvironmentConfig, UserApiClient, isMainnet } from "@layr-labs/ecloud-sdk";
33
import { withTelemetry } from "../../../telemetry";
4-
import { commonFlags, applyGasOverrides } from "../../../flags";
4+
import { commonFlags, applyTxOverrides } from "../../../flags";
55
import { createComputeClient } from "../../../client";
66
import { createViemClients } from "../../../utils/viemClients";
77
import {
@@ -425,11 +425,14 @@ export default class AppDeploy extends Command {
425425
});
426426

427427
// 9. Apply gas overrides if provided, show estimate, and prompt for confirmation on mainnet
428-
const finalGas = applyGasOverrides(gasEstimate, flags);
428+
const finalTx = await applyTxOverrides(gasEstimate, flags, { publicClient, address });
429429
if (flags["max-fee-per-gas"] || flags["max-priority-fee"]) {
430430
this.log(chalk.yellow(`\nGas override active — max fee: ${flags["max-fee-per-gas"] || "estimated"} gwei, priority fee: ${flags["max-priority-fee"] || "estimated"} gwei`));
431431
}
432-
this.log(`\nEstimated transaction cost: ${chalk.cyan(finalGas.maxCostEth)} ETH`);
432+
if (finalTx.nonce != null) {
433+
this.log(chalk.yellow(`Nonce override active — nonce: ${finalTx.nonce}`));
434+
}
435+
this.log(`\nEstimated transaction cost: ${chalk.cyan(finalTx.maxCostEth)} ETH`);
433436

434437
if (isMainnet(environmentConfig)) {
435438
const confirmed = await confirm(`Continue with deployment?`);
@@ -440,7 +443,7 @@ export default class AppDeploy extends Command {
440443
}
441444

442445
// 10. Execute the deployment
443-
const res = await compute.app.executeDeploy(prepared, finalGas);
446+
const res = await compute.app.executeDeploy(prepared, finalTx);
444447

445448
// 11. Collect app profile while deployment is in progress (optional)
446449
if (!flags["skip-profile"]) {

packages/cli/src/commands/compute/app/start.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Command, Args } from "@oclif/core";
22
import { createComputeClient } from "../../../client";
3-
import { commonFlags, applyGasOverrides } from "../../../flags";
3+
import { commonFlags, applyTxOverrides } from "../../../flags";
44
import {
55
getEnvironmentConfig,
66
estimateTransactionGas,
@@ -68,15 +68,18 @@ export default class AppLifecycleStart extends Command {
6868
});
6969

7070
// Apply gas overrides if provided
71-
const finalGas = applyGasOverrides(estimate, flags);
71+
const finalTx = await applyTxOverrides(estimate, flags, { publicClient, address });
7272
if (flags["max-fee-per-gas"] || flags["max-priority-fee"]) {
7373
this.log(chalk.yellow(`Gas override active — max fee: ${flags["max-fee-per-gas"] || "estimated"} gwei, priority fee: ${flags["max-priority-fee"] || "estimated"} gwei`));
7474
}
75+
if (finalTx.nonce != null) {
76+
this.log(chalk.yellow(`Nonce override active — nonce: ${finalTx.nonce}`));
77+
}
7578

7679
// On mainnet, prompt for confirmation with cost
7780
if (isMainnet(environmentConfig)) {
7881
const confirmed = await confirm(
79-
`This will cost up to ${finalGas.maxCostEth} ETH. Continue?`,
82+
`This will cost up to ${finalTx.maxCostEth} ETH. Continue?`,
8083
);
8184
if (!confirmed) {
8285
this.log(`\n${chalk.gray(`Start cancelled`)}`);
@@ -85,7 +88,7 @@ export default class AppLifecycleStart extends Command {
8588
}
8689

8790
const res = await compute.app.start(appId, {
88-
gas: finalGas,
91+
gas: finalTx,
8992
});
9093

9194
if (!res.tx) {

packages/cli/src/commands/compute/app/stop.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Command, Args } from "@oclif/core";
22
import { createComputeClient } from "../../../client";
3-
import { commonFlags, applyGasOverrides } from "../../../flags";
3+
import { commonFlags, applyTxOverrides } from "../../../flags";
44
import {
55
getEnvironmentConfig,
66
estimateTransactionGas,
@@ -68,15 +68,18 @@ export default class AppLifecycleStop extends Command {
6868
});
6969

7070
// Apply gas overrides if provided
71-
const finalGas = applyGasOverrides(estimate, flags);
71+
const finalTx = await applyTxOverrides(estimate, flags, { publicClient, address });
7272
if (flags["max-fee-per-gas"] || flags["max-priority-fee"]) {
7373
this.log(chalk.yellow(`Gas override active — max fee: ${flags["max-fee-per-gas"] || "estimated"} gwei, priority fee: ${flags["max-priority-fee"] || "estimated"} gwei`));
7474
}
75+
if (finalTx.nonce != null) {
76+
this.log(chalk.yellow(`Nonce override active — nonce: ${finalTx.nonce}`));
77+
}
7578

7679
// On mainnet, prompt for confirmation with cost
7780
if (isMainnet(environmentConfig)) {
7881
const confirmed = await confirm(
79-
`This will cost up to ${finalGas.maxCostEth} ETH. Continue?`,
82+
`This will cost up to ${finalTx.maxCostEth} ETH. Continue?`,
8083
);
8184
if (!confirmed) {
8285
this.log(`\n${chalk.gray(`Stop cancelled`)}`);
@@ -85,7 +88,7 @@ export default class AppLifecycleStop extends Command {
8588
}
8689

8790
const res = await compute.app.stop(appId, {
88-
gas: finalGas,
91+
gas: finalTx,
8992
});
9093

9194
if (!res.tx) {

packages/cli/src/commands/compute/app/terminate.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Command, Args, Flags } from "@oclif/core";
22
import { createComputeClient } from "../../../client";
3-
import { commonFlags, applyGasOverrides } from "../../../flags";
3+
import { commonFlags, applyTxOverrides } from "../../../flags";
44
import {
55
getEnvironmentConfig,
66
estimateTransactionGas,
@@ -73,15 +73,18 @@ export default class AppLifecycleTerminate extends Command {
7373
});
7474

7575
// Apply gas overrides if provided
76-
const finalGas = applyGasOverrides(estimate, flags);
76+
const finalTx = await applyTxOverrides(estimate, flags, { publicClient, address });
7777
if (flags["max-fee-per-gas"] || flags["max-priority-fee"]) {
7878
this.log(chalk.yellow(`Gas override active — max fee: ${flags["max-fee-per-gas"] || "estimated"} gwei, priority fee: ${flags["max-priority-fee"] || "estimated"} gwei`));
7979
}
80+
if (finalTx.nonce != null) {
81+
this.log(chalk.yellow(`Nonce override active — nonce: ${finalTx.nonce}`));
82+
}
8083

8184
// Ask for confirmation unless forced
8285
if (!flags.force) {
8386
const costInfo = isMainnet(environmentConfig)
84-
? ` (cost: up to ${finalGas.maxCostEth} ETH)`
87+
? ` (cost: up to ${finalTx.maxCostEth} ETH)`
8588
: "";
8689
const confirmed = await confirm(`⚠️ Permanently destroy app ${appId}${costInfo}?`);
8790
if (!confirmed) {
@@ -91,7 +94,7 @@ export default class AppLifecycleTerminate extends Command {
9194
}
9295

9396
const res = await compute.app.terminate(appId, {
94-
gas: finalGas,
97+
gas: finalTx,
9598
});
9699

97100
if (!res.tx) {

packages/cli/src/commands/compute/app/upgrade.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Command, Args, Flags } from "@oclif/core";
22
import { getEnvironmentConfig, UserApiClient, isMainnet } from "@layr-labs/ecloud-sdk";
33
import { withTelemetry } from "../../../telemetry";
4-
import { commonFlags, applyGasOverrides } from "../../../flags";
4+
import { commonFlags, applyTxOverrides } from "../../../flags";
55
import { createBuildClient, createComputeClient } from "../../../client";
66
import { createViemClients } from "../../../utils/viemClients";
77
import {
@@ -288,13 +288,13 @@ export default class AppUpgrade extends Command {
288288
envFilePath = envFilePath ?? (await getEnvFileInteractive(flags["env-file"]));
289289

290290
// 5. Get current instance type (best-effort, used as default)
291+
const { publicClient, walletClient, address } = createViemClients({
292+
privateKey,
293+
rpcUrl,
294+
environment,
295+
});
291296
let currentInstanceType = "";
292297
try {
293-
const { publicClient, walletClient } = createViemClients({
294-
privateKey,
295-
rpcUrl,
296-
environment,
297-
});
298298
const userApiClient = new UserApiClient(
299299
environmentConfig,
300300
walletClient,
@@ -358,11 +358,14 @@ export default class AppUpgrade extends Command {
358358
});
359359

360360
// 10. Apply gas overrides if provided, show estimate, and prompt for confirmation on mainnet
361-
const finalGas = applyGasOverrides(gasEstimate, flags);
361+
const finalTx = await applyTxOverrides(gasEstimate, flags, { publicClient, address });
362362
if (flags["max-fee-per-gas"] || flags["max-priority-fee"]) {
363363
this.log(chalk.yellow(`\nGas override active — max fee: ${flags["max-fee-per-gas"] || "estimated"} gwei, priority fee: ${flags["max-priority-fee"] || "estimated"} gwei`));
364364
}
365-
this.log(`\nEstimated transaction cost: ${chalk.cyan(finalGas.maxCostEth)} ETH`);
365+
if (finalTx.nonce != null) {
366+
this.log(chalk.yellow(`Nonce override active — nonce: ${finalTx.nonce}`));
367+
}
368+
this.log(`\nEstimated transaction cost: ${chalk.cyan(finalTx.maxCostEth)} ETH`);
366369

367370
if (isMainnet(environmentConfig)) {
368371
const confirmed = await confirm(`Continue with upgrade?`);
@@ -373,7 +376,7 @@ export default class AppUpgrade extends Command {
373376
}
374377

375378
// 11. Execute the upgrade
376-
const res = await compute.app.executeUpgrade(prepared, finalGas);
379+
const res = await compute.app.executeUpgrade(prepared, finalTx);
377380

378381
// 12. Watch until upgrade completes
379382
await compute.app.watchUpgrade(res.appId);

packages/cli/src/flags.ts

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Flags } from "@oclif/core";
22
import { getBuildType, type GasEstimate } from "@layr-labs/ecloud-sdk";
33
import { getEnvironmentInteractive, getPrivateKeyInteractive } from "./utils/prompts";
44
import { getDefaultEnvironment } from "./utils/globalConfig";
5-
import { parseGwei } from "viem";
5+
import { type Address, formatEther, parseGwei, type PublicClient } from "viem";
66

77
export type CommonFlags = {
88
verbose: boolean;
@@ -11,6 +11,7 @@ export type CommonFlags = {
1111
"rpc-url"?: string;
1212
"max-fee-per-gas"?: string;
1313
"max-priority-fee"?: string;
14+
nonce?: string;
1415
};
1516

1617
export const commonFlags = {
@@ -46,18 +47,29 @@ export const commonFlags = {
4647
description: "Override max priority fee per gas in gwei (e.g., 5)",
4748
env: "ECLOUD_MAX_PRIORITY_FEE",
4849
}),
50+
nonce: Flags.string({
51+
required: false,
52+
description: 'Override transaction nonce (integer or "latest" to replace a stuck transaction)',
53+
}),
4954
};
5055

5156
/**
52-
* Apply user-provided gas overrides to an estimated GasEstimate.
57+
* Apply user-provided gas and nonce overrides to an estimated GasEstimate.
5358
* If the user passed --max-fee-per-gas or --max-priority-fee, those values
5459
* replace the estimated ones and maxCostWei/maxCostEth are recalculated.
60+
* If --nonce is provided as a number, it sets the transaction nonce explicitly.
61+
* If --nonce is "latest", the first unconfirmed nonce is fetched (to replace a stuck tx).
5562
*/
56-
export function applyGasOverrides(estimate: GasEstimate, flags: CommonFlags): GasEstimate {
63+
export async function applyTxOverrides(
64+
estimate: GasEstimate,
65+
flags: CommonFlags,
66+
opts?: { publicClient: PublicClient; address: Address },
67+
): Promise<GasEstimate> {
5768
const maxFeeStr = flags["max-fee-per-gas"];
5869
const priorityFeeStr = flags["max-priority-fee"];
70+
const nonceStr = flags.nonce;
5971

60-
if (!maxFeeStr && !priorityFeeStr) return estimate;
72+
if (!maxFeeStr && !priorityFeeStr && nonceStr == null) return estimate;
6173

6274
let { gasLimit, maxFeePerGas, maxPriorityFeePerGas } = estimate;
6375

@@ -74,10 +86,29 @@ export function applyGasOverrides(estimate: GasEstimate, flags: CommonFlags): Ga
7486
}
7587

7688
const maxCostWei = gasLimit * maxFeePerGas;
77-
const eth = Number(maxCostWei) / 1e18;
89+
const eth = Number(formatEther(maxCostWei));
7890
const maxCostEth = eth.toFixed(6).replace(/\.?0+$/, "") || "<0.000001";
7991

80-
return { gasLimit, maxFeePerGas, maxPriorityFeePerGas, maxCostWei, maxCostEth };
92+
let nonce: number | undefined;
93+
if (nonceStr != null) {
94+
if (nonceStr === "latest") {
95+
if (!opts?.publicClient || !opts?.address) {
96+
throw new Error("--nonce latest requires a public client and address");
97+
}
98+
nonce = await opts.publicClient.getTransactionCount({
99+
address: opts.address,
100+
blockTag: "latest",
101+
});
102+
} else {
103+
const parsed = Number(nonceStr);
104+
if (!Number.isInteger(parsed) || parsed < 0) {
105+
throw new Error(`Invalid nonce: "${nonceStr}". Must be a non-negative integer or "latest".`);
106+
}
107+
nonce = parsed;
108+
}
109+
}
110+
111+
return { gasLimit, maxFeePerGas, maxPriorityFeePerGas, maxCostWei, maxCostEth, nonce };
81112
}
82113

83114
// Prompt for missing required values interactively

packages/sdk/src/client/common/contract/caller.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ export interface GasEstimate {
5151
maxCostWei: bigint;
5252
/** Maximum cost formatted as ETH string */
5353
maxCostEth: string;
54+
/** Optional nonce override (for replacing stuck transactions) */
55+
nonce?: number;
5456
}
5557

5658
/**
@@ -924,6 +926,7 @@ export async function sendAndWaitForTransaction(
924926
...(gas?.maxPriorityFeePerGas && {
925927
maxPriorityFeePerGas: gas.maxPriorityFeePerGas,
926928
}),
929+
...(gas?.nonce != null && { nonce: gas.nonce }),
927930
chain,
928931
});
929932

packages/sdk/src/client/common/contract/eip7702.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,9 @@ export async function executeBatch(options: ExecuteBatchOptions, logger: Logger
309309
if (gas?.maxPriorityFeePerGas) {
310310
txRequest.maxPriorityFeePerGas = gas.maxPriorityFeePerGas;
311311
}
312+
if (gas?.nonce != null) {
313+
txRequest.nonce = gas.nonce;
314+
}
312315

313316
const hash = await walletClient.sendTransaction(txRequest);
314317
logger.info(`Transaction sent: ${hash}`);

0 commit comments

Comments
 (0)