Early rate$2,400 of senior audit time for $500. Early members keep the rate as it climbs.$2,400 of senior audit time for $500See how →
F-2026-0001·decimal-mismatch

Protocol constants assume 18-decimal units but the settlement token is 6-decimal, locking every disputed deal

Fixedescrowarbitrationdispute-resolution
TL;DR

Every stake minimum, deal band and fee constant is written for an 18-decimal token, but the production settlement token cNGN has 6 decimals. No juror can meet a stake minimum, so every disputed deal's principal is locked with no revert or event to signal it.

Severity
CRITICAL
Impact
HIGH
Likelihood
HIGH
Method
MManual review
CAT.
Complexity
LOW
Exploitability
HIGH
02Section · Description

Description

The production settlement token is cNGN on Polygon (0x52828daa48C1a9A06F37500882b42daf0bE04C3B), which exposes 6 decimals. Every stake minimum, deal-value band, and fee constant in the protocol is written as an 18-decimal literal:

solidity
MIN_STAKE_COMMUNITY = 25_000e18; MAX_DEAL_COMMUNITY = 500_000e18;
MIN_STAKE_VERIFIED = 200_000e18; MAX_DEAL_VERIFIED = 5_000_000e18;
MIN_STAKE_EXPERT = 500_000e18; COMMERCIAL_EXPERT_JUROR_GATE = 20_000_000e18;
tierFeeCommunity = 2_000e18; tierFeeExpertCommercial = 35_000e18;

docs/PROTOCOL_SPEC.md §6.3.2 records the assumption these were written under: "deal values use token base units (18 decimals)." Against a 6-decimal token each is overstated by a factor of 10^12, and nothing in the deployment path detects it — EscrowFactory.addStablecoin accepts any token address without reading decimals().

Vulnerable Scenario: The following steps illustrate the issue:

  1. ArbitrationPool is deployed with the 6-decimal cNGN as _cNGN.
  2. A prospective juror calls stake(0, amount). minStakeForTier(0) requires 25_000e18 base units — 25 quadrillion actual cNGN. Every realistic amount reverts BelowMinStake, so jurorRegistry stays permanently empty.
  3. A buyer funds a deal, delivery fails, and the buyer calls raiseDispute. The escrow enters Disputed.
  4. openCase requests VRF. _selectJurors finds no eligible juror and returns an empty panel, so rawFulfillRandomWords re-queues the pending case and emits InsufficientEligibleJurors.
  5. retryStuckCase repeats step 4 indefinitely. Per docs/AUDIT_GATE.md DD-1 there is no time-based or unilateral exit from Disputed, so the buyer's principal stays locked.

Tier routing and fee sizing break independently of the empty registry: caseTier classifies every deal as Community because MAX_DEAL_COMMUNITY is effectively 500 quadrillion cNGN, COMMERCIAL_EXPERT_JUROR_GATE becomes unreachable, _sqrtFee always returns its floor, and the withdrawFeePool solvency floor at ArbitrationPool.sol#L444 is overstated by the same factor.

03Section · Impact

Impact

Disputed principal is permanently locked for every deal that reaches arbitration, and the failure is silent — no revert or event distinguishes it from the empty-pool state that docs/AUDIT_GATE.md accepts as normal operation.

The test suite cannot surface it. contracts/MockUSDT.sol#L9 declares 18 decimals, and test/helpers.js already mixes units — deal amounts at parseUnits("1000", 6) against stakes and fees at parseUnits("25000", 18) — so the mismatched assumption is built into the fixtures.

04Section · Recommendation

Recommendation

decimals() on the deployed token was read as 6 against three independent Polygon RPC endpoints on 2026-08-06.

Express every threshold, minimum, cap and fee in the settlement token's own unit rather than as hardcoded e18 literals, and assert IERC20Metadata(_cNGN).decimals() against the value they were compiled for in the ArbitrationPool constructor, so a mismatched deployment reverts instead of succeeding silently. Deriving the unit at runtime is not available here: the constants live in ArbitrationPoolLib and are consumed by ArbitrationPoolSelectLib and ArbitrationPoolTallyLib, which are deployed and linked as external libraries and cannot read a pool immutable.

Apply the same decimals check in EscrowFactory.addStablecoin and updateStablecoin. Without it a second whitelisted currency can be denominated in a unit the pool's bands were never compiled for, and every deal in that currency routes against the wrong tier.

The √d coefficients at ArbitrationPoolLib.sol#L67-L69 must be re-derived against the new base unit rather than rescaled alongside the thresholds, because _sqrtFee scales with sqrt(unit) and not with unit. Change contracts/MockUSDT.sol to 6 decimals and make test/helpers.js use one consistent unit so that a recurrence fails the suite.

05Section · Resolution

Resolution

Fixed. The pool constructor and the factory both reject a settlement token whose decimals() is not 6. Re-running the original test against the fixed code, a juror onboards normally with the 6-decimal token and a full panel seats on a ₦200,000 deal; the 18-decimal control can no longer be deployed at all.

06Section · Affected files

Affected files

  • contracts/lib/ArbitrationPoolLib.sol#L16-L25
  • contracts/lib/ArbitrationPoolLib.sol#L67-L69
  • contracts/lib/ArbitrationPoolLib.sol#L140-L150
  • contracts/ArbitrationPool.sol#L184-L188
  • contracts/ArbitrationPool.sol#L442-L450
  • contracts/EscrowFactory.sol#L320-L357
Status
Fixed
F-2026-0001