Skip to content

chore: manage nonce locally#23

Open
0xYoki wants to merge 3 commits intomainfrom
fix-nonce-issue
Open

chore: manage nonce locally#23
0xYoki wants to merge 3 commits intomainfrom
fix-nonce-issue

Conversation

@0xYoki
Copy link
Contributor

@0xYoki 0xYoki commented Mar 12, 2026

Added

Description of new functionality, feature, or content that has been added in this pull request.

Changed

Description of the modifications made to existing functionality, feature, or content in this pull request. This could include changes to code, CI, documentation, etc.

Summary by CodeRabbit

  • New Features

    • Automatic Ethereum RPC URL resolution with env var override
    • Improved transaction signing with provider-aware fee filling and nonce sequencing
    • Optional on-chain broadcasting of signed EVM transactions with API notification of transaction hash
  • Bug Fixes

    • Better error handling when RPC info is missing
    • Fallbacks for gas fee data retrieval and more robust nonce handling
  • Style

    • Streamlined progress messages and compact display of transaction totals

@coderabbitai
Copy link

coderabbitai bot commented Mar 13, 2026

📝 Walkthrough

Walkthrough

The recipes/borrow.ts changes add RPC URL resolution and a JsonRpcProvider path, extend signing to return { signed, isEvmTx }, add provider-aware gas/nonce population and on-chain broadcast for EVM transactions, and simplify schema-driven user prompts and default handling.

Changes

Cohort / File(s) Summary
Transaction Signing & Broadcast
recipes/borrow.ts
signTransaction now returns { signed, isEvmTx }. EVM-signed txs can be broadcast via provider; API notified with tx hash. Non-EVM or signed-payload submissions continue via API. Added nonceOffset sequencing and broadcast progress logs.
RPC & Provider Integration
recipes/borrow.ts
Added DEFAULT_RPC_URLS and getRpcUrl(chainId) with RPC_URL env override. Initializes JsonRpcProvider for fee/nonce retrieval and chainId resolution; improved error handling when RPC missing.
User Prompting & Defaults
recipes/borrow.ts
Simplified schema prompt logic: removed explicit skip for optional enums, changed array prompts to comma-separated parsing, refined boolean/default handling to use prop.default and avoid auto-filling optional fields.
Logging & Display
recipes/borrow.ts
Compacted progress messages, added nonce/logging lines, and updated display formatting to show totals (supply/debt) on one line.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant Prompt as Prompt Logic
    participant Signer as Transaction Signer
    participant Provider as JsonRpcProvider
    participant API as Stakekit API

    User->>Prompt: Provide inputs
    Prompt->>Signer: Build transaction(s)
    Signer->>Provider: Fetch chainId, nonce, fee data
    Provider-->>Signer: Return chainId, nonce, fee data
    Signer->>Signer: Sign -> {signed, isEvmTx}

    alt isEvmTx == true
        Signer->>Provider: Broadcast signed tx
        Provider-->>Signer: transactionHash
        Signer->>API: Notify with transactionHash
        API-->>User: Confirmation
    else isEvmTx == false
        Signer->>API: Submit signedPayload
        API-->>User: Confirmation
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • feat: add borrow recipes #21: Overlapping modifications to recipes/borrow.ts covering RPC resolution, provider-aware signing/nonce/gas handling, and broadcast flow.

Suggested reviewers

  • Philippoes
  • jdomingos

Poem

🐰 I hopped through RPCs, gas in tow,

Nonces lined up in tidy row,
I signed and sent a thumping hash,
Then skittered off in victory dash —
Borrow recipe blooms, hop, and go! 🥕

🚥 Pre-merge checks | ❌ 3

❌ Failed checks (3 warnings)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title 'chore: manage nonce locally' is partially related to the changeset but does not capture the main change. The actual primary focus is fixing mixed legacy/EIP-1559 fee field handling in ethers v6, not nonce management. Update the title to reflect the primary change: something like 'chore: fix mixed legacy/EIP-1559 fee field handling for ethers v6' would better represent the main objective.
Description check ⚠️ Warning The pull request description contains only placeholder text from the template without any actual implementation details, objectives, or explanation of the changes. Replace placeholder text with concrete descriptions of what was added and changed, such as fee field handling fixes, nonce management implementation, and RPC URL resolution enhancements.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix-nonce-issue
📝 Coding Plan
  • Generate coding plan for human review comments

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

You can get early access to new features in CodeRabbit.

Enable the early_access setting to enable early access features such as new models, tools, and more.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
recipes/borrow.ts (1)

562-575: Add an explicit same-chain guard when reusing one provider/nonce stream.

provider and baseNonce are initialized from the first EVM tx and reused for the whole batch. Add a chain consistency check before signing to fail fast if a mixed-chain batch ever appears.

Also applies to: 604-607

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@recipes/borrow.ts` around lines 562 - 575, The code initializes provider and
baseNonce from firstEvmTx and then reuses them for signing; add an explicit
same-chain guard so any transaction in transactions that will be signed with
provider/baseNonce is verified to have the same chainId as firstEvmTx before
signing (e.g., check tx.chainId === firstEvmTx.chainId) and throw or return an
error immediately if a mismatch is found; update both the signing loop/check
site(s) that reference provider/baseNonce (the block using
provider/getTransactionCount and the later signing path around lines 604-607) to
perform this validation so mixed-chain batches fail fast.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@recipes/borrow.ts`:
- Around line 540-549: The current fee-population logic can mix legacy and
EIP-1559 fields and must be changed so we only fill missing fields: in the block
that calls provider.getFeeData() (referencing txData and provider.getFeeData),
detect three cases — if neither txData.gasPrice nor txData.maxFeePerGas is set,
populate EIP-1559 fields from feeData or fallback to gasPrice; if
txData.gasPrice is already present, do nothing (leave legacy path untouched); if
txData.maxFeePerGas exists but txData.maxPriorityFeePerGas is missing, only set
maxPriorityFeePerGas from feeData.maxPriorityFeePerGas — never set both gasPrice
and maxFeePerGas on the same txData object.
- Around line 424-441: The enum/options and boolean prompts (where
prop.enum/prop.options is truthy and where type === "boolean") always assign
result[name] even when isRequired is false; change both prompts to offer an
explicit "Skip" option for optional fields (e.g., add a choice with a distinct
undefined/empty value) and update the assignment to only set result[name] when
response.value is present or isRequired is true (same pattern used by the
array/input branches). Locate the Enquirer.prompt calls (the select block using
choices/initial and the confirm block) and: for enums/options add a "Skip"
choice when isRequired is false, and for booleans replace or augment the confirm
with a three-way select (Yes/No/Skip) or equivalent so a skip can return
undefined; then guard the result[name] assignment with the conditional check
(response.value || isRequired).

---

Nitpick comments:
In `@recipes/borrow.ts`:
- Around line 562-575: The code initializes provider and baseNonce from
firstEvmTx and then reuses them for signing; add an explicit same-chain guard so
any transaction in transactions that will be signed with provider/baseNonce is
verified to have the same chainId as firstEvmTx before signing (e.g., check
tx.chainId === firstEvmTx.chainId) and throw or return an error immediately if a
mismatch is found; update both the signing loop/check site(s) that reference
provider/baseNonce (the block using provider/getTransactionCount and the later
signing path around lines 604-607) to perform this validation so mixed-chain
batches fail fast.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f8a26374-7de2-4091-b627-c7b33b121dcb

📥 Commits

Reviewing files that changed from the base of the PR and between cb89d52 and 62afade.

📒 Files selected for processing (1)
  • recipes/borrow.ts

Comment on lines 424 to 441
if (prop.enum || prop.options) {
const baseChoices = prop.options || (prop.enum as string[]);
const skipChoice = "<skip>";
const choices = isRequired ? baseChoices : [skipChoice, ...baseChoices];
const choices = prop.options || (prop.enum as string[]);
const response: any = await Enquirer.prompt({
type: "select",
name: "value",
message,
choices,
initial: prop.default,
} as any);
if (!isRequired && response.value === skipChoice) continue;
result[name] = response.value;
} else if (type === "boolean") {
const response: any = await Enquirer.prompt({
type: "confirm",
name: "value",
message,
initial: prop.default as boolean,
initial: prop.default || false,
} as any);
result[name] = response.value;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In enquirer@2.4.1, do selectandconfirm prompts support a true "no selection" result without adding a custom skip option?

💡 Result:

No.

  • select always has a focused choice, and selected is “the currently focused choice” (for single-select). So submitting the prompt necessarily returns some choice (unless you add a dedicated “Skip/None” choice whose value you treat as empty). [2]
  • confirm is defined to return only true or false, not an empty/undefined “no answer” state. [1]

If you want a true “no selection” outcome, you need either (a) an explicit skip/none option in the choices, or (b) treat prompt cancellation (e.g., Ctrl+C leading to a rejected promise) as “no selection” in your app logic.

Sources: [1] [2]


🏁 Script executed:

# Check the file size first
wc -l recipes/borrow.ts

Repository: stakekit/api-recipes

Length of output: 86


🏁 Script executed:

# Read the context around lines 424-441
sed -n '400,450p' recipes/borrow.ts | cat -n

Repository: stakekit/api-recipes

Length of output: 2223


🏁 Script executed:

# Search for 'isRequired' variable in the borrow.ts file
rg 'isRequired' recipes/borrow.ts -B 3 -A 3

Repository: stakekit/api-recipes

Length of output: 1083


🏁 Script executed:

# Get the broader function context to understand prop structure
sed -n '380,460p' recipes/borrow.ts | cat -n

Repository: stakekit/api-recipes

Length of output: 3360


Optional enum/boolean inputs cannot be truly omitted despite being optional fields.

The code unconditionally assigns values to result[name] for both enum/options (line 54) and boolean (line 62) fields, even when isRequired is false. This forces optional fields to always receive a value, preventing callers from genuinely omitting them. In contrast, array and input fields correctly check if (response.value || isRequired) before assignment, allowing optional fields to be skipped.

Since enquirer's select and confirm prompts don't natively support "no selection" results, add a "Skip" option for optional fields:

Suggested fix
 if (prop.enum || prop.options) {
   const choices = prop.options || (prop.enum as string[]);
+  if (!isRequired) choices.push("Skip");
   const response: any = await Enquirer.prompt({
     type: "select",
     name: "value",
     message,
     choices,
     initial: prop.default,
   } as any);
-  result[name] = response.value;
+  if (response.value !== "Skip") result[name] = response.value;
 } else if (type === "boolean") {
   const response: any = await Enquirer.prompt({
-    type: "confirm",
+    type: isRequired ? "confirm" : "select",
     name: "value",
     message,
-    initial: prop.default || false,
+    ...(isRequired
+      ? { initial: prop.default ?? false }
+      : { choices: [true, false, "Skip"], initial: "Skip" }),
   } as any);
-  result[name] = response.value;
+  if (response.value !== "Skip") result[name] = response.value;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@recipes/borrow.ts` around lines 424 - 441, The enum/options and boolean
prompts (where prop.enum/prop.options is truthy and where type === "boolean")
always assign result[name] even when isRequired is false; change both prompts to
offer an explicit "Skip" option for optional fields (e.g., add a choice with a
distinct undefined/empty value) and update the assignment to only set
result[name] when response.value is present or isRequired is true (same pattern
used by the array/input branches). Locate the Enquirer.prompt calls (the select
block using choices/initial and the confirm block) and: for enums/options add a
"Skip" choice when isRequired is false, and for booleans replace or augment the
confirm with a three-way select (Yes/No/Skip) or equivalent so a skip can return
undefined; then guard the result[name] assignment with the conditional check
(response.value || isRequired).

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
recipes/borrow.ts (1)

424-441: ⚠️ Potential issue | 🟠 Major

Optional enum/boolean inputs are still effectively required.

Line 433 and Line 441 always assign result[name], so optional fields cannot actually be omitted.

Suggested patch
 if (prop.enum || prop.options) {
-  const choices = prop.options || (prop.enum as string[]);
+  const choices = [...(prop.options || (prop.enum as string[]))];
+  if (!isRequired) choices.push("__skip__");
   const response: any = await Enquirer.prompt({
     type: "select",
     name: "value",
     message,
     choices,
     initial: prop.default,
   } as any);
-  result[name] = response.value;
+  if (response.value !== "__skip__") result[name] = response.value;
 } else if (type === "boolean") {
   const response: any = await Enquirer.prompt({
-    type: "confirm",
+    type: isRequired ? "confirm" : "select",
     name: "value",
     message,
-    initial: prop.default || false,
+    ...(isRequired
+      ? { initial: prop.default ?? false }
+      : {
+          choices: [
+            { name: "Yes", value: true },
+            { name: "No", value: false },
+            { name: "Skip", value: "__skip__" },
+          ],
+          initial: 2,
+        }),
   } as any);
-  result[name] = response.value;
+  if (response.value !== "__skip__") result[name] = response.value;
In enquirer 2.x, can `select` or `confirm` return an actual undefined/no-selection result without adding an explicit "Skip/None" choice?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@recipes/borrow.ts` around lines 424 - 441, The enum/boolean prompts always
assign result[name], making optional fields required; change the logic so
optional properties (check the prop optional/required flag) only set
result[name] when the user actually chooses a value: for enum/options prompts
(the block checking prop.enum || prop.options) add a "Skip" or "None" choice
when the property is optional and only assign result[name] if response.value !==
"SKIP" (or undefined), and for boolean prompts replace the confirm (which always
yields true/false) with a select that offers ["Yes","No","Skip"] for optional
booleans and map those to true/false/undefined before conditionally assigning
result[name]; update the branches using Enquirer.prompt and the assignments to
result[name] accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@recipes/borrow.ts`:
- Around line 572-587: The current logic pins a single JsonRpcProvider and
baseNonce to firstEvmTx.chainId (variables provider and baseNonce) and then
reuses nonceOffset for all EVM transactions, which breaks when subsequent EVM
txs target different chainIds; change this to maintain per-chain maps: create a
Map<number, JsonRpcProvider> (using getRpcUrl and JsonRpcProvider) and a
Map<number, number> for baseNonce plus a Map<number, number> for nonceOffset
(keyed by tx.chainId), lazily initializing provider and baseNonce for a chain
the first time you see an EVM transaction (inspect each tx in transactions with
the same EVM check used for firstEvmTx), and when assigning nonces or
broadcasting use the provider/baseNonce/nonceOffset for tx.chainId instead of
the single provider/baseNonce/nonceOffset; optionally add a fail-fast guard if
provider creation or nonce fetch fails for any chain.

---

Duplicate comments:
In `@recipes/borrow.ts`:
- Around line 424-441: The enum/boolean prompts always assign result[name],
making optional fields required; change the logic so optional properties (check
the prop optional/required flag) only set result[name] when the user actually
chooses a value: for enum/options prompts (the block checking prop.enum ||
prop.options) add a "Skip" or "None" choice when the property is optional and
only assign result[name] if response.value !== "SKIP" (or undefined), and for
boolean prompts replace the confirm (which always yields true/false) with a
select that offers ["Yes","No","Skip"] for optional booleans and map those to
true/false/undefined before conditionally assigning result[name]; update the
branches using Enquirer.prompt and the assignments to result[name] accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a4d7b73d-ebfc-461a-8182-864d01f5fc12

📥 Commits

Reviewing files that changed from the base of the PR and between 62afade and 7e8ebac.

📒 Files selected for processing (1)
  • recipes/borrow.ts

Comment on lines +572 to +587
let provider: JsonRpcProvider | undefined;
let baseNonce: number | undefined;
const firstEvmTx = transactions.find(
(tx) =>
tx.signablePayload &&
tx.status !== TransactionStatus.CONFIRMED &&
tx.status !== TransactionStatus.SKIPPED &&
(tx.signingFormat === SigningFormat.EVM_TRANSACTION || tx.signingFormat === undefined),
);
if (firstEvmTx) {
const rpcUrl = getRpcUrl(firstEvmTx.chainId);
provider = new JsonRpcProvider(rpcUrl);
baseNonce = await provider.getTransactionCount(wallet.address, "pending");
console.log(` On-chain nonce: ${baseNonce} (chain ${firstEvmTx.chainId})`);
}
let nonceOffset = 0;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Provider/nonce context is pinned to the first EVM transaction chain.

Line 583 initializes a single provider from firstEvmTx.chainId, then Line 614 reuses that nonce context for all later transactions. If any later EVM tx has a different chainId, nonce and broadcast context can be wrong.

Fail-fast guard (minimal fix)
   let provider: JsonRpcProvider | undefined;
   let baseNonce: number | undefined;
+  const evmChainIds = new Set(
+    transactions
+      .filter(
+        (tx) =>
+          tx.signablePayload &&
+          tx.status !== TransactionStatus.CONFIRMED &&
+          tx.status !== TransactionStatus.SKIPPED &&
+          (tx.signingFormat === SigningFormat.EVM_TRANSACTION || tx.signingFormat === undefined),
+      )
+      .map((tx) => tx.chainId),
+  );
+
+  if (evmChainIds.size > 1) {
+    throw new Error("Mixed EVM chainIds in one action are not supported with single-provider nonce sequencing");
+  }
+
   const firstEvmTx = transactions.find(

Also applies to: 614-623

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@recipes/borrow.ts` around lines 572 - 587, The current logic pins a single
JsonRpcProvider and baseNonce to firstEvmTx.chainId (variables provider and
baseNonce) and then reuses nonceOffset for all EVM transactions, which breaks
when subsequent EVM txs target different chainIds; change this to maintain
per-chain maps: create a Map<number, JsonRpcProvider> (using getRpcUrl and
JsonRpcProvider) and a Map<number, number> for baseNonce plus a Map<number,
number> for nonceOffset (keyed by tx.chainId), lazily initializing provider and
baseNonce for a chain the first time you see an EVM transaction (inspect each tx
in transactions with the same EVM check used for firstEvmTx), and when assigning
nonces or broadcasting use the provider/baseNonce/nonceOffset for tx.chainId
instead of the single provider/baseNonce/nonceOffset; optionally add a fail-fast
guard if provider creation or nonce fetch fails for any chain.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant