Skip to content

chore: fix broadcast failures and tighten action prompts#29

Open
0xYoki wants to merge 1 commit into
mainfrom
fix-borrow-enrich-payload
Open

chore: fix broadcast failures and tighten action prompts#29
0xYoki wants to merge 1 commit into
mainfrom
fix-borrow-enrich-payload

Conversation

@0xYoki

@0xYoki 0xYoki commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Added

  • utils/evm-nonce.ts: cached JsonRpcProvider per network (with RPC_URL_<NETWORK> env overrides), pending-nonce lookup, local broadcast, and a heuristic for detecting backend broadcast failures.
  • enrichEvmPayload in the borrow signing path: patches chainId, nonce, and gas fees on the API-supplied payload when missing or zero, so the signed tx is broadcast-ready.
  • Local-broadcast fallback: if /submit returns 5xx "broadcast failed", the recipe rebroadcasts via the configured RPC and registers the hash via /submit { transactionHash }.
  • Client-side prompt validation: enforces schema pattern and rejects non-0x… input for *Address fields before submission.

Changed

  • Optional action arguments are now truly opt-in: no silent defaults, no pre-filled inputs, defaults shown as hints.
  • Optional booleans use a <skip> / true / false select; optional enums include a <skip> choice.

Summary by CodeRabbit

  • New Features

    • Improved transaction handling for EVM flows, with smarter recovery when a submission fails.
    • Added support for filling in missing transaction details automatically before signing when needed.
  • Bug Fixes

    • Optional prompts now stay empty unless needed, making input flows clearer.
    • Validation is stricter for address fields and pattern-based inputs.
    • Failed broadcasts can now be retried automatically, reducing submission errors.

@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds a new utils/evm-nonce.ts module providing RPC URL resolution, cached provider lookup, pending nonce fetching, and local transaction broadcast with failure recovery. recipes/borrow.ts integrates these utilities, reworking interactive prompts for optional fields, extending validation, adding EVM payload enrichment before signing, and adding a local-broadcast fallback when API submission fails.

Changes

EVM enrichment and submission resilience

Layer / File(s) Summary
EVM nonce and broadcast utilities
utils/evm-nonce.ts
New module exporting getRpcUrl, getProvider, fetchPendingNonce, broadcastSignedTx, and isApiBroadcastFailure with cached providers and env-var RPC overrides.
Optional field prompting and validation
recipes/borrow.ts
Reworks prompts for enum, boolean, array, and text fields to use explicit skip behavior and hint-based defaults; extends validation for pattern regex and Ethereum address formatting.
Payload enrichment and signing override
recipes/borrow.ts
Adds evmPayloadOverride to signTransaction and new helpers (isZeroish, parseIntegerField, enrichEvmPayload) to fill missing chainId/nonce/fee fields using live provider data.
Transaction processing with fallback submission
recipes/borrow.ts
Enriches EVM payloads before signing and falls back to local broadcast via broadcastSignedTx followed by resubmission when API submission fails with a broadcast error.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant BorrowRecipe
  participant EnrichEvmPayload
  participant Provider as JsonRpcProvider
  participant API
  participant BroadcastSignedTx

  User->>BorrowRecipe: submit signed transaction request
  BorrowRecipe->>EnrichEvmPayload: enrich payload (chainId, nonce, fees)
  EnrichEvmPayload->>Provider: fetch live nonce/fee data
  Provider-->>EnrichEvmPayload: nonce, fee values
  EnrichEvmPayload-->>BorrowRecipe: enriched payload
  BorrowRecipe->>API: POST /submit signed tx
  alt broadcast failure
    API-->>BorrowRecipe: 5xx broadcast error
    BorrowRecipe->>BroadcastSignedTx: broadcast(signedTx, network)
    BroadcastSignedTx->>Provider: broadcastTransaction(signedTx)
    Provider-->>BroadcastSignedTx: transactionHash
    BroadcastSignedTx-->>BorrowRecipe: transactionHash
    BorrowRecipe->>API: resubmit with transactionHash
  else success
    API-->>BorrowRecipe: confirmation
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

A rabbit hopped through chains of nonce,
Found gaps in payloads, fixed them once,
When broadcasts failed, I dug a hole,
And pushed the tx straight to its goal. 🐇⛓️
Skip fields blank, hop past the rest—
This burrow's signed and ready, blessed.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main changes: fixing broadcast failures and tightening action prompts.
Description check ✅ Passed The description follows the template with clear Added and Changed sections covering the main code changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-borrow-enrich-payload

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
recipes/borrow.ts (1)

407-452: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Let optional object arguments be skipped before recursing.

Line 407 tells users optional fields can be skipped, but the object branch always prompts nested fields and assigns result[name]. Optional object args can still be sent as {} instead of being omitted.

Suggested fix
     } else if (type === "object" && prop.properties) {
+      if (!isRequired) {
+        const response: any = await Enquirer.prompt({
+          type: "select",
+          name: "value",
+          message,
+          choices: ["<skip>", "configure"],
+          initial: "<skip>",
+        } as any);
+        if (response.value === "<skip>") continue;
+      }
       console.log(`\n${prop.label || name}:`);
-      result[name] = await promptFromSchema(prop as ArgumentSchemaDto, []);
+      const nested = await promptFromSchema(prop as ArgumentSchemaDto, []);
+      if (!isRequired && Object.keys(nested).length === 0) continue;
+      result[name] = nested;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@recipes/borrow.ts` around lines 407 - 452, Optional object arguments are
still being recursed into and assigned even when the user should be able to skip
them. Update the object-handling branch in promptFromSchema (the path that
checks type === "object" && prop.properties) to mirror the skip behavior used
for enums and booleans: prompt for an explicit skip choice when isRequired is
false, and only call promptFromSchema and set result[name] when the user does
not skip.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@recipes/borrow.ts`:
- Around line 619-646: Update the fee enrichment logic in borrow.ts so it
decides completeness based on the transaction type instead of treating any
non-zero gasPrice as sufficient. In the fee selection block around
hasLegacyFee/hasEip1559Fees, use payload.type (via parseIntegerField) to
determine whether to require gasPrice for legacy txs or
maxFeePerGas/maxPriorityFeePerGas for type-2 txs, and allow a type-2 payload
with only gasPrice to still fetch and fill EIP-1559 fields. Keep the existing
provider.getFeeData() fallback and ensure payload.type is normalized
consistently when setting fees.
- Around line 458-463: The required-array handling in the prompt flow should not
silently skip empty input when `response.value` is falsy. Update the logic
around the `initial` prompt result and the `if (!response.value)` branch so that
required arrays are explicitly validated in the `borrow.ts` flow, and either
reprompt or reject empty input before continuing. Make the fix in the code path
that assigns `result[name]` so required array arguments cannot be omitted by
pressing Enter.

In `@utils/evm-nonce.ts`:
- Around line 109-115: The already-known/already-in-mempool branch in the nonce
handling logic only returns a hash when the RPC message includes one, which
makes broadcastSignedTx() throw and breaks the fallback path. Update the branch
in evm-nonce.ts to derive and return the transaction hash from
Transaction.from(signedTx).hash whenever the message matches already known or
already in mempool, and keep the nonce too low case handled in its own separate
path.

---

Outside diff comments:
In `@recipes/borrow.ts`:
- Around line 407-452: Optional object arguments are still being recursed into
and assigned even when the user should be able to skip them. Update the
object-handling branch in promptFromSchema (the path that checks type ===
"object" && prop.properties) to mirror the skip behavior used for enums and
booleans: prompt for an explicit skip choice when isRequired is false, and only
call promptFromSchema and set result[name] when the user does not skip.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d98fb810-467d-4e7d-ab1f-c01e6ccd7e94

📥 Commits

Reviewing files that changed from the base of the PR and between d53cf45 and 2e4c34e.

📒 Files selected for processing (2)
  • recipes/borrow.ts
  • utils/evm-nonce.ts

Comment thread recipes/borrow.ts
Comment on lines +458 to +463
initial: isRequired && prop.default ? JSON.stringify(prop.default) : "",
} as any);

if (response.value) {
if (!response.value) {
if (!isRequired) continue;
} else {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Validate required array input before accepting it.

For a required array, pressing Enter takes the !response.value branch but does not set result[name] or reprompt, so the required argument is silently omitted.

Suggested fix
       const response: any = await Enquirer.prompt({
         type: "input",
         name: "value",
         message: `${message} (comma-separated or JSON array)`,
         initial: isRequired && prop.default ? JSON.stringify(prop.default) : "",
+        validate: (input: string) => {
+          if (isRequired && input.trim() === "") return `${prop.label || name} is required`;
+          return true;
+        },
       } as any);
 
-      if (!response.value) {
+      if (!response.value.trim()) {
         if (!isRequired) continue;
       } else {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
initial: isRequired && prop.default ? JSON.stringify(prop.default) : "",
} as any);
if (response.value) {
if (!response.value) {
if (!isRequired) continue;
} else {
initial: isRequired && prop.default ? JSON.stringify(prop.default) : "",
validate: (input: string) => {
if (isRequired && input.trim() === "") return `${prop.label || name} is required`;
return true;
},
} as any);
if (!response.value.trim()) {
if (!isRequired) continue;
} else {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@recipes/borrow.ts` around lines 458 - 463, The required-array handling in the
prompt flow should not silently skip empty input when `response.value` is falsy.
Update the logic around the `initial` prompt result and the `if
(!response.value)` branch so that required arrays are explicitly validated in
the `borrow.ts` flow, and either reprompt or reject empty input before
continuing. Make the fix in the code path that assigns `result[name]` so
required array arguments cannot be omitted by pressing Enter.

Comment thread recipes/borrow.ts
Comment on lines +619 to +646
const hasLegacyFee = !isZeroish(payload.gasPrice);
const hasEip1559Fees =
!isZeroish(payload.maxFeePerGas) && !isZeroish(payload.maxPriorityFeePerGas);
if (!hasLegacyFee && !hasEip1559Fees) {
try {
const feeData = await provider.getFeeData();
const apiType = parseIntegerField(payload.type);
const canUseEip1559 = feeData.maxFeePerGas !== null && feeData.maxPriorityFeePerGas !== null;
const wantEip1559 = apiType === 2 || (apiType === undefined && canUseEip1559);
if (wantEip1559 && feeData.maxFeePerGas && feeData.maxPriorityFeePerGas) {
payload.maxFeePerGas = feeData.maxFeePerGas.toString();
payload.maxPriorityFeePerGas = feeData.maxPriorityFeePerGas.toString();
payload.type = 2;
payload.gasPrice = undefined;
console.log(
` Setting fees (from chain): maxFeePerGas=${feeData.maxFeePerGas}, maxPriorityFeePerGas=${feeData.maxPriorityFeePerGas}`,
);
} else if (feeData.gasPrice) {
payload.gasPrice = feeData.gasPrice.toString();
payload.type = 0;
payload.maxFeePerGas = undefined;
payload.maxPriorityFeePerGas = undefined;
console.log(` Setting fees (from chain): gasPrice=${feeData.gasPrice}`);
}
} catch (err: any) {
console.warn(` Could not fetch fee data: ${err?.message || err}`);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Decide missing fee fields based on transaction type.

Line 619 lets any non-zero gasPrice suppress fee enrichment, even when payload.type is 2. A type-2 payload with gasPrice but missing maxFeePerGas / maxPriorityFeePerGas remains under-filled.

Suggested fix
+  const apiType = parseIntegerField(payload.type);
   const hasLegacyFee = !isZeroish(payload.gasPrice);
   const hasEip1559Fees =
     !isZeroish(payload.maxFeePerGas) && !isZeroish(payload.maxPriorityFeePerGas);
-  if (!hasLegacyFee && !hasEip1559Fees) {
+  const missingRequiredFees =
+    apiType === 2
+      ? !hasEip1559Fees
+      : apiType === 0
+        ? !hasLegacyFee
+        : !hasLegacyFee && !hasEip1559Fees;
+
+  if (missingRequiredFees) {
     try {
       const feeData = await provider.getFeeData();
-      const apiType = parseIntegerField(payload.type);
       const canUseEip1559 = feeData.maxFeePerGas !== null && feeData.maxPriorityFeePerGas !== null;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const hasLegacyFee = !isZeroish(payload.gasPrice);
const hasEip1559Fees =
!isZeroish(payload.maxFeePerGas) && !isZeroish(payload.maxPriorityFeePerGas);
if (!hasLegacyFee && !hasEip1559Fees) {
try {
const feeData = await provider.getFeeData();
const apiType = parseIntegerField(payload.type);
const canUseEip1559 = feeData.maxFeePerGas !== null && feeData.maxPriorityFeePerGas !== null;
const wantEip1559 = apiType === 2 || (apiType === undefined && canUseEip1559);
if (wantEip1559 && feeData.maxFeePerGas && feeData.maxPriorityFeePerGas) {
payload.maxFeePerGas = feeData.maxFeePerGas.toString();
payload.maxPriorityFeePerGas = feeData.maxPriorityFeePerGas.toString();
payload.type = 2;
payload.gasPrice = undefined;
console.log(
` Setting fees (from chain): maxFeePerGas=${feeData.maxFeePerGas}, maxPriorityFeePerGas=${feeData.maxPriorityFeePerGas}`,
);
} else if (feeData.gasPrice) {
payload.gasPrice = feeData.gasPrice.toString();
payload.type = 0;
payload.maxFeePerGas = undefined;
payload.maxPriorityFeePerGas = undefined;
console.log(` Setting fees (from chain): gasPrice=${feeData.gasPrice}`);
}
} catch (err: any) {
console.warn(` Could not fetch fee data: ${err?.message || err}`);
}
}
const apiType = parseIntegerField(payload.type);
const hasLegacyFee = !isZeroish(payload.gasPrice);
const hasEip1559Fees =
!isZeroish(payload.maxFeePerGas) && !isZeroish(payload.maxPriorityFeePerGas);
const missingRequiredFees =
apiType === 2
? !hasEip1559Fees
: apiType === 0
? !hasLegacyFee
: !hasLegacyFee && !hasEip1559Fees;
if (missingRequiredFees) {
try {
const feeData = await provider.getFeeData();
const canUseEip1559 = feeData.maxFeePerGas !== null && feeData.maxPriorityFeePerGas !== null;
const wantEip1559 = apiType === 2 || (apiType === undefined && canUseEip1559);
if (wantEip1559 && feeData.maxFeePerGas && feeData.maxPriorityFeePerGas) {
payload.maxFeePerGas = feeData.maxFeePerGas.toString();
payload.maxPriorityFeePerGas = feeData.maxPriorityFeePerGas.toString();
payload.type = 2;
payload.gasPrice = undefined;
console.log(
` Setting fees (from chain): maxFeePerGas=${feeData.maxFeePerGas}, maxPriorityFeePerGas=${feeData.maxPriorityFeePerGas}`,
);
} else if (feeData.gasPrice) {
payload.gasPrice = feeData.gasPrice.toString();
payload.type = 0;
payload.maxFeePerGas = undefined;
payload.maxPriorityFeePerGas = undefined;
console.log(` Setting fees (from chain): gasPrice=${feeData.gasPrice}`);
}
} catch (err: any) {
console.warn(` Could not fetch fee data: ${err?.message || err}`);
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@recipes/borrow.ts` around lines 619 - 646, Update the fee enrichment logic in
borrow.ts so it decides completeness based on the transaction type instead of
treating any non-zero gasPrice as sufficient. In the fee selection block around
hasLegacyFee/hasEip1559Fees, use payload.type (via parseIntegerField) to
determine whether to require gasPrice for legacy txs or
maxFeePerGas/maxPriorityFeePerGas for type-2 txs, and allow a type-2 payload
with only gasPrice to still fetch and fill EIP-1559 fields. Keep the existing
provider.getFeeData() fallback and ensure payload.type is normalized
consistently when setting fees.

Comment thread utils/evm-nonce.ts
Comment on lines +109 to +115
if (/already known|already in mempool|nonce.*too low|already exists/i.test(msg)) {
const hashMatch = msg.match(/0x[a-fA-F0-9]{64}/);
if (hashMatch) {
console.warn(` Tx already broadcast; reusing hash ${hashMatch[0]}`);
return hashMatch[0];
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

ethers v6.13.4 Transaction.from serialized signed transaction hash property

💡 Result:

In ethers v6.13.4, the Transaction object (created via Transaction.from) contains a read-only hash property that represents the transaction hash [1][2]. When you initialize a Transaction object from a serialized signed transaction string using Transaction.from(serializedTx), the library automatically parses the transaction, verifies the signature, and computes the hash [3][4]. The hash property will contain the transaction hash as a string if the transaction is signed, or null if it is unsigned [1][2]. Key details regarding this property: - Access: It is a read-only property [1]. - Availability: It is computed automatically after parsing if the serialized transaction includes a valid signature [1][3]. - Behavior: You do not need to manually compute the hash; the Transaction object handles this internally once instantiated [3]. Example usage: const tx = ethers.Transaction.from(serializedSignedTransaction); console.log(tx.hash); // Returns the transaction hash string [1][3]

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the target file and nearby code
file="utils/evm-nonce.ts"
if [ ! -f "$file" ]; then
  echo "Missing file: $file" >&2
  exit 1
fi

wc -l "$file"
sed -n '1,220p' "$file"

# Find other references to the same error handling / hash recovery
rg -n "already known|already in mempool|nonce.*too low|already exists|Transaction.from|signedTx|reuse hash|already broadcast" .

Repository: stakekit/api-recipes

Length of output: 5773


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the call site and any downstream fallback behavior
sed -n '430,500p' recipes/yields.ts

# Look for other uses of broadcastSignedTx
rg -n "broadcastSignedTx\\(" .

Repository: stakekit/api-recipes

Length of output: 2555


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '430,500p' recipes/yields.ts
rg -n "broadcastSignedTx\\(" .

Repository: stakekit/api-recipes

Length of output: 2555


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the borrow flow around broadcastSignedTx usage and fallback behavior
sed -n '680,740p' recipes/borrow.ts

# Check whether any other code recovers the hash independently
rg -n "localHash|transactionHash|submitTransaction|broadcastSignedTx|Transaction.from" recipes utils

Repository: stakekit/api-recipes

Length of output: 4768


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '680,740p' recipes/borrow.ts
rg -n "localHash|transactionHash|submitTransaction|broadcastSignedTx|Transaction.from" recipes utils

Repository: stakekit/api-recipes

Length of output: 4768


Recover the hash when the RPC says the tx is already known. broadcastSignedTx() still throws if that error omits the hash, which breaks the local fallback in recipes/borrow.ts even though the signed tx is already in the mempool. Use Transaction.from(signedTx).hash in the already-known/already-in-mempool branch, and keep nonce too low handled separately.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@utils/evm-nonce.ts` around lines 109 - 115, The
already-known/already-in-mempool branch in the nonce handling logic only returns
a hash when the RPC message includes one, which makes broadcastSignedTx() throw
and breaks the fallback path. Update the branch in evm-nonce.ts to derive and
return the transaction hash from Transaction.from(signedTx).hash whenever the
message matches already known or already in mempool, and keep the nonce too low
case handled in its own separate path.

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.

2 participants