-
Notifications
You must be signed in to change notification settings - Fork 0
chore: manage nonce locally #23
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<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 { | ||
|
|
@@ -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; | ||
| } 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,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( | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Provider/nonce context is pinned to the first EVM transaction chain. Line 583 initializes a single provider from 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 |
||
|
|
||
| for (let i = 0; i < transactions.length; i++) { | ||
| const tx = transactions[i]; | ||
|
|
||
|
|
@@ -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}`); | ||
|
|
@@ -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)); | ||
|
|
@@ -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 ===== | ||
|
|
@@ -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)); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🌐 Web query:
In enquirer@2.4.1, doselectandconfirmprompts support a true "no selection" result without adding a custom skip option?💡 Result:
No.
selectalways has a focused choice, andselectedis “the currently focused choice” (for single-select). So submitting the prompt necessarily returns some choice (unless you add a dedicated “Skip/None” choice whosevalueyou treat as empty). [2]confirmis defined to return onlytrueorfalse, 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.tsRepository: stakekit/api-recipes
Length of output: 86
🏁 Script executed:
Repository: stakekit/api-recipes
Length of output: 2223
🏁 Script executed:
Repository: stakekit/api-recipes
Length of output: 1083
🏁 Script executed:
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 whenisRequiredis false. This forces optional fields to always receive a value, preventing callers from genuinely omitting them. In contrast, array and input fields correctly checkif (response.value || isRequired)before assignment, allowing optional fields to be skipped.Since enquirer's
selectandconfirmprompts 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