Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
338c844
fix(solana): typed close-failure errors + pre-broadcast simulation gu…
fengtality Aug 13, 2026
8f423d4
feat(solana): distinguish NOT_FOUND from UNCONFIRMED in transaction poll
fengtality Aug 13, 2026
f9d4ed7
docs: rewrite retry-architecture as issues + final architecture
fengtality Aug 13, 2026
71537de
fix(poll): one txStatus contract for Solana and Ethereum; report EVM …
fengtality Aug 13, 2026
18d6e95
docs: single close-retry loop; unified poll contract
fengtality Aug 13, 2026
546c643
fix(poll): attribute failed-transaction errors to the failing program
fengtality Aug 13, 2026
2452a89
feat(clmm): binCount on unified pool-info; add bin support to Pancake…
fengtality Aug 13, 2026
4e7233b
fix(bsc): default nodeURL to bsc-dataseed.bnbchain.org
fengtality Aug 13, 2026
0a54fed
fix(ethereum): default mainnet nodeURL to Alchemy's public endpoint
fengtality Aug 13, 2026
6aeeb7e
docs: cover the poll and CLMM pool-info contracts
fengtality Aug 13, 2026
80c1445
fix(clmm): validate open amounts at the route, matching add
fengtality Aug 18, 2026
7eb4ffc
refactor(clmm): drop the never-populated reward fields from PositionInfo
fengtality Aug 18, 2026
f686579
feat(clmm): binCount bins for pancakeswap-sol; fix inverted out-of-ra…
fengtality Aug 18, 2026
227f22d
refactor(clmm): one fee-tier vocabulary for create-pool; canonical sc…
fengtality Aug 18, 2026
95e7ecd
refactor(amm): consistent unified params — no gas overrides, one conf…
fengtality Aug 19, 2026
7a36c6a
refactor(swap): uniform router contract — slippagePct + approximateIf…
fengtality Aug 19, 2026
a2b9779
refactor(amm): drop openTime from unified create-pool; optionals trai…
fengtality Aug 19, 2026
a67c572
fix(clmm): expose strategyType on unified add-liquidity, matching open
fengtality Aug 19, 2026
6b3383c
refactor(trading): standardize unified route surfaces
fengtality Aug 19, 2026
26c3bb3
fix(trading): uniform error passthrough on all unified routes
fengtality Aug 19, 2026
1559081
fix(trading): connector-config slippage defaults; percentageToRemove …
fengtality Aug 19, 2026
2eaee8b
docs: regenerate openapi.json from current routes
fengtality Aug 19, 2026
1fbbacf
test(jupiter): assert removed fields' absence independently
fengtality Aug 19, 2026
da3008b
refactor(trading): fold AMM pool-scoped swaps into the unified swap r…
fengtality Aug 19, 2026
828159e
fix(trading): fail loudly on landed-but-failed Solana txs; echo appli…
fengtality Aug 19, 2026
a6d82bb
feat(trading): let unified swaps pin a pool by address
fengtality Aug 19, 2026
1884ed7
test(trading): assert the live field names on unified CLMM and orca r…
fengtality Aug 19, 2026
40bb747
test(trading): send chainNetwork, not the pre-fold network field
fengtality Aug 19, 2026
198e0d8
docs: regenerate openapi.json from the current route table
fengtality Aug 19, 2026
b5f5e04
fix(raydium): collect fees without withdrawing liquidity
fengtality Aug 19, 2026
fdad604
fix(evm): route every EVM transaction through one confirmation gate
fengtality Aug 19, 2026
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
240 changes: 240 additions & 0 deletions docs/retry-architecture.md

Large diffs are not rendered by default.

9,187 changes: 6,537 additions & 2,650 deletions openapi.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import { logger } from './services/logger';
import { quoteCache } from './services/quote-cache';
import { displayChainConfigurations } from './services/startup-banner';
import { tokensRoutes } from './tokens/tokens.routes';
import { tradingRoutes, tradingClmmRoutes, tradingAmmRoutes } from './trading/trading.routes';
import { tradingSwapRoutes, tradingClmmRoutes, tradingAmmRoutes } from './trading/trading.routes';
import { GATEWAY_VERSION } from './version';
import { walletRoutes } from './wallet/wallet.routes';

Expand Down Expand Up @@ -292,7 +292,7 @@ const configureGatewayServer = () => {
app.register(poolRoutes, { prefix: '/pools' });

// Register trading routes (unified cross-chain swap)
app.register(tradingRoutes, { prefix: '/trading/swap' });
app.register(tradingSwapRoutes, { prefix: '/trading/swap' });

// Register trading CLMM routes (unified cross-chain concentrated liquidity)
app.register(tradingClmmRoutes, { prefix: '/trading/clmm' });
Expand Down
67 changes: 67 additions & 0 deletions src/chains/ethereum/ethereum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { RPCProvider } from '../../rpc/rpc-provider-base';
import { TokenValue, tokenValueToString } from '../../services/base';
import { ConfigManagerCertPassphrase } from '../../services/config-manager-cert-passphrase';
import { ConfigManagerV2 } from '../../services/config-manager-v2';
import { transactionFailed } from '../../services/error-handler';
import { logger, redactUrl } from '../../services/logger';
import { TokenService } from '../../services/token-service';
import { walletPath, isHardwareWallet as checkIsHardwareWallet } from '../../wallet/utils';
Expand All @@ -29,6 +30,15 @@ export interface TokenInfo {
export type NewBlockHandler = (bn: number) => void;
export type NewDebugMsgHandler = (msg: any) => void;

/**
* Outcome of an EVM liquidity/pool transaction as a route is allowed to report it.
* A revert is not one of the cases: it throws, so it can never be mistaken for PENDING.
* See {@link Ethereum.handleTransactionConfirmation}.
*/
export type EthereumTransactionOutcome =
| { confirmed: false; signature: string }
| { confirmed: true; signature: string; receipt: providers.TransactionReceipt; fee: number };

// Networks that support EIP-1559 (type 2) transactions
export const EIP1559_NETWORKS = [
'mainnet',
Expand Down Expand Up @@ -1026,6 +1036,60 @@ export class Ethereum {
return null;
}

/**
* Single confirmation gate for EVM liquidity and pool transactions (open/close position,
* add/remove liquidity, collect fees, create pool, execute swap).
*
* `handleTransactionExecution` has three outcomes but only two of them are a valid response
* body, so every caller used to have to remember two separate checks — and almost none did:
*
* - no receipt (still pending after the extended poll) — dereferencing it throws a TypeError
* that the route catch turns into a generic 500, losing the transaction hash and with it
* any chance of reconciling a transaction that lands a minute later.
* - `receipt.status === 0` (reverted on-chain) — forwarding it verbatim as the response
* `status` reports the revert as {@link TransactionStatus.PENDING}, which is also 0, so a
* poller waits on it forever while the route's pre-send amounts are booked as if the
* tokens had moved.
*
* This helper resolves both:
*
* - still pending -> `{ confirmed: false, signature: tx.hash }`. The caller returns
* `{ signature, status: TransactionStatus.PENDING }` with NO `data` — the amounts it
* computed before sending have not moved and must not be reported as if they had.
* - reverted -> throws the shared 400 TRANSACTION_FAILED, the same terminal, non-retryable
* error the Solana routes throw for a landed-but-failed transaction.
* - confirmed -> `{ confirmed: true, signature, receipt, fee }`, fee already converted from
* gas units to the chain's native currency.
*/
public async handleTransactionConfirmation(tx: TransactionResponse): Promise<EthereumTransactionOutcome> {
const receipt = await this.handleTransactionExecution(tx);

if (!receipt) {
logger.warn(`Transaction ${tx.hash} still pending — reporting PENDING so the caller can reconcile it later`);
return { confirmed: false, signature: tx.hash };
}

if (receipt.status === 0) {
throw transactionFailed(
`Transaction ${receipt.transactionHash} reverted on-chain. Gas was spent; no tokens moved.`,
);
}

if (receipt.status !== 1) {
// No status on the receipt (pre-Byzantium chain or a provider quirk): neither a
// confirmation nor a revert, so report it as pending rather than guessing.
logger.warn(`Transaction ${receipt.transactionHash} has no receipt status — reporting PENDING`);
return { confirmed: false, signature: receipt.transactionHash };
}

return {
confirmed: true,
signature: receipt.transactionHash,
receipt,
fee: parseFloat(utils.formatUnits(receipt.gasUsed.mul(receipt.effectiveGasPrice), 18)),
};
}

/**
* Handle transaction confirmation status and return appropriate response
* Similar to Solana's handleConfirmation helper
Expand All @@ -1045,6 +1109,7 @@ export class Ethereum {
expectedAmountOut: number,
side?: 'BUY' | 'SELL',
txHash?: string, // Optional tx hash for pending transactions
slippagePct?: number, // Slippage tolerance actually applied to the swap (echoed in data)
): {
signature: string;
status: number;
Expand All @@ -1056,6 +1121,7 @@ export class Ethereum {
fee: number;
baseTokenBalanceChange: number;
quoteTokenBalanceChange: number;
slippagePct?: number;
};
} {
if (!txReceipt) {
Expand Down Expand Up @@ -1118,6 +1184,7 @@ export class Ethereum {
fee,
baseTokenBalanceChange,
quoteTokenBalanceChange,
slippagePct,
},
};
}
Expand Down
57 changes: 29 additions & 28 deletions src/chains/ethereum/routes/approve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { FastifyPluginAsync, FastifyInstance } from 'fastify';

import { getSpender as pancakeswapSpender } from '../../../connectors/pancakeswap/pancakeswap.contracts';
import { getSpender as uniswapSpender } from '../../../connectors/uniswap/uniswap.contracts';
import { TransactionStatus } from '../../../schemas/chain-schema';
import { bigNumberWithDecimalToStr } from '../../../services/base';
import { logger } from '../../../services/logger';
import { Ethereum } from '../ethereum';
Expand Down Expand Up @@ -178,17 +179,20 @@ export async function approveEthereumToken(
const txResponse = await ethereum.provider.sendTransaction(signedTx);

// Wait for confirmation with timeout
const receipt = await ethereum.handleTransactionExecution(txResponse);
// A revert throws 400 TRANSACTION_FAILED out of the helper; only a still-pending
// transaction comes back unconfirmed, and an approval cannot be reported without a
// confirmed allowance.
const outcome = await ethereum.handleTransactionConfirmation(txResponse);

if (!receipt || receipt.status === -1) {
if (!outcome.confirmed) {
throw new Error('Transaction timed out or failed to get receipt');
}

approval = {
hash: receipt.transactionHash,
hash: outcome.signature,
nonce: nonce,
gasUsed: receipt.gasUsed,
effectiveGasPrice: receipt.effectiveGasPrice,
gasUsed: outcome.receipt.gasUsed,
effectiveGasPrice: outcome.receipt.effectiveGasPrice,
};
} else {
// Regular wallet flow
Expand All @@ -207,18 +211,17 @@ export async function approveEthereumToken(
const tx = await ethereum.approveERC20(contract, wallet, spenderAddress, amountBigNumber);

// Wait for the transaction to be mined with timeout (60 seconds for approvals)
const receipt = await ethereum.handleTransactionExecution(tx);
const outcome = await ethereum.handleTransactionConfirmation(tx);

if (!receipt || receipt.status === -1) {
if (!outcome.confirmed) {
throw new Error('Transaction timed out or failed to get receipt');
}

approval = {
hash: tx.hash,
nonce: tx.nonce,
gasUsed: receipt.gasUsed,
effectiveGasPrice: receipt.effectiveGasPrice,
status: receipt.status,
gasUsed: outcome.receipt.gasUsed,
effectiveGasPrice: outcome.receipt.effectiveGasPrice,
};
}
} else {
Expand All @@ -229,7 +232,6 @@ export async function approveEthereumToken(
nonce: 0,
gasUsed: ethers.BigNumber.from('0'),
effectiveGasPrice: ethers.BigNumber.from('0'),
status: 1,
};
}

Expand Down Expand Up @@ -291,20 +293,18 @@ export async function approveEthereumToken(
const txResponse = await ethereum.provider.sendTransaction(signedTx);

// Wait for confirmation with extended timeout
const permit2Receipt = await ethereum.handleTransactionExecution(txResponse);
const permit2Outcome = await ethereum.handleTransactionConfirmation(txResponse);

if (!permit2Receipt) {
if (!permit2Outcome.confirmed) {
throw new Error('Permit2 transaction timed out or failed to get receipt');
}

logger.info(`Permit2 approval transaction confirmed: ${permit2Receipt.transactionHash}`);
logger.info(`Permit2 approval transaction confirmed: ${permit2Outcome.signature}`);

// Update fee to include both transactions
if (permit2Receipt.gasUsed && permit2Receipt.effectiveGasPrice) {
const permit2FeeInWei = permit2Receipt.gasUsed.mul(permit2Receipt.effectiveGasPrice);
const totalFeeInWei = approval.gasUsed.mul(approval.effectiveGasPrice).add(permit2FeeInWei);
feeInEth = utils.formatEther(totalFeeInWei);
}
const permit2FeeInWei = permit2Outcome.receipt.gasUsed.mul(permit2Outcome.receipt.effectiveGasPrice);
const totalFeeInWei = approval.gasUsed.mul(approval.effectiveGasPrice).add(permit2FeeInWei);
feeInEth = utils.formatEther(totalFeeInWei);
} else {
// Regular wallet flow for Permit2 approve
const wallet = await ethereum.getWallet(address);
Expand All @@ -326,20 +326,18 @@ export async function approveEthereumToken(
);

// Wait for confirmation with extended timeout
const permit2Receipt = await ethereum.handleTransactionExecution(permit2Tx);
const permit2Outcome = await ethereum.handleTransactionConfirmation(permit2Tx);

if (!permit2Receipt || permit2Receipt.status === -1) {
if (!permit2Outcome.confirmed) {
throw new Error('Permit2 transaction timed out or failed to get receipt');
}

logger.info(`Permit2 approval transaction confirmed: ${permit2Receipt.transactionHash}`);
logger.info(`Permit2 approval transaction confirmed: ${permit2Outcome.signature}`);

// Update fee to include both transactions
if (permit2Receipt.gasUsed && permit2Receipt.effectiveGasPrice) {
const permit2FeeInWei = permit2Receipt.gasUsed.mul(permit2Receipt.effectiveGasPrice);
const totalFeeInWei = approval.gasUsed.mul(approval.effectiveGasPrice).add(permit2FeeInWei);
feeInEth = utils.formatEther(totalFeeInWei);
}
const permit2FeeInWei = permit2Outcome.receipt.gasUsed.mul(permit2Outcome.receipt.effectiveGasPrice);
const totalFeeInWei = approval.gasUsed.mul(approval.effectiveGasPrice).add(permit2FeeInWei);
feeInEth = utils.formatEther(totalFeeInWei);
}

logger.info(
Expand All @@ -349,7 +347,10 @@ export async function approveEthereumToken(

return {
signature: approval.hash,
status: approval.status ?? -1,
// Every path that reaches here confirmed: the helper throws on a revert and the
// branches above bail out while a transaction is still pending. `approval.status ?? -1`
// used to report a confirmed Ledger approval (which never carried a status) as FAILED.
status: TransactionStatus.CONFIRMED,
data: {
tokenAddress: fullToken.address,
spender: isUniversalRouter ? universalRouterAddress || spenderAddress : spenderAddress,
Expand Down
63 changes: 18 additions & 45 deletions src/chains/ethereum/routes/poll.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { ethers } from 'ethers';
import { FastifyPluginAsync, FastifyInstance } from 'fastify';

import { PollRequestType, PollResponseType, PollResponseSchema } from '../../../schemas/chain-schema';
import {
PollRequestType,
PollResponseType,
PollResponseSchema,
TransactionStatusCode,
} from '../../../schemas/chain-schema';
import { getConnector } from '../../../services/connection-manager';
import { logger } from '../../../services/logger';
import { Ethereum } from '../ethereum';
Expand Down Expand Up @@ -36,57 +41,25 @@ export async function pollEthereumTransaction(
const ethereum = await Ethereum.getInstance(network);

const currentBlock = await ethereum.getCurrentBlockNumber();
let txData = await ethereum.getTransaction(signature);
const txData = await ethereum.getTransaction(signature);
let txBlock, txReceipt, txStatus;
if (!txData) {
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 1000;
let retryCount = 0;

while (retryCount < MAX_RETRIES) {
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
txData = await ethereum.getTransaction(signature);
if (txData) break;
retryCount++;
}

if (!txData) {
// tx not found after retries
logger.info(`Transaction ${signature} not found in mempool or does not exist after ${MAX_RETRIES} retries.`);
txBlock = -1;
txReceipt = null;
txStatus = -1;
}
}

if (txData) {
// Unknown to the node: never received or dropped. eth_getTransactionByHash
// returns mempool transactions, so not-found is distinct from pending.
logger.info(`Transaction ${signature} not found in mempool or on-chain.`);
txBlock = -1;
txReceipt = null;
txStatus = TransactionStatusCode.NOT_FOUND;
} else {
txReceipt = await ethereum.getTransactionReceipt(signature);
if (txReceipt === null) {
// tx is in the mempool
// In the mempool, awaiting inclusion
txBlock = -1;
txReceipt = null;

// In stateless approach, we simply check if the transaction is still pending
// We use a basic status code of 0 for pending transactions in mempool
txStatus = 0;

// Check if transaction is likely to be processed based on gas price
if (txData.gasPrice) {
const currentGasPrice = await ethereum.estimateGasPrice();
// Convert current gas price from GWEI to wei for comparison
const currentGasPriceWei = currentGasPrice * 1e9;
// If the transaction's gas price is significantly lower than current gas price,
// it might be stuck (status 3), otherwise it's likely to be processed (status 2)
if (txData.gasPrice.toNumber() < currentGasPriceWei * 0.8) {
txStatus = 3; // Likely stuck
} else {
txStatus = 2; // Likely to be processed
}
}
txStatus = TransactionStatusCode.PENDING;
} else {
// tx has been processed
txBlock = txReceipt.blockNumber;
txStatus = typeof txReceipt.status === 'number' ? 1 : -1;
// Receipt status 0 = reverted, 1 = success (undefined only pre-Byzantium)
txStatus = txReceipt.status === 0 ? TransactionStatusCode.FAILED : TransactionStatusCode.CONFIRMED;

// decode logs
if (connector) {
Expand Down
Loading
Loading