<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Daniel</title>
    <description>The latest articles on DEV Community by Daniel (@f00dat).</description>
    <link>https://dev.to/f00dat</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4061021%2F02eedf53-d6bf-4cff-905e-e7c1b375f7bb.png</url>
      <title>DEV Community: Daniel</title>
      <link>https://dev.to/f00dat</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/f00dat"/>
    <language>en</language>
    <item>
      <title>How a Constructor Bypass Let Malicious CROSS Makers Block Native Pair Matching</title>
      <dc:creator>Daniel</dc:creator>
      <pubDate>Mon, 10 Aug 2026 14:28:38 +0000</pubDate>
      <link>https://dev.to/f00dat/how-a-constructor-bypass-let-malicious-cross-makers-block-native-pair-matching-3n64</link>
      <guid>https://dev.to/f00dat/how-a-constructor-bypass-let-malicious-cross-makers-block-native-pair-matching-3n64</guid>
      <description>&lt;p&gt;A contract can have no runtime code and still already be executing as a contract.&lt;/p&gt;

&lt;p&gt;That construction window is enough to bypass one of CROSS DEX V3's maker restrictions.&lt;/p&gt;

&lt;p&gt;The router tries to reject contract accounts with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function _checkAccountCode(address account) private view {
    if (whitelistedCodeAccounts.contains(account)) return;

    if (account.code.length != 0) {
        revert RouterContractAccountBlocked(account);
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;During a constructor, however:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;address.code.length == 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A malicious maker can therefore submit a sell order while it is still being deployed. After deployment, the same address has runtime code and can reject native CROSS in &lt;code&gt;receive()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That becomes exploitable on native CROSS quote pairs because CROSS WETH automatically unwraps payouts to non pair recipients.&lt;/p&gt;

&lt;p&gt;When an honest buyer matches the malicious sell order, the maker payout becomes a native CROSS transfer. The maker rejects it, the WETH transfer reverts, and the buyer's entire matching transaction rolls back.&lt;/p&gt;

&lt;p&gt;The malicious order remains live with the same amount, so later buyers can hit the same failure again.&lt;/p&gt;

&lt;p&gt;I reported this finding as &lt;strong&gt;Medium&lt;/strong&gt; through the CertiK SkyShield CROSS bug bounty program. It earned &lt;strong&gt;$109.40&lt;/strong&gt;.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7d45viivdx9op0lc9fm0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7d45viivdx9op0lc9fm0.png" alt=" " width="741" height="97"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://gist.github.com/f00dat/f4cd5aaaebc649999cb730606c31f17b" rel="noopener noreferrer"&gt;Public report and PoC&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The demonstrated impact is localized order book griefing, not theft or global DEX unavailability.&lt;/p&gt;
&lt;h2&gt;
  
  
  The constructor bypass creates a valid contract owned order
&lt;/h2&gt;

&lt;p&gt;Order submission uses the router's &lt;code&gt;checkSubmit&lt;/code&gt; path, which eventually calls &lt;code&gt;_checkAccountCode(_msgSender())&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The restriction works for contracts that already have runtime code. The PoC confirms that with a post deployment control.&lt;/p&gt;

&lt;p&gt;The problem is the constructor case.&lt;/p&gt;

&lt;p&gt;The PoC uses &lt;code&gt;CREATE2&lt;/code&gt; so the attacker's future maker address is known before deployment. The attacker approves that predicted address to spend the required BASE tokens.&lt;/p&gt;

&lt;p&gt;Then &lt;code&gt;RejectMaker&lt;/code&gt; is deployed.&lt;/p&gt;

&lt;p&gt;Inside its constructor, it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pulls BASE from the attacker
approves the router
submits a sell limit order
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At that moment the router sees no runtime code and accepts the maker.&lt;/p&gt;

&lt;p&gt;After deployment, the contract has:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;receive() external payable {
    revert RejectNative();
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The order itself remains a normal stored sell order owned by that now deployed contract.&lt;/p&gt;

&lt;p&gt;This matters because matching does not later revalidate whether the owner can safely receive the configured payout.&lt;/p&gt;

&lt;h2&gt;
  
  
  Native CROSS settlement turns the maker into a revert point
&lt;/h2&gt;

&lt;p&gt;When an honest buyer crosses a sell order, &lt;code&gt;PairImplV3&lt;/code&gt; pays the maker in the pair's QUOTE token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function _exchangeSellOrder(
    uint256 orderId,
    address _owner,
    uint256 amount,
    uint256 fee
) private {
    if (fee == 0) {
        QUOTE.safeTransfer(_owner, amount);
    } else {
        uint256 value = amount - fee;
        QUOTE.safeTransfer(_owner, value);
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a normal ERC20 quote asset, the malicious contract can receive the token and matching succeeds.&lt;/p&gt;

&lt;p&gt;Native CROSS pairs are different because QUOTE is CROSS WETH.&lt;/p&gt;

&lt;p&gt;Its transfer path automatically unwraps tokens sent to non pair recipients:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function _update(
    address from,
    address to,
    uint256 value
) internal override {
    super._update(from, to, value);

    if (to != address(0)) {
        if (!IRouterV3(ROUTER).isPair(to)) {
            _burn(to, value);
            payable(to).sendValue(value);
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The maker is not a registered pair.&lt;/p&gt;

&lt;p&gt;So what looks like a WETH payout becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WETH transfer
→ burn
→ native CROSS transfer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The malicious maker rejects that native transfer.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;sendValue&lt;/code&gt; reverts, which propagates through the WETH transfer and through the buyer's match.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failed match does not consume the malicious order
&lt;/h2&gt;

&lt;p&gt;Because the buyer transaction reverts atomically, the attempted trade does not partially complete.&lt;/p&gt;

&lt;p&gt;The PoC verifies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;buyer receives BASE
0

maker receives native CROSS
0

buyer match
REVERTED

malicious order remains live
YES

order amount unchanged
YES
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That persistence is what makes the issue a repeatable liveness problem rather than an isolated failed trade.&lt;/p&gt;

&lt;p&gt;While the order remains in the book, later matching attempts that reach it can encounter the same revert.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four controls isolate the exact cause
&lt;/h2&gt;

&lt;p&gt;The Foundry PoC contains four tests:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test&lt;/th&gt;
&lt;th&gt;What it proves&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;testBug&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A constructor submitted maker order is accepted, an honest native CROSS match reverts, and the order remains unchanged.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;testEOA&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The same native CROSS path succeeds with an EOA maker.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;testToken&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The same malicious contract works when QUOTE is a normal ERC20.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;testBlock&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A contract submitting after deployment is correctly rejected by the router.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The reproduced run completed with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;4 passed
0 failed
0 skipped
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These controls rule out the main false positives.&lt;/p&gt;

&lt;p&gt;The issue is not that native pair matching is generally broken.&lt;/p&gt;

&lt;p&gt;It is not that contract makers inherently break every quote asset.&lt;/p&gt;

&lt;p&gt;And it is not that the router completely fails to block deployed contracts.&lt;/p&gt;

&lt;p&gt;The failure requires this specific chain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;constructor submission
→ contract maker stored
→ native CROSS quote
→ WETH auto unwrap
→ rejecting receive()
→ settlement revert
→ full rollback
→ order remains live
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why this is Medium, not Low
&lt;/h2&gt;

&lt;p&gt;The demonstrated impact is griefing and matching liveness failure, not theft.&lt;/p&gt;

&lt;p&gt;That limitation is real and is why I do not classify the issue as High or Critical. But it does not reduce the finding to an informational or minor implementation quirk.&lt;/p&gt;

&lt;p&gt;The downgrade rationale identifies several constraints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;the order must be submitted during construction
the pair must use CROSS on the maker payout side
the attacker must lock their own BASE into the malicious order
the impact is localized rather than protocol wide
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqpfc513nrrftvu522efc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqpfc513nrrftvu522efc.png" alt=" " width="754" height="163"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Those are valid exploit preconditions and they should be included in the severity analysis.&lt;/p&gt;

&lt;p&gt;They do not, however, change what happens once the malicious order has been accepted.&lt;/p&gt;

&lt;p&gt;The PoC reaches a deterministic state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;constructor submitted order accepted
→ contract finishes deployment
→ maker rejects native CROSS
→ honest buyer reaches the order
→ CROSS WETH auto unwraps the maker payout
→ receive() reverts
→ buyer matching transaction reverts
→ the order and its amount roll back unchanged
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the malicious order survives the failed transaction, the failure is repeatable. A later matching attempt that reaches the same order can hit the same revert again.&lt;/p&gt;

&lt;h3&gt;
  
  
  The attack requires attacker supplied inventory
&lt;/h3&gt;

&lt;p&gt;The attacker must fund the sell order with BASE.&lt;/p&gt;

&lt;p&gt;That is a meaningful constraint and it makes the attack less attractive than a zero cost denial of service.&lt;/p&gt;

&lt;p&gt;The article should not hide that fact.&lt;/p&gt;

&lt;p&gt;But requiring the attacker to place real inventory into a valid looking maker order does not eliminate the impact on honest takers. The protocol accepts that order into the book, and once settlement reaches it, the buyer cannot complete the match because the protocol's own payout path invokes a hostile native receive hook.&lt;/p&gt;

&lt;p&gt;The report does not need to assume that the attacker's locked BASE is stolen, lost, or recoverable through some unproven path. The relevant demonstrated fact is narrower: the funded order exists, remains live after the failed match, and can continue to poison settlement while it remains in the book.&lt;/p&gt;

&lt;h3&gt;
  
  
  The taker failure is caused by protocol settlement
&lt;/h3&gt;

&lt;p&gt;The honest buyer is not directly choosing to send native CROSS to the maker.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;PairImplV3&lt;/code&gt; pays the sell maker through &lt;code&gt;QUOTE.safeTransfer&lt;/code&gt;, and CROSS WETH itself converts that transfer into native CROSS for a non pair recipient.&lt;/p&gt;

&lt;p&gt;There is no recovery path around the reverting &lt;code&gt;sendValue&lt;/code&gt; inside the matching transaction shown by the PoC. The revert propagates and rolls the transaction back.&lt;/p&gt;

&lt;p&gt;That makes the failure part of the normal in scope matching path, not merely a malicious contract reverting an unrelated call.&lt;/p&gt;

&lt;h3&gt;
  
  
  Localized does not mean negligible
&lt;/h3&gt;

&lt;p&gt;The downgrade correctly notes that the issue does not compromise protocol wide solvency, ownership, or accounting.&lt;/p&gt;

&lt;p&gt;I agree.&lt;/p&gt;

&lt;p&gt;The PoC also does not demonstrate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;direct theft
unauthorized minting
permanent user fund loss
full DEX unavailability
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those limitations are exactly why the report was submitted as Medium griefing.&lt;/p&gt;

&lt;p&gt;But the affected path is still a real user facing trading path. An order accepted by the router can become persistently unmatchable on a native CROSS pair and make honest buyer transactions revert whenever matching reaches it.&lt;/p&gt;

&lt;p&gt;That is more than a cosmetic inconsistency or a best practice observation.&lt;/p&gt;

&lt;h3&gt;
  
  
  The contract restriction is intentionally enforced by the router
&lt;/h3&gt;

&lt;p&gt;CROSS explicitly blocks non whitelisted accounts that already have runtime code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if (account.code.length != 0) {
    revert RouterContractAccountBlocked(account);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The PoC confirms this policy with &lt;code&gt;testBlock&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The constructor path defeats that restriction only because runtime code has not been installed yet. The accepted address later becomes exactly the type of deployed contract the router normally refuses as a maker.&lt;/p&gt;

&lt;p&gt;That bypass would still be incomplete as a report if no downstream effect existed.&lt;/p&gt;

&lt;p&gt;Here there is one: the stored contract maker can make the native payout revert and abort honest matching.&lt;/p&gt;

&lt;h3&gt;
  
  
  The controls support Medium griefing
&lt;/h3&gt;

&lt;p&gt;The four tests isolate the impact:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;post deployment contract submission blocked
YES

constructor submitted contract order accepted
YES

EOA maker on native CROSS succeeds
YES

same malicious maker on non native quote succeeds
YES

native CROSS match against malicious maker reverts
YES

buyer receives BASE
NO

maker receives native CROSS
NO

malicious order remains live after revert
YES

order amount remains unchanged
YES
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The issue is therefore conditional and narrow, but it is also concrete and repeatable.&lt;/p&gt;

&lt;p&gt;That maps cleanly to the report's original framing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Smart Contract Medium: Griefing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I therefore consider Medium technically better supported by the demonstrated behavior than Low.&lt;/p&gt;

&lt;p&gt;The downgrade's prerequisites reduce exploitability and blast radius. They do not turn a repeatable failure of honest matching against an accepted order into a non impactful implementation detail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix the settlement dependency
&lt;/h2&gt;

&lt;p&gt;The most direct fix is to prevent arbitrary contract receive hooks from controlling DEX matching.&lt;/p&gt;

&lt;p&gt;The report recommends auto unwrapping WETH only for EOAs.&lt;/p&gt;

&lt;p&gt;Contract recipients should receive WETH instead and explicitly unwrap it later if they can safely accept native CROSS.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if (
    !IRouterV3(ROUTER).isPair(to)
    &amp;amp;&amp;amp; to.code.length == 0
) {
    _burn(to, value);
    payable(to).sendValue(value);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A separate explicit &lt;code&gt;withdraw()&lt;/code&gt; path can let contract recipients unwrap voluntarily.&lt;/p&gt;

&lt;p&gt;That changes the security property from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;maker receive() decides
whether matching succeeds
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;matching completes in WETH
maker decides later
whether to unwrap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The router's account restriction should also not assume that &lt;code&gt;code.length == 0&lt;/code&gt; proves an address will permanently behave like an EOA.&lt;/p&gt;

&lt;p&gt;But the settlement fix is more important because it removes the ability of an untrusted recipient hook to revert an otherwise valid match.&lt;/p&gt;

&lt;h2&gt;
  
  
  Broader audit lesson
&lt;/h2&gt;

&lt;p&gt;This finding comes from combining two behaviors that look manageable on their own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;constructor:
code.length == 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WETH transfer:
automatic native unwrap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The vulnerability appears only when the entire lifecycle is followed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;maker validation
→ constructor order
→ stored owner
→ later matching
→ quote payout
→ auto unwrap
→ receive() revert
→ transaction rollback
→ order survives
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the broader lesson.&lt;/p&gt;

&lt;p&gt;If a protocol validates an account only when state is created, ask whether the account can behave differently when that state is consumed later.&lt;/p&gt;

&lt;p&gt;And if settlement sends native value to an arbitrary recipient, ask whether that recipient can make unrelated users' transactions fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;CROSS DEX V3 tries to block contract makers by checking &lt;code&gt;account.code.length&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A constructor can bypass that restriction because runtime code is still absent.&lt;/p&gt;

&lt;p&gt;Using &lt;code&gt;CREATE2&lt;/code&gt;, the PoC funds a predicted maker address and submits a sell order from inside the maker's constructor. After deployment, the same maker rejects native CROSS.&lt;/p&gt;

&lt;p&gt;When an honest buyer later matches that order on a native CROSS quote pair, CROSS WETH automatically unwraps the maker payout into native CROSS.&lt;/p&gt;

&lt;p&gt;The maker rejects the transfer.&lt;/p&gt;

&lt;p&gt;The entire buyer match reverts, and the malicious order remains live with the same amount.&lt;/p&gt;

&lt;p&gt;The core invariant is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A maker's payout behavior should not be able to make honest taker matching revert.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A stored order should be safe to settle even if its owner is a contract with hostile native receive behavior.&lt;/p&gt;

&lt;p&gt;That repeatable, user facing matching failure is why I classify the issue as Medium rather than Low.&lt;/p&gt;

</description>
      <category>smartcontract</category>
      <category>blockchain</category>
      <category>security</category>
      <category>web3</category>
    </item>
    <item>
      <title>How a Positive offsetSeconds Bug Let a CROSS Forge Mint Twice the Intended ERC20 Period Limit</title>
      <dc:creator>Daniel</dc:creator>
      <pubDate>Mon, 10 Aug 2026 13:32:35 +0000</pubDate>
      <link>https://dev.to/f00dat/how-a-positive-offsetseconds-bug-let-a-cross-forge-mint-2x-the-intended-erc20-period-limit-eon</link>
      <guid>https://dev.to/f00dat/how-a-positive-offsetseconds-bug-let-a-cross-forge-mint-2x-the-intended-erc20-period-limit-eon</guid>
      <description>&lt;p&gt;&lt;code&gt;ERC20MintLimited&lt;/code&gt; is not an unlimited forge minter.&lt;/p&gt;

&lt;p&gt;It authorizes a forge to mint only while that forge remains inside the configured capacity for the current period.&lt;/p&gt;

&lt;p&gt;This finding breaks that second authorization boundary.&lt;/p&gt;

&lt;p&gt;With a positive &lt;code&gt;offsetSeconds&lt;/code&gt;, CROSS calculates the current period start incorrectly. At the native UTC boundary, &lt;code&gt;PeriodManager.getPeriodStartForTime()&lt;/code&gt; can return the next offset boundary even though that boundary is still in the future.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ERC20PeriodMintLimit&lt;/code&gt; interprets the changed timestamp as a new period and restores a full &lt;code&gt;LIMIT&lt;/code&gt; of capacity before the real configured period has ended.&lt;/p&gt;

&lt;p&gt;The PoC demonstrates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Configured limit
100,000 tokens per period

First mint
100,000 tokens

Remaining capacity
0

Configured +9h period ended?
NO

Capacity at UTC boundary
100,000 tokens

Second mint
100,000 tokens

Total inside the same intended period
200,000 tokens
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I reported the finding as &lt;strong&gt;Critical&lt;/strong&gt; through the CertiK SkyShield CROSS bug bounty program. CROSS finalized it as Low, and the report earned &lt;strong&gt;$109.40&lt;/strong&gt;.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5vk92mysafqto5t2wrzk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5vk92mysafqto5t2wrzk.png" alt=" " width="731" height="67"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://gist.github.com/f00dat/fe7084f0045a3e69434ad973ad2a21d2" rel="noopener noreferrer"&gt;Public report and PoC&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The severity dispute turns on whether forge membership alone authorizes the amount being minted. The contract does not support that interpretation.&lt;/p&gt;
&lt;h2&gt;
  
  
  The mint policy has two gates
&lt;/h2&gt;

&lt;p&gt;The base mint path requires &lt;code&gt;onlyForge&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;modifier onlyForge() {
    if (!isForge(_msgSender())) {
        revert TokenBase__OnlyForge(_msgSender());
    }
    _;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But &lt;code&gt;ERC20MintLimited&lt;/code&gt; routes minting through &lt;code&gt;ERC20PeriodMintLimit&lt;/code&gt;, which also checks the available period capacity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if (periodCapacity &amp;lt; amount) {
    revert ERC20PeriodMintLimit__ExceedsPeriodLimit(
        amount,
        periodCapacity
    );
}

_periodCapacity = periodCapacity - amount;

super.mint(to, amount);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The effective policy is therefore:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;caller is an authorized forge

AND

amount &amp;lt;= remaining capacity
for the current configured period
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The PoC does not claim that a random EOA bypasses &lt;code&gt;onlyForge&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It proves that a forge with zero remaining capacity can receive a new full capacity before the period actually ends and mint supply that the period limit should reject.&lt;/p&gt;

&lt;h2&gt;
  
  
  The positive offset formula is wrong
&lt;/h2&gt;

&lt;p&gt;For positive offsets, the vulnerable implementation effectively computes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;floor(timestamp / duration)
× duration
+ offsetSeconds
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For an offset period, the timestamp must be shifted before flooring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;floor(
    (timestamp - offsetSeconds)
    / duration
)
× duration
+ offsetSeconds
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The PoC uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;duration = 86,400 seconds
offsetSeconds = 32,400 seconds
LIMIT = 100,000 tokens
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The intended period is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[1000 days + 9 hours,
 1001 days + 9 hours)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One second before the UTC boundary, the forge mints the full &lt;code&gt;LIMIT&lt;/code&gt;. Capacity becomes zero, and an additional one wei mint is rejected.&lt;/p&gt;

&lt;p&gt;Then the timestamp reaches:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1001 days
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The configured &lt;code&gt;+9h&lt;/code&gt; period still has nine hours remaining.&lt;/p&gt;

&lt;p&gt;The correct current period start is still:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1000 days + 9 hours
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The PoC records:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;block.timestamp
86486400

expected period start
86432400

actual period start
86518800
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So CROSS returns a current period start that is greater than &lt;code&gt;block.timestamp&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That impossible timestamp is the root cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  The future timestamp restores capacity
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ERC20PeriodMintLimit&lt;/code&gt; resets capacity whenever the calculated period start changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;uint256 currentPeriodStart =
    _period.getCurrentPeriodStart();

if (currentPeriodStart != _periodStartTime) {
    _periodStartTime = currentPeriodStart;
    periodCapacity = _limit;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At the UTC boundary, the bad positive offset calculation changes &lt;code&gt;currentPeriodStart&lt;/code&gt; even though the real configured period is still active.&lt;/p&gt;

&lt;p&gt;Capacity is restored to &lt;code&gt;LIMIT&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The same forge then mints another full &lt;code&gt;LIMIT&lt;/code&gt;, and the call reaches the real ERC20 mint path.&lt;/p&gt;

&lt;p&gt;This is not merely an incorrect view value. &lt;code&gt;totalSupply&lt;/code&gt; increases.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PoC isolates the bug
&lt;/h2&gt;

&lt;p&gt;The Foundry proof uses the real &lt;code&gt;ERC20MintLimited&lt;/code&gt; preset, &lt;code&gt;ERC20PeriodMintLimit&lt;/code&gt;, &lt;code&gt;PeriodManager&lt;/code&gt;, &lt;code&gt;onlyForge&lt;/code&gt;, and ERC20 mint path.&lt;/p&gt;

&lt;p&gt;It contains three tests:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test&lt;/th&gt;
&lt;th&gt;What it proves&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;testBug&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The full limit is consumed, capacity resets too early, and the same forge mints a second full limit before the actual period ends.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;testControl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;An extra mint is correctly rejected while capacity is legitimately zero before the false reset.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;testAccess&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A non forge caller cannot mint.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The run completed with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3 passed
0 failed
0 skipped
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The relevant result is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Non forge mint
REJECTED

First forge mint
LIMIT

Capacity afterward
0

Extra forge mint before UTC
REJECTED

Configured +9h period still active at UTC
YES

Returned period start in future
YES

Capacity at UTC
LIMIT

Second forge mint
LIMIT

Total minted in intended period
2 × LIMIT

Excess over configured limit
1 × LIMIT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is why the issue cannot be reduced to a generic access control discussion. Access control is working. The quantitative mint authorization is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I classified it as Critical
&lt;/h2&gt;

&lt;p&gt;At the time of submission, the CROSS SkyShield scope listed &lt;strong&gt;Unauthorized mint, burn, or transfer of crypto assets&lt;/strong&gt; as a Critical smart contract impact.&lt;/p&gt;

&lt;p&gt;My report maps the second full &lt;code&gt;LIMIT&lt;/code&gt; mint to that category because the supply exceeds the amount authorized by the preset for the active period.&lt;/p&gt;

&lt;p&gt;After the first mint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;remaining period capacity = 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before the false boundary, another mint correctly reverts.&lt;/p&gt;

&lt;p&gt;The real configured period has not changed one second later. Only the incorrect &lt;code&gt;PeriodManager&lt;/code&gt; result changes.&lt;/p&gt;

&lt;p&gt;That result creates capacity that should not exist and allows another full supply increase.&lt;/p&gt;

&lt;p&gt;If forge membership alone authorized every amount, &lt;code&gt;ERC20MintLimited&lt;/code&gt; would add no security boundary beyond &lt;code&gt;onlyForge&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;But it does add one: a per period quantitative limit.&lt;/p&gt;

&lt;p&gt;The PoC demonstrates one full &lt;code&gt;LIMIT&lt;/code&gt; of supply above that limit.&lt;/p&gt;

&lt;p&gt;That is the technical basis for my Critical assessment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Low rationale is not supported by the PoC
&lt;/h2&gt;

&lt;p&gt;The downgrade explanation raised four points:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Downgrade point&lt;/th&gt;
&lt;th&gt;What the code and PoC show&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Minting remains restricted to forges&lt;/td&gt;
&lt;td&gt;Correct, but the finding is an amount authorization bypass, not a forge identity bypass.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The effect is bounded and timing dependent&lt;/td&gt;
&lt;td&gt;The bound is still one full extra &lt;code&gt;LIMIT&lt;/code&gt; in the demonstrated period. Timing is deterministic at the native boundary.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Native aligned windows remain near &lt;code&gt;LIMIT&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The affected positive offset configuration is supported. A different configuration working correctly does not repair this one.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;DEFAULT_ADMIN_ROLE&lt;/code&gt; can adjust &lt;code&gt;LIMIT&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;A manual parameter response does not fix the incorrect period calculation or restore automatic enforcement.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of those points changes the core state transition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;period not ended
capacity already 0
extra mint should revert
future period start returned
capacity reset to LIMIT
second full LIMIT mint succeeds
totalSupply increases
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I therefore consider the Low rationale technically inconsistent with the behavior demonstrated by the contract and PoC.&lt;/p&gt;

&lt;p&gt;That conclusion does not require speculating about motive. The public evidence supports a strong technical disagreement. It does not establish bad faith.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bounded does not mean harmless
&lt;/h2&gt;

&lt;p&gt;The PoC uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LIMIT = 100,000 tokens
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and produces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;intended maximum
100,000 tokens

actual minted
200,000 tokens

excess
100,000 tokens
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The issue does not need infinite minting to violate the security policy.&lt;/p&gt;

&lt;p&gt;The entire purpose of the rate limit is to prevent the forge from creating that additional supply before the configured period ends.&lt;/p&gt;

&lt;p&gt;The relevant question is whether the contract creates supply that its own limit logic should reject.&lt;/p&gt;

&lt;p&gt;The PoC shows that it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix
&lt;/h2&gt;

&lt;p&gt;The offset must be applied before the timestamp is floored.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;shifted =
timestamp - offset

periodStart =
shifted
- (shifted % duration)
+ offset
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The period calculation should always preserve:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;periodStart &amp;lt;= timestamp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;timestamp &amp;lt; periodStart + duration
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the PoC configuration, the regression should be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;mint LIMIT before UTC boundary
SUCCEEDS

mint 1 extra wei
REVERTS

reach UTC boundary
capacity remains 0

mint LIMIT
REVERTS

reach actual +9h boundary
capacity resets to LIMIT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The report also recommends normalizing offsets by the period duration so equivalent offsets map to the same canonical boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Broader audit lesson
&lt;/h2&gt;

&lt;p&gt;Role authorization and quantitative authorization are separate controls.&lt;/p&gt;

&lt;p&gt;A protocol can correctly enforce:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;who may call
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;while incorrectly enforcing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;how much that caller may do
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same reasoning applies to mint quotas, withdrawal ceilings, bridge caps, spending limits, epoch budgets, and rate limits.&lt;/p&gt;

&lt;p&gt;When a quantity is part of the policy, bypassing that quantity is an authorization failure even if the caller identity is valid.&lt;/p&gt;

&lt;p&gt;Time based controls add another useful invariant:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A function returning the start of the current period must never return a timestamp in the future.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That invariant would have exposed this bug directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The CROSS bug is a positive offset period accounting failure in the mint limit path.&lt;/p&gt;

&lt;p&gt;After an authorized forge consumes its full &lt;code&gt;LIMIT&lt;/code&gt;, capacity should remain zero until the actual offset boundary.&lt;/p&gt;

&lt;p&gt;Instead, at the earlier UTC boundary, &lt;code&gt;PeriodManager&lt;/code&gt; can report the next &lt;code&gt;+9h&lt;/code&gt; boundary as the current period start.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ERC20PeriodMintLimit&lt;/code&gt; treats that future timestamp as a new period, restores a full &lt;code&gt;LIMIT&lt;/code&gt;, and allows another full mint while the same configured period is still active.&lt;/p&gt;

&lt;p&gt;The PoC proves that non forge callers remain blocked, exhausted capacity normally rejects extra minting, the period has not ended, capacity resets early, and total minting reaches &lt;code&gt;2 × LIMIT&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The relevant authorization question is not only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;is the caller a forge?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;is the caller a forge
and does it still have capacity
inside the current configured period?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second condition fails because of CROSS's period calculation.&lt;/p&gt;

&lt;p&gt;That is why I reported the finding as Critical, and why I do not consider the Low downgrade technically supported by the demonstrated behavior.&lt;/p&gt;

</description>
      <category>smartcontract</category>
      <category>blockchain</category>
      <category>security</category>
      <category>web3</category>
    </item>
    <item>
      <title>How Double Subtracting cash_reserve Reopened Full CurrentSui Markets and Diverted Liquidity Mining Rewards</title>
      <dc:creator>Daniel</dc:creator>
      <pubDate>Mon, 10 Aug 2026 12:48:00 +0000</pubDate>
      <link>https://dev.to/f00dat/how-double-subtracting-cashreserve-reopened-full-currentsui-markets-and-diverted-liquidity-mining-160m</link>
      <guid>https://dev.to/f00dat/how-double-subtracting-cashreserve-reopened-full-currentsui-markets-and-diverted-liquidity-mining-160m</guid>
      <description>&lt;p&gt;A deposit cap is supposed to answer one simple question:&lt;/p&gt;

&lt;p&gt;Is this market already full?&lt;/p&gt;

&lt;p&gt;In CurrentSui, that answer can become wrong once the protocol has accumulated reserve.&lt;/p&gt;

&lt;p&gt;The reason is a double subtraction of &lt;code&gt;cash_reserve&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The protocol’s &lt;code&gt;total_deposit_plus_interest()&lt;/code&gt; value already represents depositor backing net of reserve. But &lt;code&gt;deposit_limit_breached()&lt;/code&gt; subtracts &lt;code&gt;cash_reserve&lt;/code&gt; again before comparing the result with &lt;code&gt;max_deposit_amount&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That makes current utilization look smaller than it really is.&lt;/p&gt;

&lt;p&gt;Once positive reserve exists, a market that is already full according to the protocol’s own depositor backing metric can admit another deposit that should have been rejected.&lt;/p&gt;

&lt;p&gt;The impact becomes more interesting when deposit liquidity mining is active. An extra deposit accepted through the broken cap receives cTokens, enters the reward share set, and starts receiving campaign emissions.&lt;/p&gt;

&lt;p&gt;The PoC demonstrated this with an exact reserve state: a market already at its configured cap accepted an additional deposit equal to 8,000 USDC of reserve, and that new depositor later captured more than 1,000 USDC of rewards that would otherwise have remained with the original lender.&lt;/p&gt;

&lt;p&gt;I reported this finding as Medium in the Sherlock CurrentSui contest. It earned $92.24.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsjc3jfohcna3xhv4ozri.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsjc3jfohcna3xhv4ozri.png" alt=" " width="800" height="217"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://audits.sherlock.xyz/contests/1256/voting/244" rel="noopener noreferrer"&gt;Public Sherlock submission&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The flash loan path used in the PoC is only a deterministic way to create positive reserve. The exploit itself is the ordinary public deposit path.&lt;/p&gt;
&lt;h2&gt;
  
  
  The cap is supposed to measure total market deposits
&lt;/h2&gt;

&lt;p&gt;CurrentSui defines &lt;code&gt;max_deposit_amount&lt;/code&gt; as the maximum amount that can be deposited across the market:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/// max amount this asset can be deposited across the market
max_deposit_amount: u64,
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/sherlock-audit/2026-03-currentsui-contest-march-2026/blob/main/sui-move-contract/contracts/protocol/sources/internal/market/asset.move#L17-L20" rel="noopener noreferrer"&gt;asset.move&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The intended invariant is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;current depositor backing
+
new deposit
&amp;lt;=
max_deposit_amount
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If current backing is already equal to the configured cap, a new deposit should fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Depositor backing already removes cash_reserve
&lt;/h2&gt;

&lt;p&gt;The important accounting path begins in &lt;code&gt;reserve.move&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public(package) fun cash_plus_borrows_minus_reserves&amp;lt;MarketType&amp;gt;(
    self: &amp;amp;Reserve&amp;lt;MarketType&amp;gt;
): Decimal {
    self.debt.add_u64(self.cash).sub(self.cash_reserve)
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exchange rate uses that value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public(package) fun exchange_rate&amp;lt;MarketType&amp;gt;(
    self: &amp;amp;Reserve&amp;lt;MarketType&amp;gt;
): Decimal {
    if (self.total_supply == 0) {
        return float::from_quotient(1, 1)
    };

    let numerator = self.cash_plus_borrows_minus_reserves();
    let denominator = float::from(self.total_supply);

    numerator.div(denominator)
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;total_deposit_plus_interest()&lt;/code&gt; is calculated from the exchange rate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public(package) fun total_deposit_plus_interest&amp;lt;MarketType&amp;gt;(
    self: &amp;amp;Reserve&amp;lt;MarketType&amp;gt;
): Decimal {
    self.exchange_rate().mul_u64(self.total_supply)
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/sherlock-audit/2026-03-currentsui-contest-march-2026/blob/main/sui-move-contract/contracts/protocol/sources/internal/market/reserve.move#L74-L100" rel="noopener noreferrer"&gt;reserve.move&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So the resulting depositor backing already reflects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;debt
+
cash
-
cash_reserve
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That reserve subtraction has already happened before the deposit cap check runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The deposit gate subtracts the same reserve again
&lt;/h2&gt;

&lt;p&gt;The bug is in &lt;code&gt;deposit_limit_breached()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public(package) fun deposit_limit_breached&amp;lt;MarketType&amp;gt;(
    self: &amp;amp;Reserve&amp;lt;MarketType&amp;gt;,
    increment: u64,
    limit: u64
): bool {
    let total_deposit_plus_interest =
        self.total_deposit_plus_interest();

    total_deposit_plus_interest.ceil()
        + increment
        - self.cash_reserve.ceil()
        &amp;gt; limit
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/sherlock-audit/2026-03-currentsui-contest-march-2026/blob/main/sui-move-contract/contracts/protocol/sources/internal/market/reserve.move#L82-L89" rel="noopener noreferrer"&gt;reserve.move&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Conceptually, the intended check is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;current depositor backing
+
new deposit
&amp;gt;
cap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But the implementation behaves like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;current depositor backing
+
new deposit
-
cash_reserve
&amp;gt;
cap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because &lt;code&gt;current depositor backing&lt;/code&gt; was already net of reserve, the gate understates utilization by the reserve term again.&lt;/p&gt;

&lt;p&gt;In the PoC, the broken check reopens apparent capacity equal to the 8,000 USDC reserve.&lt;/p&gt;

&lt;h2&gt;
  
  
  The public deposit path trusts the broken result
&lt;/h2&gt;

&lt;p&gt;The market deposit flow directly relies on that helper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;assert!(
    !reserve.deposit_limit_breached&amp;lt;MarketType&amp;gt;(
        deposit_amount,
        asset_config.max_deposit_amount(),
    ),
    error::market_deposit_limit_exceeded(),
);

let ctoken = reserve.mint_ctokens(coin);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/sherlock-audit/2026-03-currentsui-contest-march-2026/blob/main/sui-move-contract/contracts/protocol/sources/internal/market/market.move#L264-L300" rel="noopener noreferrer"&gt;market.move&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There is no later cap reconciliation.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;deposit_limit_breached()&lt;/code&gt; returns false, the deposit is accepted and cTokens are minted.&lt;/p&gt;

&lt;p&gt;That is what turns the accounting mistake into a real public state transition.&lt;/p&gt;

&lt;h2&gt;
  
  
  Positive reserve is a normal protocol state
&lt;/h2&gt;

&lt;p&gt;The PoC uses flash loan fees because they make it easy to construct an exact reserve amount without changing depositor backing or depositor shares.&lt;/p&gt;

&lt;p&gt;But the vulnerability is not specific to flash loans.&lt;/p&gt;

&lt;p&gt;The protocol can accumulate reserve through ordinary mechanisms, including borrow interest accrual, liquidation revenue, over repayment donated to reserve, and retained flash loan fees.&lt;/p&gt;

&lt;p&gt;For example, borrow interest accrual increases &lt;code&gt;cash_reserve&lt;/code&gt; here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let interest_accumulated =
    self.debt.mul(simple_interest_factor);

self.debt =
    self.debt.add(interest_accumulated);

self.cash_reserve =
    self.cash_reserve.add(
        reserve_factor.mul(interest_accumulated)
    );
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/sherlock-audit/2026-03-currentsui-contest-march-2026/blob/main/sui-move-contract/contracts/protocol/sources/internal/market/reserve.move#L139-L143" rel="noopener noreferrer"&gt;reserve.move&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So an ordinary user can encounter the vulnerable state after reserve has accumulated naturally.&lt;/p&gt;

&lt;p&gt;The attacker does not need to control the mechanism that created the reserve.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the flash loan setup does not make the exploit privileged
&lt;/h2&gt;

&lt;p&gt;The test uses the real flash loan fee path because it creates a clean state where:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;depositor backing
unchanged

depositor shares
unchanged

protocol reserve
positive
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Flash loan borrowing itself requires a permissioned caller, but that permission is only used to build the test state.&lt;/p&gt;

&lt;p&gt;The action that exploits the broken cap is the normal public &lt;code&gt;deposit()&lt;/code&gt; entry point.&lt;/p&gt;

&lt;p&gt;Once positive reserve already exists, the attacker only needs an ordinary position and the ability to deposit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The accepted deposit immediately affects liquidity mining
&lt;/h2&gt;

&lt;p&gt;After &lt;code&gt;handle_mint()&lt;/code&gt; succeeds, the deposit entry point updates the user’s deposit reward share:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let (
    ctoken_amount,
    total_ctoken_amount
) = market.handle_mint(
    obligation_owner_cap.id(),
    coin,
    now
);

let miner =
    market.borrow_liquidity_mining_mut&amp;lt;MarketType&amp;gt;();

miner.update_obligation_reward_manager&amp;lt;
    MarketType,
    CoinType
&amp;gt;(
    get_deposit_reward_type(),
    obligation_owner_cap.id(),
    total_ctoken_amount,
    clock
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/sherlock-audit/2026-03-currentsui-contest-march-2026/blob/main/sui-move-contract/contracts/protocol/sources/entry_points/lending/deposit.move#L49-L57" rel="noopener noreferrer"&gt;deposit.move&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That update changes the reward manager share accounting used to distribute campaign emissions.&lt;/p&gt;

&lt;p&gt;So the broken cap does not stop at market capacity.&lt;/p&gt;

&lt;p&gt;It changes who participates in reward distribution.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PoC proves the full chain
&lt;/h2&gt;

&lt;p&gt;The Move PoC uses seven tests, each isolating a different part of the finding:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test&lt;/th&gt;
&lt;th&gt;What it proves&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cap_fail&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The exploit sized deposit correctly fails when no reserve has created false capacity.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gate_lies&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;After reserve exists, the market is still economically full but &lt;code&gt;deposit_limit_breached()&lt;/code&gt; returns false for the extra deposit.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cap_pass&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The attacker successfully deposits the full reopened amount through the public path.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;reserve_8k&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The setup proves the exact reserve amount and computes the expected reward diversion.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mine_full&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Without the attacker, the original lender receives the full campaign reward.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mine_1k&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The accepted extra deposit produces a measurable attacker reward and an equal lender loss.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;live_1k&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The same diversion works after the reward campaign is already active.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The root cause is demonstrated especially clearly by &lt;code&gt;gate_lies&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Before the attacker deposits, the test proves that depositor backing remains equal to the cap and that the new deposit would push backing above it.&lt;/p&gt;

&lt;p&gt;Yet the helper still says the limit is not breached:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;assert!(
    !r.deposit_limit_breached&amp;lt;MainMarket&amp;gt;(
        target,
        cap0
    ),
    37
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The later reward tests then show why that incorrect admission matters economically.&lt;/p&gt;

&lt;p&gt;Without the exploit, the lender receives the full campaign. With the extra deposit, the attacker receives more than 1,000 USDC, and the lender’s reduction matches the attacker’s gain.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;live_1k&lt;/code&gt; test also starts the campaign before the attacker enters, proving that the attacker can dilute an already active reward program rather than merely joining some future campaign.&lt;/p&gt;

&lt;p&gt;The complete test run passed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Test result: OK.
Total tests: 7; passed: 7; failed: 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The evidence therefore forms one continuous path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;full market
→ positive reserve
→ utilization undercounted
→ extra public deposit accepted
→ reward shares increased
→ attacker receives campaign rewards
→ original lender loses the same value
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why I reported it as Medium
&lt;/h2&gt;

&lt;p&gt;The impact demonstrated by the PoC is direct user yield loss.&lt;/p&gt;

&lt;p&gt;The attacker enters a market that should reject new deposits, becomes part of the active deposit reward share set, and receives emissions that would otherwise have remained with existing lenders.&lt;/p&gt;

&lt;p&gt;The test demonstrates a four figure attacker reward and an equal lender loss.&lt;/p&gt;

&lt;p&gt;At the same time, exploitation depends on positive protocol reserve and an active deposit liquidity mining campaign.&lt;/p&gt;

&lt;p&gt;Those are realistic protocol states, but they are still required conditions.&lt;/p&gt;

&lt;p&gt;That is why I reported the finding as Medium.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is straightforward
&lt;/h2&gt;

&lt;p&gt;The cap should use one consistent measure of depositor backing.&lt;/p&gt;

&lt;p&gt;Since &lt;code&gt;total_deposit_plus_interest()&lt;/code&gt; already excludes &lt;code&gt;cash_reserve&lt;/code&gt;, the second reserve subtraction should be removed.&lt;/p&gt;

&lt;p&gt;A safe check is conceptually equivalent to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;self.total_deposit_plus_interest().ceil()
    + increment
    &amp;gt; limit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key invariant is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if current depositor backing
is already equal to max_deposit_amount,

any additional deposit must revert
regardless of cash_reserve
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Regression coverage should exercise every normal reserve growth path that can expose the bug, including interest accrual, liquidation revenue, over repayment, and flash loan fees.&lt;/p&gt;

&lt;p&gt;It is also worth keeping a reward focused regression test. A deposit cap bug can become a yield ownership bug when accepted deposits feed directly into liquidity mining shares.&lt;/p&gt;

&lt;h2&gt;
  
  
  The broader lesson
&lt;/h2&gt;

&lt;p&gt;This finding is a good example of why derived accounting values need clear semantics.&lt;/p&gt;

&lt;p&gt;If one helper already represents:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cash + borrows - reserves
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;then a downstream caller must not silently assume reserve still needs to be removed.&lt;/p&gt;

&lt;p&gt;The arithmetic error here is small in code but crosses subsystem boundaries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;market risk control
        ↓
deposit admission
        ↓
reward share accounting
        ↓
user yield distribution
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The useful audit question is not only whether a cap computes the correct number.&lt;/p&gt;

&lt;p&gt;It is also what the rest of the protocol trusts once that cap says yes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;CurrentSui’s deposit cap gate used &lt;code&gt;total_deposit_plus_interest()&lt;/code&gt;, a value already net of &lt;code&gt;cash_reserve&lt;/code&gt;, and then subtracted &lt;code&gt;cash_reserve&lt;/code&gt; again.&lt;/p&gt;

&lt;p&gt;That understated utilization whenever protocol reserve was positive.&lt;/p&gt;

&lt;p&gt;The PoC showed that a market already at its configured cap could accept an additional 8,000 USDC after exactly 8,000 USDC of reserve had accumulated.&lt;/p&gt;

&lt;p&gt;Because a successful deposit immediately enters the liquidity mining share accounting, the extra depositor could then capture more than 1,000 USDC from an active reward campaign, with the original lender losing the same amount.&lt;/p&gt;

&lt;p&gt;The underlying rule is simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A reserve accounting component should be removed exactly once from the metric used to enforce a market wide deposit cap.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;When that rule is broken, a limit bypass can propagate into a completely different subsystem and become direct user yield loss.&lt;/p&gt;

</description>
      <category>smartcontract</category>
      <category>blockchain</category>
      <category>security</category>
      <category>web3</category>
    </item>
    <item>
      <title>How an Expired CurrentSui Reward Pool Could Refund Yield Borrowers Had Already Earned</title>
      <dc:creator>Daniel</dc:creator>
      <pubDate>Mon, 10 Aug 2026 12:13:06 +0000</pubDate>
      <link>https://dev.to/f00dat/how-an-expired-currentsui-reward-pool-could-refund-yield-borrowers-had-already-earned-32aa</link>
      <guid>https://dev.to/f00dat/how-an-expired-currentsui-reward-pool-could-refund-yield-borrowers-had-already-earned-32aa</guid>
      <description>&lt;p&gt;An expired reward pool is not necessarily an economically empty reward pool.&lt;/p&gt;

&lt;p&gt;That distinction caused this finding in CurrentSui’s borrow liquidity mining flow.&lt;/p&gt;

&lt;p&gt;A borrower can already be active before a new reward campaign is created. Their participation is reflected in the global reward share accounting, but the borrower specific tracker for the new pool is created only when that borrower interacts again or claims.&lt;/p&gt;

&lt;p&gt;If the campaign expires before that tracker is materialized, the close path can see:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;num_obligation_reward_managers = 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and treat the pool as safe to refund.&lt;/p&gt;

&lt;p&gt;The problem is that zero materialized trackers does not mean zero economically accrued rewards.&lt;/p&gt;

&lt;p&gt;The proof of concept showed both sides of that mismatch. If the borrower claims first, the borrower receives the reward and a later close refunds zero. If the pool is closed first, the close recipient receives the same value and the borrower can no longer claim it.&lt;/p&gt;

&lt;p&gt;I reported the finding as High in the Sherlock CurrentSui contest. Sherlock ultimately classified it as Medium, and it earned $92.24.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw00e7b2iwpu73htragv9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw00e7b2iwpu73htragv9.png" alt=" " width="800" height="210"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://audits.sherlock.xyz/contests/1256/voting/154" rel="noopener noreferrer"&gt;Public Sherlock submission&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The bug is therefore not simply about claim timing. It is about using lazy materialization as a proxy for reward ownership.&lt;/p&gt;
&lt;h2&gt;
  
  
  The accounting model creates economic rewards before claim
&lt;/h2&gt;

&lt;p&gt;Claim is not the moment the reward first comes into existence.&lt;/p&gt;

&lt;p&gt;CurrentSui tracks borrow liquidity mining participation globally through &lt;code&gt;total_shares&lt;/code&gt;, and the pool later updates &lt;code&gt;cumulative_rewards_per_share&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That means an active borrower can participate economically in a campaign even if the borrower specific reward tracker for that pool has not yet been created.&lt;/p&gt;

&lt;p&gt;This matters because lazy accounting is only safe when every lifecycle function understands that some obligations may exist economically before they exist as explicit per user state.&lt;/p&gt;

&lt;p&gt;The close path does not preserve that distinction.&lt;/p&gt;
&lt;h2&gt;
  
  
  A borrower can already be active before the campaign exists
&lt;/h2&gt;

&lt;p&gt;When a user borrows, the borrow path updates the obligation reward manager immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let miner = market.borrow_liquidity_mining_mut&amp;lt;MarketType&amp;gt;();

miner.update_obligation_reward_manager&amp;lt;MarketType, CoinType&amp;gt;(
    get_borrow_reward_type(),
    obligation_owner_cap.id(),
    total_borrow.floor(),
    clock
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/sherlock-audit/2026-03-currentsui-contest-march-2026/blob/main/sui-move-contract/contracts/protocol/sources/entry_points/lending/borrow.move#L51-L66" rel="noopener noreferrer"&gt;borrow.move&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So a borrower can already have active borrow reward shares when a later reward pool is created.&lt;/p&gt;

&lt;p&gt;That is the setup used by the PoC: the borrower exists first, then the campaign is added.&lt;/p&gt;

&lt;h2&gt;
  
  
  A new pool starts with no materialized borrower trackers
&lt;/h2&gt;

&lt;p&gt;A newly created reward pool starts with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;num_obligation_reward_managers: 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;along with zero allocated rewards and zero cumulative rewards per share:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let pool_reward = PoolReward {
    id,
    reward_type: object::id::&amp;lt;TypeName&amp;gt;(&amp;amp;RewardType&amp;lt;CoinType&amp;gt; {}),
    start_time_ms,
    end_time_ms,
    total_rewards: rewards.value(),
    allocated_rewards: float::from(0),
    cumulative_rewards_per_share: float::from(0),
    num_obligation_reward_managers: 0,
    additional_fields
};
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/sherlock-audit/2026-03-currentsui-contest-march-2026/blob/main/sui-move-contract/contracts/protocol/sources/internal/liquidity/reward_manager.move#L161-L194" rel="noopener noreferrer"&gt;reward_manager.move&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Starting at zero is not itself a problem.&lt;/p&gt;

&lt;p&gt;The problem appears later when the close path interprets that counter as evidence that no borrower reward remains.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rewards accrue through the global share total
&lt;/h2&gt;

&lt;p&gt;The pool update logic distributes unlocked rewards across &lt;code&gt;total_shares&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if (pool_reward_manager.total_shares == 0) {
    pool_reward_manager.last_update_time_ms = cur_time_ms;
    return
};

let unlocked_rewards =
    float::from(pool_reward.total_rewards).mul(
        float::from(time_passed_ms)
    ).div(
        float::from(pool_reward.end_time_ms - pool_reward.start_time_ms)
    );

pool_reward.allocated_rewards =
    pool_reward.allocated_rewards.add(unlocked_rewards);

pool_reward.cumulative_rewards_per_share =
    pool_reward.cumulative_rewards_per_share.add(
        unlocked_rewards.div(
            float::from(pool_reward_manager.total_shares)
        )
    );
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/sherlock-audit/2026-03-currentsui-contest-march-2026/blob/main/sui-move-contract/contracts/protocol/sources/internal/liquidity/reward_manager.move#L281-L336" rel="noopener noreferrer"&gt;reward_manager.move&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;As time passes, value becomes attributable to active shares.&lt;/p&gt;

&lt;p&gt;So the system can already have borrower yield that is economically earned even though the pool still has no materialized borrower tracker for that user.&lt;/p&gt;

&lt;h2&gt;
  
  
  The borrower tracker is created lazily
&lt;/h2&gt;

&lt;p&gt;The user specific tracker is filled later inside &lt;code&gt;update_obligation_reward_manager&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That same path increments &lt;code&gt;num_obligation_reward_managers&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let optional_reward = obligation_reward_manager.rewards.borrow_mut(i);

if (optional_reward.is_none()) {
    if (obligation_reward_manager.last_update_time_ms &amp;lt;= pool_reward.end_time_ms) {
        optional_reward.fill(ObligationReward {
            pool_reward_id: object::id(pool_reward),
            earned_rewards: {
                if (obligation_reward_manager.last_update_time_ms &amp;lt;= pool_reward.start_time_ms) {
                    pool_reward.cumulative_rewards_per_share.mul(
                        float::from(obligation_reward_manager.share)
                    )
                } else {
                    float::from(0)
                }
            },
            cumulative_rewards_per_share: pool_reward.cumulative_rewards_per_share
        });

        pool_reward.num_obligation_reward_managers =
            pool_reward.num_obligation_reward_managers + 1;
    };
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/sherlock-audit/2026-03-currentsui-contest-march-2026/blob/main/sui-move-contract/contracts/protocol/sources/internal/liquidity/reward_manager.move#L338-L399" rel="noopener noreferrer"&gt;reward_manager.move&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This creates the dangerous state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;borrower has active economic participation
YES

borrower specific tracker for this pool
NO

num_obligation_reward_managers
0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The counter describes materialization state, not necessarily economic obligation state.&lt;/p&gt;

&lt;h2&gt;
  
  
  The expired pool close path relies on that counter
&lt;/h2&gt;

&lt;p&gt;The close function extracts the pool, checks that the campaign has ended, and then requires:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;assert!(
    num_obligation_reward_managers == 0,
    error::liquidity_mining_not_all_rewards_claimed()
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Relevant code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public(package) fun close_pool_reward&amp;lt;CoinType&amp;gt;(
    pool_reward_manager: &amp;amp;mut PoolRewardManager,
    index: u64,
    clock: &amp;amp;Clock
): Balance&amp;lt;CoinType&amp;gt; {
    let optional_pool_reward =
        pool_reward_manager.pool_rewards.borrow_mut(index);

    let PoolReward {
        id,
        reward_type: _,
        start_time_ms: _,
        end_time_ms,
        total_rewards: _,
        allocated_rewards: _,
        cumulative_rewards_per_share: _,
        num_obligation_reward_managers,
        mut additional_fields,
    } = optional_pool_reward.extract();

    object::delete(id);

    let cur_time_ms = clock.timestamp_ms();

    assert!(
        cur_time_ms &amp;gt;= end_time_ms,
        error::liquidity_mining_pool_reward_period_not_over()
    );

    assert!(
        num_obligation_reward_managers == 0,
        error::liquidity_mining_not_all_rewards_claimed()
    );

    let reward_balance =
        additional_fields.remove&amp;lt;
            RewardBalance&amp;lt;CoinType&amp;gt;,
            Balance&amp;lt;CoinType&amp;gt;
        &amp;gt;(RewardBalance&amp;lt;CoinType&amp;gt;{});
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/sherlock-audit/2026-03-currentsui-contest-march-2026/blob/main/sui-move-contract/contracts/protocol/sources/internal/liquidity/reward_manager.move#L106-L136" rel="noopener noreferrer"&gt;reward_manager.move&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The close path does not first refresh the pool and materialize the reward state of already active borrowers.&lt;/p&gt;

&lt;p&gt;As a result, it can interpret a zero tracker count as permission to refund a pool whose global share accounting would produce a nonzero borrower claim.&lt;/p&gt;

&lt;h2&gt;
  
  
  The claim path exposes the mismatch
&lt;/h2&gt;

&lt;p&gt;The normal claim path does something the close path does not.&lt;/p&gt;

&lt;p&gt;Before reading the borrower’s reward tracker, it calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;update_obligation_reward_manager(
    pool_reward_manager,
    obligation_id,
    clock,
    false
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/sherlock-audit/2026-03-currentsui-contest-march-2026/blob/main/sui-move-contract/contracts/protocol/sources/internal/liquidity/reward_manager.move#L227-L262" rel="noopener noreferrer"&gt;reward_manager.move&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That update materializes the borrower’s reward state.&lt;/p&gt;

&lt;p&gt;So the same expired campaign can produce two mutually exclusive ownership outcomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Borrower claims first
→ reward state is materialized
→ borrower receives the accrued reward
→ later close has nothing to refund
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Pool closes first
→ tracker count is still zero
→ reward balance is refunded
→ borrower cannot realize the reward afterward
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The borrower’s economic participation is the same in both paths. Only the timing of materialization changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PoC proves the value is the borrower’s reward, not leftover dust
&lt;/h2&gt;

&lt;p&gt;The Move PoC uses five tests that answer different questions.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test&lt;/th&gt;
&lt;th&gt;What it proves&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ctl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;An already active borrower can claim more than 10 USDC after the campaign expires.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tail&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;If the borrower claims first, the later pool close refund is exactly zero.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;exp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;If the pool closes first, the close recipient receives more than 10 USDC.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cmp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The normal borrower claim is exactly equal to the close first refund.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dead&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;After the pool closes first, the borrower can no longer claim the reward.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The strongest comparison is in &lt;code&gt;cmp&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;assert!(a &amp;gt; TEN, 5);
assert!(b &amp;gt; TEN, 6);
assert!(t == 0, 7);
assert!(a + t == REWARD, 8);
assert!(a == b, 9);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;a = borrower claim in the normal path
t = refund after the normal claim
b = refund when the pool closes first
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The assertions prove:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;t = 0
a = b
a + t = REWARD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the close first path is not collecting unrelated campaign dust.&lt;/p&gt;

&lt;p&gt;It receives the same value that the borrower receives in the normal path.&lt;/p&gt;

&lt;p&gt;The PoC defines &lt;code&gt;REWARD&lt;/code&gt; as 1,000 USDC, and the comparison test accounts for that entire amount through the control claim and zero tail refund.&lt;/p&gt;

&lt;h2&gt;
  
  
  The full Move test suite passes
&lt;/h2&gt;

&lt;p&gt;The PoC lives in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tests/integration/test_cases/poc_close.move
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and runs with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sui move test protocol::poc_close -i 100000000000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reported result was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ PASS    ] protocol::poc_close::cmp
[ PASS    ] protocol::poc_close::ctl
[ PASS    ] protocol::poc_close::dead
[ PASS    ] protocol::poc_close::exp
[ PASS    ] protocol::poc_close::tail

Test result: OK.
Total tests: 5; passed: 5; failed: 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Together, these tests establish the full lifecycle problem: the borrower can claim the reward normally, the post claim refund is zero, closing first redirects the same value, and the borrower cannot recover it afterward.&lt;/p&gt;

&lt;h2&gt;
  
  
  Impact and severity context
&lt;/h2&gt;

&lt;p&gt;The demonstrated impact is direct loss of borrower yield.&lt;/p&gt;

&lt;p&gt;The finding does not depend on malicious privileged behavior. Closing an expired pool is part of the intended reward lifecycle, and there is no contract rule requiring every already active borrower to claim before closure.&lt;/p&gt;

&lt;p&gt;The problem is that the close function itself treats:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;num_obligation_reward_managers == 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;as its safety condition even though that counter only reflects materialized trackers.&lt;/p&gt;

&lt;p&gt;My original High assessment was based on the fact that the PoC demonstrates user yield loss greater than 10 USDC and shows that the same value moves to the close recipient instead.&lt;/p&gt;

&lt;p&gt;The official Sherlock classification is Medium, which is the severity I use in my public record.&lt;/p&gt;

&lt;p&gt;Regardless of severity, the technical failure is clear: pool closure can refund value that the normal claim path proves was economically attributable to an eligible borrower.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix the lifecycle, not only the counter
&lt;/h2&gt;

&lt;p&gt;The core invariant should be:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Closing a reward campaign must not destroy or refund rewards that have already accrued economically to eligible users.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There are several possible directions.&lt;/p&gt;

&lt;p&gt;The pool can be fully refreshed before refund logic runs.&lt;/p&gt;

&lt;p&gt;Existing obligations can have their reward trackers materialized when a new pool is created.&lt;/p&gt;

&lt;p&gt;A cleaner long term design is to separate campaign finalization from reward ownership: closing the campaign stops new accrual, while rewards already earned before expiry remain claimable.&lt;/p&gt;

&lt;p&gt;Only value that is provably not attributable to eligible users should become refundable.&lt;/p&gt;

&lt;p&gt;A regression test should preserve the control and close first comparison from the PoC. If a borrower can claim &lt;code&gt;X&lt;/code&gt; immediately before closure, closing the campaign must not make that same &lt;code&gt;X&lt;/code&gt; refundable to another recipient.&lt;/p&gt;

&lt;h2&gt;
  
  
  The broader lesson
&lt;/h2&gt;

&lt;p&gt;Lazy accounting is useful because it avoids updating every user whenever global state changes.&lt;/p&gt;

&lt;p&gt;But it creates a strict design requirement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;not materialized
does not mean
not economically owed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any close, sweep, refund, cleanup, or migration path that treats lazy state as complete state risks erasing obligations that would have appeared on the next user interaction.&lt;/p&gt;

&lt;p&gt;Reward systems are especially sensitive because time can create economic entitlement while user specific state remains untouched.&lt;/p&gt;

&lt;p&gt;The safest review question is simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;If a user can claim X immediately before closure,
can closure make X disappear or become refundable elsewhere?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the answer is yes, the lifecycle is not preserving reward ownership.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The CurrentSui reward pool close path used materialized borrower trackers as a proxy for outstanding economic obligations.&lt;/p&gt;

&lt;p&gt;That proxy was incomplete.&lt;/p&gt;

&lt;p&gt;An already active borrower could accrue rewards through the global share accounting while the pool still reported zero materialized obligation reward managers. If the borrower interacted first, the normal claim path materialized the reward and paid it to the borrower. If the expired pool closed first, the same value could be refunded and the borrower could no longer claim it.&lt;/p&gt;

&lt;p&gt;The core issue is not reward calculation accuracy. It is reward ownership being decided by the timing of lazy materialization.&lt;/p&gt;

&lt;p&gt;The engineering rule is broader than this one protocol:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Lifecycle functions must reason about economic obligations, not only the objects that have already been materialized to represent them.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>smartcontract</category>
      <category>blockchain</category>
      <category>security</category>
      <category>web3</category>
    </item>
    <item>
      <title>How a 1 USDC Position Let an Attacker Withdraw 800 USDC From Fluid MoneyMarket’s Shared Liquidity</title>
      <dc:creator>Daniel</dc:creator>
      <pubDate>Mon, 10 Aug 2026 02:28:46 +0000</pubDate>
      <link>https://dev.to/f00dat/how-a-1-usdc-position-let-an-attacker-withdraw-800-usdc-from-fluid-moneymarkets-shared-liquidity-4pcn</link>
      <guid>https://dev.to/f00dat/how-a-1-usdc-position-let-an-attacker-withdraw-800-usdc-from-fluid-moneymarkets-shared-liquidity-4pcn</guid>
      <description>&lt;p&gt;A withdrawal cap only protects funds if every downstream layer uses the capped amount.&lt;/p&gt;

&lt;p&gt;That was the problem in Fluid MoneyMarket’s normal supply withdrawal path.&lt;/p&gt;

&lt;p&gt;When a user requests more than their position balance, MoneyMarket converts the request into raw units and caps &lt;code&gt;withdrawAmountRaw_&lt;/code&gt; to the position’s actual &lt;code&gt;tokenRawSupply_&lt;/code&gt;. This keeps the internal position update within the user’s balance.&lt;/p&gt;

&lt;p&gt;But the protocol then calls the Liquidity layer with the original, uncapped &lt;code&gt;supplyAmount_&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The result is a mismatch between ownership accounting and the real token transfer.&lt;/p&gt;

&lt;p&gt;In the proof of concept, a victim supplied 1,000 USDC and an attacker supplied only 1 USDC. The attacker then requested an 800 USDC withdrawal. MoneyMarket exhausted the attacker’s 1 USDC position in storage, while Liquidity transferred the full 800 USDC. The attacker ended with a 799 USDC net profit, and the victim’s later withdrawal reverted.&lt;/p&gt;

&lt;p&gt;I reported the finding as &lt;strong&gt;High&lt;/strong&gt; in the Sherlock Fluid DEX V2 contest, where it earned &lt;strong&gt;$34&lt;/strong&gt;.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F033sp6msmm89s02fkz5q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F033sp6msmm89s02fkz5q.png" alt=" " width="799" height="179"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://audits.sherlock.xyz/contests/1225/voting/860" rel="noopener noreferrer"&gt;Public Sherlock submission&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;No oracle manipulation, reentrancy, governance compromise, privileged role, or unusual ERC20 behavior was required. The issue came from one value being capped for storage while another value was used for the external withdrawal.&lt;/p&gt;
&lt;h2&gt;
  
  
  The broken accounting invariant
&lt;/h2&gt;

&lt;p&gt;MoneyMarket and Liquidity use different accounting layers, but they still need to agree on the economic amount being withdrawn.&lt;/p&gt;

&lt;p&gt;The relevant invariant is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Value withdrawn from Liquidity
&amp;lt;=
Value debited from the withdrawing user’s MoneyMarket position
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The vulnerable path violates that invariant because the storage debit is based on &lt;code&gt;withdrawAmountRaw_&lt;/code&gt;, while the actual Liquidity call still depends on the original &lt;code&gt;supplyAmount_&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That means MoneyMarket can correctly determine that a user owns only a small position and still authorize a much larger withdrawal against its pooled Liquidity balance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the mismatch begins
&lt;/h2&gt;

&lt;p&gt;The vulnerable logic is in &lt;code&gt;_processNormalSupplyAction&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For a normal withdrawal, the user provides a negative &lt;code&gt;supplyAmount_&lt;/code&gt;. MoneyMarket converts that amount into raw units and calculates &lt;code&gt;withdrawAmountRaw_&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If the requested raw withdrawal exceeds the position’s current supply, the code caps it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if (withdrawAmountRaw_ &amp;gt; tokenRawSupply_) {
    withdrawAmountRaw_ = tokenRawSupply_;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/sherlock-audit/2026-01-fluid-dex-v2/blob/main/fluid-contracts/contracts/protocols/moneyMarket/core/operateModule/helpers.sol#L389" rel="noopener noreferrer"&gt;MoneyMarket operate helper&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The cap itself is reasonable. It allows the protocol to consume at most the available position instead of passing an excessive raw amount into the storage update.&lt;/p&gt;

&lt;p&gt;The vulnerability is that this new effective withdrawal amount is not propagated to the value later sent to Liquidity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Storage uses the capped amount
&lt;/h2&gt;

&lt;p&gt;The final raw amount reaches &lt;code&gt;_updateStorageForWithdraw&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That helper rejects a raw withdrawal larger than the stored position:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if (tokenRawSupply_ &amp;lt; withdrawAmountRaw_) revert();
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It then subtracts only the amount passed to it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tokenRawSupply_ =
    tokenRawSupply_ - withdrawAmountRaw_;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/sherlock-audit/2026-01-fluid-dex-v2/blob/main/fluid-contracts/contracts/protocols/moneyMarket/core/other/helpers.sol#L92" rel="noopener noreferrer"&gt;MoneyMarket storage withdrawal helper&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Because &lt;code&gt;_processNormalSupplyAction&lt;/code&gt; already capped &lt;code&gt;withdrawAmountRaw_&lt;/code&gt;, this storage update succeeds even when the original user request was larger than the position.&lt;/p&gt;

&lt;p&gt;So the internal accounting remains bounded by what the attacker actually owns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Liquidity still receives the uncapped request
&lt;/h2&gt;

&lt;p&gt;After the storage update, MoneyMarket calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LIQUIDITY.operate(
    token_,
    supplyAmount_,
    0,
    to_,
    ...
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/sherlock-audit/2026-01-fluid-dex-v2/blob/main/fluid-contracts/contracts/protocols/moneyMarket/core/operateModule/helpers.sol#L480" rel="noopener noreferrer"&gt;MoneyMarket Liquidity call&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The important detail is that &lt;code&gt;supplyAmount_&lt;/code&gt; is still the original user supplied withdrawal request.&lt;/p&gt;

&lt;p&gt;It is not recomputed from the capped &lt;code&gt;withdrawAmountRaw_&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The protocol therefore applies one amount to the attacker’s position and another amount to the real asset movement.&lt;/p&gt;

&lt;p&gt;This is not a precision or rounding edge case. The raw conversion successfully detects that the request exceeds the position and the code intentionally caps it. The failure happens afterward, when the external call goes back to the original input.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Liquidity can pay the larger amount
&lt;/h2&gt;

&lt;p&gt;Liquidity tracks supply using:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;_userSupplyData[msg.sender][token_]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/sherlock-audit/2026-01-fluid-dex-v2/blob/main/fluid-contracts/contracts/liquidity/userModule/main.sol#L25" rel="noopener noreferrer"&gt;Liquidity user module&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;During this flow, &lt;code&gt;msg.sender&lt;/code&gt; at the Liquidity layer is the MoneyMarket contract.&lt;/p&gt;

&lt;p&gt;Liquidity therefore does not see separate balances for Alice, Bob, or each MoneyMarket NFT. It sees MoneyMarket’s aggregated supply position.&lt;/p&gt;

&lt;p&gt;That separation is expected. MoneyMarket is responsible for tracking how much of that aggregate position belongs to each user.&lt;/p&gt;

&lt;p&gt;The problem is that after limiting the attacker’s internal debit, MoneyMarket still asks Liquidity to release the larger requested amount.&lt;/p&gt;

&lt;p&gt;As long as MoneyMarket has enough aggregate supply at Liquidity and the configured withdrawal constraints permit the operation, Liquidity can fulfill it from the pooled position.&lt;/p&gt;

&lt;p&gt;That is how funds backing other MoneyMarket suppliers become available to the attacker.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PoC demonstrates the full economic impact
&lt;/h2&gt;

&lt;p&gt;The Foundry proof uses a listed USDC normal supply market and two ordinary users.&lt;/p&gt;

&lt;p&gt;The victim supplies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;uint256 victimSupply = 1_000e6;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The attacker supplies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;uint256 attackerDeposit = 1e6;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The attacker then chooses an explicit withdrawal larger than their position:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;uint256 overWithdraw = 800e6;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exploit uses the normal &lt;code&gt;operate()&lt;/code&gt; path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;moneyMarket.operate(
    attackerNftId,
    attackerPosIndex,
    abi.encode(
        -int256(overWithdraw),
        alice
    )
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test confirms three separate facts.&lt;/p&gt;

&lt;p&gt;First, the attacker receives the full requested amount:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;assertEq(
    attackerBalAfterExploit - attackerBalAfterDeposit,
    overWithdraw,
    "attacker got overWithdraw"
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, the attacker’s internal MoneyMarket supply is fully exhausted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;assertEq(
    attackerRawAfterExploit,
    0,
    "attacker raw supply should be exhausted in storage"
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Third, the victim can no longer complete a full withdrawal afterward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;vm.expectRevert(stdError.arithmeticError);

moneyMarket.operate(
    victimNftId,
    victimPosIndex,
    abi.encode(
        type(int256).min,
        bob
    )
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The resulting state is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Victim supplied
1,000 USDC

Attacker supplied
1 USDC

Attacker received
800 USDC

Attacker net profit
799 USDC

Attacker remaining raw supply
0

Victim withdrawal afterward
REVERTED
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the important proof boundary. The test does not merely show inconsistent storage. It shows an actual token transfer above the attacker’s deposited amount and downstream failure for another supplier.&lt;/p&gt;

&lt;p&gt;The validated Foundry run completed with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1 passed; 0 failed; 0 skipped
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The PoC did not modify the protocol contracts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the protocol’s amount limits do not stop it
&lt;/h2&gt;

&lt;p&gt;MoneyMarket also checks amounts through &lt;code&gt;_verifyAmountLimits(int256)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That validation enforces configured minimum and maximum magnitudes. It does not enforce the user specific condition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;requested withdrawal
&amp;lt;=
position balance
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An attacker can therefore request more than their own position while still staying within protocol configured amount limits.&lt;/p&gt;

&lt;p&gt;The exact maximum extractable amount per call depends on available pooled liquidity and Liquidity withdrawal constraints.&lt;/p&gt;

&lt;p&gt;The PoC does not establish an unlimited drain under every configuration, and the finding does not require that claim. It proves that the protocol can transfer more underlying than it debits from the attacker’s position.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the withdraw all branch is different
&lt;/h2&gt;

&lt;p&gt;The issue is specific to the normal explicit withdrawal amount path.&lt;/p&gt;

&lt;p&gt;The report distinguishes it from the sentinel branch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;supplyAmount_ == type(int256).min
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That branch represents withdrawing the full available position.&lt;/p&gt;

&lt;p&gt;There, MoneyMarket rewrites &lt;code&gt;supplyAmount_&lt;/code&gt; to the actual withdrawable amount before calling Liquidity. The value used for the external transfer therefore follows the position balance.&lt;/p&gt;

&lt;p&gt;The vulnerable branch instead behaves like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User provides explicit negative withdrawal
        ↓
withdrawAmountRaw_ is capped
        ↓
Storage uses the capped amount
        ↓
supplyAmount_ remains unchanged
        ↓
Liquidity receives the original request
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This distinction matters because it isolates the bug to one concrete value propagation path rather than every MoneyMarket withdrawal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Severity
&lt;/h2&gt;

&lt;p&gt;The exploit is permissionless once normal system conditions exist.&lt;/p&gt;

&lt;p&gt;The target token must be listed, MoneyMarket must have pooled supply for that token at Liquidity, and the attacker needs a MoneyMarket NFT with a normal supply position. Those are ordinary prerequisites for using the feature.&lt;/p&gt;

&lt;p&gt;The attack does not rely on an oracle, callback, governance action, special token behavior, or compromised role.&lt;/p&gt;

&lt;p&gt;The demonstrated impact is direct extraction from MoneyMarket’s pooled Liquidity position followed by a failed withdrawal for another supplier.&lt;/p&gt;

&lt;p&gt;The maximum amount is still bounded by available pooled liquidity and configured Liquidity limits, so the PoC should not be read as proving that any arbitrary amount can always be drained in one transaction.&lt;/p&gt;

&lt;p&gt;What it does prove is that an individual user’s balance is no longer the effective authorization boundary for the withdrawal.&lt;/p&gt;

&lt;p&gt;That is the basis for the High severity assessment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix the effective withdrawal amount once
&lt;/h2&gt;

&lt;p&gt;The protocol should ensure that the value forwarded to Liquidity is derived from the same final amount used for the MoneyMarket storage debit.&lt;/p&gt;

&lt;p&gt;If partial fulfillment is intentional, the safe flow is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User requests withdrawal
        ↓
Convert to raw units
        ↓
Cap raw amount to position balance
        ↓
Convert the final capped raw amount
back into normal units
        ↓
Use that final amount for Liquidity
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The report recommends following the same conversion style used by the withdraw all path and rounding down when converting the capped raw amount back into normal units so the protocol cannot over withdraw.&lt;/p&gt;

&lt;p&gt;The final adjusted amount should also be the amount checked by &lt;code&gt;_verifyAmountLimits&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A simpler alternative is to revert whenever the requested withdrawal exceeds the position balance. That changes the user experience from partial fulfillment to rejection, but it also prevents the two layers from executing different withdrawal amounts.&lt;/p&gt;

&lt;p&gt;The regression test should recreate a large victim position and a small attacker position, attempt an over balance withdrawal, and assert either that the operation reverts or that the attacker receives no more than the economic value removed from their position. The victim should remain able to withdraw afterward.&lt;/p&gt;

&lt;h2&gt;
  
  
  The broader audit lesson
&lt;/h2&gt;

&lt;p&gt;This finding is a useful example of a value propagation bug.&lt;/p&gt;

&lt;p&gt;A security check can be correct where it is written and still fail to protect funds if the corrected value is not carried through the rest of the call chain.&lt;/p&gt;

&lt;p&gt;For multi layer accounting flows, it is worth tracing the same user controlled value through every transformation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User input
→ normal amount
→ raw amount
→ capped raw amount
→ storage debit
→ external protocol argument
→ actual token transfer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, MoneyMarket correctly discovered that the attacker did not own the full requested amount and protected its own storage accordingly.&lt;/p&gt;

&lt;p&gt;The protection failed because the final Liquidity call returned to the original request instead of using the adjusted value.&lt;/p&gt;

&lt;p&gt;The cap protected the accounting record, but not the pooled assets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Fluid MoneyMarket’s normal supply withdrawal path used two different effective withdrawal amounts inside the same transaction.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;withdrawAmountRaw_&lt;/code&gt; was capped to the user’s real &lt;code&gt;tokenRawSupply_&lt;/code&gt;, so MoneyMarket storage consumed only the available position. But &lt;code&gt;LIQUIDITY.operate()&lt;/code&gt; still received the original &lt;code&gt;supplyAmount_&lt;/code&gt;, allowing the external transfer to exceed the value debited from that user.&lt;/p&gt;

&lt;p&gt;Because Liquidity accounts for the MoneyMarket contract as an aggregated supplier, the excess can be paid from pooled funds backing other MoneyMarket positions.&lt;/p&gt;

&lt;p&gt;The PoC demonstrated that this mismatch was economically exploitable and could leave another supplier unable to withdraw.&lt;/p&gt;

&lt;p&gt;The invariant for the fix is simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The amount transferred by Liquidity must never exceed the economic amount debited from the withdrawing user’s MoneyMarket position.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Once a withdrawal amount is adjusted, every downstream accounting and transfer operation must use that same effective value.&lt;/p&gt;

</description>
      <category>smartcontract</category>
      <category>blockchain</category>
      <category>security</category>
      <category>web3</category>
    </item>
    <item>
      <title>How Transaction-Scoped 0xMarkets Oracle Prices Could Freeze CarthaVault After GM Settlement</title>
      <dc:creator>Daniel</dc:creator>
      <pubDate>Sun, 09 Aug 2026 10:10:55 +0000</pubDate>
      <link>https://dev.to/f00dat/how-transaction-scoped-0xmarkets-oracle-prices-could-freeze-carthavault-after-gm-settlement-1n1c</link>
      <guid>https://dev.to/f00dat/how-transaction-scoped-0xmarkets-oracle-prices-could-freeze-carthavault-after-gm-settlement-1n1c</guid>
      <description>&lt;p&gt;The bug was not a bad oracle price.&lt;/p&gt;

&lt;p&gt;It was a mismatch in how long that price was supposed to exist.&lt;/p&gt;

&lt;p&gt;CarthaVault included the value of its settled 0xMarkets GM balance in total value locked. To calculate that value, &lt;code&gt;PoolDeployLib.gmValueInUsdc&lt;/code&gt; queried the 0xMarkets Oracle for the market’s primary prices.&lt;/p&gt;

&lt;p&gt;That looks reasonable until you follow the lifetime of those prices.&lt;/p&gt;

&lt;p&gt;0xMarkets primary prices are transaction scoped execution values. &lt;code&gt;OracleModule.withOraclePrices&lt;/code&gt; sets them before keeper execution and clears them before the transaction ends.&lt;/p&gt;

&lt;p&gt;CarthaVault, however, can continue holding the GM token long after that execution transaction is over.&lt;/p&gt;

&lt;p&gt;The resulting steady state was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CarthaVault holds GM
YES

0xMarkets primary prices exist
NO
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From there, the next CarthaVault operation that needed TVL could reach &lt;code&gt;Oracle.getPrimaryPrice&lt;/code&gt; and revert with &lt;code&gt;EmptyPrimaryPrice&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The proof reproduced that failure across:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;depositAndLock

lockTopUp

release

processQueuedWithdrawals
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also broke:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;totalValueLocked

deployedGmBalance
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I reported the finding as &lt;strong&gt;High&lt;/strong&gt; through the 0xMarkets program on HackenProof.&lt;/p&gt;

&lt;p&gt;It earned &lt;strong&gt;$0.52&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The important part was not that a view could revert. The same valuation dependency blocked state changing deposit and withdrawal paths while the vault held real GM.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lifecycle mismatch
&lt;/h2&gt;

&lt;p&gt;CarthaVault calculates TVL from two components:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Idle vault asset
+
Value of settled GM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The implementation is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function _totalValueLocked()
    internal
    view
    returns (uint256)
{
    CarthaVaultStorage storage $ =
        _getCarthaVaultStorage();

    return
        IERC20($.asset)
            .balanceOf(address(this))
        +
        _gmValueInUsdc($);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The GM valuation is delegated to &lt;code&gt;PoolDeployLib&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function _gmValueInUsdc(
    CarthaVaultStorage storage $
)
    internal
    view
    returns (uint256)
{
    return
        PoolDeployLib.gmValueInUsdc(
            address($.poolToken),
            $.reader,
            $.dataStore,
            $.marketToken,
            $.oracle
        );
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/hackenproof-public/cartha-vaults/blob/main/src/CarthaVault.sol#L938-L945" rel="noopener noreferrer"&gt;CarthaVault.sol&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is safe only if the configured price source is available whenever CarthaVault needs to value its GM holdings.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;PoolDeployLib.gmValueInUsdc&lt;/code&gt; takes an early return while the GM balance is zero. Once GM is present, it reaches &lt;code&gt;_getGmPrice&lt;/code&gt;, which reads the 0xMarkets Oracle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function _getGmPrice(
    address reader,
    address dataStore,
    address market,
    address oracle
)
    private
    view
    returns (
        int256 gmPrice
    )
{
    I0xMReader r =
        I0xMReader(reader);

    I0xMOracle o =
        I0xMOracle(oracle);

    I0xMReader.MarketProps memory mkt =
        r.getMarket(
            dataStore,
            market
        );

    I0xMReader.PriceProps memory indexPrice =
        o.getPrimaryPrice(
            mkt.indexToken
        );

    I0xMReader.PriceProps memory longPrice =
        o.getPrimaryPrice(
            mkt.longToken
        );

    I0xMReader.PriceProps memory shortPrice =
        o.getPrimaryPrice(
            mkt.shortToken
        );

    (
        gmPrice,
    ) =
        r.getMarketTokenPrice(
            dataStore,
            mkt,
            indexPrice,
            longPrice,
            shortPrice,
            MAX_PNL_FACTOR_FOR_DEPOSITS,
            false
        );
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/hackenproof-public/cartha-vaults/blob/main/src/libraries/PoolDeployLib.sol#L97-L127" rel="noopener noreferrer"&gt;PoolDeployLib.sol&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The integration therefore assumes that &lt;code&gt;Oracle.getPrimaryPrice&lt;/code&gt; behaves like a persistent price feed.&lt;/p&gt;

&lt;p&gt;It does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  0xMarkets clears the prices by design
&lt;/h2&gt;

&lt;p&gt;The 0xMarkets execution model makes the lifetime of &lt;code&gt;primaryPrices&lt;/code&gt; explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;modifier withOraclePrices(
    OracleUtils.SetPricesParams memory params
) {
    oracle.setPrices(params);
    _;
    oracle.clearAllPrices();
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The atomic-action variant follows the same pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;modifier withOraclePricesForAtomicAction(
    OracleUtils.SetPricesParams memory params
) {
    oracle.setPricesForAtomicAction(
        params
    );
    _;
    oracle.clearAllPrices();
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/hackenproof-public/0xMarkets_contract/blob/e94daee6558f90e479c2c967dedae1d7cd06fb0c/contracts/oracle/OracleModule.sol#L26-L40" rel="noopener noreferrer"&gt;OracleModule.sol&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The prices are available while the keeper action is executing.&lt;/p&gt;

&lt;p&gt;Then &lt;code&gt;clearAllPrices&lt;/code&gt; removes them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function clearAllPrices()
    external
    onlyController
{
    uint256 length =
        tokensWithPrices.length();

    for (
        uint256 i;
        i &amp;lt; length;
        i++
    ) {
        address token =
            tokensWithPrices.at(0);

        _removePrimaryPrice(
            token
        );
    }

    minTimestamp = 0;
    maxTimestamp = 0;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Later, &lt;code&gt;getPrimaryPrice&lt;/code&gt; explicitly rejects an empty value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function getPrimaryPrice(
    address token
)
    external
    view
    returns (
        Price.Props memory
    )
{
    if (
        token == address(0)
    ) {
        return
            Price.Props(
                0,
                0
            );
    }

    Price.Props memory price =
        primaryPrices[
            token
        ];

    if (
        price.isEmpty()
    ) {
        revert
            Errors.EmptyPrimaryPrice(
                token
            );
    }

    return price;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/hackenproof-public/0xMarkets_contract/blob/e94daee6558f90e479c2c967dedae1d7cd06fb0c/contracts/oracle/Oracle.sol#L131-L168" rel="noopener noreferrer"&gt;Oracle.sol&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So seeing &lt;code&gt;EmptyPrimaryPrice&lt;/code&gt; after an execution is not evidence that 0xMarkets itself failed to clean up.&lt;/p&gt;

&lt;p&gt;The cleanup is the expected behavior.&lt;/p&gt;

&lt;p&gt;The integration failure is using that transaction scoped state as though it were available for persistent vault accounting.&lt;/p&gt;

&lt;h2&gt;
  
  
  A normal deposit creates the frozen steady state
&lt;/h2&gt;

&lt;p&gt;The PoC did not manufacture the condition by directly minting GM or directly clearing the Oracle.&lt;/p&gt;

&lt;p&gt;It used the real 0xMarkets deposit execution path.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;DepositHandler.executeDeposit&lt;/code&gt; runs under &lt;code&gt;withOraclePrices&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function executeDeposit(
    bytes32 key,
    OracleUtils.SetPricesParams calldata oracleParams
)
    external
    globalNonReentrant
    onlyOrderKeeper
    withOraclePrices(
        oracleParams
    )
{
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/hackenproof-public/0xMarkets_contract/blob/e94daee6558f90e479c2c967dedae1d7cd06fb0c/contracts/exchange/DepositHandler.sol#L88-L95" rel="noopener noreferrer"&gt;DepositHandler.sol&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;During execution, the market token is minted to the request receiver.&lt;/p&gt;

&lt;p&gt;For this integration, the receiver is the real CarthaVault.&lt;/p&gt;

&lt;p&gt;The transaction therefore legitimately performs both of these actions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Mint GM to CarthaVault

Clear Oracle primary prices
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the transaction ends, the PoC observed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CarthaVault GM balance
50000000000000000000000

Oracle tokensWithPrices count
0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That raw GM balance is &lt;code&gt;50,000e18&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;CarthaVault now has an asset it must value across transactions, but the price state selected by the integration has already completed its lifecycle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before GM existed, the vault worked normally
&lt;/h2&gt;

&lt;p&gt;The proof first established a healthy control state.&lt;/p&gt;

&lt;p&gt;User A started with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1,000,000 USDC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and deposited:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;200,000 USDC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User A balance after deposit
800,000 USDC

CarthaVault idle USDC
200,000 USDC

CarthaVault GM balance
0

totalValueLocked
PASS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This matters because &lt;code&gt;gmValueInUsdc&lt;/code&gt; skips the oracle path while GM is zero.&lt;/p&gt;

&lt;p&gt;The vault only enters the broken state after a real 0xMarkets deployment has settled and GM is actually held.&lt;/p&gt;

&lt;h2&gt;
  
  
  The keeper deployed only 50,000 USDC
&lt;/h2&gt;

&lt;p&gt;The PoC then deployed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;50,000 USDC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;from the vault into 0xMarkets.&lt;/p&gt;

&lt;p&gt;That deliberately left:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;150,000 USDC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;inside CarthaVault.&lt;/p&gt;

&lt;p&gt;The real 0xMarkets deposit request was created and executed.&lt;/p&gt;

&lt;p&gt;GM was minted to the vault.&lt;/p&gt;

&lt;p&gt;The Oracle finished the keeper transaction with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tokensWithPrices
0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That produced the exact state needed to test whether CarthaVault could continue operating after normal settlement.&lt;/p&gt;

&lt;p&gt;It could not.&lt;/p&gt;

&lt;h2&gt;
  
  
  TVL and deployed GM accounting reverted
&lt;/h2&gt;

&lt;p&gt;Once GM was positive, the early-return condition in &lt;code&gt;gmValueInUsdc&lt;/code&gt; no longer applied.&lt;/p&gt;

&lt;p&gt;CarthaVault attempted to value the GM.&lt;/p&gt;

&lt;p&gt;The valuation reached &lt;code&gt;Oracle.getPrimaryPrice&lt;/code&gt; after the prices had been cleared.&lt;/p&gt;

&lt;p&gt;The PoC observed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;totalValueLocked
REVERTS EmptyPrimaryPrice

deployedGmBalance
REVERTS EmptyPrimaryPrice
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that were the full impact, this could be dismissed as a reporting or UI problem.&lt;/p&gt;

&lt;p&gt;The same dependency sits inside state changing user paths.&lt;/p&gt;

&lt;h2&gt;
  
  
  New deposits were blocked
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;depositAndLock&lt;/code&gt; calculates shares through &lt;code&gt;_convertToShares&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function _convertToShares(
    uint256 assets
)
    internal
    view
    returns (
        uint256 shares
    )
{
    uint256 totalShares =
        totalSupply();

    uint256 totalAssets_ =
        _totalValueLocked();

    if (
        totalShares == 0
            ||
        totalAssets_ == 0
    ) {
        CarthaVaultStorage storage $ =
            _getCarthaVaultStorage();

        return
            assets
            *
        10 ** (
            decimals()
                -
            IERC20Metadata(
                $.asset
            ).decimals()
        );
    }

    return
        (
            assets
                *
            totalShares
        )
            /
        totalAssets_;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/hackenproof-public/cartha-vaults/blob/main/src/CarthaVault.sol#L912-L922" rel="noopener noreferrer"&gt;CarthaVault.sol&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;User B had:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1,000,000 USDC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;before the attempted deposit.&lt;/p&gt;

&lt;p&gt;While GM was held, &lt;code&gt;depositAndLock&lt;/code&gt; reverted with &lt;code&gt;EmptyPrimaryPrice&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Afterward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User B balance
1,000,000 USDC

Position created
NO
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The failed call did not consume the user’s assets, but new deposits were unavailable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Existing positions could not be topped up
&lt;/h2&gt;

&lt;p&gt;The PoC then exercised &lt;code&gt;lockTopUp&lt;/code&gt; on User A’s existing position.&lt;/p&gt;

&lt;p&gt;That path also required TVL based share conversion.&lt;/p&gt;

&lt;p&gt;It reverted with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EmptyPrimaryPrice
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user balance and position values remained unchanged.&lt;/p&gt;

&lt;p&gt;So the deposit side was blocked for both new users and existing positions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Direct release failed despite abundant idle liquidity
&lt;/h2&gt;

&lt;p&gt;This was the strongest control in the entire proof.&lt;/p&gt;

&lt;p&gt;A withdrawal failure after deploying funds externally could otherwise be explained as an ordinary liquidity problem.&lt;/p&gt;

&lt;p&gt;The PoC made that explanation impossible.&lt;/p&gt;

&lt;p&gt;Before the release probe, CarthaVault still held:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;150,000 USDC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user attempted to release only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;10,000 USDC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The vault had fifteen times the probe amount available locally.&lt;/p&gt;

&lt;p&gt;After the lock and cooldown requirements were satisfied, User A called the real &lt;code&gt;release&lt;/code&gt; path.&lt;/p&gt;

&lt;p&gt;The result was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;release
REVERTS EmptyPrimaryPrice

User release delta
0 USDC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason is that &lt;code&gt;release&lt;/code&gt; needs &lt;code&gt;_convertToAssets&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function _convertToAssets(
    uint256 shares
)
    internal
    view
    returns (
        uint256 assets
    )
{
    uint256 totalShares =
        totalSupply();

    if (
        totalShares == 0
    ) {
        return 0;
    }

    return
        (
            shares
                *
            _totalValueLocked()
        )
            /
        totalShares;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/hackenproof-public/cartha-vaults/blob/main/src/CarthaVault.sol#L928-L933" rel="noopener noreferrer"&gt;CarthaVault.sol&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The vault had enough USDC.&lt;/p&gt;

&lt;p&gt;What it lacked was a price source that survived long enough to calculate the payout.&lt;/p&gt;

&lt;p&gt;That isolates the liveness failure from ordinary external illiquidity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Queued withdrawal processing was blocked too
&lt;/h2&gt;

&lt;p&gt;The proof also exercised the keeper-driven withdrawal flow.&lt;/p&gt;

&lt;p&gt;User A successfully called:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;requestRelease
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the pending withdrawal was set.&lt;/p&gt;

&lt;p&gt;The keeper then called:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;processQueuedWithdrawals
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That operation also depended on TVL and reverted with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EmptyPrimaryPrice
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pending withdrawal was not processed.&lt;/p&gt;

&lt;p&gt;The two tested withdrawal modes therefore failed for the same root cause:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Direct release
FAILED

Queued withdrawal processing
FAILED
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A full GM recall restored normal accounting
&lt;/h2&gt;

&lt;p&gt;The issue had a valid recovery path.&lt;/p&gt;

&lt;p&gt;The keeper created a real 0xMarkets withdrawal for the vault’s complete GM balance and executed it through the real withdrawal handler.&lt;/p&gt;

&lt;p&gt;Afterward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CarthaVault GM balance
0

deployedGmBalance
0

totalValueLocked
PASS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once the GM balance reaches zero, &lt;code&gt;gmValueInUsdc&lt;/code&gt; no longer needs to query the cleared 0xMarkets primary prices.&lt;/p&gt;

&lt;p&gt;This is why the demonstrated impact is a temporary freeze rather than a permanent one.&lt;/p&gt;

&lt;p&gt;The proof establishes that normal accounting resumes after a full recall. It does not rely on keeping stale primary prices alive.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PoC used the real integration
&lt;/h2&gt;

&lt;p&gt;The test was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FreezeFlow.ts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and used the real:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CarthaVault

CarthaVault proxy and factory

Cartha AccessControl

0xMarkets Oracle

0xMarkets Reader

0xMarkets ExchangeRouter

0xMarkets DepositHandler

0xMarkets WithdrawalHandler

0xMarkets MarketToken

OracleModule.withOraclePrices
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It did not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Mock the 0xMarkets Oracle

Mock the 0xMarkets Reader

Mock the ExchangeRouter

Mint GM directly

Call clearAllPrices directly

Depend on mainnet

Depend on testnet

Depend on public RPC

Require governance compromise

Require malicious admin behavior
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The validated run ended with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✔ freezes (611575ms)

1 passing (10m)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The runtime evidence
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Observed result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;User A initial deposit&lt;/td&gt;
&lt;td&gt;200,000 USDC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idle USDC before deploy&lt;/td&gt;
&lt;td&gt;200,000 USDC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;USDC deployed to 0xMarkets&lt;/td&gt;
&lt;td&gt;50,000 USDC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idle USDC after deploy&lt;/td&gt;
&lt;td&gt;150,000 USDC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GM before execution&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GM after execution&lt;/td&gt;
&lt;td&gt;50,000e18 raw units&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oracle tokens with prices after execution&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;totalValueLocked&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EmptyPrimaryPrice&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;deployedGmBalance&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EmptyPrimaryPrice&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New &lt;code&gt;depositAndLock&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EmptyPrimaryPrice&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;lockTopUp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EmptyPrimaryPrice&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Direct release probe&lt;/td&gt;
&lt;td&gt;10,000 USDC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idle USDC available for release&lt;/td&gt;
&lt;td&gt;150,000 USDC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;release&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EmptyPrimaryPrice&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;processQueuedWithdrawals&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EmptyPrimaryPrice&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full GM recall&lt;/td&gt;
&lt;td&gt;Restored TVL&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The most important comparison is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Idle liquidity
150,000 USDC

Release probe
10,000 USDC

Liquidity sufficient
YES

Release result
EmptyPrimaryPrice
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user was not blocked because the vault lacked assets.&lt;/p&gt;

&lt;p&gt;The user was blocked because the vault could not value the GM it already held.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I reported it as High
&lt;/h2&gt;

&lt;p&gt;The proof did not demonstrate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Direct theft

Insolvency

Permanent freezing of funds

No honest recovery path
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A complete GM recall restored accounting.&lt;/p&gt;

&lt;p&gt;That bounded the severity.&lt;/p&gt;

&lt;p&gt;But the issue was clearly more than a failed view.&lt;/p&gt;

&lt;p&gt;While GM remained held, the same root cause blocked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;New deposits

Position top-ups

Direct releases

Queued withdrawal processing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Existing users could not release through either tested withdrawal route even though enough idle USDC was already available.&lt;/p&gt;

&lt;p&gt;Recovery required a keeper to unwind the complete GM position before normal TVL based operations resumed.&lt;/p&gt;

&lt;p&gt;That is why I reported the finding as &lt;strong&gt;High&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The HackenProof reward allocated to the report was &lt;strong&gt;$0.52&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is not the GM decimal mismatch
&lt;/h2&gt;

&lt;p&gt;A separate CarthaVault finding also involved settled GM valuation, but the failure mode is different.&lt;/p&gt;

&lt;p&gt;The decimal-normalization issue has this shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GM exists

Price exists

Valuation returns

Returned value uses the wrong scale
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This finding has this shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GM exists

Primary price has been cleared

Valuation reverts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One corrupts the number returned by TVL.&lt;/p&gt;

&lt;p&gt;The other prevents TVL from being calculated at all.&lt;/p&gt;

&lt;p&gt;They require different fixes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is not the pending-request accounting issue
&lt;/h2&gt;

&lt;p&gt;The pending-request finding exists before 0xMarkets settlement.&lt;/p&gt;

&lt;p&gt;This issue exists after settlement.&lt;/p&gt;

&lt;p&gt;Here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Deposit request
EXECUTED

GM minted to CarthaVault
YES

Oracle price cache
CLEARED

Steady-state TVL
BROKEN
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bug does not depend on value being in transit.&lt;/p&gt;

&lt;p&gt;It depends on using transaction scoped oracle state to value an asset held persistently across transactions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix the price lifetime mismatch
&lt;/h2&gt;

&lt;p&gt;CarthaVault should not use &lt;code&gt;Oracle.primaryPrices&lt;/code&gt; as its persistent GM valuation source.&lt;/p&gt;

&lt;p&gt;Those prices belong to the 0xMarkets execution lifecycle.&lt;/p&gt;

&lt;p&gt;The report proposes a dedicated GM pricing adapter backed by a persistent token price source.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CarthaVault
    ↓
Persistent GM price adapter
    ↓
Persistent token prices
    ↓
0xMarkets Reader.getMarketTokenPrice
    ↓
GM valuation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;instead of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CarthaVault
    ↓
0xMarkets Oracle.primaryPrices
    ↓
Transaction scoped execution state
    ↓
EmptyPrimaryPrice after execution
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This lets CarthaVault continue using the market valuation logic without assuming that execution-only Oracle state survives into later transactions.&lt;/p&gt;

&lt;p&gt;The integration should enforce a simple invariant:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If CarthaVault can hold GM across transactions, it must have a valuation source that remains available across transactions.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The lifetime of the asset and the lifetime of its pricing dependency must be compatible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Regression coverage
&lt;/h2&gt;

&lt;p&gt;A corrected implementation should reproduce the same normal lifecycle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. User deposits into CarthaVault

2. Keeper calls deployToPool

3. Real 0xMarkets deposit executes

4. GM is minted to CarthaVault

5. 0xMarkets clears primary prices

6. CarthaVault continues holding GM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At that point, none of the CarthaVault operations should depend on the cleared cache.&lt;/p&gt;

&lt;p&gt;The regression suite should require:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;totalValueLocked
PASS

deployedGmBalance
PASS

depositAndLock
PASS

lockTopUp
PASS

release with sufficient idle USDC
PASS

processQueuedWithdrawals
PASS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test should not preserve or restore 0xMarkets &lt;code&gt;primaryPrices&lt;/code&gt; to make these checks pass.&lt;/p&gt;

&lt;p&gt;The fix should remove the persistent dependency on that transient state.&lt;/p&gt;

&lt;h2&gt;
  
  
  The broader audit lesson
&lt;/h2&gt;

&lt;p&gt;Cross-protocol integrations need compatible state lifetimes, not just compatible interfaces.&lt;/p&gt;

&lt;p&gt;A function called:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;getPrimaryPrice
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;can look like a conventional oracle read.&lt;/p&gt;

&lt;p&gt;That name alone says nothing about whether the value exists outside an execution transaction.&lt;/p&gt;

&lt;p&gt;When integrating another protocol’s state, I now treat these as separate questions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Who sets the value?

When does it become valid?

Who clears it?

How long is it expected to exist?

Does my protocol need it after that lifetime ends?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;0xMarkets cleared its primary prices as part of the normal execution lifecycle.&lt;/p&gt;

&lt;p&gt;CarthaVault held the resulting GM after that lifecycle had finished.&lt;/p&gt;

&lt;p&gt;The integration crossed those two lifetimes without a persistent pricing layer in between.&lt;/p&gt;

&lt;p&gt;That was the bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;CarthaVault valued its settled 0xMarkets GM through &lt;code&gt;PoolDeployLib.gmValueInUsdc&lt;/code&gt;, which queried &lt;code&gt;Oracle.getPrimaryPrice&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A real &lt;code&gt;DepositHandler.executeDeposit&lt;/code&gt; call set the required prices for execution, minted GM to CarthaVault, and then cleared the Oracle primary prices through &lt;code&gt;OracleModule.withOraclePrices&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;After the transaction, the vault was left in a normal but incompatible state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GM held by CarthaVault
YES

0xMarkets primary prices available
NO
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any CarthaVault operation that needed TVL then reached the cleared price cache and reverted with &lt;code&gt;EmptyPrimaryPrice&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The proof demonstrated failures in &lt;code&gt;depositAndLock&lt;/code&gt;, &lt;code&gt;lockTopUp&lt;/code&gt;, &lt;code&gt;release&lt;/code&gt;, and &lt;code&gt;processQueuedWithdrawals&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The release control was especially strong: the vault had &lt;code&gt;150,000 USDC&lt;/code&gt; of idle liquidity while the user attempted to release only &lt;code&gt;10,000 USDC&lt;/code&gt;, yet the call still reverted.&lt;/p&gt;

&lt;p&gt;A full GM recall restored accounting, so the demonstrated impact was a temporary freeze rather than a permanent one.&lt;/p&gt;

&lt;p&gt;I reported the finding as High through HackenProof and received &lt;strong&gt;$0.52&lt;/strong&gt;.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1vdcm3n31d4m81bag7ot.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1vdcm3n31d4m81bag7ot.png" alt=" " width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The core engineering lesson is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Persistent accounting cannot depend on transaction scoped oracle state.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If a vault holds an external asset after the execution transaction ends, the valuation source for that asset must still exist when the next transaction begins.&lt;/p&gt;

</description>
      <category>smartcontract</category>
      <category>blockchain</category>
      <category>security</category>
      <category>web3</category>
    </item>
    <item>
      <title>How Successful 0xMarkets Requests Could Permanently Orphan Users’ WNT Execution Fees</title>
      <dc:creator>Daniel</dc:creator>
      <pubDate>Sun, 09 Aug 2026 09:44:09 +0000</pubDate>
      <link>https://dev.to/f00dat/how-successful-0xmarkets-requests-could-permanently-orphan-users-wnt-execution-fees-2hcg</link>
      <guid>https://dev.to/f00dat/how-successful-0xmarkets-requests-could-permanently-orphan-users-wnt-execution-fees-2hcg</guid>
      <description>&lt;p&gt;A successful request is usually the path auditors worry about the least.&lt;/p&gt;

&lt;p&gt;The user submits the request, a keeper executes it, the expected protocol action completes, a success event is emitted, and the request disappears from storage. From the outside, everything looks finished.&lt;/p&gt;

&lt;p&gt;In 0xMarkets, that assumption was wrong for several request types.&lt;/p&gt;

&lt;p&gt;A user could supply a nonzero WNT execution fee through the normal production flow. The protocol accepted and recorded that fee, stored it in the request, executed the request successfully, and removed the request from storage.&lt;/p&gt;

&lt;p&gt;But the success path never settled the fee.&lt;/p&gt;

&lt;p&gt;The keeper received no WNT execution payment.&lt;/p&gt;

&lt;p&gt;The user received no refund of the original fee.&lt;/p&gt;

&lt;p&gt;The WNT remained inside the request vault and remained included in the vault’s &lt;code&gt;StrictBank&lt;/code&gt; accounting baseline.&lt;/p&gt;

&lt;p&gt;A later request then recorded only newly transferred WNT, so the old fee was not rediscovered through the normal request lifecycle.&lt;/p&gt;

&lt;p&gt;I reported this finding as &lt;strong&gt;High&lt;/strong&gt; through the 0xMarkets program on HackenProof.&lt;/p&gt;

&lt;p&gt;It earned &lt;strong&gt;$0.24&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The reward was small, but the underlying lifecycle failure affected five distinct production request paths.&lt;/p&gt;

&lt;p&gt;The proof reproduced the same behavior across five successful production paths:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Withdrawal

Order

GLV Deposit

GLV Withdrawal

Shift
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of those requests failed.&lt;/p&gt;

&lt;p&gt;None depended on cancellation.&lt;/p&gt;

&lt;p&gt;None depended on a frozen order.&lt;/p&gt;

&lt;p&gt;Every mandatory request executed successfully.&lt;/p&gt;

&lt;p&gt;That is what made the bug interesting: the requested action completed, while value associated with that request did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The execution fee has its own lifecycle
&lt;/h2&gt;

&lt;p&gt;0xMarkets request flows can carry a WNT execution fee intended to support keeper execution.&lt;/p&gt;

&lt;p&gt;A simplified expected lifecycle is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User supplies WNT execution fee
        ↓
Request vault records the transfer
        ↓
Request stores executionFee
        ↓
Keeper executes the request
        ↓
GasUtils.payExecutionFee
        ↓
Keeper receives the execution payment
        ↓
Any remaining amount is refunded
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The affected success paths instead behaved like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User supplies WNT execution fee
        ↓
Request vault records the transfer
        ↓
Request stores executionFee
        ↓
Keeper executes the request successfully
        ↓
Request is removed
        ↓
Execution fee payout is skipped
        ↓
WNT remains in the request vault
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The protocol finished the request lifecycle.&lt;/p&gt;

&lt;p&gt;It did not finish the fee lifecycle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Withdrawal shows the mismatch clearly
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;WithdrawalUtils.createWithdrawal&lt;/code&gt; records incoming WNT through the real &lt;code&gt;WithdrawalVault&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;address wnt = TokenUtils.wnt(dataStore);
uint256 wntAmount =
    withdrawalVault.recordTransferIn(
        wnt
    );

if (
    wntAmount
        &amp;lt;
    params.executionFee
) {
    revert Errors.InsufficientWntAmount(
        wntAmount,
        params.executionFee
    );
}

params.executionFee =
    wntAmount;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/hackenproof-public/0xMarkets_contract/blob/e94daee6558f90e479c2c967dedae1d7cd06fb0c/contracts/withdrawal/WithdrawalUtils.sol#L82-L117" rel="noopener noreferrer"&gt;WithdrawalUtils.sol&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The important point is that this is not a stray token accidentally sent to a vault.&lt;/p&gt;

&lt;p&gt;The protocol:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Receives the WNT

Records the WNT

Checks the amount against executionFee

Stores the resulting value in the request
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem appears after successful execution.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ExecuteWithdrawalUtils.executeWithdrawal&lt;/code&gt; removes the request, performs the withdrawal, emits &lt;code&gt;WithdrawalExecuted&lt;/code&gt;, executes the callback, calculates the oracle price count, and then reaches a disabled payout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// ! EXECUTION FEE EXEMPTION
// GasUtils.payExecutionFee(
//     params.dataStore,
//     params.eventEmitter,
//     params.withdrawalVault,
//     params.key,
//     withdrawal.callbackContract(),
//     withdrawal.executionFee(),
//     params.startingGas,
//     cache.oraclePriceCount,
//     params.keeper,
//     withdrawal.receiver()
// );
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/hackenproof-public/0xMarkets_contract/blob/e94daee6558f90e479c2c967dedae1d7cd06fb0c/contracts/withdrawal/ExecuteWithdrawalUtils.sol#L91-L168" rel="noopener noreferrer"&gt;ExecuteWithdrawalUtils.sol&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The request succeeds.&lt;/p&gt;

&lt;p&gt;The request is gone.&lt;/p&gt;

&lt;p&gt;The fee remains.&lt;/p&gt;

&lt;h2&gt;
  
  
  Orders had the same lifecycle break
&lt;/h2&gt;

&lt;p&gt;Normal user orders could also store a nonzero execution fee.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;OrderUtils.createOrder&lt;/code&gt; records WNT and stores the resulting amount:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;uint256 wntAmount =
    orderVault.recordTransferIn(
        cache.wnt
    );

if (
    wntAmount
        &amp;lt;
    params.numbers.executionFee
) {
    revert Errors.InsufficientWntAmountForExecutionFee(
        wntAmount,
        params.numbers.executionFee
    );
}

params.numbers.executionFee =
    wntAmount;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;uint256 executionFee;

(
    executionFee,
    cache.executionFeeDiff
) =
    (
        params.numbers.executionFee,
        0
    );

order.setExecutionFee(
    executionFee
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/hackenproof-public/0xMarkets_contract/blob/e94daee6558f90e479c2c967dedae1d7cd06fb0c/contracts/order/OrderUtils.sol#L113-L179" rel="noopener noreferrer"&gt;OrderUtils.sol&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The success path then skips the production payout routine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// ! EXECUTION FEE EXEMPTION
// GasUtils.payExecutionFee(
//     params.contracts.dataStore,
//     params.contracts.eventEmitter,
//     params.contracts.orderVault,
//     params.key,
//     params.order.callbackContract(),
//     params.order.executionFee(),
//     params.startingGas,
//     GasUtils.estimateOrderOraclePriceCount(
//         params.order.swapPath().length
//     ),
//     params.keeper,
//     params.order.receiver()
// );
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/hackenproof-public/0xMarkets_contract/blob/e94daee6558f90e479c2c967dedae1d7cd06fb0c/contracts/order/ExecuteOrderUtils.sol#L87-L110" rel="noopener noreferrer"&gt;ExecuteOrderUtils.sol&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The code comments correctly note that liquidation and ADL orders can have zero execution fees.&lt;/p&gt;

&lt;p&gt;That does not resolve normal user orders that actually stored a nonzero fee before successful execution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Both GLV directions were affected
&lt;/h2&gt;

&lt;p&gt;The same pattern existed in GLV request processing.&lt;/p&gt;

&lt;h3&gt;
  
  
  GLV Deposit
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;GlvDepositUtils.createGlvDeposit&lt;/code&gt; either separates WNT from the deposited token amount or records a separate WNT transfer and stores the result as the request’s &lt;code&gt;executionFee&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;After successful execution, the payout call is disabled:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// ! EXECUTION FEE EXEMPTION
// GasUtils.payExecutionFee(
//     params.dataStore,
//     params.eventEmitter,
//     params.glvVault,
//     params.key,
//     glvDeposit.callbackContract(),
//     glvDeposit.executionFee(),
//     params.startingGas,
//     cache.oraclePriceCount,
//     params.keeper,
//     glvDeposit.receiver()
// );
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/hackenproof-public/0xMarkets_contract/blob/e94daee6558f90e479c2c967dedae1d7cd06fb0c/contracts/glv/glvDeposit/GlvDepositUtils.sol#L284-L306" rel="noopener noreferrer"&gt;GlvDepositUtils.sol&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  GLV Withdrawal
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;GlvWithdrawalUtils.createGlvWithdrawal&lt;/code&gt; records WNT in &lt;code&gt;GlvVault&lt;/code&gt; and stores that amount as &lt;code&gt;executionFee&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Its successful execution path reaches the same disabled settlement pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// ! EXECUTION FEE EXEMPTION
// GasUtils.payExecutionFee(
//     params.dataStore,
//     params.eventEmitter,
//     params.glvVault,
//     params.key,
//     glvWithdrawal.callbackContract(),
//     glvWithdrawal.executionFee(),
//     params.startingGas,
//     cache.oraclePriceCount,
//     params.keeper,
//     glvWithdrawal.receiver()
// );
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/hackenproof-public/0xMarkets_contract/blob/e94daee6558f90e479c2c967dedae1d7cd06fb0c/contracts/glv/glvWithdrawal/GlvWithdrawalUtils.sol#L190-L206" rel="noopener noreferrer"&gt;GlvWithdrawalUtils.sol&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Different request direction.&lt;/p&gt;

&lt;p&gt;Same orphaned fee.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shift makes the lifecycle problem especially visible
&lt;/h2&gt;

&lt;p&gt;Shift performs an internal withdrawal followed by an internal deposit.&lt;/p&gt;

&lt;p&gt;Those internal requests deliberately use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;executionFee
0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;because the outer Shift request carries the user-funded fee.&lt;/p&gt;

&lt;p&gt;That is internally consistent.&lt;/p&gt;

&lt;p&gt;The problem is what happens when the Shift finishes.&lt;/p&gt;

&lt;p&gt;After the internal withdrawal and deposit complete, &lt;code&gt;ShiftExecuted&lt;/code&gt; is emitted and the callback runs.&lt;/p&gt;

&lt;p&gt;The outer fee settlement is then disabled:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// ! EXECUTION FEE EXEMPTION
// GasUtils.payExecutionFee(
//     params.dataStore,
//     params.eventEmitter,
//     params.shiftVault,
//     params.key,
//     shift.callbackContract(),
//     shift.executionFee(),
//     params.startingGas,
//     GasUtils.estimateShiftOraclePriceCount(),
//     params.keeper,
//     shift.receiver()
// );
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/hackenproof-public/0xMarkets_contract/blob/e94daee6558f90e479c2c967dedae1d7cd06fb0c/contracts/shift/ShiftUtils.sol#L158-L318" rel="noopener noreferrer"&gt;ShiftUtils.sol&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The entire Shift can therefore succeed while its original nonzero WNT execution fee remains in &lt;code&gt;ShiftVault&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  StrictBank is why the old fee does not become the next fee
&lt;/h2&gt;

&lt;p&gt;The strongest part of the proof was not simply showing WNT sitting in the vault.&lt;/p&gt;

&lt;p&gt;It showed why a later request does not naturally recover it.&lt;/p&gt;

&lt;p&gt;Each request vault inherits &lt;code&gt;StrictBank&lt;/code&gt;, which keeps an accounting baseline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;mapping(
    address =&amp;gt;
    uint256
) public tokenBalances;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;recordTransferIn&lt;/code&gt; calculates only the balance increase since the previous accounting point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function _recordTransferIn(
    address token
)
    internal
    returns (
        uint256
    )
{
    uint256 prevBalance =
        tokenBalances[
            token
        ];

    uint256 nextBalance =
        IERC20(
            token
        ).balanceOf(
            address(
                this
            )
        );

    tokenBalances[
        token
    ] =
        nextBalance;

    return
        nextBalance
            -
        prevBalance;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/hackenproof-public/0xMarkets_contract/blob/e94daee6558f90e479c2c967dedae1d7cd06fb0c/contracts/bank/StrictBank.sol#L24-L57" rel="noopener noreferrer"&gt;StrictBank.sol&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Suppose the first request adds one execution fee:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Vault actual WNT
old balance + fee

StrictBank tokenBalances
old balance + fee
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The request then succeeds without paying or refunding that fee.&lt;/p&gt;

&lt;p&gt;Nothing removes it from either the real vault balance or the accounting baseline.&lt;/p&gt;

&lt;p&gt;When another request transfers new WNT:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;prevBalance
already includes old fee

nextBalance
old fee + newly transferred fee

recordTransferIn
new fee only
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The old fee is not picked up again.&lt;/p&gt;

&lt;p&gt;The PoC explicitly created a later request for every affected path and verified that only newly transferred WNT was recorded.&lt;/p&gt;

&lt;p&gt;That is why the issue is stronger than “the vault has leftover tokens.”&lt;/p&gt;

&lt;p&gt;The original execution fee becomes detached from the request that owned it and invisible to later request accounting.&lt;/p&gt;

&lt;h2&gt;
  
  
  What “permanent” means in this report
&lt;/h2&gt;

&lt;p&gt;The word &lt;strong&gt;permanent&lt;/strong&gt; should be read precisely.&lt;/p&gt;

&lt;p&gt;The PoC proves that the fee is permanently lost from the &lt;strong&gt;normal request lifecycle&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Original request
Removed

Keeper settlement
Absent

User refund
Absent

Old fee discovered by later request
No

Normal request-based recovery
Gone
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The proof does not establish that no privileged migration, contract upgrade, or extraordinary administrative recovery could ever move the WNT.&lt;/p&gt;

&lt;p&gt;That is not required for the demonstrated bug.&lt;/p&gt;

&lt;p&gt;The security failure is that after a successful request is removed, the protocol leaves no normal request state through which that user’s fee is settled or recovered.&lt;/p&gt;

&lt;h2&gt;
  
  
  The production settlement routine already existed
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;GasUtils.payExecutionFee&lt;/code&gt; contains the expected settlement logic.&lt;/p&gt;

&lt;p&gt;It calculates the keeper portion:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;uint256 executionFeeForKeeper =
    adjustGasUsage(
        dataStore,
        gasUsed,
        oraclePriceCount
    )
        *
    tx.gasprice;

if (
    executionFeeForKeeper
        &amp;gt;
    executionFee
) {
    executionFeeForKeeper =
        executionFee;
}

bank.transferOutNativeToken(
    keeper,
    executionFeeForKeeper
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and then computes the remainder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cache.refundFeeAmount =
    executionFee
        -
    executionFeeForKeeper;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/hackenproof-public/0xMarkets_contract/blob/e94daee6558f90e479c2c967dedae1d7cd06fb0c/contracts/gas/GasUtils.sol#L114-L150" rel="noopener noreferrer"&gt;GasUtils.sol&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This matters because the report is not inventing a hypothetical fee destination.&lt;/p&gt;

&lt;p&gt;The protocol already has a production routine designed to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Pay the keeper

Refund the remainder
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cancellation paths use fee settlement behavior.&lt;/p&gt;

&lt;p&gt;The affected successful paths do not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PoC used real routers, handlers, vaults, and accounting
&lt;/h2&gt;

&lt;p&gt;The proof was a single TypeScript test built on the repository’s production deployment fixture.&lt;/p&gt;

&lt;p&gt;It exercised these complete paths:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;Creation entrypoint&lt;/th&gt;
&lt;th&gt;Execution entrypoint&lt;/th&gt;
&lt;th&gt;Vault&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Withdrawal&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ExchangeRouter.createWithdrawal&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WithdrawalHandler.executeWithdrawal&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WithdrawalVault&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Order&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ExchangeRouter.createOrder&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OrderHandler.executeOrder&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OrderVault&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GLV Deposit&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GlvRouter.createGlvDeposit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GlvHandler.executeGlvDeposit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GlvVault&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GLV Withdrawal&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GlvRouter.createGlvWithdrawal&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GlvHandler.executeGlvWithdrawal&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GlvVault&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shift&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ExchangeRouter.createShift&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ShiftHandler.executeShift&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ShiftVault&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It did not depend on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Mock affected contracts

Custom harnesses

Direct storage writes

Direct vault accounting mutation

Failed requests

Cancellation

Frozen orders

Mainnet

Testnet

Public RPC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test execution fee was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1000000000000000 wei
0.001 WNT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All five mandatory paths passed.&lt;/p&gt;

&lt;p&gt;The exact test run finished with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✔ locks (614607ms)

1 passing (10m)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What was verified after each successful execution
&lt;/h2&gt;

&lt;p&gt;A commented-out function alone would not prove impact.&lt;/p&gt;

&lt;p&gt;For every path, the PoC followed the state before and after execution.&lt;/p&gt;

&lt;p&gt;It verified:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Nonzero executionFee stored before success

Request created through the production router

Production handler execution succeeded

Expected success event emitted

Cancel event absent

Freeze event absent where applicable

Request removed after success

KeeperExecutionFee event absent

ExecutionFeeRefund event absent

ExecutionFeeRefundCallback event absent

Keeper WNT delta zero

Original fee remained in the request vault

Original fee remained in StrictBank accounting

Later request registered only newly transferred WNT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is one important measurement nuance.&lt;/p&gt;

&lt;p&gt;Withdrawal-like operations may legitimately send WNT to the receiver as protocol output.&lt;/p&gt;

&lt;p&gt;For that reason, the proof did not identify the missing execution-fee refund merely by looking at the receiver’s raw WNT delta.&lt;/p&gt;

&lt;p&gt;It relied on the absence of the production refund events together with the fact that the original fee remained in the vault and in &lt;code&gt;StrictBank&lt;/code&gt; accounting.&lt;/p&gt;

&lt;p&gt;That prevents normal withdrawal output from being mistaken for a fee refund.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is not merely an unpaid keeper
&lt;/h2&gt;

&lt;p&gt;The keeper receiving zero WNT is one symptom.&lt;/p&gt;

&lt;p&gt;The user-funded value is the more important part.&lt;/p&gt;

&lt;p&gt;If successful execution was intentionally supposed to be fee exempt, the protocol had safe design options:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Do not accept a nonzero fee

or

Return the supplied fee

or

Settle it explicitly under another documented policy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead, the affected flows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Accept the WNT

Validate the WNT

Store executionFee

Execute successfully

Delete the request

Leave the WNT in the vault
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A fee exemption can explain why the keeper is not paid.&lt;/p&gt;

&lt;p&gt;It does not explain why user-supplied WNT should become detached from the request that supplied it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I reported it as High
&lt;/h2&gt;

&lt;p&gt;The submitted report mapped the issue to three impact categories:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Theft of gas or execution fee

Permanent freezing of funds

Functional correctness failure
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The affected value is not the user’s principal position or LP pool principal.&lt;/p&gt;

&lt;p&gt;The proof also did not demonstrate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Protocol insolvency

Protocol-wide unrecoverable loss

Direct theft of market principal

Attacker-controlled extraction of the locked WNT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those limits are important.&lt;/p&gt;

&lt;p&gt;But the affected WNT is still user-supplied value accepted by production request flows.&lt;/p&gt;

&lt;p&gt;The protocol stores it specifically as an execution fee and then loses the normal settlement path after successful request deletion.&lt;/p&gt;

&lt;p&gt;The same root cause reproduced across five distinct request classes.&lt;/p&gt;

&lt;p&gt;That is why I submitted the finding as High.&lt;/p&gt;

&lt;p&gt;The HackenProof reward allocated to my report was &lt;strong&gt;$0.24&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The reward amount reflects contest payout mechanics. It does not alter what the proof demonstrated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is separate from other 0xMarkets fee findings
&lt;/h2&gt;

&lt;p&gt;Several 0xMarkets findings involved execution fees, but the lifecycle and root cause here are different.&lt;/p&gt;

&lt;h3&gt;
  
  
  Frozen order execution fee lock
&lt;/h3&gt;

&lt;p&gt;That issue occurs in the freeze path.&lt;/p&gt;

&lt;p&gt;This report requires successful execution and does not use &lt;code&gt;freezeOrder&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Callback fee-cap behavior
&lt;/h3&gt;

&lt;p&gt;Those findings concern oversized execution fees and callback-based recovery behavior.&lt;/p&gt;

&lt;p&gt;This report uses normal nonzero execution fees that remain in the vault after success.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pyth Lazer provider drain
&lt;/h3&gt;

&lt;p&gt;That finding concerns ETH held by the oracle provider and permissionless verification calls.&lt;/p&gt;

&lt;p&gt;This report concerns user-supplied WNT stored in request vaults.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cartha fee routing
&lt;/h3&gt;

&lt;p&gt;The Cartha integration issue sends WNT to the wrong destination before 0xMarkets can record the request fee.&lt;/p&gt;

&lt;p&gt;Here, WNT reaches the correct 0xMarkets vault and is correctly recorded.&lt;/p&gt;

&lt;p&gt;The failure happens after successful execution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix the lifecycle, not just the symptom
&lt;/h2&gt;

&lt;p&gt;The safest correction is to make every success path that accepts a nonzero execution fee settle that fee before returning.&lt;/p&gt;

&lt;p&gt;The commented &lt;code&gt;GasUtils.payExecutionFee&lt;/code&gt; calls show the intended settlement structure and already contain the required request-specific parameters.&lt;/p&gt;

&lt;p&gt;However, the fix should be applied consistently with the protocol’s intended fee policy.&lt;/p&gt;

&lt;p&gt;For example, order creation also contains disabled execution-fee validation and capping logic. A production patch should review creation and settlement together rather than blindly uncommenting one line in isolation.&lt;/p&gt;

&lt;p&gt;The invariant should be:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A successful request that accepts a nonzero execution fee must not leave that fee orphaned from the request lifecycle.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If successful execution is intentionally subsidized, creation should reject unnecessary nonzero fees or return them explicitly.&lt;/p&gt;

&lt;p&gt;What should never be possible is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Accept fee
Store fee
Execute successfully
Delete request
Leave fee without a normal owner-specific settlement path
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Regression tests
&lt;/h2&gt;

&lt;p&gt;A complete correction should cover all five affected request families:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Withdrawal success
Fee settled
No orphaned WNT

Order success
Fee settled
No orphaned WNT

GLV Deposit success
Fee settled
No orphaned WNT

GLV Withdrawal success
Fee settled
No orphaned WNT

Shift success
Fee settled
No orphaned WNT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The suite should also verify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Later requests record only their own incoming fee

StrictBank accounting matches the intended post-settlement balance

Cancellation behavior remains correct

Liquidation and ADL zero-fee behavior remains correct

Zero-fee requests remain valid where intended

Receiver protocol output is not mistaken for execution-fee refund
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The broader audit lesson
&lt;/h2&gt;

&lt;p&gt;Request-based protocols have two lifecycles that need to be reviewed independently.&lt;/p&gt;

&lt;p&gt;The first is the action:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Create
↓
Execute
↓
Complete
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second is every asset attached to that action:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Fund
↓
Account
↓
Store
↓
Settle
↓
Release
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A success event only proves the first lifecycle completed.&lt;/p&gt;

&lt;p&gt;It says nothing about whether every associated balance was settled correctly.&lt;/p&gt;

&lt;p&gt;This finding existed precisely in that gap.&lt;/p&gt;

&lt;p&gt;The request succeeded.&lt;/p&gt;

&lt;p&gt;The fee did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;0xMarkets accepted nonzero WNT execution fees in successful Withdrawal, Order, GLV Deposit, GLV Withdrawal, and Shift flows.&lt;/p&gt;

&lt;p&gt;The protocol recorded the fee and stored it in each request.&lt;/p&gt;

&lt;p&gt;The production handler then executed the requested operation successfully and removed the request.&lt;/p&gt;

&lt;p&gt;But the corresponding execution-fee payout was disabled.&lt;/p&gt;

&lt;p&gt;The keeper received no execution-fee WNT.&lt;/p&gt;

&lt;p&gt;No production refund was emitted for the original fee.&lt;/p&gt;

&lt;p&gt;The WNT remained in the request vault and remained part of &lt;code&gt;StrictBank&lt;/code&gt; accounting.&lt;/p&gt;

&lt;p&gt;Later requests recorded only newly transferred WNT, so the old fee did not return through subsequent request processing.&lt;/p&gt;

&lt;p&gt;The proof reproduced this behavior across all five mandatory paths using real routers, handlers, vaults, and accounting. It required no affected-contract mocks, custom harnesses, direct storage writes, failed requests, cancellation, freeze path, live RPC, mainnet, or testnet.&lt;/p&gt;

&lt;p&gt;I reported the finding as High through HackenProof and received &lt;strong&gt;$0.24&lt;/strong&gt;.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz9l2zzduvvcazxsaewej.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz9l2zzduvvcazxsaewej.png" alt=" " width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The engineering invariant is simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Successful execution must settle every piece of value that the request accepted before the request is deleted.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A success event should never be the moment a user-funded fee becomes orphaned from the protocol’s normal lifecycle.&lt;/p&gt;

</description>
      <category>smartcontract</category>
      <category>blockchain</category>
      <category>security</category>
      <category>web3</category>
    </item>
    <item>
      <title>How Permissionless Pyth Lazer Calls Could Drain 0xMarkets’ Oracle Fee Balance and Break Price Updates</title>
      <dc:creator>Daniel</dc:creator>
      <pubDate>Sun, 09 Aug 2026 09:14:22 +0000</pubDate>
      <link>https://dev.to/f00dat/how-permissionless-pyth-lazer-calls-could-drain-0xmarkets-oracle-fee-balance-and-break-price-5kd</link>
      <guid>https://dev.to/f00dat/how-permissionless-pyth-lazer-calls-could-drain-0xmarkets-oracle-fee-balance-and-break-price-5kd</guid>
      <description>&lt;p&gt;Oracle integrations often look like read-only infrastructure.&lt;/p&gt;

&lt;p&gt;This one was not.&lt;/p&gt;

&lt;p&gt;Inside 0xMarkets, &lt;code&gt;PythLazerFeedProvider.getOraclePrice&lt;/code&gt; could be called directly by any external account. Every successful call then paid the Pyth Lazer verification fee using ETH already held by the provider contract.&lt;/p&gt;

&lt;p&gt;The caller did not need to be the protocol Oracle.&lt;/p&gt;

&lt;p&gt;The caller did not need the &lt;code&gt;CONTROLLER&lt;/code&gt; role.&lt;/p&gt;

&lt;p&gt;The caller did not need administrator privileges.&lt;/p&gt;

&lt;p&gt;The caller did not even need to send ETH.&lt;/p&gt;

&lt;p&gt;With valid update data, an unprivileged account could repeatedly call the provider with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;msg.value
0 ETH
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;while the provider paid the verification fee from its own native balance.&lt;/p&gt;

&lt;p&gt;In my proof of concept, the provider held:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0.05 ETH
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the local verification fee was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0.01 ETH
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five zero-value attacker transactions reduced the provider balance to zero.&lt;/p&gt;

&lt;p&gt;The same legitimate &lt;code&gt;Oracle.setPrices&lt;/code&gt; flow that worked before the drain then failed because &lt;code&gt;PythLazerFeedProvider&lt;/code&gt; could no longer fund &lt;code&gt;pythLazer.verifyUpdate&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I submitted this finding as Medium through the 0xMarkets audit contest on HackenProof.&lt;/p&gt;

&lt;p&gt;It earned &lt;strong&gt;$1.24&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The interesting part was never the small test balance.&lt;/p&gt;

&lt;p&gt;The real issue was the authorization boundary:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Any external account with valid update data could decide when the protocol spent its oracle-verification balance and could keep doing so until the provider no longer had enough funds to execute the legitimate price-update path.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The design assumption
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;PythLazerFeedProvider&lt;/code&gt; was intentionally able to hold ETH:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Accept ETH to cover Pyth verification fees
receive() external payable {}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is reasonable if the provider is expected to pay a verification fee on behalf of the protocol.&lt;/p&gt;

&lt;p&gt;The problem was that the function spending that balance was unrestricted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function getOraclePrice(
    address token,
    bytes memory data
) external returns (OracleUtils.ValidatedPrice memory) {
    uint32 feedId =
        uint32(
            dataStore.getUint(
                Keys.pythLazerFeedIdKey(token)
            )
        );

    if (feedId == 0) {
        revert Errors.EmptyPythLazerFeedId(token);
    }

    uint256 feedMultiplier =
        dataStore.getUint(
            Keys.pythLazerFeedMultiplierKey(token)
        );

    if (feedMultiplier == 0) {
        revert Errors.EmptyPythLazerFeedMultiplier(token);
    }

    bool inverted =
        dataStore.getBool(
            Keys.pythLazerFeedInvertedKey(token)
        );

    uint256 fee =
        pythLazer.verification_fee();

    (
        bytes memory payload,
    ) =
        pythLazer.verifyUpdate{
            value: fee
        }(
            data
        );
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There was no &lt;code&gt;onlyOracle&lt;/code&gt; style guard.&lt;/p&gt;

&lt;p&gt;There was no &lt;code&gt;onlyController&lt;/code&gt; restriction.&lt;/p&gt;

&lt;p&gt;Nothing checked &lt;code&gt;msg.sender&lt;/code&gt; before the fee-bearing external call.&lt;/p&gt;

&lt;p&gt;The original implementation is visible in &lt;a href="https://github.com/hackenproof-public/0xMarkets_contract/blob/e94daee6558f90e479c2c967dedae1d7cd06fb0c/contracts/oracle/PythLazerFeedProvider.sol#L27-L44" rel="noopener noreferrer"&gt;&lt;code&gt;PythLazerFeedProvider.sol&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why zero &lt;code&gt;msg.value&lt;/code&gt; is the decisive detail
&lt;/h2&gt;

&lt;p&gt;A natural reaction to a paid oracle call is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The caller is paying the verification fee.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That would change the threat model completely.&lt;/p&gt;

&lt;p&gt;But it was not what happened here.&lt;/p&gt;

&lt;p&gt;The provider calculated the fee internally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;uint256 fee =
    pythLazer.verification_fee();
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and then executed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pythLazer.verifyUpdate{
    value: fee
}(
    data
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That value came from the balance of &lt;code&gt;PythLazerFeedProvider&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The caller only paid transaction gas.&lt;/p&gt;

&lt;p&gt;The PoC made this explicit by sending every drain transaction with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;attacker msg.value
0 ETH
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each successful call still reduced the provider balance by exactly one verification fee.&lt;/p&gt;

&lt;p&gt;The vulnerable capability was therefore not merely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Anyone can request an oracle validation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Anyone can request an oracle validation
and make the protocol pay for it
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The legitimate oracle path
&lt;/h2&gt;

&lt;p&gt;The intended flow was protected at the higher level.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Oracle.setPrices&lt;/code&gt; is restricted to controllers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function setPrices(
    OracleUtils.SetPricesParams memory params
) external onlyController {
    OracleUtils.ValidatedPrice[] memory prices =
        _validatePrices(
            params,
            false
        );

    _setPrices(
        prices
    );
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;During &lt;code&gt;_validatePrices&lt;/code&gt;, the Oracle calls the configured provider:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OracleUtils.ValidatedPrice memory validatedPrice =
    IOracleProvider(
        provider
    ).getOraclePrice(
        token,
        data
    );
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the legitimate architecture looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Controller
    ↓
Oracle.setPrices
    ↓
Oracle._validatePrices
    ↓
PythLazerFeedProvider.getOraclePrice
    ↓
pythLazer.verifyUpdate
    ↓
Validated price
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem was that nothing forced callers to use that architecture.&lt;/p&gt;

&lt;p&gt;An attacker could skip the protected Oracle contract and call the provider directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Unprivileged account
    ↓
PythLazerFeedProvider.getOraclePrice
    ↓
Provider pays verification fee
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;0xMarkets protected who could set prices.&lt;/p&gt;

&lt;p&gt;It did not protect who could spend the fee balance required to validate those prices.&lt;/p&gt;

&lt;h2&gt;
  
  
  The attack in eleven steps
&lt;/h2&gt;

&lt;p&gt;The proof used a configured token and valid local Pyth Lazer update data.&lt;/p&gt;

&lt;p&gt;The complete sequence was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. PythLazerFeedProvider is funded with 0.05 ETH

2. The verification fee is configured to 0.01 ETH

3. A controller calls Oracle.setPrices

4. The legitimate price update succeeds

5. An unprivileged account calls getOraclePrice directly

6. The attacker sends zero ETH

7. The provider pays 0.01 ETH to the verifier

8. The attacker repeats the call five times

9. The provider balance reaches zero

10. The controller calls Oracle.setPrices again

11. The legitimate update fails because the provider cannot fund verifyUpdate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The attacker did not receive the drained ETH.&lt;/p&gt;

&lt;p&gt;That distinction matters.&lt;/p&gt;

&lt;p&gt;This was not demonstrated as direct attacker profit.&lt;/p&gt;

&lt;p&gt;It was protocol-funded griefing: the attacker controlled when operational ETH was converted into verification fees.&lt;/p&gt;

&lt;h2&gt;
  
  
  The accounting was exact
&lt;/h2&gt;

&lt;p&gt;The test started with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Provider balance
0.050000000000000000 ETH
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The configured fee was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Verification fee
0.010000000000000000 ETH
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The attacker executed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Direct transactions
5

msg.value per transaction
0 ETH
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Afterward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Provider balance
0 ETH

Provider loss
0.05 ETH

Verifier gain
0.05 ETH
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The provider lost exactly the amount the verifier gained.&lt;/p&gt;

&lt;p&gt;That was important because it proved where the ETH went.&lt;/p&gt;

&lt;p&gt;This was not an estimate based on gas usage.&lt;/p&gt;

&lt;p&gt;It was not a balance change caused by the attacker sending value.&lt;/p&gt;

&lt;p&gt;It was the provider paying the verification fee five times.&lt;/p&gt;

&lt;h2&gt;
  
  
  The legitimate price path failed afterward
&lt;/h2&gt;

&lt;p&gt;The PoC established a before-and-after control.&lt;/p&gt;

&lt;p&gt;Before depletion:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Oracle.setPrices
PASS

Primary price
SET
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test then cleared the previously stored price state before starting the attacker drain.&lt;/p&gt;

&lt;p&gt;After depletion:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Oracle.setPrices
FAIL

Price count after failed update
0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second &lt;code&gt;setPrices&lt;/code&gt; attempt failed because the provider no longer had the funds required for &lt;code&gt;verifyUpdate&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is the actual liveness impact.&lt;/p&gt;

&lt;p&gt;For a token configured to use this provider, fresh Pyth Lazer prices could no longer be validated through the demonstrated path until the provider was funded again or the implementation was corrected.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PoC exercised the real vulnerable path
&lt;/h2&gt;

&lt;p&gt;The proof was not built around a replacement provider or copied vulnerable function.&lt;/p&gt;

&lt;p&gt;It used the real:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PythLazerFeedProvider

PythLazerFeedProvider.getOraclePrice

PythLazerFeedProvider.receive

Oracle.setPrices

Oracle._validatePrices

ChainlinkDataStreamProvider
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The local Pyth Lazer verifier was the only external boundary simulated.&lt;/p&gt;

&lt;p&gt;Its job in the test was narrow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Expose the verification fee

Accept the valid local update payload

Receive the fee

Return parsed payload data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The access-control failure and provider-funded payment remained inside the real &lt;code&gt;PythLazerFeedProvider&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The test suite completed with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3 passing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Chainlink provider exposed the inconsistency
&lt;/h2&gt;

&lt;p&gt;The strongest comparison was already in the same repository.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ChainlinkDataStreamProvider&lt;/code&gt; protected its equivalent provider entrypoint with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;modifier onlyOracle() {
    if (
        msg.sender != oracle
    ) {
        revert Errors.Unauthorized(
            msg.sender,
            "Oracle"
        );
    }

    _;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function getOraclePrice(
    address token,
    bytes memory data
)
    external
    onlyOracle
    returns (
        OracleUtils.ValidatedPrice memory
    )
{
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The PoC tested both providers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Direct attacker call to ChainlinkDataStreamProvider
REJECTED

Direct attacker call to PythLazerFeedProvider
ACCEPTED
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That comparison did more than suggest a possible fix.&lt;/p&gt;

&lt;p&gt;It showed that the codebase already had the correct trust boundary for another oracle provider.&lt;/p&gt;

&lt;p&gt;The Pyth Lazer implementation was the outlier.&lt;/p&gt;

&lt;h2&gt;
  
  
  The static-call control removed another false positive
&lt;/h2&gt;

&lt;p&gt;The PoC also tested repeated &lt;code&gt;callStatic&lt;/code&gt; invocations.&lt;/p&gt;

&lt;p&gt;Those calls validated successfully but did not change the provider balance.&lt;/p&gt;

&lt;p&gt;Then the attacker sent a real transaction using the same entrypoint.&lt;/p&gt;

&lt;p&gt;The provider lost one fee.&lt;/p&gt;

&lt;p&gt;The control result was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;callStatic
No balance change

Real transaction
Provider balance decreases
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That matters because an oracle function can appear exploitable during simulation while no persistent value movement actually occurs.&lt;/p&gt;

&lt;p&gt;Here, real transactions produced the drain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Valid update data was a precondition, not an authorization control
&lt;/h2&gt;

&lt;p&gt;The attacker needed valid update data for a configured feed.&lt;/p&gt;

&lt;p&gt;That is an important limitation and should not be hidden.&lt;/p&gt;

&lt;p&gt;But valid oracle data answers one question:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Is this update acceptable?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It does not answer another:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Who is allowed to make the protocol pay to verify it?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The attack did not depend on forged prices.&lt;/p&gt;

&lt;p&gt;The attacker could use legitimate update material and repeatedly externalize the verification cost to the protocol.&lt;/p&gt;

&lt;p&gt;Data validity and spending authorization were separate security boundaries.&lt;/p&gt;

&lt;p&gt;Only the first one was enforced.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I submitted it as Medium
&lt;/h2&gt;

&lt;p&gt;The proof demonstrated concrete economic and liveness impact:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Permissionless provider-funded verification

No protocol role required

Zero attacker msg.value

Deterministic depletion of operational ETH

Legitimate Oracle.setPrices failure after depletion

Configured token price updates unavailable through this provider until recovery
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It did not demonstrate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Direct theft of trader or LP funds

Oracle price forgery

Permanent corruption of oracle state

Irrecoverable protocol shutdown

Attacker capture of the drained ETH
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The provider could be funded again.&lt;/p&gt;

&lt;p&gt;That bounded the duration of the liveness failure.&lt;/p&gt;

&lt;p&gt;For that reason, I submitted the issue as Medium: the attack could force the protocol to spend operational funds and disable a critical price-validation path, but the proof did not establish direct user-fund theft or an irreversible outage.&lt;/p&gt;

&lt;p&gt;The HackenProof reward was &lt;strong&gt;$1.24&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The root cause in one table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Property&lt;/th&gt;
&lt;th&gt;Observed behavior&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Native fee balance&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;PythLazerFeedProvider&lt;/code&gt; accepts ETH&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unrestricted entrypoint&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;getOraclePrice&lt;/code&gt; has no caller guard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provider-funded verification&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;verifyUpdate&lt;/code&gt; spends the provider balance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Attacker privileges&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Attacker value&lt;/td&gt;
&lt;td&gt;Zero &lt;code&gt;msg.value&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Broken legitimate path&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Oracle.setPrices&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Any one of those design choices can be reasonable in isolation.&lt;/p&gt;

&lt;p&gt;The dangerous combination was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A protocol-funded external call exposed through a permissionless entrypoint.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The missing invariant was simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Only the protocol Oracle should be able to trigger provider-funded Pyth Lazer verification.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Recommended fix
&lt;/h2&gt;

&lt;p&gt;The cleanest fix is to mirror &lt;code&gt;ChainlinkDataStreamProvider&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;PythLazerFeedProvider&lt;/code&gt; should know the authorized Oracle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;address public immutable oracle;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and enforce:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;modifier onlyOracle() {
    if (
        msg.sender != oracle
    ) {
        revert Errors.Unauthorized(
            msg.sender,
            "Oracle"
        );
    }

    _;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The provider entrypoint then becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function getOraclePrice(
    address token,
    bytes memory data
)
    external
    onlyOracle
    returns (
        OracleUtils.ValidatedPrice memory
    )
{
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The constructor should receive the Oracle address:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;constructor(
    DataStore _dataStore,
    address _oracle,
    address pythLazerFeedVerifier
) {
    dataStore =
        _dataStore;

    oracle =
        _oracle;

    pythLazer =
        PythLazer(
            pythLazerFeedVerifier
        );
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The deployment configuration must pass the Oracle contract address, and existing deployments would need to migrate to the corrected provider and update the configured provider for affected tokens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the fix preserves the intended flow
&lt;/h2&gt;

&lt;p&gt;The legitimate call already originates through Oracle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Controller
    ↓
Oracle.setPrices
    ↓
Oracle._validatePrices
    ↓
PythLazerFeedProvider.getOraclePrice
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Oracle.setPrices
ALLOWED

Oracle.setPricesForAtomicAction
ALLOWED through Oracle

Direct external provider call
REJECTED

Provider funding
UNCHANGED

Authorized Pyth Lazer verification
UNCHANGED
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The patch does not remove provider-funded verification.&lt;/p&gt;

&lt;p&gt;It restricts who can trigger that expenditure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Regression coverage
&lt;/h2&gt;

&lt;p&gt;The regression suite should permanently establish:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Direct attacker call to PythLazerFeedProvider.getOraclePrice
REVERTS

Oracle.setPrices with valid Pyth Lazer data
SUCCEEDS when provider is funded

Repeated unauthorized calls
CANNOT reduce provider balance

Authorized Oracle calls
CAN spend the configured verification fee

Direct Chainlink provider calls
CONTINUE to revert
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is also worth applying the same review rule to any future provider that pays an external service from protocol-owned funds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The broader audit lesson
&lt;/h2&gt;

&lt;p&gt;A function named &lt;code&gt;getOraclePrice&lt;/code&gt; sounds like a read.&lt;/p&gt;

&lt;p&gt;This one was a spend.&lt;/p&gt;

&lt;p&gt;That distinction is easy to miss during a large protocol review.&lt;/p&gt;

&lt;p&gt;The moment a function performs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;externalCall{
    value: protocolFunds
}(...)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;the audit needs to treat caller authorization as an economic security boundary.&lt;/p&gt;

&lt;p&gt;The useful question is not only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Can the returned data be trusted?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is also:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Who controls how often this contract pays to obtain that data?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In this case, the answer was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Any external account with valid update data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That was enough to turn the oracle fee balance into a permissionless griefing surface.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;PythLazerFeedProvider&lt;/code&gt; held ETH to pay Pyth Lazer verification fees.&lt;/p&gt;

&lt;p&gt;Its &lt;code&gt;getOraclePrice&lt;/code&gt; function was externally callable and did not restrict the caller to the protocol Oracle.&lt;/p&gt;

&lt;p&gt;Every successful call executed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pythLazer.verifyUpdate{
    value: fee
}(
    data
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;using ETH from the provider itself.&lt;/p&gt;

&lt;p&gt;The attacker sent zero ETH.&lt;/p&gt;

&lt;p&gt;Five real transactions consumed the complete &lt;code&gt;0.05 ETH&lt;/code&gt; test balance.&lt;/p&gt;

&lt;p&gt;The verifier received the same &lt;code&gt;0.05 ETH&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Before depletion, the legitimate &lt;code&gt;Oracle.setPrices&lt;/code&gt; flow succeeded.&lt;/p&gt;

&lt;p&gt;After depletion, that same path failed because the provider could no longer fund verification.&lt;/p&gt;

&lt;p&gt;The finding was submitted as Medium through HackenProof and earned &lt;strong&gt;$1.24&lt;/strong&gt;.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhvn7pwwfp7xiv76064k2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhvn7pwwfp7xiv76064k2.png" alt=" " width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The engineering lesson is broader than this one provider:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If the protocol funds oracle verification, permission to request that verification is permission to spend protocol funds.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That permission should belong to the protocol’s Oracle path, not to every external account.&lt;/p&gt;

</description>
      <category>smartcontract</category>
      <category>blockchain</category>
      <category>security</category>
      <category>web3</category>
    </item>
    <item>
      <title>How Integer-Truncated Vote Weights Could Lock WEMIX Governance for Seven Days</title>
      <dc:creator>Daniel</dc:creator>
      <pubDate>Fri, 07 Aug 2026 02:27:42 +0000</pubDate>
      <link>https://dev.to/f00dat/how-integer-truncated-vote-weights-could-lock-wemix-governance-for-seven-days-2efp</link>
      <guid>https://dev.to/f00dat/how-integer-truncated-vote-weights-could-lock-wemix-governance-for-seven-days-2efp</guid>
      <description>&lt;p&gt;A governance ballot should not remain open after every eligible member has already voted.&lt;/p&gt;

&lt;p&gt;WEMIX could reach exactly that state.&lt;/p&gt;

&lt;p&gt;Each member received equal voting power through integer division:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;uint256 weight =
    10000
        /
    getMemberLength();
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With six members:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;10000 / 6
1666 per voter
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The division discarded the remainder.&lt;/p&gt;

&lt;p&gt;Even after all six members voted, the maximum recorded power was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1666 × 6
9996
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The finalization logic required one side to reach the &lt;code&gt;5001&lt;/code&gt; threshold or the combined power to equal exactly &lt;code&gt;10000&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if (
    accept &amp;gt;= threshold
        ||
    reject &amp;gt;= threshold
        ||
    (
        accept
            +
        reject
    )
        ==
    10000
) {
    finalizeVote(
        ballotIdx,
        ballotType,
        accept &amp;gt; reject,
        false
    );
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A three-to-three split therefore produced:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Accept power
4998

Reject power
4998

Total power
9996
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Neither side reached &lt;code&gt;5001&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The total could never reach &lt;code&gt;10000&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Every eligible member had voted.&lt;/p&gt;

&lt;p&gt;Duplicate votes were correctly rejected.&lt;/p&gt;

&lt;p&gt;The ballot still remained &lt;code&gt;InProgress&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Because WEMIX used &lt;code&gt;ballotInVoting&lt;/code&gt; as a singleton active-voting slot, that fully participated ballot prevented another valid governance proposal from being voted until the maximum duration expired.&lt;/p&gt;

&lt;p&gt;The proof of concept used the configured maximum duration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;604800 seconds
7 days
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It then created a valid operational emergency proposal and demonstrated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Emergency vote before timeout
Reverted

Emergency vote at endTime minus one
Reverted

Timeout cleanup
Succeeded

Emergency vote after cleanup
Succeeded
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I submitted this report as Major through the WEMIX bug bounty program hosted on CertiK Skynet.&lt;/p&gt;

&lt;p&gt;The program classified it as Low and paid a $100 bounty.&lt;/p&gt;

&lt;p&gt;That classification does not match the demonstrated impact.&lt;/p&gt;

&lt;p&gt;This was not a cosmetic four-unit rounding discrepancy.&lt;/p&gt;

&lt;p&gt;Those four unreachable units made fully recorded participation indistinguishable from incomplete voting power and allowed one ballot to block the governance voting pipeline for seven days.&lt;/p&gt;

&lt;p&gt;The complete public report and proof of concept are available in the &lt;a href="https://gist.github.com/f00dat/e159e3b949537e59839e667ebbe7f82e" rel="noopener noreferrer"&gt;GitHub Gist&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The invariant that failed
&lt;/h2&gt;

&lt;p&gt;Equal-weight governance does not require &lt;code&gt;10000&lt;/code&gt; to divide perfectly by every possible member count.&lt;/p&gt;

&lt;p&gt;It does require the protocol to recognize when every eligible voter has participated.&lt;/p&gt;

&lt;p&gt;The invariant should be:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Once every eligible member has voted, arithmetic rounding must not keep the ballot active.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;WEMIX inferred full participation from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;accept power + reject power == 10000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That proxy works only when:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;10000 % memberCount == 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For six members:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;10000 % 6
4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those four units were never assigned to any voter.&lt;/p&gt;

&lt;p&gt;The contract interpreted the missing power as incomplete participation even though the authoritative voter count already showed that all six members had voted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the voting power was truncated
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;createVote&lt;/code&gt; calculated the weight at the moment each vote was created:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function createVote(
    uint256 ballotIdx,
    bool approval
)
    private
{
    uint256 voteIdx =
        voteLength
            +
        1;

    address staker =
        getStakerAddr(
            msg.sender
        );

    uint256 weight =
        10000
            /
        getMemberLength();

    uint256 decision =
        approval
            ?
        uint256(
            DecisionTypes.Accept
        )
            :
        uint256(
            DecisionTypes.Reject
        );

    IBallotStorage(
        getBallotStorageAddress()
    ).createVote(
        voteIdx,
        ballotIdx,
        staker,
        decision,
        weight
    );

    voteLength =
        voteIdx;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With six members:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Exact mathematical weight
1666.666...

Stored weight
1666
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The remainder was not distributed, stored, or reconciled later.&lt;/p&gt;

&lt;p&gt;That alone would have been a minor precision issue if ballot finalization used the actual participation count.&lt;/p&gt;

&lt;p&gt;It did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finalization depended on an unreachable equality
&lt;/h2&gt;

&lt;p&gt;After recording a vote, &lt;code&gt;GovImp.vote&lt;/code&gt; read the accumulated power:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(
    ,
    uint256 accept,
    uint256 reject
) =
    getBallotVotingInfo(
        ballotIdx
    );

uint256 threshold =
    getThreshold();

if (
    accept &amp;gt;= threshold
        ||
    reject &amp;gt;= threshold
        ||
    (
        accept
            +
        reject
    )
        ==
    10000
) {
    finalizeVote(
        ballotIdx,
        ballotType,
        accept &amp;gt; reject,
        false
    );
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The three finalization conditions were:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Accept reaches 5001

Reject reaches 5001

Combined power reaches exactly 10000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a balanced six-member vote:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3 × 1666
4998 accept

3 × 1666
4998 reject
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every condition remained false.&lt;/p&gt;

&lt;p&gt;The ballot was fully participated but numerically unable to prove it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The contract already knew that everybody had voted
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;BallotStorageImp&lt;/code&gt; tracked the real number of voters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;_ballot.totalVoters =
    _ballot.totalVoters
        +
    1;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also prevented duplicate votes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;require(
    !hasVotedMap[
        _ballotId
    ][
        _voter
    ],
    "already voted"
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the sixth vote:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;totalVoters
6

eligible members
6

all members voted
Yes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But the finalization path ignored &lt;code&gt;totalVoters&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That created an impossible lifecycle state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Every eligible member has voted
True

Any member can vote again
False

Either side reached threshold
False

Combined power equals 10000
False

Ballot finalized
False
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The missing four units could not be supplied by any legitimate action.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why another vote could not fix the ballot
&lt;/h2&gt;

&lt;p&gt;Once all six members had voted, the duplicate-vote protection correctly rejected every further attempt.&lt;/p&gt;

&lt;p&gt;Before timeout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Additional eligible voters
0

Duplicate voting allowed
No

Power still missing
4

Normal finalization available
No
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ballot was not waiting for participation.&lt;/p&gt;

&lt;p&gt;It was waiting for voting power that the formula had made unreachable.&lt;/p&gt;

&lt;h2&gt;
  
  
  BallotInVoting amplified one rounding bug
&lt;/h2&gt;

&lt;p&gt;WEMIX allowed only one ballot to occupy the active voting slot.&lt;/p&gt;

&lt;p&gt;When a ready ballot began, &lt;code&gt;checkVotable&lt;/code&gt; required:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;require(
    ballotInVoting == 0,
    "Now in voting with different ballot"
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It then assigned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ballotInVoting =
    ballotIdx;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a ballot was already &lt;code&gt;InProgress&lt;/code&gt;, voting was restricted to that same ballot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;require(
    ballotIdx
        ==
    ballotInVoting,
    "Now in voting with different ballot"
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The affected ballot therefore did more than remain unresolved.&lt;/p&gt;

&lt;p&gt;It occupied the shared voting slot and blocked every different proposal from entering the voting flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PoC blocked a valid emergency proposal
&lt;/h2&gt;

&lt;p&gt;After creating the stuck ballot, the proof created a separate valid operational environment proposal.&lt;/p&gt;

&lt;p&gt;Proposal creation succeeded.&lt;/p&gt;

&lt;p&gt;Voting on it reverted because &lt;code&gt;ballotInVoting&lt;/code&gt; still referenced the fully voted split ballot.&lt;/p&gt;

&lt;p&gt;The test then advanced time to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;endTime - 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The emergency vote still reverted.&lt;/p&gt;

&lt;p&gt;Only after moving beyond the end time could a member call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;finalizeEndedVote();
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The timeout cleanup rejected the expired ballot and reset:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ballotInVoting =
    0;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same emergency proposal then became votable and finalized normally.&lt;/p&gt;

&lt;p&gt;The demonstrated sequence was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Fully participated split ballot
Remained InProgress

Emergency proposal
Created

Emergency vote before timeout
Blocked

Emergency vote one second before expiry
Blocked

Timeout cleanup
Succeeded

Emergency vote after cleanup
Succeeded
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This moved the impact beyond incorrect accounting and into governance liveness.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lock used the real maximum duration
&lt;/h2&gt;

&lt;p&gt;The PoC read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;getBallotDurationMax()
604800
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That equals seven days.&lt;/p&gt;

&lt;p&gt;The affected ballot was created with this valid configured duration.&lt;/p&gt;

&lt;p&gt;The test did not invent a delay outside protocol rules.&lt;/p&gt;

&lt;p&gt;It proved that the contract could keep the active voting slot occupied for the maximum duration allowed by governance.&lt;/p&gt;

&lt;p&gt;For an operational emergency proposal, seven days is a material delay.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ballot and voters were valid
&lt;/h2&gt;

&lt;p&gt;The proof removed several alternative explanations.&lt;/p&gt;

&lt;p&gt;Every participant was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A real governance member

A real staker

Assigned a nonzero staker index

Within the required staking range
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The member count was read from the real contract and remained six during the affected ballot.&lt;/p&gt;

&lt;p&gt;The proposal was a valid &lt;code&gt;addProposalToChangeEnv&lt;/code&gt; ballot.&lt;/p&gt;

&lt;p&gt;There was no voter-only target, invalid role configuration, NCP exit path, delegated reserve path, or understaked member.&lt;/p&gt;

&lt;p&gt;The stuck state came directly from the voting-power formula and the finalization conditions.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the proof used
&lt;/h2&gt;

&lt;p&gt;The Go test exercised the real:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GovImp

GovImp.vote

GovImp.checkVotable

GovImp.finalizeEndedVote

BallotStorageImp

BallotStorageImp.createVote

BallotStorageImp.hasVotedMap

StakingImp

Current member count

Configured maximum ballot duration
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The vulnerable arithmetic and lifecycle logic were not copied into a simplified contract.&lt;/p&gt;

&lt;p&gt;The command was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go &lt;span class="nb"&gt;test&lt;/span&gt; ./wemix/governance-contract/test &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-run&lt;/span&gt; TestVoteRounding &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test passed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exact runtime result
&lt;/h2&gt;

&lt;p&gt;The proof recorded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Current member count
6

Vote weight
1666

Recorded full-participation power
9996

Lost remainder
4

Accept voters
3

Reject voters
3

Accept power
4998

Reject power
4998

Threshold
5001

All voters participated
Yes

Ballot state
InProgress

Maximum duration
604800 seconds
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The liveness assertions recorded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Further votes can clear the ballot
No

Emergency vote before timeout
Reverted

Emergency vote at endTime minus one
Reverted

Timeout cleanup
Succeeded

Emergency vote after cleanup
Succeeded
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The clean controls also showed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Emergency proposal without a stuck ballot
Votable

Normal majority ballot
Finalized
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These controls isolated integer truncation as the cause of the lock.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is a separate finding
&lt;/h2&gt;

&lt;p&gt;This report is distinct from the other WEMIX governance findings.&lt;/p&gt;

&lt;h3&gt;
  
  
  It does not use a voter-only target
&lt;/h3&gt;

&lt;p&gt;Every voter was a real staker with a nonzero staker index.&lt;/p&gt;

&lt;h3&gt;
  
  
  It does not involve NCPExit
&lt;/h3&gt;

&lt;p&gt;No exit reserve, delegated balance, or exit mapping was involved.&lt;/p&gt;

&lt;h3&gt;
  
  
  It does not remove an NCP
&lt;/h3&gt;

&lt;p&gt;The test never reached &lt;code&gt;transferLockedAndUnlock&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  It does not depend on minStaking underflow
&lt;/h3&gt;

&lt;p&gt;Every voter remained within the required staking range.&lt;/p&gt;

&lt;h3&gt;
  
  
  It is entirely on chain
&lt;/h3&gt;

&lt;p&gt;The root cause exists in smart contract vote arithmetic and ballot finalization, not in the client or P2P layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the proof establishes
&lt;/h2&gt;

&lt;p&gt;The proof directly demonstrates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Six valid members

1666 power per vote

Four voting-power units lost

All six members voted

Three accepted and three rejected

4998 power on each side

9996 total power

No threshold reached

Ballot remained InProgress

No voter could vote again

Another valid proposal could not be voted

The block persisted at endTime minus one

Timeout cleanup restored governance

The emergency proposal succeeded afterward
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The proof does not demonstrate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Direct theft

Permanent governance takeover

Permanent fund freeze

Protocol insolvency

Unauthorized minting

Permanent network shutdown

A lock that survives the configured timeout
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those limits are why the report was submitted as Major rather than Critical.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is Major, not Low
&lt;/h2&gt;

&lt;p&gt;The issue caused a temporary denial of service against the governance voting pipeline.&lt;/p&gt;

&lt;h3&gt;
  
  
  The ballot had complete participation
&lt;/h3&gt;

&lt;p&gt;This was not a proposal waiting for absent members.&lt;/p&gt;

&lt;p&gt;Every eligible voter had acted.&lt;/p&gt;

&lt;p&gt;The protocol still failed to finalize it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The only active voting slot remained occupied
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ballotInVoting&lt;/code&gt; prevented any different proposal from being voted.&lt;/p&gt;

&lt;p&gt;The impact therefore extended beyond the original split ballot.&lt;/p&gt;

&lt;h3&gt;
  
  
  A valid emergency proposal was demonstrably blocked
&lt;/h3&gt;

&lt;p&gt;The PoC created another valid proposal and showed that its vote reverted both before timeout and at &lt;code&gt;endTime - 1&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The lock lasted seven days
&lt;/h3&gt;

&lt;p&gt;The duration came from the real &lt;code&gt;getBallotDurationMax()&lt;/code&gt; configuration.&lt;/p&gt;

&lt;p&gt;Recovery was available only after timeout cleanup.&lt;/p&gt;

&lt;p&gt;That makes the issue temporary, which rules out Critical, but it does not make the impact Low.&lt;/p&gt;

&lt;h3&gt;
  
  
  No invalid privilege was required
&lt;/h3&gt;

&lt;p&gt;The state could arise from six legitimate members casting ordinary votes.&lt;/p&gt;

&lt;p&gt;No outsider, invalid role, administrator key, or contract owner permission was needed to create the vulnerable ballot state.&lt;/p&gt;

&lt;h3&gt;
  
  
  Low does not fit the demonstrated effect
&lt;/h3&gt;

&lt;p&gt;Low would fit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A displayed percentage discrepancy

A harmless four-unit rounding loss

A ballot that still finalized from voter count

A state that did not affect other proposals

A delay immediately recoverable through another vote
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This report showed a fully participated ballot preventing emergency governance voting for the maximum configured duration.&lt;/p&gt;

&lt;p&gt;That is Major.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the $100 outcome understates the evidence
&lt;/h2&gt;

&lt;p&gt;The final Low classification produced a $100 bounty.&lt;/p&gt;

&lt;p&gt;That records the program decision.&lt;/p&gt;

&lt;p&gt;It does not change the observed result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Eligible voters
6

Votes cast
6

Recorded power
9996

Ballot finalized
No

Emergency vote before timeout
Reverted

Emergency vote one second before expiry
Reverted

Governance restored only after timeout
Yes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The vulnerability was not the numerical difference between &lt;code&gt;9996&lt;/code&gt; and &lt;code&gt;10000&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The vulnerability was the lifecycle consequence:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Full participation was treated as incomplete participation, allowing one ballot to block every other governance vote for seven days.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is a Major governance liveness failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I describe the WEMIX handling as systematic downgrading
&lt;/h2&gt;

&lt;p&gt;Severity disagreements are normal in bug bounty programs.&lt;/p&gt;

&lt;p&gt;My concern is the recurring outcome across my own valid WEMIX submissions.&lt;/p&gt;

&lt;p&gt;Reports supported by reproducible proofs and concrete protocol behavior were repeatedly assigned substantially lower final severities.&lt;/p&gt;

&lt;p&gt;This report was submitted as Major.&lt;/p&gt;

&lt;p&gt;The PoC showed every eligible member voting, the ballot remaining &lt;code&gt;InProgress&lt;/code&gt;, duplicate votes being impossible, a valid emergency proposal being blocked at &lt;code&gt;endTime - 1&lt;/code&gt;, and governance recovering only after timeout cleanup.&lt;/p&gt;

&lt;p&gt;It was classified as Low.&lt;/p&gt;

&lt;p&gt;I cannot infer intent from that decision.&lt;/p&gt;

&lt;p&gt;I can document the repeated outcome and compare the final classification with the technical evidence.&lt;/p&gt;

&lt;p&gt;Calling it systematic downgrading describes that recurring pattern across my WEMIX submissions without claiming a motive.&lt;/p&gt;

&lt;p&gt;A technically convincing Low justification would need to explain why these effects are minor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Every eligible member already voted

The ballot still remained active

No additional vote could change the result

BallotInVoting blocked another valid proposal

The emergency vote remained blocked for seven days

Only timeout cleanup restored voting
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without addressing those facts, the Low classification does not match the proof.&lt;/p&gt;

&lt;h2&gt;
  
  
  The minimal correction
&lt;/h2&gt;

&lt;p&gt;The contract already tracked &lt;code&gt;totalVoters&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The minimal correction is to use the actual participation count instead of an exact rounded-power equality.&lt;/p&gt;

&lt;p&gt;Inside &lt;code&gt;vote&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(
    uint256 totalVoters,
    uint256 accept,
    uint256 reject
) =
    getBallotVotingInfo(
        ballotIdx
    );

uint256 threshold =
    getThreshold();

if (
    accept &amp;gt;= threshold
        ||
    reject &amp;gt;= threshold
        ||
    totalVoters
        &amp;gt;=
    getMemberLength()
) {
    finalizeVote(
        ballotIdx,
        ballotType,
        accept &amp;gt; reject,
        false
    );
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The corresponding validation in &lt;code&gt;fromValidBallot&lt;/code&gt; should use the same rule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(
    uint256 totalVoters,
    uint256 accept,
    uint256 reject
) =
    getBallotVotingInfo(
        ballotIdx
    );

require(
    accept &amp;gt;= getThreshold()
        ||
    reject &amp;gt;= getThreshold()
        ||
    totalVoters
        &amp;gt;=
    getMemberLength(),
    "Not yet finalized"
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This makes full participation depend on actual voters rather than a power total that may be mathematically unreachable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hardening the fix
&lt;/h2&gt;

&lt;p&gt;Using the current member count is the smallest compatible correction for the demonstrated flow.&lt;/p&gt;

&lt;p&gt;A more robust governance design should snapshot the number of eligible voters when the ballot starts and compare &lt;code&gt;totalVoters&lt;/code&gt; against that snapshot.&lt;/p&gt;

&lt;p&gt;That prevents later membership changes from redefining what full participation means for an already active ballot.&lt;/p&gt;

&lt;p&gt;The invariant becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;totalVoters &amp;gt;= eligibleVotersAtBallotStart
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why the correction preserves existing behavior
&lt;/h2&gt;

&lt;p&gt;The patch keeps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Accept threshold finalization

Reject threshold finalization

Duplicate-vote prevention

Existing majority behavior

Existing tie outcome
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A full-participation tie still reaches:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;accept &amp;gt; reject
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and finalizes as rejected under the existing decision rule.&lt;/p&gt;

&lt;p&gt;The only behavior removed is the invalid state where every eligible voter has participated but integer truncation keeps the ballot open.&lt;/p&gt;

&lt;h2&gt;
  
  
  Regression tests
&lt;/h2&gt;

&lt;p&gt;A complete correction should verify:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Six members with a three-to-three split finalize after the sixth vote&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Full participation finalizes for every supported member count even when &lt;code&gt;10000&lt;/code&gt; is not evenly divisible&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Majority acceptance remains unchanged&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Majority rejection remains unchanged&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Duplicate votes remain rejected&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Finalization releases &lt;code&gt;ballotInVoting&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Another valid proposal becomes votable immediately&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;fromValidBallot&lt;/code&gt; uses the same full-participation rule&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Membership changes cannot redefine the eligible voter count for an active ballot&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The central invariant is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Once every voter eligible for a ballot has voted, integer rounding must not keep that ballot active.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Broader lessons
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Percentages should not replace participation counts
&lt;/h3&gt;

&lt;p&gt;A rounded power total is not authoritative evidence that everybody has voted.&lt;/p&gt;

&lt;p&gt;The contract already stored the real count.&lt;/p&gt;

&lt;h3&gt;
  
  
  Exact equality turns small remainders into liveness failures
&lt;/h3&gt;

&lt;p&gt;Losing four units appears minor.&lt;/p&gt;

&lt;p&gt;Requiring exactly &lt;code&gt;10000&lt;/code&gt; converted that small arithmetic loss into a seven-day governance lock.&lt;/p&gt;

&lt;h3&gt;
  
  
  Singleton state amplifies local bugs
&lt;/h3&gt;

&lt;p&gt;One ballot failed to finalize.&lt;/p&gt;

&lt;p&gt;Because &lt;code&gt;ballotInVoting&lt;/code&gt; was global, every other proposal was affected.&lt;/p&gt;

&lt;h3&gt;
  
  
  Emergency governance must be tested behind existing state
&lt;/h3&gt;

&lt;p&gt;Creating an emergency proposal is not enough.&lt;/p&gt;

&lt;p&gt;The protocol must also prove that it can enter voting while previous ballots exist.&lt;/p&gt;

&lt;h3&gt;
  
  
  Full participation is a protocol event
&lt;/h3&gt;

&lt;p&gt;It should be detected from eligible voters and recorded participation, not inferred from a percentage sum that may truncate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;WEMIX governance had six valid members.&lt;/p&gt;

&lt;p&gt;Each vote received:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1666
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All six votes produced:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;9996
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A three-to-three split recorded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Accept
4998

Reject
4998
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No side reached &lt;code&gt;5001&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The total never reached &lt;code&gt;10000&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Every eligible member had voted.&lt;/p&gt;

&lt;p&gt;Nobody could vote again.&lt;/p&gt;

&lt;p&gt;The ballot remained &lt;code&gt;InProgress&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Because it occupied &lt;code&gt;ballotInVoting&lt;/code&gt;, a valid emergency proposal could not be voted.&lt;/p&gt;

&lt;p&gt;The lock remained at &lt;code&gt;endTime - 1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Only after the maximum duration of &lt;code&gt;604800&lt;/code&gt; seconds could timeout cleanup restore governance voting.&lt;/p&gt;

&lt;p&gt;The program classified the report as Low and paid $100.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhmd63ts9in53zj363lop.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhmd63ts9in53zj363lop.png" alt=" " width="681" height="18"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr2f2ur0oikmkwpck0ki8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr2f2ur0oikmkwpck0ki8.png" alt=" " width="701" height="29"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The demonstrated impact was Major.&lt;/p&gt;

&lt;p&gt;The report did not claim theft, permanent takeover, or permanent shutdown.&lt;/p&gt;

&lt;p&gt;It demonstrated a narrower and directly proven failure:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Integer-truncated vote weights could keep a fully participated ballot active and block all other governance voting for seven days.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A governance ballot that has received every possible vote but still blocks emergency action until maximum timeout is not a Low-severity issue.&lt;/p&gt;

</description>
      <category>smartcontract</category>
      <category>blockchain</category>
      <category>security</category>
      <category>web3</category>
    </item>
    <item>
      <title>How a MinStaking Increase Could Make an Understaked WEMIX NCP Unremovable</title>
      <dc:creator>Daniel</dc:creator>
      <pubDate>Fri, 07 Aug 2026 01:50:39 +0000</pubDate>
      <link>https://dev.to/f00dat/how-a-minstaking-increase-could-make-an-understaked-wemix-ncp-unremovable-4je5</link>
      <guid>https://dev.to/f00dat/how-a-minstaking-increase-could-make-an-understaked-wemix-ncp-unremovable-4je5</guid>
      <description>&lt;p&gt;A governance system should always be able to remove a member that no longer satisfies its active staking requirements.&lt;/p&gt;

&lt;p&gt;WEMIX had a state where the opposite happened.&lt;/p&gt;

&lt;p&gt;An NCP could join governance while the current &lt;code&gt;minStaking&lt;/code&gt; requirement was satisfied.&lt;/p&gt;

&lt;p&gt;Governance could later raise that minimum above the NCP’s locked balance.&lt;/p&gt;

&lt;p&gt;The NCP would remain in the member set, but &lt;code&gt;checkLockedAmount&lt;/code&gt; would prevent it from voting or creating proposals.&lt;/p&gt;

&lt;p&gt;That was already an inconsistent state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Still a governance member
Yes

Still satisfies the current staking minimum
No

Can vote
No

Can create proposals
No
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The deeper failure appeared when valid members tried to remove it.&lt;/p&gt;

&lt;p&gt;The finalization path reached:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;uint256 ext =
    locked
        -
    getMinStaking();
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;locked &amp;lt; current minStaking
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Solidity 0.8 checked arithmetic reverted before either removal branch could run.&lt;/p&gt;

&lt;p&gt;The entire transaction rolled back.&lt;/p&gt;

&lt;p&gt;The understaked NCP remained in the governance member set.&lt;/p&gt;

&lt;p&gt;The proof of concept demonstrated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Initial minStaking
1,500,000 WEMIX

Target locked balance
1,500,000 WEMIX

Current minStaking after increase
2,000,000 WEMIX

Target remains a member
True

Target vote
Reverted

Target proposal
Reverted

Removal proposal from a valid member
Succeeded

Removal parameters
Valid

Removal finalization
Reverted

Target remains a member after failed removal
True
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I submitted this report as Major through the WEMIX bug bounty program hosted on CertiK Skynet.&lt;/p&gt;

&lt;p&gt;The program classified it as Low and paid a $100 bounty.&lt;/p&gt;

&lt;p&gt;That classification does not match the demonstrated impact.&lt;/p&gt;

&lt;p&gt;This was not a harmless arithmetic edge case.&lt;/p&gt;

&lt;p&gt;The underflow disabled the standard cleanup path for exactly the member state governance needed to clean up.&lt;/p&gt;

&lt;p&gt;The result was a governance lifecycle denial of service: an NCP that no longer met the current minimum could neither participate normally nor be removed through the ordinary finalization flow.&lt;/p&gt;

&lt;p&gt;The complete public report and proof of concept are available in the &lt;a href="https://gist.github.com/f00dat/c639b85236f38a6916f8367d40aba766" rel="noopener noreferrer"&gt;GitHub Gist&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The invariant governance must preserve
&lt;/h2&gt;

&lt;p&gt;A configurable staking minimum can change over time.&lt;/p&gt;

&lt;p&gt;That means governance must safely handle members who were valid under an earlier value but fall below a later one.&lt;/p&gt;

&lt;p&gt;The required invariant is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;An NCP that becomes understaked after a valid minStaking change must remain removable by valid governance members.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;WEMIX violated that invariant.&lt;/p&gt;

&lt;p&gt;The same parameter increase that disabled the target’s normal participation also created the arithmetic condition that blocked its removal.&lt;/p&gt;

&lt;p&gt;Governance could therefore create a member state that its own cleanup logic could not resolve.&lt;/p&gt;

&lt;h2&gt;
  
  
  The target entered governance legitimately
&lt;/h2&gt;

&lt;p&gt;The PoC did not begin with an invalid or manually corrupted member.&lt;/p&gt;

&lt;p&gt;The initial minimum was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1,500,000 WEMIX
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The target locked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1,500,000 WEMIX
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At admission time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;locked balance &amp;gt;= current minStaking
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The target satisfied the active requirement and entered through the real membership flow.&lt;/p&gt;

&lt;p&gt;No admission check was bypassed.&lt;/p&gt;

&lt;p&gt;No storage was modified directly.&lt;/p&gt;

&lt;p&gt;The vulnerable state appeared only after a later governance parameter change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Governance raised minStaking above the target balance
&lt;/h2&gt;

&lt;p&gt;The proof used the real environment proposal flow to raise &lt;code&gt;minStaking&lt;/code&gt; to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2,000,000 WEMIX
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The target remained locked at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1,500,000 WEMIX
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The resulting state was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Target locked balance
1,500,000 WEMIX

Current minStaking
2,000,000 WEMIX

Shortfall
500,000 WEMIX
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The target remained present in the actual member set.&lt;/p&gt;

&lt;p&gt;The parameter change did not automatically remove it, migrate it, or place it into a separate cleanup state.&lt;/p&gt;

&lt;h2&gt;
  
  
  The target lost normal governance participation
&lt;/h2&gt;

&lt;p&gt;The ordinary governance gate was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;modifier checkLockedAmount() {
    address staker =
        getStakerAddr(
            _msgSender()
        );

    require(
        lockedBalanceOf(
            staker
        )
            &amp;lt;=
        getMaxStaking()
            &amp;amp;&amp;amp;
        lockedBalanceOf(
            staker
        )
            &amp;gt;=
        getMinStaking(),
        "Invalid staking balance"
    );

    _;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the increase, the target failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;lockedBalanceOf(target) &amp;gt;= getMinStaking()
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The PoC confirmed that the target’s vote reverted.&lt;/p&gt;

&lt;p&gt;It also confirmed that the target could not create a normal proposal protected by the same modifier.&lt;/p&gt;

&lt;p&gt;The target therefore remained a governance member in storage while losing the ordinary powers associated with membership.&lt;/p&gt;

&lt;h2&gt;
  
  
  A valid member could still create the removal proposal
&lt;/h2&gt;

&lt;p&gt;Another member remained above the new minimum.&lt;/p&gt;

&lt;p&gt;That valid member created a proposal to remove the understaked target.&lt;/p&gt;

&lt;p&gt;The relevant proposal checks included:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;require(
    isMember(
        staker
    ),
    "Non-member"
);

require(
    lockedBalanceOf(
        staker
    )
        &amp;gt;=
    lockAmount,
    "Insufficient balance that can be unlocked."
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The PoC used:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;unlockAmount
1,500,000 WEMIX

slashing
0 WEMIX
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The removal helper later required:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;require(
    unlockAmount
        +
    slashing
        &amp;lt;=
    getMinStaking(),
    "minStaking value must be greater than or equal to the sum of unlockAmount, slashing"
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The values were valid:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1,500,000 + 0 &amp;lt;= 2,000,000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The proposal was accepted.&lt;/p&gt;

&lt;p&gt;The voting member was valid.&lt;/p&gt;

&lt;p&gt;The target was still a member.&lt;/p&gt;

&lt;p&gt;The failure occurred only when the approved cleanup reached finalization.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finalization reached the vulnerable helper
&lt;/h2&gt;

&lt;p&gt;The real removal flow called:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;transferLockedAndUnlock(
    ballotIdx,
    oldStaker
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The affected helper was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function transferLockedAndUnlock(
    uint256 ballotIdx,
    address addr
)
    private
{
    (
        uint256 unlockAmount,
        uint256 slashing
    ) =
        getBallotForExit(
            ballotIdx
        );

    require(
        unlockAmount
            +
        slashing
            &amp;lt;=
        getMinStaking(),
        "minStaking value must be greater than or equal to the sum of unlockAmount, slashing"
    );

    IStaking staking =
        IStaking(
            getStakingAddress()
        );

    uint256 locked =
        staking.lockedBalanceOf(
            addr
        );

    uint256 ext =
        locked
            -
        getMinStaking();

    if (
        locked
            &amp;gt;
        unlockAmount
    ) {
        unlock(
            addr,
            unlockAmount
        );

        staking.transferLocked(
            addr,
            slashing,
            ext
        );
    } else {
        unlock(
            addr,
            locked
        );
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The implementation assumed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;locked &amp;gt;= current minStaking
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That assumption was valid for an active member before the parameter increase.&lt;/p&gt;

&lt;p&gt;It was not valid for the member state created by the increase itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The underflow happened before branch selection
&lt;/h2&gt;

&lt;p&gt;The concrete values were:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;locked
1,500,000 WEMIX

current minStaking
2,000,000 WEMIX
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The helper attempted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1,500,000
-
2,000,000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The subtraction reverted.&lt;/p&gt;

&lt;p&gt;The function never reached:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if (
    locked
        &amp;gt;
    unlockAmount
)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This distinction is important.&lt;/p&gt;

&lt;p&gt;The bug was not that the wrong branch executed.&lt;/p&gt;

&lt;p&gt;No cleanup branch executed at all.&lt;/p&gt;

&lt;p&gt;The arithmetic failed before the function could decide how to unlock or transfer the target’s stake.&lt;/p&gt;

&lt;h2&gt;
  
  
  Atomic rollback restored every removal change
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;removeMember&lt;/code&gt; modified membership structures before calling &lt;code&gt;transferLockedAndUnlock&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It removed entries from the staker, voter, reward, and node structures, reduced the member count, and then reached the vulnerable helper.&lt;/p&gt;

&lt;p&gt;But EVM transactions are atomic.&lt;/p&gt;

&lt;p&gt;When the helper reverted, every earlier state update in that transaction reverted too.&lt;/p&gt;

&lt;p&gt;The final state was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Target was a member before finalization
True

Removal finalization
Reverted

Target is a member afterward
True

Target locked balance afterward
1,500,000 WEMIX
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The source code may appear to remove the member first.&lt;/p&gt;

&lt;p&gt;The committed state did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The target was trapped in a contradictory lifecycle state
&lt;/h2&gt;

&lt;p&gt;After the minimum increase and failed cleanup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Target remains a member
True

Target satisfies current minStaking
False

Target can vote
False

Target can create proposals
False

Valid members can propose removal
True

Approved removal can finalize
False
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The member was too understaked to participate.&lt;/p&gt;

&lt;p&gt;The same understaked condition caused the removal calculation to revert.&lt;/p&gt;

&lt;p&gt;This is the core lifecycle failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The controls isolate the exact cause
&lt;/h2&gt;

&lt;p&gt;The proof included three positive controls.&lt;/p&gt;

&lt;h3&gt;
  
  
  Top up control
&lt;/h3&gt;

&lt;p&gt;The target added enough stake to satisfy the new minimum.&lt;/p&gt;

&lt;p&gt;Afterward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;locked &amp;gt;= current minStaking
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The subtraction no longer underflowed.&lt;/p&gt;

&lt;p&gt;Removal succeeded.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lower minimum control
&lt;/h3&gt;

&lt;p&gt;Governance lowered &lt;code&gt;minStaking&lt;/code&gt; back to the target’s locked amount.&lt;/p&gt;

&lt;p&gt;Again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;locked &amp;gt;= current minStaking
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Removal succeeded.&lt;/p&gt;

&lt;h3&gt;
  
  
  Healthy removal control
&lt;/h3&gt;

&lt;p&gt;A target that never became understaked was removed successfully through the ordinary path.&lt;/p&gt;

&lt;p&gt;Together, the controls demonstrate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Proposal creation works

Voting works

Removal works for healthy members

Removal works after a top up

Removal works after lowering the minimum

Removal fails specifically while locked &amp;lt; current minStaking
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That isolates the unchecked subtraction as the root cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PoC used the real governance path
&lt;/h2&gt;

&lt;p&gt;The Go proof used:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Real GovImp

Real StakingImp

Real EnvStorageImp

Real BallotStorageImp

Real addProposalToChangeEnv

Real addProposalToRemoveMember

Real vote finalization

Real transferLockedAndUnlock
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The vulnerable calculation was not copied into a simplified contract.&lt;/p&gt;

&lt;p&gt;The primary path used:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;No delegated user reserve

No NCPExit deposit

No NCPExit mapping overwrite

No public RPC

No mainnet or testnet dependency
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go &lt;span class="nb"&gt;test&lt;/span&gt; ./wemix/governance-contract/test &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-run&lt;/span&gt; TestUnder &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test passed and confirmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Target became below minStaking
Pass

Target vote rejected
Pass

Target proposal rejected
Pass

Removal proposal accepted
Pass

Removal finalization reverted
Pass

Target remained a member
Pass

Top up control removal succeeded
Pass

Lower minimum control removal succeeded
Pass

Healthy removal control succeeded
Pass
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why this is a separate finding
&lt;/h2&gt;

&lt;p&gt;This issue is distinct from the previous WEMIX NCP exit reports.&lt;/p&gt;

&lt;h3&gt;
  
  
  It is not the NCPExit overwrite
&lt;/h3&gt;

&lt;p&gt;That finding required multiple deposits for the same NCP and overwrote existing exit accounting.&lt;/p&gt;

&lt;p&gt;This report:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Does not call NCPExit twice

Does not overwrite NCPExit mappings

Does not strand value already deposited into NCPExit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The root cause is an unchecked subtraction in &lt;code&gt;GovImp.transferLockedAndUnlock&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  It is not the delegated exit skip
&lt;/h3&gt;

&lt;p&gt;The delegated reserve finding used:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;locked == current minStaking
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Removal completed, but the delegated reserve was not transferred to &lt;code&gt;NCPExit&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This report uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;locked &amp;lt; current minStaking
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Removal does not complete.&lt;/p&gt;

&lt;p&gt;The transaction reverts before either branch can run.&lt;/p&gt;

&lt;p&gt;One issue completes removal with broken delegated accounting.&lt;/p&gt;

&lt;p&gt;This issue blocks removal itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the proof establishes
&lt;/h2&gt;

&lt;p&gt;The proof directly demonstrates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The target entered under a valid earlier minimum

Governance raised minStaking above the target balance

The target remained in the member set

The target could no longer vote

The target could no longer create proposals

A valid member created the removal proposal

The removal parameters passed their bounds

Finalization reached the real removal path

locked - getMinStaking underflowed

The transaction reverted

The target remained a member

Three control paths succeeded after the underflow condition was removed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The proof does not demonstrate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Direct theft

Loss of user balances

Protocol-wide insolvency

Permanent network shutdown

An arbitrary external attacker changing governance parameters

A state that no upgrade or parameter change could recover
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those limits are why the report was submitted as Major rather than Critical.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is Major, not Low
&lt;/h2&gt;

&lt;p&gt;The report demonstrates a governance lifecycle denial of service.&lt;/p&gt;

&lt;h3&gt;
  
  
  The invalid member loses participation
&lt;/h3&gt;

&lt;p&gt;After the parameter change, &lt;code&gt;checkLockedAmount&lt;/code&gt; blocks the target from voting and proposing.&lt;/p&gt;

&lt;p&gt;The target remains in governance without satisfying the current requirement.&lt;/p&gt;

&lt;h3&gt;
  
  
  The standard cleanup path is unavailable
&lt;/h3&gt;

&lt;p&gt;Valid members can create and approve a removal proposal.&lt;/p&gt;

&lt;p&gt;Finalization still reverts.&lt;/p&gt;

&lt;p&gt;The ordinary mechanism intended to restore membership integrity cannot complete.&lt;/p&gt;

&lt;h3&gt;
  
  
  The actual member set remains inconsistent
&lt;/h3&gt;

&lt;p&gt;This is not a view-only mismatch.&lt;/p&gt;

&lt;p&gt;The target remains in the real member set after the failed transaction.&lt;/p&gt;

&lt;h3&gt;
  
  
  The failure is deterministic
&lt;/h3&gt;

&lt;p&gt;Whenever the removal helper evaluates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;locked &amp;lt; current minStaking
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;the subtraction reverts before any branch runs.&lt;/p&gt;

&lt;p&gt;No race, timing condition, or frontend behavior is required.&lt;/p&gt;

&lt;h3&gt;
  
  
  Recovery requires another state transition
&lt;/h3&gt;

&lt;p&gt;The PoC demonstrated three recovery classes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The target voluntarily tops up

Governance rolls minStaking back down

The implementation is corrected
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Until one of those occurs, the standard removal finalization remains blocked.&lt;/p&gt;

&lt;h3&gt;
  
  
  Low does not fit the result
&lt;/h3&gt;

&lt;p&gt;Low would fit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A harmless arithmetic value

A reverted view function

A cosmetic member-status mismatch

An unreachable edge case

A failed path with another normal cleanup route
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This report showed the real governance cleanup transaction reverting and the invalid member remaining in the actual member set.&lt;/p&gt;

&lt;p&gt;That is Major.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the $100 outcome understates the evidence
&lt;/h2&gt;

&lt;p&gt;The final Low classification produced a $100 bounty.&lt;/p&gt;

&lt;p&gt;That records the program decision.&lt;/p&gt;

&lt;p&gt;It does not change the observed result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Initial minStaking
1,500,000 WEMIX

Current minStaking
2,000,000 WEMIX

Target locked
1,500,000 WEMIX

Target participation
Blocked

Removal proposal
Valid

Removal finalization
Reverted

Target remains a member
True
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The demonstrated impact was not a theoretical unsigned subtraction.&lt;/p&gt;

&lt;p&gt;It was governance cleanup denial of service.&lt;/p&gt;

&lt;p&gt;A member that no longer satisfies the active requirement can remain stuck until a top up, parameter rollback, or implementation correction removes the condition.&lt;/p&gt;

&lt;p&gt;That is not Low severity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I describe the WEMIX handling as systematic downgrading
&lt;/h2&gt;

&lt;p&gt;Severity disagreements are normal in bug bounty programs.&lt;/p&gt;

&lt;p&gt;My concern is the recurring outcome across my own valid WEMIX submissions.&lt;/p&gt;

&lt;p&gt;Reports supported by reproducible proofs and concrete protocol behavior were repeatedly assigned substantially lower final severities.&lt;/p&gt;

&lt;p&gt;This report was submitted as Major.&lt;/p&gt;

&lt;p&gt;The proof showed a valid governance parameter change producing an understaked member that could no longer participate and could not be removed because the real finalization path reverted.&lt;/p&gt;

&lt;p&gt;It was classified as Low.&lt;/p&gt;

&lt;p&gt;I cannot infer intent from that decision.&lt;/p&gt;

&lt;p&gt;I can document the repeated outcome and compare the final classification with the technical evidence.&lt;/p&gt;

&lt;p&gt;Calling it systematic downgrading describes that recurring pattern across my WEMIX submissions without claiming a motive.&lt;/p&gt;

&lt;p&gt;A technically convincing Low justification would need to explain why these effects are minor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;An invalid NCP remaining in the actual member set

The target unable to vote or propose

A valid removal proposal being accepted

Finalization reverting deterministically

The target remaining a member after failed cleanup

Removal succeeding only after the underflow condition is eliminated
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without addressing those facts, the Low classification does not match the proof.&lt;/p&gt;

&lt;h2&gt;
  
  
  The minimal safe correction
&lt;/h2&gt;

&lt;p&gt;The subtraction must occur only when excess stake actually exists.&lt;/p&gt;

&lt;p&gt;The report proposed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function transferLockedAndUnlock(
    uint256 ballotIdx,
    address addr
)
    private
{
    (
        uint256 unlockAmount,
        uint256 slashing
    ) =
        getBallotForExit(
            ballotIdx
        );

    uint256 minStaking =
        getMinStaking();

    require(
        unlockAmount
            +
        slashing
            &amp;lt;=
        minStaking,
        "minStaking value must be greater than or equal to the sum of unlockAmount, slashing"
    );

    IStaking staking =
        IStaking(
            getStakingAddress()
        );

    uint256 locked =
        staking.lockedBalanceOf(
            addr
        );

    uint256 ext =
        0;

    if (
        locked
            &amp;gt;
        minStaking
    ) {
        ext =
            locked
                -
            minStaking;
    }

    if (
        locked
            &amp;gt;
        unlockAmount
    ) {
        unlock(
            addr,
            unlockAmount
        );

        staking.transferLocked(
            addr,
            slashing,
            ext
        );
    } else {
        unlock(
            addr,
            locked
        );
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;If locked &amp;gt; minStaking
ext = locked - minStaking

Otherwise
ext = 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no unsigned negative excess.&lt;/p&gt;

&lt;p&gt;The function can reach the appropriate cleanup branch instead of reverting during arithmetic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the correction preserves existing behavior
&lt;/h2&gt;

&lt;p&gt;For a member above the minimum:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ext = locked - minStaking
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The existing excess calculation remains unchanged.&lt;/p&gt;

&lt;p&gt;For a member exactly at the minimum:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ext = 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the correct mathematical excess.&lt;/p&gt;

&lt;p&gt;For a member below the minimum:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ext = 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cleanup no longer attempts an impossible unsigned subtraction.&lt;/p&gt;

&lt;p&gt;The proposal bound remains intact:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;unlockAmount + slashing &amp;lt;= current minStaking
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The patch does not weaken that rule.&lt;/p&gt;

&lt;p&gt;Delegated reserve scenarios still require regression coverage together with the separate delegated exit correction because that report addresses a different accounting invariant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Regression tests
&lt;/h2&gt;

&lt;p&gt;A complete correction should verify:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;A target below the current minimum can be removed&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A target exactly at the current minimum preserves existing behavior&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A target above the current minimum preserves the excess path&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Valid members can still create and finalize removal proposals&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The target no longer remains in membership after successful cleanup&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Top up and lower-minimum controls continue to succeed&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Delegated reserve scenarios remain safe with the separate delegated exit fix&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The central invariant is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A valid minStaking change must never create an NCP state that is both ineligible to participate and impossible to remove.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Broader lessons
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Configuration changes create migration states
&lt;/h3&gt;

&lt;p&gt;Raising a minimum does not automatically migrate existing members.&lt;/p&gt;

&lt;p&gt;Cleanup logic must support members admitted under the previous value.&lt;/p&gt;

&lt;h3&gt;
  
  
  Removal code must handle invalid members
&lt;/h3&gt;

&lt;p&gt;A removal path exists to process members that should no longer remain.&lt;/p&gt;

&lt;p&gt;It cannot assume the target still satisfies every current admission requirement.&lt;/p&gt;

&lt;h3&gt;
  
  
  Arithmetic should follow the condition it represents
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ext&lt;/code&gt; represented stake above the current minimum.&lt;/p&gt;

&lt;p&gt;The code calculated it before checking whether any excess existed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Atomicity can hide failed cleanup
&lt;/h3&gt;

&lt;p&gt;The source removed membership entries before calling the helper.&lt;/p&gt;

&lt;p&gt;The later revert restored all of them.&lt;/p&gt;

&lt;p&gt;Only the committed state determines whether removal succeeded.&lt;/p&gt;

&lt;h3&gt;
  
  
  Controls make lifecycle findings stronger
&lt;/h3&gt;

&lt;p&gt;The top up, lower-minimum, and healthy controls proved that the entire governance system was not generically broken.&lt;/p&gt;

&lt;p&gt;The failure was the exact state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;locked &amp;lt; current minStaking
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The target entered governance with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Locked balance
1,500,000 WEMIX

Initial minStaking
1,500,000 WEMIX
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Governance later raised the minimum to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2,000,000 WEMIX
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The target remained a member but could no longer vote or propose.&lt;/p&gt;

&lt;p&gt;A valid member created a valid removal proposal.&lt;/p&gt;

&lt;p&gt;During finalization, &lt;code&gt;transferLockedAndUnlock&lt;/code&gt; attempted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1,500,000
-
2,000,000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The subtraction reverted before either branch executed.&lt;/p&gt;

&lt;p&gt;The whole removal transaction rolled back.&lt;/p&gt;

&lt;p&gt;The understaked NCP remained in the member set.&lt;/p&gt;

&lt;p&gt;The program classified the report as Low and paid $100.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuhuxwwmone2nlavzqtha.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuhuxwwmone2nlavzqtha.png" alt=" " width="687" height="52"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The demonstrated impact was Major.&lt;/p&gt;

&lt;p&gt;The report did not claim theft, insolvency, or permanent network shutdown.&lt;/p&gt;

&lt;p&gt;It demonstrated a narrower and directly proven failure:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A valid minStaking increase could leave an NCP too understaked to participate and understaked in a way the normal removal calculation could not safely handle.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A governance cleanup path that cannot remove the invalid member it was invoked to remove is not a Low severity issue.&lt;/p&gt;

</description>
      <category>smartcontract</category>
      <category>blockchain</category>
      <category>security</category>
      <category>web3</category>
    </item>
    <item>
      <title>How Unsolicited EtcdCluster Packets Could Stall or Redirect WEMIX Mining Coordination</title>
      <dc:creator>Daniel</dc:creator>
      <pubDate>Fri, 07 Aug 2026 01:04:40 +0000</pubDate>
      <link>https://dev.to/f00dat/how-unsolicited-etcdcluster-packets-could-stall-or-redirect-wemix-mining-coordination-fn0</link>
      <guid>https://dev.to/f00dat/how-unsolicited-etcdcluster-packets-could-stall-or-redirect-wemix-mining-coordination-fn0</guid>
      <description>&lt;p&gt;A node that asks one peer for a cluster response should not accept the first answer broadcast by somebody else.&lt;/p&gt;

&lt;p&gt;WEMIX &lt;code&gt;etcdJoin&lt;/code&gt; did exactly that.&lt;/p&gt;

&lt;p&gt;The function selected a specific peer, sent &lt;code&gt;admin_requestEtcdAddMember&lt;/code&gt; for that peer’s ID, and then waited for a response through a global feed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;ch&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;chan&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="m"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;sub&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt;
    &lt;span class="n"&gt;wemixapi&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SubscribeToEtcdCluster&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That feed carried only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cluster string
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It did not carry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Sending peer ID

Request ID

Pending join identity
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Meanwhile, every accepted partner &lt;code&gt;EtcdCluster&lt;/code&gt; packet was published into the same feed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;go&lt;/span&gt; &lt;span class="n"&gt;wemixapi&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GotEtcdCluster&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;cluster&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As a result, another WEMIX partner peer could send an unsolicited cluster string before the legitimate response arrived.&lt;/p&gt;

&lt;p&gt;The target would consume that value as though it answered the pending join.&lt;/p&gt;

&lt;p&gt;The proof of concept demonstrated the real &lt;code&gt;etcdJoin&lt;/code&gt; requesting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;legit-id
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and then consuming an attacker value first.&lt;/p&gt;

&lt;p&gt;The join returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;not found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;before the legitimate response was released.&lt;/p&gt;

&lt;p&gt;A separate control showed that an attacker-influenced cluster containing the local node endpoint passed &lt;code&gt;etcdFixCluster&lt;/code&gt; and became a valid &lt;code&gt;InitialCluster&lt;/code&gt; candidate.&lt;/p&gt;

&lt;p&gt;I submitted this report as Major through the WEMIX bug bounty program hosted on CertiK Skynet.&lt;/p&gt;

&lt;p&gt;The program classified it as Low and paid a $100 bounty.&lt;/p&gt;

&lt;p&gt;That classification does not match the demonstrated impact.&lt;/p&gt;

&lt;p&gt;This was not a harmless unsolicited packet.&lt;/p&gt;

&lt;p&gt;It was request confusion in a privileged P2P control plane used for mining coordination.&lt;/p&gt;

&lt;p&gt;The complete public report and proof of concept are available in the &lt;a href="https://gist.github.com/f00dat/61da5d2e573db3387876bb50b14a66c7" rel="noopener noreferrer"&gt;GitHub Gist&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The security invariant
&lt;/h2&gt;

&lt;p&gt;WEMIX uses etcd as part of the coordination layer for mining nodes.&lt;/p&gt;

&lt;p&gt;A node joining that cluster needs an &lt;code&gt;InitialCluster&lt;/code&gt; configuration describing the expected members and endpoints.&lt;/p&gt;

&lt;p&gt;The request-response invariant should be simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A join request sent for peer A must be completed only by a response correlated with peer A and that pending request.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Partner status alone is not enough.&lt;/p&gt;

&lt;p&gt;A peer may be authorized to participate in the partner protocol without being authorized to answer every join request currently pending on the target.&lt;/p&gt;

&lt;h2&gt;
  
  
  EtcdJoin selected a legitimate peer
&lt;/h2&gt;

&lt;p&gt;The request side knew which peer it wanted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ma&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;wemixAdmin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;etcdJoin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;wemixNode&lt;/span&gt;

    &lt;span class="n"&gt;ma&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Lock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt;
        &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;ma&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nodes&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
            &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Enode&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
            &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
            &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Ip&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;ma&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unlock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ethereum&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NotFound&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The function later issued:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt;
    &lt;span class="n"&gt;admin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rpcCli&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"admin_requestEtcdAddMember"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the PoC, the selected peer was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Name
legit

ID
legit-id
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The RPC boundary confirmed that &lt;code&gt;etcdJoin&lt;/code&gt; requested the correct ID.&lt;/p&gt;

&lt;p&gt;The failure was not incorrect peer selection.&lt;/p&gt;

&lt;p&gt;The failure was losing that identity while waiting for the response.&lt;/p&gt;

&lt;h2&gt;
  
  
  The response path discarded correlation
&lt;/h2&gt;

&lt;p&gt;The subscription API exposed a process-wide feed of strings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;SubscribeToEtcdCluster&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ch&lt;/span&gt; &lt;span class="k"&gt;chan&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Subscription&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;etcdClusterFeed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;GotEtcdCluster&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;cluster&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;etcdClusterFeed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;cluster&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The consumer received no origin information.&lt;/p&gt;

&lt;p&gt;Once a value entered the feed, &lt;code&gt;etcdJoin&lt;/code&gt; could not determine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Which peer sent it

Which request it answered

Whether it was solicited

Whether it arrived for another concurrent operation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the root cause.&lt;/p&gt;

&lt;p&gt;A broadcast notification channel was used as though it were a correlated response channel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Any accepted partner packet entered that feed
&lt;/h2&gt;

&lt;p&gt;The P2P handler checked whether both the local node and the sending peer were WEMIX partners:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;handleEtcdCluster&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;backend&lt;/span&gt; &lt;span class="n"&gt;Backend&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="n"&gt;Decoder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;peer&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Peer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;wemixminer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AmPartner&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
        &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;wemixminer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsPartner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;peer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;cluster&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt;
        &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;cluster&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"%w: message %v: %v"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;errDecode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;go&lt;/span&gt; &lt;span class="n"&gt;wemixapi&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GotEtcdCluster&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;cluster&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The partner check is a real exploitation precondition.&lt;/p&gt;

&lt;p&gt;The report does not claim that an arbitrary public peer can reach this path.&lt;/p&gt;

&lt;p&gt;But after the check, &lt;code&gt;peer.ID()&lt;/code&gt; was discarded.&lt;/p&gt;

&lt;p&gt;Only the cluster string survived.&lt;/p&gt;

&lt;p&gt;The code transformed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A message from partner X
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;into:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;An originless global event
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That allowed one partner to satisfy a request intended for another.&lt;/p&gt;

&lt;h2&gt;
  
  
  First response wins
&lt;/h2&gt;

&lt;p&gt;The relevant response branch inside &lt;code&gt;etcdJoin&lt;/code&gt; was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;cluster&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="n"&gt;ch&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;

    &lt;span class="n"&gt;cluster&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt;
        &lt;span class="n"&gt;ma&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;etcdFixCluster&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;cluster&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"etcd failed to join"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;cfg&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt;
        &lt;span class="n"&gt;ma&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;etcdNewConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="no"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InitialCluster&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="n"&gt;cluster&lt;/span&gt;

    &lt;span class="n"&gt;etcd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt;
        &lt;span class="n"&gt;embed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StartEtcd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first delivered value controlled the attempt.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;etcdFixCluster&lt;/code&gt; rejected it, &lt;code&gt;etcdJoin&lt;/code&gt; returned immediately.&lt;/p&gt;

&lt;p&gt;It did not ignore the unrelated packet and continue waiting for the expected peer.&lt;/p&gt;

&lt;p&gt;If the value passed parsing, the result became:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InitialCluster&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="n"&gt;cluster&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The code never compared the response against:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;node.Id

Expected partner

Pending request ID
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The parser could reject or preserve attacker input
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;etcdFixCluster&lt;/code&gt; required the local node to appear in the candidate cluster.&lt;/p&gt;

&lt;p&gt;If the local entry was absent, it returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ethereum.NotFound
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the local endpoint was present, the function could normalize that entry while preserving other supplied members.&lt;/p&gt;

&lt;p&gt;The PoC used two attacker strings.&lt;/p&gt;

&lt;h3&gt;
  
  
  Deterministic stall input
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;evil=https://203.0.113.10:2380
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This omitted the local node.&lt;/p&gt;

&lt;p&gt;The real parser rejected it.&lt;/p&gt;

&lt;p&gt;Because &lt;code&gt;etcdJoin&lt;/code&gt; treated the packet as the awaited response, the whole join attempt returned before the legitimate response arrived.&lt;/p&gt;

&lt;h3&gt;
  
  
  Redirect candidate input
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;evil=https://203.0.113.10:2380,=https://127.0.0.1:50001
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This included:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;An attacker-controlled member

The expected local endpoint
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The parser accepted it and returned a fixed cluster candidate.&lt;/p&gt;

&lt;p&gt;The proof deliberately did not start a persistent etcd process with that value.&lt;/p&gt;

&lt;p&gt;That kept the test deterministic and bounded.&lt;/p&gt;

&lt;p&gt;The directly demonstrated result is therefore:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The wrong peer can terminate the real join
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;An attacker-influenced redirect candidate can pass the real parser
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The article does not claim that the PoC maintained a live malicious etcd cluster.&lt;/p&gt;

&lt;h2&gt;
  
  
  The complete stalled-join sequence
&lt;/h2&gt;

&lt;p&gt;The proof executed this flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. The target called etcdJoin("legit")

2. EtcdJoin selected legit-id

3. It subscribed to the global EtcdCluster feed

4. It sent admin_requestEtcdAddMember for legit-id

5. The legitimate response was delayed

6. The attacker cluster was emitted first through GotEtcdCluster

7. The global feed delivered that value to the real etcdJoin

8. EtcdFixCluster returned not found

9. EtcdJoin returned before the legitimate response was released

10. The later legitimate response could not complete the finished attempt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The recorded result was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RPC requested peer ID
legit-id

Malicious cluster emitted first
True

EtcdJoin consumed attacker cluster first
True

EtcdJoin returned before legitimate response
True

Join error
not found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is deterministic request confusion.&lt;/p&gt;

&lt;h2&gt;
  
  
  The legitimate response arrived too late
&lt;/h2&gt;

&lt;p&gt;The in-process RPC boundary delayed the legitimate cluster until after the attacker value had already caused &lt;code&gt;etcdJoin&lt;/code&gt; to return.&lt;/p&gt;

&lt;p&gt;The test then released:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;wemixapi&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GotEtcdCluster&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;legit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The legitimate cluster itself was valid.&lt;/p&gt;

&lt;p&gt;The real parser accepted it in the direct control.&lt;/p&gt;

&lt;p&gt;But the request had already been completed by the wrong value.&lt;/p&gt;

&lt;p&gt;The bug was therefore not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The legitimate peer returned malformed data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The consumer accepted an unrelated response first
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The protocol already recognized request IDs
&lt;/h2&gt;

&lt;p&gt;The P2P layer contained request-tracking machinery:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Peer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;RequestEtcdAddMember&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt;
        &lt;span class="n"&gt;rand&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Uint64&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;requestTracker&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Track&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;EtcdAddMemberMsg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;EtcdClusterMsg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;p2p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;EtcdAddMemberMsg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;common&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Big1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The eth66 response shape also contained:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;EtcdClusterPacket66&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;RequestId&lt;/span&gt; &lt;span class="kt"&gt;uint64&lt;/span&gt;
    &lt;span class="n"&gt;EtcdClusterPacket&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem was not the complete absence of a correlation concept in the codebase.&lt;/p&gt;

&lt;p&gt;The problem was that &lt;code&gt;etcdJoin&lt;/code&gt; ultimately consumed only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;chan string
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By the time the cluster reached the waiting join, peer and request context were gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the PoC used
&lt;/h2&gt;

&lt;p&gt;The proof used the real:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;wemixAdmin.etcdJoin

SubscribeToEtcdCluster

GotEtcdCluster

etcdFixCluster

Node-selection logic

First-response consumption behavior
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An in-process RPC boundary was used only to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Record admin_requestEtcdAddMember

Confirm legit-id was requested

Delay the legitimate response
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It did not publish the malicious value.&lt;/p&gt;

&lt;p&gt;It did not decide the final error.&lt;/p&gt;

&lt;p&gt;The attacker value entered through the same &lt;code&gt;GotEtcdCluster&lt;/code&gt; sink used by the real P2P handler after partner validation.&lt;/p&gt;

&lt;p&gt;The real &lt;code&gt;etcdJoin&lt;/code&gt; consumed it.&lt;/p&gt;

&lt;p&gt;The real &lt;code&gt;etcdFixCluster&lt;/code&gt; returned &lt;code&gt;ethereum.NotFound&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The command was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go &lt;span class="nb"&gt;test&lt;/span&gt; ./wemix &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-run&lt;/span&gt; TestEtcd &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test passed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The controls isolated the issue
&lt;/h2&gt;

&lt;p&gt;The PoC verified:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Real EtcdJoin used
True

Real EtcdFixCluster used
True

Real global feed used
True

Requested peer
legit-id

Feed contains peer identity
False

Feed contains request ID
False

Join consumed attacker first
True

Join returned before legitimate response
True

Legitimate cluster passes parser
True

Invalid attacker cluster fails parser
True

Attacker redirect candidate passes parser
True

Persistent etcd process started in primary path
False
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These controls matter because they separate three questions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Was the correct peer requested?
Yes

Did the consumer know who answered?
No

Could the attacker value control the result?
Yes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What the proof establishes
&lt;/h2&gt;

&lt;p&gt;The proof demonstrates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EtcdJoin selected a specific legitimate peer

The RPC request contained legit-id

The response feed had no peer binding

The response feed had no request binding

An unsolicited attacker value arrived first

The real join consumed it

The real join returned before the legitimate response

A malformed attacker value caused deterministic join failure

An attacker-influenced cluster passed the real parser
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The proof does not demonstrate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Direct fund theft

A completed live-network exploit

Total network shutdown

Permanent consensus failure

A persistent attacker-controlled etcd process

Exploitation by a non-partner peer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those limits are why the report was submitted as Major rather than Critical.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is Major, not Low
&lt;/h2&gt;

&lt;p&gt;The impact is a privileged P2P control-plane failure affecting mining coordination.&lt;/p&gt;

&lt;h3&gt;
  
  
  The request was completed by the wrong peer
&lt;/h3&gt;

&lt;p&gt;The target requested &lt;code&gt;legit-id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The consumer accepted an originless value that could have come from another partner.&lt;/p&gt;

&lt;p&gt;That is a failure of request-response integrity.&lt;/p&gt;

&lt;h3&gt;
  
  
  The join failed through the real path
&lt;/h3&gt;

&lt;p&gt;The attacker value was not rejected as unrelated.&lt;/p&gt;

&lt;p&gt;It reached &lt;code&gt;etcdFixCluster&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The function returned &lt;code&gt;not found&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The real &lt;code&gt;etcdJoin&lt;/code&gt; terminated before the legitimate response.&lt;/p&gt;

&lt;h3&gt;
  
  
  The attacker could influence the cluster candidate
&lt;/h3&gt;

&lt;p&gt;The redirect control passed the real parser while preserving the attacker-controlled member.&lt;/p&gt;

&lt;p&gt;The PoC did not start etcd with that candidate, but it proved the value crossed the validation step that feeds &lt;code&gt;InitialCluster&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The affected path coordinates mining nodes
&lt;/h3&gt;

&lt;p&gt;This was not a UI or monitoring endpoint.&lt;/p&gt;

&lt;p&gt;The function joins the etcd cluster used by mining nodes.&lt;/p&gt;

&lt;p&gt;A failed or attacker-influenced join can prevent the target from coordinating as intended and can contribute to missed production opportunities.&lt;/p&gt;

&lt;h3&gt;
  
  
  The attack is privileged but not administrative
&lt;/h3&gt;

&lt;p&gt;A real exploit requires a WEMIX partner peer.&lt;/p&gt;

&lt;p&gt;It does not require:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Governance control

Administrator credentials

Validator private keys

Public RPC manipulation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Partner membership should permit protocol participation.&lt;/p&gt;

&lt;p&gt;It should not permit one peer to impersonate another peer’s response.&lt;/p&gt;

&lt;h3&gt;
  
  
  The failure can be raced again
&lt;/h3&gt;

&lt;p&gt;A failed attempt requires another join.&lt;/p&gt;

&lt;p&gt;Without correlation, the same class of unsolicited partner packet can race the next attempt as well.&lt;/p&gt;

&lt;h3&gt;
  
  
  Low does not fit the demonstrated result
&lt;/h3&gt;

&lt;p&gt;Low would fit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;An unsolicited packet ignored before use

A stale status update

A harmless parser discrepancy

A message with no effect on a pending operation

A UI-only failure
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This report showed the real mining-coordination join being terminated by the wrong response.&lt;/p&gt;

&lt;p&gt;That is Major.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the $100 outcome understates the evidence
&lt;/h2&gt;

&lt;p&gt;The final Low classification produced a $100 bounty.&lt;/p&gt;

&lt;p&gt;That records the program decision.&lt;/p&gt;

&lt;p&gt;It does not change the result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Requested peer
legit-id

Consumed response
Attacker cluster

Peer identity in feed
None

Request ID in feed
None

Join returned before legitimate response
True

Join result
not found

Attacker-influenced redirect candidate accepted
True
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The strongest directly demonstrated impact was a malicious partner controlling the result of another peer’s pending mining-coordination join.&lt;/p&gt;

&lt;p&gt;That is a Major control-plane and liveness issue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I describe the WEMIX handling as systematic downgrading
&lt;/h2&gt;

&lt;p&gt;Severity disagreements are normal in bug bounty programs.&lt;/p&gt;

&lt;p&gt;My concern is the recurring outcome across my own valid WEMIX submissions.&lt;/p&gt;

&lt;p&gt;Reports supported by reproducible proofs and concrete protocol behavior were repeatedly assigned substantially lower final severities.&lt;/p&gt;

&lt;p&gt;This report was submitted as Major.&lt;/p&gt;

&lt;p&gt;The proof showed the real &lt;code&gt;etcdJoin&lt;/code&gt; requesting &lt;code&gt;legit-id&lt;/code&gt;, consuming an unsolicited attacker value, and returning before the legitimate response.&lt;/p&gt;

&lt;p&gt;It also showed that the consumer received neither peer identity nor request ID and that an attacker-influenced redirect candidate passed &lt;code&gt;etcdFixCluster&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The report was classified as Low.&lt;/p&gt;

&lt;p&gt;I cannot infer intent from that decision.&lt;/p&gt;

&lt;p&gt;I can document the repeated outcome and compare the final classification with the technical evidence.&lt;/p&gt;

&lt;p&gt;Calling it systematic downgrading describes that recurring pattern across my WEMIX submissions without claiming a motive.&lt;/p&gt;

&lt;p&gt;A technically convincing Low justification would need to explain why these effects are minor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;One partner satisfying another peer’s pending request

No peer binding

No request-ID binding

The real join consuming the attacker value

The join returning before the legitimate response

Attacker-controlled membership surviving parser validation

Mining coordination for the target being stalled or influenced
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without addressing those facts, the Low classification does not match the proof.&lt;/p&gt;

&lt;h2&gt;
  
  
  The correct fix
&lt;/h2&gt;

&lt;p&gt;The response consumed by &lt;code&gt;etcdJoin&lt;/code&gt; must preserve correlation context.&lt;/p&gt;

&lt;p&gt;A structured response can carry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;EtcdClusterResponse&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;PeerID&lt;/span&gt;    &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;RequestID&lt;/span&gt; &lt;span class="kt"&gt;uint64&lt;/span&gt;
    &lt;span class="n"&gt;Cluster&lt;/span&gt;   &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pending join should record:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Expected peer ID

Expected request ID
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The handler should preserve:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Actual sending peer ID

Received request ID

Cluster string
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The waiting join must ignore any response where:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PeerID does not match
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RequestID does not match
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The existing global string feed may remain for telemetry or legacy observers.&lt;/p&gt;

&lt;p&gt;It must not control a correlated join operation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Safer lifecycle
&lt;/h2&gt;

&lt;p&gt;The corrected flow should be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. EtcdJoin selects legit-id

2. The request receives a unique request ID

3. The pending operation stores both values

4. A partner response arrives

5. The handler preserves peer and request identity

6. EtcdJoin rejects unrelated peers

7. EtcdJoin rejects mismatched request IDs

8. Only the matching cluster reaches etcdFixCluster

9. Only the validated matching result becomes InitialCluster
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An unsolicited packet can then be logged or penalized without completing the join.&lt;/p&gt;

&lt;h2&gt;
  
  
  Regression tests
&lt;/h2&gt;

&lt;p&gt;A complete correction should verify:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;The expected peer with the correct request ID completes the join&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Another partner’s response is ignored&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A mismatched request ID is ignored&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;An unsolicited packet arriving first cannot terminate the operation&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;An unrelated malformed cluster cannot make &lt;code&gt;etcdJoin&lt;/code&gt; return&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;An attacker-influenced cluster from the wrong peer cannot become &lt;code&gt;InitialCluster&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Concurrent joins cannot consume each other’s responses&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Timeout behavior remains bounded when no matching response arrives&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The central invariant is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Only a response matching both the intended peer and the pending request may complete an etcd join.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Broader lessons
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Partner status is not response correlation
&lt;/h3&gt;

&lt;p&gt;A partner can be authorized to use a privileged protocol without being authorized to answer every pending request.&lt;/p&gt;

&lt;h3&gt;
  
  
  Global feeds are wrong for correlated responses
&lt;/h3&gt;

&lt;p&gt;Broadcast feeds are useful for notifications.&lt;/p&gt;

&lt;p&gt;They are unsafe when exactly one response must complete exactly one request.&lt;/p&gt;

&lt;h3&gt;
  
  
  First response wins is not authentication
&lt;/h3&gt;

&lt;p&gt;Timing decided which cluster value &lt;code&gt;etcdJoin&lt;/code&gt; trusted.&lt;/p&gt;

&lt;p&gt;A peer that arrived first controlled the attempt.&lt;/p&gt;

&lt;h3&gt;
  
  
  Control-plane messages need context
&lt;/h3&gt;

&lt;p&gt;A cluster string is not enough.&lt;/p&gt;

&lt;p&gt;The consumer also needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Who sent it

Which request it answers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Targeted liveness failures still matter
&lt;/h3&gt;

&lt;p&gt;A vulnerability does not need to stop the entire network to affect blockchain operations.&lt;/p&gt;

&lt;p&gt;Disrupting one mining node’s coordination path can still cost production opportunities and degrade liveness for that target.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The target selected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Peer
legit

Peer ID
legit-id
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But it waited on a global feed carrying only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cluster string
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A malicious partner emitted an unsolicited value first.&lt;/p&gt;

&lt;p&gt;The real &lt;code&gt;etcdJoin&lt;/code&gt; consumed it.&lt;/p&gt;

&lt;p&gt;The feed contained no peer identity.&lt;/p&gt;

&lt;p&gt;It contained no request ID.&lt;/p&gt;

&lt;p&gt;The join returned &lt;code&gt;not found&lt;/code&gt; before the legitimate response arrived.&lt;/p&gt;

&lt;p&gt;A separate control proved that an attacker-influenced cluster containing the local endpoint passed &lt;code&gt;etcdFixCluster&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The primary PoC did not start a persistent malicious etcd process.&lt;/p&gt;

&lt;p&gt;It proved the underlying defect:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The first accepted partner cluster string could complete a pending join regardless of which peer or request it belonged to.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The program classified the report as Low and paid $100.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9qh5wq65fqbp65s7tzbt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9qh5wq65fqbp65s7tzbt.png" alt=" " width="650" height="16"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwoalufv660ha40rpd8hz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwoalufv660ha40rpd8hz.png" alt=" " width="713" height="38"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The demonstrated impact was Major.&lt;/p&gt;

&lt;p&gt;The report did not claim theft, total network shutdown, or permanent consensus failure.&lt;/p&gt;

&lt;p&gt;It demonstrated a narrower and directly proven failure:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A malicious partner peer could terminate another node’s mining-coordination join and could supply an attacker-influenced cluster candidate by winning an uncorrelated response race.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A privileged P2P response-confusion bug that controls a mining node’s join attempt is not a Low-severity issue.&lt;/p&gt;

</description>
      <category>smartcontract</category>
      <category>blockchain</category>
      <category>security</category>
      <category>web3</category>
    </item>
    <item>
      <title>How Unsigned Partner Metadata Could Make WEMIX Block Producers Build Invalid Blocks</title>
      <dc:creator>Daniel</dc:creator>
      <pubDate>Fri, 07 Aug 2026 00:49:23 +0000</pubDate>
      <link>https://dev.to/f00dat/how-unsigned-partner-metadata-could-make-wemix-block-producers-build-invalid-blocks-17fn</link>
      <guid>https://dev.to/f00dat/how-unsigned-partner-metadata-could-make-wemix-block-producers-build-invalid-blocks-17fn</guid>
      <description>&lt;p&gt;A signed transaction should have exactly one sender.&lt;/p&gt;

&lt;p&gt;That sender must come from the signature.&lt;/p&gt;

&lt;p&gt;WEMIX introduced a &lt;code&gt;TransactionsEx&lt;/code&gt; P2P packet that carried two pieces of data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A signed transaction

A separate From address
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;From&lt;/code&gt; field was network metadata.&lt;/p&gt;

&lt;p&gt;It was not part of the signed transaction bytes.&lt;/p&gt;

&lt;p&gt;It did not change the transaction hash.&lt;/p&gt;

&lt;p&gt;Yet when the packet came from a peer classified as a WEMIX partner, the client trusted that metadata and wrote it directly into the transaction sender cache:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trustIt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;sigCache&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;signer&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;signer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;From&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That created a dangerous contradiction.&lt;/p&gt;

&lt;p&gt;The transaction could be signed by the attacker while the cached sender was set to a victim.&lt;/p&gt;

&lt;p&gt;Local execution would then treat the victim as the sender.&lt;/p&gt;

&lt;p&gt;A fresh decode of the same signed transaction would recover the attacker.&lt;/p&gt;

&lt;p&gt;The two executions produced different state roots.&lt;/p&gt;

&lt;p&gt;If a block producer includes the poisoned transaction, it can compute a state root that honest nodes reject after recovering the sender from the signature.&lt;/p&gt;

&lt;p&gt;I submitted this report as Major through the WEMIX bug bounty program hosted on CertiK Skynet.&lt;/p&gt;

&lt;p&gt;The program classified it as Low and paid a $100 bounty.&lt;/p&gt;

&lt;p&gt;That classification does not match the demonstrated impact.&lt;/p&gt;

&lt;p&gt;The proof did not show a harmless cache inconsistency.&lt;/p&gt;

&lt;p&gt;It showed unauthenticated P2P metadata crossing into transaction execution, changing the sender used for gas and nonce accounting, and causing consensus-relevant state divergence.&lt;/p&gt;

&lt;p&gt;The full public report and proof of concept are available in the &lt;a href="https://gist.github.com/f00dat/336c1b0ee5ce029b4e8569a8fae024e4" rel="noopener noreferrer"&gt;GitHub Gist&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sender must come from the signature
&lt;/h2&gt;

&lt;p&gt;Ethereum-style transactions are signed objects.&lt;/p&gt;

&lt;p&gt;The sender is recovered from the transaction signature under the applicable chain and signer rules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;V

R

S

Signing hash

Chain-specific signer rules
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A network peer may relay the transaction.&lt;/p&gt;

&lt;p&gt;It must not define who signed it.&lt;/p&gt;

&lt;p&gt;The invariant is simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A peer supplied field must never replace local cryptographic sender recovery.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;WEMIX broke that invariant for partner peers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The extra From field was not authenticated
&lt;/h2&gt;

&lt;p&gt;The WEMIX transaction extension was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;TransactionEx&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Tx&lt;/span&gt;   &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Transaction&lt;/span&gt;
    &lt;span class="n"&gt;From&lt;/span&gt; &lt;span class="n"&gt;common&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Address&lt;/span&gt; &lt;span class="s"&gt;`json:"from" rlp:"nil"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The signed transaction lived in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TransactionEx.Tx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The separate address lived in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TransactionEx.From
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those values were not cryptographically bound together.&lt;/p&gt;

&lt;p&gt;A partner peer could provide:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Transaction signed by attacker

TransactionEx.From set to victim
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The transaction bytes remained unchanged.&lt;/p&gt;

&lt;p&gt;The transaction hash remained unchanged.&lt;/p&gt;

&lt;p&gt;Only the peer-controlled metadata claimed a different sender.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real wire path preserved the forged metadata
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;TransactionEx.EncodeRLP&lt;/code&gt; serialized the transaction and then serialized the cached sender as the extra &lt;code&gt;From&lt;/code&gt; field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tx&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;TransactionEx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;EncodeRLP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Writer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EncodeRLP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="n"&gt;common&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Address&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;sc&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Load&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;sc&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
            &lt;span class="n"&gt;sc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sigCache&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;rlp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The receiver decoded the same structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tx&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;TransactionEx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;DecodeRLP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;rlp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Tx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Transaction&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DecodeRLP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;From&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The PoC used this real RLP path.&lt;/p&gt;

&lt;p&gt;It created a transaction signed by the attacker, set the sender side cache to the victim solely to construct the extended wire metadata, and encoded a &lt;code&gt;TransactionEx&lt;/code&gt; packet carrying:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;From = victim
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After decoding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Signed transaction bytes
Unchanged

Transaction hash
Unchanged

Decoded TransactionEx.From
Victim
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The metadata survived the wire path without becoming part of the signature.&lt;/p&gt;

&lt;h2&gt;
  
  
  Partner status activated the dangerous path
&lt;/h2&gt;

&lt;p&gt;The P2P handler converted extended packets through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;txs&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt;
    &lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TxExs2Txs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;signer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;txexs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;wemixminer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsPartner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;peer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The third argument was the trust decision:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;trustIt = wemixminer.IsPartner(peer.ID())
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The report does not claim exploitation by any arbitrary internet peer.&lt;/p&gt;

&lt;p&gt;The attacker model requires a peer that WEMIX treats as a partner.&lt;/p&gt;

&lt;p&gt;That is a meaningful precondition.&lt;/p&gt;

&lt;p&gt;It does not make the vulnerability Low.&lt;/p&gt;

&lt;p&gt;Partner status can justify permission to connect or relay specialized packets.&lt;/p&gt;

&lt;p&gt;It cannot safely grant permission to redefine the cryptographic sender of a signed transaction.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sender cache accepted the victim without verification
&lt;/h2&gt;

&lt;p&gt;The vulnerable converter was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;TxExs2Txs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;signer&lt;/span&gt; &lt;span class="n"&gt;Signer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;txs&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;TransactionEx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;trustIt&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Transaction&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Transaction&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;txs&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;trustIt&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;sigCache&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;signer&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;signer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;From&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
            &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;trustIt&lt;/code&gt; was true, the implementation did not call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;signer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sender&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Tx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It did not compare:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Recovered cryptographic sender

Peer-supplied From
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It simply stored the peer value as authoritative sender state.&lt;/p&gt;

&lt;p&gt;That poisoned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Transaction.from
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The cache bypassed signature recovery
&lt;/h2&gt;

&lt;p&gt;The normal sender helper first checked the cache:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;Sender&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;signer&lt;/span&gt; &lt;span class="n"&gt;Signer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tx&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Transaction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;common&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;sc&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Load&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;sc&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;sigCache&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt;
            &lt;span class="n"&gt;sc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sigCache&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;sigCache&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;signer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;signer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;sigCache&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt;
        &lt;span class="n"&gt;signer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sender&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;common&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Address&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;sigCache&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;signer&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;signer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the cached signer matched, &lt;code&gt;types.Sender&lt;/code&gt; returned the cached address immediately.&lt;/p&gt;

&lt;p&gt;There was no new signature recovery.&lt;/p&gt;

&lt;p&gt;The PoC isolated this behavior with two controls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;trustIt = false
Sender recovered as attacker

trustIt = true
Sender returned as victim
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A fresh decode of the same signed transaction bytes returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Attacker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The only difference was the poisoned local cache.&lt;/p&gt;

&lt;h2&gt;
  
  
  The poisoned value could propagate into txpool sender resolution
&lt;/h2&gt;

&lt;p&gt;The sender resolver checked whether the transaction already carried a cached sender:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;addr&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt;
    &lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetSender&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;signer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;addr&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tx2addr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;continue&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once &lt;code&gt;TransactionsEx&lt;/code&gt; had placed the victim into &lt;code&gt;Transaction.from&lt;/code&gt;, this path could persist that address by transaction hash.&lt;/p&gt;

&lt;p&gt;The transaction hash did not change, so the same signed transaction could remain locally associated with a sender who never signed it.&lt;/p&gt;

&lt;p&gt;The PoC did not need to execute the complete &lt;code&gt;TxPool.ResolveSenders&lt;/code&gt; pipeline. This section describes the downstream propagation visible in the production code. The execution divergence itself was directly demonstrated through &lt;code&gt;core.ApplyTransaction&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The poisoned sender reached real execution
&lt;/h2&gt;

&lt;p&gt;The PoC did not stop at demonstrating a bad helper return.&lt;/p&gt;

&lt;p&gt;It reached:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;core&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ApplyTransaction&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The execution path converted the transaction into a message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt;
    &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AsMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MakeSigner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BaseFee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That conversion used sender recovery.&lt;/p&gt;

&lt;p&gt;For the poisoned transaction, the cache returned the victim.&lt;/p&gt;

&lt;p&gt;The state transition then used:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;From&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;for balance and gas handling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;have&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;want&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt;
    &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetBalance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;From&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;balanceCheck&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;have&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Cmp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;want&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"%w: address %v have %v want %v"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ErrInsufficientFunds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;From&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Hex&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;have&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;want&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SubBalance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;From&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;mgval&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The peer-controlled metadata had reached the real transaction execution layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same transaction executed as two different senders
&lt;/h2&gt;

&lt;p&gt;The proof executed the exact same signed transaction bytes twice.&lt;/p&gt;

&lt;h3&gt;
  
  
  Poisoned partner execution
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cryptographic signer
Attacker

Cached sender
Victim

Gas charged to
Victim

Nonce incremented for
Victim
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Fresh honest execution
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cryptographic signer
Attacker

Recovered sender
Attacker

Gas charged to
Attacker

Nonce incremented for
Attacker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both executions were accepted locally.&lt;/p&gt;

&lt;p&gt;They did not produce the same state.&lt;/p&gt;

&lt;h2&gt;
  
  
  The concrete PoC result
&lt;/h2&gt;

&lt;p&gt;The transaction used:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Transaction type
DynamicFeeTx

Gas limit
21,000

Effective gas price
2 wei

Transaction value
0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The direct debit was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;21,000 × 2 wei
=
42,000 wei
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the poisoned execution:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Victim gas debit
42,000 wei

Victim nonce after execution
1

Attacker gas debit
0

Attacker nonce after execution
0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the fresh execution:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Attacker gas debit
42,000 wei

Attacker nonce after execution
1

Victim gas debit
0

Victim nonce after execution
0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The PoC did not present that local victim debit as network accepted theft.&lt;/p&gt;

&lt;p&gt;The important result was execution divergence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The state roots were different
&lt;/h2&gt;

&lt;p&gt;The poisoned execution produced:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0xda61d18fc59fe2eb48c73ebcd38d2540279eeae8317436a36eda976115dccdbe
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fresh execution produced:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0xbe165c07292fb6d76231ecad028675c208201bd18c4ddabdd7a94ce6b385ed9e
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The roots differed because the sender-dependent state changes affected different accounts.&lt;/p&gt;

&lt;p&gt;A target block producer using the poisoned cache could build a block using the first state root.&lt;/p&gt;

&lt;p&gt;Honest nodes receiving the block transaction would not receive that producer’s in-memory sender cache.&lt;/p&gt;

&lt;p&gt;They would decode the signed transaction, recover the attacker, compute the second state transition, and reject the block because the expected root did not match.&lt;/p&gt;

&lt;h2&gt;
  
  
  The complete attack sequence
&lt;/h2&gt;

&lt;p&gt;The demonstrated and directly implied sequence was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. A malicious partner peer creates a valid transaction signed by the attacker

2. The peer supplies TransactionEx.From as a victim

3. The signed transaction bytes and hash remain unchanged

4. handleTransactionsEx treats the peer as a partner

5. TxExs2Txs runs with trustIt set to true

6. The victim is stored in Transaction.from without signature recovery

7. types.Sender returns the victim

8. ApplyTransaction executes the transaction locally with the victim as sender

9. Gas and nonce are applied to the victim state

10. A fresh decode of the same transaction recovers the attacker

11. Fresh execution applies gas and nonce to the attacker state

12. The two executions produce different state roots

13. A producer that commits the poisoned root builds a block honest nodes reject
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The PoC directly proved steps one through twelve with the real execution path. Step thirteen is the consensus consequence of committing a state root that honest nodes cannot reproduce from the canonical transaction bytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the proof actually used
&lt;/h2&gt;

&lt;p&gt;The Go proof used:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Real types.TransactionEx

Real TransactionEx RLP encoding

Real TransactionEx RLP decoding

Real types.TxExs2Txs

Real types.Sender

Real types.GetSender

Real DynamicFeeTx signature

Real core.ApplyTransaction

Real StateTransition.buyGas

The same transaction bytes in both executions
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The vulnerable logic was not reimplemented in a mock.&lt;/p&gt;

&lt;p&gt;A small &lt;code&gt;ChainContext&lt;/code&gt; boundary stub was used because &lt;code&gt;ApplyTransaction&lt;/code&gt; requires that interface.&lt;/p&gt;

&lt;p&gt;The bug did not depend on the stub.&lt;/p&gt;

&lt;p&gt;The proof did not require:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Mainnet

Testnet

Public RPC

Governance

Administrator keys

External services
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It did require the partner-peer trust condition.&lt;/p&gt;

&lt;p&gt;The test command was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go &lt;span class="nb"&gt;test&lt;/span&gt; ./core &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-run&lt;/span&gt; TestTxEx &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test passed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The controls made the root cause unambiguous
&lt;/h2&gt;

&lt;p&gt;A strong consensus PoC needs controls.&lt;/p&gt;

&lt;p&gt;This one included several.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fresh sender control
&lt;/h3&gt;

&lt;p&gt;A fresh decode of the signed transaction recovered:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Attacker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That proved the signature was valid and belonged to the attacker.&lt;/p&gt;

&lt;h3&gt;
  
  
  Untrusted conversion control
&lt;/h3&gt;

&lt;p&gt;With:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;trustIt = false
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;the receiver recovered:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Attacker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The metadata did not poison the sender.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trusted conversion control
&lt;/h3&gt;

&lt;p&gt;With:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;trustIt = true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;the receiver returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Victim
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That isolated the vulnerable trust branch.&lt;/p&gt;

&lt;h3&gt;
  
  
  Same-bytes control
&lt;/h3&gt;

&lt;p&gt;The poisoned and fresh executions used the same transaction bytes.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;From&lt;/code&gt; metadata did not change the signed transaction or its hash.&lt;/p&gt;

&lt;h3&gt;
  
  
  State-transition control
&lt;/h3&gt;

&lt;p&gt;Both executions reached the real &lt;code&gt;ApplyTransaction&lt;/code&gt; path.&lt;/p&gt;

&lt;p&gt;One charged the victim.&lt;/p&gt;

&lt;p&gt;The other charged the attacker.&lt;/p&gt;

&lt;p&gt;The state roots differed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the report does not claim
&lt;/h2&gt;

&lt;p&gt;The report does not claim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Direct theft accepted by honest nodes

A permanent chain split

A total network shutdown

Permanent transaction confirmation failure

Exploitation by any arbitrary peer

A completed live-network attack
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Honest nodes reject the poisoned block.&lt;/p&gt;

&lt;p&gt;That rejection prevents the local false sender state from becoming canonical.&lt;/p&gt;

&lt;p&gt;It does not make the issue harmless.&lt;/p&gt;

&lt;p&gt;The attacker can still target block producers and make them waste proposer opportunities by constructing blocks that the rest of the network rejects.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is Major, not Low
&lt;/h2&gt;

&lt;p&gt;The report was submitted under the impact category:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Blockchain transient consensus failure

Block producer denial of service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The PoC demonstrated both sides of that classification.&lt;/p&gt;

&lt;h3&gt;
  
  
  It crosses a consensus critical trust boundary
&lt;/h3&gt;

&lt;p&gt;A P2P peer influenced the sender used in transaction execution.&lt;/p&gt;

&lt;p&gt;Sender identity is not optional metadata.&lt;/p&gt;

&lt;p&gt;It determines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Nonce

Gas payment

Balance checks

State transition

Final state root
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  It causes real execution divergence
&lt;/h3&gt;

&lt;p&gt;The same signed transaction bytes produced two accepted local executions with different senders.&lt;/p&gt;

&lt;p&gt;This is not a logging or display problem.&lt;/p&gt;

&lt;p&gt;It changes consensus-relevant state.&lt;/p&gt;

&lt;h3&gt;
  
  
  It creates the exact divergence required for invalid block production
&lt;/h3&gt;

&lt;p&gt;The poisoned execution computes one root.&lt;/p&gt;

&lt;p&gt;Fresh canonical decoding computes another.&lt;/p&gt;

&lt;p&gt;The PoC did not assemble and gossip a full block. It proved the consensus critical prerequisite through the real state transition path: honest nodes cannot reproduce the poisoned root from the signed transaction bytes.&lt;/p&gt;

&lt;p&gt;A producer that includes the poisoned transaction and commits that root constructs an invalid block.&lt;/p&gt;

&lt;h3&gt;
  
  
  It can waste a block production opportunity
&lt;/h3&gt;

&lt;p&gt;A targeted producer can spend its opportunity executing, assembling, and broadcasting a block that honest nodes reject.&lt;/p&gt;

&lt;p&gt;That is a block producer denial of service impact, even though honest rejection prevents the poisoned state from becoming canonical.&lt;/p&gt;

&lt;h3&gt;
  
  
  The attack requires a partner peer, not an administrator
&lt;/h3&gt;

&lt;p&gt;The report has a meaningful privilege precondition.&lt;/p&gt;

&lt;p&gt;The sender must be recognized by WEMIX as a partner peer.&lt;/p&gt;

&lt;p&gt;But the attack does not require:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Governance control

Administrator keys

Validator private keys

Public RPC manipulation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Partner status is a network privilege, not authority to override the sender authenticated by a transaction signature.&lt;/p&gt;

&lt;h3&gt;
  
  
  Low does not fit the demonstrated effect
&lt;/h3&gt;

&lt;p&gt;Low would fit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;An incorrect cached value with no execution impact

A display-only sender mismatch

A packet rejected before state transition

A harmless performance optimization bug

A divergence that cannot reach block construction
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This report reached real transaction execution and produced different state roots.&lt;/p&gt;

&lt;p&gt;That is Major.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the $100 outcome understates the evidence
&lt;/h2&gt;

&lt;p&gt;The final Low classification produced a $100 bounty.&lt;/p&gt;

&lt;p&gt;That records the program decision.&lt;/p&gt;

&lt;p&gt;It does not change the proof:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Transaction signer
Attacker

Partner metadata
Victim

Transaction bytes
Unchanged

Transaction hash
Unchanged

Sender with trust false
Attacker

Sender with trust true
Victim

Poisoned victim debit
42,000 wei

Fresh attacker debit
42,000 wei

State roots
Different

Block producer consequence
Invalid block if the poisoned root is committed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The strongest demonstrated impact was state-root divergence enabling targeted invalid block production through unauthenticated partner metadata.&lt;/p&gt;

&lt;p&gt;That is not a Low severity cache bug.&lt;/p&gt;

&lt;p&gt;It is a Major consensus and block production issue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I describe the WEMIX handling as systematic downgrading
&lt;/h2&gt;

&lt;p&gt;Severity disagreements are normal in bug bounty programs.&lt;/p&gt;

&lt;p&gt;My concern is the recurring outcome across my own valid WEMIX submissions.&lt;/p&gt;

&lt;p&gt;Reports backed by reproducible proofs and concrete protocol behavior were repeatedly assigned substantially lower final severities.&lt;/p&gt;

&lt;p&gt;This report was submitted as Major.&lt;/p&gt;

&lt;p&gt;The proof showed a partner peer making the target execute an attacker-signed transaction as though it came from a victim, producing the exact state-root divergence required for invalid block production.&lt;/p&gt;

&lt;p&gt;It was classified as Low.&lt;/p&gt;

&lt;p&gt;I cannot infer intent from that decision.&lt;/p&gt;

&lt;p&gt;I can document the repeated outcome and compare the final classification with the technical evidence.&lt;/p&gt;

&lt;p&gt;Calling it systematic downgrading describes that recurring pattern across my WEMIX submissions without claiming a motive.&lt;/p&gt;

&lt;p&gt;A technically convincing Low justification would need to explain why these effects are minor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Peer metadata replacing cryptographic sender recovery

The victim receiving the local gas and nonce effects

The attacker receiving those effects after fresh decoding

The same transaction bytes producing different roots

A block producer building a block honest nodes reject

A partner peer being able to trigger the poisoned trust path
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without addressing those facts, the Low classification does not match the proof.&lt;/p&gt;

&lt;h2&gt;
  
  
  The correct fix
&lt;/h2&gt;

&lt;p&gt;The safest correction is to stop treating &lt;code&gt;TransactionEx.From&lt;/code&gt; as authoritative.&lt;/p&gt;

&lt;p&gt;The receiver must recover the sender locally from the signed transaction.&lt;/p&gt;

&lt;p&gt;A compatibility-preserving patch can keep the field as advisory metadata:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;TxExs2Txs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;signer&lt;/span&gt; &lt;span class="n"&gt;Signer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;txs&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;TransactionEx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;trustIt&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Transaction&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Transaction&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;txs&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Tx&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
                &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;

            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;trustIt&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt;
                &lt;span class="n"&gt;signer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sender&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;

            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
                &lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;From&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;sigCache&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="n"&gt;signer&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;signer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
            &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The essential rule is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Recover first

Compare second

Cache only after a match
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A mismatched &lt;code&gt;TransactionEx.From&lt;/code&gt; can then be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Ignored

Rejected

Recorded as peer misbehavior

Used for P2P penalties
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It must never become transaction authority.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the fix closes the bug
&lt;/h2&gt;

&lt;p&gt;The exploit requires this transition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Peer-supplied victim
→
Transaction.from cache
→
types.Sender
→
ApplyTransaction
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The patch breaks the chain at the first step.&lt;/p&gt;

&lt;p&gt;For a transaction signed by the attacker and carrying:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;From = victim
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;local recovery returns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Attacker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The values do not match.&lt;/p&gt;

&lt;p&gt;The victim is not stored in the sender cache.&lt;/p&gt;

&lt;p&gt;Fresh execution and partner-packet execution therefore resolve the same sender and produce the same state transition.&lt;/p&gt;

&lt;p&gt;The packet format does not need to change.&lt;/p&gt;

&lt;p&gt;The block format does not need to change.&lt;/p&gt;

&lt;p&gt;Only the trust decision changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Regression tests
&lt;/h2&gt;

&lt;p&gt;A complete correction should verify:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Matching &lt;code&gt;TransactionEx.From&lt;/code&gt; may populate the cache only after local cryptographic recovery&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Mismatched metadata never becomes &lt;code&gt;Transaction.from&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;trustIt = false&lt;/code&gt; and &lt;code&gt;trustIt = true&lt;/code&gt; resolve the same sender for a mismatched packet&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Partner conversion and fresh decoding produce the same gas payer, nonce changes, and state root&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The txpool resolver never persists unverified peer metadata&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Nil or malformed extended transactions are handled safely&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A metadata mismatch may trigger the configured peer response without affecting transaction execution&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The central invariant is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Every sender used by txpool, block production, and state transition must be derived locally from the signed transaction.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Broader lessons
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Trusted peers still provide untrusted data
&lt;/h3&gt;

&lt;p&gt;A partner may be allowed to use specialized network messages.&lt;/p&gt;

&lt;p&gt;Every consensus-relevant field must still be verified.&lt;/p&gt;

&lt;h3&gt;
  
  
  Caches can become security boundaries
&lt;/h3&gt;

&lt;p&gt;A sender cache looks like a performance optimization.&lt;/p&gt;

&lt;p&gt;Once downstream code treats it as authoritative, poisoning that cache changes execution.&lt;/p&gt;

&lt;h3&gt;
  
  
  Transaction hashes do not cover external metadata
&lt;/h3&gt;

&lt;p&gt;The transaction hash remained the same because &lt;code&gt;TransactionEx.From&lt;/code&gt; was outside the signed payload.&lt;/p&gt;

&lt;p&gt;That made the mismatch easy to hide behind an otherwise valid transaction.&lt;/p&gt;

&lt;h3&gt;
  
  
  Honest rejection is still an impact
&lt;/h3&gt;

&lt;p&gt;The network rejecting the invalid block prevents canonical corruption.&lt;/p&gt;

&lt;p&gt;The targeted producer still loses its block.&lt;/p&gt;

&lt;p&gt;Consensus safety and producer availability are related but distinct properties.&lt;/p&gt;

&lt;h3&gt;
  
  
  Local and fresh decoding must be equivalent
&lt;/h3&gt;

&lt;p&gt;A transaction received through a special packet must execute identically to the same transaction decoded from canonical block bytes.&lt;/p&gt;

&lt;p&gt;Any difference is a consensus warning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The transaction was signed by the attacker.&lt;/p&gt;

&lt;p&gt;The partner packet claimed the sender was a victim.&lt;/p&gt;

&lt;p&gt;The claimed address was not signed.&lt;/p&gt;

&lt;p&gt;It did not change the transaction bytes.&lt;/p&gt;

&lt;p&gt;It did not change the transaction hash.&lt;/p&gt;

&lt;p&gt;With:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;trustIt = false
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;WEMIX recovered the attacker.&lt;/p&gt;

&lt;p&gt;With:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;trustIt = true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;WEMIX returned the victim from the sender cache.&lt;/p&gt;

&lt;p&gt;The poisoned execution charged gas and nonce to the victim.&lt;/p&gt;

&lt;p&gt;A fresh execution of the same bytes charged gas and nonce to the attacker.&lt;/p&gt;

&lt;p&gt;The state roots differed.&lt;/p&gt;

&lt;p&gt;A target producer that committed the poisoned root could therefore build a block that honest nodes reject.&lt;/p&gt;

&lt;p&gt;The program classified the report as Low and paid $100.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnxv8r5waw3a8ejuc5n76.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnxv8r5waw3a8ejuc5n76.png" alt=" " width="709" height="21"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft8gcih0fujz2523nsoty.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft8gcih0fujz2523nsoty.png" alt=" " width="716" height="34"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The demonstrated impact was Major.&lt;/p&gt;

&lt;p&gt;The report did not claim network accepted theft, permanent chain split, or arbitrary-peer exploitation.&lt;/p&gt;

&lt;p&gt;It demonstrated a narrower and directly proven failure:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A malicious partner peer could replace cryptographic sender recovery with unauthenticated metadata, creating state-root divergence that makes a producer’s block invalid.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A peer-controlled cache entry that changes the state root is not a Low severity issue.&lt;/p&gt;

</description>
      <category>smartcontract</category>
      <category>blockchain</category>
      <category>security</category>
      <category>web3</category>
    </item>
  </channel>
</rss>
