From 9403ff2748b77fcbdb706598d771f78a94fbc3bf Mon Sep 17 00:00:00 2001 From: bus Date: Tue, 10 Jun 2025 15:35:25 +0000 Subject: [PATCH 1/2] Vendor Ethers' sendTransaction method to bypass wallet's RPC --- package.json | 2 +- src/context.ts | 24 ++++++++- src/knockout.ts | 10 ++-- src/pool.ts | 17 ++++--- src/recipes/reposition.ts | 9 ++-- src/swap.ts | 34 ++++++++++--- src/tokens.ts | 32 ++++++------ src/vaults/tempest.ts | 13 +++-- src/vendorEthers.ts | 102 ++++++++++++++++++++++++++++++++++++++ 9 files changed, 201 insertions(+), 42 deletions(-) create mode 100644 src/vendorEthers.ts diff --git a/package.json b/package.json index 18b470c..a7eff14 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@crocswap-libs/sdk", - "version": "2.0.14", + "version": "2.1.0", "description": "🛠🐊🛠 An SDK for building applications on top of CrocSwap", "author": "Ben Wolski ", "repository": "https://github.com/CrocSwap/sdk.git", diff --git a/src/context.ts b/src/context.ts index 452afb5..272fd26 100644 --- a/src/context.ts +++ b/src/context.ts @@ -6,7 +6,7 @@ import { CHAIN_SPECS, ChainSpec } from "./constants"; export interface CrocContext { provider: Provider; - actor: Provider | Signer; + actor: Signer; dex: Contract; router?: Contract; routerBypass?: Contract; @@ -107,7 +107,7 @@ function inflateContracts( const context = lookupChain(chainId); return { provider: provider, - actor: actor, + actor: actor as Signer, dex: new Contract(context.addrs.dex, CROC_ABI, actor), router: context.addrs.router ? new Contract(context.addrs.router || ZeroAddress, CROC_ABI, actor) : undefined, routerBypass: context.addrs.routerBypass ? new Contract(context.addrs.routerBypass || ZeroAddress, CROC_ABI, actor) : undefined, @@ -142,3 +142,23 @@ export async function ensureChain(cntx: CrocContext) { throw new Error(`Wrong chain selected in the wallet: expected ${contextNetwork.displayName} (${contextNetwork.chainId}) but got ${walletNetwork.name} (0x${Number(walletNetwork.chainId).toString(16)})`) } } + +// Attempt to call `eth_estimateGas` using the wallet's provider, and fall back +// to the frontend's provider if it fails or times out. +export async function estimateGas(cntx: CrocContext, populatedTx: ethers.ContractTransaction): Promise { + if (cntx.actor) { + if (!populatedTx.from) + populatedTx.from = (await cntx.actor.getAddress()).toLowerCase(); + try { + const result = await Promise.race([ + new Promise((_, reject) => setTimeout(() => reject(new Error("Gas estimation timed out")), 2000)), + cntx.actor.estimateGas(populatedTx) + ]) as bigint; + return result; + } catch (e) { + console.warn("Failed to estimate gas with wallet provider, falling back to frontend provider", e); + } + } + + return await cntx.provider.estimateGas(populatedTx); +} diff --git a/src/knockout.ts b/src/knockout.ts index ba5f18e..7abe9e5 100644 --- a/src/knockout.ts +++ b/src/knockout.ts @@ -1,10 +1,11 @@ import { TransactionResponse, ZeroAddress } from 'ethers'; import { ChainSpec } from "./constants"; -import { CrocContext, ensureChain } from './context'; +import { CrocContext, ensureChain, estimateGas } from './context'; import { CrocSurplusFlags, decodeSurplusFlag, encodeSurplusArg } from "./encoding/flags"; import { KnockoutEncoder } from "./encoding/knockout"; import { CrocEthView, CrocTokenView, sortBaseQuoteViews, TokenQty } from './tokens'; import { baseTokenForQuoteConc, bigIntToFloat, floatToBigInt, GAS_PADDING, quoteTokenForBaseConc, roundForConcLiq } from "./utils"; +import { sendTransaction } from './vendorEthers'; export class CrocKnockoutHandle { @@ -87,9 +88,10 @@ export class CrocKnockoutHandle { let cntx = await this.context if (txArgs === undefined) { txArgs = {} } await ensureChain(cntx) - const gasEst = await cntx.dex.userCmd.estimateGas(KNOCKOUT_PATH, calldata, txArgs) - Object.assign(txArgs, { gasLimit: gasEst + GAS_PADDING, chainId: cntx.chain.chainId }) - return cntx.dex.userCmd(KNOCKOUT_PATH, calldata, txArgs); + const populatedTx = await cntx.dex.userCmd.populateTransaction(KNOCKOUT_PATH, calldata, txArgs) + const gasEst = await estimateGas(cntx, populatedTx); + Object.assign(populatedTx, { gasLimit: gasEst + GAS_PADDING, chainId: cntx.chain.chainId }) + return sendTransaction(cntx, populatedTx); } private maskSurplusFlags (opts?: CrocKnockoutOpts): number { diff --git a/src/pool.ts b/src/pool.ts index 3af72aa..ec5f2b9 100644 --- a/src/pool.ts +++ b/src/pool.ts @@ -1,10 +1,11 @@ import { TransactionResponse, ZeroAddress } from 'ethers'; -import { CrocContext, ensureChain } from "./context"; +import { CrocContext, ensureChain, estimateGas } from "./context"; import { CrocSurplusFlags, decodeSurplusFlag, encodeSurplusArg } from "./encoding/flags"; import { PoolInitEncoder } from "./encoding/init"; import { WarmPathEncoder } from './encoding/liquidity'; import { CrocEthView, CrocTokenView, sortBaseQuoteViews, TokenQty } from './tokens'; import { bigIntToFloat, concBaseSlippagePrice, concDepositSkew, concQuoteSlippagePrice, decodeCrocPrice, fromDisplayPrice, GAS_PADDING, neighborTicks, pinTickLower, pinTickOutside, pinTickUpper, roundForConcLiq, tickToPrice, toDisplayPrice, toDisplayQty } from './utils'; +import { sendTransaction } from './vendorEthers'; type PriceRange = [number, number] type TickRange = [number, number] @@ -128,9 +129,10 @@ export class CrocPoolView { let cntx = await this.context await ensureChain(cntx) - const gasEst = await cntx.dex.userCmd.estimateGas(cntx.chain.proxyPaths.cold, calldata, txArgs) - Object.assign(txArgs, { gasLimit: gasEst + GAS_PADDING, chainId: cntx.chain.chainId }) - return cntx.dex.userCmd(cntx.chain.proxyPaths.cold, calldata, txArgs) + const populatedTx = await cntx.dex.userCmd.populateTransaction(cntx.chain.proxyPaths.cold, calldata, txArgs) + const gasEst = await estimateGas(cntx, populatedTx) + Object.assign(populatedTx, { gasLimit: gasEst + GAS_PADDING, chainId: cntx.chain.chainId }) + return sendTransaction(cntx, populatedTx); } async mintAmbientBase (qty: TokenQty, limits: PriceRange, opts?: CrocLpOpts): @@ -190,9 +192,10 @@ export class CrocPoolView { let cntx = await this.context if (txArgs === undefined) { txArgs = {} } await ensureChain(cntx) - const gasEst = await cntx.dex.userCmd.estimateGas(cntx.chain.proxyPaths.liq, calldata, txArgs) - Object.assign(txArgs, { gasLimit: gasEst + GAS_PADDING, chainId: cntx.chain.chainId }) - return cntx.dex.userCmd(cntx.chain.proxyPaths.liq, calldata, txArgs); + const populatedTx = await cntx.dex.userCmd.populateTransaction(cntx.chain.proxyPaths.liq, calldata, txArgs) + const gasEst = await estimateGas(cntx, populatedTx); + Object.assign(populatedTx, { gasLimit: gasEst + GAS_PADDING, chainId: cntx.chain.chainId }) + return sendTransaction(cntx, populatedTx); } private async mintAmbient (qty: TokenQty, isQtyBase: boolean, diff --git a/src/recipes/reposition.ts b/src/recipes/reposition.ts index fe5d339..223648f 100644 --- a/src/recipes/reposition.ts +++ b/src/recipes/reposition.ts @@ -1,11 +1,12 @@ import { TransactionResponse } from "ethers"; -import { ensureChain } from "../context"; +import { ensureChain, estimateGas } from "../context"; import { OrderDirective, PoolDirective } from "../encoding/longform"; import { CrocPoolView } from "../pool"; import { CrocSwapPlan } from "../swap"; import { CrocTokenView } from "../tokens"; import { encodeCrocPrice, GAS_PADDING, tickToPrice } from "../utils"; import { baseTokenForConcLiq, concDepositBalance, quoteTokenForConcLiq } from "../utils/liquidity"; +import { sendTransaction } from "../vendorEthers"; interface RepositionTarget { @@ -37,8 +38,10 @@ export class CrocReposition { const cntx = await this.pool.context const path = cntx.chain.proxyPaths.long await ensureChain(cntx) - const gasEst = await cntx.dex.userCmd.estimateGas(path, directive.encodeBytes()) - return cntx.dex.userCmd(path, directive.encodeBytes(), { gasLimit: gasEst + GAS_PADDING, chainId: cntx.chain.chainId }) + const populatedTx = await cntx.dex.userCmd.populateTransaction(path, directive.encodeBytes()) + const gasEst = await estimateGas(cntx, populatedTx) + Object.assign(populatedTx, { gasLimit: gasEst + GAS_PADDING, chainId: cntx.chain.chainId }) + return sendTransaction(cntx, populatedTx) } async simStatic() { diff --git a/src/swap.ts b/src/swap.ts index 2c915a2..2b728f4 100644 --- a/src/swap.ts +++ b/src/swap.ts @@ -1,11 +1,12 @@ import { ethers, TransactionResponse, ZeroAddress } from "ethers"; import { MAX_SQRT_PRICE, MIN_SQRT_PRICE } from "./constants"; -import { CrocContext, ensureChain } from './context'; +import { CrocContext, ensureChain, estimateGas } from './context'; import { CrocSurplusFlags, decodeSurplusFlag, encodeSurplusArg } from "./encoding/flags"; import { CrocPoolView } from './pool'; import { CrocSlotReader } from "./slots"; import { CrocEthView, CrocTokenView, sortBaseQuoteViews, TokenQty } from './tokens'; import { decodeCrocPrice, GAS_PADDING, getUnsignedRawTransaction } from './utils'; +import { sendTransaction, staticCall } from "./vendorEthers"; /* Describes the predicted impact of a given swap. * @property sellQty The total quantity of tokens predicted to be sold by the swapper to the dex. @@ -67,15 +68,15 @@ export class CrocSwapPlan { } private async sendTx (args: CrocSwapExecOpts): Promise { - return this.hotPathCall(await this.txBase(), 'send', args) + return this.hotPathCall(await this.txBase(), 'send', args) as Promise; } private async callStatic (args: CrocSwapExecOpts): Promise { - return this.hotPathCall(await this.txBase(), 'staticCall', args) + return this.hotPathCall(await this.txBase(), 'staticCall', args) as Promise; } async estimateGas (args: CrocSwapExecOpts = { }): Promise { - return this.hotPathCall(await this.txBase(), 'estimateGas', args) + return this.hotPathCall(await this.txBase(), 'estimateGas', args) as Promise; } private async txBase() { @@ -112,10 +113,21 @@ export class CrocSwapPlan { const TIP = 0 const surplusFlags = this.maskSurplusArgs(args) - return contract.swap[callType](this.baseToken.tokenAddr, this.quoteToken.tokenAddr, (await this.context).chain.poolIndex, + const populatedTx = await contract.swap.populateTransaction(this.baseToken.tokenAddr, this.quoteToken.tokenAddr, (await this.context).chain.poolIndex, this.sellBase, this.qtyInBase, await this.qty, TIP, await this.calcLimitPrice(), await this.calcSlipQty(), surplusFlags, await this.buildTxArgs(surplusFlags, args.gasEst), ) + + switch (callType) { + case 'estimateGas': + return estimateGas(await this.context, populatedTx); + case 'send': + return sendTransaction(await this.context, populatedTx); + case 'staticCall': + return await staticCall(await this.context, populatedTx, contract, contract.swap.fragment); + default: + throw new Error(`Invalid call type: ${callType}`); + } } private async userCmdCall(contract: ethers.Contract, callType: 'send' | 'staticCall' | 'estimateGas', args: CrocSwapExecOpts) { @@ -130,7 +142,17 @@ export class CrocSwapPlan { this.sellBase, this.qtyInBase, await this.qty, TIP, await this.calcLimitPrice(), await this.calcSlipQty(), surplusFlags]) - return contract.userCmd[callType](HOT_PROXY_IDX, cmd, await this.buildTxArgs(surplusFlags, args.gasEst)) + const populatedTx = await contract.userCmd.populateTransaction(HOT_PROXY_IDX, cmd, await this.buildTxArgs(surplusFlags, args.gasEst)) + switch (callType) { + case 'estimateGas': + return estimateGas(await this.context, populatedTx); + case 'send': + return sendTransaction(await this.context, populatedTx); + case 'staticCall': + return await staticCall(await this.context, populatedTx, contract, contract.userCmd.fragment); + default: + throw new Error(`Invalid call type: ${callType}`); + } } /** diff --git a/src/tokens.ts b/src/tokens.ts index e9f18dc..99e7744 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -1,9 +1,10 @@ import { Contract, ethers, MaxUint256, TransactionResponse, ZeroAddress } from "ethers"; import { MAX_LIQ } from "./constants"; -import { CrocContext, ensureChain } from "./context"; +import { CrocContext, ensureChain, estimateGas } from "./context"; import { BlockTag } from "./position"; import { GAS_PADDING } from "./utils"; import { fromDisplayQty, toDisplayQty } from "./utils/token"; +import { sendTransaction } from "./vendorEthers"; /* Type representing specified token quantities. This type can either represent the raw non-decimalized * on-chain value in wei, if passed as a BigNuber. Or it can represent the decimalized value if passed @@ -50,18 +51,14 @@ export class CrocTokenView { const weiQty = approveQty !== undefined ? await this.normQty(approveQty) : MaxUint256 await ensureChain(await this.context) + const populatedTx = await (await this.resolveWrite()).approve.populateTransaction(addr, weiQty, { chainId: (await this.context).chain.chainId }) // We want to hardcode the gas limit, so we can manually pad it from the estimated // transaction. The default value is low gas calldata, but Metamask and other wallets // will often ask users to change the approval amount. Without the padding, approval // transactions can run out of gas. - const gasEst = (await this.resolveWrite()).approve.estimateGas( - addr, - weiQty - ); - - return (await this.resolveWrite()).approve( - addr, weiQty, { gasLimit: (await gasEst) + BigInt(15000), chainId: ((await this.context).chain).chainId } - ); + const gasEst = await estimateGas((await this.context), populatedTx); + populatedTx.gasLimit = gasEst + BigInt(15000); + return await sendTransaction(await this.context, populatedTx); } async approveBypassRouter(): Promise { @@ -75,9 +72,13 @@ export class CrocTokenView { const HOT_PROXY_IDX = 1 const COLD_PROXY_IDX = 3 const cmd = abiCoder.encode(["uint8", "address", "uint32", "uint16[]"], - [72, router.address, MANY_CALLS, [HOT_PROXY_IDX]]) + [72, router.target, MANY_CALLS, [HOT_PROXY_IDX]]) await ensureChain(await this.context) - return (await this.context).dex.userCmd(COLD_PROXY_IDX, cmd, { chainId: ((await this.context).chain).chainId }) + const populatedTx = await (await this.context).dex.userCmd.populateTransaction( + COLD_PROXY_IDX, cmd, { chainId: ((await this.context).chain).chainId }) + const gasEst = await estimateGas((await this.context), populatedTx); + populatedTx.gasLimit = gasEst + BigInt(15000); + return sendTransaction(await this.context, populatedTx); } async wallet (address: string, block: BlockTag = "latest"): Promise { @@ -172,12 +173,13 @@ export class CrocTokenView { const cmd = abiCoder.encode(["uint8", "address", "uint128", "address"], [subCode, recv, await weiQty, this.tokenAddr]) - const txArgs = useMsgVal ? { value: await weiQty } : { } + const txArgs = useMsgVal ? { value: await weiQty } : {} let cntx = await this.context await ensureChain(cntx) - const gasEst = await cntx.dex.userCmd.estimateGas(cntx.chain.proxyPaths.cold, cmd, txArgs) - Object.assign(txArgs, { gasLimit: gasEst + GAS_PADDING, chainId: cntx.chain.chainId }) - return cntx.dex.userCmd(cntx.chain.proxyPaths.cold, cmd, txArgs) + const populatedTx = await cntx.dex.userCmd.populateTransaction(cntx.chain.proxyPaths.cold, cmd, txArgs) + const gasEst = await estimateGas(cntx, populatedTx) + Object.assign(populatedTx, { gasLimit: gasEst + GAS_PADDING, chainId: cntx.chain.chainId }) + return sendTransaction(cntx, populatedTx); } readonly tokenAddr: string; diff --git a/src/vaults/tempest.ts b/src/vaults/tempest.ts index bfa82f5..954a4e8 100644 --- a/src/vaults/tempest.ts +++ b/src/vaults/tempest.ts @@ -2,6 +2,7 @@ import { Contract, Signer, TransactionResponse, Typed } from "ethers"; import { TEMPEST_VAULT_ABI } from "../abis/external/TempestVaultAbi"; import { CrocContext, ensureChain } from "../context"; import { CrocTokenView, TokenQty } from "../tokens"; +import { sendTransaction } from "../vendorEthers"; export type TempestStrategy = 'rswEth' | 'symetricAmbient' @@ -29,9 +30,11 @@ export class TempestVault { await ensureChain(await this.context) switch (this.strategy) { case 'symetricAmbient': - return (await this.vaultWrite).deposit(await weiQty, owner, Typed.bool(true), txArgs) + const populatedTx = await (await this.vaultWrite).deposit.populateTransaction(await weiQty, owner, Typed.bool(true), txArgs); + return sendTransaction(await this.context, populatedTx); case 'rswEth': - return (await this.vaultWrite).deposit(await weiQty, owner, Typed.bytes('0x'), txArgs) + const populatedTxRsw = await (await this.vaultWrite).deposit.populateTransaction(await weiQty, owner, Typed.bytes('0x'), txArgs); + return sendTransaction(await this.context, populatedTxRsw); } } @@ -45,9 +48,11 @@ export class TempestVault { await ensureChain(await this.context) switch (this.strategy) { case 'symetricAmbient': - return (await this.vaultWrite).redeem(await weiQty, owner, owner, Typed.uint256(await minWeiQty), Typed.bool(true)) + const populatedTx = await (await this.vaultWrite).redeem.populateTransaction(await weiQty, owner, owner, Typed.uint256(await minWeiQty), Typed.bool(true)); + return sendTransaction(await this.context, populatedTx); case 'rswEth': - return (await this.vaultWrite).redeem(await weiQty, owner, owner, Typed.bytes('0x')) + const populatedTxRsw = await (await this.vaultWrite).redeem.populateTransaction(await weiQty, owner, owner, Typed.bytes('0x')); + return sendTransaction(await this.context, populatedTxRsw); } } diff --git a/src/vendorEthers.ts b/src/vendorEthers.ts new file mode 100644 index 0000000..dddf2fc --- /dev/null +++ b/src/vendorEthers.ts @@ -0,0 +1,102 @@ +import { ethers, isCallException, isError } from "ethers"; +import { CrocContext } from "./context"; + +export async function sendTransaction(cntx: CrocContext, populatedTx: ethers.ContractTransaction): Promise { + // Every Ethers provider is actually a JsonRpcApiProvider, so we can use + // its methods when needed. + const params = (cntx.provider as ethers.JsonRpcApiProvider).getRpcTransaction(populatedTx); + return await ethers_sendTransaction(cntx, params); +} + +// Almost exact copy of `ethers.providers.JsonRpcSigner.sendTransaction` which +// doesn't use the wallet provider for RPC calls, only to send the transaction. +// +// https://github.com/ethers-io/ethers.js/blob/v6.13.5/src.ts/providers/provider-jsonrpc.ts#L353-L415 +export async function ethers_sendTransaction(cntx: CrocContext, txParams: ethers.JsonRpcTransactionRequest): Promise { + console.log('manual ethers_sendTransaction', txParams); + const signer = cntx.actor; + const walletProvider = signer.provider as ethers.JsonRpcApiProvider; + if (!signer || !walletProvider) + throw new Error("Wallet isn't connected"); + + if (!txParams.from) + txParams.from = (await signer.getAddress()).toLowerCase(); + + // This cannot be mined any earlier than any recent block + const blockNumber = await cntx.provider.getBlockNumber(); + + // Send the transaction + const hash = await walletProvider.send('eth_sendTransaction', [txParams]); + + // Unfortunately, JSON-RPC only provides and opaque transaction hash + // for a response, and we need the actual transaction, so we poll + // for it; it should show up very quickly + return await (new Promise((resolve, reject) => { + const timeouts = [1000, 100]; + let invalids = 0; + + const checkTx = async () => { + + try { + // Try getting the transaction + const tx = await cntx.provider.getTransaction(hash); + + if (tx != null) { + resolve(tx.replaceableTransaction(blockNumber)); + return; + } + + } catch (error) { + + // If we were cancelled: stop polling. + // If the data is bad: the node returns bad transactions + // If the network changed: calling again will also fail + // If unsupported: likely destroyed + if (isError(error, "CANCELLED") || isError(error, "BAD_DATA") || + isError(error, "NETWORK_ERROR") || isError(error, "UNSUPPORTED_OPERATION")) { + + if (error.info == null) { error.info = {}; } + error.info.sendTransactionHash = hash; + + reject(error); + return; + } + + // Stop-gap for misbehaving backends; see #4513 + if (isError(error, "INVALID_ARGUMENT")) { + invalids++; + if (error.info == null) { error.info = {}; } + error.info.sendTransactionHash = hash; + if (invalids > 10) { + reject(error); + return; + } + } + } + + // Wait another 4 seconds + setTimeout(() => { checkTx(); }, timeouts.pop() || 4000); + }; + checkTx(); + })); +} + +// Almost exact copy of `ethers.contract.BaseContractMethod.staticCallResult`. +// +// https://github.com/ethers-io/ethers.js/blob/v6.13.5/src.ts/contract/contract.ts#L328-L347 +export async function staticCall(cntx: CrocContext, populatedTx: ethers.ContractTransaction, contract: ethers.Contract, fragment: ethers.FunctionFragment): Promise { + const runner = cntx.provider; + + let result = "0x"; + try { + result = await runner.call(populatedTx); + } catch (error: any) { + if (isCallException(error) && error.data) { + throw contract.interface.makeError(error.data, populatedTx); + } + throw error; + } + + const decoded = contract.interface.decodeFunctionResult(fragment, result); + return decoded.length == 1 ? decoded[0] : decoded; +}; From cfd171cdb98d8652c059901a1a30f3b344287bf5 Mon Sep 17 00:00:00 2001 From: bus Date: Tue, 10 Jun 2025 20:56:10 +0000 Subject: [PATCH 2/2] Remove logging --- src/vendorEthers.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/vendorEthers.ts b/src/vendorEthers.ts index dddf2fc..ecb200f 100644 --- a/src/vendorEthers.ts +++ b/src/vendorEthers.ts @@ -13,7 +13,6 @@ export async function sendTransaction(cntx: CrocContext, populatedTx: ethers.Con // // https://github.com/ethers-io/ethers.js/blob/v6.13.5/src.ts/providers/provider-jsonrpc.ts#L353-L415 export async function ethers_sendTransaction(cntx: CrocContext, txParams: ethers.JsonRpcTransactionRequest): Promise { - console.log('manual ethers_sendTransaction', txParams); const signer = cntx.actor; const walletProvider = signer.provider as ethers.JsonRpcApiProvider; if (!signer || !walletProvider)