Quantstamp reviewed XDAO on 4 Aug 2025: 14 findings, 0 critical; all marked fixed.
Report hosted on certificate.quantstamp.com.
Trustblock does not review code.
Statuses are the security firm’s own, from its report.
| Severity | Open | Acknowledged | Fixed |
|---|---|---|---|
| Critical | 0 | 0 | 0 |
| High | 0 | 0 | 4 |
| Medium | 0 | 0 | 8 |
| Low | 0 | 0 | 2 |
When a proposal closes successfully, `elections_master.fc` forwards its entire balance—an amount that equals the exact `service_fee` collectively paid by voters—to `master.fc`. Inside the transferred message body, the contract also encodes that same `service_fee` value. Upon receipt, `master.fc` subtracts both the embedded `fee_value` and the constant `BASE_FEE` from the incoming `msg_value` in order to create the forwarding message that executes the chosen action. In the ordinary path, where no actor contributes extra value, this arithmetic becomes negative (for example, `1 TON – 1 TON – 0.05 TON = -0.05 TON`). FunC then raises a cell-underflow (runtime error 9), the transaction reverts, and execution halts. Because governance automation never appends additional coins, the failure is deterministic: every approved action collapses at the first hop unless the last voter that triggers the execution manually “tops up” the trigger transaction so that `msg_value` is at least `service_fee + BASE_FEE`. The flaw therefore turns the DAO into a voting facade; decisions appear to pass, yet no state change can occur under normal use. The problem is aggravated by the bounce handler in `elections_master.fc`. When `master.fc` reverts, the bounce message arrives carrying the full contract balance, and the handler refunds that amount to `data::initiated_by_address`, the proposal creator. Thus, the collective voting pot is silently funneled to a single participant while all other voters lose their gas fees. The combined effect is a denial-of-service on governance, an economic distortion that rewards initiators for failed actions, and a breach of user trust. At its root, the design double-spends `service_fee`: first as a payment to the fee manager and then implicitly as gas for message forwarding. Without an external subsidy, the ledger can never satisfy both obligations, guaranteeing perpetual under-funding and reversion.
The `make_action()` function in the `master` contract recalculates the expected `elections_master` address from input parameters but does not verify that the contract was deployed through the legitimate `create_elections()` flow. An attacker can independently deploy a malicious `elections_master` with a low vote threshold, satisfy it, and invoke `make_action()`, bypassing DAO governance controls and executing arbitrary actions (including contract upgrades).
In `walletV4_plugin.recv_external()`, two calls to `set_data()` rebuild the contract state but omit the `is_initialized` flag and conditional `master_dao_address`, causing misaligned storage. Subsequent parsing (e.g., in get-methods like `seqno()`, `get_subwallet_id()`) will read incorrect offsets, leading to parse errors or unintended behaviors.
In `master.recv_internal()`, the `op::burn_notification` branch updates `data::total_supply` without verifying that `sender_address` matches `data::jetton_master_address`. An attacker can send a spoofed `burn_notification` to arbitrarily change the total supply, bypassing the intended governance or burn mechanisms.
The `_calculate_service_fee()` function in `factory` determines deployment fees based on `data::count`. Its conditional thresholds cause a sharp and counterintuitive fee drop from over 1200 TON at `count = 500_000` to 25 TON at `count = 500_001`, likely due to a misconfigured threshold. Such unpredictable jumps can confuse users and be exploited by timing contract creations around the boundary. After consulting with the team, we get the expected logic to be a multi-tiered, linear progressive fee: * 1st DAO: 0.1 TON. * 1st to 1,000th DAO: A linear increase from 0.1 TON to 2.5 TON. * 1,001st to 500,000th DAO: A linear increase from 2.5 TON to 25 TON. * 500,001st DAO onwards: A fixed fee of 25 TON.
In `walletV4_plugin.recv_external()`, the contract uses `throw_unless(401, is_initialized?)` to gate external messages based on the `is_initialized` flag. This logic is inverted: once `master_dao_address` is set, external messages from the original key holder should be rejected, but the current check allows them. An initialized contract should only accept calls from the DAO, not the key holder, violating the intended security model.
The `on_bounce()` handler in `elections_master` treats any bounced message other than `op::vote_internal` as a failure of the governing action and refunds the entire contract balance to `data::initiated_by_address`. This includes bounces from the `op::make_action` message sent to `master`. If that message fails (e.g., due to gas exhaustion, malformed payloads, or upgrade incompatibilities) the refund goes to the proposal creator rather than preserving funds for voters or protocol maintenance, violating expected fund flows. At the root of the issue is the lack of a clear mechanism to refund the locked fees for a failed proposal. Another failure path occurs when a proposal fails to reach sufficient votes. Both cases should be handled in a consistent manner.
In the `factory` contract, the `op::create_master` operation fails to allocate sufficient gas for minting tokens when a new DAO is created with multiple initial members. It sends each mint request to the `jetton_minter` with a `msg_value` of only `BASE_FEE`, which is inadequate to cover the necessary forwarding fees for token distribution. As a result, the `jetton_minter`'s balance is quickly depleted after processing only a few requests. Subsequent minting operations fail silently without alerting the `factory`, which proceeds with the DAO creation as if all tokens were successfully distributed. This leads to an inconsistent state where the DAO is active, but the token allocation is incomplete and the total supply is miscalculated. In detail: 1. **Initial Underfunding**: When `op::create_master` is executed, the `factory` contract deploys a new `jetton_minter` instance, funding it with a minimal balance of `BASE_FEE`. 2. **Inadequate Minting Fee**: For each initial token holder, the `factory` sends an `op::mint` message to the `jetton_minter`. Each of these messages carries a `msg_value` of `BASE_FEE`. 3. **Fee-Draining Forwarding**: Inside the `jetton_minter`, the `op::mint` handler calls the `mint_tokens` function, which is responsible for sending the newly minted tokens to the jetton wallet. This is done via `send_raw_message` using `mode 1`. This mode requires the sender (the `jetton_minter`) to pay for the message forwarding fees from its own balance. 4. **Silent Failures**: The `BASE_FEE` provided with the `op::mint` call only covers the immediate gas costs of that transaction and does not account for the additional forwarding fees. The `jetton_minter` is forced to pay these fees from its own small balance. After a few transactions, this balance is exhausted. 5. **No Error Propagation**: Once the `jetton_minter`'s balance is depleted, subsequent calls to `send_raw_message` fail. Because `mode 1` is used, the failed transaction does not bounce, and no error is propagated back to the `factory`. The `factory` remains unaware of the failure and continues its execution, leading to a partially failed token distribution.
The `recv_internal()` handler for `op::get_fees` in `fee_manager` constructs the `op::get_fees_notification` payload with extra fields (`.store_slice(data::admin_address)` and `.store_slice(in_msg_body)`) right after the fee dictionary, deviating from the expected format used by `factory`, which expects a `query_id`. As a result, the `factory` contract cannot parse the response correctly, leading to failed fee updates or bounced messages, which can disable contract operations that depend on up-to-date fee data.
The `vote_confirm_notification()` function in `elections_master.fc` is responsible for tallying votes and executing a proposal's action when the vote threshold is met. The function increments `data::current_amount` and, if it exceeds `data::success_amount`, it sets `data::is_executed` to `true` and sends a `make_action` message to the master DAO contract. However, the function lacks an initial check to determine if the proposal has already been executed. If multiple `vote_confirm_notification` messages are processed after the success threshold has been met, the condition `data::current_amount >= data::success_amount` will remain true for each incoming message. Because there is no guard to check the `data::is_executed` flag at the beginning of the function, each of these subsequent messages will also trigger the `make_action` message, leading to the same DAO action being executed multiple times. This could cause unintended consequences, such as minting tokens multiple times, changing DAO parameters repeatedly, or sending funds more than once.
The `recv_internal()` function in `walletV4_plugin` initially filters messages by checking if `op` is not one of `0x706c7567`, `0x64737472`, or `0`. However, the self-destruct logic is triggered on `op == 0xF`, which is not included in the filter. As a result, a crafted internal message with `op == 0xF` will get into the `if (op != 0x706c7567) & (op != 0x64737472) & (op != 0) {}` block and returns early without doing the intended action.
When a new election is initiated, the `elections_master.fc` contract creates a snapshot of critical governance parameters from the main `master.fc` contract. These cached parameters include the `success_amount` (required votes), the `service_fee` for the proposed action, and the code of the contracts involved (`elections_master_code`, `elections_wallet_code`, etc.). A vulnerability exists because if these core parameters are updated in `master.fc` while an election is still active, the ongoing election will continue to operate using the old, stale data. The `make_action()` function in `master.fc`, which executes the final proposal, does not sufficiently validate all of these cached parameters against its current state. Specifically, the following parameters can become stale: 1. `success_amount`: Can change if the `total_supply` is altered (via `op::call_jetton_mint` or `op::burn_notification`) or if the `success_percentage` is modified (via `op::change_success_percentage`). 2. `service_fee`: Can change if the `master.fc` contract receives a fee update from the `fee_manager` (via `op::get_fees_notification`). 3. Contract code: Can change if a proposal to upgrade the DAO's contracts is passed (via `op::upgrade_code`). While the current implementation correctly invalidates proposals from old contract code by recalculating the sender's address, it fails to perform similar checks for `success_amount` and `service_fee`.
In the `factory`’s `op::create_master`, the initial check requires `msg_value > service_fee + BASE_FEE * (6 + mint_messages_count)`, but the refund calculation deducts `service_fee + BASE_FEE * (8 + mint_messages_count)`. This discrepancy allows transactions that pass validation to fail later or refund less than expected, leading to user confusion and potential loss of funds.
In both the `factory`’s `op::create_master` and the `master`’s `op::change_success_percentage`, the code checks that `success_percentage` does not exceed 100%, but it does not enforce a minimum above zero. Generally, we should not expect a proposal to pass with zero votes.
The report names a repository or files. Open the report to see its scope.
236 projects reviewed · 332 reports · latest Oct 2026
See Quantstamp’s auditsSomething wrong on this page? A missing report, a wrong date, a finding that was fixed since. Tell us and we check it against its source.
Tell us