From 64f93b8e2f5a0fc82bf3bb4574514db960b36a2a Mon Sep 17 00:00:00 2001 From: 0xyoki Date: Thu, 12 Mar 2026 17:32:32 +0100 Subject: [PATCH 1/3] chore: manage nonce locally --- recipes/borrow.ts | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/recipes/borrow.ts b/recipes/borrow.ts index d109344..c11122a 100644 --- a/recipes/borrow.ts +++ b/recipes/borrow.ts @@ -486,7 +486,11 @@ async function promptFromSchema( return result; } -async function signTransaction(tx: TransactionDto, wallet: HDNodeWallet): Promise { +async function signTransaction( + tx: TransactionDto, + wallet: HDNodeWallet, + nonceOffset = 0, +): Promise<{ signed: string; nonceAdjusted: boolean }> { if (!tx.signablePayload) throw new Error("Nothing to sign"); if (tx.signingFormat === SigningFormat.EIP712_TYPED_DATA) { @@ -494,7 +498,8 @@ async function signTransaction(tx: TransactionDto, wallet: HDNodeWallet): Promis 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, nonceAdjusted: false }; } if ( @@ -506,7 +511,19 @@ async function signTransaction(tx: TransactionDto, wallet: HDNodeWallet): Promis const txData = typeof tx.signablePayload === "string" ? JSON.parse(tx.signablePayload) : tx.signablePayload; - return wallet.signTransaction(txData); + + if (txData.nonce !== undefined && txData.nonce !== null) { + txData.nonce = Number(txData.nonce) + nonceOffset; + 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}`); + } + + const signed = await wallet.signTransaction(txData); + return { signed, nonceAdjusted: true }; } async function processTransactions( @@ -515,6 +532,8 @@ async function processTransactions( apiClient: BorrowApiClient, actionId: string, ): Promise { + let nonceOffset = 0; + for (let i = 0; i < transactions.length; i++) { const tx = transactions[i]; @@ -541,7 +560,8 @@ async function processTransactions( try { console.log("Signing..."); - const signature = await signTransaction(tx, wallet); + const { signed: signature, nonceAdjusted } = await signTransaction(tx, wallet, nonceOffset); + if (nonceAdjusted) nonceOffset++; console.log("Submitting..."); const result = await apiClient.submitTransaction(tx.id, { signedPayload: signature }); From 62afade9e0283a7b51812735216e64af4564056d Mon Sep 17 00:00:00 2001 From: 0xyoki Date: Fri, 13 Mar 2026 09:25:34 +0100 Subject: [PATCH 2/3] chore: flow update --- recipes/borrow.ts | 141 ++++++++++++++++++++++++++++++---------------- 1 file changed, 94 insertions(+), 47 deletions(-) diff --git a/recipes/borrow.ts b/recipes/borrow.ts index c11122a..3a48ef3 100644 --- a/recipes/borrow.ts +++ b/recipes/borrow.ts @@ -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(); @@ -19,6 +19,27 @@ if (!process.env.MNEMONIC || !process.env.BORROW_API_KEY) { process.exit(1); } +const DEFAULT_RPC_URLS: Record = { + "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 { @@ -400,15 +421,8 @@ 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 = ""; - const choices = isRequired ? baseChoices : [skipChoice, ...baseChoices]; + const choices = prop.options || (prop.enum as string[]); const response: any = await Enquirer.prompt({ type: "select", name: "value", @@ -416,14 +430,13 @@ async function promptFromSchema( 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; } else if (type === "object" && prop.properties) { @@ -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) { @@ -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`; @@ -474,12 +487,12 @@ 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; + } } } @@ -489,8 +502,9 @@ async function promptFromSchema( async function signTransaction( tx: TransactionDto, wallet: HDNodeWallet, - nonceOffset = 0, -): Promise<{ signed: string; nonceAdjusted: boolean }> { + 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) { @@ -499,21 +513,22 @@ async function signTransaction( const { domain, types, message } = typed; const { EIP712Domain: _, ...signingTypes } = types; const signed = await wallet.signTypedData(domain, signingTypes, message); - return { signed, nonceAdjusted: false }; + 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; - if (txData.nonce !== undefined && txData.nonce !== null) { - txData.nonce = Number(txData.nonce) + nonceOffset; + if (!txData.chainId && tx.chainId) { + txData.chainId = Number(tx.chainId); + } + + if (nonce !== undefined) { + txData.nonce = nonce; console.log(` Using nonce: ${txData.nonce}`); } @@ -522,8 +537,20 @@ async function signTransaction( console.log(` Gas limit: ${txData.gasLimit}`); } + if (provider && (!txData.maxFeePerGas || !txData.gasPrice)) { + const feeData = await provider.getFeeData(); + if (feeData.maxFeePerGas) { + txData.maxFeePerGas = feeData.maxFeePerGas.toString(); + txData.maxPriorityFeePerGas = (feeData.maxPriorityFeePerGas ?? 0n).toString(); + console.log(` Max fee: ${feeData.maxFeePerGas} | Priority: ${feeData.maxPriorityFeePerGas}`); + } else if (feeData.gasPrice) { + txData.gasPrice = feeData.gasPrice.toString(); + console.log(` Gas price: ${feeData.gasPrice}`); + } + } + const signed = await wallet.signTransaction(txData); - return { signed, nonceAdjusted: true }; + return { signed, isEvmTx: true }; } async function processTransactions( @@ -532,6 +559,21 @@ async function processTransactions( apiClient: BorrowApiClient, actionId: string, ): Promise { + 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; for (let i = 0; i < transactions.length; i++) { @@ -559,12 +601,25 @@ async function processTransactions( } try { + const nonce = baseNonce !== undefined ? baseNonce + nonceOffset : undefined; console.log("Signing..."); - const { signed: signature, nonceAdjusted } = await signTransaction(tx, wallet, nonceOffset); - if (nonceAdjusted) nonceOffset++; - - 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}`); @@ -633,9 +688,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)); @@ -651,12 +704,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 ===== @@ -1112,9 +1161,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)); From 7e8ebac9d59b0e4292857adada94e9c676712a8b Mon Sep 17 00:00:00 2001 From: 0xyoki Date: Fri, 13 Mar 2026 10:39:01 +0100 Subject: [PATCH 3/3] chore: fix mixed legacy/EIP-1559 fee fields will cause transaction signing to fail in ethers v6 --- recipes/borrow.ts | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/recipes/borrow.ts b/recipes/borrow.ts index 3a48ef3..737f960 100644 --- a/recipes/borrow.ts +++ b/recipes/borrow.ts @@ -537,15 +537,25 @@ async function signTransaction( console.log(` Gas limit: ${txData.gasLimit}`); } - if (provider && (!txData.maxFeePerGas || !txData.gasPrice)) { - const feeData = await provider.getFeeData(); - if (feeData.maxFeePerGas) { - txData.maxFeePerGas = feeData.maxFeePerGas.toString(); - txData.maxPriorityFeePerGas = (feeData.maxPriorityFeePerGas ?? 0n).toString(); - console.log(` Max fee: ${feeData.maxFeePerGas} | Priority: ${feeData.maxPriorityFeePerGas}`); - } else if (feeData.gasPrice) { - txData.gasPrice = feeData.gasPrice.toString(); - console.log(` Gas price: ${feeData.gasPrice}`); + 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(); + } } }