Trueo engaged Guardian to review the security of their Staking PR. From the 8th through the 10th of April, a team of 3 auditors reviewed the source code in scope.
- Published
- Rounds
- Round One, Round Two, Round Three, Round Four
- Language
- Solidity
- Chains
- Base
- Sector
- Derivatives
- 0 Critical
- 4 High
- 22 Medium
- 23 Low
- 19 Informational
Scope
Overview
Trueo engaged Guardian to review the security of their Staking PR. From the 8th through the 10th of April, a team of 3 auditors reviewed the source code in scope.
Findings 68
Round One
42 findings-
H-01 High Permissionless Markets Can Spend Global Rewards Unexpected Behavior Resolved
Description
The permissionless launch flow allows any user to submit market parameters through
LaunchpadFactory, includingrewardTokenandrewardAmount. Those values are forwarded throughTruthMarketV2LauncherintoTruthMarketManager.createMarketV2without a policy gate that binds rewards to trusted proposers, per-market caps, or pre-funded creator escrow. When a market is created,TruthMarketManagerpulls rewards from the sharedrewardWalletusingsafeTransferFrom(rewardWallet, marketAddress, rewardAmount). This means reward spending authority is delegated to any actor who can create and launch a proposal, as long as the manager has allowance from the reward wallet for the selected token. Because factory proposal creation is externally callable and the factory is granted proposer privileges in deployment config, this becomes a permissionless path to consume global reward inventory. An attacker can repeatedly create launchable markets with arbitrary reward values and force transfers from the treasury-configured reward source into attacker-chosen markets, draining or locking...Recommendation
Move reward funding from global wallet pulls to per-proposal escrow supplied by the proposal creator or a designated sponsor. If global rewards must remain supported, enforce strict governance controls on reward-bearing proposals by requiring trusted proposers, whitelisted reward tokens, and hard per-market and per-epoch limits. Add explicit validation for reward parameter coherence and reject unsupported reward sources by policy instead of relying on wallet allowance side effects.
Resolution
Trueo: Resolved.
-
H-02 High Pool Initialization Front-Run Bricks Launches Frontrunning Resolved
Description
When a deposit triggers a successful launch,
TruthMarketV2Launcher.launch()creates the market viaTruthMarketManager, then initializes both Uniswap V4 pools: Which callspoolManager.initialize(poolKey, sqrtPriceX96);. Uniswap V4's initialize reverts if the pool already exists. TheTruthMarketHookdoes not protect against this, as it setsbeforeInitialize: false. An attacker who observes a launch triggering withindeposit()can derive the resulting token addresses and pool keys, then front-run withpoolManager.initialize(poolKey, anySqrtPrice)for either pool. This attack can be utilized to block any proposal launch. Users must wait untilblock.timestamp> endTimeto withdraw without a fee.Recommendation
Enable
beforeInitializeonTruthMarketHookand restrict pool initialization to the launcher contract.Resolution
Trueo: Resolved.
-
H-03 High Premature Depositor Removal Loses Funds Logical Error Resolved
Description
When a user withdraws their full balance from a single outcome of a proposal,
withdraw()removes them from the global depositorsEnumerableSeteven if they hold deposits in another outcome. A user may have deposited to both outcomes, for example to strategically build a balanced launch position rather than a pure directional bet. If a user withdraws their fullbalance =deposits[msg.sender][outcome]of an outcome,removeDepositorremoves the user from the set thedistributorrelies on to process depositor outcome:address[] memory depositors =viewer.proposalDepositors(proposalId);A user who deposits to outcomes 1 and 2, then fully withdraws from outcome 1, is removed from the set while still holding funds in outcome 2. On launch, their remaining deposit inflates the mint amount but they receive nothing. Post-launch withdrawal is blocked due to thephasecheck, so funds are permanently lost.Recommendation
Track depositor outcome count and only remove from the set when all outcome balances are zero:
Resolution
Trueo: Resolved.
-
M-01 Medium Unbounded Depositor Set Can Block Distribution DoS Resolved
Description
The distribution flow is designed as a single-shot operation over the full depositor set, but depositor growth is not bounded. During deposits, every participant is added to an enumerable set that can keep growing until launch conditions are met. At settlement time, the distributors fetch the entire depositor list into memory and iterate across all participants in one transaction. The proportional distributor performs two outcome passes per depositor, while the single-side distributor also performs LP-position work inside the loop. Gas cost therefore scales with participant count before distribution can complete.
Launchpadattempts distribution atomically. If the distributor call becomes too expensive and fails, the proposal is deferred or pushed into manual distribution handling. In practice, a sufficiently large depositor set can turn automatic settlement into an operator-dependent recovery path.Recommendation
Replace one-shot full-set distribution with paginated settlement that persists progress on-chain. Snapshot settlement inputs once, process bounded depositor ranges per transaction, and finalize only after the last batch succeeds. ```solidity function distributeRange( ILaunchpadViewer viewer, uint256 proposalId, uint256 start, uint256 maxDepositors ) external returns (uint256 processed, bool done);
Resolution
Trueo: Resolved.
-
M-02 Medium Module Whitelist Checked Only At Prop. Creation Unexpected Behavior Resolved
Description
The module whitelist is enforced when a proposal is created, but launch and distribution later execute using stored module addresses without re-checking current whitelist status. This creates a policy gap where a proposal can continue to use a launcher, distributor, or hook plugin even after that module has been removed from the whitelist. At proposal creation, the protocol validates
proposal.launcher,proposal.distributor, and each bound plugin against_moduleWhitelist. After creation, execution paths consume the stored module addresses directly. The launch path callsstate.proposal.launcherand the distribution path callsstate.proposal.distributor, with no whitelist gate at those boundaries. Because whitelist status is not re-evaluated at execution time, de-whitelisting a module does not reliably prevent already-created proposals from invoking it. This weakens the expected effect of emergency module disablement and can preserve exposure to modules that policy intended to block.Recommendation
Enforce whitelist checks at execution boundaries in addition to proposal creation. Before calling launcher, distributor, or proposal-bound plugins, require that each module is still whitelisted. This aligns runtime behavior with current policy and makes emergency de-whitelisting effective immediately. Apply this guard in launch and distribution flows before external calls, and before plugin hook execution. If repeated plugin scans are too expensive, snapshot an immutable
allowedModulesHashat...Resolution
Trueo: Resolved.
-
M-04 Medium Unvalidated Cap Can Stall Automated Distribution DoS Resolved
Description
TruthMarketV2cap is passed through from proposal params to market creation and affects whether distribution succeeds, but proposal validation does not enforce a minimum-viable cap against launch-time mechanics. Factory-side common validation contains only question + timing checks: That path does not enforce constraints onparams.capbefore launch. The cap is consumed asyesNoTokenCapwhen market creation/minting occurs: During launch distribution: If market minting later fails (e.g., from insufficient remaining mint capacity due to a too-low cap), the automated path can move into deferred/manual recovery mode. In practice this creates a failure mode with no user-controlled fallback; completion depends on resolver intervention. Because of this proposals can become launchable/accepted by factory checks yet be settlement-fragile: • normal distribution reverts at launcher/distributor boundary, • phase transitions skip to deferred/manual handling, • users lose deterministic, permissionless settlement expectation.Recommendation
Add cap invariants during proposal validation and launch-time readiness checks: • require
cap > 0, • requirecap >= protocol-fee-adjusted minimum expected distributable, • ensure distributable projection at proposal level cannot exceed remaining token mint capacity before setting proposal ready. Fail fast invalidateCommonor earlylaunchcheck with clear revert reasons if cap cannot satisfy expected max mint for this proposal.Resolution
Trueo: Resolved.
-
M-05 Medium Deferred Distribution Failure Can Forfeit Retry Unexpected Behavior Resolved
Description
resolveDeferredDistribution()invokes_distribute(..., true)when a launched proposal is not yet distributed: Inside_distribute, any distributor error during deferred resolution sets the phase toManualDistributionNeeded: BecauseresolveDeferredDistribution()is gated onProposalPhase.Launched, once this catch branch executes there is no path back to retry_distributeautomatically; the proposal is forced into manual recovery mode. Failures during one deferred attempt are treated as terminally unrecoverable in protocol logic, even though they could be retryable. The main impacts are: • Non-deterministic failures can be converted into permanent manual-recovery outcomes. • A resolver can be forced into custodialwithdrawFundsflow by inducing a single failing attempt. • This weakens liveness and increases trust in privileged resolver actions.Recommendation
Keep a retryable deferred state and allow repeated attempts after transient failure, only escalating to manual custody after bounded failures or explicit governance approval. Suggested pattern: • Add failure counter and/or cool-down window for deferred resolution attempts. • Remain in
ProposalPhase.Launchedfor retryable failures. • Move toManualDistributionNeededonly after N failed attempts or explicit admin decision.Resolution
Trueo: Resolved.
-
M-06 Medium Blacklist Bypass Via LaunchpadFactory Validation Resolved
Description
Launchpad.propose enforces blacklist checks against msg.sender, but in factory flows msg.sender at Launchpad is the LaunchpadFactory contract, not the end user. A blacklisted user can call proposeCommunityTruthMarketV2 or proposeWhalesTruthMarketV2 through the factory and still create proposals as long as the factory has proposer permissions, which bypasses blacklist enforcement for proposal creation.
Recommendation
In Launchpad.propose, enforce blacklist validation on proposal.creator instead of msg.sender.
Resolution
Trueo: Resolved.
-
M-07 Medium Launch Readiness Not Re-evaluated On Withdraw Logical Error Resolved
Description
Launchpad._attemptLaunch is only invoked from deposit, while withdraw also changes the exact state. This means a proposal can become launch-ready after a withdrawal (for example, max-ratio becomes compliant) but remain stuck in Live until another deposit occurs. If no further deposit happens before endOfProposal, the proposal can expire to Failed even though launch conditions were satisfied after the withdrawal.
Recommendation
Re-evaluate launch conditions after withdraw, similar to how deposit invokes _attemptLaunch.
Resolution
Trueo: Resolved.
-
M-08 Medium Factory Minimum Deposit Hard-codes TYD Decimals Unexpected Behavior Resolved
Description
The factory
minimumDepositchecks for both Community and Whales proposals are hard-coded against fixed raw units that encode a TYD/6-decimal assumption, while the active payment token is protocol-configured and not fixed in the factory. Community path: Whales path: Both proposals flow into market creation against the protocol payment token configured inTruthMarketManager: Because validation compares raw units, this creates: • Wrongly strict floor for low-decimal environments where the literal assumes too much scale, • Trivially weak floor for high-decimal environments where the same raw bound represents far less than intended economic value. Thus the protocol’s economic guard is token-decimal-dependent but tied to protocol config, not to actual token decimals.Recommendation
Normalize/check
minimumDepositagainst the configured payment token decimals (or stable fixed unit policy) in both factory paths, e.g.: •minimumDeposit >= MIN_BASE *10**paymentToken.decimals(). Avoid literal assumptions in factory-level protocol checks unless the token is permanently fixed.Resolution
Trueo: Resolved.
-
M-09 Medium Deferred Distribution Is Externally Manipulable Logical Error Resolved
Description
The launch flow deploys TruthMarketV2 and initializes both pools, then transitions the proposal to Launched before calling the distributor. If distribution reverts, the proposal remains Launched and allocation is retried later, while the market and pools are already live and externally mutable. In this launched-but-undistributed window, attackers can manipulate state that deferred allocation depends on. The yesNoTokenCap headroom can be consumed through external minting so distributor-required mint capacity is no longer available, preventing completion. An attacker can also shift the pool tick before deferred allocation so single-sided LP bounds are derived from a manipulated live tick state and collapse into an invalid range where tickLower >= tickUpper. The same window also enables a sandwich around deferred allocation where the attacker moves tick before allocation, the protocol places liquidity using that manipulated state, and the attacker back-runs after allocation to obtain better execution for the same fixed input.
Recommendation
Make launch and distribution atomic so the proposal cannot remain in a launched-but-undistributed state with live mutable market/pool state.
Resolution
Trueo: Resolved.
-
M-10 Medium Single-side LP Range Can Collapse Near 1.0 DoS Acknowledged
Description
This protocol lets users bet on binary outcomes (for example YES / NO): 1. Users deposit payment tokens into a proposal on the
Launchpad. 2. When launch conditions are met, the proposal is launched and a Uniswap V4 market is created for that question. 3. On settlement, the distributor sends users their outcome tokens and creates LP positions in both outcome pools. The LP step for this protocol path uses: •TruthMarketV2SingleSideLiquidityPositionDistributor.distribute(...)•UniswapV4LPHelper.calculateSingleSidedPositionParams(...)The helper precomputes a tick range and reuses it for all users in the proposal. The protocol’s fairness intent is purely proportional math from recorded deposits: Meaning: • If a bettor put indepositAmountfor an outcome and that outcome hastotalDeposits, their payout share isdepositAmount / totalDeposits. • This share is multiplied bytotalDistributableto get their outcome payout amount. • Same formula runs for everyone; no manual choice in allocation. The implementation processes both outcomes with pre-fetched pool data:Recommendation
Compute one-sided LP bounds from live state in a way that guarantees non-zero width across boundary cases, add an explicit precondition that enforces
tickLower < tickUpperbefore LP creation and add launch-time or proposal-validation checks that reject configurations (including problematictickSpacingvalues) where single-sided math can collapse at initialization; if distribution remains long-running, switch to bounded/batch settlement with drift checks rather than relying on one-shot...Resolution
Trueo: Acknowledged.
-
M-11 Medium Hardcoded Trading Window Drift DoS Resolved
Description
The launch flow validates endOfTrading against two different policies at two different stages. At proposal creation, LaunchpadFactoryMarketV2.validateCommon accepts any value where endOfTrading > endOfProposal + 1 hour (hardcoded). At launch execution, TruthMarketManager._createMarketCommon requires endOfTrading >= block.timestamp + minimumTradingDuration, where minimumTradingDuration is mutable via setDurations. This creates two independent constraints that can diverge over time. The launch process can therefore accept parameter values that are valid in factory checks but impossible under runtime manager checks, causing launch-triggering deposits to revert with InvalidEndOfTrading. If minimumTradingDuration is increased above the factory’s fixed one-hour assumption, proposals can pass factory validation but fail at launch with InvalidEndOfTrading, creating launch-time liveness failures for otherwise accepted proposals. If minimumTradingDuration is reduced below one hour, the opposite drift remains: factory continues rejecting configurations that manager would allow, causing...
Recommendation
Replace the hardcoded +1 hour rule in factory validation with a minimum aligned to manager policy (for example, pass the manager-aligned minimum duration into library validation), so accepted proposal parameters are always launchable under the same constraints.
Resolution
Trueo: Resolved.
-
M-12 Medium Gas-Griefed Distribution Forces Deferred Window Gas Griefing Acknowledged
Description
The last depositor who triggers launch controls the gas limit of their transaction. Due to the EIP150 63/64 gas rule, they can supply enough gas for
launch()to succeed but starvedistribute()inside thetry/catch: According to EIP150, an external call is allocated 63/64 of gas, leaving extra to complete the transaction if the call runs out of gas. The market is deployed and pools initialized, but distribution fails due to out of gas, and enters thecatchblock, enforcing a deferred distribution. The proposal is stuck inLaunched, where depositors cannot withdraw. To resolve this, theLAUNCHPAD_RESOLVER_ROLEmust manually callresolveDeferredDistribution(). During this window, the market is live with initialized pools but no depositor liquidity. **Note:** While the attacker can force a deferred distribution via gas griefing, pool manipulation during this window is not possible as it stands, since both pools are immediately paused after initialization and are only unpaused infinalize, which executes only after a successfuldistribute(). Any swap attempt during the...Recommendation
Require a minimum gas threshold before entering the distribution path, or separate launch and distribution transactions so the launcher cannot be used to starve the distributor.
Resolution
Trueo: Acknowledged.
-
M-13 Medium Deposit-Only Min Check Allows Dust Contributions Validation Resolved
Description
The documentation states that RestrictMinimumDepositsPlugin “prevents small deposits from being accepted, ensuring only meaningful contributions are allowed during the fundraising phase.” However, the implementation enforces this policy only during deposit and launch-threshold checks, not during withdrawals. Since the withdrawal path applies no equivalent minimum-stake retention rule, a user can satisfy the minimum contribution requirement, then withdraw almost all funds and leave a dust balance, bypassing the stated “meaningful contributions” guarantee in practice. This can also clog depositor tracking with economically meaningless positions, increasing overhead and gas costs for operations that iterate or process depositor state.
Recommendation
If the goal is to keep each participant’s contribution above a minimum threshold throughout escrow, enforce the same minimum on withdrawals by reverting any withdrawal that would leave a non-zero balance below the configured floor, unless it is a full withdrawal.
Resolution
Trueo: Resolved.
-
L-01 Low Failed-phase Withdraw Hooks Can Block Refunds Unexpected Behavior Resolved
Description
Launchpad.withdraw()permits withdrawals in bothLiveandFailedphases, but still executes allBeforeWithdrawhooks in both cases:runBeforeWithdrawHooksiterates allBeforeWithdrawplugins and any reverting plugin blocks the call. Because of this: • A single misbehaving or intentionally revertingBeforeWithdrawhook can deny all refunds for failed proposals. • This can strand users in a state where they cannot retrieve escrowed funds, despitePhase == Failed. • The failure mode is especially severe because hook logic is external and can be upgraded/modified by module governance.Recommendation
Skip
BeforeWithdrawhooks for failed-proposal refunds (or maintain a strict allowlist of hooks that are refund-safe whenphase == Failed). At minimum, guard plugin execution: If failed-phase hooks are intentional by design, require explicit documentation and add a hard governance check that forbids reverting hooks from blocking standard refunds.Resolution
Trueo: Resolved.
-
L-02 Low Zero-amount Withdraw Mutates State Unexpected Behavior Resolved
Description
runBeforeWithdrawHookscan execute arbitrary hook logic and a transfer side-effects follow this branch based onamount. If a caller hasbalance == 0in the targetoutcome, a call with any positiveamountclamps to zero and: • emits a withdrawal event withamount = 0, • still mutates bookkeeping (removeOutcomeif tracked outcome total reaches zero,removeDepositorwhenbalance == amount),- and passes
amount=0through hook + transfer path. In practice, this means users can accidentally
or intentionally trigger removals and state transitions without economic effect, and plugins that assume non-zero
amountat hook/runtime boundaries can mis-handle control flow.Recommendation
After clamping and hook invocation, gate execution on a non-zero resolved amount: or use a separate early-return path for no-op withdrawals that does not touch state/set bookkeeping. When removing depositor entries, require that all outcome balances are zero after mutation rather than relying solely on the original
balance.Resolution
Trueo: Resolved.
- and passes
-
L-03 Low Unbounded Hook Args Can Gas-grief Proposals DoS Resolved
Description
Launchpad.propose()has no upper bound onproposal.hookBindings.lengthand no upper bound on per-hook payload size (bytes argsinHookBinding): Runtime paths iterate hooks linearly on every deposit/withdraw/check: The directLaunchpad.propose()path is role-gated, but proposal creation is also permissionless throughLaunchpadFactory: In the whales flow, user-controlledspec.whitelist(unbounded length) is encoded intoRestrictDepositorPluginhook args:RestrictDepositorPlugindecodes and linearly scans this whitelist on deposit and withdraw: As a result, permissionless users can create proposals whose interaction cost is artificially high, causing repeated user transaction failures due to gas constraints. Attack vector: 1. Any user can callproposeWhalesTruthMarketV2(permissionless factory entrypoint). 2. The user supplies a very large whitelist inspec.whitelist. 3. The whitelist is persisted as hook args and evaluated on each deposit/withdraw. 4. Interaction gas grows with whitelist length and can exceed practical gas limits. 5. Refund withdrawals can...Recommendation
Consider adding a strict whitelist length cap in whales spec validation (for example,
<= 128), a hard cap onproposal.hookBindings.lengthinLaunchpad.propose()and Add per-bindingargsbyte-length caps and/or cap total encoded hook bytes per proposal. Finally, consider also adding a gas-safe emergency withdrawal path for failed proposals that bypasses expensive policy hooks.Resolution
Trueo: Resolved.
-
L-04 Low Default Ratio Guard Doesn’t Prevent InvalidPrice Rounding Resolved
Description
LaunchpadFactoryCommunityV2 and LaunchpadFactoryWhalesV2 rewrite maxRatioThreshold from 0 to 9999 to avoid one-sided launch failures, but the ratio hook and launch path use floored integer math in a way that is inconsistent with that goal. In RestrictMaximumRatioOfOutcomeDepositPlugin.checkProposal, each outcome ratio is computed as (outcomeDeposits * 10000) / totalDeposits and rejected only when ratio > maxRatioThreshold. In near one-sided states, this can produce 9999 for the dominant side and 0 for a non-zero minority side, so readiness returns true at threshold 9999. Launch then executes Launchpad._attemptLaunch
- > TruthMarketV2Launcher.launch, where pool init prices are recomputed with the same floored
formula. The minority side init price becomes 0, UniswapV4LPHelper.priceToSqrtPriceX96 reverts with InvalidPrice, and the launch attempt fails.
Recommendation
Add an explicit readiness guard in ratio check so proposals are not launch-ready when a non-zero side floors to zero (e.g., if (outcomeDeposits > 0 && ratio == 0) return false).
Resolution
Trueo: Resolved.
-
L-05 Low Insufficient Reward Wallet Funds Bricks Launch Validation Acknowledged
Description
When a proposal triggers launch,
TruthMarketManager._createMarketCommon()transfers reward tokens from the sharedrewardWallet: IfrewardWallethas insufficient balance at launch time (i.e., due to other markets draining the same token), the safeTransferFrom reverts, bricking the entire launch until therewardWallethas sufficient funds again.Recommendation
Validate reward token availability before market creation, or escrow reward tokens at proposal creation time.
Resolution
Trueo: Acknowledged.
-
L-06 Low Withdrawal Lacks Slippage Protection Validation Acknowledged
Description
withdraw()calculates the payout using the currentprotocolFeeat execution time with no minimum payout parameter: If an admin increases the fee viasetProtocolFeebetween a user's transaction submission and execution, the user will receive less funds than expected.Recommendation
Add a
minAmountOutparameter towithdraw:Resolution
Trueo: Acknowledged.
-
L-07 Low Community Spec Getter Can Return False Positive Unexpected Behavior Resolved
Description
LaunchpadFactorystores community proposals in_communitySpecsand exposescommunityTruthMarketV2Spec(proposalId)as a typed getter. The getter loadsspec =_communitySpecs[proposalId]and then validates onlyspec.proposalType ==ProposalType.CommunityMarketV2. BecauseProposalType.CommunityMarketV2is the zero value of the enum, any unmappedproposalIdreturns the default zero-initialized struct and still passes the type check. As a result, callers can receive a community spec for an ID that was never created throughproposeCommunityTruthMarketV2.Recommendation
Track proposal existence explicitly and require it in the getter. A simple fix is to set a dedicated existence flag when persisting a spec and revert if the flag is false.
Resolution
Trueo: Resolved.
-
L-08 Low Unsettled Market Can Block OracleBonds Rotation DoS Acknowledged
Description
setOracleBondsandsetOracleBondsWithBatchCheckrequire every market in_activeMarketsto havebondSettled() == truebefore rotation succeeds. Because_activeMarketsgrows over time and is not pruned for historical markets, a single unresolved market can block OracleBonds rotation indefinitely.Recommendation
Decouple rotation from full historical market settlement. Keep an explicit set of currently blocking markets (or a removable unsettled index), and require settlement checks only for that set. Ensure settled/irrelevant markets are removed from the rotation gate so one stale market cannot deadlock upgrades.
Resolution
Trueo: Acknowledged.
-
L-09 Low Non-binary Outcomes Can Lock Deposits Validation Resolved
Description
Launchpadcore deposit accounting permits arbitraryoutcomevalues, and downstream launch/distribution logic only supports outcomes1and2. Core deposit path stores arbitrary outcome IDs without validation: By contrast, launch and distribution logic is hard-coded to two outcomes only: Funds deposited to any outcome other than1or2remain intotalDeposits, so launch price math and payouts are computed as if they do not exist, leaving that value unusable by the expected settlement rules.Recommendation
Enforce output domain in the core deposit path (or require and verify enforced domain in proposal validation): • reject any
outcomeoutside{1,2}atLaunchpad.deposit(), or • makelaunch/distributionassertproposalOutcomeDeposits(1)+proposalOutcomeDeposits(2)==proposalTotalDepositsand revert if not. If additional outcome sets are intended, add full multi-outcome accounting and distribution support instead of assuming binary-only settlement downstream.Resolution
Trueo: Resolved.
-
L-10 Low Raw Approve Breaks Non-Standard Tokens Compatibility Acknowledged
Description
TruthMarketManageruses rawIERC20.approve()when setting oracle bonds: Solidity's ABI decoding expectsapproveto return bool. However, tokens like USDT (mainnet) return void, causing the decoder to revert on empty return data. Additionally, for tokens that do return bool, a false return on failure is silently ignored. SincepaymentTokenis admin-configurable, choosing USDT or similar non-standard tokens would bricksetOracleBondsandsetOracleBondsWithBatchCheck.Recommendation
Use OpenZeppelin’s SafeERC20
forceApprovemethod to handle non-standard tokens.Resolution
Trueo: Acknowledged.
-
L-11 Low Dispute Bond Taken Silently During Failed Check Logical Error Resolved
Description
In
TruthMarketManager.disputeMarket(),sendDisputorBondToMarket()is called unconditionally before theResolutionProposedstatus check, which transfers the bound amount from thedisputorto theOracleBondscontract. If the market is not inResolutionProposedstatus, the disputor's tokens are taken butraiseDispute()is never called, and the function silently returns: The disputor loses their bond with no dispute actually raised. Note thatescalateDisputeMarket()does not have this issue as it callsraiseEscalatedDispute()unconditionally, which will revert if the status is invalid.Recommendation
Move the status check before the bond transfer, and consider reverting on mismatch:
Resolution
Trueo: Resolved.
-
I-01 Informational Derived Failed Phase Is Not Persisted On-chain Unexpected Behavior Acknowledged
Description
The
proposalPhaseview function derivesFailedfrom time by comparing the current timestamp againstendTimewhen the stored phase is stillLive. The storedstate.phaseis only updated during mutating flows that invoke_handlePhaseTransitionand theProposalFailedevent is emitted only from that transition path. This means an expired proposal can appear asFailedthroughproposalPhasewhile the persisted state remainsLiveand no failure event exists yet if no one calls a mutating function after expiry. This creates an observability consistency gap between derived read results and persisted lifecycle data. Systems that rely on events or raw storage state can lag behind systems that rely onproposalPhase.Recommendation
Add an explicit finalization entrypoint that anyone can call after expiry to persist
Failedand emitProposalFaileddeterministically when a proposal times out without launching. Keep this operation idempotent so repeated calls are safe and document that event-driven integrations should use the finalization flow when strict on-chain phase persistence is required.Resolution
Trueo: Acknowledged.
-
I-02 Informational Reentrancy Vector Via Custom Reward Token Warning Resolved
Description
A proposer can set
rewardTokento a custom ERC20 with transfer hooks, creating a reentrancy entry point during_createMarketCommon(by pre-funding the rewardWallet with the token): At this point, the market is deployed and initialized but pools are not yet initialized. The Launchpad's and TruthMarketManager'snonReentrantguards block reentry into all critical paths, and no exploitable vector was identified. However, future changes to the code between market creation and pool initialization could expose a reentrancy path.Recommendation
Consider restricting
rewardTokento a whitelist of known tokens, or moving the reward transfer to after pool initialization and token ownership transfer.Resolution
Trueo: Resolved.
-
I-03 Informational Slippage Tolerance Exceeds Permit2 Approval Validation Resolved
Description
In
createSingleSidedPositionWithParams,amountMaxincludes 0.5% slippage butensurePermit2Approvalonly approves the base amount (without slippage applied): If the full slippage were ever utilized, theaddLiquidityoperation would revert due to insufficient approval. Since all positions are created atomically within a single transaction where no price changes occur, the exacttokenAmountis consumed every time. However, it's important to note that if the slippage were ever utilized, distribution will always revert.Recommendation
Consider documenting this or align the Permit2 approval with
amountMax.Resolution
Trueo: Resolved.
-
I-04 Informational Last Depositor Bears Disproportionate Gas Cost Suggestion Acknowledged
Description
The depositor whose deposit triggers launch conditions pays gas for the entire
launchanddistributionflow on top of their own deposit: market deployment, two pool initializations, and looping through all depositors to mint tokens and create LP positions. Every other depositor pays only for their deposit transaction. This creates a negative incentive where depositors may intentionally stay below the launch threshold to avoid being the trigger, stalling proposals despite near-sufficient deposits.Recommendation
Consider separating launch triggering into a dedicated keeper call with gas incentives (e.g., a small bonus from the protocol fee), rather than relying on the last depositor.
Resolution
Trueo: Acknowledged.
-
I-05 Informational Stored Specs Drift From Applied Defaults Unexpected Behavior Resolved
Description
Factory proposal composition uses two separate data paths. In
LaunchpadFactoryMarketV2.createBaseProposal, an in-memory copy of market params is normalized first, including defaultingfeeto3000,tickSpacingto60, and zeroingrewardAmountwhenrewardTokenis zero, and then this normalized struct is encoded intoProposal.params. In parallel,LaunchpadFactory.proposeCommunityTruthMarketV2andLaunchpadFactory.proposeWhalesTruthMarketV2persist_communitySpecs[proposalId] = specand_whalesSpecs[proposalId] = specusing the original user-provided spec without applying those normalized defaults. Because there is no write-back or reconciliation between these paths, spec retrieval endpoints can return values that differ from the effective parameters actually used at launch. This mismatch can mislead indexers, dashboards and reviewers that treat stored specs as source of truth for launched market configuration.Recommendation
Persist the normalized spec after defaults are applied, or expose a dedicated view that returns effective launch parameters exactly as encoded into
Proposal.params.Resolution
Trueo: Resolved.
-
I-06 Informational Initialize Allows Protocol Fee Above Fee Scale Validation Resolved
Description
The
Launchpadinitializer acceptsprotocolFee_without enforcing the same upper bound that is required later bysetProtocolFee. This creates an inconsistent configuration surface where deployment can store a fee value that regular admin updates would reject. Fee math later assumes bounded values when computing(amount, fee)fromrawAmount. If initialization setsprotocolFeeaboveFEE_SCALE, payout paths can produce zero distributable amounts for small values and can revert on subtraction for larger values. Impact is a misconfiguration-driven denial of expected fund flows until an admin correction is made.Recommendation
Apply the same bound check in
initializethat is already present insetProtocolFee, so invalid fee configuration is impossible at deployment time.Resolution
Trueo: Resolved.
-
I-07 Informational Missing Market Payment-token Check Validation Resolved
Description
TruthMarketV2ProportionalDistributorpullspaymentTokenfrom its constructor, then callsmarket.mint(distributable)without checking that the target market uses the same payment token. If an inconsistent market/distributor pair is introduced (for example through factory/launcher misconfiguration, compromised module settings, or incorrect proposal wiring), the distributor can revert during mint or perform unintended external flow before any outcome token payout.Recommendation
Add an explicit invariant before mint: Keep this check as a hard precondition both at distributor entry and in any launcher path that selects paired modules.
Resolution
Trueo: Resolved.
-
I-08 Informational Active Markets Set Grows Unboundedly Gas Optimization Acknowledged
Description
Markets are added to
_activeMarketson creation but never removed after finalization. This set grows indefinitely, increasing gas costs forisActiveMarketlookups and making the unbatchedsetOracleBondseventually unusable. The batchedsetOracleBondsWithBatchCheckmitigates the DoS but doesn't address the root cause.Recommendation
Remove markets from
_activeMarketsafter finalization and bond settlement.Resolution
Trueo: Acknowledged.
-
I-09 Informational Bond Amounts Snapshot At Market Creation Documentation Resolved
Description
resolverBondAmount,disputerBondAmount, andescalatorBondAmountare copied fromTruthMarketManagerinto each market duringinitialize()and never updated. Admin calls toTruthMarketManager::setAmounts()to update these values then only affect future markets. while existing markets always retain their original values for these variables.Recommendation
Consider documenting that
setAmountsapplies only to future markets, not retroactively.Resolution
Trueo: Resolved.
-
I-10 Informational Single-side LP Helper Allows Zero Liquidity Unexpected Behavior Resolved
Description
UniswapV4LPHelper.createSingleSidedPositionWithParamscomputes and forwardsliquidityfromgetLiquidityForAmountswithout checking for zero: For small shares or adverse tick geometry, the returned liquidity can be zero even whenamount0/amount1are non-zero.Recommendation
Short-circuit zero-liquidity cases before calling
modifyLiquidities(e.g.,if (liquidity == 0) revertor skip distribution for that depositor with tracked remainder).Resolution
Trueo: Resolved.
-
I-11 Informational Unbounded Ratio Threshold Can Disable Check Validation Resolved
Description
The proposal spec accepts
maxRatioThresholdwithout enforcing an upper bound, while the ratio plugin compares each outcome ratio in basis points against that threshold and returns false only whenratio > maxRatioThreshold. Because outcome ratios are computed on a 0 to 10000 scale, any threshold above 10000 makes the check effectively non-binding. In that state, the protocol can treat heavily imbalanced proposals as ready even though the ratio guard is intended to reduce launch fragility near one-sided deposits. This is primarily a configuration-integrity issue. It requires a misconfigured proposal input.Recommendation
Constrain
maxRatioThresholdat proposal validation to the expected basis-point domain and reject out-of-range values. A simple rule ismaxRatioThreshold <= 10000, with explicit handling for any special sentinel semantics such as0. Keep the same bound in all proposal composition paths so the ratio guard cannot be silently disabled by configuration.Resolution
Trueo: Resolved.
-
I-12 Informational Max Sqrt Clamp Can Break Tick Conversion Validation Resolved
Description
priceToSqrtPriceX96clamps large values toTickMath.MAX_SQRT_PRICE. That value is then consumed bypriceToTick, which callsTickMath.getTickAtSqrtPrice. The Uniswap bound forgetTickAtSqrtPriceis strictly less thanMAX_SQRT_PRICE, so feeding the clamped max value can revert. This creates a boundary mismatch between two helper steps that are expected to be compatible. Under extreme ratio and decimal combinations, the clamp path can route execution into an avoidable revert instead of returning a bounded tick. In launch and distribution flows that depend on tick derivation, this can degrade liveness and push proposals into deferred or manual recovery behavior.Recommendation
Align the clamp with
getTickAtSqrtPricepreconditions by capping the upper bound atTickMath.MAX_SQRT_PRICE - 1before any tick conversion path. Keep the lower bound atTickMath.MIN_SQRT_PRICE.Resolution
Trueo: Resolved.
-
I-13 Informational Low Whales Ratio Can Deadlock Launch Validation Resolved
Description
composeProposal()normalizesmaxRatioThresholdonly when it is0, then forwards the encoded value directly to theRestrictMaximumRatioOfOutcomeDepositPlugincheck hook (src/libraries/LaunchpadFactoryWhalesV2.sol:90-99). The plugin enforces only an upper bound viaratio > maxRatioThresholdand uses the same 2-outcome deposit set thatTruthMarketV2Launcherlater converts to initial prices. For two-outcome markets, one outcome is always at least 50% by definition, so any configuredmaxRatioThreshold <5000is mathematically unsatisfiable and the proposal can never satisfy launch readiness.totalDeposits >= minimumDepositcan be met, butCheckProposalremains false forever:Recommendation
Validate
maxRatioThresholdinvalidateSpec()with a lower bound appropriate for two outcomes: If 3+ outcomes are ever supported, define threshold policy by outcome count rather than a hard 2-outcome constant.Resolution
Trueo: Resolved.
-
I-14 Informational Single-side Distributor Reuses Slot0/tick Unexpected Behavior Acknowledged
Description
TruthMarketV2SingleSideLiquidityPositionDistributorprecomputes LP position parameters once per pool and reuses them for every depositor in distribution.calculateSingleSidedPositionParamscaptures live pool state once: If pool state is not stable across sequential mints, later depositors can be processed with stale ticks/sqrt price captured from the first call, yielding allocation and slippage behavior inconsistent with current state. This stale snapshot risk is specifically relevant when: •poolManager.modifyLiquidities(...)is called repeatedly in one distribution loop and each call changes/realignsslot0ortickconditions (including protocol-level fee growth, bounds, or derived internal math side effects). • the Uniswap V4 pool is hook-aware (PoolKey.hooksis configured at market creation), so hook logic can create non-trivial state effects around each mint action. • minting/ordering effects across the yes/no pools interact indirectly, so parameters become stale for the second and later depositor operations. • distribution is resumed after partial work or retried in...Recommendation
Add one of the following: • Recompute pool pricing state per depositor (or per bounded batch chunk) before each mint call. • Snapshot + drift-check pattern: • store
slot0at the start and assertslot0unchanged before each subsequent mint; • if changed, pause batch progress and fail-safe with controlled fallback/manual recovery. • Make hook-state assumptions explicit in design/docs only if those assumptions are enforced by code-level constraints. An example fallback pattern can be:Resolution
Trueo: Acknowledged.
-
I-15 Informational Incompatibility With Fee On Transfer Tokens Warning Acknowledged
Description
Launchpadrecords deposits as if the requestedamountalways arrives in escrow. Indeposit, it updates depositor balances, outcome totals, and the proposal-widetotalDepositsbefore any token transfer takes place. The accounting layer therefore trusts the caller-supplied nominal amount, not the amount that the contract actually receives. That assumption is only safe ifpaymentTokenis a plain ERC20 that always credits the exact requested amount. The contract does not enforce that invariant. If the configured token charges a transfer fee, rebases downward, or has any nonstandard behavior that results in fewer tokens being credited than requested, the proposal accounting immediately becomes overstated. From that point onward the system believes more value is escrowed than actually exists. The Permit2 path does not repair this. It forwards asafeTransferFromcall for the requested amount, but it does not compare balances before and after the transfer and it does not prove that escrow received the full amount recorded byLaunchpad.Recommendation
If fee on transfer tokens are supported, do not credit proposal accounting from the caller-supplied
amount. Move the token transfer before the bookkeeping update, or better, measure the actual amount received with a balance-before and balance-after check and credit only that delta. If exact funding is required, revert whenever the received amount is smaller than the requested amount. If the protocol is not intended to support fee-on-transfer or rebasing assets, enforce that operational...Resolution
Trueo: Acknowledged.
-
I-16 Informational Proposal Creator Metadata Is Spoofable Warning Resolved
Description
The
Proposalstruct includes acreatorfield, andLaunchpadexposes that field back to callers throughproposalCreator. The problem is thatproposeaccepts the entire struct from the caller and stores it without canonicalizing the creator value. The transaction sender must holdRoles.LAUNCHPAD_PROPOSER_ROLE, but the stored creator does not have to match that sender. As a result, any proposer-role holder can create a proposal while attributing it to any arbitrary address. The transaction origin is still visible at the chain level, but the protocol's own creator metadata becomes untrustworthy. That matters because a field namedcreator, combined with a dedicated getter, strongly implies that the value is authoritative and safe for user interfaces, attribution logic, allowlists, analytics, revenue-sharing rules, or future policy hooks to consume. Even if the current codebase does not yet gate critical settlement behavior onproposal.creator, exposing spoofable identity metadata is still a protocol footgun. Off-chain systems can display the wrong responsible party, an...Recommendation
Do not take
creatorfrom user input. Set the stored creator tomsg.senderinsidepropose, or remove the field from the external proposal payload entirely and derive it internally. If delegated creation is a real product requirement, separate the concepts of proposer and creator instead of overloading one field. In that model, the contract should recordmsg.senderas the proposer and only accept a different creator when it is backed by explicit authorization, such as a signature from the...Resolution
Trueo: Resolved.
Round Two
13 findings-
M-01 Medium Launch Failures Trap Ready Proposals DoS Resolved
Description
Launchpad.withdraw()still calls_attemptLaunch()after recalculating balances andLaunchpad._attemptLaunch()callsILauncher.launch(...)directly with notry/catchand no refundable failure state. If a proposal remains launch-ready after a small withdrawal andlaunch()reverts, the whole withdrawal reverts too. This is reachable on the canonical factory path because proposal validation is still weaker than runtime launch validation.LaunchpadFactoryMarketV2.validateCommon()only checks non-empty question, non-empty outcome symbols and the trading window, whileTruthMarketManager.createMarketV2()and_createMarketCommon()can still revert on emptymarketSource, invalid or duplicate symbols, invalid fees, or bad V2 manager configuration. Once such a proposal becomes ready, every deposit or withdrawal that still leaves it ready will retrylaunch()and revert again. Users can only get their funds out once the proposal expires intoFailed, so smaller holders can be effectively frozen untilendTime. The factory enforces only a minimum proposal duration, not a...Recommendation
Wrap
ILauncher.launch(...)intry/catchand move the proposal into a refundable failure phase when launch fails, instead of retrying the same bad launch on every later interaction. Proposal creation should also validate the same launcher and manager preconditions that runtime launch enforces, or ask the launcher path to perform a dry-run validation before the proposal is accepted. If long-lived proposals are not intended, add a maximum proposal duration.Resolution
Trueo: Resolved.
-
M-02 Medium Expired Proposals Can Still Launch Late Validation Resolved
Description
resolveDeferredLaunch()checks only the storedstate.phasebefore retrying launch. It does not recompute the effective phase with_proposalPhase(), so it can retry launch from staleLivestate after expiry.deposit()andwithdraw()are safe because they recompute the phase and persist the transition with_handlePhaseTransition()before calling_attemptLaunch().resolveDeferredLaunch()skips that step and calls_attemptLaunch()directly._attemptLaunch()then relies on the same stale storedstate.phaseand does not perform its own expiry check. This lets an expired proposal launch after the fundraising deadline as long as it is still stored asLive. The most realistic case is a proposal that already satisfied its launch conditions before expiry, but a prior launch attempt failed and left the proposal inLive. AfterendTime, the UI and viewer reportFailed, so users reasonably expect refunds. Instead, any caller can invokeresolveDeferredLaunch()and commit the escrowed funds into a late market launch once the launcher stops reverting. The default...Recommendation
Recompute the phase inside
resolveDeferredLaunch()before retrying launch and persist the transition with_handlePhaseTransition(). If the computed phase is no longerLive, revert instead of calling_attemptLaunch(). As a defense in depth measure,_attemptLaunch()can also reject proposals whose computed phase is notLive.Resolution
Trueo: Resolved.
-
M-03 Medium Owner Council Overrides Can Brick Settlement DoS Resolved
Description
TruthMarketManagerlets its owner callresolveMarketByCouncil()andresetMarketByCouncil()directly. Those functions only forward into the market contract. They do not create or close anOracleCouncildispute first.TruthMarketandTruthMarketV2treat those calls as if council bookkeeping already exists.resolveMarketByCouncil()recordscouncilDecisionAt, which later makes_settleBonds()readoracleCouncil.getLastClosedDispute(address(this)).resetMarketByCouncil(true)makes the same assumption immediately andresetMarketByCouncil(false)reaches it on the nextproposeResolution(). If the owner used the manager override instead of lettingOracleCouncil._closeDispute()drive the transition,marketLastClosedDispute[_market]is still zero. ThereforegetLastClosedDispute()returns the zero-initialized dispute at index0. That zero struct carriesdisputorAddress == address(0). The market then callsoracleBonds.issueBondsBackToDisputor(address(this), address(0))andOracleBonds._transferBondFromMarket()reverts on the zero-address check. Consequently...Recommendation
Remove direct owner access to the council-only override functions, or require proof of a real closed dispute before either override can execute. The safer fix is to pass an explicit dispute index from
OracleCounciland reject the call unless that dispute exists and belongs to the market.TruthMarketandTruthMarketV2should not infer a valid disputor fromcouncilDecisionAtalone and should never callissueBondsBackToDisputor()with a zero address.Resolution
Trueo: Resolved.
-
M-04 Medium OracleBonds Migration Breaks Late Bond Claims Unexpected Behavior Acknowledged
Description
TruthMarketManager.setOracleBonds()andsetOracleBondsWithBatchCheck()allow the global bond contract to be replaced as soon as every active market reportsbondSettled == true. That gate is too weak. In bothTruthMarketandTruthMarketV2,_settleBonds()only settles the resolver bond and the last closed dispute or escalation outcome, then marks the market as settled. It does not clear disputor bonds that remain claimable throughOracleCouncil.claimUnclosedDisputeBonds(). The problem is that the late-claim logic inOracleCouncilalways resolves against the currentmarketManager.oracleBonds()address. It does this both incanDisputorClaimbackBondFromUnclosedDispute()and inclaimUnclosedDisputeBonds(). Existing end-to-end tests already rely on this delayed claim behavior afterredeem()has triggered bond settlement. If the owner migratesoracleBondsbefore that claim happens, the newOracleBondscontract has no legacymarketBondstate for the old market, so the claim check fails and the user cannot recover funds through the normal interface. The real bond...Recommendation
Do not use
bondSettledas the migration gate fororacleBonds. Before switching addresses, require the oldOracleBondscontract to have no remaining claimable balance for each active market, including disputor balances for unclosed disputes. If migration must happen while old markets still have pending claims, store and use a market-specific historicalOracleBondsaddress for claim functions, or migrate the per-market bond state into the new contract atomically.Resolution
Trueo: Acknowledged.
-
M-05 Medium Packed Slots Keep Historical Crossing Cost DoS Resolved
Description
removeOrder()decrementsorderCounts[tickThreshold]and clears the removed packed position, but it never shrinksorders[tickThreshold]when trailing packed slots become empty. This leaves the packed array length tied to the largest number of orders that ever existed at that tick instead of the number still live. That stale length is later trusted by bothcountPackedOrders()andmoveTick(), which useself.orders[next].lengthto count and copy packed slots. Consequently, crossing cost depends on the historical peak occupancy of a tick, not the current live order count. An attacker can abuse this cheaply. They can create a large number of orders at one tick, then cancel all but one. The bitmap still marks the tick as initialized because one order remains. However, crossing that tick still processes the old packed-slot array instead of the one live order. This turns a short-lived burst of order spam into a persistent liveness problem and makes the crossed-order gas barrier much cheaper to maintain.Recommendation
Keep the packed array length consistent with the live logical count. Consider shrinking trailing empty packed slots during
removeOrder(). If that is too expensive, stop usingorders[tick].lengthas the authoritative packed length and derive the live packed-slot count fromorderCounts[tick]instead.Resolution
Trueo: Resolved.
-
M-06 Medium Upward Boundary Touch Can Miss Fills Logical Error Resolved
Description
_isTickInRange()uses a half-open interval for upward moves:fromTick <= tick && tick < toTick.OrderManageruses that predicate for both full fills and partial fills onzeroForOneorders. Therefore a threshold is treated as crossed only when it is strictly below the post-swaptoTick. This becomes wrong when a swap ends exactly on the threshold.TruthMarketHook._afterSwap()reads the authoritative post-swapslot0.tickand Uniswap stores the exact upper boundary tick on upward moves. IftoTickequalstickUpperortickLower + tickSpacing,_isTickInRange()excludes that threshold even though the order has already reached the trigger boundary. The order stays pending until price moves one more tick upward. Price can reverse before that happens, so the user remains exposed after their limit was touched. This affects upward execution checks forzeroForOneorders. The issue is visible in both the full-fill check and the partial-fill check because they both rely on the same predicate.Recommendation
Make upward threshold checks treat an exact post-swap boundary touch as crossed. The smallest safe fix is to adjust the upward comparison used for execution thresholds so the upper bound is inclusive for these checks or to normalize
toTickbefore calling_isTickInRange()when the pool ends exactly on a trigger boundary.Resolution
Trueo: Resolved.
-
L-01 Low Launchpad Markets Record Launcher As Creator Warning Resolved
Description
TruthMarketManager._createMarketCommon()recordscreatorAddress[marketAddress] =msg.senderand emitsMarketCreatedWithDescription(..., marketOwner = msg.sender). Under the launchpad flow,msg.senderat that point is theTruthMarketV2Launchercontract, becauseLaunchpadcalls the launcher and the launcher callscreateMarketV2(). The human proposal creator tracked by the launchpad is not propagated into manager-level market creation. As a result, launchpad-created markets end up with incorrect creator metadata at the market-manager layer even though the proposal creator is already available throughILaunchpadViewer.proposalCreator(proposalId). In the current scoped code this is mostly an attribution and accounting bug, but it becomes more serious if any downstream logic, analytics, or fee-routing integration treatscreatorAddressor the emittedmarketOwneras the canonical market creator. In that case, creator-linked rewards or revenue could be misattributed to the launcher contract instead of the actual proposer.Recommendation
Do not derive the market creator from
msg.senderin the launchpad path. Pass an explicit canonical creator into market creation and store that value increatorAddressand the creation event. If the launchpad'sproposal.creatorfield is used as the source, first ensure that field is itself canonicalized to the real proposer instead of trusting arbitrary calldata.Resolution
Trueo: Resolved.
-
L-02 Low Distributor Rounding Dust Stays Stranded Rounding Resolved
Description
TruthMarketV2ProportionalDistributormints the full YES and NO balances to itself and then distributes them with per-user integer division.totalTokensMintedis computed once, but each depositor receivestokenAmount = (totalTokensMinted * depositAmount) / totalOutcomeDeposits. Because each user amount is rounded down independently, the sum of all transfers can be smaller than the minted balance. Any remainder stays on the distributor contract. The same issue exists inTruthMarketV2SingleSideLiquidityPositionDistributor, where each depositor's share is rounded down once when computingdistributableAmountand again when converting that amount into outcome tokens before transfer and LP minting. I do not see any sweep, rescue, or deterministic remainder handling in either distributor, so leftover YES and NO tokens can remain stranded on the distributor contracts indefinitely. The value is usually small, but it is permanent and can accumulate across proposals.Recommendation
Handle remainders explicitly instead of leaving them on the distributor. The cleanest fix is to track how many YES and NO tokens were actually distributed and then route the leftover balance at the end of distribution to a deterministic sink, or distribute the final remainder to a designated recipient under documented rules. If dust is intentionally tolerated, add a restricted sweep function so stranded balances are recoverable.
Resolution
Trueo: Resolved.
-
L-03 Low Community LP Payouts Are Immediately Unwindable Warning Acknowledged
Description
In the community distribution path, a depositor does not end up with a one-sided post-launch position.
TruthMarketV2SingleSideLiquidityPositionDistributor._processDepositorOutcome()transfers the depositor's chosen-side outcome tokens directly to them and then mints an opposing-side LP NFT directly to the same depositor. There is no lock, escrow, or vesting step on that NFT. OnceTruthMarketV2Launcher.finalize()unpauses the pools, the user can use the standard position-manager flow to remove liquidity from that NFT and recover the opposing-side outcome tokens. That leaves the user holding both YES and NO in matched size.TruthMarketV2.burn()andTruthMarket.burn()then let the holder burn equal YES and NO amounts back into payment tokens before market finalization. In practice, a community participant can help set the launch ratio, wait for distribution, unwind the opposing-side LP and convert most of the position back into payment tokens. Their net cost becomes mostly protocol fee, liquidity-removal friction and gas, rather than the full directional exposure implied by the...Recommendation
If the community flow is meant to represent committed directional exposure, do not hand the opposing-side LP position to the depositor in an immediately removable form. A straightforward fix is to lock community LP NFTs for a minimum period or mint them to an escrow contract that enforces delayed withdrawal. If immediate user custody is required, the docs and product assumptions should explicitly treat the launch ratio as a weak signaling mechanism rather than committed post-launch exposure.
Resolution
Trueo: Acknowledged.
-
L-04 Low Trading End Boundary Mismatch Validation Resolved
Description
The factory requires endOfTrading > endOfProposal + minimumTradingDuration, while the runtime market creation check only requires endOfTrading >= block.timestamp + minimumTradingDuration. This creates a boundary inconsistency where proposals using an exact-minimum trading window are rejected by the factory even though the manager would accept them at launch. The issue does not create unsafe launches, but it does make proposal validation stricter than the runtime policy and can cause unnecessary proposal rejection.
Recommendation
Align the comparators used by the factory and manager so both stages enforce the same boundary condition.
Resolution
Trueo: Resolved.
-
L-05 Low Duration Changes Can Break Launch Warning Acknowledged
Description
The initial factory-side validation now correctly fetches the current minimumTradingDuration from the launcher instead of relying on a hardcoded threshold. However, launch-time market creation re-validates endOfTrading against TruthMarketManager.minimumTradingDuration, which remains mutable through setDurations(). As a result, proposals accepted under one duration can still become unlaunchable if governance increases that value before launch.
Recommendation
Be aware that increasing minimumTradingDuration can invalidate already-accepted proposals that have not launched yet. When changing this value, pending proposals should be reviewed to determine whether they may become unlaunchable under the new duration. If minimumTradingDuration may be changed frequently, consider snapshotting the effective value at proposal creation so launch-time validation uses the same duration that was used when the proposal was accepted.
Resolution
Trueo: Acknowledged.
-
I-01 Informational Launchpad Accepts Incompatible Custom Proposals Warning Partially resolved
Description
Launchpad.propose()does not validate that a custom proposal is actually compatible with the binaryTruthMarketV2launch and distribution flow. It only requires at least oneCheckProposalhook and the presence of aBeforeDeposit PLUGIN_RESTRICT_OUTCOMEhook. It does not verify that the outcome whitelist is exactly[1,2]and it does not require the minimum-deposit or max-ratio safety hooks that the canonical factories always attach. This makes the core contract rely on factory-only invariants. A proposer-role holder can bypass the factory and submit a proposal with a wider outcome whitelist or a do-nothing readiness hook set. That is enough to accept deposits that downstream code never handles.TruthMarketV2Launcherand both distributors still read and distribute only outcomes1and2, so deposits into any additional outcome can remain counted inproposalTotalDepositswhile never being paid out. The same gap also allows custom proposals to omit readiness hooks that prevent one-sided or otherwise unlaunchable states.RestrictOutcomePlugin.checkParameters()does...Recommendation
Enforce launcher and distributor compatibility in the core proposal path instead of trusting factory templates. For the current binary design, require the outcome restriction args to decode to exactly
[1,2]and reject proposals that do not include the safety hooks expected by the selected launcher and distributor pair.RestrictOutcomePlugin.checkParameters()should also validate that its whitelist matches the binary-only invariant.Resolution
Trueo: Partially Resolved.
-
I-02 Informational Launch Can Finalize Without Active Liquidity Warning Acknowledged
Description
TruthMarketV2Launcher.launch()initializes pool price but does not seed any in-range liquidity andfinalize()only unpauses the pools. The two distributor paths do not close that gap.TruthMarketV2ProportionalDistributorgives whales users tokens only, with no LP position at all.TruthMarketV2SingleSideLiquidityPositionDistributordoes mint LP NFTs for community users, butUniswapV4LPHelper.calculateSingleSidedPositionParams()places those positions strictly above or below the current tick, so they are intentionally out of range at launch rather than immediately tradable liquidity. This means a market can reachDistributed, be finalized and still open with zero active liquidity on either side. In that state, the first external actor willing to supply in-range liquidity becomes the effective launch-time market maker. That actor can dominate the initial spread, capture most early fees and decide how easy it is for freshly distributed token holders to exit or hedge. If no one steps in to add active liquidity, the market is technically launched but not meaningfully tradable....Recommendation
If immediate trading is part of the product promise, seed protocol-controlled in-range liquidity during launch or finalization instead of relying on third parties to bootstrap the book after the fact. If that is not intended, the docs and UI should describe launch as token distribution first and market making later, so users are not led to assume that a finalized market is automatically liquid and ready for fair price discovery.
Resolution
Trueo: Acknowledged.
Round Three
9 findings-
H-01 High Hook Batching Starts After Full Order Scan DoS Resolved
Description
`movePoolTick()` calls `orderBook.moveTick()` before enforcing `maximumExecutionCount`. That defeats the intended gas cap because `moveTick()` is already the expensive step: it traverses the crossed range, materializes the full crossed set in memory, and deletes those ticks from storage before deferral is considered. This matters because `TruthMarketHook._afterSwap()` executes the logic inside the swap callback. A trader can concentrate enough live orders around a target range that every attempt to cross it pays the same unbounded preprocessing cost and can run out of gas before the callback completes.
Recommendation
Apply the execution budget before materializing the full crossed range. Process only a bounded slice of ticks or packed orders per callback and persist a continuation cursor for resolver transactions.
Resolution
Trueo: Resolved.
-
M-01 Medium Council Or Escalation Rotation Can Brick Markets DoS Acknowledged
Description
`TruthMarketManager.setAddresses()` can rotate `oracleCouncilAddress` and `escalationAddress`, but existing markets do not follow those changes. They cache the original contracts at initialization and continue reading dispute state from the old dependencies. After rotation, the manager authorizes only the new council or escalation contracts, while live markets can still look to the old ones for dispute records. That split can leave later dispute settlement reading stale or zero data and make the market impossible to settle without manual restoration of the historical dependencies.
Recommendation
Do not rotate these addresses while any market can still dispute, escalate, reset, or settle bonds. The safer fix is to resolve dependencies dynamically from the manager or treat them as immutable per market.
Resolution
Trueo: Acknowledged.
-
M-02 Medium Payment Token Drift Can Brick V2 Launches DoS Resolved
Description
`TruthMarketManager.setAddresses()` can change the global `paymentToken`, but `Launchpad` and the V2 distributors keep the token that was wired when they were deployed or initialized. That creates a split configuration where deposits are escrowed in the old token while newly created V2 markets expect the manager’s new token. In that state, proposals can accept deposits successfully and fail only after launch has started, pushing settlement into deferred or manual handling.
Recommendation
Do not let the manager payment token drift independently from the launchpad stack. Treat it as immutable for a deployed stack, or redeploy and rewire the full stack atomically when it changes.
Resolution
Trueo: Resolved.
-
L-01 Low Launchpad Finalize/fee Reverts Bypass Fallback DoS Resolved
Description
`Launchpad._distribute()` only wraps the distributor call in `try/catch`. If the later fee transfer or `finalize()` call reverts, that failure bypasses the fallback path entirely. When that happens, `failedDistributionAttempts` is not incremented and the proposal never progresses toward `ManualDistributionNeeded`. The same revert simply repeats on every retry until an operator changes the external configuration.
Recommendation
Wrap the full post-distribution sequence, not just the distributor call, in the same failure-accounting path so all revert cases are counted and can eventually route to manual handling.
Resolution
Trueo: Resolved.
-
L-02 Low Launchpad V2 Misses Swap-validator Bounds Unexpected Behavior Resolved
Description
The LP-manager flow initializes swap-validator boundaries before minting V2 liquidity, but the launchpad V2 flow does not. If a `TruthMarketHook` swap validator is configured, launchpad-created markets can therefore run with default full-range bounds instead of the intended binary-price envelope. That means equivalent V2 markets can have different protections depending on how they were launched, and operational assumptions about shared swap-boundary behavior become false.
Recommendation
Initialize swap-validator bounds in one canonical V2 launch path and fail closed if a market reaches distribution or finalization without the required boundaries set.
Resolution
Trueo: Resolved.
-
L-03 Low Launchpad V2 Accepts Invalid Tick Spacing Validation Resolved
Description
`LaunchpadFactoryMarketV2.validateCommon()` checks fee and related fields, but it does not validate that `tickSpacing` is positive and within Uniswap V4 bounds. Invalid values can therefore be accepted into a proposal and only fail later when pool initialization is attempted. That turns a known-bad configuration into a deferred launch-time failure instead of rejecting it up front.
Recommendation
Validate `tickSpacing` before proposal acceptance or market creation. At minimum, reject values `<= 0` and values above the supported Uniswap V4 maximum.
Resolution
Trueo: Resolved.
-
L-04 Low Per-deposit Minimum Can Truncate To Zero Rounding Resolved
Description
The community and whales factories derive per-deposit thresholds as `minimumDeposit / 100`. Because this uses integer division, any configured minimum below `100` raw token units truncates to `0`. That weakens the intended dust-deposit and dust-remainder protections, since the hooks then accept deposits and withdrawals that were supposed to fall below the enforced floor.
Recommendation
Do not allow the derived threshold to become zero. Either require `minimumDeposit >= 100` or clamp the hook input to a nonzero floor such as `max(1, minimumDeposit / 100)`.
Resolution
Trueo: Resolved.
-
L-05 Low Dust Partial Fills Requeue Deleted Orders Logical Error Resolved
Description
`_executeOrders()` treats any `false` return from `_executeOrder()` as “order not executed” and requeues the order ID. That is incorrect for dust-settled partial fills. When `_partialFillOrder()` fully settles the remaining dust and deletes the pending order, `_executeOrder()` still returns `false`, so `_executeOrders()` can reinsert an order ID whose storage entry no longer exists.
Recommendation
Return an explicit execution status instead of using one boolean for both “not executed” and “executed but closed.” Requeue an order only if it still exists after execution.
Resolution
Trueo: Resolved.
-
I-01 Informational Launchpad Fee Is Not Snapshotted Per Proposal Trust Assumptions Acknowledged
Description
`setProtocolFee()` and `setFeeReceiver()` can change the fee regime at any time, but `Launchpad` does not snapshot either value per proposal. Withdrawals, launch-time distribution, and manual distribution all use the current global fee settings when execution happens. As a result, users can deposit under one fee regime and settle under another. That makes the effective fee on a live proposal a governance decision at settlement time rather than a property fixed when users entered.
Recommendation
If fee terms should be stable for live proposals, snapshot the fee rate and receiver at `propose()` time and use those values for later settlement. Otherwise, document the behavior clearly and constrain fee changes operationally with a timelock or similar process.
Resolution
Trueo: Acknowledged.
Round Four
4 findings-
M-01 Medium Deferred Dense Tick Can Become Unresolvable DoS Resolved
Description
The tick-range deferral fix pages across ticks, but it still assumes any single tick can always be processed in one transaction. That assumption is unenforced: there is no per-tick order cap. If enough orders accumulate on one threshold, the resolver must still copy, delete, and execute the entire dense tick in one call. That transaction can exceed block gas limits and revert permanently, leaving the affected orders stranded unless operators unwind them manually.
Recommendation
Do not rely on whole-tick execution as the final liveness guarantee. Enforce a hard per-tick order cap that stays comfortably below worst-case gas limits.
Resolution
Trueo: Resolved.
-
M-02 Medium Deferred Replay Can Duplicate Partial Thresholds DoS Resolved
Description
`_executeOrders()` requeues partial-fill thresholds based on the original deferred tick range, assuming `moveTick()` removed every matching threshold. That assumption fails once deferred processing is split across bounded tick batches. If the current batch did not actually remove one of the thresholds, `_executeOrders()` can insert it again. Repeated retries can therefore grow duplicate threshold entries, increase tick density, and eventually turn deferred resolution itself into a gas-driven liveness failure.
Recommendation
Requeue only thresholds that the current `moveTick()` batch actually removed. Pass the processed tick interval or exact removed set into `_executeOrders()` instead of using the original deferred range as a proxy.
Resolution
Trueo: Resolved.
-
L-01 Low Ownership Transfer Leaves Stale Role Admins Trust Assumptions Resolved
Description
`TruthMarketManager` mixes `Ownable` and `AccessControl`, but `transferOwnership()` only updates the owner slot. It does not revoke or migrate `DEFAULT_ADMIN_ROLE` or `PAUSER_ROLE`. As a result, a former owner can retain meaningful control after an apparent governance handoff, including the ability to regrant privileged roles or pause and unpause the system.
Recommendation
Do not leave ownership transfer and role administration decoupled. Ownership handoff should also rotate the critical roles, or the protocol should enforce a formal handoff procedure that completes both steps together.
Resolution
Trueo: Resolved.
-
L-02 Low Hookless V2 Launches Skip The Pause Guard Validation Resolved
Description
`TruthMarketV2Launcher.launch()` only pauses the new pools when both pools share a nonzero compatible hook. A hookless configuration is still treated as valid and returns success instead of reverting. That breaks the deferred-settlement safety assumption that newly launched V2 markets remain inert until distribution succeeds. If settlement is deferred, a hookless market can already be live and interactive while depositors are still unpaid.
Recommendation
Reject launchpad V2 launches when the manager has no compatible pause-capable hook configured. The pause guarantee should be mandatory, not optional.
Resolution
Trueo: Resolved.
No findings match.
More from Trueo
Put your code through the same review.
This review started with a conversation about scope. Tell us what you are building and we will plan yours with you.
