pair oracle: price_a_cumulative can saturate u128, permanently bricking observe() (and likely swaps) on a live pair #1322
Labels
No labels
agent:fix_bugfix
agent:fix_conflicts
agent:fix_security
agent:gap_analysis
agent:implement
agent:implement
agent:implement
agent:open_issues
agent:ready
agent:research
agent:security_audit
agent:verify
architecture
backend
blocker:hybrid
blocker:launch
blocker:limit-orders
blocker:v2
block:log_only
block:security
bug
ci
contracts
correctness
deploy
dev
devops
docs
documentation
duplicate
e2e
enhancement
epic
feature
frontend
functional-completion
gas
good first issue
governance
help wanted
high-risk
hooks
hybrid
indexer
infra
infrastructure
integrators
invalid
launch-blocker
limit-orders
localnet
localterra
low priority
missing-implementation
needs-design
ops
performance
priority
high
priority
medium
product
qa
QA
question
ready
ready
research
scripts
security
security-hardening
smartcontracts
tech-debt
testing
ux
UX
v2
verification
wontfix
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
code/cl8y-dex-terraclassic#1322
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
On one live economic pair,
observe{seconds_ago:[0,N]}now reverts for every N with "Oracle: Cannot Add with 340144359629112943994362291128760055446 and ". The first operand isprice_a_cumulativeat 99.96% ofu128::MAX; the second grows with wall-clock, so the sum overflows and the condition is unrecoverable — the accumulator is monotonic. The last stored observation is timestamped ~2h before the query began failing, which matches the remaining headroom divided by the accrual rate. Nine other pairs on the same factory are below 0.001% of u128.Two consequences worth separating: (1) any consumer using
observe()as a TWAP reference loses it silently — a client that treats a query error as "no data" will fall through to an unguarded path; (2) if the swap handler performs the same checked add when writing the ring buffer, the pair becomes untradeable through the pool, permanently, with LP still deposited.Suggested directions: saturating or wrapping accumulation with delta-based reads (the Uniswap V2 convention), a wider accumulator, or normalising the price by token decimals before accumulating so that pairs with a large decimal asymmetry do not accrue orders of magnitude faster than others.
Approved for fix
Repair note
One issue. The query failure and the execute lock are the same
checked_addonprice_*_cumulative. Do not split them.Not a duplicate, and not already implemented.
wrapping_add/wrapping_subare not used on this oracle. Sibling tickets stay separate:Decimal::from_ratiopanic when the reserve ratio cannot be aDecimal. Execute already skips.price × dtdoes not fit inu128(price_times_dt) even though the ratio is aDecimal. Wrapping the cumulative does not fix that. Do not fold #1224 into this ticket or mark it done from this work.No other open issue tracks cumulative saturation.
Current codebase
Every pair stores an arithmetic-mean TWAP as
Uint128cumulatives of CosmWasmDecimalspot (reserve_b / reserve_aand the reciprocal), scaled by 1e18.oracle_updateruns before reserve writes on the only three paths that saveRESERVES: swap (pool and hybrid, including a book take), provide, and withdraw (smartcontracts/contracts/pair/src/contract.rs). Limit place, cancel, claim, and reprice do not calloracle_updateand do not writeRESERVES.After the #465 / #1231 ratio skip, both execute and Observe still do:
price_times_dt(smartcontracts/packages/dex-common/src/oracle.rs) —price.atomics() * dtviachecked_mul.last_cumulative.checked_add(delta)— execute prefixes the errorprice_a overflow:/price_b overflow:; Observe storese.to_string()with no prefix.ContractError::Oraclerenders asOracle: {reason}. The reported stringOracle: Cannot Add with 340144359629112943994362291128760055446 and <accrue-to-now term>matches the Observe mapping, not the execute prefix. That cumulative is about 99.96% ofu128::MAX(340282366920938463463374607431768211455); headroom is about1.38e35.query_observeuses?perseconds_agoentry, so one overflowing “now” point fails the whole query, including historical points that would have fit. That matches “every N reverts.”u128::MAXis about3.40e38. A raw ratio whose atomics accrue near1e30–1e31per second (18-vs-6 decimal asymmetry, plus a premium or imbalance, still belowDecimal::MAX) fills aUint128in months, not geological time. Nine calmer pairs staying under 0.001% ofu128fits that. Docs currently call this overflow “handled gracefully with errors” (dex-commonoracle module comment anddocs/twap-oracle.md). The error is the brick.Charts is the in-repo consumer.
getTwapPrices(frontend-dapp/src/services/terraclassic/oracle.ts) catches a failedobserveand returns null prices, so/chartsshows TWAP building… rather than a hard error.computeTwapPriceDecimalStringreturns null whencumEnd < cumStart. TWAP is display-only (quote per base, not a swap belief price).compute_twap_priceindex-commonhas the same end-before-start reject. Historical interpolation uses plainafter - beforeandbefore + diff * dt / span.There is no storage migration that rewrites cumulatives. Pair
migrateleavesOBSERVATIONSin place.Why a new implementation is needed
The accumulator is monotonic and the add is checked, so once
last + price×dtexceedsu128::MAXthe condition does not heal. Wall-clock only grows.dt > 0and non-zero reserves reverts those messages withOracle: price_a overflow: …orprice_b overflow: …. LP cannot exit. Router hops that call the pair fail with it. Limit escrow can still be cancelled or claimed, because those messages never touch the oracle. This is a permanent liveness lock of pool reserves until a governance pair wasm migrate, not a drain of other users’ tokens.Skipping the sample forever (return
Okand freeze the cumulative) would unbrick trading and still publish a stale integral. Saturating atMAXdrops every later second and makes TWAP read as zero across the clamp. Neither is the Uniswap-style fix. Rescaling by token decimals, or widening the stored type tou256, changes the publicUint128Observe ABI and makes pre-change observations incomparable. Those are different products.Constraints and guardrails
Ok, Observe returns the last stored cumulatives). Do not clamp toDecimal::MAX. Do not bring back panickingDecimal::from_ratio.price_times_dtreturnsErr, do not wrap a truncated product into the cumulative. A single delta that does not fit inu128must stay out of this ticket.2^128only. The window integral is meaningful when that integral itself fits inu128(true for Charts-length windows at the live pair’s accrual; false for a #1224-sized single step).seconds_agosemantics.10^(decimals0 − decimals1)in this ticket. Human scaling stays in the dApp (rawLimitPriceToHuman/ #564).Observationtou256in this ticket.RESERVESorOBSERVATIONSwrites from Observe.unwrapon the cumulative add.Relevant files
smartcontracts/contracts/pair/src/contract.rs—oracle_update,oracle_observe_single,query_observe; call sites inexecute_swap,execute_provide_liquidity,execute_withdraw_liquiditysmartcontracts/packages/dex-common/src/oracle.rs—price_times_dt,compute_twap_price, overflow commentsmartcontracts/contracts/pair/src/error.rs—ContractError::Oraclesmartcontracts/tests/src/lib.rs—oracle_tests(plain subtraction of Observe results)frontend-dapp/src/services/terraclassic/oracle.ts—computeTwapPriceDecimalString,getTwapPricesfrontend-dapp/src/services/terraclassic/__tests__/oracle.test.tsfrontend-dapp/src/pages/ChartsPage.tsx— TWAP chips; nulls render as “TWAP building…”docs/twap-oracle.mddocs/contracts-security-audit.md(O1231 row must stay true; add this ticket beside it, do not rewrite O1231)skills/AGENTS_TWAP_OBSERVE_RATIO.md— pointer only, so later work does not “fix” this by weakening #1231Recommended direction
Use wrapping accumulation, the Uniswap V2 convention, on both cumulatives:
oracle_updateand Observe forward-extrapolation:wrapping_addof a delta thatprice_times_dtalready accepted. Advance the ring and timestamp the same way as today.compute_twap_price:wrapping_sub(end, start)so a window that crosses the modulus still yields the in-window integral. Then divide by elapsed time andDecimal::from_atomics(..., 18)as today. Remove the hard error that treatsend < startas corruption; that branch is the wrap, not a corrupt store.computeTwapPriceDecimalString: same wrapping sub on theu128modulus, so a Charts window that crosses the modulus still shows the pair TWAP. Keep null for a non-positive elapsed time and a zero average.Reject, for this ticket: saturating add, skip-and-freeze once near
MAX, decimal normalization, and a wider stored integer.After the pair is migrated, the already-stored cumulative near
MAXdoes not need a rewrite. The next swap’s delta wraps and the reserve write commits.Acceptance criteria
price_a_cumulativeat340144359629112943994362291128760055446(and the symmetricprice_bcase) plus a representable spot and adtwhoseprice × dtfits inu128but whose sum does not:oracle_updatereturnsOk, storeswrapping_add, and advances the ring timestamp. It must not returnOracle: price_a overflow/price_b overflow.QueryMsg::Observewithseconds_agothat includes0and an in-buffer historical offset returns JSON for every offset. The “now” cumulative is the wrapped sum. Historical points that do not add a new delta stay on the stored curve.RESERVES(multitest). They must not revert withContractError::Oraclefor this add.2^128once:compute_twap_priceandcomputeTwapPriceDecimalStringreturn that integral divided by elapsed time, not null and not a value near2^128 / dt.dt > 0still advances cumulatives; Observe JSON keys stayprice_a_cumulatives/price_b_cumulatives.price_times_dtoverflow (delta itself does not fit) is unchanged by this ticket and is still not written as a wrapped truncated delta. #1224 remains open.block_time <= last_ts), zero reserves, and the first zero-cumulative seed behave as they do now.2^128and the “integral must fit inu128” window rule. O1231 text is not weakened.Test plan
Unit (pair + dex-common)
2^128(sum ≡ 0): stored cumulative is zero; the next observation can add again.seconds_ago = 0attarget == latest.timestamp: returns stored cumulatives and does not add.1vsu128::MAX): still skip, cumulatives unchanged.compute_twap_pricefor a non-wrapping window matches existing tests; wrapping window matches(end - start) mod 2^128;time_elapsed == 0still errors.price_times_dtoverflow still errors andoracle_updatedoes not persist a new observation in that case (pin current #1224 behavior so this change cannot wrap the truncated product).Multitest (
smartcontracts/testsoracle module)MAX - smallwith a fitting price, advance one block, swap, provide, and withdraw: each succeeds and reserves change.[0, window]across the wrap: both cumulatives present; client-side wrapping sub reconstructs the price.Frontend
computeTwapPriceDecimalString: existing non-wrap cases;cumEnd < cumStartwhere the wrapping diff is the real window integral returns that price; elapsed ≤ 0 and zero average stay null.getTwapPricesstill returns null prices whenobservethrows, and still returns a price when Observe succeeds across a wrap (mock the response).Paths that must keep working without an oracle write
MAX(they do not calloracle_update). Do not require them to start updating the oracle.Attack, hack, and abuse
These are lock / stale-oracle / bad-integral risks, not a pool drain. Tests seed storage or use the unit harness. Do not add a mainnet reserve-skew walkthrough.
checked_from_ratio) increases atomics per second. On a thin pool that is cheap relative to TVL; on a deep pool it is the existing TWAP-manipulation cost. After the wrap fix, the same trade must not freeze swap or LP exit. Assert executeOkand reserves moved. Assert the recorded delta is the fullprice × dt, not a saturated stub.atomics * dtexceedsu128must not storewrapping_mul’s low bits. That would understate TWAP and look like a successful observation. Expected: no new observation from this ticket’s math (current error or, later, #1224’s skip — not a wrapped lie).MAXand that a later second still changes the integral. Frozen TWAP while spot moves is a stale-price bug for any future consumer that treats Observe as a mark.compute_twap_priceis only defined for two snapshots of the same counter. A unit test documents that a single modulus crossing reconstructs the small integral. Do not add a public setter. Migrate must not accept a caller-supplied cumulative.?failure would again blank everyseconds_agolist.Decimal::from_atomicsmay still succeed. Keep this helper on pair-oracle snapshots in time order. Charts must not feed the result into swapbelief_price(it does not today; add no such wiring).price_*_cumulativeexceptoracle_update’s wrapping add and the existing first-observation zero seed.Verification
cd smartcontracts && cargo test -p cl8y-dex-pair oracle_overflow -- --nocapturecd smartcontracts && cargo test -p cl8y-dex-pair oracle_observe -- --nocapturecd smartcontracts && cargo test -p dex-common --lib oracle -- --nocapturecd smartcontracts && cargo test -p cl8y-dex-tests oracle -- --test-threads=1make test-contractsmake test-frontendscoped tooracle.test.tsand Charts TWAP if those tests changemake verify-issue-1231still passes (it already runs the #465oracle_overflowtests and the #1231 Observe tests)price_times_dtexecute brick stays open; this issue does not close itdocs/twap-oracle.mddescribes modulo2^128; the “handled with errors” sentence is goneOBSERVATIONS. Out of scope to execute here.Instead zero extend u128 into u256
Wasm from #1323 is on main (
c17e71d3).make verify-issue-1322passed before merge, and Woodpeckerci/woodpecker/pr/woodpeckersucceeded on the updated head. The live pair is still the old code. Columbus-5 migrate to cw2 1.18.0, keepingOBSERVATIONS, is #1324.+1 wasm execute still reverts on the pair oracle price_a cumulative checked-add when the running sum is a very large integer; same defect as this ticket, including the execute path already tracked here. No new constraint.
Verified the merged Uint256 fix (#1323) on origin/main at
54c4868e: make verify-issue-1322 passed all 18 checks. O1322-1–O1322-8 are documented and cross-linked with pair/oracle and Charts code, tests, TWAP docs, audit docs, and the third-party agent skill.Remaining operational follow-ups:
No live-chain migration was run here.