Skip to content
Merged
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
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,11 @@ When touching transaction and position flows, validation MUST include all releva
21. **Modal UX integrity**: transaction-modal help/fee tooltips must render above modal layers via shared tooltip z-index chokepoints, and per-flow input mode toggles (for example target LTV vs amount) must persist through shared settings across modal reopen.
22. **Chain-scoped identity integrity**: all market/token/route identity checks must be chain-scoped and use canonical identifiers (`chainId + market.uniqueKey` or `chainId + address`), including matching, dedupe keys, routing, and trust/allowlist gates.
23. **Bundler residual-asset integrity**: any flash-loan transaction path that routes assets through Bundler/adapter balances (Bundler V2, GeneralAdapter, ParaswapAdapter) must end with explicit trailing sweeps of both loan and collateral tokens to the intended recipient across leverage/deleverage and swap/ERC4626 paths, and must keep execute-time slippage bounds consistent with quote-time slippage settings.
24. **Swap execution-field integrity**: for Velora/Paraswap routes, hard preview and execution guards must validate execution-authoritative fields only (trusted target, exact sell amount, min-out / close floor, token identities). Do not block flows on echoed route metadata such as quoted source or quoted destination amounts when calldata checks already enforce the executable bounds.
25. **Deterministic flash-loan asset floors**: when a no-swap ERC4626 redeem/withdraw leg is the source of flash-loan repayment assets, its execute-time minimum asset bound must be at least the flash-loan settlement amount itself; do not apply swap-style slippage floors that allow the callback to under-return assets and fail only at final flash-loan settlement.
26. **Deterministic ERC4626 quote/execution matching**: when a no-swap ERC4626 leverage leg uses vault previews, the execute-time operation must match the preview semantics exactly: `previewDeposit` should map to exact-asset deposit with the quoted share floor, and `previewMint` should map to exact-share mint with the quoted asset cap. Do not reuse swap-style slippage floors on either path.
27. **Transaction-tracking preflight integrity**: do not call `tracking.start(...)` until all synchronous preflight validation for the flow has passed (account, route, quote, input, fee viability). Once tracking has started, execution helpers must either complete successfully or throw so the caller can finish the lifecycle with exactly one `tracking.complete()` or `tracking.fail()`.
28. **Close-route collateral handoff integrity**: when a deleverage projection derives an exact close-bound collateral amount for full-repay-by-shares, route-specific executors must receive and use that quote-derived close bound explicitly for withdraw/redeem steps instead of relying on the raw user input amount. Any remaining collateral must be returned through the dedicated post-close withdraw/sweep path.


### REQUIRED: Regression Rule Capture
Expand Down
38 changes: 21 additions & 17 deletions app/tools/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { useMorphoAuthorization } from '@/hooks/useMorphoAuthorization';
import { useStyledToast } from '@/hooks/useStyledToast';
import { toUserFacingTransactionErrorMessage } from '@/utils/transaction-errors';
import { getBundlerV2, MONARCH_TX_IDENTIFIER } from '@/utils/morpho';
import { getNetworkName, SupportedNetworks } from '@/utils/networks';
import { getNetworkName, type SupportedNetworks } from '@/utils/networks';
import NetworkFilter from '@/features/markets/components/filters/network-filter';

export default function ToolsPage() {
Expand Down Expand Up @@ -159,14 +159,16 @@ export default function ToolsPage() {
}) + MONARCH_TX_IDENTIFIER) as `0x${string}`,
value: 0n,
chainId: selectedChainId,
}).catch((error: unknown) => {
const userFacingMessage = toUserFacingTransactionErrorMessage(error, 'Failed to submit Bundler V2 asset sweep.');
if (userFacingMessage !== 'User rejected transaction.') {
toast.error('Asset sweep failed', userFacingMessage);
}
}).finally(() => {
setIsSweepConfirmModalOpen(false);
});
})
.catch((error: unknown) => {
const userFacingMessage = toUserFacingTransactionErrorMessage(error, 'Failed to submit Bundler V2 asset sweep.');
if (userFacingMessage !== 'User rejected transaction.') {
toast.error('Asset sweep failed', userFacingMessage);
}
})
.finally(() => {
setIsSweepConfirmModalOpen(false);
});
};

const getInputClassName = () => {
Expand Down Expand Up @@ -277,9 +279,7 @@ export default function ToolsPage() {
Executes via Bundler V2 multicall on the selected network:
<code className="mx-1 rounded bg-hovered px-1 py-0.5 text-xs">erc20Transfer(asset, yourWallet, maxUint256)</code>.
</p>
<p className="text-sm text-red-500">
⚠️ This transfers only what Bundler V2 already holds for the asset address.
</p>
<p className="text-sm text-red-500">⚠️ This transfers only what Bundler V2 already holds for the asset address.</p>
</div>

<div className="flex flex-col gap-3">
Expand All @@ -292,7 +292,10 @@ export default function ToolsPage() {
/>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="bundler-asset-address" className="text-xs text-secondary">
<label
htmlFor="bundler-asset-address"
className="text-xs text-secondary"
>
Asset address
</label>
<input
Expand All @@ -315,9 +318,7 @@ export default function ToolsPage() {
<p>
Network: {getNetworkName(selectedChainId)} ({selectedChainId})
</p>
<p>
Bundler V2: {bundlerV2Address === zeroAddress ? 'Not configured' : bundlerV2Address}
</p>
<p>Bundler V2: {bundlerV2Address === zeroAddress ? 'Not configured' : bundlerV2Address}</p>
<p>Recipient: {account ?? 'Connect wallet'}</p>
</div>

Expand Down Expand Up @@ -360,7 +361,10 @@ export default function ToolsPage() {
</div>
</ModalBody>
<ModalFooter>
<Button variant="default" onClick={onClose}>
<Button
variant="default"
onClick={onClose}
>
Cancel
</Button>
<Button
Expand Down
13 changes: 5 additions & 8 deletions src/features/swap/api/velora.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,14 +365,11 @@ export const buildVeloraTransactionPayload = async ({
});
}

const quotedSourceAmount = parseVeloraBigIntField(priceRoute.srcAmount, 'priceRoute.srcAmount', priceRoute);
if (quotedSourceAmount !== srcAmount) {
throw new VeloraApiError('Velora route source amount does not match the requested transaction source amount', 400, {
requestedSourceAmount: srcAmount.toString(),
quotedSourceAmount: quotedSourceAmount.toString(),
priceRoute,
});
}
// The transaction request's `srcAmount` is the execution-authoritative sell amount.
// Some routes can echo non-authoritative source metadata while still producing calldata
// with the exact requested sell amount, so we validate the built payload downstream
// instead of rejecting here on route metadata alone.
parseVeloraBigIntField(priceRoute.srcAmount, 'priceRoute.srcAmount', priceRoute);

const query = new URLSearchParams();
if (ignoreChecks) {
Expand Down
163 changes: 163 additions & 0 deletions src/hooks/deleverage/deleverageWithErc4626Redeem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { type Address, encodeAbiParameters, encodeFunctionData, maxUint256 } from 'viem';
import morphoBundlerAbi from '@/abis/bundlerV2';
import {
type EnsureBundlerAuthorization,
type MorphoMarketParams,
type SendBundlerTransaction,
sleep,
} from '@/hooks/leverage/transaction-shared';
import type { Erc4626LeverageRoute } from '@/hooks/leverage/types';
import { MONARCH_TX_IDENTIFIER } from '@/utils/morpho';
import type { Market } from '@/utils/types';
import { type DeleverageStepType, getDeleverageRepayBounds } from './transaction-shared';

type DeleverageWithErc4626RedeemParams = {
account: Address;
autoWithdrawCollateralAmount: bigint;
bundlerAddress: Address;
/**
* Exact market collateral-share amount to route through the repay/redeem leg.
* On full-close-by-shares this must come from the quote-derived close bound, not the raw input field.
*/
collateralToRedeem: bigint;
ensureBundlerAuthorization: EnsureBundlerAuthorization;
flashLoanAmount: bigint;
isBundlerAuthorized: boolean | undefined;
market: Market;
marketParams: MorphoMarketParams;
repayBySharesAmount: bigint;
route: Erc4626LeverageRoute;
sendTransactionAsync: SendBundlerTransaction;
updateStep: (step: DeleverageStepType) => void;
useCloseRoute: boolean;
useSignatureAuthorization: boolean;
};

export const deleverageWithErc4626Redeem = async ({
account,
autoWithdrawCollateralAmount,
bundlerAddress,
collateralToRedeem,
ensureBundlerAuthorization,
flashLoanAmount,
isBundlerAuthorized,
market,
marketParams,
repayBySharesAmount,
route,
sendTransactionAsync,
updateStep,
useCloseRoute,
useSignatureAuthorization,
}: DeleverageWithErc4626RedeemParams): Promise<void> => {
const txs: `0x${string}`[] = [];

if (useSignatureAuthorization) {
if (!isBundlerAuthorized) {
updateStep('authorize_bundler_sig');
}

const { authorized, authorizationTxData } = await ensureBundlerAuthorization({ mode: 'signature' });
if (!authorized) {
throw new Error('Failed to authorize Bundler via signature.');
}
if (isBundlerAuthorized && authorizationTxData) {
throw new Error('Authorization state changed. Please retry deleverage.');
}
if (authorizationTxData) {
txs.push(authorizationTxData);
await sleep(700);
}
} else if (!isBundlerAuthorized) {
updateStep('authorize_bundler_tx');
const { authorized } = await ensureBundlerAuthorization({ mode: 'transaction' });
if (!authorized) {
throw new Error('Failed to authorize Bundler via transaction.');
}
}

const { bundlerV2RepaySlippageAmount } = getDeleverageRepayBounds({
flashLoanRepayAssets: flashLoanAmount,
repayBySharesAmount,
useRepayByShares: useCloseRoute,
});

// No swap slippage exists on the ERC4626 redeem path.
// This redeem leg must return at least the flash-loan settlement amount or the whole bundle
// would revert later during flash-loan repayment. Extra loan assets from the buffered close
// amount are swept back to the user, while any remaining collateral is withdrawn separately below.
const minLoanAssetsOut = flashLoanAmount;
const callbackTxs: `0x${string}`[] = [
encodeFunctionData({
abi: morphoBundlerAbi,
functionName: 'morphoRepay',
args: [
marketParams,
useCloseRoute ? 0n : flashLoanAmount,
useCloseRoute ? repayBySharesAmount : 0n,
bundlerV2RepaySlippageAmount,
account,
'0x',
],
}),
encodeFunctionData({
abi: morphoBundlerAbi,
functionName: 'morphoWithdrawCollateral',
// Withdraw ERC4626 shares onto the bundler because the same bundler multicall redeems them immediately.
args: [marketParams, collateralToRedeem, bundlerAddress],
}),
encodeFunctionData({
abi: morphoBundlerAbi,
functionName: 'erc4626Redeem',
args: [route.collateralVault, collateralToRedeem, minLoanAssetsOut, bundlerAddress, bundlerAddress],
}),
];

if (autoWithdrawCollateralAmount > 0n) {
callbackTxs.push(
encodeFunctionData({
abi: morphoBundlerAbi,
functionName: 'morphoWithdrawCollateral',
args: [marketParams, autoWithdrawCollateralAmount, account],
}),
);
}

const flashLoanCallbackData = encodeAbiParameters([{ type: 'bytes[]' }], [callbackTxs]);
txs.push(
encodeFunctionData({
abi: morphoBundlerAbi,
functionName: 'morphoFlashLoan',
args: [market.loanAsset.address as Address, flashLoanAmount, flashLoanCallbackData],
}),
);
// Safety net: sweep any residual loan/collateral balances from the bundler back to the user.
txs.push(
encodeFunctionData({
abi: morphoBundlerAbi,
functionName: 'erc20Transfer',
args: [market.loanAsset.address as Address, account, maxUint256],
}),
);
txs.push(
encodeFunctionData({
abi: morphoBundlerAbi,
functionName: 'erc20Transfer',
args: [market.collateralAsset.address as Address, account, maxUint256],
}),
);

updateStep('execute');
await sleep(700);

await sendTransactionAsync({
account,
to: bundlerAddress,
data: (encodeFunctionData({
abi: morphoBundlerAbi,
functionName: 'multicall',
args: [txs],
}) + MONARCH_TX_IDENTIFIER) as `0x${string}`,
value: 0n,
});
};
Loading