300s fee-discount cache lets a wallet hold the tier balance one block per 5 minutes #275
Labels
No labels
agent:fix_bugfix
agent:fix_conflicts
agent:fix_security
agent:gap_analysis
agent:implement
agent:implement
agent:implement
agent:open_issues
agent:ready
agent:research
agent:security_audit
agent:verify
architecture
backend
blocker:hybrid
blocker:launch
blocker:limit-orders
blocker:v2
block:log_only
block:security
bug
ci
contracts
correctness
deploy
dev
devops
docs
documentation
duplicate
e2e
enhancement
epic
feature
frontend
functional-completion
gas
good first issue
governance
help wanted
high-risk
hooks
hybrid
indexer
infra
infrastructure
integrators
invalid
launch-blocker
limit-orders
localnet
localterra
low priority
missing-implementation
needs-design
ops
performance
priority
high
priority
medium
product
qa
QA
question
ready
ready
research
scripts
security
security-hardening
smartcontracts
tech-debt
testing
ux
UX
v2
verification
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
code/cl8y-dex-terraclassic#275
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Severity: Medium
Reachability: Any registered wallet that wants the discount without holding the CL8Y.
Affected: pair fee-discount cache (
smartcontracts/contracts/pair/src/discount_cache.rs), 300s TTL.Root cause: the pair caches the registry's discount answer for 300s and serves it without re-checking live balance, so the "hold CL8Y to get the discount" requirement only has to be true for one block every 5 minutes.
Summary
The discount registry reads live CL8Y balance at query time and returns
needs_deregister: trueonce a wallet drops below its tier. Good. But the pair memoizes that answer forDISCOUNT_CACHE_TTL_SECONDS = 300, keyed on(trader, sender), and during that window it returns the cached discount without going back to the registry.So the intended invariant ("you must hold the tier balance while you trade") degrades to "you must hold it for one block per 300 seconds":
It's fee leakage, not theft, but it undercuts the CL8Y demand sink the discount is supposed to create. Calling it Medium.
Current codebase
discount_cache.rsis_fresh(block_time - cached_at < 300),try_discount_cache_hitreturns the cached(effective_fee_bps, discount)without re-querying.smartcontracts/contracts/fee-discount/src/contract.rsquery_discountdoes query live balance — so the hole is the pair-side cache window, not the registry.Recommended direction
Acceptance criteria
Test plan (attack / abuse)
The invariant needs to be updated. The reason for the 300s cache is to reduce gas costs for frequent traders. This is a necessary gas optimization, and requiring a trader to buy/sell every 300s to maintain their status still provides sufficient benefit given the gas optimization advantages.
Took your call — kept the 300s cache as-is (gas optimization, #251) and updated the invariant to the snapshot model.
Documented in three places:
discount_cache.rsmodule doc — the cached(effective_fee_bps, discount)is a 300s snapshot, not a live per-trade check; tier holder must transact ≥ once per 300s to keep it; a wallet dropping below tier keeps the cached discount until the entry expires; deliberate gas optimization vs a per-swap registry query; the registry itself still reads live balance.DISCOUNT_CACHE_TTL_SECONDSconst doc (dex-common/pair.rs) — same snapshot note + #275 ref.docs/contracts-security-audit.md) stating the 300s snapshot is accepted design, fee leakage bounded to one window, the once-per-window refresh deemed sufficient CL8Y-sink demand.Doc-only, no logic change. Branch
qa/275-fee-discount-cache-invariant-doc, MR fork→main (no closing keyword).One thing for you: the issue's acceptance criteria as written ("a wallet that drops below its tier cannot keep claiming the discount", "no better than holding") can't be satisfied under this decision — they describe the live-check model you've declined. They should be rewritten to the snapshot model (e.g. "discount documented as a 300s snapshot; refresh requirement is once per window") before this closes. Left that for you since it's your AC. @PlasticDigits
mentioned in merge request !747
mentioned in commit
38bd253138Took your call — kept the 300s cache (the #251 gas optimization) and moved the invariant to the snapshot model, doc-only, no logic change. Verified the docs landed in all three spots: the
discount_cache.rsmodule doc, theDISCOUNT_CACHE_TTL_SECONDSconst doc, and the P9 row in the security-audit matrix — all stating the cached discount is a 300s snapshot, the tier holder must transact at least once per window, a wallet dropping below tier keeps the cached discount until expiry, and the registry still reads live balance. The cache mechanism matches the doc (the discount_cache TTL-boundary and post-registry-upgrade tests pass).One thing that's yours before this closes: the original acceptance criteria ("a wallet that drops below its tier cannot keep claiming the discount", "no better than holding") describe the live-check model you declined — they can't be met under the snapshot decision, so they read as failing even though the resolution is exactly what you asked for. They need rewriting to the snapshot model so the issue closes clean against its own AC. @PlasticDigits
Approved to ovverride acceptance criteria
Verification summary (GitLab #275)
Verified on
main@9f0babe(includes mergeqa/275-fee-discount-cache-invariant-doc/942bae9). No additional code or doc changes were required in this pass.Decision / overridden acceptance criteria
Per @PlasticDigits (2026-06-05): keep the 300s on-pair cache (#251 gas optimization) and treat the discount as a 300s snapshot, not a live per-trade balance gate. Original issue AC (live-check / “no better than holding”) are superseded by this decision.
discount_cache.rsmodule doc;DISCOUNT_CACHE_TTL_SECONDSdoc index-common/src/pair.rs; P9 row indocs/contracts-security-audit.md— all cite #275 and describe bounded fee leakage + registry still reads live balanceis_fresh:block_time - cached_at < DISCOUNT_CACHE_TTL_SECONDSgit log --grep=275shows doc-only merge; working tree cleancargo test discount_cache(3 tests): TTL boundary, cache hit after registry upgrade within TTL, hybrid sim matches executediscount_cache.rs+ TTL const;cargo test fee_discount_coverage(11 tests)make check-fee-discount-tier-docsCommands run
Cross-links
docs/reference/fee-discount-tiers.md) and agent skills (AGENTS_FEE_DISCOUNT_TIERS.md,AGENTS_HYBRID_QUOTING.md,AGENTS_TERRACLASSIC_GAS.md) already describe the 300s cache via #251 / I9; authoritative accepted-design statement is P9 + pair source docs above..cursor/skills/andskills/README.mdare not present in this repo (N/A).Closing as verified — documentation and tests align with the approved snapshot invariant.
mentioned in issue #424