Cache CL8Y fee-discount tier on pair with 5-minute TTL (reduce per-swap registry query) #251

Closed
opened 2026-05-31 12:21:58 +00:00 by PlasticDigits · 8 comments
PlasticDigits commented 2026-05-31 12:21:58 +00:00 (Migrated from gitlab.com)

Summary

Add a short-lived on-pair cache of the trader’s CL8Y fee-discount tier so execute_swap (and optionally limit placement) does not call the fee-discount registry on every transaction. Cache entries expire after 5 minutes (wall-clock block time).

Current codebase

  • Every swap calls effective_fee_bps_with_deregister_msgs → lookup_effective_fee_bps (contract.rs ~46–75, ~824–832).
  • When DISCOUNT_REGISTRY is set, each swap performs query_wasm_smart to registry GetDiscount { trader, sender } — cross-contract query gas on every hybrid swap.
  • Registry correctly skipped when discount_registry == None.
  • Deregister side-effects: One-time discount consumption still emits deregister_msgs when tier is single-use (deregister_msgs_for_discount).
  • Related: GitLab #245 (off-chain quote parity for trader wallet) — cache must not break discounted quotes if stale; TTL bounds staleness.

Why this is needed

Discount tier changes are infrequent relative to swap frequency. A 5-minute TTL removes a fixed cross-contract query from the hot path, reducing hybrid swap gas for CL8Y tier holders and all users on pairs with registry configured.

Constraints / guardrails

  • TTL: 300 seconds (5 minutes) from cache write block time; re-query registry after expiry.
  • Cache key: (trader_addr, sender_addr) or trader-only if sender irrelevant for tier — match current GetDiscount semantics exactly.
  • Storage: New Map on pair, e.g. DISCOUNT_CACHE: Map<(Addr, Addr), DiscountCacheEntry { effective_fee_bps, discount_meta, cached_at }> — bound entry size; no unbounded growth (TTL + overwrite per key).
  • Correctness vs gaming: Stale cache must not increase discount beyond registry truth; on re-query, apply minimum of cached and fresh fee or always refresh if cache expired. Never cache a better tier longer than TTL without re-query.
  • Deregister / one-shot discounts: If discount is consumable, do not serve from cache for deregister-eligible tiers OR invalidate cache entry on deregister tx in same swap (today deregister runs in same tx as swap — cache write after successful query, invalidate on use).
  • Limit orders: Scope decision: swap-only in v1, or shared helper for execute_place_limit_orders_batch — document in PR.
  • Migration: Existing pairs need empty map; no migrate required if may_load defaults.
  • Queries: HybridSimulation with trader should use same cache logic for parity with execute.

Relevant files

File Role
smartcontracts/contracts/pair/src/contract.rs lookup_effective_fee_bps, swap + sim paths
smartcontracts/contracts/pair/src/state.rs New cache map
smartcontracts/contracts/fee-discount/ Registry query shape
smartcontracts/packages/dex-common/src/pair.rs Optional exported TTL constant
smartcontracts/tests/ Swap discount tests
docs/reference/fee-discount-tiers.md Tier behavior
frontend-dapp/ No change required if on-chain only; quotes via sim still accurate within TTL
  1. Add DISCOUNT_TTL_SECONDS: u64 = 300 and cache map in state.rs.
  2. Refactor lookup_effective_fee_bps → lookup_effective_fee_bps_cached(deps, block_time, ...):
    • If entry exists and block_time - cached_at < TTL and tier not consumable (or policy defined): return cached bps.
    • Else query registry, write cache, return fresh value.
  3. On deregister-eligible discount used in swap: remove cache entry for trader after emitting deregister msg.
  4. Wire into effective_fee_bps_for_sim for query parity.

Acceptance criteria

  • Second swap same trader within 5 minutes: no registry wasm query (verify via gas delta or mock/test hook).
  • Swap after TTL expiry: registry re-queried; fee matches fresh tier.
  • Tier upgrade on registry reflected within TTL window on next swap after expiry (not before).
  • One-shot discount: not double-applied via cache; deregister still fires once.
  • HybridSimulation with trader matches execute fee within cache window.
  • Pairs without registry: zero cache overhead (early exit).

Test plan — functional paths

  • Swap with registry, no prior cache → query + cache write.
  • Immediate second swap → cache hit, same effective_fee_bps.
  • Swap at cached_at + 299 → cache hit.
  • Swap at cached_at + 301 → cache miss, re-query.
  • Different sender same trader (if distinguished by registry) → separate cache keys.
  • Limit placement uses cache (if in scope).

Test plan — attack / abuse vectors

  • Stale discount farming: User loses tier on registry; within TTL still gets old discount — accepted trade-off bounded to 5 min; document. Mitigation: registry can set short tier revocation (operational).
  • Cache poisoning: Only pair contract writes cache; external users cannot inject entries.
  • Storage spam: Repeated unique trader addresses → map grows; optional max entries per pair or accept O(unique traders) — document limit.
  • Sim vs execute mismatch: Same block, sim then execute → identical fee.
  • Deregister bypass: Two swaps same block with one-shot discount → only one deregister msg.

Verification criteria

  • Integration tests in smartcontracts/tests/ for cache hit/miss/TTL.
  • Gas comparison: cached vs uncached swap (document uluna savings).
  • docs/reference/fee-discount-tiers.md updated with TTL semantics.
  • No regression on #245 trader quote parity when sim passes block time correctly.
## Summary Add a **short-lived on-pair cache** of the trader’s CL8Y fee-discount tier so **`execute_swap`** (and optionally limit placement) does not call the fee-discount registry on **every** transaction. Cache entries expire after **5 minutes** (wall-clock block time). ## Current codebase - Every swap calls `effective_fee_bps_with_deregister_msgs` → `lookup_effective_fee_bps` (`contract.rs` ~46–75, ~824–832). - When `DISCOUNT_REGISTRY` is set, **each swap** performs **`query_wasm_smart`** to registry `GetDiscount { trader, sender }` — cross-contract query gas on every hybrid swap. - Registry correctly skipped when `discount_registry == None`. - **Deregister side-effects:** One-time discount consumption still emits `deregister_msgs` when tier is single-use (`deregister_msgs_for_discount`). - Related: GitLab **#245** (off-chain quote parity for trader wallet) — cache must not break discounted **quotes** if stale; TTL bounds staleness. ## Why this is needed Discount tier changes are infrequent relative to swap frequency. A 5-minute TTL removes a fixed cross-contract query from the hot path, reducing hybrid swap gas for CL8Y tier holders and all users on pairs with registry configured. ## Constraints / guardrails - **TTL:** **300 seconds** (5 minutes) from cache write block time; re-query registry after expiry. - **Cache key:** `(trader_addr, sender_addr)` or trader-only if sender irrelevant for tier — match current `GetDiscount` semantics exactly. - **Storage:** New `Map` on pair, e.g. `DISCOUNT_CACHE: Map<(Addr, Addr), DiscountCacheEntry { effective_fee_bps, discount_meta, cached_at }>` — bound entry size; no unbounded growth (TTL + overwrite per key). - **Correctness vs gaming:** Stale cache must not **increase** discount beyond registry truth; on re-query, apply **minimum** of cached and fresh fee or always refresh if cache expired. Never cache a **better** tier longer than TTL without re-query. - **Deregister / one-shot discounts:** If discount is consumable, **do not serve from cache** for deregister-eligible tiers OR invalidate cache entry on deregister tx in same swap (today deregister runs in same tx as swap — cache write after successful query, invalidate on use). - **Limit orders:** Scope decision: swap-only in v1, or shared helper for `execute_place_limit_orders_batch` — document in PR. - **Migration:** Existing pairs need empty map; no migrate required if `may_load` defaults. - **Queries:** `HybridSimulation` with `trader` should use same cache logic for parity with execute. ## Relevant files | File | Role | |------|------| | `smartcontracts/contracts/pair/src/contract.rs` | `lookup_effective_fee_bps`, swap + sim paths | | `smartcontracts/contracts/pair/src/state.rs` | New cache map | | `smartcontracts/contracts/fee-discount/` | Registry query shape | | `smartcontracts/packages/dex-common/src/pair.rs` | Optional exported TTL constant | | `smartcontracts/tests/` | Swap discount tests | | `docs/reference/fee-discount-tiers.md` | Tier behavior | | `frontend-dapp/` | No change required if on-chain only; quotes via sim still accurate within TTL | ## Recommended solution direction 1. Add `DISCOUNT_TTL_SECONDS: u64 = 300` and cache map in `state.rs`. 2. Refactor `lookup_effective_fee_bps` → `lookup_effective_fee_bps_cached(deps, block_time, ...)`: - If entry exists and `block_time - cached_at < TTL` and tier not consumable (or policy defined): return cached bps. - Else query registry, write cache, return fresh value. 3. On deregister-eligible discount used in swap: remove cache entry for trader after emitting deregister msg. 4. Wire into `effective_fee_bps_for_sim` for query parity. ## Acceptance criteria - [ ] Second swap same trader within 5 minutes: no registry wasm query (verify via gas delta or mock/test hook). - [ ] Swap after TTL expiry: registry re-queried; fee matches fresh tier. - [ ] Tier upgrade on registry reflected within TTL window on next swap after expiry (not before). - [ ] One-shot discount: not double-applied via cache; deregister still fires once. - [ ] `HybridSimulation` with trader matches execute fee within cache window. - [ ] Pairs without registry: zero cache overhead (early exit). ## Test plan — functional paths - [ ] Swap with registry, no prior cache → query + cache write. - [ ] Immediate second swap → cache hit, same `effective_fee_bps`. - [ ] Swap at `cached_at + 299` → cache hit. - [ ] Swap at `cached_at + 301` → cache miss, re-query. - [ ] Different sender same trader (if distinguished by registry) → separate cache keys. - [ ] Limit placement uses cache (if in scope). ## Test plan — attack / abuse vectors - [ ] **Stale discount farming:** User loses tier on registry; within TTL still gets old discount — **accepted trade-off** bounded to 5 min; document. Mitigation: registry can set short tier revocation (operational). - [ ] **Cache poisoning:** Only pair contract writes cache; external users cannot inject entries. - [ ] **Storage spam:** Repeated unique trader addresses → map grows; optional max entries per pair or accept O(unique traders) — document limit. - [ ] **Sim vs execute mismatch:** Same block, sim then execute → identical fee. - [ ] **Deregister bypass:** Two swaps same block with one-shot discount → only one deregister msg. ## Verification criteria - [ ] Integration tests in `smartcontracts/tests/` for cache hit/miss/TTL. - [ ] Gas comparison: cached vs uncached swap (document uluna savings). - [ ] `docs/reference/fee-discount-tiers.md` updated with TTL semantics. - [ ] No regression on #245 trader quote parity when sim passes block time correctly.
PlasticDigits commented 2026-05-31 13:29:15 +00:00 (Migrated from gitlab.com)

mentioned in commit d2be3b8d6c

mentioned in commit d2be3b8d6cd1d92337693ed260ba2ef97967c965
PlasticDigits commented 2026-05-31 13:29:20 +00:00 (Migrated from gitlab.com)

Implementation (merged to main — d2be3b8)

Added a 5-minute (300s) on-pair cache for CL8Y fee-discount registry lookups so execute_swap and limit placement avoid a GetDiscount cross-contract query on every tx when the same (trader, sender) repeats within TTL.

What changed

  • smartcontracts/contracts/pair/src/discount_cache.rs — cache read/write/invalidate; TTL from dex_common::pair::DISCOUNT_CACHE_TTL_SECONDS
  • DISCOUNT_CACHE map in pair state.rs; pair contract version 1.7.0
  • Execute + limit placement write cache on registry hit; needs_deregister tiers are never cached; cache entry removed when deregister submessages are emitted
  • HybridSimulation / reverse sim read cache (no write) for parity with execute within TTL
  • Tests: discount_cache_hit_within_ttl_after_registry_upgrade, discount_cache_ttl_boundary, discount_cache_hybrid_sim_matches_execute
  • Docs: invariant I9 in docs/reference/fee-discount-tiers.md
  • Agent skills: AGENTS_FEE_DISCOUNT_TIERS.md, AGENTS_TERRACLASSIC_GAS.md, AGENTS_HYBRID_QUOTING.md

Verification checklist

  • Second swap by same wallet on a pair with registry within 5 min: same effective_fee_bps event; lower gas_used vs first swap (LocalTerra / columbus benchmark)
  • Swap after cached_at + 301s: registry re-queried; fee reflects current tier
  • Tier upgrade on registry: not visible until TTL expires (accepted staleness)
  • Balance-below-tier / needs_deregister path: deregister still fires once; no double-apply via cache
  • HybridSimulation with trader matches execute fee when cache warm
  • Pair without discount_registry: no cache storage overhead
  • cargo test -p cl8y-dex-tests fee_discount_tests and hybrid_simulation_matches_execute_with_fee_discount pass on CI

Follow-ups

  • Contract migration / redeploy required for live pairs to pick up pair 1.7.0 wasm (empty DISCOUNT_CACHE map by default).
  • Optional: LocalTerra gas delta doc (cached vs uncached swap) — can attach to #252 benchmark work.

@qa-agent-team — please verify the checklist above on LocalTerra (or staging) after pair wasm upgrade; leave this issue open until sign-off.

## Implementation (merged to `main` — d2be3b8) Added a **5-minute (300s) on-pair cache** for CL8Y fee-discount registry lookups so `execute_swap` and limit placement avoid a `GetDiscount` cross-contract query on every tx when the same `(trader, sender)` repeats within TTL. ### What changed - **`smartcontracts/contracts/pair/src/discount_cache.rs`** — cache read/write/invalidate; TTL from `dex_common::pair::DISCOUNT_CACHE_TTL_SECONDS` - **`DISCOUNT_CACHE` map** in pair `state.rs`; pair contract version **1.7.0** - **Execute + limit placement** write cache on registry hit; **`needs_deregister`** tiers are never cached; cache entry removed when deregister submessages are emitted - **`HybridSimulation` / reverse sim** read cache (no write) for parity with execute within TTL - **Tests:** `discount_cache_hit_within_ttl_after_registry_upgrade`, `discount_cache_ttl_boundary`, `discount_cache_hybrid_sim_matches_execute` - **Docs:** invariant **I9** in `docs/reference/fee-discount-tiers.md` - **Agent skills:** `AGENTS_FEE_DISCOUNT_TIERS.md`, `AGENTS_TERRACLASSIC_GAS.md`, `AGENTS_HYBRID_QUOTING.md` ### Verification checklist - [ ] Second swap by same wallet on a pair with registry within 5 min: same `effective_fee_bps` event; lower `gas_used` vs first swap (LocalTerra / columbus benchmark) - [ ] Swap after `cached_at + 301s`: registry re-queried; fee reflects current tier - [ ] Tier upgrade on registry: not visible until TTL expires (accepted staleness) - [ ] Balance-below-tier / `needs_deregister` path: deregister still fires once; no double-apply via cache - [ ] `HybridSimulation` with `trader` matches execute fee when cache warm - [ ] Pair without `discount_registry`: no cache storage overhead - [ ] `cargo test -p cl8y-dex-tests fee_discount_tests` and `hybrid_simulation_matches_execute_with_fee_discount` pass on CI ### Follow-ups - **Contract migration / redeploy** required for live pairs to pick up pair **1.7.0** wasm (empty `DISCOUNT_CACHE` map by default). - Optional: LocalTerra gas delta doc (cached vs uncached swap) — can attach to #252 benchmark work. **@qa-agent-team** — please verify the checklist above on LocalTerra (or staging) after pair wasm upgrade; leave this issue open until sign-off.
Brouie commented 2026-06-01 01:57:55 +00:00 (Migrated from gitlab.com)

mentioned in issue #252

mentioned in issue #252
Brouie commented 2026-06-01 01:58:51 +00:00 (Migrated from gitlab.com)

Verified the CL8Y fee-discount cache against the source on 6b22feb, plus observed its gas effect live in the #252 benchmark.

Works as designed (source-confirmed in discount_cache.rs + contract.rs):

  • 3-part hit condition: entry exists AND fresh (block_time - cached_at < 300s) AND !needs_deregister. Any one failing falls through to a fresh registry lookup.
  • Write-through on execute + limit placement only (lookup_effective_fee_bps_cached). Simulation uses the readonly lookup — it reads the cache but never writes it, so quote spam does not warm the execute path; the first real execute still pays cold.
  • Undiscounted simulation (trader: None) short-circuits straight to fee_bps with no registry query at all — cheapest quote path.
  • Invalidation is correct: when a discount needs_deregister, it's treated as a miss, never saved, the stale entry is removed, and deregister submsgs are emitted. So a revoked/expired-registration trader gets no cache benefit and re-queries every swap (intended).

Gas effect (observed live in #252): the first fee-bearing op per (trader, sender) key per window pays ~145k extra for the cross-contract registry query + the cache write; warm ops within 300s skip both. That's the cold/warm split in the #252 table (cold M=1 625,896 vs warm 481,066). Cache key is (trader, sender), and on a swap trader defaults to sender when the hook omits it — so each distinct trader pays its own cold cost once per window.

Layer, being explicit: all five behavior points are source-verified; the cold/warm gas delta was observed live in the benchmark. The invalidation and staleness paths are source-confirmed but not separately exercised on-chain in a dedicated test here.

One tradeoff to confirm: a registry discount change is not reflected for up to 300s (until TTL expiry), except revokes — those invalidate immediately via the deregister path. So the effective fee can be up to 5 min stale on a discount increase/decrease that isn't a revoke.

@PlasticDigits — confirm 300s is the intended TTL and that up-to-5-min fee staleness (non-revoke changes) is acceptable. If yes, I'm good signing off the cache behavior here; the staleness window is the only thing I'd want your explicit OK on before this closes.

Verified the CL8Y fee-discount cache against the source on 6b22feb, plus observed its gas effect live in the #252 benchmark. Works as designed (source-confirmed in discount_cache.rs + contract.rs): - 3-part hit condition: entry exists AND fresh (block_time - cached_at < 300s) AND !needs_deregister. Any one failing falls through to a fresh registry lookup. - Write-through on execute + limit placement only (lookup_effective_fee_bps_cached). Simulation uses the readonly lookup — it reads the cache but never writes it, so quote spam does not warm the execute path; the first real execute still pays cold. - Undiscounted simulation (trader: None) short-circuits straight to fee_bps with no registry query at all — cheapest quote path. - Invalidation is correct: when a discount needs_deregister, it's treated as a miss, never saved, the stale entry is removed, and deregister submsgs are emitted. So a revoked/expired-registration trader gets no cache benefit and re-queries every swap (intended). Gas effect (observed live in #252): the first fee-bearing op per (trader, sender) key per window pays ~145k extra for the cross-contract registry query + the cache write; warm ops within 300s skip both. That's the cold/warm split in the #252 table (cold M=1 625,896 vs warm 481,066). Cache key is (trader, sender), and on a swap trader defaults to sender when the hook omits it — so each distinct trader pays its own cold cost once per window. Layer, being explicit: all five behavior points are source-verified; the cold/warm gas delta was observed live in the benchmark. The invalidation and staleness paths are source-confirmed but not separately exercised on-chain in a dedicated test here. One tradeoff to confirm: a registry discount change is not reflected for up to 300s (until TTL expiry), except revokes — those invalidate immediately via the deregister path. So the effective fee can be up to 5 min stale on a discount increase/decrease that isn't a revoke. @PlasticDigits — confirm 300s is the intended TTL and that up-to-5-min fee staleness (non-revoke changes) is acceptable. If yes, I'm good signing off the cache behavior here; the staleness window is the only thing I'd want your explicit OK on before this closes.
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-06-01 02:05:43 +00:00
PlasticDigits commented 2026-06-01 02:05:44 +00:00 (Migrated from gitlab.com)

We accept the 5 min staleness on fees, closing. Note that future observation of traders to identify exploitation of the 5 min staleness should be done, but is not critical enough to require anything other than casual observation (no metrics needed)

We accept the 5 min staleness on fees, closing. Note that future observation of traders to identify exploitation of the 5 min staleness should be done, but is not critical enough to require anything other than casual observation (no metrics needed)
Brouie commented 2026-06-01 05:25:47 +00:00 (Migrated from gitlab.com)

mentioned in issue #258

mentioned in issue #258
Brouie commented 2026-06-03 07:11:53 +00:00 (Migrated from gitlab.com)

mentioned in issue #275

mentioned in issue #275
Brouie commented 2026-06-04 06:18:37 +00:00 (Migrated from gitlab.com)

mentioned in merge request !747

mentioned in merge request !747
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
code/cl8y-dex-terraclassic#251
No description provided.