fix(pair): reject zero and dust-floor belief_price in check_max_spread #1230

Closed
opened 2026-09-11 07:52:41 +00:00 by PlasticDigits · 5 comments

Summary

dex_common::max_spread::check_max_spread (invariant L9, #197) does not validate a present belief_price. Two caller-supplied values break the guard:

  1. belief_price == 0 — Decimal::one() / bp panics (VM abort) instead of a contract error.
  2. belief_price large enough that expected output floor(offer_amount / bp) (implemented as offer_amount * (Decimal::one() / bp)) is 0 — the belief branch then skips the ratio check because of if expected_return > Uint128::zero(), so execute succeeds with no L9 bound.

When belief_price is Some(_), the #307 material-pool floor is also skipped. A dust-floor belief therefore fail-opens both the belief shortfall check and the no-belief hybrid floor.

Bundle (do not split): zero-panic, dust-floor silent skip, dedicated error, tests, integrator/L9 doc sentence.

Parent L9 work: #197. Related but not this ticket: #81 (hybrid semantics, closed), #273 / #307 / #334 (no-belief / pure-book floors). Do not reopen those unless a merged invariant is wrong.

Current codebase

smartcontracts/packages/dex-common/src/max_spread.rs belief branch:

if let Some(bp) = belief_price {
    let expected_return = inputs.offer_amount * (Decimal::one() / bp);
    // ...
    if expected_return > Uint128::zero()
        && Decimal::from_ratio(spread, expected_return) > max_allowed
    {
        return Err(CheckMaxSpreadError::SpreadExceeded(...));
    }
}
  • CosmWasm Decimal division by zero panics. There is no CheckMaxSpreadError variant for invalid belief.
  • expected_return == 0 was guarded to avoid Decimal::from_ratio(_, 0), but the fallback is pass, not reject.
  • Docs (docs/integrators.md, ADR 0001, contracts-security-audit L9) state expected output is offer_amount / belief_price and do not require bp > 0 or expected_return > 0.
  • Pair assert_max_spread and Simulation / ReverseSimulation / HybridSimulation all delegate here. Router hops that set belief_price inherit the same math.
  • Frontend swapMaxSpread.ts mirrors the no-belief path only (retail does not send belief_price today). Integrators and greedy/pure-book execute (#334 G8) do send it.

Why this is needed

L9 is the on-chain sandwich / slippage floor for anyone who sets belief_price (TerraSwap-compatible). A panic is an unhelpful abort and can brick a query. A dust-floor belief is worse: the swap is treated as “within max_spread” even when actual output is arbitrarily bad, and hybrid #307 no longer applies. That is a fail-open of the only remaining spread check on that message.

Happy-path L9 formula (shortfall vs offer / belief_price using book_net + pool_net + pool_commission) stays.

Constraints / guardrails

  • Preserve byte-for-byte behavior when belief_price is None (#197 / #273 / #307 / #334).
  • Preserve belief happy path: non-zero bp whose expected_return >= 1 still uses the existing shortfall / max_spread inequality (strict >).
  • Do not change default max_spread (1%). Do not invent a new retail dApp belief_price field.
  • Reject with a contract error (typed CheckMaxSpreadError or existing SpreadExceeded), never panic! / unwrap / Decimal div-by-zero.
  • Simulation and execute must agree (same helper). Query must not VM-panic on bp = 0.
  • Integer / Decimal only; no floats. Do not “fix” by omitting the expected_return > 0 guard and then panicking in from_ratio.
  • Pair min_return / router per-hop min_return remain independent floors (#334). A valid belief_price still satisfies G8; an invalid one must not count as “belief was set.”
  • Frontend no-belief preflight stays. Optional: reject belief_price 0 / non-positive in any TS builder that already serializes it (scripts, e2e, integrator examples) — not a new Swap UI control.
  • Update L9 / docs/integrators.md one sentence: zero and dust-floor belief are invalid.
  • Do not migrate wasm, change factory, or retune default spread.

Relevant files

Path Why
smartcontracts/packages/dex-common/src/max_spread.rs Belief branch: Decimal::one() / bp, expected_return > 0 skip, tests
smartcontracts/packages/dex-common/src/max_spread.rs (CheckMaxSpreadError) New or reused reject reason
smartcontracts/contracts/pair/src/contract.rs assert_max_spread mapping to pair ContractError
smartcontracts/contracts/router/src/contract.rs Hops that pass belief_price; error passthrough
smartcontracts/contracts/pair / cl8y-dex-tests hybrid belief tests hybrid_belief_price_max_spread_*, pool-only test_swap_max_spread
docs/integrators.md “With belief_price” paragraph
docs/contracts-security-audit.md L9 Same rule
skills/AGENTS_MAX_SPREAD_HYBRID.md Agent invariant
frontend-dapp/src/utils/swapMaxSpread.ts Only if a serializer already emits belief; do not expand retail
  1. At the top of the Some(bp) branch, reject bp.is_zero() with a dedicated error (e.g. InvalidBeliefPrice / BeliefPriceZero). Map it in pair/router so LCD shows an attribute, not RuntimeError.
  2. Compute expected_return with checked Decimal math. If reciprocal underflows to 0 or offer * (1/bp) floors to Uint128::zero(), Err (same invalid-belief error, or SpreadExceeded with actual = 1). Do not take the current expected_return > 0 skip.
  3. Keep the existing shortfall check for expected_return >= 1.
  4. Unit tests in dex-common (no chain): zero panic-regression; dust floor with huge bp and healthy actual_return must is_err; existing belief_counts_pool_commission_in_actual_return still passes.
  5. One pair/integration test: native or CW20 Swap with belief_price: "0" returns a contract error; one with belief_price such that offer / bp == 0 and a non-zero pool fill is rejected even at max_spread = 1.
  6. Doc/skill one-liners. No frontend Swap UX change.

Acceptance criteria

  • AC1. Given belief_price = Decimal::zero() (execute or Simulation / HybridSimulation). When check_max_spread / pair swap runs. Then result is a typed contract error. Not a CosmWasm panic / out of gas from abort.
  • AC2. Given offer_amount > 0 and belief_price such that offer_amount * (Decimal::one() / bp) == 0. When actual return (book_net + pool_net + pool_commission) is > 0. Then the call errors. It must not Ok(()) at default or 100% max_spread.
  • AC3. Given a legal belief_price with expected_return >= 1 inside tolerance. When the same inputs as today’s passing unit tests. Then still Ok (commission-in-actual, hybrid total output unchanged).
  • AC4. Given belief_price: None. When pool-only and hybrid no-belief cases from #197 / #273 / #307 / #334. Then unchanged pass/fail.
  • AC5. Simulation query with belief_price: "0" returns an error JSON / StdError, not a VM panic that 500s LCD.
  • AC6. Docs + skill: L9 / integrators state that belief_price must be strictly positive and must produce expected_return >= 1 raw unit; otherwise the swap is rejected.
  • AC7. Pair min_return still independently enforces #334 when belief is absent; an invalid belief does not satisfy “belief was set” for G8.

Expected vs actual

Case Expected Actual today
bp = 0 Contract error Decimal div-by-zero panic
floor(offer/bp) == 0 Reject (fail closed) Ok — L9 and #307 skipped
Legal bp, within max_spread Unchanged Ok Ok
belief_price: None Unchanged Unchanged

Test plan (functional paths)

# Path Expect
T1 check_max_spread(Some(0), …) unit Err, no panic
T2 bp with 1/bp == 0 (Decimal underflow) Err
T3 Small offer, bp with offer * (1/bp) == 0 but 1/bp != 0 Err
T4 Existing belief_counts_pool_commission_in_actual_return Pass
T5 hybrid_belief_price_max_spread_rejects_shortfall_on_total_output Pass
T6 belief_price: None pool-only test_swap_max_spread Pass
T7 Pair execute Swap { belief_price: "0" } Contract error attribute
T8 Pair Simulation belief_price: "0" Query error, not VM panic
T9 Legal bp, exact tolerance Still succeeds (strict > only)
T10 Invalid bp and min_return set Still reject invalid belief (do not skip validation because min_return is present)

Vitest: only if a TS helper is taught to reject 0 / non-positive belief. swapMaxSpread.test.ts no-belief table stays green.

Test plan (attack, hack, and abuse)

# Vector Expect
A1 Integrator sets belief_price to a huge Decimal so expected_return floors to 0 and sandwich fills at any price Reject; L9 does not fail-open
A2 Hybrid toxic book + dust pool + dummy belief_price that floors to 0 (bypass #307 by setting Some(bp)) Reject
A3 Greedy / pure-book G8 “set belief” with bp = 0 to skip min_return Must not count as a valid floor; error (G8 still requires a usable belief or min_return)
A4 Query vs execute disagreement (Simulation panics, execute would have… ) Both error the same way
A5 max_spread: Some(1) (100%) plus dust-floor belief Still reject invalid belief; 100% tolerance is not a skip of validation
A6 Negative is not representable; bp just above 0 with expected_return >= 1 Normal shortfall math, not this reject
A7 Router multi-hop one hop with bp = 0 That hop errors; no silent pass-through
A8 Frontend retail path (belief_price unset) Unchanged; no new URL/query belief injection

Verification criteria

  • cd smartcontracts && cargo test -p dex-common max_spread — new cases + existing (including belief_counts_pool_commission_in_actual_return).
  • cd smartcontracts && cargo test -p cl8y-dex-tests hybrid_belief_price_max_spread
  • cd smartcontracts && cargo test -p cl8y-dex-tests test_swap_max_spread
  • Pair/query path for belief_price: "0" does not panic (unit or multi-test).
  • make test-contracts green if that target is the repo’s contract gate.
  • Docs grep: integrators + L9 mention invalid zero / dust-floor belief.
  • Frontend swapMaxSpread.test.ts still green if untouched.

Out of scope

  • Changing no-belief hybrid shortfall or the 10% pool floor (#273 / #307).
  • Requiring belief_price on pool-only retail swaps (dApp stays null).
  • Indexer /route/solve exact-out.
  • Wasm migrate / code-id bump as part of this ticket (ops follow-up after merge).
  • Reopening #81 / #197 / #307.

First-pass model recommendation

Recommendation: grok-high

Rationale: Production CosmWasm slippage math in dex-common::max_spread plus pair/router error mapping and L9 docs. Founder-required contracts / wasm scope (model-policy invariant 5). Fail-open of a sandwich guard is not a single-file UI tweak; wrong reject/pass changes execute vs simulation. Verify with dex-common unit tests plus existing hybrid belief / pool-only spread tests — not Composer-eligible.

## Summary `dex_common::max_spread::check_max_spread` (invariant L9, #197) does not validate a present `belief_price`. Two caller-supplied values break the guard: 1. `belief_price == 0` — `Decimal::one() / bp` panics (VM abort) instead of a contract error. 2. `belief_price` large enough that expected output `floor(offer_amount / bp)` (implemented as `offer_amount * (Decimal::one() / bp)`) is **0** — the belief branch then skips the ratio check because of `if expected_return > Uint128::zero()`, so execute **succeeds** with no L9 bound. When `belief_price` is `Some(_)`, the #307 material-pool floor is also skipped. A dust-floor belief therefore fail-opens **both** the belief shortfall check and the no-belief hybrid floor. Bundle (do not split): zero-panic, dust-floor silent skip, dedicated error, tests, integrator/L9 doc sentence. Parent L9 work: #197. Related but **not** this ticket: #81 (hybrid semantics, closed), #273 / #307 / #334 (no-belief / pure-book floors). Do not reopen those unless a merged invariant is wrong. ## Current codebase `smartcontracts/packages/dex-common/src/max_spread.rs` belief branch: ```rust if let Some(bp) = belief_price { let expected_return = inputs.offer_amount * (Decimal::one() / bp); // ... if expected_return > Uint128::zero() && Decimal::from_ratio(spread, expected_return) > max_allowed { return Err(CheckMaxSpreadError::SpreadExceeded(...)); } } ``` - CosmWasm `Decimal` division by zero panics. There is no `CheckMaxSpreadError` variant for invalid belief. - `expected_return == 0` was guarded to avoid `Decimal::from_ratio(_, 0)`, but the fallback is **pass**, not reject. - Docs (`docs/integrators.md`, ADR 0001, contracts-security-audit L9) state expected output is `offer_amount / belief_price` and do not require `bp > 0` or `expected_return > 0`. - Pair `assert_max_spread` and Simulation / ReverseSimulation / HybridSimulation all delegate here. Router hops that set `belief_price` inherit the same math. - Frontend `swapMaxSpread.ts` mirrors the **no-belief** path only (retail does not send `belief_price` today). Integrators and greedy/pure-book execute (#334 G8) **do** send it. ## Why this is needed L9 is the on-chain sandwich / slippage floor for anyone who sets `belief_price` (TerraSwap-compatible). A panic is an unhelpful abort and can brick a query. A dust-floor belief is worse: the swap is treated as “within max_spread” even when actual output is arbitrarily bad, and hybrid #307 no longer applies. That is a fail-open of the only remaining spread check on that message. Happy-path L9 formula (shortfall vs `offer / belief_price` using `book_net + pool_net + pool_commission`) stays. ## Constraints / guardrails - Preserve byte-for-byte behavior when `belief_price` is `None` (#197 / #273 / #307 / #334). - Preserve belief happy path: non-zero `bp` whose `expected_return >= 1` still uses the existing shortfall / `max_spread` inequality (strict `>`). - Do not change default `max_spread` (1%). Do not invent a new retail dApp `belief_price` field. - Reject with a **contract error** (typed `CheckMaxSpreadError` or existing `SpreadExceeded`), never `panic!` / `unwrap` / Decimal div-by-zero. - Simulation and execute must agree (same helper). Query must not VM-panic on `bp = 0`. - Integer / Decimal only; no floats. Do not “fix” by omitting the `expected_return > 0` guard and then panicking in `from_ratio`. - Pair `min_return` / router per-hop `min_return` remain independent floors (#334). A valid `belief_price` still satisfies G8; an invalid one must not count as “belief was set.” - Frontend no-belief preflight stays. Optional: reject `belief_price` `0` / non-positive in any TS builder that already serializes it (scripts, e2e, integrator examples) — not a new Swap UI control. - Update L9 / `docs/integrators.md` one sentence: zero and dust-floor belief are invalid. - Do not migrate wasm, change factory, or retune default spread. ## Relevant files | Path | Why | | --- | --- | | `smartcontracts/packages/dex-common/src/max_spread.rs` | Belief branch: `Decimal::one() / bp`, `expected_return > 0` skip, tests | | `smartcontracts/packages/dex-common/src/max_spread.rs` (`CheckMaxSpreadError`) | New or reused reject reason | | `smartcontracts/contracts/pair/src/contract.rs` | `assert_max_spread` mapping to pair `ContractError` | | `smartcontracts/contracts/router/src/contract.rs` | Hops that pass `belief_price`; error passthrough | | `smartcontracts/contracts/pair` / `cl8y-dex-tests` hybrid belief tests | `hybrid_belief_price_max_spread_*`, pool-only `test_swap_max_spread` | | `docs/integrators.md` | “With `belief_price`” paragraph | | `docs/contracts-security-audit.md` L9 | Same rule | | `skills/AGENTS_MAX_SPREAD_HYBRID.md` | Agent invariant | | `frontend-dapp/src/utils/swapMaxSpread.ts` | Only if a serializer already emits belief; do not expand retail | ## Recommended direction 1. At the top of the `Some(bp)` branch, reject `bp.is_zero()` with a dedicated error (e.g. `InvalidBeliefPrice` / `BeliefPriceZero`). Map it in pair/router so LCD shows an attribute, not `RuntimeError`. 2. Compute `expected_return` with checked Decimal math. If reciprocal underflows to 0 **or** `offer * (1/bp)` floors to `Uint128::zero()`, **Err** (same invalid-belief error, or `SpreadExceeded` with `actual = 1`). Do not take the current `expected_return > 0` skip. 3. Keep the existing shortfall check for `expected_return >= 1`. 4. Unit tests in `dex-common` (no chain): zero panic-regression; dust floor with huge `bp` and healthy `actual_return` must `is_err`; existing `belief_counts_pool_commission_in_actual_return` still passes. 5. One pair/integration test: native or CW20 `Swap` with `belief_price: "0"` returns a contract error; one with `belief_price` such that `offer / bp == 0` and a non-zero pool fill is rejected even at `max_spread = 1`. 6. Doc/skill one-liners. No frontend Swap UX change. ## Acceptance criteria - AC1. **Given** `belief_price = Decimal::zero()` (execute or Simulation / HybridSimulation). **When** `check_max_spread` / pair swap runs. **Then** result is a typed contract error. **Not** a CosmWasm panic / `out of gas` from abort. - AC2. **Given** `offer_amount > 0` and `belief_price` such that `offer_amount * (Decimal::one() / bp) == 0`. **When** actual return (`book_net + pool_net + pool_commission`) is **> 0**. **Then** the call errors. It must not `Ok(())` at default or 100% `max_spread`. - AC3. **Given** a legal `belief_price` with `expected_return >= 1` inside tolerance. **When** the same inputs as today’s passing unit tests. **Then** still `Ok` (commission-in-actual, hybrid total output unchanged). - AC4. **Given** `belief_price: None`. **When** pool-only and hybrid no-belief cases from #197 / #273 / #307 / #334. **Then** unchanged pass/fail. - AC5. Simulation query with `belief_price: "0"` returns an error JSON / `StdError`, not a VM panic that 500s LCD. - AC6. Docs + skill: L9 / integrators state that `belief_price` must be strictly positive and must produce `expected_return >= 1` raw unit; otherwise the swap is rejected. - AC7. Pair `min_return` still independently enforces #334 when belief is absent; an invalid belief does not satisfy “belief was set” for G8. **Expected vs actual** | Case | Expected | Actual today | | --- | --- | --- | | `bp = 0` | Contract error | Decimal div-by-zero panic | | `floor(offer/bp) == 0` | Reject (fail closed) | `Ok` — L9 and #307 skipped | | Legal `bp`, within `max_spread` | Unchanged `Ok` | `Ok` | | `belief_price: None` | Unchanged | Unchanged | ## Test plan (functional paths) | # | Path | Expect | | --- | --- | --- | | T1 | `check_max_spread(Some(0), …)` unit | `Err`, no panic | | T2 | `bp` with `1/bp == 0` (Decimal underflow) | `Err` | | T3 | Small offer, `bp` with `offer * (1/bp) == 0` but `1/bp != 0` | `Err` | | T4 | Existing `belief_counts_pool_commission_in_actual_return` | Pass | | T5 | `hybrid_belief_price_max_spread_rejects_shortfall_on_total_output` | Pass | | T6 | `belief_price: None` pool-only `test_swap_max_spread` | Pass | | T7 | Pair execute `Swap { belief_price: "0" }` | Contract error attribute | | T8 | Pair Simulation `belief_price: "0"` | Query error, not VM panic | | T9 | Legal `bp`, exact tolerance | Still succeeds (strict `>` only) | | T10 | Invalid `bp` **and** `min_return` set | Still reject invalid belief (do not skip validation because min_return is present) | Vitest: only if a TS helper is taught to reject `0` / non-positive belief. `swapMaxSpread.test.ts` no-belief table stays green. ## Test plan (attack, hack, and abuse) | # | Vector | Expect | | --- | --- | --- | | A1 | Integrator sets `belief_price` to a huge Decimal so `expected_return` floors to 0 and sandwich fills at any price | Reject; L9 does not fail-open | | A2 | Hybrid toxic book + dust pool + dummy `belief_price` that floors to 0 (bypass #307 by setting `Some(bp)`) | Reject | | A3 | Greedy / pure-book G8 “set belief” with `bp = 0` to skip `min_return` | Must not count as a valid floor; error (G8 still requires a **usable** belief or `min_return`) | | A4 | Query vs execute disagreement (`Simulation` panics, execute would have… ) | Both error the same way | | A5 | `max_spread: Some(1)` (100%) plus dust-floor belief | Still reject invalid belief; 100% tolerance is not a skip of validation | | A6 | Negative is not representable; `bp` just above 0 with `expected_return >= 1` | Normal shortfall math, not this reject | | A7 | Router multi-hop one hop with `bp = 0` | That hop errors; no silent pass-through | | A8 | Frontend retail path (`belief_price` unset) | Unchanged; no new URL/query belief injection | ## Verification criteria - `cd smartcontracts && cargo test -p dex-common max_spread` — new cases + existing (including `belief_counts_pool_commission_in_actual_return`). - `cd smartcontracts && cargo test -p cl8y-dex-tests hybrid_belief_price_max_spread` - `cd smartcontracts && cargo test -p cl8y-dex-tests test_swap_max_spread` - Pair/query path for `belief_price: "0"` does not panic (unit or multi-test). - `make test-contracts` green if that target is the repo’s contract gate. - Docs grep: integrators + L9 mention invalid zero / dust-floor belief. - Frontend `swapMaxSpread.test.ts` still green if untouched. ## Out of scope - Changing no-belief hybrid shortfall or the 10% pool floor (#273 / #307). - Requiring `belief_price` on pool-only retail swaps (dApp stays `null`). - Indexer `/route/solve` exact-out. - Wasm migrate / code-id bump as part of this ticket (ops follow-up after merge). - Reopening #81 / #197 / #307. ## First-pass model recommendation Recommendation: grok-high Rationale: Production CosmWasm slippage math in `dex-common::max_spread` plus pair/router error mapping and L9 docs. Founder-required **contracts / wasm** scope (model-policy invariant 5). Fail-open of a sandwich guard is not a single-file UI tweak; wrong reject/pass changes execute vs simulation. Verify with `dex-common` unit tests plus existing hybrid belief / pool-only spread tests — not Composer-eligible.
Author
Owner

cl8y-agent-control: queued implement job 19394d61-77f9-4856-b956-d7f566c42ea8 (not executed; no Hetzner VM).

cl8y-agent-control: queued `implement` job `19394d61-77f9-4856-b956-d7f566c42ea8` (not executed; no Hetzner VM).
Author
Owner

Merged onto origin/main via #1238. make verify-issue-1230 was 13/13. Zero / dust-floor belief_price now InvalidBeliefPrice.

Leftover: pair wasm store+migrate so listed columbus-5 pairs reject dust belief. Bundled with #1227 / #1231 in a new ops issue.

CI: Woodpecker did not post live statuses; merge used local gitleaks + Forgejo status.

Merged onto `origin/main` via #1238. `make verify-issue-1230` was 13/13. Zero / dust-floor `belief_price` now `InvalidBeliefPrice`. Leftover: pair wasm store+migrate so listed columbus-5 pairs reject dust belief. Bundled with #1227 / #1231 in a new ops issue. CI: Woodpecker did not post live statuses; merge used local gitleaks + Forgejo status.
Author
Owner

Follow-up ops ticket: #1246. Woodpecker enablement: #1247.

Follow-up ops ticket: #1246. Woodpecker enablement: #1247.
Author
Owner

columbus-5 wasm for this ticket is not live. Ops tracker: #1246.

Live pairs are 11639 / 1.16.0 (#712). LCD HybridSimulation belief_price: "0" still 200 (same output as omitted belief) — #1230 / #1227 / #1231 execute/query wasm still needs a 1.17.0 store+migrate (git CONTRACT_VERSION is still 1.15.0). Tax listed pin is 11630 (not 11611/11619); ALPHA terra1x6e64… is 1.0.0 and needs a tax cw2 bump + CMM migrate for #1228 / #1237.

columbus-5 wasm for this ticket is **not** live. Ops tracker: [#1246](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/1246). Live pairs are **11639 / 1.16.0** (#712). LCD `HybridSimulation` `belief_price: "0"` still **200** (same output as omitted belief) — #1230 / #1227 / #1231 execute/query wasm still needs a **1.17.0** store+migrate (git `CONTRACT_VERSION` is still 1.15.0). Tax listed pin is **11630** (not 11611/11619); ALPHA `terra1x6e64…` is 1.0.0 and needs a tax cw2 bump + CMM migrate for #1228 / #1237.
Author
Owner

columbus-5 pair wasm is live: 11664 / cw2 1.17.0 (store DB35943A4925059E770A63C9ADDF35622696088A4DFFC8A91110466034995961). Factory pair_code_id=11664, GetPairCount=20. LCD UST1/cUSTC HybridSimulation belief_price:"0" → Invalid belief_price 0. Ops #1246.

columbus-5 pair wasm is live: **11664 / cw2 1.17.0** (store `DB35943A4925059E770A63C9ADDF35622696088A4DFFC8A91110466034995961`). Factory `pair_code_id=11664`, `GetPairCount=20`. LCD UST1/cUSTC `HybridSimulation` `belief_price:"0"` → `Invalid belief_price 0`. Ops [#1246](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/1246).
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#1230
No description provided.