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
198 changes: 178 additions & 20 deletions recipes/borrow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import "cross-fetch/polyfill";
import * as dotenv from "dotenv";
import Enquirer from "enquirer";
import { HDNodeWallet } from "ethers";
import { broadcastSignedTx, getProvider, isApiBroadcastFailure } from "../utils/evm-nonce";
import { request } from "../utils/requests";

dotenv.config();
Expand Down Expand Up @@ -403,12 +404,14 @@ async function promptFromSchema(

let message = prop.label || name;
if (prop.description) message += ` - ${prop.description}`;
if (!isRequired) message += " (optional)";
if (!isRequired) message += " (optional, press Enter to skip)";

if (!isRequired && prop.default !== undefined) {
result[name] = prop.default;
continue;
}
// Surface defaults/placeholders as hints instead of pre-filling them, so
// optional fields stay truly opt-in.
const hints: string[] = [];
if (prop.placeholder) hints.push(`example: ${prop.placeholder}`);
if (prop.default !== undefined) hints.push(`default: ${prop.default}`);
if (hints.length) message += ` [${hints.join(", ")}]`;

if (prop.enum || prop.options) {
const baseChoices = prop.options || (prop.enum as string[]);
Expand All @@ -419,18 +422,31 @@ async function promptFromSchema(
name: "value",
message,
choices,
initial: prop.default,
initial: isRequired ? prop.default : skipChoice,
} 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,
} as any);
result[name] = response.value;
if (isRequired) {
const response: any = await Enquirer.prompt({
type: "confirm",
name: "value",
message,
initial: prop.default as boolean,
} as any);
result[name] = response.value;
} else {
const skipChoice = "<skip>";
const response: any = await Enquirer.prompt({
type: "select",
name: "value",
message,
choices: [skipChoice, "true", "false"],
initial: skipChoice,
} as any);
if (response.value === skipChoice) continue;
result[name] = response.value === "true";
}
} else if (type === "object" && prop.properties) {
console.log(`\n${prop.label || name}:`);
result[name] = await promptFromSchema(prop as ArgumentSchemaDto, []);
Expand All @@ -439,10 +455,12 @@ async function promptFromSchema(
type: "input",
name: "value",
message: `${message} (comma-separated or JSON array)`,
initial: prop.default ? JSON.stringify(prop.default) : "",
initial: isRequired && prop.default ? JSON.stringify(prop.default) : "",
} as any);

if (response.value) {
if (!response.value) {
if (!isRequired) continue;
} else {
Comment on lines +458 to +463

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.

try {
result[name] = response.value.includes("[")
? JSON.parse(response.value)
Expand All @@ -456,7 +474,7 @@ async function promptFromSchema(
type: "input",
name: "value",
message,
initial: (prop.placeholder || prop.default) as string,
initial: isRequired ? ((prop.placeholder || prop.default) as string) : undefined,
validate: (input: string) => {
if (!isRequired && input === "") return true;
if (isRequired && input === "") return `${prop.label || name} is required`;
Expand All @@ -475,6 +493,18 @@ async function promptFromSchema(
return `Must be at least ${prop.minLength} characters`;
}

if (prop.pattern && !new RegExp(prop.pattern).test(input)) {
return `Must match pattern ${prop.pattern}`;
}

// Catch obvious "wrong field" mistakes (e.g. typing an amount into
// tokenAddress) when the schema doesn't declare a pattern.
if (!prop.pattern && /address(es)?$/i.test(name) && type !== "array") {
if (!/^0x[a-fA-F0-9]{40}$/.test(input)) {
return "Must be a valid Ethereum address (0x + 40 hex chars)";
}
}

return true;
},
} as any);
Expand All @@ -491,7 +521,11 @@ async function promptFromSchema(
return result;
}

async function signTransaction(tx: TransactionDto, wallet: HDNodeWallet): Promise<string> {
async function signTransaction(
tx: TransactionDto,
wallet: HDNodeWallet,
evmPayloadOverride?: Record<string, any>,
): Promise<string> {
if (!tx.signablePayload) throw new Error("Nothing to sign");

if (tx.signingFormat === SigningFormat.EIP712_TYPED_DATA) {
Expand All @@ -510,10 +544,110 @@ async function signTransaction(tx: TransactionDto, wallet: HDNodeWallet): Promis
}

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

function isZeroish(v: unknown): boolean {
if (v === undefined || v === null) return true;
if (typeof v === "number") return v === 0;
if (typeof v === "string") {
if (v === "") return true;
try {
return BigInt(v) === 0n;
} catch {
return false;
}
}
if (typeof v === "bigint") return v === 0n;
return false;
}

function parseIntegerField(v: unknown): number | undefined {
if (typeof v === "number") return v;
if (typeof v === "string" && v !== "") {
return Number.parseInt(v, v.startsWith("0x") ? 16 : 10);
}
return undefined;
}

/**
* Build a complete, signable EVM payload by enriching the API-supplied
* `signablePayload` with any missing critical fields. The Yield.xyz API
* sometimes hands back payloads with `chainId`, `nonce`, or fee fields left
* unset/zero; signing those produces transactions the chain immediately
* rejects ("transaction type not supported", "max fee per gas less than
* block base fee", etc.). We patch them up here using `tx.chainId` and live
* chain data so the signed tx is always broadcast-ready.
*/
async function enrichEvmPayload(
tx: TransactionDto,
walletAddress: string,
): Promise<Record<string, any>> {
const payload: Record<string, any> =
typeof tx.signablePayload === "string"
? JSON.parse(tx.signablePayload as string)
: { ...(tx.signablePayload as Record<string, any>) };

if (isZeroish(payload.chainId)) {
const outerChainId = Number(tx.chainId);
if (Number.isFinite(outerChainId) && outerChainId > 0) {
console.log(` Setting chainId ${outerChainId} (from tx.chainId)`);
payload.chainId = outerChainId;
}
}

const provider = getProvider(tx.network);
if (!provider) return payload;

try {
const freshNonce = await provider.getTransactionCount(walletAddress, "pending");
const apiNonce = parseIntegerField(payload.nonce);
if (apiNonce === undefined) {
console.log(` Setting nonce ${freshNonce} (from chain)`);
} else if (apiNonce !== freshNonce) {
console.log(` Overriding nonce ${apiNonce} → ${freshNonce} (from chain)`);
}
payload.nonce = freshNonce;
} catch (err: any) {
console.warn(` Could not refresh nonce: ${err?.message || err}`);
}

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}`);
}
}
Comment on lines +619 to +646

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.


return payload;
}

async function processTransactions(
transactions: TransactionDto[],
wallet: HDNodeWallet,
Expand Down Expand Up @@ -545,11 +679,35 @@ async function processTransactions(
}

try {
const enrichedPayload =
tx.signingFormat === undefined || tx.signingFormat === SigningFormat.EVM_TRANSACTION
? await enrichEvmPayload(tx, wallet.address)
: undefined;

console.log("Signing...");
const signature = await signTransaction(tx, wallet);
const signature = await signTransaction(tx, wallet, enrichedPayload);

console.log("Submitting...");
const result = await apiClient.submitTransaction(tx.id, { signedPayload: signature });
let result: SubmitTransactionResponseDto;
try {
result = await apiClient.submitTransaction(tx.id, { signedPayload: signature });
} catch (submitErr: any) {
// The backend's `/submit` calls an upstream RPC to broadcast. When
// that upstream broadcast fails (e.g. a flaky public RPC), it
// surfaces as a 5xx with an empty "Transaction broadcast failed: "
// message. Recover by broadcasting via our own RPC and registering
// the resulting hash with the API.
if (!isApiBroadcastFailure(submitErr)) throw submitErr;
console.warn(` API broadcast failed: ${submitErr.message}`);
console.warn(" Falling back to local broadcast via configured RPC...");
const localHash = await broadcastSignedTx(signature, tx.network).catch((err) => {
console.error(` Local broadcast also failed: ${err?.message || err}`);
return undefined;
});
if (!localHash) throw submitErr;
console.log(` Locally broadcast: ${localHash}`);
result = await apiClient.submitTransaction(tx.id, { transactionHash: localHash });
}

if (result.transactionHash) console.log(` Hash: ${result.transactionHash}`);
if (result.link) console.log(` Explorer: ${result.link}`);
Expand Down
Loading