Organization
- @morpho
Engagement Type
Cantina Reviews
Period
-
Repositories
Findings
Medium Risk
1 findings
0 fixed
1 acknowledged
Low Risk
11 findings
4 fixed
7 acknowledged
Informational
5 findings
4 fixed
1 acknowledged
Medium Risk1 finding
Missing version pinning could result in supply chain attacks
State
- Acknowledged
Severity
- Severity: Medium
Submitted by
m4rio
Description
All
morpho-sdk
packages are published with caret (^
) version ranges independencies
,peerDependencies
, and theworkspace:^
placeholders that are expanded during publishing:"dependencies": { "mutative": "^1.1.0" },"peerDependencies": { "@morpho-org/blue-sdk": "workspace:^", "@morpho-org/morpho-ts": "workspace:^"}
Caret ranges allow any newer minor or patch release with the same major version to be installed. This means that:
- A fresh install, a partially-regenerated lock-file, or the use of npm/yarn instead of pnpm can silently pick up newer code.
- Down-stream projects that depend on these SDKs but maintain their own lock-files will resolve the latest matching versions.
The result is a supply-chain risk: a faulty or malicious minor update of
blue-sdk
,morpho-ts
, or another dependency could change off-chain bundle logic, redirect approvals, or introduce other unsafe behavior without any code change in the integrator’s repository.Recommendation
-
Replace caret (
^
) andworkspace:^
ranges with exact versions independencies
andpeerDependencies
—for example"@morpho-org/blue-sdk": "2.0.0"
-
Bump these versions explicitly when a new release is reviewed and accepted.
-
Keep the lock-file committed, but treat it as a secondary safeguard.
-
Add a CI check that fails if a published
package.json
still contains^
,~
, orworkspace:
ranges. -
Optionally include the
packageManager
field ("[email protected]"
) to discourage installation with other clients.
Pinning versions ensures every dependency update undergoes review, closing the silent supply-chain upgrade path.
Low Risk11 findings
Missing checks throughout the simulation-sdk
Description
The
simulation-sdk
represents a dry-run of a suit of operations that would be bundled together in theBundler3
. In theory the simulator should mimic the onchain behavior, including all the checks plus should add extra checks that would protect the user from a mistake in his endavor to bundle multiple transactions at once. We've enumerated the checks as follow:- The
slippage
parameter is used in various operations but we do not have any check to make sure this slippage is not negative or it's over the WAD value. E.g. SimulationState.ts?lines=358,358 in thegetBundleMaxBalance
where we apply the slippage when we convert to wrapped tokens. This is just one instance, the slippage parameter is used kinda everywhere across the simulation. - When we borrow/withdraw/withdrawCollateral we should revert if the sender it's not authorized to modify
onBehalf
's position, we only do this if the general adapter is the sender. e.g. borrow.ts?lines=24,29 - The
permit2
simulation does not check if the expiration is lower thanblock.timestamp
permit2.ts?lines=14,14
Recommendation
Consider adding these extra checks. Furthermore, while we tried to highlight some of the missing checks, we recommend to go over the onchain code and the simulation and make sure all the checks are implemented in the simulation as well.
- The
Reallocating assets should be conservative when we look at the vault's cap
Finding Description
The
getMarketPublicReallocations
function calculates the public reallocations required to reach the maximum available liquidity, based on a specific reallocation algorithm. This algorithm is implemented in_getMarketPublicReallocations
and operates by iterating through available vaults and their withdrawal queues. It calculates the maximum amount that can be safely reallocated from each source market to a destination market, while respecting various constraints such as market caps, utilization targets, and configured limits. For each vault, the algorithm identifies the most liquid source market (i.e., the one with the highest available assets for withdrawal) and creates a reallocation operation to move those assets to the destination market.The algorithm sorts vaults by their reallocatable liquidity in descending order and recursively processes these reallocations, simulating each operation to ensure the system remains within its defined parameters. The process includes checks for market caps, utilization rates, maximum inflow/outflow limits, and accrued interest, ultimately optimizing liquidity distribution across the protocol.
To conservatively compute the reallocatable assets, interest is accrued one hour in advance:
const suppliable = cap - data .getAccrualPosition(vault, marketId) .accrueInterest(this.block.timestamp + delay).supplyAssets;
We can see that we are missing an important aspect: if a
pendingCap
is valid within the next hour, it is not currently included in the algorithm and we are using the current cap that is active. A market may have a cap that is pending and set to be applied after a specific duration (MetaMorpho.sol#L472-L480).This would cause the algorithm to inefficient calculate the assets that could be reallocated if a cap that is lower than the :
- srcPosition.supplyAssets,
- targetUtilizationLiquidity
- publicAllocatorConfig?.maxIn
- publicAllocatorConfig?.maxOut
In the case of the bundler, the transaction would revert if the pending cap will be applied because the cap would be lower than what we try to reallocate, so no funds would be lost, but we must treat the simulation as an sdk that could be used in various usecase.
Recommendation
Consider using
min(cap, pendingCap)
if thependingCap
will be available within the next hour.The holdings do not exclude tokens that can not be transferred
State
- Acknowledged
Severity
- Severity: Low
Submitted by
m4rio
Finding Description
The
getHolding
function returns a snapshot of the current holdings of a specific token for a user. TheHolding
object includes the following field:/** * Whether the user is allowed to transfer this holding's balance. */public canTransfer?: boolean;
This indicates whether the user is allowed to transfer the balance of the holding.
Currently, this field is not taken into account, and the holding is considered valid even if the user cannot transfer it. This may result in the simulation counting an underfunded balance for the user.
This function is used across various logic, so the impact can vary depending on the use case.
Recommendation
Consider excluding from holdings any tokens marked as non-transferrable. After discussing with the team, it was acknowledged that this is typically used for permissioned tokens. However, we still recommend adding a flag to optionally exclude such tokens when a specific call requires it.
Single slippage value used for different conversions
State
- Acknowledged
Severity
- Severity: Low
Submitted by
m4rio
Description
SimulationState.getBundleAssetBalances
uses a singleslippage
value through every conversion that can occur while estimating the maximum amount of a target token. For example, when the target is wstETH the helper may chain up to four different transformations:- wstETH → wstETH (direct balance)
- stETH → wstETH
- ETH → stETH → wstETH
- WETH → ETH → stETH → wstETH
Each hop has its own exchange rate and liquidity profile, yet the same slippage tolerance used at every step. A single parameter cannot capture the risk distribution across:
- protocol‐native wraps / unwraps (stETH ↔ wstETH)
- staking contracts (ETH → stETH)
- ERC-20 wrappers
Consequences:
-
Max-balance calculations may be overly optimistic on one leg and overly pessimistic on another, leading to:
- Bundles that revert on-chain because the real output is smaller than simulated.
- Missed liquidity opportunities when the simulator underestimates what can be supplied or withdrawn.
-
Future support for ERC-4626 wrappers or other multi-hop paths would inherit the same inaccuracy.
Recommendation
- Replace the single
slippage
argument with a structure that lets callers specify per-hop tolerances, e.g.:
type HopSlippage = { unwrapNative?: bigint; // WETH -> ETH stake?: bigint; // ETH -> stETH wrapStaked?: bigint; // stETH -> wstETH generic?: bigint; // fallback }
-
Internally, apply the appropriate value at each conversion step. If none is provided, fall back to
generic
or a conservative default. -
Expose a convenience helper that translates “overall” slippage to a safe set of hop-specific values for simple use-cases.
Morpho: Acknowledged.
Cantina Managed: Acknowledged.
Blue_Paraswap_BuyDebt ignores ParaswapAdapter limitAmount
State
- Acknowledged
Severity
- Severity: Low
Submitted by
m4rio
Description
In
handlers/blue/buyDebt.ts
the swap branch pullsexactAmount
andquotedAmount
from the ParaSwap calldata but completely skips thelimitAmount
field:const exactAmountOffset = Number(offsets.exactAmount);const quotedAmountOffset = Number(offsets.quotedAmount);// limitAmountOffset is never read...amount = hexToBigInt(slice(args.swap.data, exactAmountOffset, exactAmountOffset + 32));quotedAmount = hexToBigInt(slice(args.swap.data, quotedAmountOffset, quotedAmountOffset + 32));
On-chain,
ParaswapAdapter.swap()
treatslimitAmount
(taken from offsets.limitAmount) as the maximum srcToken the trade is allowed to spend and reverts if that cap is exceeded:ParaswapAdapter.sol::buy
swap({ augustus: augustus, callData: callData, srcToken: srcToken, destToken: destToken, maxSrcAmount: callData.get(offsets.limitAmount), minDestAmount: callData.get(offsets.exactAmount), receiver: receiver });
By ignoring it the simulator:
- may treat swaps as valid even when the calldata would revert on-chain because the price has moved past the user’s limit;
- cannot warn when a bundle spends more
srcToken
than authorised;
Recommendation
-
In buyDebt.ts parse offsets.limitAmount exactly as done for exactAmount/quotedAmount.
-
When you want to perform the synthetic buy use limit amount as the max amount that gets "burned"
Missing invariant checks after a bundler being successfully executed could result in dangling approvals or residual amounts
State
- Acknowledged
Severity
- Severity: Low
Submitted by
m4rio
Description
Bundler3
today guarantees that each encoded operation succeeds, but it does not enforce any post-execution invariant that user assets (balances + approvals) are actually flushed back to the user. If a bundle finishes while:- an ERC-20 allowance to
bundler3
is still > 0 - any token balance (ETH or ERC-20) involved in the current batch remains on
Bundler3
or on one of its adapters,
then those approvals / tokens stay trapped until the user manually rescues them — a pattern that could produce losses for the user.
Because we optimise the bundle off-chain, a faulty optimisation, an unexpected callback branch, or a new adapter that is forgotten in
finalizeBundle
could silently leave residual value.Recommendation
-
Add a “safety-mode” flag (e.g.
requireClean = true
) in the SDK. When set, the encoder should automatically append a single invariant-check call as the final step of the bundle. -
Implement a minimal “InvariantChecker” function (could live inside an adapter) that reverts unless:
// for every token involved in this bundle transactionIERC20(tokenX).allowance(user, address(bundler3)) == 0 &&IERC20(tokenX).balanceOf(address(bundler3)) == 0 &&IERC20(tokenX).balanceOf(address(adapterX)) == 0 // for every known adapter
and
address(bundler3).balance before - after == 0
.
Incorrect Encoding When Both assets and shares Are Non-Zero in morphoSupply
State
- Acknowledged
Severity
- Severity: Low
Submitted by
slowfi
Description
The
morphoSupply
function withinBundlerAction.ts
constructs calldata assuming that bothassets
andshares
can be non-zero. However, according to the GeneralAdapter1 implementation, providing both as non-zero results in a revert withinconsistent input
. This inconsistency arises because the underlying Morpho protocol expects eitherassets
orshares
to be set, but not both.Currently, the SDK does not validate this constraint, which leads to malformed calldata and execution reverts during simulation or bundling.
Proof Of Concept
Given a test case with the following inputs:
{ type: "Blue_Supply", sender: client.account.address, address: morpho, args: { idMarket, assets: amount, shares: amount, onBehalf: client.account.address, slippage: DEFAULT_SLIPPAGE_TOLERANCE, },}
The resulting bundle leads to a revert with:
Execution reverted with reason: revert: inconsistent input
This occurs during a
morphoSupply
call to thegeneralAdapter1
contract with bothassets
andshares
set.Recommendation
Consider enforcing validation in the SDK logic (e.g., in
populateSubBundle
or before encoding theBlue_Supply
operation) to ensure that eitherassets
orshares
is zero before proceeding. An early guard clause or explicit invariant check can help prevent user or developer mistakes and reduce noise during testing.Alternatively, handle this condition gracefully by choosing one value to override (e.g., zeroing
shares
ifassets
is set) or returning a meaningful error from the SDK itself when both are non-zero.Long Fixed Deadline Used for Signature-Based Operations
Severity
- Severity: Low
Submitted by
slowfi
Description
In
packages/bundler-sdk-viem/src/actions.ts
, thedeadline
for signature-based operations (e.g., Permit2) is hardcoded to 24 hours:const deadline = Time.timestamp() + Time.s.from.h(24n);
This is used uniformly across the bundler SDK without consideration of the type of operation, user intent, or contextual sensitivity of the transaction. Setting such a long, fixed deadline may expose users to unnecessary risks in scenarios where:
- The user expects a short-lived permit or allowance.
- An operation could be front-run or reused within the deadline window.
- The user’s risk profile would benefit from shorter expiry (e.g., interactive UIs or automated flows).
Recommendation
Consider to allow finer control over the
deadline
parameter:- Let it be passed as an optional argument, defaulting to a conservative value (e.g., 5–15 minutes).
- Fine-tune deadlines based on the operation type (e.g., short for
transferFrom
, longer for batchedsupply
). - Optionally document the rationale behind any defaults chosen.
Reducing deadline duration or making it configurable may improve the security posture and flexibility of the SDK.
Morpho: Fixed on PR-351
Cantina Managed: Fix verified. The issue reduced the 24 hours frame to a 2 hours maximum time as per use case design.
Blue_Supply Allows Arbitrary Target Address, Leading to Misleading Bundle Construction
State
- Acknowledged
Severity
- Severity: Low
Submitted by
slowfi
Description
The
Blue_Supply
operation constructed by the SDK allows setting an arbitraryaddress
field in the input operation. In practice, this field is not enforced, and during bundling, it results in a call togeneralAdapter1.morphoSupply(...)
— which uses a hardcoded internal reference to the real Morpho address.This causes a divergence between the declared
address
in the operation and the actual contract being interacted with. As a result:- The final encoded call targets Morpho correctly via the general adapter.
- The operation in the bundle shows the incorrect address set by the user.
- Developers or downstream systems relying on the
address
field may be misled.
In the example below, the user-supplied address is
0x1234...5678
, yet the adapter still targets the correct Morpho address:{ type: 'Blue_Supply', sender: generalAdapter1, address: '0x1234567890abcdef1234567890abcdef12345678', // User-defined, incorrect args: { ... }}
The transaction succeeds due to the adapter’s internal use of the correct address, but this introduces an inconsistency that could be problematic in simulation, tracing, or signature logic.
Recommendation
Consider to:
- Either remove the
address
parameter from the Blue operations entirely during input processing. - Or validate it to match the known Morpho address, and revert or warn if mismatched.
This would enforce consistency between declared and executed behavior and reduce the risk of accidental misuse or confusion when debugging or generating signatures.
Unrevoked DAI Allowance via Permit
Severity
- Severity: Low
Submitted by
slowfi
Description
When using DAI's
permit
flow, the generated signature sets an unlimited allowance (MAX_UINT256
) for the spender (typicallygeneralAdapter1
). However, the SDK does not revoke this allowance after usage.This leads to a persistent approval that remains active beyond the current bundle, which may not be the desired behavior in many contexts, particularly when building secure, limited-scope permissioned flows.
DAI's
permit
standard only supports toggling betweenMAX_UINT256
and0
. Since the SDK does not append a follow-up signature to revoke the approval, the spender retains permanent allowance after the call.Recommendation
Consider to support an option in the SDK (e.g.
revokeAfterUse: true
) that appends a secondpermit
signature (withallowed = false
oramount = 0
) after the action completes.Alternatively, allow users to configure whether they want the default behavior to be ephemeral (i.e. revoke immediately after) or persistent.
Morpho: Fixed on PR-350
Cantina Managed: Fix verified.
Incorrect BalancerV2 offsets in the Paraswap helper
State
- Acknowledged
Severity
- Severity: Low
Submitted by
m4rio
Description
helpers/paraswap.ts
currently defines the offsets forswapExactAmountOutOnBalancerV2
as{ exactAmount: 4 + 32 × 0, limitAmount: 4 + 32 × 1 }
. This is inverted.For swapExactAmountOut the calldata layout of
BalancerV2Data
is:word field 0 fromAmount
(max src, limit amount)1 toAmount
(exact dest, exact amount)2 quotedAmount
3 metadata
4 beneficiaryAndApproveFlag
Consequently:
exactAmount = toAmount → 4n + 32n * 1nlimitAmount = fromAmount → 4n + 32n * 0n
Currently this is not used in the simulation but as being a helper, in the future it can decode the calldata wrongfully.
Furthermore, consider changing the comment on the
paraswap.ts
to the latest version of the Augustus Router which is 6.2 https://etherscan.io/address/0x6A000F20005980200259B80c5102003040001068Recommendation
Consider modifying the offsets as follows:
swapExactAmountOutOnBalancerV2: { exactAmount: 4n + 32n * 1n, // ← toAmount limitAmount: 4n + 32n * 0n, // ← fromAmount quotedAmount: 4n + 32n * 2n,},
Informational5 findings
The simulation does not take into consideration special tokens when simulates an approval
Finding Description
The simulation does not account for tokens where the allowance must be set to zero before setting a new value. Theoretically, this would revert onchain, but the simulation does not take this into consideration.
Recommendation
Theoretically, it should be fine not to simulate this, but consider adding a comment noting this behavior, as it is handled in the bundler.
Missing MAX_UINT256 handling in several Blue-handlers inside the simulation-sdk
Description
The on-chain adapters treat
2**256 - 1
as a special flag meaning “use the whole balance / debt / collateral”. Several handlers in the simulation layer ignore this flag, so whenassets
orshares
is set toMathLib.MAX_UINT_256
the dry-run diverges from real execution:If the value is taken literally, balances under- or overflow and the local simulation reverts although the real call would succeed which means the dry-run diverges from on-chain behaviour and can mask fund-draining mistakes.
File Parameter src/handlers/blue/withdrawCollateral.ts
assets
src/handlers/blue/withdraw.ts
shares
src/handlers/blue/repay.ts
assets
/shares
Recommendation
For every handler that forwards Blue contract calls:
- Detect
if (assets == MathLib.MAX_UINT_256)
(and the equivalent forshares
). - Replace it with the correct current balance, full collateral, or full debt depending on the action, exactly like the on-chain adapters do.
- Detect
Union technique can be bypassed via object spreading
Description
The SDK encodes mutually-exclusive variants with a union such as
type BlueSupplyArgs = | { assets: bigint; shares?: never } | { assets?: never; shares: bigint };
This catches literals like
{ assets: 100n, shares: 200n } // compile-time error
but it does not catch objects produced via spread/merging:
const base = { assets: 100n };const op = { type: "Blue_Supply", sender: user, args: { ...base, shares: 200n } // ✅ passes TS};
The spread causes
args
to be inferred as{ assets: bigint; shares: bigint }
, which is compatible with the union even though it violates the intended XOR rule.At runtime the handler reaches
exactlyOneZero(assets, shares)
after several other steps. Depending on the numbers supplied it may:- revert with a misleading error (e.g. “insufficient balance”), or
- proceed with an unexpected branch (only one of the values is used), causing the simulation to diverge from on-chain behaviour.
Because these objects can be generated programmatically, the bug is easy to miss in testing.
Proof of Concept
test("should demonstrate assets XOR shares safeguard bypass by spread technique", () => { // Create a base object with assets for the args property const baseArgs = { id: marketA1.id, onBehalf: userA, assets: 100n, }; // Create an operation with both assets and shares by using spread const invalidOperation = { type: "Blue_Supply", sender: userA, args: { ...baseArgs, // Adding shares to an object that already has assets shares: 200n, } }; console.log(invalidOperation); // This will print you the invalid operation like this /* { "type": "Blue_Supply", "sender": "0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa", "args": { "id": "0x042487b563685b432d4d2341934985eca3993647799cb5468fb366fad26b4fdd", "onBehalf": "0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa", "assets": "100n", "shares": "200n" } } */ // TypeScript allows this invalid operation to be created expect(invalidOperation.args).toHaveProperty("assets"); expect(invalidOperation.args).toHaveProperty("shares"); try { const simState = dataFixture; handleOperation(invalidOperation as Operation, simState); } catch (error: unknown) { console.log(error); // This will fail with insufficient balance (because we do not have balance in the users) instead of invalid input } }); });
Recommendation
-
Stronger compile-time XOR: replace the current union with a utility type that cannot be widened, e.g.
type XOR<A, B> = | (A & { [K in keyof B]?: never }) | (B & { [K in keyof A]?: never }); type BlueSupplyArgs = XOR< { assets: bigint }, { shares: bigint }>;
or make sure you always check the objects that use the union technique for assets/shares are always checked to not contain both fields.
Supply Input Allows Both assets and shares to Be Set Simultaneously
Severity
- Severity: Informational
Submitted by
slowfi
Description
In the Blue supply handler, the current validation only checks if both
assets
andshares
are zero:if (assets === 0n && shares === 0n) throw new BlueErrors.InconsistentInput();
This allows a case where both
assets
andshares
are non-zero, which may lead to ambiguity or unintended behavior during supply execution.Proof Of Concept
The following logic bypasses the check and may incorrectly interpret dual input values:
const assets = parseEther("100");const shares = 100n; await handler({ assets, shares, // ...});
This passes validation despite both values being defined, which may not be an intended use case.
Recommendation
Consider to replace the condition with:
if ((assets === 0n && shares === 0n) || (assets !== 0n && shares !== 0n)) { throw new BlueErrors.InconsistentInput();}
This ensures that exactly one of
assets
orshares
is provided.Morpho: Fixed on PR-351
Cantina Managed: Fix verified.
Lack of Documentation Across SDK Packages
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
slowfi
Description
The SDK, including packages such as
bundler-sdk-viem
,simulation-sdk
, and associated type definitions, lacks comprehensive documentation across both code-level interfaces and high-level usage flows. Core components such asAction
,ActionBundle
,populateBundle
, simulation handlers (e.g.,blue/supply.ts
), and the variousOperationType
-based abstractions are exposed without sufficient inline documentation or developer-facing references.This absence increases onboarding time and raises the likelihood of incorrect usage. Developers must often reverse-engineer behavior by inspecting source code, especially when dealing with intricate constructs such as nested callback flows, signature-based permissioning, or assumptions in simulation input/output consistency.
Recommendation
Consider to:
-
Add inline documentation (JSDoc or TypeScript comments) for exported classes, interfaces, and utility functions across all public-facing packages.
-
Provide detailed reference or example usage for complex features such as:
- Signature flows (Permit, Permit2, Dai-specific permits)
skipRevert
semantics- Nested callbacks and reallocation flows
- How simulation output maps to bundling input
-
Document known assumptions or constraints (e.g., expected simulation freshness, address validation, or bundler chainId alignment).
-
Include a package-level README or developer guide outlining intended workflows and module responsibilities.
Improving documentation across packages would reduce developer friction and help ensure correct integration of the SDK components across consumer apps and audits.