Menese DeFi Team evaluation of MULTI/DEX, Part 2/2: value, integrity, and the oracle input path
Continuing our evaluation (Part 1 covered load-bounding). This group is about places
where a stated guarantee (conservation of value, ledger integrity, oracle robustness)
holds almost everywhere but has one seam. None require a malicious controller; all are
reproduced on the published tip (commit 0241cbafb779415e45dc56f0fabc1bbbb6ba9a5d). Each
item follows the report shape SECURITY.md asks for: affected surface, steps to reproduce,
and the impact we believe it has.
One note up front: your mulDiv is arbitrary-precision Nat, so the Solidity
mulDiv-overflow class (the one that forces Uniswap's FullMath) does not exist here.
The items below are narrower.
The two pure-function findings here (items 3 and 4) ship as runnable Motoko tests:
https://gist.github.com/KYounesMercatura/3bb91a214ed9419fd17423915cceb36f — drop
ReproR5.test.mo and ReproParser.test.mo into tests/ and run mops test ReproR5 /
mops test ReproParser.
1. The insurance tranche's share math is asymmetric; a JIT round-trip mints value from the fund (a ported ERC-4626 detail)
Where: stake mints with the virtual-share offset in stakeInsurance
(src/backend/main.mo:10625-10631), using the same OpenZeppelin pattern as
VaultMath.mintAmount. Unstake redeems at the raw ratio in unstakeInsurance
(main.mo:10668), with no offset. seedInsuranceFund (main.mo:10697) is also raw.
Why it matters: ERC-4626's virtual-share offset is designed to apply to both conversion
directions so that a mint-then-burn round-trip is value-neutral. Applied to mint only,
the round-trip is not the identity: redeem(mint(A)) > A whenever the share value exceeds
1.0. Share value exceeds 1.0 as the normal state, because liquidation penalties accrue to
the fund without minting shares (settleInsuranceArrears, main.mo:5786-5793). So a
staker can enter just before a penalty lands and exit just after, skimming a slice of the
penalty that is meant to reward the long-term stakers who bear the bad-debt risk. There is
no exit fee and no minimum stake duration on this tranche. The AMM LP tranche has the same
asymmetry, but its LP_EXIT_FEE_BPS = 40 dominates the sub-basis-point edge, so it is not
profitable there; the fix already exists in your codebase.
Reproduction (local replica, exact to the base unit):
-
Bootstrap the insurance pool and stake a seed so shareSupply > 0.
-
Move the share value off parity (in production this is the normal state via penalty
accrual; on a fresh replica we credited the fund principal directly to reach
shareValueUsd = 1.03333333).
-
Stake A = 1_000_000_000_000 and immediately redeem the shares minted:
shareValue at mint : 1.03333333
staked A : 1_000_000_000_000
returned V : 1_000_000_104_165 (+104_165 base units from nothing)
pool buffer : fell by exactly 104_165 (the existing stakers funded it)
-
Confirmed at four share values (1.033, 1.05, 1.083, 1.15), each matching the closed
form gain = A*V*(P-S) / ((P+V)*(S+m)) to the base unit.
Why we flag it despite the small magnitude: it is permissionless, repeatable, and it
targets the one fund whose whole job is solvency; over time it pushes out the honest
long-term stakers the backstop depends on. It sits in your stated "mints, loses, or
double-counts value" category.
Suggested direction: apply the virtual-share offset to the redeem side too, or add an
insurance exit fee / minimum duration mirroring LP_EXIT_FEE_BPS. The simplest fix is
symmetry: offset on both conversions, or neither.
2. verifyChain skips one link per page and still reports ok = true
Where: src/backend/ArchiveCanister.mo:231-259.
Why it matters: the archive's promise is an append-only, offline-verifiable hash chain
under a certified head. verifyChain seeds var prev = null and only compares each
event's prevHash against the previous event in the same call. So the first event of
every page has its inbound link unchecked, and nextSeq resumes at an event whose link is
skipped again. A paged audit of N events at page size P leaves ceil(N/P) - 1 links
unverified, and returns ok = true throughout. Corruption sitting exactly on a page
boundary is invisible to the one function built to detect it. This is a detection gap, not
an insertion path; appendBatch still enforces the chain on ingest, so an attacker would
need pre-existing ability to alter stored bytes.
Reproduction (live archive on a local replica, 56 events / 55 links):
| page size |
pages |
links verified |
needed |
silently unverified |
| 5 |
12 |
44 |
55 |
11 |
| 10 |
6 |
50 |
55 |
5 |
| 20 |
3 |
53 |
55 |
2 |
The gap equals pages - 1 at every page size, with ok = true each time. At the 10,000
page cap a 10M-event archive leaves ~999 links unchecked while reporting success.
Suggested direction: one line; when start > chainStartSeq, seed prev from
loadAt(globalIndex, start - base - 1) so each page re-checks its boundary link.
3. Outlier rejection is inoperative at the minimum source count you accept
Where: PRICE_MIN_SOURCES = 2 (src/backend/main.mo:12901) versus trimOutliers, which
returns the sample set untrimmed below 3 (lib/PriceFeed.mo:199-200,
if (n < 3) { return xs }).
Why it matters: the aggregator will accept a mark computed from as few as 2 readings, but
the plus/minus 2-sigma outlier filter only engages at 3 or more. So at exactly the minimum
you allow, the outlier rejection does nothing and a single rogue or glitching source
carries 50% weight of a mark that drives collateral valuation and liquidations. The
exposure window is precisely when sources are scarce, the moment robustness matters most.
Reproduction (mops test, pure PriceFeed functions):
- n=2, one honest (100) plus one rogue (200):
trimOutliers returns both untrimmed, and
the robust median is 150; the rogue moved the mark 50%.
- n=3, two honest (100) plus one rogue (200): trimmed, mark holds at 100.
- The only difference between the two is the sample count.
Suggested direction: either raise PRICE_MIN_SOURCES to 3 so a published mark always
passes through the filter, or apply a 2-of-2 agreement bound (reject when two sources
disagree beyond a threshold) for the n=2 case.
4. The primary oracle parser drops scientific notation and first-matches keys
Where: parseLeadingFloat (lib/PriceFeed.mo:271-312) and numberAfterKey/findAfter
(:316-339), used by extractFromBody (:341) on the HTTPS-outcall bodies fetched in
main.mo:fetchFromSource (:11959). This is the primary mark path
(refreshMultiSourcePrice, main.mo:3039); the XRC canister is only a typed fallback
anchor and does not go through this parser.
Why it matters: parseLeadingFloat stops at the first non-digit, so a scientific-notation
value loses its exponent silently, and numberAfterKey returns the number after the first
occurrence of the key. Both feed the mark that drives liquidations and collateral value,
and they compound with item 3: at the 2-source minimum the outlier filter is off, so a
single misparsed source moves the median directly.
Reproduction (mops test, pure PriceFeed functions):
parseLeadingFloat("1.5e3") => 1.5 (true value 1500, 1000x low)
parseLeadingFloat("1.2e-8") => 1.2 (true value 0.000000012, ~1e8 high)
parseLeadingFloat("6.4E4") => 6.4 (true value 64000)
parseLeadingFloat("64046.03") => 64046.03 (control, plain decimal, correct)
numberAfterKey("{\"a\":\"99.90\",\"ticker\":{\"a\":\"2.29\"}}", "\"a\"") => 99.90 (intended 2.29)
This is latent for the currently-wired assets (BTC/ETH/SOL/ICP print plain decimals) and
becomes active if a low-priced asset is added, or a provider emits scientific notation or
reorders fields so the first key match is the wrong one.
Suggested direction: parse the exponent (or reject a value containing e/E explicitly
rather than truncating it), and anchor each extractor's key to its full path so a short
key cannot first-match an earlier field.
We are happy to open PRs; several of these are one- to few-line changes.
Filed by the Menese DeFi Team.
Menese DeFi Team evaluation of MULTI/DEX, Part 2/2: value, integrity, and the oracle input path
Continuing our evaluation (Part 1 covered load-bounding). This group is about places
where a stated guarantee (conservation of value, ledger integrity, oracle robustness)
holds almost everywhere but has one seam. None require a malicious controller; all are
reproduced on the published tip (commit
0241cbafb779415e45dc56f0fabc1bbbb6ba9a5d). Eachitem follows the report shape SECURITY.md asks for: affected surface, steps to reproduce,
and the impact we believe it has.
One note up front: your
mulDivis arbitrary-precisionNat, so the SoliditymulDiv-overflow class (the one that forces Uniswap'sFullMath) does not exist here.The items below are narrower.
The two pure-function findings here (items 3 and 4) ship as runnable Motoko tests:
https://gist.github.com/KYounesMercatura/3bb91a214ed9419fd17423915cceb36f — drop
ReproR5.test.moandReproParser.test.mointotests/and runmops test ReproR5/mops test ReproParser.1. The insurance tranche's share math is asymmetric; a JIT round-trip mints value from the fund (a ported ERC-4626 detail)
Where: stake mints with the virtual-share offset in
stakeInsurance(
src/backend/main.mo:10625-10631), using the same OpenZeppelin pattern asVaultMath.mintAmount. Unstake redeems at the raw ratio inunstakeInsurance(
main.mo:10668), with no offset.seedInsuranceFund(main.mo:10697) is also raw.Why it matters: ERC-4626's virtual-share offset is designed to apply to both conversion
directions so that a mint-then-burn round-trip is value-neutral. Applied to mint only,
the round-trip is not the identity:
redeem(mint(A)) > Awhenever the share value exceeds1.0. Share value exceeds 1.0 as the normal state, because liquidation penalties accrue to
the fund without minting shares (
settleInsuranceArrears,main.mo:5786-5793). So astaker can enter just before a penalty lands and exit just after, skimming a slice of the
penalty that is meant to reward the long-term stakers who bear the bad-debt risk. There is
no exit fee and no minimum stake duration on this tranche. The AMM LP tranche has the same
asymmetry, but its
LP_EXIT_FEE_BPS = 40dominates the sub-basis-point edge, so it is notprofitable there; the fix already exists in your codebase.
Reproduction (local replica, exact to the base unit):
Bootstrap the insurance pool and stake a seed so
shareSupply > 0.Move the share value off parity (in production this is the normal state via penalty
accrual; on a fresh replica we credited the fund principal directly to reach
shareValueUsd = 1.03333333).Stake
A = 1_000_000_000_000and immediately redeem the shares minted:Confirmed at four share values (1.033, 1.05, 1.083, 1.15), each matching the closed
form
gain = A*V*(P-S) / ((P+V)*(S+m))to the base unit.Why we flag it despite the small magnitude: it is permissionless, repeatable, and it
targets the one fund whose whole job is solvency; over time it pushes out the honest
long-term stakers the backstop depends on. It sits in your stated "mints, loses, or
double-counts value" category.
Suggested direction: apply the virtual-share offset to the redeem side too, or add an
insurance exit fee / minimum duration mirroring
LP_EXIT_FEE_BPS. The simplest fix issymmetry: offset on both conversions, or neither.
2.
verifyChainskips one link per page and still reportsok = trueWhere:
src/backend/ArchiveCanister.mo:231-259.Why it matters: the archive's promise is an append-only, offline-verifiable hash chain
under a certified head.
verifyChainseedsvar prev = nulland only compares eachevent's
prevHashagainst the previous event in the same call. So the first event ofevery page has its inbound link unchecked, and
nextSeqresumes at an event whose link isskipped again. A paged audit of N events at page size P leaves ceil(N/P) - 1 links
unverified, and returns
ok = truethroughout. Corruption sitting exactly on a pageboundary is invisible to the one function built to detect it. This is a detection gap, not
an insertion path;
appendBatchstill enforces the chain on ingest, so an attacker wouldneed pre-existing ability to alter stored bytes.
Reproduction (live archive on a local replica, 56 events / 55 links):
The gap equals
pages - 1at every page size, withok = trueeach time. At the 10,000page cap a 10M-event archive leaves ~999 links unchecked while reporting success.
Suggested direction: one line; when
start > chainStartSeq, seedprevfromloadAt(globalIndex, start - base - 1)so each page re-checks its boundary link.3. Outlier rejection is inoperative at the minimum source count you accept
Where:
PRICE_MIN_SOURCES = 2(src/backend/main.mo:12901) versustrimOutliers, whichreturns the sample set untrimmed below 3 (
lib/PriceFeed.mo:199-200,if (n < 3) { return xs }).Why it matters: the aggregator will accept a mark computed from as few as 2 readings, but
the plus/minus 2-sigma outlier filter only engages at 3 or more. So at exactly the minimum
you allow, the outlier rejection does nothing and a single rogue or glitching source
carries 50% weight of a mark that drives collateral valuation and liquidations. The
exposure window is precisely when sources are scarce, the moment robustness matters most.
Reproduction (
mops test, purePriceFeedfunctions):trimOutliersreturns both untrimmed, andthe robust median is 150; the rogue moved the mark 50%.
Suggested direction: either raise
PRICE_MIN_SOURCESto 3 so a published mark alwayspasses through the filter, or apply a 2-of-2 agreement bound (reject when two sources
disagree beyond a threshold) for the n=2 case.
4. The primary oracle parser drops scientific notation and first-matches keys
Where:
parseLeadingFloat(lib/PriceFeed.mo:271-312) andnumberAfterKey/findAfter(
:316-339), used byextractFromBody(:341) on the HTTPS-outcall bodies fetched inmain.mo:fetchFromSource(:11959). This is the primary mark path(
refreshMultiSourcePrice,main.mo:3039); the XRC canister is only a typed fallbackanchor and does not go through this parser.
Why it matters:
parseLeadingFloatstops at the first non-digit, so a scientific-notationvalue loses its exponent silently, and
numberAfterKeyreturns the number after the firstoccurrence of the key. Both feed the mark that drives liquidations and collateral value,
and they compound with item 3: at the 2-source minimum the outlier filter is off, so a
single misparsed source moves the median directly.
Reproduction (
mops test, purePriceFeedfunctions):This is latent for the currently-wired assets (BTC/ETH/SOL/ICP print plain decimals) and
becomes active if a low-priced asset is added, or a provider emits scientific notation or
reorders fields so the first key match is the wrong one.
Suggested direction: parse the exponent (or reject a value containing
e/Eexplicitlyrather than truncating it), and anchor each extractor's key to its full path so a short
key cannot first-match an earlier field.
We are happy to open PRs; several of these are one- to few-line changes.
Filed by the Menese DeFi Team.