Organization
- @tadle
Engagement Type
Cantina Reviews
Period
-
Repositories
Findings
High Risk
3 findings
3 fixed
0 acknowledged
Medium Risk
4 findings
2 fixed
2 acknowledged
Low Risk
5 findings
2 fixed
3 acknowledged
Informational
6 findings
3 fixed
3 acknowledged
Gas Optimizations
2 findings
1 fixed
1 acknowledged
High Risk3 findings
Validator spoofing blocks victim claims indefinitely
State
Severity
- Severity: High
Submitted by
r0bert
Description
The
claimfunction insideTadleConnectorstreats the validator address supplied by the caller exactly as it treatsmsg.sender: it verifies a 24‑hour cooldown and then records the current timestamp for both parties through_updateClaimTimestamps. Because there is no authentication or consent check on the validator, an attacker can pick any arbitrary address as the validator and force the cooldown onto that address.function claim(address token, address validator, uint256 level) external nonReentrant onlySandboxAccount { _verifyClaimEligibility(msg.sender, token); _verifyClaimEligibility(validator, token); _updateClaimTimestamps(msg.sender, validator, token); _transferTokens(token, msg.sender, level);}Any user may create a sandbox account with
TadleSandBoxFactory.build, obtain the required manager signature once and then callclaim(token, victim, level)as often as desired. Each call setslastClaimTimes[victim][token]to the current timestamp, even though the victim never interacted with the protocol. The result is a repeatable, cost-effective griefing vector that indefinitely stops the victim from claiming their allocation.Recommendation
Ensure the validator parameter cannot be spoofed by arbitrary callers. Require a validator signature that binds the validator address to the claimant and request or make the function derive the validator from authenticated context (for example, store approved validator relationships on-chain and reject any call where validator is not linked to
msg.sender). This guarantees that cooldown updates only affect consenting validators.Tadle: Fixed in 11daedd.
Cantina: Fix OK.
Permanent block of withdrawal requests
State
Severity
- Severity: High
Submitted by
r0bert
Description
The ETH withdrawal workflow protects against overspending by comparing the account’s live balance against the sum of its lifetime claimed ETH and the requested withdrawal.
if (token == ethAddr && airdropAddress != address(0)) { uint256 claimedAmount = IAirdrop(airdropAddress).getUserClaimedAmount(address(this), ethAddr); require( address(this).balance >= claimedAmount + amt, "AccountManagerResolver: insufficient balance after airdrop claims" );}Airdrop implementations that record the exact ETH transferred for each claim frustrate this check: immediately after the first claim the live balance equals
claimedAmount, soclaimedAmount + amtalways exceeds the balance for any positiveamt. From that point on, every ETH withdrawal request reverts even though the wallet holds the funds.Impact: once the smart account receives ETH from the airdrop, it can no longer forward or spend the tokens, functionally locking the balance.
Recommendation
Base the guard on actual available ETH rather than cumulative inflows. For example, track the unspent portion of claimed ETH or remove the addition entirely (
require(address(this).balance >= amt, ...)). Either adjustment allows withdrawals while still ensuring the account cannot spend more than it currently holds.Tadle: Fixed in 11daedd.
Cantina: Fix OK.
Proxy upgrade functions do not verify implementation code
State
Severity
- Severity: High
Submitted by
slowfi
Description
UpgradeableProxy.initializeImplementationandupgradeTorequire non-zero implementation addresses but do not verify that those addresses contain deployed code. If an EOA or self-destructed contract is provided, the proxy could be upgraded to an invalid implementation, bricking functionality or making delegatecalls revert.Recommendation
Consider to add a code-existence check such as
require(Address.isContract(newImplementation), "UpgradeableProxy: not a contract");to both initialization and upgrade functions to ensure valid logic addresses.
Tadle: Fixed in 11daedd.
Cantina: Partially solved. The risk still exists. While
upgradeTonow enforcesnewImplementation.code.length > 0,initializeImplementationonly rejects the zero address. With empty data, an EOA or self-destructed contract can still be set as the implementation, bricking the proxy before any code check happens. To fully address this, add the same contract-code length requirement (or equivalentAddress.isContractcheck) insideinitializeImplementationprior to_setImplementation.
Medium Risk4 findings
Factory rotation breaks new sandbox claims
State
Severity
- Severity: Medium
Submitted by
r0bert
Description
Auth.setFactoryallows an admin to rotate the sandbox factory, persisting the new address on-chain without touching downstream contracts.function setFactory(address _factory) external onlyAdmin { require(_factory != address(0), "Auth: invalid factory address"); address oldFactory = factory; factory = _factory; emit FactoryUpdated(oldFactory, _factory);}TadleConnectorscaptures the factory address once during initialize and uses that cached value forever inside theonlySandboxAccountgate.function initialize(address _auth, address _factory) external onlyOwner initializer { require(_factory != address(0), "TadleConnectors: factory address cannot be zero"); auth = IAuth(_auth); factory = _factory;} modifier onlySandboxAccount() { require(factory != address(0), "TadleConnectors: factory not initialized"); require(ITadleSandBoxFactory(factory).isSandboxAccount(msg.sender), "TadleConnectors: caller is not a verified sandbox account"); _;}Once governance rotates the factory, new sandboxes are registered under the fresh address but the connectors still query
isSandboxAccounton the retired factory, so every call toclaimfrom those users reverts. Existing sandboxes keep working, yet all newly spawned accounts are denied their daily distributions, halting onboarding flows that rely onConnectors.Impact: every sandbox created after a factory rotation is permanently DoS’d from claiming via
ConnectV1Airdropuntil connectors are redeployed.Recommendation
Propagate factory rotations to
TadleConnectors. Expose a privileged setter on connectors thatAuth.setFactorycalls after updating its own state or have connectors read the current factory directly fromAuthon each check (for example,ITadleSandBoxFactory(auth.factory()).isSandboxAccount(msg.sender)) while caching nothing.Tadle: Fixed in 11daedd.
Cantina: Fix OK.
Slippage input mis-scaled, causing reverts or loose fills
State
- Acknowledged
Severity
- Severity: Medium
Submitted by
r0bert
Description
mintanddepositexpose a slippage parameter documented as “maximum allowed slippage in basis points” and pass the caller’s value straight into the helper flow. The helper contracts, however, expect that argument to be WAD-scaled (1e18 = 100%) when computing minimum accepted amounts.function mint( address tokenA, address tokenB, uint24 fee, int24 tickLower, int24 tickUpper, uint256 amtA, uint256 amtB, uint256 slippage, uint256[] calldata getIds, uint256 setId) external payable returns (string memory _eventName, bytes memory _eventParam) { MintParams memory params; { params = MintParams(tokenA, tokenB, fee, tickLower, tickUpper, amtA, amtB, slippage); } params.amtA = getUint(getIds[0], params.amtA); params.amtB = getUint(getIds[1], params.amtB); (uint256 _tokenId, uint256 liquidity, uint256 amountA, uint256 amountB) = _mint(params); ...}Inside
_mintand_addLiquiditythe input drivesgetMinAmount, which multiplies byWAD.sub(slippage).function _mint(MintParams memory params) internal returns (uint256 tokenId, uint128 liquidity, uint256 amountA, uint256 amountB){ ... uint256 _minAmt0 = getMinAmount(_token0, _amount0, params.slippage); uint256 _minAmt1 = getMinAmount(_token1, _amount1, params.slippage); ...}function getMinAmount(TokenInterface token, uint256 amt, uint256 slippage) internal view returns (uint256 minAmt) { uint256 _amt18 = amt.convertTo18(token.decimals()); minAmt = _amt18.wmul(WAD.sub(slippage)); minAmt = minAmt.convert18ToDec(token.decimals());}Supplying the documented 1% tolerance as
slippage = 100producesWAD - 100, which corresponds to a tolerance of0.00000000000000001%instead of 1%. Routine liquidity actions revert because the price must match the quoted amount almost exactly. Operators trying to “fix” the revert by raising the value (e.g., to 5,000 expecting 5%) actually shrink the tolerance even further and using large WAD-sized numbers creates a 100%+ tolerance that silently fills at terrible prices.Recommendation
Align the units between the public surface and the helper logic. Either convert the basis point input into
WADbefore callinggetMinAmount(e.g.,params.slippage = (slippage * 1e14)), or change the external documentation and interfaces to require WAD-scaled slippage values so the backend can supply the correct magnitude.Tadle: Acknowledged. Natspec comment was corrected in the
mintfunction but not in thedeposit.UpgradeableProxy allows unprotected first-time init
State
- Acknowledged
Severity
- Severity: Medium
Submitted by
slowfi
Description
The function
initializeImplementation(address newImplementation, bytes memory data)is declaredexternaland only checks that the current implementation is unset:require(_getImplementation() == address(0), "implementation already initialized");If the proxy is deployed with
_logic == address(0), any address can call this function to set the first implementation and trigger an arbitrarydelegatecallthroughAddress.functionDelegateCall(newImplementation, data). This effectively allows an attacker to install malicious logic and execute arbitrary code in the proxy’s storage context before the legitimate owner performs initialization.A successful attacker can permanently compromise the proxy by setting a hostile implementation that can seize funds, corrupt storage, or disable functionality. Even though the
ownervariable remains intact, the attacker-controlled logic can overwrite or misuse state variables, leading to complete loss of control. This is a direct unauthorized-upgrade vector that grants full control over affected proxies, impacting both integrity and availability.Recommendation
Consider to restrict
initializeImplementationwithonlyOwneror remove it entirely. A safer design is to provide the initial implementation and optional initialization data directly in the constructor to ensure setup is atomic. If keeping the function, require it to be callable only by the owner and ensure the factory never deploys a proxy with a zero implementation. Optionally, add anupgradeToAndCallfunction guarded byonlyOwnerto allow safe one-step upgrades with initialization.Tadle: Acknowledged.
Missing contract code check in TadleImplementations
State
Severity
- Severity: Medium
Submitted by
slowfi
Description
TadleImplementations.setDefaultImplementationandaddImplementationonly check for non-zero addresses but do not verify that the provided addresses contain code. Registering EOAs or destroyed contracts could break routing, returning invalid implementation targets and causing transaction reverts or malfunctioning proxies.Recommendation
Consider to validate that all implementation addresses contain code using
Address.isContract(newImplementation)(or an equivalent check) before registration.Tadle: Fixed in 11daedd.
Cantina: Partially solved. The registry now guards
addImplementationwithrequire(_implementation.code.length > 0, "Implementations: not a contract");, so new entries can’t be EOAs. HoweversetDefaultImplementationstill only rejects the zero address and doesn’t verify deployed code. An admin could set the default to an EOA or wiped contract and break routing. To fully resolve this issue, add the same.code.length > 0(orAddress.isContract) check tosetDefaultImplementation.
Low Risk5 findings
Unrestricted check-ins skew reward tracking
State
- Acknowledged
Severity
- Severity: Low
Submitted by
r0bert
Description
The
checkInfunction is intended for sandbox accounts only but the implementation omits theonlySandboxAccountgate. Any address may invoke the function and emitUserCheckIn, even if it is unrelated to the sandbox system.function checkIn() external { uint256 lastCheckIn = lastCheckInTimes[msg.sender]; if (lastCheckIn > 0) { uint256 daysSinceLastCheckIn = (block.timestamp - lastCheckIn) / 1 days; require(daysSinceLastCheckIn >= 1, "TadleConnectors: daily check-in limit reached"); } lastCheckInTimes[msg.sender] = block.timestamp; emit UserCheckIn(msg.sender, block.timestamp);}Off-chain services that rely on these events for reward distribution or engagement metrics cannot distinguish real sandbox users from arbitrary accounts, so malicious actors can spam check-ins and distort campaign analytics.
Recommendation
Protect
checkInwith the same sandbox verification as claim, for example by adding theonlySandboxAccountmodifier or an equivalent access-control check before updating state or emitting the event. This ensures only registered sandboxes contribute to engagement tracking.Tadle: Acknowledged.
Constructor sets state on implementation
State
- Acknowledged
Severity
- Severity: Low
Submitted by
slowfi
Description
TadleConnectorsusesconstructor() Ownable(msg.sender). In an upgradeable architecture, persistent state lives in the proxy, and logic-contract constructors do not execute for the proxy. As a result, ownership and state set in the constructor exist only in the implementation’s storage. Likewise, “public” variables and getters on the implementation address return default values, not the proxy’s actual state. This can mislead operators and tools inspecting state directly at the implementation address. The same pattern appears across other proxied logic contracts such asAuth,TadleImplementations,Validator, andTadleMemory.Recommendation
Consider to adopt upgradeable patterns where setup occurs through an initializer function called via the proxy. Constructors in logic contracts should only call
_disableInitializers()to prevent accidental local initialization. This approach ensures ownership and state are properly recorded in proxy storage while avoiding confusion and misconfiguration when reading or deploying logic contracts.Tadle: Acknowledged.
Missing storage gap in upgradeable base
State
Severity
- Severity: Low
Submitted by
slowfi
Description
Setupserves as a base contract for proxied implementations such asTadleImplementations, but it lacks a storage gap. In upgradeable contracts, adding a fixed-size gap at the end of base contracts preserves storage layout compatibility when new variables are introduced in future versions. Without a gap, adding state variables later can shift existing storage slots, potentially corrupting proxy data and breaking upgrade safety.Recommendation
Consider to add a storage gap (e.g.,
uint256[50] private __gap;) at the end ofSetupand other upgradeable base contracts. This allows appending new variables in future upgrades without shifting existing storage and helps maintain consistent layout across contract versions.Tadle: Fixed in 11daedd.
Cantina: Fix OK.
Use constant for connector name
State
Severity
- Severity: Low
Submitted by
slowfi
Description
string public name = "WETH-v1.0.0";stores the identifier in a storage slot. For static, immutable metadata, a constant avoids a storage write/read, slightly lowers deploy/runtime gas, and reduces storage-collision risk underdelegatecall, while keeping behavior identical. This aligns with other connectors that use fixed identifiers (e.g., Uniswap routers, position helpers, AccountManager).Recommendation
Consider to return a compile-time constant instead of a storage variable, e.g.:
function name() external pure returns (string memory) { return "WETH-v1.0.0";}(or expose
string public constant NAME = "WETH-v1.0.0";if callers can useNAME()instead ofname()). If you want, I’ll keep this as OOS/context-only unless you prefer it included in the report.Tadle: Fixed in 11daedd.
Cantina: Fix OK.
Admin can call user logic through proxy
State
- Acknowledged
Severity
- Severity: Low
Submitted by
slowfi
Description
UpgradeableProxyallows the owner to both manage upgrades and call user-facing functions through the same address. Since there is no transparent-proxy check to block admin calls from reaching the fallback, the owner could unintentionally trigger proxied logic instead of performing administrative actions. This does not directly impact security but increases the chance of operational errors and complicates monitoring.Recommendation
Consider to implement a transparent-proxy pattern that prevents the admin from accessing user logic, or enforce operational separation between admin and user wallets with clear documentation.
Tadle: Acknowledged.
Informational6 findings
Dual cooldown trackers allow double rewards
State
Severity
- Severity: Informational
Submitted by
r0bert
Description
TadleConnectors.claimenforces its 24‑hour cooldown through thelastClaimTimesmapping, while the public airdrop route useslastTokenReceiveTimes. These state variables never interact, so claiming through one path leaves the other timer untouched. The inline comment asserts the timestamps are shared, but the code updates a different mapping:// Update recipient's last claim timestamp (shared between both claim types to prevent double claiming)lastTokenReceiveTimes[msg.sender][token] = block.timestamp;A sandbox account can call
airdrop(token)to populatelastTokenReceiveTimes, then immediately invokeclaim(token, validator, level)to claim again becauselastClaimTimesstill passes its eligibility check.Impact: users can repeatedly collect both payouts inside every intended cooldown window, inflating token distribution.
Recommendation
Unify the cooldown bookkeeping so every reward path reads and updates a single timestamp per (user, token) pair. Either store all cooldowns in
lastClaimTimesand have both claim paths update that mapping, or refactor to a shared modifier that checks and sets a common cooldown variable before transferring tokens.Tadle: Fixed in 11daedd.
Cantina: Fix OK.
Fixed price limit disables Ambient trade protections
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
r0bert
Description
The Ambient connector’s trade path claims to set conservative price limits, yet
_encodeTradeCommandhardcodes the limit to near-protocol extremes so the check never bites. Every buy call receivespriceLimit = 21267430153580247136652501917186561137, while sells usepriceLimit = 65538, values that map close to Ambient’s maximum and minimum ticks.function _encodeTradeCommand( address base, address quote, uint256 poolIndex, uint256 amountIn, uint256 amountOutMin, bool buyBase) internal pure returns (bytes memory) { uint128 priceLimit = buyBase ? uint128(21267430153580247136652501917186561137) : uint128(65538); return abi.encode( base, quote, poolIndex, buyBase, buyBase, uint128(amountIn), uint16(0), priceLimit, uint128(amountOutMin), 0 );}Because the limit sits essentially at Ambient’s boundaries, swaps execute at any price unless
amountOutMincatches the move. Operators who rely on the advertised limit run trades without meaningful protection, so a misconfiguredamountOutMinleaves users exposed to heavily slippage-prone fills.Marking this issue as informational as still, the
amountOutMinparameter can be used to prevent slippage.Recommendation
Derive
priceLimitfrom real market parameters (e.g., current tick ± slippage tolerance) or accept a validated limit from the caller, ensuring Ambient’s native guardrails actually cap execution prices.Tadle: Acknowledged.
Ambient buy flow withholds quote token approvals
State
Severity
- Severity: Informational
Submitted by
r0bert
Description
The Ambient connector’s buy routine always approves the base token before executing the trade, ignoring the fact that base purchases must spend the quote asset.
function buy( uint16 callPath, address base, address quote, uint256 poolIndex, uint256 amountIn, uint256 amountOutMin, bool buyBase, uint256 getIds, uint256 setIds) external returns (string memory _eventName, bytes memory _eventParam) { amountIn = getUint(getIds, amountIn); if (base != NATIVE_TOKEN) { approve(base); } bytes memory cmd = _encodeTradeCommand( base, quote, poolIndex, amountIn, amountOutMin, buyBase ); bytes memory res = _executeCommand( callPath, cmd, base == NATIVE_TOKEN ? amountIn : 0 ); ...}When
buyBaseistrue, the sandbox must provide quote tokens, yet the allowance stays zero. Every ERC‑20 quote purchase therefore reverts at Ambient due to a missing approval, leaving only native ETH flows functional.Recommendation
Approve the token that will actually be debited. Conditionally call
approve(quote)whenbuyBaseistrue(and quote is not the native token), while retaining the currentapprove(base)path for sells that spend the base token.Tadle: Fixed in 11daedd.
Cantina: Fix OK.
TadleImplementations does not inherit AccountImplementations interface
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
slowfi
Description
AccountProxyexpects an implementation registry conforming to:interface AccountImplementations { function getImplementation(bytes4 _sig) external view returns (address);}TadleImplementationsprovides a compatiblegetImplementation(bytes4)but the contract declaration is:contract TadleImplementations is Ownable2Step, Implementations { ... }Without explicitly inheriting
AccountImplementations, the type guarantee is weaker: the compiler won’t enforce interface conformance, and external integrators or proxies that are typed againstAccountImplementationswon’t get compile-time checks thatTadleImplementationssatisfies the interface.This has no runtime gas cost, but improves type safety, documentation, and integration clarity.
Recommendation
Consider to add the interface to the inheritance list:
contract TadleImplementations is Ownable2Step, Implementations, AccountImplementations { // getImplementation(bytes4) already matches the required signature}Optionally, add a short NatSpec note that this contract is intended to be consumed by
AccountProxy(or any proxy) via theAccountImplementationsinterface.Tadle: Acknowledged.
TadleSandBoxFactory mimics an upgradeable pattern in a non-proxied deployment
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
slowfi
Description
TadleSandBoxFactoryis deployed as a regular contract (it has a constructor setting the owner), but it exposes an externalinitialize(address _auth, address _accountProxy) onlyOwnerthat sets core dependencies. This pseudo-upgradeable pattern adds ambiguity: there is no initializer guard (it can be called multiple times by the owner), and tooling/readers may assume a proxy context when there isn’t one. Since the contract is not behind a proxy, the dependencies can be set more clearly via constructor parameters or explicit setters.Recommendation
Consider to remove
initializeand either:Constructor wiring: take
_authand_accountProxyin the constructor and set them once at deployment; or Explicit setters: replace withsetAuth(address)andsetAccountProxy(address)guarded byonlyOwner, each emitting events (e.g.,AuthUpdated,AccountProxyUpdated).Consider also to retain the existing non-zero validations, add events, and decide whether reconfiguration should be allowed (setters) or prevented (constructor-only).
Tadle: Acknowledged.
Public initializer allows arbitrary name/symbol
State
Severity
- Severity: Informational
Submitted by
slowfi
Description
MonUSD.initialize(string __name, string __symbol)ispublicand guarded only by a localinitializedflag. If the proxy (or the implementation when called directly) is deployed without atomically invokinginitialize, any address can call it first to set_nameand_symbol, then lock the contract by flippinginitialized = true.Recommendation
Consider to restrict
initializetoonlyOwnerand ensure it is invoked atomically during deployment (e.g., via proxy constructor data or an owner-guarded upgrade-and-call). If you plan to keep an upgradeable pattern, consider adopting OZ-style upgradeable bases (Initializable,Ownable2StepUpgradeable) and disable local initialization on the implementation to avoid accidental calls.Tadle: Fixed in 11daedd.
Cantina: Fix OK.
Gas Optimizations2 findings
Usage of custom errors
State
- Acknowledged
Severity
- Severity: Gas optimization
Submitted by
slowfi
Description
At
Auth.initialize(...), the checkrequire(_admin != address(0), "Auth: invalid admin address");uses a string-based revert reason. Across this codebase, similar patterns appear (e.g.,
UpgradeableProxy,Implementations,Factory,Validator,TadleMemory,Connectors,AccountProxy, etc.). Custom errors are more gas-efficient (short, selector-based) and provide structured revert data that tools can decode reliably. This change is behavioral-equivalent in success paths and improves maintainability and consistency.Recommendation
Consider to define and use custom errors, e.g.:
error InvalidAdminAddress(); function initialize(address _admin) external onlyOwner initializer { if (_admin == address(0)) revert InvalidAdminAddress(); // ...}Tadle: Acknowledged.
Assembly blocks not marked memory-safe
State
Severity
- Severity: Gas optimization
Submitted by
slowfi
Description
In
TadleSandBoxFactory.createClone(around theassemblyat line 39), the inline assembly isn’t annotated as memory-safe. Usingassembly ("memory-safe") { ... }tells the compiler your block won’t clobber Solidity’s free-memory pointer, which can enable minor optimizer wins and avoids subtle integration bugs. The earlier suggestion to rewrite around scratch memory isn’t required here—the key improvement is simply adding the memory-safe annotation. Apply the same annotation to any other inline assembly blocks in this codebase (e.g., factory/proxy delegatecall paths) for consistency.Recommendation
Consider to wrap inline assembly with
assembly ("memory-safe") { ... }. If the block writes to memory beyond the scratch region, ensure you either preserve/update the free-memory pointer correctly or keep writes confined to scratch to uphold the memory-safe contract.Tadle: Fixed in 11daedd.
Cantina: Fix OK.