diff --git a/dex/pair/safe-price-mechanism.md b/dex/pair/safe-price-mechanism.md index b00d00eee..b15d2d3d0 100644 --- a/dex/pair/safe-price-mechanism.md +++ b/dex/pair/safe-price-mechanism.md @@ -1,722 +1,418 @@ -# Safe Price Mechanism Documentation +# Safe Price Integration Guide -## Table of Contents +This document describes how external applications and smart contracts should consume the xExchange Safe Price mechanism. It covers the public interfaces, time units, query behavior, legacy-observation compatibility, and integration constraints. Deployment and protocol-upgrade procedures are intentionally outside its scope. + +## Contents 1. [Overview](#overview) -2. [Architecture and Deployment](#architecture-and-deployment) -3. [Protocol Upgrade: Time-Based Safe Price](#protocol-upgrade-time-based-safe-price) -4. [How It Works](#how-it-works) -5. [Price Observation Structure](#price-observation-structure) -6. [Recording Mechanisms](#recording-mechanisms) -7. [Available Endpoints](#available-endpoints) -8. [Configuration](#configuration) -9. [Usage Examples](#usage-examples) -10. [Technical Details](#technical-details) +2. [Quick Start](#quick-start) +3. [Integration Architecture](#integration-architecture) +4. [Time and Observation Model](#time-and-observation-model) +5. [Query Semantics](#query-semantics) +6. [Public Endpoints](#public-endpoints) +7. [LP-Supply Compatibility](#lp-supply-compatibility) +8. [Router Configuration](#router-configuration) +9. [Errors and Edge Cases](#errors-and-edge-cases) +10. [Integration Checklist](#integration-checklist) +11. [Appendix: Direct Storage Reading](#appendix-direct-storage-reading) ## Overview -The Safe Price mechanism is a Time-Weighted Average Price (TWAP) oracle implementation designed to provide manipulation-resistant price data for DEX pairs. It accumulates Pair reserves over elapsed time and finalizes a cumulative observation on the first eligible Pair operation after the configured interval elapses. A sufficiently long lookback reduces the influence of short-lived price manipulation, but does not make every integration safe by itself. - -### Key Benefits - -- **Manipulation Resistance**: TWAP calculations reduce the influence of short-lived price manipulation -- **Historical Data**: Maintains up to 65,536 price observations -- **Flexible Queries**: Supports both round-based and timestamp-based price lookups -- **Gas Efficiency**: Configurable recording intervals optimize gas costs -- **Millisecond Timeline**: Preserves elapsed-time weighting across the planned `6,000` to `600` millisecond round-duration transition - -## Architecture and Deployment - -### Central Safe Price View Contract +Safe Price is a time-weighted average price (TWAP) mechanism for xExchange Pairs. It accumulates reserves over elapsed time and uses cumulative differences to calculate token-to-token quotes and LP-token values over a requested interval. -**IMPORTANT**: dApps should query safe price data through the **Central Safe Price View Contract**, not individual pair contracts. This is the only recommended approach for production use. +For production use, integrators should validate the Pair address, choose a suitable lookback, handle unavailable history explicitly, and define how Pair pause state affects their own application. -#### Why Use the Central View Contract +Key properties: -- **Unified Interface**: Single contract address for all safe price queries across all pairs -- **Pair Address as Parameter**: Query any pair by passing its address as a parameter -- **View-Only Operations**: The central contract exposes read-only views. Off-chain queries do not submit transactions; synchronous on-chain consumers still pay execution gas -- **Stable Integration**: Contract address remains constant even as new pairs are added -- **Cross-Pair Queries**: Easily query multiple pairs without managing multiple contract addresses +- cumulative weights and positive observation timestamps use milliseconds; +- finalized history is bounded to 65,536 observations per Pair; +- one additional `current_price_observation` stores the latest cumulative state between finalizations; +- observations are updated by Pair operations, not by the passage of time alone; +- timestamp queries are independent of round-duration inference; +- round queries remain available for compatibility, with the limitations described below. -#### How It Works +## Quick Start -The Safe Price View contract reads price observation data directly from individual pair contract storage. When you call an endpoint with a `pair_address` parameter, it: +1. Obtain the canonical Safe Price View and Router addresses for the target network and release. +2. Resolve the Pair through `Router.getPair(first_token_id, second_token_id)` and verify that the Pair's `getRouterManagedAddress`, `getFirstTokenId`, and `getSecondTokenId` values match the expected Router and token identifiers. +3. Choose an endpoint from the table below. New timestamp integrations should use an `Ms` endpoint. +4. Query the Safe Price View, passing the verified Pair address as the first argument. +5. Validate that the returned token identifiers and amounts match the application's expected assets and minimum-value policy. +6. Treat a rejected query as unavailable oracle data and apply the application's explicit reject, defer, pause, or independently secured fallback policy. -1. Reads the pair's safe price observations from storage -2. Performs all calculations (interpolation, weighted averaging) -3. Returns manipulation-resistant price data +Use release metadata that binds each network and shard address to the matching Safe Price View ABI and code version. Treat this metadata as a required integration input and keep the addresses configurable. -All historical data remains stored in the pair contracts, while the view contract provides a centralized query interface. The central view accepts an arbitrary `pair_address` and does not authenticate it against the Router registry, so state-changing consumers must use a trusted Pair address. +For off-chain flows that perform several validation queries, read them from the same finalized block snapshot. A later transaction can observe newer oracle state, so state-changing logic should either query synchronously during execution or enforce its own limits and slippage constraints. -#### Deployment +### Which endpoint should I use? -The Safe Price View contract is built as a separate WASM module (`wasm-safe-price-view`) that exposes view endpoints from the pair contract's `SafePriceViewModule`. - -**Build artifact**: `safe-price-view.wasm`. Operators must separately record and verify the deployed contract address and code hash. +| Integration need | Recommended endpoint | +|---|---| +| Token quote over an explicit millisecond lookback | `getSafePriceByTimestampOffsetMs` | +| LP-token value over an explicit millisecond lookback | `getLpTokensSafePriceByTimestampOffsetMs` | +| Best available history, up to the Router-configured default | `getSafePriceByDefaultOffset` or `getLpTokensSafePriceByDefaultOffset` | +| Existing seconds-based integration | Compatibility endpoint without the `Ms` suffix | +| Existing round-based integration | Round endpoint, subject to the cadence limitations below | +| Custom cumulative-data processing | `getPriceObservation`; see its semantics before use | -#### Supernova Upgrade Order +## Integration Architecture -The migration must be executed in this order: +### Use the central Safe Price View -1. Rebuild the Router, Pair, Pair Full, and central Safe Price View WASM artifacts reproducibly. Record and verify every exact code hash before deployment. -2. Pause every Pair and every state-changing Safe Price consumer, including Farm Staking Proxy flows. Stop or temporarily revoke every whitelisted maintenance caller that can invoke a liquidity endpoint while a Pair is paused, including the buyback-and-burn flow. -3. Upgrade the Router, verify that both Safe Price configuration values are present in milliseconds and that `temporary_owner_period` is `30` seconds, and resume only the Router. Do not perform the upgrade while a newly created Pair is still using temporary-owner permissions to issue its LP token. -4. Deploy or upgrade the standard Pair template from the verified `pair.wasm`, update the Router template address if necessary, and verify its code hash. -5. Upgrade every standard Pair before Supernova activation while runtime round duration is still `6,000` milliseconds. Router-triggered Pair upgrades are asynchronous and have no callback, so verify every Pair individually. For each Pair with finalized history, verify a positive `(round, timestamp_ms)` cutover through `getSafePriceLegacyCutover` and a non-empty normalized `current_price_observation`. Empty-history Pairs intentionally keep both values empty at upgrade. -6. Upgrade any deployed `pair-full.wasm` instance from the separately verified `pair-full.wasm` source. Do not copy the standard Pair template onto a Pair Full instance, because that would remove its view endpoints. If the Router template is temporarily changed for this operation, restore and re-verify the standard template afterward. -7. Only after every Pair has been upgraded, upgrade the separate `safe-price-view.wasm` contract and verify legacy, cross-transition, and current-history queries. -8. Resume the Pairs and suspended maintenance callers only after the central view is compatible with six-field observations. -9. As a rollout safety policy for state-changing consumers that require a fully time-weighted LP supply, keep those consumers disabled until the start of their default lookback is strictly later than the LP legacy boundary. The exact condition is `current_timestamp_ms - effective_default_offset_ms > legacy_lp_boundary_ms`. Verify that the selected first observation has a positive `lp_supply_accumulated` before enabling Farm Staking Proxy flows. The Pair does not enforce this gate itself; before maturity, it remains callable and uses the legacy current-supply fallback. +New integrations should query the separately deployed `safe-price-view.wasm` contract. Every central query endpoint accepts the target `pair_address` as its first argument. A View deployment can serve compatible Pairs whose storage is accessible from that deployment. -Pair pause alone is not a complete write freeze: the whitelisted buyback-and-burn liquidity path can update and finalize Safe Price observations while paused. That actor must remain stopped during the mixed-binary window so the old central view never encounters a newly finalized six-field observation. Pair pause also does not disable Safe Price reads, so dependent contracts must be gated separately when required. +These query endpoints are read-only. Off-chain VM queries do not submit transactions; synchronous smart-contract consumers still pay execution gas. -#### Legacy Endpoints +Because the View reads Pair and Router state synchronously, on-chain integrations must use a compatible View deployment in the Pair's shard. Keep its address configurable by network and release. -Individual pair contracts still expose safe price endpoints (`updateAndGetSafePrice`, `updateAndGetTokensForGivenPositionWithSafePrice`) for backwards compatibility only. These are **not recommended** for new integrations. +The central view: -### Important Security Consideration +1. reads the required Safe Price, reserve, LP-supply, token, and Router-address storage from the supplied Pair; +2. for default-window queries, reads the configured lookback from that Pair's Router; +3. normalizes or resolves the requested cumulative boundaries; +4. returns the calculated quote or observation. -**CRITICAL**: The Safe Price module retrieves data independently of the liquidity pool's active/paused state. Even if a Pair is paused, the Safe Price module continues to return data. +Before using a Safe Price result for accounting or other state changes, resolve `pair_address` through the trusted Router registry and confirm that the Pair's `getRouterManagedAddress` value matches the expected Router. This binds the query to the intended Pair and Router-owned configuration. -**For external contract integrations**: If your contract requires awareness of the pair's operational status, you must **manually check the liquidity pool's pause state** before using safe price data. The safe price mechanism does not enforce or reflect pause states. +### Pair pause state -This design allows price queries to remain available for informational purposes while giving integrating contracts full control over how they handle paused pool scenarios. Integrations must also validate that the queried address is an approved Pair because the central view does not perform that registry check. +If an integration accepts data only from active Pairs, query the Pair's `getState` view and enforce that requirement before consuming Safe Price. A paused Pair can still return Safe Price data, and permitted operations can continue advancing observations. If the integration requires an active Pair or unchanged observations, check and enforce that policy separately. -## Protocol Upgrade: Time-Based Safe Price +### Endpoint availability by artifact -### Background +| Artifact | Safe Price interface | +|---|---| +| `safe-price-view.wasm` | Documented central quote and observation endpoints | +| `pair.wasm` | Pair storage views and compatibility quote endpoints | +| `pair-full.wasm` | Pair interfaces plus the central quote and observation endpoints | +| `router.wasm` | Safe Price configuration getters | -With the MultiversX protocol upgrade reducing round duration from 6 seconds to 0.6 seconds, Safe Price accumulation moved to a canonical millisecond timeline instead of using round counts as time weights. +Central query endpoints are not exported by a standard `pair.wasm`. Call them on the Safe Price View and pass the Pair address instead. -### Key Changes +## Time and Observation Model -#### 1. Timestamp Support in Price Observations +### Observation layout -The `PriceObservation` structure now includes a `recording_timestamp` field alongside `recording_round`: +The current observation type has six fields, in this exact order: ```rust pub struct PriceObservation { pub first_token_reserve_accumulated: BigUint, pub second_token_reserve_accumulated: BigUint, pub weight_accumulated: u64, - pub recording_round: Round, - pub recording_timestamp: Timestamp, + pub recording_round: u64, + pub recording_timestamp: u64, pub lp_supply_accumulated: BigUint, } ``` -#### 2. Millisecond Timestamp Endpoints and ABI Compatibility - -- `getSafePriceByTimestampOffsetMs`: Get safe price using a millisecond timestamp offset -- `getLpTokensSafePriceByTimestampOffsetMs`: Get LP token value using a millisecond timestamp offset - -These are the primary timestamp-offset endpoints. They query elapsed time in **milliseconds** rather than rounds. Every positive `recording_timestamp`, save interval, default offset, and offset passed to an `Ms` endpoint is expressed in milliseconds. - -The deployed endpoint names remain available with their original seconds-based ABI: - -- `getSafePriceByTimestampOffset` -- `getLpTokensSafePriceByTimestampOffset` - -Each compatibility endpoint multiplies its seconds argument by `1,000` and delegates to the corresponding `Ms` endpoint. Existing callers continue passing `3,600` for one hour; new callers should use the `Ms` endpoint and pass `3,600,000`. The `ByDefaultOffset` endpoints read the Router-owned millisecond default and call the `Ms` implementation directly. - -This ABI compatibility is separate from observation migration: no timestamp-bearing observation format with positive seconds was deployed. Consumers of `getPriceObservation` must still decode the new six-field return value. - -#### 3. Round-to-Timestamp Compatibility - -Round-based endpoints remain available for compatibility, but round numbers are no longer a separate lookup axis. Under the protocol's deterministic round schedule, the view normalizes the oldest retained observation to a positive millisecond timestamp and combines that anchor with the current round, current timestamp, and runtime round duration. Assuming the single protocol transition from the legacy `6,000` millisecond cadence to the current `600` millisecond cadence, it solves the exact number of legacy-duration rounds and infers the requested round's timestamp. - -This relies on the protocol invariant that round numbers represent every scheduled round and are not skipped like produced block nonces. The elapsed round count and elapsed timestamp must therefore satisfy the supported cadence equation exactly. - -The inferred timestamp is then resolved through the same timestamp binary-search and interpolation path used by timestamp-based endpoints. Inconsistent timelines and rounds outside the normalized anchor-to-current range are rejected. The solver is exact for the supported zero- or one-transition history, but it does not encode a complete cadence history and is not safe for a later third cadence. Before any future round-duration change, round-based endpoints must be deprecated or extended with explicit cadence-transition data. Timestamp-based endpoints remain the preferred interface. +For current observations: -#### 4. Intermediate Save Functionality +- `weight_accumulated` is cumulative elapsed time in milliseconds; +- `recording_timestamp` is a block timestamp in milliseconds; +- reserve and LP-supply accumulators are value-times-milliseconds totals; +- `recording_round` preserves the corresponding blockchain round. -To optimize gas costs with faster block times, the system now supports: +Raw legacy observations use a different four-field encoding. External storage readers must follow [Legacy Observation Normalization](#legacy-observation-normalization) before mixing legacy and current cumulative values. -- **Configurable Save Intervals**: Set how often observations are finalized -- **Current Accumulation**: Keeps the latest cumulative state available between saves -- **Event-Driven Finalization**: Saves a cumulative observation on the first eligible Pair operation after the interval is reached +### Recording behavior -#### 5. Legacy Cutover and Normalization - -Every pair with legacy history must be upgraded before protocol activation. Pair upgrade stores an immutable `(round, timestamp_ms)` normalization anchor while the legacy cadence is still active. This pair-upgrade anchor is not the protocol activation round or timestamp. - -Raw legacy observations are identified only by `recording_timestamp == 0`; every positive stored timestamp is already milliseconds and is never interpreted as seconds. - -- A legacy observation timestamp is inferred only from the immutable cutover: `cutover_timestamp_ms - (cutover_round - observation_round) * 6,000`. -- A pair with legacy history and a missing or invalid cutover fails closed. -- Values written to `current_price_observation` are created only by the upgraded binary, always contain a positive millisecond timestamp, and never participate in legacy normalization. -- Legacy accumulators and weights are multiplied by `6,000` only after a valid positive timestamp is inferred. A truly empty oracle at chain timestamp zero is the separate no-write bootstrap case. -- Pair upgrade initializes `current_price_observation` from the latest finalized observation. A legacy latest observation is normalized in memory before it is stored in the current mapper; the legacy vector entry remains unchanged. -- A Pair with no finalized Safe Price observation returns early from this migration: it needs neither a legacy cutover nor legacy normalization. Its first valid update starts a native six-field millisecond observation. - -The supported runtime cases are: - -| Case | Runtime round duration | Stored timestamp | Handling | -|---|---:|---:|---| -| Legacy observation read before or after activation | `6,000` or `600` ms | `0` | Infer timestamp from the pre-activation Pair cutover and scale round-weighted cumulative values by `6,000` | -| Upgraded Pair before Supernova | `6,000` ms | Positive milliseconds | Use unchanged; accumulate with millisecond deltas | -| Upgraded Pair after Supernova | `600` ms | Positive milliseconds | Use unchanged; accumulate with millisecond deltas | - -The `600` millisecond cadence affects fresh-oracle bootstrap weight and round-to-timestamp compatibility. It does not trigger another normalization of observations that already have a positive timestamp. - -#### LP-Supply Migration Boundary - -Legacy four-field observations contain no LP-supply accumulator. Timestamp normalization converts their reserve accumulators and weights to milliseconds, but `lp_supply_accumulated` remains zero because historical LP supply cannot be reconstructed from the deployed schema. - -For an LP-value query, the implementation uses time-weighted LP supply only when the first resolved observation has `lp_supply_accumulated > 0`. If it is zero, the query preserves legacy behavior by dividing the time-weighted reserves by the Pair's current LP supply. This fallback is decoding- and storage-compatible, but it is not a time-consistent denominator if LP supply changes around the queried period. - -Define `legacy_lp_boundary_ms` as the normalized timestamp of the newest legacy observation. On upgrade, this is the timestamp of the observation used to initialize `current_price_observation`. A Pair is mature for default LP-value queries only when: +Swap, add-liquidity, and remove-liquidity paths call the Safe Price writer before changing reserves. For an existing observation, a valid update adds: ```text -current_timestamp_ms - effective_default_offset_ms > legacy_lp_boundary_ms -``` - -and the first resolved observation has a positive LP-supply accumulator. The inequality is strict: equality still selects the zero-accumulator boundary observation and activates the fallback. `effective_default_offset_ms` is the Router-configured default offset, shortened to the available history when necessary. - -For a rollout in which state-changing accounting consumers require a fully time-weighted LP supply, keep consumers of `getLpTokensSafePriceByDefaultOffset` and `updateAndGetTokensForGivenPositionWithSafePrice`, including Farm Staking Proxy flows, disabled until this condition is verified independently for every Pair. This is an integration policy, not a Pair-level execution guard: before maturity, the endpoints remain callable and use the current-supply fallback. The boundary can mature while a Pair remains paused when reserves and supply are unchanged because views extrapolate the current state in memory. If the Router default offset changes, maturity must be re-evaluated. Explicit LP-value ranges whose first resolved observation is legacy continue using the current-supply fallback even after the default range has matured. Token-to-token Safe Price queries do not use LP supply and are not subject to this specific limitation. - -## How It Works - -### Recording Process - -1. **Before Each Reserve Change**: Swap, add-liquidity, and remove-liquidity paths pass the current pre-mutation reserves and LP supply to the writer -2. **Elapsed-Time Accumulation**: The writer extends the cumulative observation from its last timestamp to the current block timestamp -3. **Interval Check**: Once finalized history exists, the writer compares elapsed milliseconds with the newest finalized observation; during bootstrap, it uses the current observation's cumulative weight. When the threshold is reached, the cumulative value is also written to the circular buffer -4. **Current Observation**: `current_price_observation` is updated after every valid distinct-timestamp write, whether or not finalization occurs -5. **Circular Buffer**: The most recent finalized observations are retained up to `MAX_OBSERVATIONS` - -The mechanism is event-driven: elapsed time alone does not create a storage write. Finalization happens on the next eligible Pair operation. Multiple operations at the same block timestamp do not add weight twice; after the first update, later same-timestamp calls are no-ops for Safe Price accumulation. - -### Calculation Method - -Safe price uses time-weighted averaging: +elapsed_ms = current_timestamp_ms - previous_timestamp_ms +first_reserve_accumulator += first_reserve * elapsed_ms +second_reserve_accumulator += second_reserve * elapsed_ms +lp_supply_accumulator += lp_supply * elapsed_ms +weight_accumulated += elapsed_ms ``` -Weighted Reserve = (Σ reserve_i × time_i) / (Σ time_i) - -Weighted LP Supply = (Σ lp_supply_i × time_i) / (Σ time_i) -``` - -Where: -- `reserve_i` is the token reserve at observation i -- `lp_supply_i` is the LP supply at observation i when the queried range has native LP-supply history -- `time_i` is the duration (weight) of that observation -- The sum covers all observations in the specified time range - -### Price Query Process - -1. **Determine Time Range**: Use the configured/default timestamp offset, an explicit timestamp offset, or explicit round boundaries -2. **Resolve Boundaries**: Find exact observations, interpolate between available cumulative boundaries (whose right boundary may be the non-finalized `current_price_observation`), or extrapolate the latest observation with the Pair's current reserves and LP supply -3. **Calculate Weighted Amounts**: Subtract cumulative values and divide by elapsed millisecond weight -4. **Return Price**: Calculate the token ratio or LP-token value from the weighted amounts - -Default-offset queries use the Router-configured lookback, shortened to the amount of history available. They still require a positive time range and at least one finalized observation plus a current observation. - -## Price Observation Structure -### Circular Buffer Storage +Eligible Pair operations update `current_price_observation` when reserves and LP supply are non-zero and the timestamp advances. Every such update replaces it with the latest cumulative state. Once the configured save interval has elapsed, the same state is also finalized in the circular observation buffer. -Price observations are stored in a **circular buffer** (circular list) with a maximum capacity of **65,536 observations** (2^16). This data structure provides: +Finalization is event-driven: elapsed time alone does not write storage. The first eligible Pair operation after the interval is reached performs the finalization. Multiple operations at the same timestamp add no additional weight after the first update. -- **Efficient Storage**: Automatically overwrites oldest data when capacity is reached -- **Fast Lookups**: Optimized for binary search operations -- **Predictable Memory**: Fixed maximum storage footprint -- **Rolling Window**: Always maintains the most recent observation history +For a fresh oracle without a previous timestamp, the first contribution uses the runtime-reported round duration. Later updates use timestamp differences, so Pair recording is not tied to a hardcoded current round duration. -The circular buffer provides bounded finalized history. Recording is event-driven rather than continuous: eligible Pair operations update storage, while views can extend the latest cumulative observation in memory to a requested timestamp without persisting that extension. +### Finalized and current observations -`safe_price_current_index` points to the newest finalized vector entry. When the vector is full, the oldest entry is `(current_index % MAX_OBSERVATIONS) + 1`, and lookup searches the correct physical segment around the wrap. `current_price_observation` is stored separately and represents the same or a newer cumulative state than the newest finalized entry. +The Pair retains at most 65,536 finalized observations in a circular buffer. `safe_price_current_index` identifies the newest finalized entry. `current_price_observation` is stored separately and is either equal to that finalized observation or newer. -### Observation Fields +A fresh Pair can therefore have a pending current observation before any history is finalized. Queries reject this state until at least one finalized observation and a current observation exist. -Each price observation records: +## Query Semantics -- **first_token_reserve_accumulated**: Cumulative weighted first token reserve -- **second_token_reserve_accumulated**: Cumulative weighted second token reserve -- **weight_accumulated**: Cumulative time weight in milliseconds -- **recording_round**: Blockchain round when recorded -- **recording_timestamp**: Block timestamp in milliseconds when recorded -- **lp_supply_accumulated**: Cumulative weighted LP token supply +### Cumulative calculation -These fields describe upgraded observations and normalized in-memory legacy observations. Raw legacy vector entries remain in their deployed four-field, round-weighted encoding; upgrade does not rewrite them in place. Views normalize raw legacy entries when reading them. +For two resolved cumulative observations, the implementation calculates: -### Weight Calculation - -`weight_accumulated` is the cumulative sum of every elapsed-time contribution. For an existing observation, one writer update adds: - -**Incremental Weight = Current Timestamp Milliseconds - Observation Timestamp Milliseconds** - -For a fresh oracle without a previous timestamp, the first contribution uses the runtime round duration: `6,000` milliseconds before Supernova or `600` milliseconds after activation. If 600 milliseconds elapsed after an existing observation, the writer adds 600 to `weight_accumulated` and adds `reserve × 600` to each reserve accumulator. - -Views calculate an interval's duration by subtracting the two cumulative weights. This gives longer-lived states proportionally greater influence and reduces the effect of rapid reserve changes. - -## Recording Mechanisms - -### Default Finalization Interval (6,000 Milliseconds) +```text +duration_ms = end.weight_accumulated - start.weight_accumulated -The default interval retains the historical six-second observation cadence: +weighted_first_reserve = + (end.first_reserve_accumulator - start.first_reserve_accumulator) / duration_ms -```rust -safe_price_timestamp_save_interval = 6_000 // milliseconds (default) +weighted_second_reserve = + (end.second_reserve_accumulator - start.second_reserve_accumulator) / duration_ms ``` -- Before the first finalized observation exists, the millisecond-weighted cumulative duration is used as the interval clock. -- A fresh oracle uses the protocol's runtime round duration for its first weight: `6,000` milliseconds before Supernova and `600` milliseconds after activation. -- Later updates within the same 6,000-millisecond window replace the single current observation. -- On the first eligible update where elapsed time since the last finalized observation has reached 6,000 milliseconds, the current state is also written to the circular buffer. It remains in `current_price_observation` as the canonical latest state. - -### Configurable Intermediate Save Mode +A token quote then uses the ratio of the weighted reserves: -Any positive configured interval controls how long millisecond-weighted data accumulates before finalization. For example: - -```rust -safe_price_timestamp_save_interval = 60_000 // milliseconds +```text +output_amount = input_amount * weighted_output_reserve / weighted_input_reserve ``` -- Keeps exactly one latest value in `current_price_observation` after the first valid update -- Finalizes the observation on the next eligible update after the interval in milliseconds has passed -- Retains the finalized value in `current_price_observation`; later updates replace it with the next cumulative value -- Longer intervals lower circular-buffer and index-write frequency but delay finalized history - -## Available Endpoints - -### Artifact Availability - -| Artifact | Safe Price endpoints | -|---|---| -| `pair.wasm` | `getSafePriceCurrentIndex`, `getCurrentPriceObservation`, `getSafePriceLegacyCutover`, `updateAndGetTokensForGivenPositionWithSafePrice`, `updateAndGetSafePrice` | -| `safe-price-view.wasm` | The eleven central query endpoints documented below, including both seconds compatibility wrappers, both `Ms` endpoints, and `getPriceObservation` | -| `pair-full.wasm` | Standard Pair endpoints plus all eleven central query endpoints | -| `router.wasm` | The two timestamp configuration setters and two timestamp configuration getters | - -The eleven labeled central query endpoints are not exported by a standard `pair.wasm`. Calling, for example, `getSafePriceByTimestampOffsetMs` directly on a standard Pair returns `endpoint not found`; use the central Safe Price View address and pass the Pair address as an argument. - -All quote endpoints require at least one finalized vector observation and a non-empty `current_price_observation`. A fresh Pair with only a pending current observation is not queryable yet. +All divisions are integer divisions and round down. -### Token Swap Price Endpoints +Safe Price averages each reserve over the interval and then takes the ratio of those weighted reserves. Treat the result as a reference valuation. An executable swap quote should separately account for swap fees, constant-product price impact, routing, and slippage protection. -#### `getSafePriceByDefaultOffset` +### Boundary resolution -Compute the token-to-token TWAP over the trailing default window ending at the current block timestamp. If positive available history is shorter than the Router-configured default, the effective offset is reduced to the available span. +The View uses exact stored observations when available, interpolates between valid neighboring observations, and extrapolates from the latest persisted state to the current block. It rejects requests outside retained history or across inconsistent cumulative data. These calculations do not modify storage. -**Parameters:** -- `pair_address: ManagedAddress` - The pair contract address -- `input_payment: EsdtTokenPayment` - Input token and amount +### Timestamp offsets -**Returns:** -- `EsdtTokenPayment` - Output token and amount +The `Ms` endpoints are the primary timestamp APIs. Their offsets are milliseconds. They calculate a trailing interval ending at the current block timestamp: -**Usage:** -```rust -// Get price for swapping 1000 WEGLD -let output = getSafePriceByDefaultOffset(pair_addr, EsdtTokenPayment(WEGLD, 0, 1000)); +```text +[current_timestamp_ms - offset_ms, current_timestamp_ms] ``` -#### `getSafePriceByRoundOffset` - -Compute the token-to-token TWAP over the trailing round window `[current_round - round_offset, current_round]`. +The compatibility endpoints without the `Ms` suffix accept seconds, multiply the value by 1,000 with checked arithmetic, and delegate to the corresponding `Ms` endpoint. -**Parameters:** -- `pair_address: ManagedAddress` - The pair contract address -- `round_offset: Round` - Number of rounds to look back -- `input_payment: EsdtTokenPayment` - Input token and amount +Only the `ByDefaultOffset` endpoints shorten their window to available elapsed history, defined as the span from the oldest valid observation to the current block. Explicit timestamp offsets are not clamped: they are rejected if they begin before retained history. For an `Ms` endpoint, `0 < offset_ms < current_timestamp_ms`. A seconds wrapper first performs its checked multiplication by 1,000, then applies that millisecond condition. -**Returns:** -- `EsdtTokenPayment` - Output token and amount +When an integration requires a minimum manipulation-resistance window, use an explicit `Ms` offset so insufficient history fails, or verify the available span independently before accepting a default-window result. Default-window queries may use a shorter effective span when the full configured history is not yet available. -**Example:** -```rust -// Get the TWAP over the latest 600 rounds -let output = getSafePriceByRoundOffset(pair_addr, 600, input); -``` +### Round ranges -#### `getSafePriceByTimestampOffsetMs` +Round-based endpoints map rounds to timestamps and then use the same timestamp search, interpolation, and extrapolation path. -Compute the token-to-token TWAP over the trailing millisecond window `[current_timestamp_ms - timestamp_offset, current_timestamp_ms]`. This is not a point-in-time price at the start of the window. - -**Parameters:** -- `pair_address: ManagedAddress` - The pair contract address -- `timestamp_offset_milliseconds: Timestamp` - Number of milliseconds to look back -- `input_payment: EsdtTokenPayment` - Input token and amount - -**Returns:** -- `EsdtTokenPayment` - Output token and amount - -**Example:** -```rust -// Get the TWAP over the latest hour (3,600,000 milliseconds) -let output = getSafePriceByTimestampOffsetMs(pair_addr, 3_600_000, input); -``` +A round offset must satisfy `0 < round_offset < current_round`. Explicit round ranges require `end_round > start_round`, and every requested round must lie between the normalized oldest anchor and the current round. -**Note:** This endpoint is time-independent and works across block duration changes. +The current solver supports a history with either no cadence transition or one transition from the legacy 6,000-millisecond round duration to the current runtime duration. It relies on scheduled round numbers not being skipped and requires the elapsed rounds and elapsed time to satisfy that cadence model exactly. Round-based endpoints are retained for compatibility; timestamp-based `Ms` endpoints are the preferred integration interface. -#### `getSafePriceByTimestampOffset` +## Public Endpoints -Seconds-based compatibility wrapper for the endpoint deployed before this migration. It accepts `timestamp_offset_seconds`, multiplies it by `1,000`, and delegates to `getSafePriceByTimestampOffsetMs`. New integrations should use the `Ms` endpoint directly. +All central endpoints take `pair_address: Address` as their first argument. -#### `getSafePrice` +### Token-to-token quotes -Get safe price for a custom round range. +| Endpoint | Remaining arguments | Window | Output | +|---|---|---|---| +| `getSafePriceByDefaultOffset` | `input_payment: EsdtTokenPayment` | Router default in milliseconds, shortened to available elapsed history | `EsdtTokenPayment` | +| `getSafePriceByTimestampOffsetMs` | `timestamp_offset_milliseconds: u64`, `input_payment: EsdtTokenPayment` | Trailing milliseconds | `EsdtTokenPayment` | +| `getSafePriceByTimestampOffset` | `timestamp_offset_seconds: u64`, `input_payment: EsdtTokenPayment` | Trailing seconds; compatibility wrapper | `EsdtTokenPayment` | +| `getSafePriceByRoundOffset` | `round_offset: u64`, `input_payment: EsdtTokenPayment` | `[current_round - offset, current_round]` | `EsdtTokenPayment` | +| `getSafePrice` | `start_round: u64`, `end_round: u64`, `input_payment: EsdtTokenPayment` | Explicit round range | `EsdtTokenPayment` | -**Parameters:** -- `pair_address: ManagedAddress` - The pair contract address -- `start_round: Round` - Starting round -- `end_round: Round` - Ending round -- `input_payment: EsdtTokenPayment` - Input token and amount +`input_payment.token_identifier` must be one of the Pair's two tokens. The returned payment contains the other token and the calculated amount. -**Returns:** -- `EsdtTokenPayment` - Output token and amount +### LP-token values -### LP Token Value Endpoints +| Endpoint | Remaining arguments | Window | Output | +|---|---|---|---| +| `getLpTokensSafePriceByDefaultOffset` | `liquidity: BigUint` | Router default in milliseconds, shortened to available elapsed history | Two `EsdtTokenPayment` values | +| `getLpTokensSafePriceByTimestampOffsetMs` | `timestamp_offset_milliseconds: u64`, `liquidity: BigUint` | Trailing milliseconds | Two `EsdtTokenPayment` values | +| `getLpTokensSafePriceByTimestampOffset` | `timestamp_offset_seconds: u64`, `liquidity: BigUint` | Trailing seconds; compatibility wrapper | Two `EsdtTokenPayment` values | +| `getLpTokensSafePriceByRoundOffset` | `round_offset: u64`, `liquidity: BigUint` | `[current_round - offset, current_round]` | Two `EsdtTokenPayment` values | +| `getLpTokensSafePrice` | `start_round: u64`, `end_round: u64`, `liquidity: BigUint` | Explicit round range | Two `EsdtTokenPayment` values | -#### `getLpTokensSafePriceByDefaultOffset` +The two returned payments represent the first-token and second-token value of the requested LP amount. See [LP-Supply Compatibility](#lp-supply-compatibility) before using an LP result for state-changing accounting. -Compute the LP-token value over the trailing default window. If positive available history is shorter than the Router-configured default, the effective offset is reduced to the available span. +### Observation query -The fully time-weighted LP-supply guarantee applies only when the first resolved observation has positive `lp_supply_accumulated`. Otherwise the query uses the current-supply legacy fallback described in [LP-Supply Migration Boundary](#lp-supply-migration-boundary). +`getPriceObservation(pair_address: Address, search_round: u64) -> PriceObservation` returns the exact, normalized, interpolated, or extrapolated cumulative observation for a valid round. The returned six-field value has `recording_round` set to the requested round and `recording_timestamp` set to its inferred millisecond timestamp. -**Parameters:** -- `pair_address: ManagedAddress` - The pair contract address -- `liquidity: BigUint` - Amount of LP tokens +This is cumulative oracle state, not a token price or executable quote. Use the quote endpoints unless the integration intentionally implements cumulative-difference calculations. Unlike a quote, this endpoint can request one valid round and does not require a non-zero range between two requested rounds. -**Returns:** -- `MultiValue2` - First and second token amounts +### Pair storage views -**Example:** -```rust -// Get value of 1000 LP tokens -let (first_token, second_token) = getLpTokensSafePriceByDefaultOffset(pair_addr, 1000); -``` +The following views are available directly on `pair.wasm` and `pair-full.wasm`: -#### `getLpTokensSafePriceByRoundOffset` +| Endpoint | Output | Meaning | +|---|---|---| +| `getSafePriceCurrentIndex` | `u32` in the WASM ABI | Zero if nothing is finalized; otherwise the 1-based newest finalized index | +| `getCurrentPriceObservation` | `PriceObservation` | Latest persisted cumulative state, finalized or in progress | +| `getSafePriceLegacyCutover` | `(u64, u64)` | Legacy normalization anchor `(round, timestamp_ms)` when the Pair has legacy history | -Compute the LP-token value over a trailing round window. If its first resolved observation is legacy, that query continues using the current-supply fallback regardless of whether the default window has already matured. +### Legacy Pair quote endpoints -**Parameters:** -- `pair_address: ManagedAddress` - The pair contract address -- `round_offset: Round` - Number of rounds to look back -- `liquidity: BigUint` - Amount of LP tokens +Standard Pairs retain these endpoints for backwards compatibility: -**Returns:** -- `MultiValue2` - First and second token amounts +- `updateAndGetSafePrice(input: EsdtTokenPayment) -> EsdtTokenPayment` delegates to the default token Safe Price query; +- `updateAndGetTokensForGivenPositionWithSafePrice(liquidity: BigUint) -> (EsdtTokenPayment, EsdtTokenPayment)` delegates to the default LP-value query. -#### `getLpTokensSafePriceByTimestampOffsetMs` +Despite their historical names, these endpoints do not update Safe Price storage. New integrations should use the central Safe Price View. -Compute the LP-token value over a trailing millisecond window. This is an interval valuation, not a point-in-time valuation at the start timestamp. +### Argument-order examples -**Parameters:** -- `pair_address: ManagedAddress` - The pair contract address -- `timestamp_offset_milliseconds: Timestamp` - Number of milliseconds to look back -- `liquidity: BigUint` - Amount of LP tokens +The following pseudocode shows the contract address, endpoint name, and argument order. Encode and decode every argument through the MultiversX SDK using the exact ABI distributed with the matching View release; `query(...)` is not an SDK function. -**Returns:** -- `MultiValue2` - First and second token amounts +Assume `pair_address` has passed the validation steps above. For fungible Pair tokens, construct `input_payment` as `EsdtTokenPayment(input_token_id, nonce = 0, positive_amount)`, and provide `lp_amount` as a positive `BigUint`. The token query returns one nonce-zero payment for the opposite Pair token. The LP query returns two nonce-zero payments ordered as the Pair's first token and second token. -**Example:** -```rust -// Get LP value over the latest 30 minutes (1,800,000 milliseconds) -let (token1, token2) = getLpTokensSafePriceByTimestampOffsetMs(pair_addr, 1_800_000, lp_amount); +```text +query( + contract = canonical_safe_price_view_address, + endpoint = "getSafePriceByTimestampOffsetMs", + arguments = [pair_address, 3_600_000, input_payment], +) ``` -#### `getLpTokensSafePriceByTimestampOffset` - -Seconds-based compatibility wrapper for the endpoint deployed before this migration. It accepts `timestamp_offset_seconds`, multiplies it by `1,000`, and delegates to `getLpTokensSafePriceByTimestampOffsetMs`. New integrations should use the `Ms` endpoint directly. - -#### `getLpTokensSafePrice` - -Get LP token value for a custom round range. A range that begins from a legacy observation uses the current-supply fallback for that query. - -**Parameters:** -- `pair_address: ManagedAddress` - The pair contract address -- `start_round: Round` - Starting round -- `end_round: Round` - Ending round -- `liquidity: BigUint` - Amount of LP tokens - -**Returns:** -- `MultiValue2` - First and second token amounts - -### Observation Query Endpoints - -#### `getPriceObservation` - -Get the exact, normalized, interpolated, or extrapolated cumulative observation corresponding to a requested round after mapping that round to a supported timestamp. - -**Parameters:** -- `pair_address: ManagedAddress` - The pair contract address -- `search_round: Round` - The round to query - -**Returns:** -- `PriceObservation` - The observation data (may be interpolated) - -### Legacy Endpoints - -#### `updateAndGetTokensForGivenPositionWithSafePrice` - -Legacy endpoint that calls `getLpTokensSafePriceByDefaultOffset` on the pair itself. - -Despite its historical name, this endpoint does not call `update_safe_price` and does not mutate Safe Price storage. Its LP-supply guarantees are subject to [LP-Supply Migration Boundary](#lp-supply-migration-boundary). - -#### `updateAndGetSafePrice` - -Legacy endpoint that calls `getSafePriceByDefaultOffset` on the pair itself. - -Despite its historical name, this endpoint does not call `update_safe_price` and does not mutate Safe Price storage. - -### View Endpoints - -#### `getSafePriceCurrentIndex` - -Returns `0` while no observation has been finalized; otherwise returns the 1-based index of the newest finalized circular-buffer entry. A pending `current_price_observation` can exist while this index is still zero. - -**Available on:** Pair contract - -**Returns:** -- `usize` in Rust (`u32` in the WASM ABI) - `0` sentinel or a 1-based current index - -#### `getSafePriceTimestampSaveInterval` - -Returns the configured finalized-observation interval in milliseconds. - -**Available on:** Router contract (storage is in router; pair reads from router's storage) - -**Returns:** -- `u64` - Number of milliseconds between finalized saves - -**Default Value:** `6,000` milliseconds - -#### `getCurrentPriceObservation` - -Returns the latest recorded observation. After a finalize operation, this is the same cumulative state as the newest circular-buffer entry; between finalizations, it is the newer in-progress state. - -For an upgraded Pair with legacy finalized history, this mapper is initialized during upgrade. For a Pair with no finalized history, it remains empty until the first valid writer update. - -**Available on:** Pair contract +This calculates a token TWAP over the latest hour. -**Returns:** -- `PriceObservation` - The latest recorded observation, finalized or in progress +```text +query( + contract = canonical_safe_price_view_address, + endpoint = "getLpTokensSafePriceByDefaultOffset", + arguments = [pair_address, lp_amount], +) +``` -#### `getSafePriceLegacyCutover` +This returns the first-token and second-token value of `lp_amount` over the effective default window. -Returns the Pair upgrade anchor used to normalize legacy four-field observations and infer their millisecond timestamps. +## LP-Supply Compatibility -**Available on:** Pair contract +Legacy four-field observations did not record cumulative LP supply, and historical LP supply cannot be reconstructed from their encoding. Their normalized `lp_supply_accumulated` therefore remains zero. -**Returns:** -- `(Round, Timestamp)` - Pair-upgrade round and timestamp in milliseconds for a Pair with finalized legacy history +For an LP-value query: -#### `getDefaultSafePriceTimestampOffset` +1. the view calculates time-weighted LP supply only when the first resolved observation has a positive LP-supply accumulator; +2. if the calculated weighted LP supply is zero, it falls back to the Pair's current LP supply; +3. if the current LP supply is also zero, it returns two zero-amount payments. -Returns the default timestamp offset for safe price queries. +A range beginning in legacy history uses the Pair's current LP supply as its denominator. If LP supply changed during that range, the result is not fully time-weighted. -**Available on:** Router contract (storage is in router; pair reads from router's storage) +Define `legacy_lp_boundary_ms` as the normalized timestamp of the newest legacy observation. A default-window LP query is fully time-weighted only when: -**Returns:** -- `u64` - Default offset in milliseconds +```text +current_timestamp_ms - effective_default_offset_ms > legacy_lp_boundary_ms +``` -**Default Value:** `3,600,000` milliseconds (1 hour) +and the first resolved observation has a positive LP-supply accumulator. The inequality is strict: equality still begins at the legacy boundary and uses the fallback. -## Configuration +`effective_default_offset_ms` is the Router-configured default shortened to available elapsed history. If the Router changes the default, re-evaluate the condition. -### Owner-Only Configuration Endpoints +To accept a fully time-weighted LP result, verify the strict boundary condition above and a positive LP-supply accumulator at the first observation. Obtain the boundary from trusted Pair metadata or normalized raw storage. If the boundary cannot be established, do not classify the result as fully time-weighted. The LP response contains only the two token payments and does not indicate which denominator was used. Token-to-token Safe Price quotes are unaffected by this LP-supply condition. -The Router SC acts as the central hub for safe price configuration. Configuration values are stored in the router's storage, and pair contracts read these values directly from the router using external storage reads. +## Router Configuration -#### `setSafePriceTimestampSaveInterval` +Safe Price configuration is stored in the Router and read directly by Pairs and the central view. -Set how frequently observations are saved. +| Getter | Unit | Initialization default | Meaning | +|---|---|---:|---| +| `getSafePriceTimestampSaveInterval() -> u64` | milliseconds | `6,000` | Minimum elapsed time before a writer update can finalize another observation | +| `getDefaultSafePriceTimestampOffset() -> u64` | milliseconds | `3,600,000` | Default trailing query window | -**Available on:** Router contract +These values are mutable and must be positive. External integrations should read the live Router getters rather than hardcode the initialization defaults, and should treat missing or zero configuration as unavailable. -**Storage:** Router contract (pairs read from router's storage via `new_from_address`) +## Errors and Edge Cases -**Parameters:** -- `new_interval_milliseconds: u64` - Milliseconds; must be > 0 +Queries reject empty or pending-only history, zero or excessive offsets, incorrectly ordered explicit round ranges, targets before retained history or after the current block, overflowing seconds-to-milliseconds conversion, and round history that does not satisfy the supported cadence model. -**Example:** -- `interval = 6_000` becomes eligible for finalization after six seconds and is finalized by the next valid writer update +Only default-offset endpoints shorten their requested window to available elapsed history. Treat any rejected query as unavailable oracle data until the application's retry or fallback policy permits another action. -#### `setDefaultSafePriceTimestampOffset` +## Integration Checklist -Set the default lookback period for safe price queries. +Before relying on Safe Price: -**Available on:** Router contract +1. Obtain the canonical Safe Price View and Router addresses for the Pair's network, release, and shard. +2. Resolve the Pair through `Router.getPair`, then verify its managed Router and token identifiers. +3. Use the `Ms` endpoints for new timestamp-based integrations; retain seconds and round endpoints only where compatibility requires them. +4. Query the live Router configuration instead of assuming its initialization defaults. +5. Validate returned token identifiers and apply the application's amount and rounding policy. +6. Define an explicit oracle-unavailable policy that rejects, defers, or pauses the affected action. Use a fallback only when it is independently secured, intentionally configured, and clearly distinguished from Safe Price. +7. Decide whether a paused Pair is acceptable and enforce that policy independently. +8. Enforce the LP-supply compatibility condition before treating a legacy-transition LP result as fully time-weighted. +9. Use a lookback long enough for the application's manipulation and responsiveness requirements. +10. Account for cross-contract storage reads and binary-search execution cost in synchronous on-chain calls. -**Storage:** Router contract (pairs read from router's storage via `new_from_address`) +## Appendix: Direct Storage Reading -**Parameters:** -- `new_offset_milliseconds: u64` - Milliseconds; must be > 0 +This appendix applies only to integrations that decode Pair observation storage themselves. Consumers that use the official Safe Price View receive normalized cumulative observations and calculated quotes from the contract. -**Default Value:** -- `3,600,000` milliseconds (1 hour) +Normalization alone does not reproduce an official quote. A custom implementation must also reproduce boundary search, interpolation, extrapolation, Router configuration, and the required current reserves and LP supply, all from a consistent block snapshot. -**Note:** Explicit timestamp-offset endpoints still require an offset parameter. The `Ms` endpoints use milliseconds; the deployed compatibility names use seconds. The `ByDefaultOffset` endpoints read this Router-owned millisecond default. +### Relevant storage -### Router Upgrade and Storage Compatibility +| Storage key | Meaning | +|---|---| +| `price_observations` | `VecMapper` containing finalized raw observations | +| `safe_price_current_index` | 1-based physical index of the newest finalized observation | +| `current_price_observation` | Latest persisted six-field observation | +| `safe_price_legacy_cutover` | Legacy normalization anchor `(round, timestamp_ms)` | -Router `init` and `upgrade` seed the two millisecond configuration keys with `set_if_empty`, preserving any valid values configured before the call. Router upgrade also overwrites the legacy block-count `temporary_owner_period` with `30` seconds and makes the Router inactive; it must be resumed before invoking `upgradePair`. +`price_observations` uses the MultiversX framework's `VecMapper` storage encoding; it is not one flat encoded value. Before the buffer is full, physical indices `1..=len` are chronological. At full capacity, the oldest entry is `(safe_price_current_index % 65_536) + 1`; chronological traversal wraps from there through the current index. -Legacy `pair_temporary_owner` records store a block nonce where the upgraded type expects a timestamp. Such old values compare as timestamps far in the past and are removed as expired when accessed, so they fail closed rather than extending authority. The migration must not be executed during an active Pair-creation/LP-token-issuance flow because that temporary permission would be invalidated. +Fetch all required keys from the same finalized block. Validate the vector length and current index, then validate normalized timestamp and cumulative-weight ordering, including that `current_price_observation` is not older than the newest finalized observation. -Every upgraded Pair writer reads `safe_price_timestamp_save_interval` directly from its Router. A missing or zero value causes an eligible Pair operation that reaches Safe Price accumulation to revert, which is why Router-first deployment is mandatory. +### Supported raw encodings -The following round-named keys and ABI endpoints existed only in the unreleased RC implementation and are neither read nor migrated: +Exactly two observation layouts are supported: -- `safe_price_round_save_interval` / `setSafePriceRoundSaveInterval` / `getSafePriceRoundSaveInterval` -- `default_safe_price_rounds_offset` / `setDefaultSafePriceRoundsOffset` / `getDefaultSafePriceRoundsOffset` +1. Legacy four-field layout: + 1. `first_token_reserve_accumulated` + 2. `second_token_reserve_accumulated` + 3. `weight_accumulated` + 4. `recording_round` +2. Current six-field layout: + 1. the same four fields; + 2. `recording_timestamp`; + 3. `lp_supply_accumulated`. -### Constants +Decode only the four-field legacy and six-field current layouts. Treat other encodings as unsupported. Positive stored timestamps are milliseconds and must not be rescaled. -- **MAX_OBSERVATIONS**: 65,536 (2^16 records for optimized binary search) -- **DEFAULT_SAFE_PRICE_TIMESTAMP_SAVE_INTERVAL_MILLISECONDS**: 6,000 -- **DEFAULT_SAFE_PRICE_TIMESTAMP_OFFSET_MILLISECONDS**: 3,600,000 +### Legacy Observation Normalization -## Usage Examples +`recording_timestamp == 0` identifies an observation that requires legacy normalization. A valid legacy observation also has `lp_supply_accumulated == 0`. -### Example 1: Get Current Safe Price (Default Offset) +For a Pair with legacy finalized history, read its immutable `safe_price_legacy_cutover` value, exposed by `getSafePriceLegacyCutover`: -```rust -// Query safe price with default 1-hour lookback -let pair_address = managed_address!(...); -let input = EsdtTokenPayment::new( - TokenIdentifier::from("WEGLD-123456"), - 0, - BigUint::from(1000u64) -); - -let output = self.get_safe_price_by_default_offset( - pair_address, - input -); -// Returns: EsdtTokenPayment for MEX with calculated amount +```text +(cutover_round, cutover_timestamp_ms) ``` -### Example 2: Get a Timestamp-Window TWAP +This is a Pair-specific normalization anchor, not a network-wide activation value. A Pair created with only current six-field observations does not need a legacy cutover. -```rust -// Get the TWAP over the latest 30 minutes using a millisecond timestamp offset -let thirty_minutes_milliseconds = 30 * 60 * 1_000; -let output = self.get_safe_price_by_timestamp_offset_ms( - pair_address, - thirty_minutes_milliseconds, - input -); -``` +The contract normalizes each legacy observation in memory as follows: -### Example 3: Get LP Token Value +```text +require observation.lp_supply_accumulated == 0 +require legacy cutover is available +require observation.recording_round <= cutover_round -```rust -// Calculate LP-token value over the latest hour -let one_hour_milliseconds = 60 * 60 * 1_000; -let lp_amount = BigUint::from(1000000u64); - -let (first_token, second_token) = self.get_lp_tokens_safe_price_by_timestamp_offset_ms( - pair_address, - one_hour_milliseconds, - lp_amount -); -// Returns: (WEGLD payment, MEX payment) -``` +elapsed_rounds = cutover_round - observation.recording_round +elapsed_ms = elapsed_rounds * 6_000 -### Example 4: Custom Time Range +require elapsed_ms < cutover_timestamp_ms -```rust -// Get price between two specific rounds -let start_round = 1000; -let end_round = 2000; - -let output = self.get_safe_price( - pair_address, - start_round, - end_round, - input -); +observation.recording_timestamp = cutover_timestamp_ms - elapsed_ms +observation.first_token_reserve_accumulated *= 6_000 +observation.second_token_reserve_accumulated *= 6_000 +observation.weight_accumulated *= 6_000 +observation.lp_supply_accumulated = 0 ``` -## Technical Details +The 6,000 multiplier converts legacy round-weighted cumulative values to the canonical millisecond timeline. Raw legacy vector entries are not rewritten. -### Binary Search Algorithm +External readers should additionally validate that `cutover_round` and `cutover_timestamp_ms` are positive and should use checked arithmetic or sufficiently wide integer representations for multiplication, scaling, and subtraction. Treat missing or invalid cutover data, invalid ordering, or invalid arithmetic as unavailable history. -The mechanism uses one timestamp-based binary search with `O(log n)` observation lookup. Timestamp endpoints use it directly; round endpoints first perform the exact round-to-millisecond conversion described above and then use the same search. Circular-buffer indexing handles wraparound when the buffer is full. +Normalize every legacy boundary before combining it with millisecond-weighted current accumulators. -### Linear Interpolation +### Boundary reconstruction -When exact observation matches aren't found, the system interpolates: +When interpolating between adjacent normalized observations, require ordered timestamps and: -```rust -// Weighted average calculation -weighted_value = (left_value × left_weight + right_value × right_weight) / total_weight - -// Where: -// left_weight = distance to right observation -// right_weight = distance to left observation +```text +right.weight_accumulated - left.weight_accumulated + == right.recording_timestamp - left.recording_timestamp ``` -This produces a deterministic cumulative estimate between neighboring available cumulative boundaries. The right boundary can be the non-finalized `current_price_observation`. The estimate is exact when the cumulative path is linear over that interval; it cannot reconstruct reserve changes whose intermediate states were never recorded. - -### Timestamp Search - -All Safe Price quote and observation lookups are resolved on the millisecond timeline. A target between available cumulative boundaries is interpolated; the right boundary may be the non-finalized `current_price_observation`. A target after `current_price_observation`, but not after the current block timestamp, is extrapolated in memory using the Pair's current reserves and LP supply; this extension is not persisted. For `getPriceObservation`, the returned observation's `recording_round` and `recording_timestamp` metadata are set to the requested round and its inferred timestamp under the supported cadence model. - -### Migration Compatibility - -The system supports exactly two raw storage encodings and timestamp cases: - -- A raw observation with `recording_timestamp == 0` requires legacy normalization. Its reserve accumulators and weight are round-weighted, so each read normalizes them in memory with the legacy 6,000 millisecond round duration and infers its timestamp from the Pair-upgrade cutover. The raw vector entry is not rewritten. -- An observation emitted by the upgraded writer has `recording_timestamp > 0`. Its timestamp and cumulative fields are already millisecond-weighted and are returned unchanged. A normalized in-memory legacy observation also has a positive timestamp and is intentionally unchanged by later normalization calls. - -The custom decoder accepts exactly two storage layouts, in this field order: - -1. Deployed legacy four-field observation: first-token reserve accumulator, second-token reserve accumulator, weight, `recording_round`. -2. Upgraded six-field observation: the same four fields, then `recording_timestamp`, then `lp_supply_accumulated`. - -The current writer always emits the six-field layout with a positive millisecond timestamp. Structurally, the decoder can also read a six-field value whose timestamp is zero; semantic normalization treats it as legacy only when its LP-supply accumulator is also zero. There is no seconds-based timestamp migration format. Truncated or unexpected encodings are rejected. For a decoded four-field observation, `recording_timestamp` and `lp_supply_accumulated` default to zero until normalization. - -### Gas Optimization - -The intermediate save functionality reduces circular buffer writes: - -- **At the finalize interval**: The observation is written to the circular buffer (`VecMapper`) and also retained in `current_price_observation` (`SingleValueMapper`). -- **Below the finalize interval**: Only `current_price_observation` is replaced; the circular buffer is unchanged. -- **No-write cases**: Updates with zero reserves or LP supply, round zero, timestamp zero, or a timestamp not newer than the current observation return without writing. -- **Trade-off**: Every valid strictly newer-timestamp update writes the current mapper; finalizing updates additionally write the vector and index. -- **Read cost**: Lookups use `O(log n)` search and cross-contract storage reads. Default-offset endpoints currently load observation context once to determine available history and again to execute the range query. - -### Price Manipulation Resistance and Limits - -Reserve TWAP reduces sensitivity to short-lived reserve movement: - -1. **Time-weighted**: A zero-duration reserve change receives no immediate weight; its influence grows only while that state persists -2. **Historical lookback**: Uses data from before potential attack -3. **Configurable periods**: Longer periods generally reduce short-lived influence but react more slowly to genuine market changes -4. **Integration-dependent safety**: Consumers must validate Pair identity, available history, pause-state policy, and the chosen range - -Token-to-token quotes use only weighted reserves. LP-token valuation is fully time-consistent only when the first resolved observation has positive LP-supply accumulation. Legacy-start LP ranges use the current-supply fallback and must be handled according to [LP-Supply Migration Boundary](#lp-supply-migration-boundary). - -### Edge Cases Handled - -- **Empty history**: Quote views reject a Pair without a finalized vector observation or without `current_price_observation` -- **Zero-length ranges and offsets**: Rejected; `getPriceObservation` can still request a single valid round -- **Out of range**: Requested timestamps or rounds outside retained/supported history are rejected -- **Zero reserves or LP supply**: Writer updates are skipped -- **Same timestamp**: A timestamp that is not strictly newer than the current observation does not add weight or write storage -- **Buffer wraparound**: Circular buffer logic maintains correct ordering - -## Best Practices for Developers - -1. **Validate Pair identity**: Resolve or verify Pair addresses through the trusted Router registry before relying on central-view results. -2. **Check Pair pause state**: If your contract must respect operational state, check it explicitly; Safe Price reads remain available while the Pair is paused. -3. **Use timestamp offsets**: Timestamp endpoints are independent of round-duration inference and are preferred over round endpoints. -4. **Choose an appropriate lookback**: Longer periods generally reduce short-lived influence but are less reactive. -5. **Enforce LP maturity**: Do not use a legacy-start LP range as a fully time-weighted LP valuation. -6. **Handle unavailable history**: Quote views can reject empty, pending-only, out-of-range, or inconsistent history. -7. **Account for on-chain gas**: Cross-contract storage reads and binary search consume gas in synchronous contract calls. -8. **Use the correct artifact**: New integrations should query the central Safe Price View contract; standard Pair contracts expose only the compatibility endpoints listed above. +The View rejects inconsistent cumulative boundaries. A target after `current_price_observation`, but not after the current block timestamp, is extrapolated in memory using current Pair reserves and LP supply. This reconstruction does not modify Pair storage. -## References +## Source References -- Implementation: [src/safe_price.rs](src/safe_price.rs) -- View endpoints: [src/safe_price_view.rs](src/safe_price_view.rs) -- General Pair tests: [tests/pair_rs_test.rs](tests/pair_rs_test.rs) -- Focused timestamp/migration tests: [tests/safe_price_timestamp_canonical_test.rs](tests/safe_price_timestamp_canonical_test.rs) +- Pair recording and normalization: [src/safe_price.rs](src/safe_price.rs) +- Central view endpoints and query logic: [src/safe_price_view.rs](src/safe_price_view.rs) +- Cross-contract storage keys: [src/read_pair_storage.rs](src/read_pair_storage.rs) +- Artifact endpoint selection: [sc-config.toml](sc-config.toml) diff --git a/dex/pair/tests/safe_price_supernova_lifecycle_test.rs b/dex/pair/tests/safe_price_supernova_lifecycle_test.rs new file mode 100644 index 000000000..b8ac4206a --- /dev/null +++ b/dex/pair/tests/safe_price_supernova_lifecycle_test.rs @@ -0,0 +1,1691 @@ +use multiversx_sc::{ + api::ManagedTypeApi, + codec::{ + self, + derive::{NestedDecode, NestedEncode, TopDecode, TopEncode}, + }, + imports::StorageMapper, + storage::{mappers::VecMapper, StorageKey}, + types::{TestAddress, TestSCAddress, TimestampMillis}, +}; +use multiversx_sc_scenario::imports::*; +use pair::{ + config::ConfigModule, + pair_actions::swap::SwapModule, + pair_actions::{add_liq::AddLiquidityModule, remove_liq::RemoveLiquidityModule}, + safe_price::{PriceObservation, SafePriceModule, MAX_OBSERVATIONS}, + safe_price_view::SafePriceViewModule, + Pair, +}; +use pausable::{PausableModule, State}; + +// Safe Price views below are invoked through ScenarioWorld whitebox Rust modules. This covers +// view semantics and cross-account storage integration, not safe-price-view.wasm ABI, export, +// label, or VM dispatch behavior. +const OWNER: TestAddress = TestAddress::new("owner"); +const USER: TestAddress = TestAddress::new("user"); +const ROUTER: TestAddress = TestAddress::new("router"); +const PAIR: TestSCAddress = TestSCAddress::new("pair"); + +const WEGLD_TOKEN_ID: &[u8] = b"WEGLD-abcdef"; +const MEX_TOKEN_ID: &[u8] = b"MEX-abcdef"; +const LP_TOKEN_ID: &[u8] = b"LPTOK-abcdef"; + +const INITIAL_FIRST_RESERVE: u64 = 1_008_000; +const INITIAL_SECOND_RESERVE: u64 = 2_016_000; +const LP_SUPPLY: u64 = 1_008_000; +const SWAP_AMOUNT: u64 = 1_008_000; +const QUOTE_INPUT: u64 = 420_001; +const LP_QUOTE: u64 = 420_001; + +#[derive(TopEncode, TopDecode, NestedEncode, NestedDecode, Clone, Debug)] +struct LegacyPriceObservation { + first_token_reserve_accumulated: BigUint, + second_token_reserve_accumulated: BigUint, + weight_accumulated: u64, + recording_round: u64, +} + +#[derive(Clone, Copy, Debug)] +struct ConstantReserveSegment { + start_timestamp_ms: u64, + end_timestamp_ms: u64, + first_reserve: u64, + second_reserve: u64, + lp_supply: u64, +} + +#[derive(Default)] +struct ReferenceLedger { + segments: Vec, +} + +#[derive(Debug)] +struct ExpectedWindow { + weight_ms: u64, + first_reserve_weighted: u128, + second_reserve_weighted: u128, + lp_supply_weighted: u128, + first_to_second: u64, + second_to_first: u64, + first_to_second_remainder: u128, + second_to_first_remainder: u128, + lp_first: u64, + lp_second: u64, + lp_first_remainder: u128, + lp_second_remainder: u128, +} + +impl ReferenceLedger { + fn push( + &mut self, + start_timestamp_ms: u64, + end_timestamp_ms: u64, + first_reserve: u64, + second_reserve: u64, + ) { + self.push_with_lp_supply( + start_timestamp_ms, + end_timestamp_ms, + first_reserve, + second_reserve, + LP_SUPPLY, + ); + } + + fn push_with_lp_supply( + &mut self, + start_timestamp_ms: u64, + end_timestamp_ms: u64, + first_reserve: u64, + second_reserve: u64, + lp_supply: u64, + ) { + assert!(end_timestamp_ms > start_timestamp_ms); + self.segments.push(ConstantReserveSegment { + start_timestamp_ms, + end_timestamp_ms, + first_reserve, + second_reserve, + lp_supply, + }); + } + + fn expected(&self, start_timestamp_ms: u64, end_timestamp_ms: u64) -> ExpectedWindow { + assert!(end_timestamp_ms > start_timestamp_ms); + + let mut covered_ms = 0u64; + let mut first_reserve_weighted = 0u128; + let mut second_reserve_weighted = 0u128; + let mut lp_supply_weighted = 0u128; + + for segment in &self.segments { + let overlap_start = core::cmp::max(start_timestamp_ms, segment.start_timestamp_ms); + let overlap_end = core::cmp::min(end_timestamp_ms, segment.end_timestamp_ms); + if overlap_end <= overlap_start { + continue; + } + + let weight_ms = overlap_end - overlap_start; + covered_ms += weight_ms; + first_reserve_weighted += u128::from(weight_ms) * u128::from(segment.first_reserve); + second_reserve_weighted += u128::from(weight_ms) * u128::from(segment.second_reserve); + lp_supply_weighted += u128::from(weight_ms) * u128::from(segment.lp_supply); + } + + let weight_ms = end_timestamp_ms - start_timestamp_ms; + assert_eq!( + covered_ms, weight_ms, + "reference ledger does not cover the complete requested window" + ); + + let average_first = first_reserve_weighted / u128::from(weight_ms); + let average_second = second_reserve_weighted / u128::from(weight_ms); + let average_lp_supply = lp_supply_weighted / u128::from(weight_ms); + + let first_to_second_numerator = u128::from(QUOTE_INPUT) * average_second; + let second_to_first_numerator = u128::from(QUOTE_INPUT) * average_first; + let lp_first_numerator = u128::from(LP_QUOTE) * average_first; + let lp_second_numerator = u128::from(LP_QUOTE) * average_second; + + ExpectedWindow { + weight_ms, + first_reserve_weighted, + second_reserve_weighted, + lp_supply_weighted, + first_to_second: u64::try_from(first_to_second_numerator / average_first).unwrap(), + second_to_first: u64::try_from(second_to_first_numerator / average_second).unwrap(), + first_to_second_remainder: first_to_second_numerator % average_first, + second_to_first_remainder: second_to_first_numerator % average_second, + lp_first: u64::try_from(lp_first_numerator / average_lp_supply).unwrap(), + lp_second: u64::try_from(lp_second_numerator / average_lp_supply).unwrap(), + lp_first_remainder: lp_first_numerator % average_lp_supply, + lp_second_remainder: lp_second_numerator % average_lp_supply, + } + } +} + +fn setup_pair(round_time_ms: u64, block_round: u64, block_timestamp_ms: u64) -> ScenarioWorld { + let mut world = ScenarioWorld::debugger(); + world.register_contract("0x0500", pair::ContractBuilder); + world.account(OWNER).balance(0u64); + world + .account(ROUTER) + .balance(0u64) + .storage_mandos("str:default_safe_price_timestamp_offset", "u64:6000") + .storage_mandos("str:safe_price_timestamp_save_interval", "u64:6000"); + world + .account(USER) + .balance(0u64) + .esdt_balance(TestTokenIdentifier::new("WEGLD-abcdef"), 100_000_000u64) + .esdt_balance(TestTokenIdentifier::new("MEX-abcdef"), 100_000_000u64); + world.block_round_time_ms(round_time_ms); + set_block(&mut world, block_round, block_timestamp_ms); + + world + .tx() + .from(OWNER) + .raw_deploy() + .code(BytesValue::from_hex("0500")) + .new_address(PAIR) + .whitebox(pair::contract_obj, |sc| { + let first_token_id = managed_token_id!(WEGLD_TOKEN_ID); + let second_token_id = managed_token_id!(MEX_TOKEN_ID); + sc.init( + first_token_id.clone(), + second_token_id.clone(), + ROUTER.to_managed_address(), + OWNER.to_managed_address(), + 0u64, + 0u64, + ManagedAddress::zero(), + MultiValueEncoded::new(), + ); + sc.lp_token_identifier().set(managed_token_id!(LP_TOKEN_ID)); + sc.pair_reserve(&first_token_id) + .set(managed_biguint!(INITIAL_FIRST_RESERVE)); + sc.pair_reserve(&second_token_id) + .set(managed_biguint!(INITIAL_SECOND_RESERVE)); + sc.lp_token_supply().set(managed_biguint!(LP_SUPPLY)); + sc.state().set(State::Active); + assert_eq!(sc.router_address().get(), ROUTER.to_managed_address()); + }); + world.set_esdt_local_roles( + PAIR, + LP_TOKEN_ID, + &[EsdtLocalRole::Mint, EsdtLocalRole::Burn], + ); + + for (token_id, amount) in [ + ("WEGLD-abcdef", INITIAL_FIRST_RESERVE), + ("MEX-abcdef", INITIAL_SECOND_RESERVE), + ] { + world + .tx() + .from(USER) + .to(PAIR) + .payment(Payment::::try_new(token_id, 0u64, amount).unwrap()) + .whitebox(pair::contract_obj, |_sc| {}); + } + + world +} + +fn set_block(world: &mut ScenarioWorld, block_round: u64, block_timestamp_ms: u64) { + world + .current_block() + .block_round(block_round) + .block_timestamp_millis(TimestampMillis::new(block_timestamp_ms)); +} + +fn swap_first_for_second(world: &mut ScenarioWorld) { + world + .tx() + .from(USER) + .to(PAIR) + .payment(Payment::::try_new("WEGLD-abcdef", 0u64, SWAP_AMOUNT).unwrap()) + .whitebox(pair::contract_obj, |sc| { + let output = + sc.swap_tokens_fixed_input(managed_token_id!(MEX_TOKEN_ID), managed_biguint!(1u64)); + assert_eq!(output.token_identifier, managed_token_id!(MEX_TOKEN_ID)); + assert_eq!(output.amount, managed_biguint!(SWAP_AMOUNT)); + }); +} + +fn swap_second_for_first(world: &mut ScenarioWorld) { + world + .tx() + .from(USER) + .to(PAIR) + .payment(Payment::::try_new("MEX-abcdef", 0u64, SWAP_AMOUNT).unwrap()) + .whitebox(pair::contract_obj, |sc| { + let output = sc + .swap_tokens_fixed_input(managed_token_id!(WEGLD_TOKEN_ID), managed_biguint!(1u64)); + assert_eq!(output.token_identifier, managed_token_id!(WEGLD_TOKEN_ID)); + assert_eq!(output.amount, managed_biguint!(SWAP_AMOUNT)); + }); +} + +fn swap_first_for_fixed_second_output(world: &mut ScenarioWorld, output_amount: u64) { + world + .tx() + .from(USER) + .to(PAIR) + .payment(Payment::::try_new("WEGLD-abcdef", 0u64, SWAP_AMOUNT).unwrap()) + .whitebox(pair::contract_obj, |sc| { + let (output, residuum) = sc + .swap_tokens_fixed_output( + managed_token_id!(MEX_TOKEN_ID), + managed_biguint!(output_amount), + ) + .into_tuple(); + assert_eq!(output.token_identifier, managed_token_id!(MEX_TOKEN_ID)); + assert_eq!(output.amount, managed_biguint!(output_amount)); + assert_eq!(residuum.token_identifier, managed_token_id!(WEGLD_TOKEN_ID)); + assert!(residuum.amount > 0u64); + }); +} + +fn add_proportional_liquidity(world: &mut ScenarioWorld, first_amount: u64, second_amount: u64) { + let mut payments = PaymentVec::::new(); + payments.push(Payment::try_new("WEGLD-abcdef", 0u64, first_amount).unwrap()); + payments.push(Payment::try_new("MEX-abcdef", 0u64, second_amount).unwrap()); + + world + .tx() + .from(USER) + .to(PAIR) + .payment(payments) + .whitebox(pair::contract_obj, |sc| { + let (lp_payment, first_added, second_added) = sc + .add_liquidity(managed_biguint!(1u64), managed_biguint!(1u64)) + .into_tuple(); + assert_eq!(lp_payment.token_identifier, managed_token_id!(LP_TOKEN_ID)); + assert_eq!(lp_payment.amount, managed_biguint!(first_amount)); + assert_eq!(first_added.amount, managed_biguint!(first_amount)); + assert_eq!(second_added.amount, managed_biguint!(second_amount)); + }); +} + +fn remove_liquidity_position(world: &mut ScenarioWorld, lp_amount: u64) { + world + .tx() + .from(USER) + .to(PAIR) + .payment(Payment::::try_new("LPTOK-abcdef", 0u64, lp_amount).unwrap()) + .whitebox(pair::contract_obj, |sc| { + let (first_payment, second_payment) = sc + .remove_liquidity(managed_biguint!(1u64), managed_biguint!(1u64)) + .into_tuple(); + assert_eq!( + first_payment.token_identifier, + managed_token_id!(WEGLD_TOKEN_ID) + ); + assert_eq!( + second_payment.token_identifier, + managed_token_id!(MEX_TOKEN_ID) + ); + assert!(first_payment.amount > 0u64); + assert!(second_payment.amount > 0u64); + }); +} + +fn set_raw_router_default_offset(world: &mut ScenarioWorld, offset_ms: u64) { + set_raw_router_config(world, offset_ms, 6_000u64); +} + +fn set_raw_router_config(world: &mut ScenarioWorld, default_offset_ms: u64, save_interval_ms: u64) { + let encoded_default_offset = match default_offset_ms { + 600u64 => "u64:600", + 3_000u64 => "u64:3000", + 6_000u64 => "u64:6000", + 12_000u64 => "u64:12000", + 18_000u64 => "u64:18000", + 24_000u64 => "u64:24000", + 42_000u64 => "u64:42000", + 60_000u64 => "u64:60000", + _ => panic!("unsupported deterministic default offset: {default_offset_ms}"), + }; + let encoded_save_interval = match save_interval_ms { + 6_000u64 => "u64:6000", + 6_500u64 => "u64:6500", + _ => panic!("unsupported deterministic save interval: {save_interval_ms}"), + }; + let mut router = Account::new().balance(0u64); + router.storage.insert( + "str:default_safe_price_timestamp_offset".into(), + encoded_default_offset.into(), + ); + router.storage.insert( + "str:safe_price_timestamp_save_interval".into(), + encoded_save_interval.into(), + ); + world.set_state_step(SetStateStep::new().put_account(ROUTER, router)); +} + +fn remove_raw_router_save_interval(world: &mut ScenarioWorld) { + let mut router = Account::new().balance(0u64); + router.storage.insert( + "str:default_safe_price_timestamp_offset".into(), + "u64:6000".into(), + ); + world.set_state_step(SetStateStep::new().put_account(ROUTER, router)); +} + +fn assert_failed_action_preserved_upgraded_baseline(world: &mut ScenarioWorld) { + world.query().to(PAIR).whitebox(pair::contract_obj, |sc| { + assert_eq!(sc.price_observations().len(), 3usize); + assert_eq!(sc.safe_price_current_index().get(), 3usize); + let current = sc.current_price_observation().get(); + assert_eq!(current.recording_round, 102u64); + assert_eq!(current.recording_timestamp, 612_000u64); + assert_eq!(current.weight_accumulated, 612_000u64); + assert_eq!( + current.first_token_reserve_accumulated, + managed_biguint!(616_896_000_000u64) + ); + assert_eq!( + current.second_token_reserve_accumulated, + managed_biguint!(1_233_792_000_000u64) + ); + assert_eq!(current.lp_supply_accumulated, managed_biguint!(0u64)); + assert_eq!( + sc.pair_reserve(&managed_token_id!(WEGLD_TOKEN_ID)).get(), + managed_biguint!(INITIAL_FIRST_RESERVE) + ); + assert_eq!( + sc.pair_reserve(&managed_token_id!(MEX_TOKEN_ID)).get(), + managed_biguint!(INITIAL_SECOND_RESERVE) + ); + }); + world + .check_account(USER) + .esdt_balance( + TestTokenIdentifier::new("WEGLD-abcdef"), + 100_000_000u64 - INITIAL_FIRST_RESERVE, + ) + .esdt_balance( + TestTokenIdentifier::new("MEX-abcdef"), + 100_000_000u64 - INITIAL_SECOND_RESERVE, + ); + world + .check_account(PAIR) + .esdt_balance( + TestTokenIdentifier::new("WEGLD-abcdef"), + INITIAL_FIRST_RESERVE, + ) + .esdt_balance( + TestTokenIdentifier::new("MEX-abcdef"), + INITIAL_SECOND_RESERVE, + ); +} + +fn assert_token_quote_by_timestamp_range( + world: &mut ScenarioWorld, + start_timestamp_ms: u64, + end_timestamp_ms: u64, + expected_amount: u64, +) { + world.query().to(PAIR).whitebox(pair::contract_obj, |sc| { + let quote = sc.get_safe_price_by_timestamp_range( + PAIR.to_managed_address(), + start_timestamp_ms, + end_timestamp_ms, + EsdtTokenPayment::new( + managed_token_id!(WEGLD_TOKEN_ID), + 0, + managed_biguint!(QUOTE_INPUT), + ), + ); + assert_eq!(quote.token_identifier, managed_token_id!(MEX_TOKEN_ID)); + assert_eq!(quote.amount, managed_biguint!(expected_amount)); + }); +} + +fn assert_finalized_cumulative_delta( + world: &mut ScenarioWorld, + start_index: usize, + end_index: usize, + duration_ms: u64, + first_reserve: u64, + second_reserve: u64, + lp_supply: u64, +) { + world.query().to(PAIR).whitebox(pair::contract_obj, |sc| { + let start = sc.price_observations().get(start_index); + let end = sc.price_observations().get(end_index); + assert_eq!( + &end.first_token_reserve_accumulated - &start.first_token_reserve_accumulated, + managed_biguint!(duration_ms * first_reserve) + ); + assert_eq!( + &end.second_token_reserve_accumulated - &start.second_token_reserve_accumulated, + managed_biguint!(duration_ms * second_reserve) + ); + assert_eq!( + &end.lp_supply_accumulated - &start.lp_supply_accumulated, + managed_biguint!(duration_ms * lp_supply) + ); + assert_eq!( + end.weight_accumulated - start.weight_accumulated, + duration_ms + ); + assert_eq!( + end.recording_timestamp - start.recording_timestamp, + duration_ms + ); + }); +} + +fn assert_default_offset_uses_expected_window( + world: &mut ScenarioWorld, + ledger: &ReferenceLedger, + configured_offset_ms: u64, + expected_start_timestamp_ms: u64, + end_timestamp_ms: u64, +) { + set_raw_router_config(world, configured_offset_ms, 6_000u64); + let expected = ledger.expected(expected_start_timestamp_ms, end_timestamp_ms); + world.query().to(PAIR).whitebox(pair::contract_obj, |sc| { + let token_quote = sc.get_safe_price_by_default_offset( + PAIR.to_managed_address(), + EsdtTokenPayment::new( + managed_token_id!(WEGLD_TOKEN_ID), + 0, + managed_biguint!(QUOTE_INPUT), + ), + ); + assert_eq!( + token_quote.token_identifier, + managed_token_id!(MEX_TOKEN_ID) + ); + assert_eq!( + token_quote.amount, + managed_biguint!(expected.first_to_second) + ); + + let (first_lp_quote, second_lp_quote) = sc + .get_lp_tokens_safe_price_by_default_offset( + PAIR.to_managed_address(), + managed_biguint!(LP_QUOTE), + ) + .into_tuple(); + assert_eq!(first_lp_quote.amount, managed_biguint!(expected.lp_first)); + assert_eq!(second_lp_quote.amount, managed_biguint!(expected.lp_second)); + }); +} + +fn seed_four_field_legacy_history_and_upgrade(world: &mut ScenarioWorld) -> ReferenceLedger { + // Unit tests cannot execute the old deployed WASM. This is the only direct storage fixture: + // it writes the deployed four-field value shape, then all lifecycle actions use current + // production entrypoints and the real Pair upgrade handler. + world + .tx() + .from(OWNER) + .to(PAIR) + .whitebox(pair::contract_obj, |sc| { + let mut legacy_observations = + VecMapper::>::new(StorageKey::new( + b"price_observations", + )); + for observation in [ + LegacyPriceObservation { + first_token_reserve_accumulated: managed_biguint!(100_800_000u64), + second_token_reserve_accumulated: managed_biguint!(201_600_000u64), + weight_accumulated: 100u64, + recording_round: 100u64, + }, + LegacyPriceObservation { + first_token_reserve_accumulated: managed_biguint!(101_808_000u64), + second_token_reserve_accumulated: managed_biguint!(203_616_000u64), + weight_accumulated: 101u64, + recording_round: 101u64, + }, + LegacyPriceObservation { + first_token_reserve_accumulated: managed_biguint!(102_816_000u64), + second_token_reserve_accumulated: managed_biguint!(205_632_000u64), + weight_accumulated: 102u64, + recording_round: 102u64, + }, + ] { + legacy_observations.push(&observation); + } + sc.safe_price_current_index().set(3usize); + + let decoded: PriceObservation = sc.price_observations().get(3usize); + assert_eq!(decoded.recording_round, 102u64); + assert_eq!(decoded.recording_timestamp, 0u64); + assert_eq!(decoded.lp_supply_accumulated, 0u64); + assert!(sc.current_price_observation().is_empty()); + assert!(sc.safe_price_legacy_cutover().is_empty()); + }); + + world + .tx() + .from(OWNER) + .to(PAIR) + .whitebox(pair::contract_obj, |sc| { + assert_eq!( + sc.blockchain() + .get_block_round_time_millis() + .as_u64_millis(), + 6_000u64 + ); + sc.upgrade(); + }); + + world.query().to(PAIR).whitebox(pair::contract_obj, |sc| { + assert_eq!(sc.safe_price_legacy_cutover().get(), (102u64, 612_000u64)); + let current = sc.current_price_observation().get(); + assert_eq!(current.recording_round, 102u64); + assert_eq!(current.recording_timestamp, 612_000u64); + assert_eq!(current.weight_accumulated, 612_000u64); + assert_eq!( + current.first_token_reserve_accumulated, + managed_biguint!(616_896_000_000u64) + ); + assert_eq!( + current.second_token_reserve_accumulated, + managed_biguint!(1_233_792_000_000u64) + ); + assert_eq!(current.lp_supply_accumulated, 0u64); + + let still_four_field: PriceObservation = sc.price_observations().get(1usize); + assert_eq!(still_four_field.recording_timestamp, 0u64); + assert_eq!(still_four_field.lp_supply_accumulated, 0u64); + }); + + let mut ledger = ReferenceLedger::default(); + ledger.push( + 600_000u64, + 606_000u64, + INITIAL_FIRST_RESERVE, + INITIAL_SECOND_RESERVE, + ); + ledger.push( + 606_000u64, + 612_000u64, + INITIAL_FIRST_RESERVE, + INITIAL_SECOND_RESERVE, + ); + ledger +} + +fn upgrade_empty_legacy_history(world: &mut ScenarioWorld) { + world + .tx() + .from(OWNER) + .to(PAIR) + .whitebox(pair::contract_obj, |sc| { + assert_eq!( + sc.blockchain() + .get_block_round_time_millis() + .as_u64_millis(), + 6_000u64 + ); + assert!(sc.price_observations().is_empty()); + sc.upgrade(); + }); + + world.query().to(PAIR).whitebox(pair::contract_obj, |sc| { + assert!(sc.price_observations().is_empty()); + assert!(sc.current_price_observation().is_empty()); + assert!(sc.safe_price_legacy_cutover().is_empty()); + }); +} + +fn assert_window_through_every_endpoint_family( + world: &mut ScenarioWorld, + ledger: &ReferenceLedger, + start_round: u64, + end_round: u64, + start_timestamp_ms: u64, + end_timestamp_ms: u64, +) { + let expected = ledger.expected(start_timestamp_ms, end_timestamp_ms); + let offset_ms = end_timestamp_ms - start_timestamp_ms; + assert_eq!(expected.weight_ms, offset_ms); + assert_eq!( + offset_ms % 1_000, + 0, + "legacy seconds endpoint needs an exact second window" + ); + assert!(expected.first_reserve_weighted > 0); + assert!(expected.second_reserve_weighted > 0); + assert!(expected.lp_supply_weighted > 0); + + set_raw_router_default_offset(world, offset_ms); + + world.query().to(PAIR).whitebox(pair::contract_obj, |sc| { + assert_eq!(sc.blockchain().get_block_round(), end_round); + assert_eq!( + sc.blockchain().get_block_timestamp_millis().as_u64_millis(), + end_timestamp_ms + ); + let pair_address = PAIR.to_managed_address(); + let round_offset = end_round - start_round; + let offset_seconds = offset_ms / 1_000; + let first_input = EsdtTokenPayment::new( + managed_token_id!(WEGLD_TOKEN_ID), + 0, + managed_biguint!(QUOTE_INPUT), + ); + let second_input = EsdtTokenPayment::new( + managed_token_id!(MEX_TOKEN_ID), + 0, + managed_biguint!(QUOTE_INPUT), + ); + + let first_to_second_quotes = [ + sc.get_safe_price( + pair_address.clone(), + start_round, + end_round, + first_input.clone(), + ), + sc.get_safe_price_by_round_offset( + pair_address.clone(), + round_offset, + first_input.clone(), + ), + sc.get_safe_price_by_timestamp_offset( + pair_address.clone(), + offset_seconds, + first_input.clone(), + ), + sc.get_safe_price_by_timestamp_offset_ms( + pair_address.clone(), + offset_ms, + first_input.clone(), + ), + sc.get_safe_price_by_default_offset(pair_address.clone(), first_input.clone()), + sc.update_and_get_safe_price(first_input), + ]; + for quote in first_to_second_quotes { + assert_eq!(quote.token_identifier, managed_token_id!(MEX_TOKEN_ID)); + assert_eq!(quote.amount, managed_biguint!(expected.first_to_second)); + } + + let second_to_first_quotes = [ + sc.get_safe_price( + pair_address.clone(), + start_round, + end_round, + second_input.clone(), + ), + sc.get_safe_price_by_round_offset( + pair_address.clone(), + round_offset, + second_input.clone(), + ), + sc.get_safe_price_by_timestamp_offset( + pair_address.clone(), + offset_seconds, + second_input.clone(), + ), + sc.get_safe_price_by_timestamp_offset_ms( + pair_address.clone(), + offset_ms, + second_input.clone(), + ), + sc.get_safe_price_by_default_offset(pair_address.clone(), second_input.clone()), + sc.update_and_get_safe_price(second_input), + ]; + for quote in second_to_first_quotes { + assert_eq!(quote.token_identifier, managed_token_id!(WEGLD_TOKEN_ID)); + assert_eq!(quote.amount, managed_biguint!(expected.second_to_first)); + } + + let lp_quotes = [ + sc.get_lp_tokens_safe_price( + pair_address.clone(), + start_round, + end_round, + managed_biguint!(LP_QUOTE), + ) + .into_tuple(), + sc.get_lp_tokens_safe_price_by_round_offset( + pair_address.clone(), + round_offset, + managed_biguint!(LP_QUOTE), + ) + .into_tuple(), + sc.get_lp_tokens_safe_price_by_timestamp_offset( + pair_address.clone(), + offset_seconds, + managed_biguint!(LP_QUOTE), + ) + .into_tuple(), + sc.get_lp_tokens_safe_price_by_timestamp_offset_ms( + pair_address.clone(), + offset_ms, + managed_biguint!(LP_QUOTE), + ) + .into_tuple(), + sc.get_lp_tokens_safe_price_by_default_offset(pair_address, managed_biguint!(LP_QUOTE)) + .into_tuple(), + sc.update_and_get_tokens_for_given_position_with_safe_price(managed_biguint!(LP_QUOTE)) + .into_tuple(), + ]; + for (first_payment, second_payment) in lp_quotes { + assert_eq!( + first_payment.token_identifier, + managed_token_id!(WEGLD_TOKEN_ID) + ); + assert_eq!(first_payment.amount, managed_biguint!(expected.lp_first)); + assert_eq!( + second_payment.token_identifier, + managed_token_id!(MEX_TOKEN_ID) + ); + assert_eq!(second_payment.amount, managed_biguint!(expected.lp_second)); + } + }); +} + +#[test] +fn fresh_post_supernova_pair_tracks_actual_swaps_at_600ms_cadence() { + let mut world = setup_pair(600u64, 190u64, 114_000u64); + let mut ledger = ReferenceLedger::default(); + + // Bootstrap native history, then create the first finalized boundary after 6 seconds. + swap_first_for_second(&mut world); + + set_block(&mut world, 200u64, 120_000u64); + swap_second_for_first(&mut world); + + set_block(&mut world, 215u64, 129_000u64); + swap_first_for_second(&mut world); + ledger.push( + 120_000u64, + 129_000u64, + INITIAL_FIRST_RESERVE, + INITIAL_SECOND_RESERVE, + ); + + set_block(&mut world, 220u64, 132_000u64); + swap_second_for_first(&mut world); + ledger.push( + 129_000u64, + 132_000u64, + INITIAL_SECOND_RESERVE, + INITIAL_FIRST_RESERVE, + ); + + let expected = ledger.expected(120_000u64, 132_000u64); + assert_ne!(expected.first_to_second, expected.second_to_first); + assert!(expected.first_to_second_remainder > 0u128); + assert!(expected.second_to_first_remainder > 0u128); + assert!(expected.lp_first_remainder > 0u128); + assert!(expected.lp_second_remainder > 0u128); + + assert_window_through_every_endpoint_family( + &mut world, &ledger, 200u64, 220u64, 120_000u64, 132_000u64, + ); +} + +#[test] +fn legacy_pair_upgrade_before_supernova_preserves_every_reachable_window() { + let mut world = setup_pair(6_000u64, 102u64, 612_000u64); + let mut ledger = seed_four_field_legacy_history_and_upgrade(&mut world); + + // A-A: pre-upgrade/pre-Supernova legacy history, observed after the real upgrade. + assert_window_through_every_endpoint_family( + &mut world, &ledger, 100u64, 102u64, 600_000u64, 612_000u64, + ); + + // Phase B uses timestamp-millisecond accounting while runtime rounds are still 6 seconds. + set_block(&mut world, 103u64, 618_000u64); + swap_first_for_second(&mut world); + ledger.push( + 612_000u64, + 618_000u64, + INITIAL_FIRST_RESERVE, + INITIAL_SECOND_RESERVE, + ); + + set_block(&mut world, 104u64, 624_000u64); + swap_second_for_first(&mut world); + ledger.push( + 618_000u64, + 624_000u64, + INITIAL_SECOND_RESERVE, + INITIAL_FIRST_RESERVE, + ); + + // A-B and B-B are both trailing windows at the final Phase-B block. + assert_window_through_every_endpoint_family( + &mut world, &ledger, 100u64, 104u64, 600_000u64, 624_000u64, + ); + assert_window_through_every_endpoint_family( + &mut world, &ledger, 103u64, 104u64, 618_000u64, 624_000u64, + ); + + // Phase C switches only the ScenarioWorld runtime cadence. The first action is pending + // at +3s; the second reaches the configured 6s save interval and finalizes the aggregate. + world.block_round_time_ms(600u64); + set_block(&mut world, 109u64, 627_000u64); + swap_first_for_second(&mut world); + ledger.push( + 624_000u64, + 627_000u64, + INITIAL_FIRST_RESERVE, + INITIAL_SECOND_RESERVE, + ); + + set_block(&mut world, 114u64, 630_000u64); + swap_second_for_first(&mut world); + ledger.push( + 627_000u64, + 630_000u64, + INITIAL_SECOND_RESERVE, + INITIAL_FIRST_RESERVE, + ); + + // Create a finalized C boundary, then end on a pending current observation. This makes + // a C-C window exactly reachable without assuming an unsaved breakpoint is queryable. + set_block(&mut world, 129u64, 639_000u64); + swap_first_for_second(&mut world); + ledger.push( + 630_000u64, + 639_000u64, + INITIAL_FIRST_RESERVE, + INITIAL_SECOND_RESERVE, + ); + + set_block(&mut world, 134u64, 642_000u64); + swap_second_for_first(&mut world); + ledger.push( + 639_000u64, + 642_000u64, + INITIAL_SECOND_RESERVE, + INITIAL_FIRST_RESERVE, + ); + + world.query().to(PAIR).whitebox(pair::contract_obj, |sc| { + let finalized = sc + .price_observations() + .get(sc.safe_price_current_index().get()); + assert_eq!(finalized.recording_timestamp, 639_000u64); + assert_eq!( + sc.current_price_observation().get().recording_timestamp, + 642_000u64 + ); + }); + + // A-C, B-C, and C-C complete the six reachable chronological window classes. + assert_window_through_every_endpoint_family( + &mut world, &ledger, 100u64, 134u64, 600_000u64, 642_000u64, + ); + assert_window_through_every_endpoint_family( + &mut world, &ledger, 103u64, 134u64, 618_000u64, 642_000u64, + ); + assert_window_through_every_endpoint_family( + &mut world, &ledger, 129u64, 134u64, 639_000u64, 642_000u64, + ); +} + +#[test] +fn empty_legacy_pair_upgrade_then_post_supernova_swaps_build_native_history() { + let mut world = setup_pair(6_000u64, 102u64, 612_000u64); + upgrade_empty_legacy_history(&mut world); + + // No Phase-B swap occurs. The first two Phase-C swaps bootstrap the native history and + // create its first finalized boundary under the unchanged 6,000ms save interval. + world.block_round_time_ms(600u64); + set_block(&mut world, 103u64, 612_600u64); + swap_first_for_second(&mut world); + + set_block(&mut world, 112u64, 618_000u64); + swap_second_for_first(&mut world); + + let mut ledger = ReferenceLedger::default(); + set_block(&mut world, 127u64, 627_000u64); + swap_first_for_second(&mut world); + ledger.push( + 618_000u64, + 627_000u64, + INITIAL_FIRST_RESERVE, + INITIAL_SECOND_RESERVE, + ); + + set_block(&mut world, 132u64, 630_000u64); + swap_second_for_first(&mut world); + ledger.push( + 627_000u64, + 630_000u64, + INITIAL_SECOND_RESERVE, + INITIAL_FIRST_RESERVE, + ); + + world.query().to(PAIR).whitebox(pair::contract_obj, |sc| { + assert!(sc.safe_price_legacy_cutover().is_empty()); + let oldest: PriceObservation = sc.price_observations().get(1usize); + assert!(oldest.recording_timestamp > 0); + assert!(oldest.lp_supply_accumulated > 0u64); + }); + + assert_window_through_every_endpoint_family( + &mut world, &ledger, 112u64, 132u64, 618_000u64, 630_000u64, + ); +} + +#[test] +fn populated_legacy_upgrade_without_phase_b_actions_preserves_a_c_and_c_c_windows() { + let mut world = setup_pair(6_000u64, 102u64, 612_000u64); + let mut ledger = seed_four_field_legacy_history_and_upgrade(&mut world); + + world.block_round_time_ms(600u64); + set_block(&mut world, 112u64, 618_000u64); + swap_first_for_second(&mut world); + ledger.push( + 612_000u64, + 618_000u64, + INITIAL_FIRST_RESERVE, + INITIAL_SECOND_RESERVE, + ); + + set_block(&mut world, 122u64, 624_000u64); + swap_second_for_first(&mut world); + ledger.push( + 618_000u64, + 624_000u64, + INITIAL_SECOND_RESERVE, + INITIAL_FIRST_RESERVE, + ); + + assert_window_through_every_endpoint_family( + &mut world, &ledger, 100u64, 122u64, 600_000u64, 624_000u64, + ); + assert_window_through_every_endpoint_family( + &mut world, &ledger, 112u64, 122u64, 618_000u64, 624_000u64, + ); +} + +#[test] +fn first_post_supernova_action_exactly_six_hundred_ms_after_cutover_adds_one_round_weight() { + let mut world = setup_pair(6_000u64, 102u64, 612_000u64); + seed_four_field_legacy_history_and_upgrade(&mut world); + + world.block_round_time_ms(600u64); + set_block(&mut world, 103u64, 612_600u64); + swap_first_for_second(&mut world); + + world.query().to(PAIR).whitebox(pair::contract_obj, |sc| { + let current = sc.current_price_observation().get(); + assert_eq!(current.recording_round, 103u64); + assert_eq!(current.recording_timestamp, 612_600u64); + assert_eq!(current.weight_accumulated, 612_600u64); + assert_eq!( + current.first_token_reserve_accumulated, + managed_biguint!(616_896_000_000u64 + 600u64 * INITIAL_FIRST_RESERVE) + ); + assert_eq!( + current.second_token_reserve_accumulated, + managed_biguint!(1_233_792_000_000u64 + 600u64 * INITIAL_SECOND_RESERVE) + ); + assert_eq!( + current.lp_supply_accumulated, + managed_biguint!(600u64 * LP_SUPPLY) + ); + }); +} + +#[test] +fn action_at_exact_cutover_timestamp_is_weight_noop_and_next_round_uses_post_action_reserves() { + let mut world = setup_pair(6_000u64, 102u64, 612_000u64); + seed_four_field_legacy_history_and_upgrade(&mut world); + + world.block_round_time_ms(600u64); + swap_first_for_second(&mut world); + world.query().to(PAIR).whitebox(pair::contract_obj, |sc| { + assert_eq!(sc.price_observations().len(), 3usize); + assert_eq!(sc.safe_price_current_index().get(), 3usize); + let current = sc.current_price_observation().get(); + assert_eq!(current.recording_timestamp, 612_000u64); + assert_eq!(current.weight_accumulated, 612_000u64); + assert_eq!( + current.first_token_reserve_accumulated, + managed_biguint!(616_896_000_000u64) + ); + assert_eq!( + current.second_token_reserve_accumulated, + managed_biguint!(1_233_792_000_000u64) + ); + }); + + set_block(&mut world, 103u64, 612_600u64); + swap_second_for_first(&mut world); + world.query().to(PAIR).whitebox(pair::contract_obj, |sc| { + let current = sc.current_price_observation().get(); + assert_eq!(current.recording_timestamp, 612_600u64); + assert_eq!(current.weight_accumulated, 612_600u64); + assert_eq!( + current.first_token_reserve_accumulated, + managed_biguint!(616_896_000_000u64 + 600u64 * INITIAL_SECOND_RESERVE) + ); + assert_eq!( + current.second_token_reserve_accumulated, + managed_biguint!(1_233_792_000_000u64 + 600u64 * INITIAL_FIRST_RESERVE) + ); + }); +} + +#[test] +fn minimal_post_supernova_window_of_six_hundred_ms_is_queryable_by_ms_and_round() { + let mut world = setup_pair(6_000u64, 102u64, 612_000u64); + seed_four_field_legacy_history_and_upgrade(&mut world); + + world.block_round_time_ms(600u64); + set_block(&mut world, 103u64, 612_600u64); + swap_first_for_second(&mut world); + + world.query().to(PAIR).whitebox(pair::contract_obj, |sc| { + let input = EsdtTokenPayment::new( + managed_token_id!(WEGLD_TOKEN_ID), + 0, + managed_biguint!(QUOTE_INPUT), + ); + let expected = managed_biguint!(QUOTE_INPUT * 2u64); + let by_ms = sc.get_safe_price_by_timestamp_offset_ms( + PAIR.to_managed_address(), + 600u64, + input.clone(), + ); + let by_round = sc.get_safe_price(PAIR.to_managed_address(), 102u64, 103u64, input); + assert_eq!(by_ms.amount, expected); + assert_eq!(by_round.amount, expected); + }); +} + +#[test] +fn non_aligned_six_thousand_five_hundred_ms_save_interval_keeps_pending_then_finalizes() { + let mut world = setup_pair(6_000u64, 102u64, 612_000u64); + seed_four_field_legacy_history_and_upgrade(&mut world); + set_raw_router_config(&mut world, 6_000u64, 6_500u64); + + world.block_round_time_ms(600u64); + set_block(&mut world, 112u64, 618_000u64); + swap_first_for_second(&mut world); + world.query().to(PAIR).whitebox(pair::contract_obj, |sc| { + assert_eq!(sc.price_observations().len(), 3usize); + assert_eq!(sc.safe_price_current_index().get(), 3usize); + assert_eq!( + sc.current_price_observation().get().recording_timestamp, + 618_000u64 + ); + }); + + set_block(&mut world, 113u64, 618_600u64); + swap_second_for_first(&mut world); + world.query().to(PAIR).whitebox(pair::contract_obj, |sc| { + assert_eq!(sc.safe_price_current_index().get(), 4usize); + assert_eq!( + sc.price_observations().get(4usize).recording_timestamp, + 618_600u64 + ); + assert_eq!( + sc.current_price_observation().get().recording_timestamp, + 618_600u64 + ); + }); +} + +#[test] +fn fresh_pair_writer_uses_future_two_hundred_ms_runtime_cadence() { + let mut world = setup_pair(200u64, 100u64, 20_000u64); + swap_first_for_second(&mut world); + + set_block(&mut world, 101u64, 20_200u64); + swap_second_for_first(&mut world); + + world.query().to(PAIR).whitebox(pair::contract_obj, |sc| { + assert!(sc.price_observations().is_empty()); + let current = sc.current_price_observation().get(); + assert_eq!(current.recording_round, 101u64); + assert_eq!(current.recording_timestamp, 20_200u64); + assert_eq!(current.weight_accumulated, 400u64); + assert_eq!( + current.first_token_reserve_accumulated, + managed_biguint!(200u64 * INITIAL_FIRST_RESERVE + 200u64 * INITIAL_SECOND_RESERVE) + ); + assert_eq!( + current.second_token_reserve_accumulated, + managed_biguint!(200u64 * INITIAL_SECOND_RESERVE + 200u64 * INITIAL_FIRST_RESERVE) + ); + assert_eq!( + current.lp_supply_accumulated, + managed_biguint!(400u64 * LP_SUPPLY) + ); + }); +} + +#[test] +fn fixed_output_swap_post_supernova_weights_the_resulting_reserves_in_the_next_segment() { + let mut world = setup_pair(6_000u64, 102u64, 612_000u64); + seed_four_field_legacy_history_and_upgrade(&mut world); + + world.block_round_time_ms(600u64); + set_block(&mut world, 103u64, 612_600u64); + swap_first_for_fixed_second_output(&mut world, 504_000u64); + world.query().to(PAIR).whitebox(pair::contract_obj, |sc| { + assert_eq!( + sc.pair_reserve(&managed_token_id!(WEGLD_TOKEN_ID)).get(), + managed_biguint!(1_344_001u64) + ); + assert_eq!( + sc.pair_reserve(&managed_token_id!(MEX_TOKEN_ID)).get(), + managed_biguint!(1_512_000u64) + ); + }); + + set_block(&mut world, 113u64, 618_600u64); + swap_first_for_fixed_second_output(&mut world, 252_000u64); + + let average_first = + (u128::from(INITIAL_FIRST_RESERVE) * 600u128 + 1_344_001u128 * 6_000u128) / 6_600u128; + let average_second = + (u128::from(INITIAL_SECOND_RESERVE) * 600u128 + 1_512_000u128 * 6_000u128) / 6_600u128; + let expected = u64::try_from(u128::from(QUOTE_INPUT) * average_second / average_first).unwrap(); + assert_token_quote_by_timestamp_range(&mut world, 612_000u64, 618_600u64, expected); +} + +#[test] +fn add_liquidity_across_migration_weights_new_reserves_and_lp_supply() { + let mut world = setup_pair(6_000u64, 102u64, 612_000u64); + seed_four_field_legacy_history_and_upgrade(&mut world); + + set_block(&mut world, 103u64, 618_000u64); + add_proportional_liquidity(&mut world, 504_000u64, 1_008_000u64); + world.query().to(PAIR).whitebox(pair::contract_obj, |sc| { + assert_eq!(sc.lp_token_supply().get(), managed_biguint!(1_512_000u64)); + assert_eq!( + sc.pair_reserve(&managed_token_id!(WEGLD_TOKEN_ID)).get(), + managed_biguint!(1_512_000u64) + ); + assert_eq!( + sc.pair_reserve(&managed_token_id!(MEX_TOKEN_ID)).get(), + managed_biguint!(3_024_000u64) + ); + }); + + world.block_round_time_ms(600u64); + set_block(&mut world, 113u64, 624_000u64); + swap_first_for_fixed_second_output(&mut world, 504_000u64); + assert_finalized_cumulative_delta( + &mut world, + 4usize, + 5usize, + 6_000u64, + 1_512_000u64, + 3_024_000u64, + 1_512_000u64, + ); + + let mut ledger = ReferenceLedger::default(); + ledger.push_with_lp_supply( + 618_000u64, + 624_000u64, + 1_512_000u64, + 3_024_000u64, + 1_512_000u64, + ); + assert_window_through_every_endpoint_family( + &mut world, &ledger, 103u64, 113u64, 618_000u64, 624_000u64, + ); +} + +#[test] +fn remove_liquidity_post_supernova_weights_reduced_reserves_and_lp_supply() { + let mut world = setup_pair(6_000u64, 102u64, 612_000u64); + seed_four_field_legacy_history_and_upgrade(&mut world); + + add_proportional_liquidity(&mut world, 504_000u64, 1_008_000u64); + world.block_round_time_ms(600u64); + set_block(&mut world, 112u64, 618_000u64); + remove_liquidity_position(&mut world, 252_000u64); + world.query().to(PAIR).whitebox(pair::contract_obj, |sc| { + assert_eq!(sc.lp_token_supply().get(), managed_biguint!(1_260_000u64)); + assert_eq!( + sc.pair_reserve(&managed_token_id!(WEGLD_TOKEN_ID)).get(), + managed_biguint!(1_260_000u64) + ); + assert_eq!( + sc.pair_reserve(&managed_token_id!(MEX_TOKEN_ID)).get(), + managed_biguint!(2_520_000u64) + ); + }); + + set_block(&mut world, 122u64, 624_000u64); + swap_first_for_fixed_second_output(&mut world, 420_000u64); + assert_finalized_cumulative_delta( + &mut world, + 4usize, + 5usize, + 6_000u64, + 1_260_000u64, + 2_520_000u64, + 1_260_000u64, + ); + + let mut ledger = ReferenceLedger::default(); + ledger.push_with_lp_supply( + 618_000u64, + 624_000u64, + 1_260_000u64, + 2_520_000u64, + 1_260_000u64, + ); + assert_window_through_every_endpoint_family( + &mut world, &ledger, 112u64, 122u64, 618_000u64, 624_000u64, + ); +} + +#[test] +fn two_real_actions_at_same_timestamp_add_weight_once_and_next_segment_uses_final_reserves() { + let mut world = setup_pair(6_000u64, 102u64, 612_000u64); + seed_four_field_legacy_history_and_upgrade(&mut world); + + world.block_round_time_ms(600u64); + set_block(&mut world, 103u64, 612_600u64); + swap_first_for_second(&mut world); + swap_second_for_first(&mut world); + world.query().to(PAIR).whitebox(pair::contract_obj, |sc| { + let current = sc.current_price_observation().get(); + assert_eq!(current.weight_accumulated, 612_600u64); + assert_eq!( + sc.pair_reserve(&managed_token_id!(WEGLD_TOKEN_ID)).get(), + managed_biguint!(INITIAL_FIRST_RESERVE) + ); + assert_eq!( + sc.pair_reserve(&managed_token_id!(MEX_TOKEN_ID)).get(), + managed_biguint!(INITIAL_SECOND_RESERVE) + ); + }); + + set_block(&mut world, 104u64, 613_200u64); + swap_first_for_second(&mut world); + world.query().to(PAIR).whitebox(pair::contract_obj, |sc| { + let current = sc.current_price_observation().get(); + assert_eq!(current.weight_accumulated, 613_200u64); + assert_eq!( + current.first_token_reserve_accumulated, + managed_biguint!(616_896_000_000u64 + 1_200u64 * INITIAL_FIRST_RESERVE) + ); + assert_eq!( + current.second_token_reserve_accumulated, + managed_biguint!(1_233_792_000_000u64 + 1_200u64 * INITIAL_SECOND_RESERVE) + ); + }); +} + +#[test] +fn failed_swap_after_safe_price_update_rolls_back_oracle_reserves_and_balances() { + let mut world = setup_pair(6_000u64, 102u64, 612_000u64); + seed_four_field_legacy_history_and_upgrade(&mut world); + world.block_round_time_ms(600u64); + set_block(&mut world, 112u64, 618_000u64); + + world + .tx() + .from(USER) + .to(PAIR) + .payment(Payment::::try_new("WEGLD-abcdef", 0u64, SWAP_AMOUNT).unwrap()) + .returns(ExpectError(4, "Slippage exceeded")) + .whitebox(pair::contract_obj, |sc| { + sc.swap_tokens_fixed_input( + managed_token_id!(MEX_TOKEN_ID), + managed_biguint!(1_500_000u64), + ); + }); + + assert_failed_action_preserved_upgraded_baseline(&mut world); +} + +#[test] +fn missing_router_save_interval_reverts_real_swap_and_all_safe_price_side_effects() { + let mut world = setup_pair(6_000u64, 102u64, 612_000u64); + seed_four_field_legacy_history_and_upgrade(&mut world); + remove_raw_router_save_interval(&mut world); + world.block_round_time_ms(600u64); + set_block(&mut world, 112u64, 618_000u64); + + world + .tx() + .from(USER) + .to(PAIR) + .payment(Payment::::try_new("WEGLD-abcdef", 0u64, SWAP_AMOUNT).unwrap()) + .returns(ExpectError(4, "Safe price timestamp save interval not set")) + .whitebox(pair::contract_obj, |sc| { + sc.swap_tokens_fixed_input(managed_token_id!(MEX_TOKEN_ID), managed_biguint!(1u64)); + }); + + assert_failed_action_preserved_upgraded_baseline(&mut world); +} + +#[test] +fn timestamp_offsets_zero_equal_to_current_and_greater_than_current_fail_closed() { + let mut world = setup_pair(6_000u64, 102u64, 612_000u64); + seed_four_field_legacy_history_and_upgrade(&mut world); + + for invalid_offset in [0u64, 612_000u64, 612_001u64] { + world + .query() + .to(PAIR) + .returns(ExpectError(4, "Bad parameters")) + .whitebox(pair::contract_obj, |sc| { + sc.get_safe_price_by_timestamp_offset_ms( + PAIR.to_managed_address(), + invalid_offset, + EsdtTokenPayment::new( + managed_token_id!(WEGLD_TOKEN_ID), + 0, + managed_biguint!(QUOTE_INPUT), + ), + ); + }); + } +} + +#[test] +fn equal_reversed_before_oldest_and_future_timestamp_ranges_fail_closed() { + let mut world = setup_pair(6_000u64, 102u64, 612_000u64); + seed_four_field_legacy_history_and_upgrade(&mut world); + + for (start, end) in [(606_000u64, 606_000u64), (606_000u64, 600_000u64)] { + world + .query() + .to(PAIR) + .returns(ExpectError(4, "Bad parameters")) + .whitebox(pair::contract_obj, |sc| { + sc.get_safe_price_by_timestamp_range( + PAIR.to_managed_address(), + start, + end, + EsdtTokenPayment::new( + managed_token_id!(WEGLD_TOKEN_ID), + 0, + managed_biguint!(QUOTE_INPUT), + ), + ); + }); + } + + for (start, end) in [(599_400u64, 600_000u64), (612_000u64, 612_600u64)] { + world + .query() + .to(PAIR) + .returns(ExpectError(4, "The price observation does not exist")) + .whitebox(pair::contract_obj, |sc| { + sc.get_safe_price_by_timestamp_range( + PAIR.to_managed_address(), + start, + end, + EsdtTokenPayment::new( + managed_token_id!(WEGLD_TOKEN_ID), + 0, + managed_biguint!(QUOTE_INPUT), + ), + ); + }); + } +} + +#[test] +fn legacy_seconds_offset_rejects_milliseconds_conversion_overflow_for_token_and_lp_views() { + let mut world = setup_pair(6_000u64, 102u64, 612_000u64); + seed_four_field_legacy_history_and_upgrade(&mut world); + let overflowing_seconds = u64::MAX / 1_000u64 + 1u64; + + world + .query() + .to(PAIR) + .returns(ExpectError(4, "Bad parameters")) + .whitebox(pair::contract_obj, |sc| { + sc.get_safe_price_by_timestamp_offset( + PAIR.to_managed_address(), + overflowing_seconds, + EsdtTokenPayment::new( + managed_token_id!(WEGLD_TOKEN_ID), + 0, + managed_biguint!(QUOTE_INPUT), + ), + ); + }); + world + .query() + .to(PAIR) + .returns(ExpectError(4, "Bad parameters")) + .whitebox(pair::contract_obj, |sc| { + sc.get_lp_tokens_safe_price_by_timestamp_offset( + PAIR.to_managed_address(), + overflowing_seconds, + managed_biguint!(LP_QUOTE), + ); + }); +} + +#[test] +fn default_offset_below_equal_and_above_available_history_uses_expected_clamped_window() { + let mut world = setup_pair(6_000u64, 102u64, 612_000u64); + let mut ledger = seed_four_field_legacy_history_and_upgrade(&mut world); + + set_block(&mut world, 103u64, 618_000u64); + swap_first_for_second(&mut world); + ledger.push( + 612_000u64, + 618_000u64, + INITIAL_FIRST_RESERVE, + INITIAL_SECOND_RESERVE, + ); + set_block(&mut world, 104u64, 624_000u64); + swap_second_for_first(&mut world); + ledger.push( + 618_000u64, + 624_000u64, + INITIAL_SECOND_RESERVE, + INITIAL_FIRST_RESERVE, + ); + + world.block_round_time_ms(600u64); + set_block(&mut world, 109u64, 627_000u64); + swap_first_for_second(&mut world); + ledger.push( + 624_000u64, + 627_000u64, + INITIAL_FIRST_RESERVE, + INITIAL_SECOND_RESERVE, + ); + set_block(&mut world, 114u64, 630_000u64); + swap_second_for_first(&mut world); + ledger.push( + 627_000u64, + 630_000u64, + INITIAL_SECOND_RESERVE, + INITIAL_FIRST_RESERVE, + ); + set_block(&mut world, 129u64, 639_000u64); + swap_first_for_second(&mut world); + ledger.push( + 630_000u64, + 639_000u64, + INITIAL_FIRST_RESERVE, + INITIAL_SECOND_RESERVE, + ); + set_block(&mut world, 134u64, 642_000u64); + swap_second_for_first(&mut world); + ledger.push( + 639_000u64, + 642_000u64, + INITIAL_SECOND_RESERVE, + INITIAL_FIRST_RESERVE, + ); + + assert_default_offset_uses_expected_window( + &mut world, &ledger, 24_000u64, 618_000u64, 642_000u64, + ); + assert_default_offset_uses_expected_window( + &mut world, &ledger, 42_000u64, 600_000u64, 642_000u64, + ); + assert_default_offset_uses_expected_window( + &mut world, &ledger, 60_000u64, 600_000u64, 642_000u64, + ); +} + +#[test] +fn single_four_field_legacy_observation_upgrades_then_extends_with_native_post_supernova_history() { + let mut world = setup_pair(6_000u64, 102u64, 612_000u64); + world + .tx() + .from(OWNER) + .to(PAIR) + .whitebox(pair::contract_obj, |sc| { + let mut legacy_observations = + VecMapper::>::new(StorageKey::new( + b"price_observations", + )); + legacy_observations.push(&LegacyPriceObservation { + first_token_reserve_accumulated: managed_biguint!(102_816_000u64), + second_token_reserve_accumulated: managed_biguint!(205_632_000u64), + weight_accumulated: 102u64, + recording_round: 102u64, + }); + sc.safe_price_current_index().set(1usize); + sc.upgrade(); + }); + + world.block_round_time_ms(600u64); + set_block(&mut world, 103u64, 612_600u64); + swap_first_for_second(&mut world); + set_block(&mut world, 113u64, 618_600u64); + swap_second_for_first(&mut world); + + world.query().to(PAIR).whitebox(pair::contract_obj, |sc| { + let legacy: PriceObservation = sc.price_observations().get(1usize); + let native = sc.price_observations().get(2usize); + assert_eq!(legacy.recording_timestamp, 0u64); + assert_eq!(legacy.lp_supply_accumulated, 0u64); + assert_eq!(native.recording_timestamp, 618_600u64); + assert!(native.lp_supply_accumulated > 0u64); + assert_eq!(sc.safe_price_current_index().get(), 2usize); + assert_eq!( + sc.current_price_observation().get().recording_timestamp, + 618_600u64 + ); + }); + + let average_first = (u128::from(INITIAL_FIRST_RESERVE) * 600u128 + + u128::from(INITIAL_SECOND_RESERVE) * 6_000u128) + / 6_600u128; + let average_second = (u128::from(INITIAL_SECOND_RESERVE) * 600u128 + + u128::from(INITIAL_FIRST_RESERVE) * 6_000u128) + / 6_600u128; + let expected = u64::try_from(u128::from(QUOTE_INPUT) * average_second / average_first).unwrap(); + assert_token_quote_by_timestamp_range(&mut world, 612_000u64, 618_600u64, expected); +} + +#[test] +fn timestamp_interpolation_across_full_ring_physical_seam_current_index_one_is_correct() { + let oldest_timestamp = 1_000u64; + let oldest_round = 10_000u64; + let penultimate_timestamp = + oldest_timestamp + u64::try_from(MAX_OBSERVATIONS - 2usize).unwrap() * 600u64; + let penultimate_round = oldest_round + u64::try_from(MAX_OBSERVATIONS - 2usize).unwrap(); + let latest_timestamp = penultimate_timestamp + 1_200u64; + let latest_round = penultimate_round + 2u64; + let midpoint_timestamp = penultimate_timestamp + 600u64; + let midpoint_round = penultimate_round + 1u64; + let penultimate_elapsed = penultimate_timestamp - oldest_timestamp; + let penultimate_first_accumulated = penultimate_elapsed * 10u64; + let penultimate_second_accumulated = penultimate_elapsed * 20u64; + let penultimate_lp_accumulated = penultimate_elapsed * 100u64; + let latest_first_accumulated = penultimate_first_accumulated + 1_200u64 * 30u64; + let latest_second_accumulated = penultimate_second_accumulated + 1_200u64 * 50u64; + let latest_lp_accumulated = penultimate_lp_accumulated + 1_200u64 * 70u64; + let mut world = setup_pair(600u64, latest_round, latest_timestamp); + + world + .tx() + .from(OWNER) + .to(PAIR) + .whitebox(pair::contract_obj, |sc| { + let latest = PriceObservation { + first_token_reserve_accumulated: managed_biguint!(latest_first_accumulated), + second_token_reserve_accumulated: managed_biguint!(latest_second_accumulated), + weight_accumulated: latest_timestamp, + recording_round: latest_round, + recording_timestamp: latest_timestamp, + lp_supply_accumulated: managed_biguint!(latest_lp_accumulated), + }; + let historical_observation = |physical_index: usize| { + let elapsed = u64::try_from(physical_index - 2usize).unwrap() * 600u64; + let timestamp = oldest_timestamp + elapsed; + PriceObservation { + first_token_reserve_accumulated: managed_biguint!(elapsed * 10u64), + second_token_reserve_accumulated: managed_biguint!(elapsed * 20u64), + weight_accumulated: timestamp, + recording_round: oldest_round + u64::try_from(physical_index - 2usize).unwrap(), + recording_timestamp: timestamp, + lp_supply_accumulated: managed_biguint!(elapsed * 100u64), + } + }; + + sc.price_observations().push(&latest); + for physical_index in 2usize..=MAX_OBSERVATIONS { + sc.price_observations() + .push(&historical_observation(physical_index)); + } + sc.safe_price_current_index().set(1usize); + sc.current_price_observation().set(&latest); + }); + + world.query().to(PAIR).whitebox(pair::contract_obj, |sc| { + let midpoint = sc.get_price_observation_view(PAIR.to_managed_address(), midpoint_round); + assert_eq!(midpoint.recording_round, midpoint_round); + assert_eq!(midpoint.recording_timestamp, midpoint_timestamp); + assert_eq!(midpoint.weight_accumulated, midpoint_timestamp); + assert_eq!( + midpoint.first_token_reserve_accumulated, + managed_biguint!(penultimate_first_accumulated + 600u64 * 30u64) + ); + assert_eq!( + midpoint.second_token_reserve_accumulated, + managed_biguint!(penultimate_second_accumulated + 600u64 * 50u64) + ); + assert_eq!( + midpoint.lp_supply_accumulated, + managed_biguint!(penultimate_lp_accumulated + 600u64 * 70u64) + ); + + let token_quote = sc.get_safe_price_by_timestamp_range( + PAIR.to_managed_address(), + penultimate_timestamp, + midpoint_timestamp, + EsdtTokenPayment::new( + managed_token_id!(WEGLD_TOKEN_ID), + 0, + managed_biguint!(QUOTE_INPUT), + ), + ); + assert_eq!( + token_quote.amount, + managed_biguint!(QUOTE_INPUT * 50u64 / 30u64) + ); + + let (first_lp_quote, second_lp_quote) = sc + .get_lp_tokens_safe_price_by_timestamp_range( + PAIR.to_managed_address(), + penultimate_timestamp, + midpoint_timestamp, + managed_biguint!(LP_QUOTE), + ) + .into_tuple(); + assert_eq!( + first_lp_quote.amount, + managed_biguint!(LP_QUOTE * 30u64 / 70u64) + ); + assert_eq!( + second_lp_quote.amount, + managed_biguint!(LP_QUOTE * 50u64 / 70u64) + ); + }); +}