TS math: minCharmWadAt / maxCharmWadAt missing ref==0 guard that the contract has (#61/#73 parity gap) #109

Closed
opened 2026-05-02 01:07:35 +00:00 by Brouie · 4 comments
Brouie commented 2026-05-02 01:07:35 +00:00 (Migrated from gitlab.com)

P3-low parity gap: frontend/src/lib/timeCurveMath.ts minCharmWadAt, maxCharmWadAt, minCharmWadAtFloat, and maxCharmWadAtFloat divide by charmEnvelopeRefWad without the ref == 0 defensive guard that TimeCurve._charmBounds has. A 0n input throws RangeError: Division by zero in JS, where the contract returns (CHARM_MIN_BASE_WAD, CHARM_MAX_BASE_WAD) defensively per #61 and #73.

The contract guard

contracts/src/TimeCurve.sol:348-358:

function _charmBounds(uint256 elapsed) internal view returns (uint256 minCharmWad, uint256 maxCharmWad) {
    uint256 ref = initialMinBuy;
    // `initialMinBuy` is only set in `initialize` (required > 0). Default storage (e.g. calling the
    // implementation, GitLab #61) leaves it zero and would panic in `mulDiv` (GitLab #73).
    if (ref == 0) {
        return (CHARM_MIN_BASE_WAD, CHARM_MAX_BASE_WAD);
    }
    uint256 scale = TimeMath.currentMinBuy(ref, growthRateWad, elapsed);
    minCharmWad = Math.mulDiv(CHARM_MIN_BASE_WAD, scale, ref);
    maxCharmWad = Math.mulDiv(CHARM_MAX_BASE_WAD, scale, ref);
}

The TS mirrors

frontend/src/lib/timeCurveMath.ts:56-69:

export function minCharmWadAt(charmEnvelopeRefWad: bigint, growthRateWad: bigint, elapsed: bigint): bigint {
  const scale = currentMinBuyAt(charmEnvelopeRefWad, growthRateWad, elapsed);
  return (CHARM_MIN_BASE_WAD * scale) / charmEnvelopeRefWad;  // <-- throws if ref === 0n
}

export function maxCharmWadAt(charmEnvelopeRefWad: bigint, growthRateWad: bigint, elapsed: bigint): bigint {
  const scale = currentMinBuyAt(charmEnvelopeRefWad, growthRateWad, elapsed);
  return (CHARM_MAX_BASE_WAD * scale) / charmEnvelopeRefWad;  // <-- throws if ref === 0n
}

Same missing guard in minCharmWadAtFloat (line 115) and maxCharmWadAtFloat (line 124).

Why it does not currently crash users

External callers wrap in try/catch. Example TimeCurveSimplePage.tsx:131-148:

try {
  minSpend = displayMinGrossSpendAtFloat(envelopeParams.charmEnvelopeRefWad, ...);
  maxSpend = maxGrossSpendAtFloat(envelopeParams.charmEnvelopeRefWad, ...);
} catch {
  minSpend = undefined;
  maxSpend = undefined;
}

The bare catch silently swallows the RangeError and falls back to a non-throwing path. So no current user impact — but:

  1. Implicit — the contract documents the guard (#61/#73), the TS does not.
  2. Style mismatch — protocol mirrors should encode the same defensive invariants explicitly.
  3. Future-fragile — a future caller without the surrounding try/catch would crash on a 0n input.
  4. Test gap — frontend/src/lib/timeCurveMath.test.ts does not exercise the ref === 0n path.

Suggested fix

Add the ref === 0n guard at the top of all four functions, mirroring the contract:

export function minCharmWadAt(charmEnvelopeRefWad: bigint, growthRateWad: bigint, elapsed: bigint): bigint {
  if (charmEnvelopeRefWad === 0n) return CHARM_MIN_BASE_WAD;
  const scale = currentMinBuyAt(charmEnvelopeRefWad, growthRateWad, elapsed);
  return (CHARM_MIN_BASE_WAD * scale) / charmEnvelopeRefWad;
}

export function maxCharmWadAt(charmEnvelopeRefWad: bigint, growthRateWad: bigint, elapsed: bigint): bigint {
  if (charmEnvelopeRefWad === 0n) return CHARM_MAX_BASE_WAD;
  const scale = currentMinBuyAt(charmEnvelopeRefWad, growthRateWad, elapsed);
  return (CHARM_MAX_BASE_WAD * scale) / charmEnvelopeRefWad;
}

Add regression tests:

it('returns CHARM_MIN_BASE_WAD when ref is zero (parity with TimeCurve._charmBounds)', () => {
  expect(minCharmWadAt(0n, GROWTH_RATE_20PCT, ONE_DAY)).toBe(CHARM_MIN_BASE_WAD);
});
it('returns CHARM_MAX_BASE_WAD when ref is zero (parity with TimeCurve._charmBounds)', () => {
  expect(maxCharmWadAt(0n, GROWTH_RATE_20PCT, ONE_DAY)).toBe(CHARM_MAX_BASE_WAD);
});

Severity

P3-low. Not user-facing today (try/catch saves us). Defensive parity gap with documented contract behavior. Per 4/29 standing rule on aggressive filing.

Acceptance

  • All 4 functions (minCharmWadAt, maxCharmWadAt, minCharmWadAtFloat, maxCharmWadAtFloat) guard against charmEnvelopeRefWad === 0n and return the static CHARM_*_BASE_WAD constants.
  • Regression tests added covering the ref === 0n path for each function.
  • Existing tests still pass.

cc @PlasticDigits

P3-low parity gap: `frontend/src/lib/timeCurveMath.ts` `minCharmWadAt`, `maxCharmWadAt`, `minCharmWadAtFloat`, and `maxCharmWadAtFloat` divide by `charmEnvelopeRefWad` without the `ref == 0` defensive guard that `TimeCurve._charmBounds` has. A 0n input throws `RangeError: Division by zero` in JS, where the contract returns `(CHARM_MIN_BASE_WAD, CHARM_MAX_BASE_WAD)` defensively per [#61](https://gitlab.com/PlasticDigits/yieldomega/-/issues/61) and [#73](https://gitlab.com/PlasticDigits/yieldomega/-/issues/73). ### The contract guard `contracts/src/TimeCurve.sol:348-358`: ```solidity function _charmBounds(uint256 elapsed) internal view returns (uint256 minCharmWad, uint256 maxCharmWad) { uint256 ref = initialMinBuy; // `initialMinBuy` is only set in `initialize` (required > 0). Default storage (e.g. calling the // implementation, GitLab #61) leaves it zero and would panic in `mulDiv` (GitLab #73). if (ref == 0) { return (CHARM_MIN_BASE_WAD, CHARM_MAX_BASE_WAD); } uint256 scale = TimeMath.currentMinBuy(ref, growthRateWad, elapsed); minCharmWad = Math.mulDiv(CHARM_MIN_BASE_WAD, scale, ref); maxCharmWad = Math.mulDiv(CHARM_MAX_BASE_WAD, scale, ref); } ``` ### The TS mirrors `frontend/src/lib/timeCurveMath.ts:56-69`: ```ts export function minCharmWadAt(charmEnvelopeRefWad: bigint, growthRateWad: bigint, elapsed: bigint): bigint { const scale = currentMinBuyAt(charmEnvelopeRefWad, growthRateWad, elapsed); return (CHARM_MIN_BASE_WAD * scale) / charmEnvelopeRefWad; // <-- throws if ref === 0n } export function maxCharmWadAt(charmEnvelopeRefWad: bigint, growthRateWad: bigint, elapsed: bigint): bigint { const scale = currentMinBuyAt(charmEnvelopeRefWad, growthRateWad, elapsed); return (CHARM_MAX_BASE_WAD * scale) / charmEnvelopeRefWad; // <-- throws if ref === 0n } ``` Same missing guard in `minCharmWadAtFloat` (line 115) and `maxCharmWadAtFloat` (line 124). ### Why it does not currently crash users External callers wrap in `try/catch`. Example `TimeCurveSimplePage.tsx:131-148`: ```ts try { minSpend = displayMinGrossSpendAtFloat(envelopeParams.charmEnvelopeRefWad, ...); maxSpend = maxGrossSpendAtFloat(envelopeParams.charmEnvelopeRefWad, ...); } catch { minSpend = undefined; maxSpend = undefined; } ``` The bare `catch` silently swallows the `RangeError` and falls back to a non-throwing path. So no current user impact — but: 1. **Implicit** — the contract documents the guard ([#61](https://gitlab.com/PlasticDigits/yieldomega/-/issues/61)/[#73](https://gitlab.com/PlasticDigits/yieldomega/-/issues/73)), the TS does not. 2. **Style mismatch** — protocol mirrors should encode the same defensive invariants explicitly. 3. **Future-fragile** — a future caller without the surrounding try/catch would crash on a 0n input. 4. **Test gap** — `frontend/src/lib/timeCurveMath.test.ts` does not exercise the `ref === 0n` path. ### Suggested fix Add the `ref === 0n` guard at the top of all four functions, mirroring the contract: ```ts export function minCharmWadAt(charmEnvelopeRefWad: bigint, growthRateWad: bigint, elapsed: bigint): bigint { if (charmEnvelopeRefWad === 0n) return CHARM_MIN_BASE_WAD; const scale = currentMinBuyAt(charmEnvelopeRefWad, growthRateWad, elapsed); return (CHARM_MIN_BASE_WAD * scale) / charmEnvelopeRefWad; } export function maxCharmWadAt(charmEnvelopeRefWad: bigint, growthRateWad: bigint, elapsed: bigint): bigint { if (charmEnvelopeRefWad === 0n) return CHARM_MAX_BASE_WAD; const scale = currentMinBuyAt(charmEnvelopeRefWad, growthRateWad, elapsed); return (CHARM_MAX_BASE_WAD * scale) / charmEnvelopeRefWad; } ``` Add regression tests: ```ts it('returns CHARM_MIN_BASE_WAD when ref is zero (parity with TimeCurve._charmBounds)', () => { expect(minCharmWadAt(0n, GROWTH_RATE_20PCT, ONE_DAY)).toBe(CHARM_MIN_BASE_WAD); }); it('returns CHARM_MAX_BASE_WAD when ref is zero (parity with TimeCurve._charmBounds)', () => { expect(maxCharmWadAt(0n, GROWTH_RATE_20PCT, ONE_DAY)).toBe(CHARM_MAX_BASE_WAD); }); ``` ### Severity P3-low. Not user-facing today (try/catch saves us). Defensive parity gap with documented contract behavior. Per 4/29 standing rule on aggressive filing. ### Acceptance - [ ] All 4 functions (`minCharmWadAt`, `maxCharmWadAt`, `minCharmWadAtFloat`, `maxCharmWadAtFloat`) guard against `charmEnvelopeRefWad === 0n` and return the static `CHARM_*_BASE_WAD` constants. - [ ] Regression tests added covering the `ref === 0n` path for each function. - [ ] Existing tests still pass. cc @PlasticDigits
PlasticDigits commented 2026-05-02 04:53:16 +00:00 (Migrated from gitlab.com)

mentioned in commit 28d6c8f50a

mentioned in commit 28d6c8f50a542993ab2ca778faa642b2677753b1
PlasticDigits commented 2026-05-02 04:53:17 +00:00 (Migrated from gitlab.com)

mentioned in commit f6d907ffed

mentioned in commit f6d907ffedc965dc0a223c976b0d7da18d3b4a68
PlasticDigits commented 2026-05-02 04:57:30 +00:00 (Migrated from gitlab.com)

Implemented GitLab #109 on main (commit db00da4 merges fix/gl109-ts-charm-bounds-ref-zero).

What changed

  • frontend/src/lib/timeCurveMath.ts: minCharmWadAt, maxCharmWadAt, minCharmWadAtFloat, maxCharmWadAtFloat return CHARM_MIN_BASE_WAD / CHARM_MAX_BASE_WAD when charmEnvelopeRefWad === 0n, matching TimeCurve._charmBounds for degenerate initialMinBuy (#61 / #73).
  • frontend/src/lib/timeCurveMath.test.ts: four regression tests (one per helper).
  • Docs: new invariant row + anchor #ts-charm-envelope-ref-zero-issue-109 in docs/testing/invariants-and-business-logic.md; contributor guardrails item 19 extended; player skills crosslinks in skills/README.md and skills/script-with-timecurve-local/SKILL.md.

Verification checklist for @brouie

  • cd frontend && npx vitest run src/lib/timeCurveMath.test.ts — all 8 tests pass (includes new ref-zero block).
  • Read minCharmWadAt / maxCharmWadAt / *Float — early return on 0n matches contract base envelope constants.
  • Doc anchor resolves: docs/testing/invariants-and-business-logic.md#ts-charm-envelope-ref-zero-issue-109.
  • (Optional) Call each helper with 0n ref in a quick REPL — no RangeError: Division by zero.

Leaving issue open for your sign-off.

Implemented **GitLab #109** on `main` (commit db00da4 merges `fix/gl109-ts-charm-bounds-ref-zero`). ## What changed - `frontend/src/lib/timeCurveMath.ts`: `minCharmWadAt`, `maxCharmWadAt`, `minCharmWadAtFloat`, `maxCharmWadAtFloat` return `CHARM_MIN_BASE_WAD` / `CHARM_MAX_BASE_WAD` when `charmEnvelopeRefWad === 0n`, matching `TimeCurve._charmBounds` for degenerate `initialMinBuy` ([#61](https://gitlab.com/PlasticDigits/yieldomega/-/issues/61) / [#73](https://gitlab.com/PlasticDigits/yieldomega/-/issues/73)). - `frontend/src/lib/timeCurveMath.test.ts`: four regression tests (one per helper). - Docs: new invariant row + anchor `#ts-charm-envelope-ref-zero-issue-109` in `docs/testing/invariants-and-business-logic.md`; contributor guardrails item 19 extended; player skills crosslinks in `skills/README.md` and `skills/script-with-timecurve-local/SKILL.md`. ## Verification checklist for @brouie - [ ] `cd frontend && npx vitest run src/lib/timeCurveMath.test.ts` — all 8 tests pass (includes new ref-zero block). - [ ] Read `minCharmWadAt` / `maxCharmWadAt` / `*Float` — early return on `0n` matches contract base envelope constants. - [ ] Doc anchor resolves: `docs/testing/invariants-and-business-logic.md#ts-charm-envelope-ref-zero-issue-109`. - [ ] (Optional) Call each helper with `0n` ref in a quick REPL — no `RangeError: Division by zero`. Leaving issue **open** for your sign-off.
Brouie commented 2026-05-03 22:51:39 +00:00 (Migrated from gitlab.com)

Verification PASS on db00da4

All acceptance items verified.

Items

[✓] Test suite passes — npx vitest run src/lib/timeCurveMath.test.ts → 8/8 PASS in 4ms (matches expected count: 4 new ref-zero tests + existing).

[✓] Source-side parity confirmed — All 4 helpers in frontend/src/lib/timeCurveMath.ts early-return on charmEnvelopeRefWad === 0n:

  • minCharmWadAt (line 56) — guard at line 62, returns CHARM_MIN_BASE_WAD
  • maxCharmWadAt (line 69) — guard at line 74, returns CHARM_MAX_BASE_WAD
  • minCharmWadAtFloat (line 122) — guard at line 127, returns CHARM_MIN_BASE_WAD
  • maxCharmWadAtFloat (line 134) — guard at line 139, returns CHARM_MAX_BASE_WAD

Comment at line 61 explicitly cites parity with TimeCurve._charmBounds and #61 / #73. Each function returns the static base constant, no mulDiv / division reached on the zero path. JS RangeError: Division by zero no longer reachable.

[✓] Doc anchor resolves — docs/testing/invariants-and-business-logic.md line 463 has anchor <a id="ts-charm-envelope-ref-zero-issue-109"> plus full invariant row cross-linking _charmBounds parity (#61 / #73) and ERC1967 proxy reminder.

[✓] Skip REPL — guard behavior is deterministic from source read; tests already exercise the 0n path for all 4 helpers.

cc @PlasticDigits

## Verification PASS on `db00da4` All acceptance items verified. ### Items [✓] **Test suite passes** — `npx vitest run src/lib/timeCurveMath.test.ts` → 8/8 PASS in 4ms (matches expected count: 4 new ref-zero tests + existing). [✓] **Source-side parity confirmed** — All 4 helpers in `frontend/src/lib/timeCurveMath.ts` early-return on `charmEnvelopeRefWad === 0n`: - `minCharmWadAt` (line 56) — guard at line 62, returns `CHARM_MIN_BASE_WAD` - `maxCharmWadAt` (line 69) — guard at line 74, returns `CHARM_MAX_BASE_WAD` - `minCharmWadAtFloat` (line 122) — guard at line 127, returns `CHARM_MIN_BASE_WAD` - `maxCharmWadAtFloat` (line 134) — guard at line 139, returns `CHARM_MAX_BASE_WAD` Comment at line 61 explicitly cites parity with `TimeCurve._charmBounds` and #61 / #73. Each function returns the static base constant, no `mulDiv` / division reached on the zero path. JS `RangeError: Division by zero` no longer reachable. [✓] **Doc anchor resolves** — `docs/testing/invariants-and-business-logic.md` line 463 has anchor `<a id="ts-charm-envelope-ref-zero-issue-109">` plus full invariant row cross-linking `_charmBounds` parity (#61 / #73) and ERC1967 proxy reminder. [✓] **Skip REPL** — guard behavior is deterministic from source read; tests already exercise the `0n` path for all 4 helpers. cc @PlasticDigits
Brouie (Migrated from gitlab.com) closed this issue 2026-05-04 03:10:21 +00:00
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/yieldomega#109
No description provided.