Skip to content
Open
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
163 changes: 120 additions & 43 deletions recipes/borrow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import "cross-fetch/polyfill";
import * as dotenv from "dotenv";
import Enquirer from "enquirer";
import { HDNodeWallet } from "ethers";
import { HDNodeWallet, JsonRpcProvider } from "ethers";
import { request } from "../utils/requests";

dotenv.config();
Expand All @@ -19,6 +19,27 @@ if (!process.env.MNEMONIC || !process.env.BORROW_API_KEY) {
process.exit(1);
}

const DEFAULT_RPC_URLS: Record<string, string> = {
"1": "https://eth.llamarpc.com",
"42161": "https://arb1.arbitrum.io/rpc",
"10": "https://mainnet.optimism.io",
"8453": "https://mainnet.base.org",
"137": "https://polygon-rpc.com",
"43114": "https://api.avax.network/ext/bc/C/rpc",
"56": "https://bsc-dataseed.binance.org",
"100": "https://rpc.gnosischain.com",
"534352": "https://rpc.scroll.io",
"59144": "https://rpc.linea.build",
};

function getRpcUrl(chainId: string): string {
if (process.env.RPC_URL) return process.env.RPC_URL;
const url = DEFAULT_RPC_URLS[chainId];
if (!url)
throw new Error(`No RPC URL for chain ${chainId}. Set the RPC_URL environment variable.`);
return url;
}

// ===== Type Definitions =====

enum BorrowActionType {
Expand Down Expand Up @@ -400,30 +421,22 @@ async function promptFromSchema(
if (prop.description) message += ` - ${prop.description}`;
if (!isRequired) message += " (optional)";

if (!isRequired && prop.default !== undefined) {
result[name] = prop.default;
continue;
}

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;
Comment on lines 424 to 441
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).

} else if (type === "object" && prop.properties) {
Expand All @@ -433,8 +446,8 @@ async function promptFromSchema(
const response: any = await Enquirer.prompt({
type: "input",
name: "value",
message: `${message} (comma-separated or JSON array)`,
initial: prop.default ? JSON.stringify(prop.default) : "",
message: `${message} (comma-separated)`,
initial: prop.default,
} as any);

if (response.value) {
Expand All @@ -451,7 +464,7 @@ async function promptFromSchema(
type: "input",
name: "value",
message,
initial: (prop.placeholder || prop.default) as string,
initial: prop.default,
validate: (input: string) => {
if (!isRequired && input === "") return true;
if (isRequired && input === "") return `${prop.label || name} is required`;
Expand All @@ -474,39 +487,80 @@ async function promptFromSchema(
},
} as any);

if (response.value === "" && !isRequired) continue;

result[name] =
type === "number" || type === "integer"
? Number.parseFloat(response.value)
: response.value;
if (response.value || isRequired) {
result[name] =
type === "number" || type === "integer"
? Number.parseFloat(response.value)
: response.value;
}
}
}

return result;
}

async function signTransaction(tx: TransactionDto, wallet: HDNodeWallet): Promise<string> {
async function signTransaction(
tx: TransactionDto,
wallet: HDNodeWallet,
nonce?: number,
provider?: JsonRpcProvider,
): Promise<{ signed: string; isEvmTx: boolean }> {
if (!tx.signablePayload) throw new Error("Nothing to sign");

if (tx.signingFormat === SigningFormat.EIP712_TYPED_DATA) {
const typed =
typeof tx.signablePayload === "string" ? JSON.parse(tx.signablePayload) : tx.signablePayload;
const { domain, types, message } = typed;
const { EIP712Domain: _, ...signingTypes } = types;
return wallet.signTypedData(domain, signingTypes, message);
const signed = await wallet.signTypedData(domain, signingTypes, message);
return { signed, isEvmTx: false };
}

if (
tx.signingFormat !== SigningFormat.EVM_TRANSACTION &&
tx.signingFormat !== undefined
) {
if (tx.signingFormat !== SigningFormat.EVM_TRANSACTION && tx.signingFormat !== undefined) {
throw new Error(`Unsupported signing format: ${tx.signingFormat}`);
}

const txData =
typeof tx.signablePayload === "string" ? JSON.parse(tx.signablePayload) : tx.signablePayload;
return wallet.signTransaction(txData);

if (!txData.chainId && tx.chainId) {
txData.chainId = Number(tx.chainId);
}

if (nonce !== undefined) {
txData.nonce = nonce;
console.log(` Using nonce: ${txData.nonce}`);
}

if (txData.gasLimit !== undefined && txData.gasLimit !== null) {
txData.gasLimit = Math.floor(Number(txData.gasLimit) * 1.3);
console.log(` Gas limit: ${txData.gasLimit}`);
}

if (provider) {
const hasLegacy = txData.gasPrice != null;
const has1559 = txData.maxFeePerGas != null || txData.maxPriorityFeePerGas != null;

if (!hasLegacy && !has1559) {
const feeData = await provider.getFeeData();
if (feeData.maxFeePerGas != null && feeData.maxPriorityFeePerGas != null) {
txData.maxFeePerGas = feeData.maxFeePerGas.toString();
txData.maxPriorityFeePerGas = feeData.maxPriorityFeePerGas.toString();
console.log(` Max fee: ${feeData.maxFeePerGas} | Priority: ${feeData.maxPriorityFeePerGas}`);
} else if (feeData.gasPrice != null) {
txData.gasPrice = feeData.gasPrice.toString();
console.log(` Gas price: ${feeData.gasPrice}`);
}
} else if (has1559 && txData.maxPriorityFeePerGas == null) {
const feeData = await provider.getFeeData();
if (feeData.maxPriorityFeePerGas != null) {
txData.maxPriorityFeePerGas = feeData.maxPriorityFeePerGas.toString();
}
}
}

const signed = await wallet.signTransaction(txData);
return { signed, isEvmTx: true };
}

async function processTransactions(
Expand All @@ -515,6 +569,23 @@ async function processTransactions(
apiClient: BorrowApiClient,
actionId: string,
): Promise<void> {
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;
Comment on lines +572 to +587
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.


for (let i = 0; i < transactions.length; i++) {
const tx = transactions[i];

Expand All @@ -540,11 +611,25 @@ async function processTransactions(
}

try {
const nonce = baseNonce !== undefined ? baseNonce + nonceOffset : undefined;
console.log("Signing...");
const signature = await signTransaction(tx, wallet);

console.log("Submitting...");
const result = await apiClient.submitTransaction(tx.id, { signedPayload: signature });
const { signed, isEvmTx } = await signTransaction(tx, wallet, nonce, provider);

let result: SubmitTransactionResponseDto;
if (isEvmTx && provider) {
console.log("Broadcasting...");
const broadcastResponse = await provider.broadcastTransaction(signed);
console.log(` Hash: ${broadcastResponse.hash}`);
nonceOffset++;

console.log("Notifying API...");
result = await apiClient.submitTransaction(tx.id, {
transactionHash: broadcastResponse.hash,
});
} else {
console.log("Submitting...");
result = await apiClient.submitTransaction(tx.id, { signedPayload: signed });
}

if (result.transactionHash) console.log(` Hash: ${result.transactionHash}`);
if (result.link) console.log(` Explorer: ${result.link}`);
Expand Down Expand Up @@ -613,9 +698,7 @@ async function processMultiStepAction(

let currentAction = action;
while (currentAction.hasNextStep) {
console.log(
`\nStep ${currentAction.currentStep} of ${currentAction.totalSteps} completed.`,
);
console.log(`\nStep ${currentAction.currentStep} of ${currentAction.totalSteps} completed.`);
console.log("Fetching next step...\n");

await new Promise((resolve) => setTimeout(resolve, 1000));
Expand All @@ -631,12 +714,8 @@ function displayActionMetadata(metadata: ActionMetadataDto): void {
);
console.log(` LTV: ${formatRate(metadata.currentLtv)} → ${formatRate(metadata.predictedLtv)}`);
console.log(` Liquidation Threshold: ${formatRate(metadata.liquidationThreshold)}`);
console.log(
` Total Supply: ${formatUsd(metadata.predictedTotalSupplyUsd)}`,
);
console.log(
` Total Debt: ${formatUsd(metadata.predictedTotalDebtUsd)}`,
);
console.log(` Total Supply: ${formatUsd(metadata.predictedTotalSupplyUsd)}`);
console.log(` Total Debt: ${formatUsd(metadata.predictedTotalDebtUsd)}`);
}

// ===== Main Function =====
Expand Down Expand Up @@ -1092,9 +1171,7 @@ async function executeActionFlow(
}

if (actionResponse.totalSteps > 1) {
console.log(
`Multi-step action: ${actionResponse.totalSteps} steps total\n`,
);
console.log(`Multi-step action: ${actionResponse.totalSteps} steps total\n`);
}

await new Promise((resolve) => setTimeout(resolve, 1000));
Expand Down
Loading