Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f47c89c6b | |||
| 101a750452 | |||
| 9924a4853a | |||
| 9abda8d01e | |||
| dd84e24a51 | |||
| 53ad56e3d2 | |||
| 9e7fc403f5 | |||
| bcae60666b |
+22
-22
@@ -139,7 +139,7 @@ fixtures. No keeper, no Cosmos runtime, no `app.go`.
|
|||||||
> module); G-006 go.mod unchanged (HTMX is a vendored static asset, not a Go
|
> module); G-006 go.mod unchanged (HTMX is a vendored static asset, not a Go
|
||||||
> dep). The final-phase audit enforces the feature purity gate.
|
> dep). The final-phase audit enforces the feature purity gate.
|
||||||
|
|
||||||
## v0.7 Milestone Requirements (Fraternal Groups Foundation — Feature)
|
## v0.7 Milestone Requirements (Fraternal Groups Foundation — Feature) — COMPLETE
|
||||||
|
|
||||||
v0.7 adapts the 1890–1930 fraternal benefit-society model for borderless
|
v0.7 adapts the 1890–1930 fraternal benefit-society model for borderless
|
||||||
digital service. It delivers Cover Pools (mission-locked reserve floors +
|
digital service. It delivers Cover Pools (mission-locked reserve floors +
|
||||||
@@ -161,27 +161,27 @@ Crowding-Out firewall + Anti-Capture Bill of Rights). The existing `x/pact`
|
|||||||
|
|
||||||
| ID | Requirement | Vision § | Priority | Status | Phase |
|
| ID | Requirement | Vision § | Priority | Status | Phase |
|
||||||
|----|-------------|----------|----------|--------|-------|
|
|----|-------------|----------|----------|--------|-------|
|
||||||
| REQ-046 | Cover Pool Factory runtime — Factory rejects category launches below in-force reserve floor; supports Cover-Charter deployment; Watcher attestation pipeline operational; category staging per REQ-065 | §16 | High | Not started | v0.7/P1 |
|
| REQ-046 | Cover Pool Factory runtime — Factory rejects category launches below in-force reserve floor; supports Cover-Charter deployment; Watcher attestation pipeline operational; category staging per REQ-065 | §16 | High | Complete | v0.7/P1 |
|
||||||
| REQ-047 | Cover Pool reserve target floor 1.5× annual contributions — LOCKED; mission-lock semantic enforced; below-floor auto-pause of Cover-Fee routing | §16 | High | Not started | v0.7/P1 |
|
| REQ-047 | Cover Pool reserve target floor 1.5× annual contributions — LOCKED; mission-lock semantic enforced; below-floor auto-pause of Cover-Fee routing | §16 | High | Complete | v0.7/P1 |
|
||||||
| REQ-048 | Cover Pool reserve target ceiling 2.5× (governance-tunable within 1.5×–2.5×) — Watcher escalation after 12 months; Pool Council MAY vote within bounded range | §16 | High | Not started | v0.7/P2 |
|
| REQ-048 | Cover Pool reserve target ceiling 2.5× (governance-tunable within 1.5×–2.5×) — Watcher escalation after 12 months; Pool Council MAY vote within bounded range | §16 | High | Complete | v0.7/P2 |
|
||||||
| REQ-049 | Cover Pool Standing gate minimums — LOCKED; Travel ≥ Trusted 4.0; Health-MCS ≥ Preferred 4.5; Pool MAY tighten but NEVER loosen below protocol minimum. Binds at Factory runtime (D-077) | §16, §9.3 | High | Not started | v0.7/P1 |
|
| REQ-049 | Cover Pool Standing gate minimums — LOCKED; Travel ≥ Trusted 4.0; Health-MCS ≥ Preferred 4.5; Pool MAY tighten but NEVER loosen below protocol minimum. Binds at Factory runtime (D-077) | §16, §9.3 | High | Complete | v0.7/P1 |
|
||||||
| REQ-050 | Cover-Fee tagging at protocol layer — LOCKED; Cover-Fee Grains carry `category_tag`; settlement rejects category-mismatched Calls (FR-COVER-11); Pool-level fungibility preserved for net-reserve accounting | §16 | High | Not started | v0.7/P1 |
|
| REQ-050 | Cover-Fee tagging at protocol layer — LOCKED; Cover-Fee Grains carry `category_tag`; settlement rejects category-mismatched Calls (FR-COVER-11); Pool-level fungibility preserved for net-reserve accounting | §16 | High | Complete | v0.7/P1 |
|
||||||
| REQ-051 | Guild Charter + Common Bond requirement — LOCKED; at formation: Common Bond declared + hash-pinned; Public Profile published (bond summary, disclaimers, Mason count or "private", Pier wrapper if any) | §12 | Medium | Not started | v0.7/P3 |
|
| REQ-051 | Guild Charter + Common Bond requirement — LOCKED; at formation: Common Bond declared + hash-pinned; Public Profile published (bond summary, disclaimers, Mason count or "private", Pier wrapper if any) | §12 | Medium | Complete | v0.7/P3 |
|
||||||
| REQ-052 | Cover-Charter (SoB, dispute path, gate, holding period) — LOCKED; distinct from governance charter; signed by Pool Host + witnessed by Watcher at deployment; amendments require Pool supermajority + 7-day cooling + Watcher + Counsel; protocol does NOT enforce SoB content (FR-CHTR-5) | §16 | High | Not started | v0.7/P2 |
|
| REQ-052 | Cover-Charter (SoB, dispute path, gate, holding period) — LOCKED; distinct from governance charter; signed by Pool Host + witnessed by Watcher at deployment; amendments require Pool supermajority + 7-day cooling + Watcher + Counsel; protocol does NOT enforce SoB content (FR-CHTR-5) | §16 | High | Complete | v0.7/P2 |
|
||||||
| REQ-053 | Chapter Federation (Parent/Chapter, secession terms, liens at founding) — Parent Guild + Chapters; Chapters inherit + may tighten but not loosen; secession terms coded at founding; good-standing liens at founding (not freely increasable); Chapter retains mesh-level Voice (Pier does NOT carry Voice per FR-VOICE-6) | §12 | High | Not started | v0.7/P3 |
|
| REQ-053 | Chapter Federation (Parent/Chapter, secession terms, liens at founding) — Parent Guild + Chapters; Chapters inherit + may tighten but not loosen; secession terms coded at founding; good-standing liens at founding (not freely increasable); Chapter retains mesh-level Voice (Pier does NOT carry Voice per FR-VOICE-6) | §12 | High | Complete | v0.7/P3 |
|
||||||
| REQ-054 | Mutual Aid Bond (issuance ceiling 1×–3×, coupons in Cover Calls) — LOCKED; issuance ceiling mission-locked at 3× annual surplus; coupons payable in Cover Calls or mutual-aid credits (NEVER Bread); coupon rate bounded by `CouponCapBps=800`; use-of-proceeds locked to reserve build-out; default recapture per FR-MAB-7; Watcher attestation at deployment + quarterly audit. Enforcement: tagged streaming + Watcher-witnessed release (D-080) | §17 | High | Not started | v0.7/P4 |
|
| REQ-054 | Mutual Aid Bond (issuance ceiling 1×–3×, coupons in Cover Calls) — LOCKED; issuance ceiling mission-locked at 3× annual surplus; coupons payable in Cover Calls or mutual-aid credits (NEVER Bread); coupon rate bounded by `CouponCapBps=800`; use-of-proceeds locked to reserve build-out; default recapture per FR-MAB-7; Watcher attestation at deployment + quarterly audit. Enforcement: tagged streaming + Watcher-witnessed release (D-080) | §17 | High | Complete | v0.7/P4 |
|
||||||
| REQ-055 | Cover Claims Voucher role + bond + slashing — Specialization of Voucher role; bond default 10× avg Call size per Pool; reviews each Call independently (no self-adjudication, FR-CPCV-2); slashing via §9.4 mechanism with cross-Pool applicability (NFR-SEC-8); bounded earnings | §9.4, §15 | High | Not started | v0.7/P4 |
|
| REQ-055 | Cover Claims Voucher role + bond + slashing — Specialization of Voucher role; bond default 10× avg Call size per Pool; reviews each Call independently (no self-adjudication, FR-CPCV-2); slashing via §9.4 mechanism with cross-Pool applicability (NFR-SEC-8); bounded earnings | §9.4, §15 | High | Complete | v0.7/P4 |
|
||||||
| REQ-056 | Anti-Capture Bill of Rights v0.2 — LOCKED; 13 rights codified in code; cannot be amended or waived by any Charter; covers one-tap exit, no tax on personal Stash, audit-able Voice, cooling, Watcher inspection, Freeholder voucher, Counsel escalation, Anchored-Bread conversion, Wayfarer's Record, secession (founding terms), non-Cover-access, category-mismatch refusal | §8.2 [3] | High | Not started | v0.7/P5 |
|
| REQ-056 | Anti-Capture Bill of Rights v0.2 — LOCKED; 13 rights codified in code; cannot be amended or waived by any Charter; covers one-tap exit, no tax on personal Stash, audit-able Voice, cooling, Watcher inspection, Freeholder voucher, Counsel escalation, Anchored-Bread conversion, Wayfarer's Record, secession (founding terms), non-Cover-access, category-mismatch refusal | §8.2 [3] | High | Complete | v0.7/P5 |
|
||||||
| REQ-057 | Household simplified — no formal Council, one-tap exit — Household Stand may operate without formal Council; one-tap exit is the dispute path | §11 | Low | Not started | v0.7/P3 |
|
| REQ-057 | Household simplified — no formal Council, one-tap exit — Household Stand may operate without formal Council; one-tap exit is the dispute path | §11 | Low | Complete | v0.7/P3 |
|
||||||
| REQ-058 | Confederation Voice — one-Stand-one-Vote, internal bundle — LOCKED; Confederation aggregates member Stand Voice one-per-Stand; member Stands may bundle delegated Voice internally via §19 delegation | §11 | Medium | Not started | v0.7/P3 |
|
| REQ-058 | Confederation Voice — one-Stand-one-Vote, internal bundle — LOCKED; Confederation aggregates member Stand Voice one-per-Stand; member Stands may bundle delegated Voice internally via §19 delegation | §11 | Medium | Complete | v0.7/P3 |
|
||||||
| REQ-059 | Stand→Pier-customer boundary — escalation rule ($100k per D-074) — When annual Pass volume > $100k (10M Grain-cents), Stand is invited to Hub API; soft upgrade, not a ban | §11, §13 | Medium | Not started | v0.7/P5 |
|
| REQ-059 | Stand→Pier-customer boundary — escalation rule ($100k per D-074) — When annual Pass volume > $100k (10M Grain-cents), Stand is invited to Hub API; soft upgrade, not a ban | §11, §13 | Medium | Complete | v0.7/P5 |
|
||||||
| REQ-060 | Shadow vouch partial credit — 50% weight in Freeholder signal — LOCKED; Shadow vouch weight = 0.5× in Community Endorsement signal (vs 1.0× for non-Shadow vouch) | §9.1 | Medium | Not started | v0.7/P4 |
|
| REQ-060 | Shadow vouch partial credit — 50% weight in Freeholder signal — LOCKED; Shadow vouch weight = 0.5× in Community Endorsement signal (vs 1.0× for non-Shadow vouch) | §9.1 | Medium | Complete | v0.7/P4 |
|
||||||
| REQ-061 | Disclaimer cadence — per charter signing — LOCKED; jurisdictional disclaimer surfaced at every charter signing; not session-bounded | §11 | Low | Not started | v0.7/P3 |
|
| REQ-061 | Disclaimer cadence — per charter signing — LOCKED; jurisdictional disclaimer surfaced at every charter signing; not session-bounded | §11 | Low | Complete | v0.7/P3 |
|
||||||
| REQ-062 | Pool governance hybrid (Host + 3 elected + Watcher observer) — LOCKED; Cover Pool Council = Pool Host + 3 Masons elected by Pool-eligible Masons + Watcher observer seat; Cover Calls require majority with Watcher observer present. No Anchor seat (Anchor no-Voice §5) | §16 | High | Not started | v0.7/P2 |
|
| REQ-062 | Pool governance hybrid (Host + 3 elected + Watcher observer) — LOCKED; Cover Pool Council = Pool Host + 3 Masons elected by Pool-eligible Masons + Watcher observer seat; Cover Calls require majority with Watcher observer present. No Anchor seat (Anchor no-Voice §5) | §16 | High | Complete | v0.7/P2 |
|
||||||
| REQ-063 | MAB holder — surplus seniority only, no Voice at dissolution — LOCKED; MAB holders rank after Cover-Fee contributors but before Bread holders in Pool-surplus distributions (FR-MAB-4); NO Voice in Pool dissolution decisions (claimants, not Masons) | §17 | Medium | Not started | v0.7/P4 |
|
| REQ-063 | MAB holder — surplus seniority only, no Voice at dissolution — LOCKED; MAB holders rank after Cover-Fee contributors but before Bread holders in Pool-surplus distributions (FR-MAB-4); NO Voice in Pool dissolution decisions (claimants, not Masons) | §17 | Medium | Complete | v0.7/P4 |
|
||||||
| REQ-064 | Secession cooling — 21d Cover-active / 14d non-Cover — LOCKED; Chapter secession cooling: 21 Mesh-days if Cover-active, 14 Mesh-days if non-Cover; secured at founding, not reducible; lien audit required; Cover Call / Bond covenant clearance required before secession completes | §4.6 [3] | Medium | Not started | v0.7/P5 |
|
| REQ-064 | Secession cooling — 21d Cover-active / 14d non-Cover — LOCKED; Chapter secession cooling: 21 Mesh-days if Cover-active, 14 Mesh-days if non-Cover; secured at founding, not reducible; lien audit required; Cover Call / Bond covenant clearance required before secession completes | §4.6 [3] | Medium | Complete | v0.7/P5 |
|
||||||
| REQ-065 | Cover Pool category staging — Phase 2/3/4 — LOCKED; Phase 2: Travel + Health-MCS + Income-Pause; Phase 3: Equipment/Loss + Life-Burial + Road-Side; Phase 4: Cyber-Skimming + Guild-Internal-Mutual-Aid; Factory respects staging and rejects out-of-phase launches | §16 | High | Not started | v0.7/P2 |
|
| REQ-065 | Cover Pool category staging — Phase 2/3/4 — LOCKED; Phase 2: Travel + Health-MCS + Income-Pause; Phase 3: Equipment/Loss + Life-Burial + Road-Side; Phase 4: Cyber-Skimming + Guild-Internal-Mutual-Aid; Factory respects staging and rejects out-of-phase launches | §16 | High | Complete | v0.7/P2 |
|
||||||
| REQ-066 | Pier selection — Guild Council chooses, reversible, Pier Selection Index — Guild Council chooses Pier at formation; reversible by Cover Pool supermajority + Counsel witness; mesh maintains Pier Selection Index; Pier-Routed Legal Wrapper OPTIONAL (§5 default-no-wrapper) | §13 | Medium | Not started | v0.7/P5 |
|
| REQ-066 | Pier selection — Guild Council chooses, reversible, Pier Selection Index — Guild Council chooses Pier at formation; reversible by Cover Pool supermajority + Counsel witness; mesh maintains Pier Selection Index; Pier-Routed Legal Wrapper OPTIONAL (§5 default-no-wrapper) | §13 | Medium | Complete | v0.7/P5 |
|
||||||
|
|
||||||
> REQ-046..REQ-066 are NEW in v0.7. All are `feat`-class primitives (Cover
|
> REQ-046..REQ-066 are NEW in v0.7. All are `feat`-class primitives (Cover
|
||||||
> Pool Factory, Cover-Charter, Chapter Federation, MAB, Cover Claims Voucher,
|
> Pool Factory, Cover-Charter, Chapter Federation, MAB, Cover Claims Voucher,
|
||||||
|
|||||||
@@ -224,6 +224,71 @@ fixtures. No keeper, no Cosmos runtime, no `app.go` (none exists in the repo).
|
|||||||
- The 5 P1+ mainnet-readiness items deferred from v0.5 (governance spam deposit, CLOB front-running, real IBC simtest, CLOB perf, emitMatchEventHook testability) — those are v0.7+ mainnet-readiness, not UI work
|
- The 5 P1+ mainnet-readiness items deferred from v0.5 (governance spam deposit, CLOB front-running, real IBC simtest, CLOB perf, emitMatchEventHook testability) — those are v0.7+ mainnet-readiness, not UI work
|
||||||
- Bread-scale doc-fix (`docs/shared/bread-scale.md` is outdated vs code constants — P1+ follow-up, not v0.6 scope)
|
- Bread-scale doc-fix (`docs/shared/bread-scale.md` is outdated vs code constants — P1+ follow-up, not v0.6 scope)
|
||||||
|
|
||||||
|
## Milestone v0.7 — Fraternal Groups Foundation (COMPLETE; feature type; tags v0.6.x)
|
||||||
|
|
||||||
|
Target: Cover Pools + Chapter Federation + Mutual Aid Bonds + Anti-Capture
|
||||||
|
Bill of Rights v0.2. Adapts the 1890–1930 fraternal benefit-society model
|
||||||
|
for borderless digital service. New `x/cover` module (D-084, D-039
|
||||||
|
preced) + extensions to `x/bond` (MAB), `x/guild` (Chapter Federation),
|
||||||
|
`x/standing` (Shadow vouch), `x/stand` (Stand→Pier boundary). Simtest-
|
||||||
|
grade runtime (D-020 continues — no mainnet, no app.go).
|
||||||
|
|
||||||
|
- [x] P0: Pre-Execution (spec/clarify/research/plan/grill/mvp-ux) → v0.6.0
|
||||||
|
- [x] P1: Cover Pool Factory + firewall + floors + gates + tagging (REQ-046/047/049/050) → v0.6.1
|
||||||
|
- [x] P2: Cover-Charter + Pool governance + category staging + Bill of Rights types (REQ-048/052/062/065 + D-090(1)) → v0.6.2
|
||||||
|
- [x] P3: Guild Charter + Chapter Federation + Household/Confederation + disclaimer (REQ-051/053/057/058/061) → v0.6.3
|
||||||
|
- [x] P4: MAB + Cover Claims Voucher + Shadow vouch + MAB seniority (REQ-054/055/060/063) → v0.6.4
|
||||||
|
- [x] P5: Anti-Capture Bill ceremony + secession cooling + Stand→Pier + Pier selection (REQ-056/059/064/066) → v0.6.5
|
||||||
|
- [x] P6: Final Review + Audit + milestone Ship → v0.6.6 (milestone release)
|
||||||
|
- Status: COMPLETE — 21 REQs shipped; NEW x/cover module + 4 extensions; 12 locked consts; 4 lexicon meta-tests; G-003/G-006/G-028 intact; coverage ≥80% on all v0.7 surfaces
|
||||||
|
|
||||||
|
| Phase | Type | Scope | Patch |
|
||||||
|
|---|---|---|---|
|
||||||
|
| P0 | docs | Pre-Execution (spec/clarify/research/plan/grill/mvp-ux) | v0.6.0 |
|
||||||
|
| P1 | feat+test | Cover Pool Factory + Anti-Crowding-Out firewall + floors + gates + tagging (x/cover NEW) | v0.6.1 |
|
||||||
|
| P2 | feat | Cover-Charter + Pool Council + category staging + Bill of Rights types (D-090(1) temporal-gap fix) | v0.6.2 |
|
||||||
|
| P3 | feat | Guild Charter + Chapter Federation + Household/Confederation + disclaimer (x/guild keeper NEW) | v0.6.3 |
|
||||||
|
| P4 | feat | MAB + Cover Claims Voucher + Shadow vouch + MAB seniority waterfall | v0.6.4 |
|
||||||
|
| P5 | feat | Anti-Capture Bill ceremony + secession cooling + Stand→Pier + Pier selection | v0.6.5 |
|
||||||
|
| P6 | final | REVIEW + AUDIT + milestone SHIP | v0.6.6 (milestone release) |
|
||||||
|
|
||||||
|
### v0.7 Component mapping
|
||||||
|
|
||||||
|
| Component | Deliverable | v0.7 Module | Phase |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Cover Pool Factory | CoverPool + CoverFeeTag + CoverCall types + Factory keeper + firewall | x/cover (NEW) | v0.7/P1 |
|
||||||
|
| Anti-Crowding-Out firewall | x/cover/firewall + lexicon_meta_cover 4th meta-test | x/cover/firewall (NEW) + lexicon_meta_cover (NEW) | v0.7/P1 |
|
||||||
|
| Cover-Charter | CoverCharter + CharterAmendment + 7-day cooling | x/cover | v0.7/P2 |
|
||||||
|
| Anti-Capture Bill of Rights | 13 RightID consts + 13 Waivable* consts + RightIsWaivable + ValidateBasic gate (D-090(1) P2) + Counsel review ceremony (P5) | x/cover/types/rights.go (NEW) | v0.7/P2+P5 |
|
||||||
|
| Pool governance hybrid | PoolCouncil (3 Masons + Watcher observer; NO Anchor/MAB seat) | x/cover | v0.7/P2 |
|
||||||
|
| Category staging | CoverCategoryPhase + CoverCategory enums + FactoryAllowedPhases | x/cover | v0.7/P1+P2 |
|
||||||
|
| Mutual Aid Bond | MAB struct (Bond embed) + CouponDenom + 3× ceiling + tagged streaming | x/bond | v0.7/P4 |
|
||||||
|
| Cover Claims Voucher | CoverClaimsVoucher + 10× bond + no self-adjudication + slash | x/cover | v0.7/P4 |
|
||||||
|
| Shadow vouch 50% | ShadowVouchWeightMultiplier + IsShadow field + GetVoucherWeight | x/standing | v0.7/P4 |
|
||||||
|
| MAB seniority waterfall | PoolDissolutionWaterfall (Cover-Fee > MAB > Bread; MAB no Voice) | x/cover | v0.7/P4 |
|
||||||
|
| Chapter Federation | ParentGuildID + IsChapter + SecessionTerms + GoodStandingLiens | x/guild | v0.7/P3 |
|
||||||
|
| Secession cooling | 21d Cover-active / 14d non-Cover + lien audit + covenant clearance | x/guild | v0.7/P3+P5 |
|
||||||
|
| Household simplified | IsHousehold + one-tap exit | x/guild + x/stand | v0.7/P3 |
|
||||||
|
| Confederation Voice | IsConfederation + one-per-Stand delegation | x/guild + x/stand | v0.7/P3 |
|
||||||
|
| Stand→Pier boundary | StandPierEscalationAnnualPassVolumeCents + soft upgrade | x/stand + x/guild | v0.7/P5 |
|
||||||
|
| Pier selection | PierSelectionIndex + SelectPier + RevokePierSelection | x/cover | v0.7/P5 |
|
||||||
|
|
||||||
|
> **Tag-line note (G-010 continuation)**: v0.7 (feature) ships on the
|
||||||
|
> `v0.6.x` patch line (config.json tag_base `v0.6.x`): P0 -> `v0.6.0`,
|
||||||
|
> P1..P5 -> `v0.6.1..v0.6.5`, P6 -> `v0.6.6` (= the v0.7 milestone release,
|
||||||
|
> per D-008 — final phase patch IS the milestone release; no separate
|
||||||
|
> minor tag).
|
||||||
|
|
||||||
|
### v0.7 deferred to v0.8+
|
||||||
|
- Real blockchain interaction / mainnet / IBC (D-020 continues; simtest-grade runtime)
|
||||||
|
- A real `oyd` daemon / `app.go` / `cmd/oyd` (no chain runtime exists)
|
||||||
|
- Sovereign Anchor SPEC (`oy-sovereign-anchors` forthcoming; experimental per §5/D-076)
|
||||||
|
- USZ classification runtime (v0.8 REQ-095 — depends on Anchor pre-commitment)
|
||||||
|
- Cluster A–E + Infrastructure Economics (REQ-067..REQ-097, all v0.8 per D-081)
|
||||||
|
- Pier-Routed Legal Wrapper (OPTIONAL per §5; default-no-wrapper; not implemented as code)
|
||||||
|
- The 5 P1+ mainnet-readiness items deferred from v0.5 (v0.8+)
|
||||||
|
- SignalKind 4→5 expansion (v0.8+ governance vote)
|
||||||
|
|
||||||
## Phase 3 — The Bearers (Year 3) — v0.3 PARTIAL SKELETON
|
## Phase 3 — The Bearers (Year 3) — v0.3 PARTIAL SKELETON
|
||||||
**Target**: $10B annual volume → fee auto-declines to 0.07%
|
**Target**: $10B annual volume → fee auto-declines to 0.07%
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -351,7 +351,7 @@ func (s *Store) ComputeStandingScore(reachID string) (float64, standingtypes.Sta
|
|||||||
sum := 0.0
|
sum := 0.0
|
||||||
categories := map[string]bool{}
|
categories := map[string]bool{}
|
||||||
for _, r := range ratings {
|
for _, r := range ratings {
|
||||||
w := standingtypes.GetVoucherWeight(false, r.Score, len(ratings))
|
w := standingtypes.GetVoucherWeight(false, r.Score, len(ratings), false)
|
||||||
sum += r.Score * w
|
sum += r.Score * w
|
||||||
categories[r.Category] = true
|
categories[r.Category] = true
|
||||||
}
|
}
|
||||||
|
|||||||
+155
-5
@@ -42,17 +42,23 @@ import (
|
|||||||
|
|
||||||
// Keeper is the store-backed bond market keeper.
|
// Keeper is the store-backed bond market keeper.
|
||||||
type Keeper struct {
|
type Keeper struct {
|
||||||
cdc codec.Codec
|
cdc codec.Codec
|
||||||
storeKey storetypes.StoreKey
|
storeKey storetypes.StoreKey
|
||||||
standKeeper types.StandKeeper
|
standKeeper types.StandKeeper
|
||||||
seq uint64 // monotonic sequence for price-time priority (CLOB)
|
coverKeeper types.CoverKeeper
|
||||||
|
watcherKeeper types.WatcherKeeper
|
||||||
|
stillKeeper types.StillKeeper
|
||||||
|
seq uint64 // monotonic sequence for price-time priority (CLOB)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewKeeper constructs a new store-backed bond Keeper. The StandKeeper
|
// NewKeeper constructs a new store-backed bond Keeper. The StandKeeper
|
||||||
// expected-keeper shim is injected (nil-able for partial tests; the
|
// expected-keeper shim is injected (nil-able for partial tests; the
|
||||||
// IssueBond / IssueGrowthBond handlers guard a nil shim and skip the
|
// IssueBond / IssueGrowthBond handlers guard a nil shim and skip the
|
||||||
// StandExists check, still mutating state — the simtest wiring documents
|
// StandExists check, still mutating state — the simtest wiring documents
|
||||||
// this).
|
// this). The v0.7 P4 MAB shims (CoverKeeper, WatcherKeeper, StillKeeper)
|
||||||
|
// are wired via the Set* methods (post-construction wiring for app wiring
|
||||||
|
// or test setup); the MAB handlers guard nil shims per the documented
|
||||||
|
// contract.
|
||||||
func NewKeeper(cdc codec.Codec, storeKey storetypes.StoreKey, sk types.StandKeeper) Keeper {
|
func NewKeeper(cdc codec.Codec, storeKey storetypes.StoreKey, sk types.StandKeeper) Keeper {
|
||||||
return Keeper{
|
return Keeper{
|
||||||
cdc: cdc,
|
cdc: cdc,
|
||||||
@@ -65,6 +71,19 @@ func NewKeeper(cdc codec.Codec, storeKey storetypes.StoreKey, sk types.StandKeep
|
|||||||
// construction wiring, e.g., app wiring or test setup).
|
// construction wiring, e.g., app wiring or test setup).
|
||||||
func (k *Keeper) SetStandKeeper(sk types.StandKeeper) { k.standKeeper = sk }
|
func (k *Keeper) SetStandKeeper(sk types.StandKeeper) { k.standKeeper = sk }
|
||||||
|
|
||||||
|
// SetCoverKeeper sets the CoverKeeper expected-keeper shim (D-089(2) reverse
|
||||||
|
// edge — for post-construction wiring, e.g., app wiring or test setup).
|
||||||
|
func (k *Keeper) SetCoverKeeper(ck types.CoverKeeper) { k.coverKeeper = ck }
|
||||||
|
|
||||||
|
// SetWatcherKeeper sets the WatcherKeeper expected-keeper shim (for the MAB
|
||||||
|
// proceeds-release quorum check — post-construction wiring).
|
||||||
|
func (k *Keeper) SetWatcherKeeper(wk types.WatcherKeeper) { k.watcherKeeper = wk }
|
||||||
|
|
||||||
|
// SetStillKeeper sets the StillKeeper expected-keeper shim (D-089(1) — for
|
||||||
|
// the MAB misuse auto-Still on a destination mismatch; post-construction
|
||||||
|
// wiring).
|
||||||
|
func (k *Keeper) SetStillKeeper(stK types.StillKeeper) { k.stillKeeper = stK }
|
||||||
|
|
||||||
// StoreKey returns the keeper's store key (exported for simtest access to
|
// StoreKey returns the keeper's store key (exported for simtest access to
|
||||||
// the raw KVStore for corrupt-byte injection in marshal-error coverage
|
// the raw KVStore for corrupt-byte injection in marshal-error coverage
|
||||||
// paths).
|
// paths).
|
||||||
@@ -177,6 +196,137 @@ func (k Keeper) AllGrowthBonds(ctx sdk.Context) []types.GrowthBond {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- MAB store (v0.7 P4 — REQ-054, D-080, D-089(2)) ---------------------------
|
||||||
|
//
|
||||||
|
// The MAB store is keyed by bond-id -> MAB. A separate mab-pool index
|
||||||
|
// (bond-id -> pool-id) records the pool each MAB was issued for, so the
|
||||||
|
// 3× annual surplus ceiling check can sum the MAB principals for a pool,
|
||||||
|
// and the MsgDebitMABProceeds handler can query the CoverKeeper for the
|
||||||
|
// pool's ReserveAccount. The mab-attest store records the quarterly Watcher
|
||||||
|
// attestations (mab_attest/<bondID>/<timestamp> -> attestationRef).
|
||||||
|
|
||||||
|
var mabKeyPrefix = []byte("mab/")
|
||||||
|
|
||||||
|
func mabKey(bondID string) []byte {
|
||||||
|
return append(mabKeyPrefix, []byte(bondID)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMAB loads an issued MAB by bond-id. Returns the MAB and true if found,
|
||||||
|
// or zero value + false if not.
|
||||||
|
func (k Keeper) GetMAB(ctx sdk.Context, bondID string) (types.MAB, bool) {
|
||||||
|
store := ctx.KVStore(k.storeKey)
|
||||||
|
bz := store.Get(mabKey(bondID))
|
||||||
|
if bz == nil {
|
||||||
|
return types.MAB{}, false
|
||||||
|
}
|
||||||
|
var m types.MAB
|
||||||
|
if err := json.Unmarshal(bz, &m); err != nil {
|
||||||
|
return types.MAB{}, false
|
||||||
|
}
|
||||||
|
return m, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMAB persists an issued MAB by bond-id.
|
||||||
|
func (k Keeper) SetMAB(ctx sdk.Context, m types.MAB) {
|
||||||
|
store := ctx.KVStore(k.storeKey)
|
||||||
|
bz, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("bond: marshal mab %q: %v", m.BondID, err))
|
||||||
|
}
|
||||||
|
store.Set(mabKey(m.BondID), bz)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllMABs returns all issued MABs (iteration helper, unordered).
|
||||||
|
func (k Keeper) AllMABs(ctx sdk.Context) []types.MAB {
|
||||||
|
store := ctx.KVStore(k.storeKey)
|
||||||
|
iterator := store.Iterator(mabKeyPrefix, prefixEnd(mabKeyPrefix))
|
||||||
|
defer iterator.Close()
|
||||||
|
out := []types.MAB{}
|
||||||
|
for ; iterator.Valid(); iterator.Next() {
|
||||||
|
var m types.MAB
|
||||||
|
if err := json.Unmarshal(iterator.Value(), &m); err == nil {
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MAB pool index (bond-id -> pool-id) --------------------------------------
|
||||||
|
|
||||||
|
var mabPoolKeyPrefix = []byte("mab-pool/")
|
||||||
|
|
||||||
|
func mabPoolKey(bondID string) []byte {
|
||||||
|
return append(mabPoolKeyPrefix, []byte(bondID)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setMABPool records the pool-id a MAB was issued for (bond-id -> pool-id).
|
||||||
|
func (k Keeper) setMABPool(ctx sdk.Context, bondID, poolID string) {
|
||||||
|
store := ctx.KVStore(k.storeKey)
|
||||||
|
store.Set(mabPoolKey(bondID), []byte(poolID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMABPool returns the pool-id a MAB was issued for (bond-id -> pool-id).
|
||||||
|
// Returns the pool-id and true if found, or "" + false if not.
|
||||||
|
func (k Keeper) GetMABPool(ctx sdk.Context, bondID string) (string, bool) {
|
||||||
|
store := ctx.KVStore(k.storeKey)
|
||||||
|
bz := store.Get(mabPoolKey(bondID))
|
||||||
|
if bz == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return string(bz), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MABsForPool returns all MABs issued for the given pool-id (the 3× annual
|
||||||
|
// surplus ceiling check sums their principals). Iterates the mab-pool index
|
||||||
|
// + loads each MAB by bond-id.
|
||||||
|
func (k Keeper) MABsForPool(ctx sdk.Context, poolID string) []types.MAB {
|
||||||
|
store := ctx.KVStore(k.storeKey)
|
||||||
|
iterator := store.Iterator(mabPoolKeyPrefix, prefixEnd(mabPoolKeyPrefix))
|
||||||
|
defer iterator.Close()
|
||||||
|
out := []types.MAB{}
|
||||||
|
for ; iterator.Valid(); iterator.Next() {
|
||||||
|
if string(iterator.Value()) != poolID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// The key is mab-pool/<bondID>; extract the bondID (strip the
|
||||||
|
// prefix) and load the MAB.
|
||||||
|
bondID := string(iterator.Key()[len(mabPoolKeyPrefix):])
|
||||||
|
if m, ok := k.GetMAB(ctx, bondID); ok {
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MAB attestation store (mab_attest/<bondID>/<timestamp> -> ref) -----------
|
||||||
|
|
||||||
|
var mabAttestKeyPrefix = []byte("mab_attest/")
|
||||||
|
|
||||||
|
func mabAttestKey(bondID string, ts int64) []byte {
|
||||||
|
return append(append(mabAttestKeyPrefix, []byte(bondID)...), []byte(fmt.Sprintf("/%d", ts))...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMABAttest records a quarterly Watcher attestation on a MAB (bond-id +
|
||||||
|
// timestamp -> attestation-ref).
|
||||||
|
func (k Keeper) SetMABAttest(ctx sdk.Context, bondID string, ts int64, attestationRef string) {
|
||||||
|
store := ctx.KVStore(k.storeKey)
|
||||||
|
store.Set(mabAttestKey(bondID, ts), []byte(attestationRef))
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllMABAttests returns all recorded Watcher attestations for a MAB
|
||||||
|
// (bond-id -> []attestationRef, unordered).
|
||||||
|
func (k Keeper) AllMABAttests(ctx sdk.Context, bondID string) []string {
|
||||||
|
store := ctx.KVStore(k.storeKey)
|
||||||
|
prefix := append(mabAttestKeyPrefix, []byte(bondID+"/")...)
|
||||||
|
iterator := store.Iterator(prefix, prefixEnd(prefix))
|
||||||
|
defer iterator.Close()
|
||||||
|
out := []string{}
|
||||||
|
for ; iterator.Valid(); iterator.Next() {
|
||||||
|
out = append(out, string(iterator.Value()))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// --- Order store (CLOB resting book) -----------------------------------------
|
// --- Order store (CLOB resting book) -----------------------------------------
|
||||||
//
|
//
|
||||||
// The resting book is keyed by order-id → restingOrder (the in-keeper book
|
// The resting book is keyed by order-id → restingOrder (the in-keeper book
|
||||||
|
|||||||
@@ -426,3 +426,241 @@ func (s msgServer) MatchSecondaryOrder(ctx interface{}, msg *types.MsgMatchSecon
|
|||||||
Rejected: false,
|
Rejected: false,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- v0.7 P4: MAB handlers (REQ-054, D-080, D-089(1), D-089(2)) ----------------
|
||||||
|
//
|
||||||
|
// (Mutual Aid Bond runtime — IssueMAB + DebitMABProceeds +
|
||||||
|
// WitnessMABProceedsRelease + WatcherAttestMAB). The four handlers exercise
|
||||||
|
// the 3× annual surplus ceiling, the FR-MAB-3 Bread-coupon rejection, the
|
||||||
|
// D-080 tagged-streaming destination check (CoverKeeper reverse edge —
|
||||||
|
// D-089(2)), the D-089(1) auto-Still on misuse, and the Watcher quorum
|
||||||
|
// (6-of-9) on proceeds release.
|
||||||
|
|
||||||
|
// checkMABIssuanceCeiling asserts the 3× annual surplus ceiling (REQ-054
|
||||||
|
// locked). It sums the existing MAB principals for the poolID + the new
|
||||||
|
// principal and asserts the sum <= MABIssuanceCeilingAnnualSurplusMultiple ×
|
||||||
|
// annualSurplusAtIssuance. Returns the post-issuance
|
||||||
|
// (sumMABPrincipal / annualSurplusAtIssuance) ratio (for the response) and
|
||||||
|
// an error if above ceiling. The check re-runs at every issuance (not just
|
||||||
|
// the first), so a pool that issues up to the ceiling cannot issue more.
|
||||||
|
func (s msgServer) checkMABIssuanceCeiling(ctx sdk.Context, poolID string, newPrincipal int64, annualSurplusAtIssuance int64) (int64, error) {
|
||||||
|
existing := int64(0)
|
||||||
|
for _, m := range s.Keeper.MABsForPool(ctx, poolID) {
|
||||||
|
existing += m.PrincipalGrain
|
||||||
|
}
|
||||||
|
total := existing + newPrincipal
|
||||||
|
ceiling := int64(types.MABIssuanceCeilingAnnualSurplusMultiple) * annualSurplusAtIssuance
|
||||||
|
if total > ceiling {
|
||||||
|
return 0, fmt.Errorf("bond: MAB issuance ceiling breached (sum %d + new %d = %d > 3× annual-surplus %d = %d — REQ-054 locked)",
|
||||||
|
existing, newPrincipal, total, annualSurplusAtIssuance, ceiling)
|
||||||
|
}
|
||||||
|
if annualSurplusAtIssuance == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return total / annualSurplusAtIssuance, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueMAB issues a Mutual Aid Bond (REQ-054, D-080). The handler enforces:
|
||||||
|
// 1. ValidateBasic (stateless — includes ValidateMAB: rejects
|
||||||
|
// CouponDenomBread with FR-MAB-3).
|
||||||
|
// 2. Idempotency: bond-id must not already exist (as a Bond, GrowthBond, or
|
||||||
|
// MAB).
|
||||||
|
// 3. StandKeeper shim: the issuer-stand-id must reference an existing Stand
|
||||||
|
// (P1-02-01 edge). A nil shim skips (simtest wiring).
|
||||||
|
// 4. FR-MAB-3 defense-in-depth: ValidateMAB re-check (rejects
|
||||||
|
// CouponDenomBread — the handler re-checks in case of a future
|
||||||
|
// ValidateBasic bypass).
|
||||||
|
// 5. 3× annual surplus ceiling: checkMABIssuanceCeiling asserts
|
||||||
|
// sum(existingMABPrincipal for poolID) + PrincipalGrain <=
|
||||||
|
// MABIssuanceCeilingAnnualSurplusMultiple × AnnualSurplusAtIssuance.
|
||||||
|
// REJECT if above ceiling.
|
||||||
|
// 6. Coupon clamp via Clamp (A-563 — defense in depth).
|
||||||
|
// 7. Persist the MAB with UseOfProceedsTag = MABUseOfProceedsReserveBuildOut
|
||||||
|
// + record the pool-id in the mab-pool index. Emit bond.mab_issued.
|
||||||
|
func (s msgServer) IssueMAB(ctx interface{}, msg *types.MsgIssueMAB) (*types.MsgIssueMABResponse, error) {
|
||||||
|
if err := msg.ValidateBasic(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sdkCtx := unwrapCtx(ctx)
|
||||||
|
|
||||||
|
// Idempotency: bond-id must not already exist (as Bond, GrowthBond, or MAB).
|
||||||
|
if _, ok := s.Keeper.GetBond(sdkCtx, msg.BondID); ok {
|
||||||
|
return nil, fmt.Errorf("bond: bond-id %q already exists (as a Bond)", msg.BondID)
|
||||||
|
}
|
||||||
|
if _, ok := s.Keeper.GetGrowthBond(sdkCtx, msg.BondID); ok {
|
||||||
|
return nil, fmt.Errorf("bond: bond-id %q already exists (as a GrowthBond)", msg.BondID)
|
||||||
|
}
|
||||||
|
if _, ok := s.Keeper.GetMAB(sdkCtx, msg.BondID); ok {
|
||||||
|
return nil, fmt.Errorf("bond: bond-id %q already exists (as a MAB)", msg.BondID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StandKeeper: issuer-stand-id must reference an existing Stand.
|
||||||
|
if s.Keeper.standKeeper != nil {
|
||||||
|
if !s.Keeper.standKeeper.StandExists(msg.IssuerStandID) {
|
||||||
|
return nil, fmt.Errorf("bond: issuer-stand-id %q does not exist (IssueMAB rejected)", msg.IssuerStandID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FR-MAB-3 defense-in-depth: re-run ValidateMAB (the handler re-checks
|
||||||
|
// in case of a future ValidateBasic bypass).
|
||||||
|
if err := types.ValidateMAB(types.MAB{CouponKind: msg.CouponKind}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3× annual surplus ceiling (REQ-054 locked).
|
||||||
|
ceilingMultiple, err := s.checkMABIssuanceCeiling(sdkCtx, msg.PoolID, msg.PrincipalGrain, msg.AnnualSurplusAtIssuance)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coupon clamp (A-563 — defense in depth; ValidateBasic already
|
||||||
|
// rejected out-of-band, so Clamp is a no-op here).
|
||||||
|
clamped := types.Clamp(msg.CouponBps)
|
||||||
|
m := types.IssueMAB(msg.BondID, msg.IssuerStandID, msg.PrincipalGrain, clamped, msg.CouponKind, msg.AnnualSurplusAtIssuance, msg.TermDays, sdkCtx.BlockTime().Unix(), sdkCtx.BlockTime().Unix()+int64(msg.TermDays)*24*60*60)
|
||||||
|
s.Keeper.SetMAB(sdkCtx, m)
|
||||||
|
s.Keeper.setMABPool(sdkCtx, msg.BondID, msg.PoolID)
|
||||||
|
|
||||||
|
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||||
|
"bond.mab_issued",
|
||||||
|
sdk.NewAttribute("bond_id", msg.BondID),
|
||||||
|
sdk.NewAttribute("pool_id", msg.PoolID),
|
||||||
|
sdk.NewAttribute("issuer_stand_id", msg.IssuerStandID),
|
||||||
|
sdk.NewAttribute("coupon_bps", fmt.Sprintf("%d", clamped)),
|
||||||
|
sdk.NewAttribute("coupon_kind", string(msg.CouponKind)),
|
||||||
|
sdk.NewAttribute("use_of_proceeds_tag", m.UseOfProceedsTag),
|
||||||
|
sdk.NewAttribute("ceiling_multiple", fmt.Sprintf("%d", ceilingMultiple)),
|
||||||
|
))
|
||||||
|
return &types.MsgIssueMABResponse{
|
||||||
|
ClampedCouponBps: clamped,
|
||||||
|
CeilingMultiple: ceilingMultiple,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DebitMABProceeds debits a MAB's tagged proceeds to the Pool's
|
||||||
|
// ReserveAccount (D-080). The handler enforces:
|
||||||
|
// 1. ValidateBasic (stateless).
|
||||||
|
// 2. The MAB must exist.
|
||||||
|
// 3. D-080 tagged streaming: query the mab-pool index for the MAB's poolID,
|
||||||
|
// then query CoverKeeper.GetPoolReserveAccount(poolID). If the
|
||||||
|
// DestinationAccount != the pool's ReserveAccount -> StillKeeper.Still(
|
||||||
|
// bondID, "MAB misuse — proceeds routed outside reserve") (D-089(1) — a
|
||||||
|
// nil StillKeeper skips the Still recording but the handler STILL
|
||||||
|
// REJECTS) AND REJECT. A nil CoverKeeper is a wiring error -> REJECT
|
||||||
|
// (the destination cannot be validated). If match -> emit
|
||||||
|
// bond.mab_proceeds_debited (simtest: the debit is the event; no actual
|
||||||
|
// Grain transfer in P4).
|
||||||
|
func (s msgServer) DebitMABProceeds(ctx interface{}, msg *types.MsgDebitMABProceeds) (*types.MsgDebitMABProceedsResponse, error) {
|
||||||
|
if err := msg.ValidateBasic(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sdkCtx := unwrapCtx(ctx)
|
||||||
|
|
||||||
|
m, ok := s.Keeper.GetMAB(sdkCtx, msg.BondID)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("bond: mab %q not found (DebitMABProceeds rejected)", msg.BondID)
|
||||||
|
}
|
||||||
|
_ = m
|
||||||
|
|
||||||
|
poolID, ok := s.Keeper.GetMABPool(sdkCtx, msg.BondID)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("bond: mab %q has no pool binding (DebitMABProceeds rejected)", msg.BondID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// D-080 tagged streaming: the destination must == the pool's
|
||||||
|
// ReserveAccount. A nil CoverKeeper is a wiring error -> REJECT (the
|
||||||
|
// destination cannot be validated).
|
||||||
|
if s.Keeper.coverKeeper == nil {
|
||||||
|
return nil, fmt.Errorf("bond: CoverKeeper shim not wired (DebitMABProceeds cannot validate destination — D-089(2) reverse edge required)")
|
||||||
|
}
|
||||||
|
reserveAccount, exists := s.Keeper.coverKeeper.GetPoolReserveAccount(poolID)
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("bond: pool %q ReserveAccount not found (DebitMABProceeds rejected)", poolID)
|
||||||
|
}
|
||||||
|
if msg.DestinationAccount != reserveAccount {
|
||||||
|
// D-080 misuse -> D-089(1) auto-Still. A nil StillKeeper skips the
|
||||||
|
// Still recording but the handler STILL REJECTS (the debit is not
|
||||||
|
// committed regardless).
|
||||||
|
if s.Keeper.stillKeeper != nil {
|
||||||
|
_ = s.Keeper.stillKeeper.Still(msg.BondID, "MAB misuse — proceeds routed outside reserve")
|
||||||
|
}
|
||||||
|
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||||
|
"bond.mab_proceeds_misuse",
|
||||||
|
sdk.NewAttribute("bond_id", msg.BondID),
|
||||||
|
sdk.NewAttribute("pool_id", poolID),
|
||||||
|
sdk.NewAttribute("destination_account", msg.DestinationAccount),
|
||||||
|
sdk.NewAttribute("expected_reserve_account", reserveAccount),
|
||||||
|
))
|
||||||
|
return nil, fmt.Errorf("bond: MAB %q proceeds destination %q != pool %q ReserveAccount %q (D-080 tagged-streaming misuse — auto-Still + REJECT)", msg.BondID, msg.DestinationAccount, poolID, reserveAccount)
|
||||||
|
}
|
||||||
|
|
||||||
|
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||||
|
"bond.mab_proceeds_debited",
|
||||||
|
sdk.NewAttribute("bond_id", msg.BondID),
|
||||||
|
sdk.NewAttribute("pool_id", poolID),
|
||||||
|
sdk.NewAttribute("destination_account", msg.DestinationAccount),
|
||||||
|
))
|
||||||
|
return &types.MsgDebitMABProceedsResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WitnessMABProceedsRelease is a Watcher-witnessed release of a MAB's tagged
|
||||||
|
// proceeds from staging to the reserve (D-080). The handler enforces:
|
||||||
|
// 1. ValidateBasic (stateless).
|
||||||
|
// 2. The MAB must exist.
|
||||||
|
// 3. Watcher quorum: WatcherKeeper.AttestMABRelease(bondID, attestationRef)
|
||||||
|
// returns true if quorum (6-of-9) is met. If false (quorum not met) ->
|
||||||
|
// REJECT. If true -> emit bond.mab_proceeds_released. A nil WatcherKeeper
|
||||||
|
// skips the quorum check (simtest wiring — the handler still mutates
|
||||||
|
// state; the simtest documents the wiring).
|
||||||
|
func (s msgServer) WitnessMABProceedsRelease(ctx interface{}, msg *types.MsgWitnessMABProceedsRelease) (*types.MsgWitnessMABProceedsReleaseResponse, error) {
|
||||||
|
if err := msg.ValidateBasic(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sdkCtx := unwrapCtx(ctx)
|
||||||
|
|
||||||
|
if _, ok := s.Keeper.GetMAB(sdkCtx, msg.BondID); !ok {
|
||||||
|
return nil, fmt.Errorf("bond: mab %q not found (WitnessMABProceedsRelease rejected)", msg.BondID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watcher quorum (D-080). A nil WatcherKeeper skips the quorum check
|
||||||
|
// (simtest wiring — the handler still mutates state).
|
||||||
|
if s.Keeper.watcherKeeper != nil {
|
||||||
|
if !s.Keeper.watcherKeeper.AttestMABRelease(msg.BondID, msg.AttestationRef) {
|
||||||
|
return nil, fmt.Errorf("bond: MAB %q proceeds release rejected (Watcher quorum not met — D-080 6-of-9 required)", msg.BondID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||||
|
"bond.mab_proceeds_released",
|
||||||
|
sdk.NewAttribute("bond_id", msg.BondID),
|
||||||
|
sdk.NewAttribute("attestation_ref", msg.AttestationRef),
|
||||||
|
))
|
||||||
|
return &types.MsgWitnessMABProceedsReleaseResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WatcherAttestMAB records a quarterly Watcher audit attestation on a MAB
|
||||||
|
// (D-080). The handler enforces:
|
||||||
|
// 1. ValidateBasic (stateless).
|
||||||
|
// 2. The MAB must exist.
|
||||||
|
// 3. Record the attestation (a store entry mab_attest/<bondID>/<timestamp>
|
||||||
|
// -> attestationRef). Emit bond.mab_watcher_attested.
|
||||||
|
func (s msgServer) WatcherAttestMAB(ctx interface{}, msg *types.MsgWatcherAttestMAB) (*types.MsgWatcherAttestMABResponse, error) {
|
||||||
|
if err := msg.ValidateBasic(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sdkCtx := unwrapCtx(ctx)
|
||||||
|
|
||||||
|
if _, ok := s.Keeper.GetMAB(sdkCtx, msg.BondID); !ok {
|
||||||
|
return nil, fmt.Errorf("bond: mab %q not found (WatcherAttestMAB rejected)", msg.BondID)
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := sdkCtx.BlockTime().Unix()
|
||||||
|
s.Keeper.SetMABAttest(sdkCtx, msg.BondID, ts, msg.AttestationRef)
|
||||||
|
|
||||||
|
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||||
|
"bond.mab_watcher_attested",
|
||||||
|
sdk.NewAttribute("bond_id", msg.BondID),
|
||||||
|
sdk.NewAttribute("attestation_ref", msg.AttestationRef),
|
||||||
|
sdk.NewAttribute("timestamp", fmt.Sprintf("%d", ts)),
|
||||||
|
))
|
||||||
|
return &types.MsgWatcherAttestMABResponse{}, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -94,6 +94,51 @@ func (s *stubStandKeeper) StandExists(standID string) bool {
|
|||||||
return s.existsAll
|
return s.existsAll
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stubCoverKeeper satisfies btypes.CoverKeeper for the v0.7 P4 MAB simtest
|
||||||
|
// (D-089(2) reverse edge). It returns the configured ReserveAccount per
|
||||||
|
// pool-id.
|
||||||
|
type stubCoverKeeper struct {
|
||||||
|
reserveAccounts map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubCoverKeeper) GetPoolReserveAccount(poolID string) (string, bool) {
|
||||||
|
if s.reserveAccounts == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
acc, ok := s.reserveAccounts[poolID]
|
||||||
|
return acc, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// stubWatcherKeeperBond satisfies btypes.WatcherKeeper for the v0.7 P4 MAB
|
||||||
|
// simtest. It returns a configurable quorum-met bool per
|
||||||
|
// AttestMABRelease call.
|
||||||
|
type stubWatcherKeeperBond struct {
|
||||||
|
quorumMet bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubWatcherKeeperBond) AttestMABRelease(bondID string, attestationRef string) bool {
|
||||||
|
return s.quorumMet
|
||||||
|
}
|
||||||
|
|
||||||
|
// stubStillKeeperBond satisfies btypes.StillKeeper for the v0.7 P4 MAB
|
||||||
|
// simtest (D-089(1)). It records every Still() call for assertion (the
|
||||||
|
// tagged-streaming misuse simtest asserts Still was called with the right
|
||||||
|
// bond-id + reason).
|
||||||
|
type stubStillKeeperBond struct {
|
||||||
|
calls []struct {
|
||||||
|
bondID string
|
||||||
|
reason string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubStillKeeperBond) Still(bondID string, reason string) error {
|
||||||
|
s.calls = append(s.calls, struct {
|
||||||
|
bondID string
|
||||||
|
reason string
|
||||||
|
}{bondID, reason})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// --- Simtest context helper --------------------------------------------------
|
// --- Simtest context helper --------------------------------------------------
|
||||||
|
|
||||||
// newSimtestContext constructs an in-memory sdk.Context with a KVStore
|
// newSimtestContext constructs an in-memory sdk.Context with a KVStore
|
||||||
@@ -165,6 +210,32 @@ func freshCtx(t *testing.T) (sdk.Context, *stubStandKeeper, keeper.Keeper) {
|
|||||||
return newSimtestContext(t)
|
return newSimtestContext(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newMABSimtestContext constructs an in-memory sdk.Context with the MAB
|
||||||
|
// shims (CoverKeeper + WatcherKeeper + StillKeeper) wired for the v0.7 P4
|
||||||
|
// MAB simtest (D-089(1) + D-089(2)). Returns the ctx, the four stubs, and
|
||||||
|
// the Keeper.
|
||||||
|
func newMABSimtestContext(t *testing.T) (sdk.Context, *stubStandKeeper, *stubCoverKeeper, *stubWatcherKeeperBond, *stubStillKeeperBond, keeper.Keeper) {
|
||||||
|
t.Helper()
|
||||||
|
db := dbm.NewMemDB()
|
||||||
|
cdc := newTestCodec()
|
||||||
|
storeKey := storetypes.NewKVStoreKey(btypes.StoreKey)
|
||||||
|
cms := store.NewCommitMultiStore(db, log.NewNopLogger(), nil)
|
||||||
|
cms.MountStoreWithDB(storeKey, storetypes.StoreTypeDB, nil)
|
||||||
|
if err := cms.LoadLatestVersion(); err != nil {
|
||||||
|
t.Fatalf("load latest version: %v", err)
|
||||||
|
}
|
||||||
|
ctx := sdk.NewContext(cms, cmtproto.Header{Time: time.Unix(1000, 0)}, false, log.NewNopLogger())
|
||||||
|
sk := &stubStandKeeper{existsAll: true}
|
||||||
|
ck := &stubCoverKeeper{reserveAccounts: map[string]string{"pool-1": "reserve-acc-1"}}
|
||||||
|
wk := &stubWatcherKeeperBond{quorumMet: true}
|
||||||
|
stK := &stubStillKeeperBond{}
|
||||||
|
k := keeper.NewKeeper(cdc, storeKey, sk)
|
||||||
|
k.SetCoverKeeper(ck)
|
||||||
|
k.SetWatcherKeeper(wk)
|
||||||
|
k.SetStillKeeper(stK)
|
||||||
|
return ctx, sk, ck, wk, stK, k
|
||||||
|
}
|
||||||
|
|
||||||
// --- Bond issuance (coupon clamp at issuance) --------------------------------
|
// --- Bond issuance (coupon clamp at issuance) --------------------------------
|
||||||
|
|
||||||
// TestIssueBondInBand asserts an in-band coupon (500) is recorded unchanged
|
// TestIssueBondInBand asserts an in-band coupon (500) is recorded unchanged
|
||||||
@@ -1292,3 +1363,473 @@ func TestMatchAboveCapRejectStopsMatching(t *testing.T) {
|
|||||||
t.Errorf("sell-inband RemainingQuantityGrain = %d, want 50 (untouched)", ro.RemainingQuantityGrain)
|
t.Errorf("sell-inband RemainingQuantityGrain = %d, want 50 (untouched)", ro.RemainingQuantityGrain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- v0.7 P4: MAB simtest (REQ-054, D-080, D-089(1), D-089(2)) ----------------
|
||||||
|
//
|
||||||
|
// (Mutual Aid Bond runtime — issuance + Bread-coupon rejection + 3× annual
|
||||||
|
// surplus ceiling + tagged-streaming misuse -> auto-Still + Watcher-witnessed
|
||||||
|
// release + quarterly attestation).
|
||||||
|
|
||||||
|
// TestMABIssuanceValidCoverCallCoupons (case a) asserts a MAB issuance with
|
||||||
|
// valid Cover-Call coupons (CouponDenomCoverCall) succeeds + the
|
||||||
|
// bond.mab_issued event is emitted + the UseOfProceedsTag is locked to
|
||||||
|
// "reserve_build_out".
|
||||||
|
func TestMABIssuanceValidCoverCallCoupons(t *testing.T) {
|
||||||
|
ctx, _, _, _, _, k := newMABSimtestContext(t)
|
||||||
|
srv := keeper.NewMsgServerImpl(k)
|
||||||
|
|
||||||
|
resp, err := srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
|
||||||
|
BondID: "mab-1", PoolID: "pool-1", IssuerStandID: "stand-1",
|
||||||
|
PrincipalGrain: 1_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomCoverCall,
|
||||||
|
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IssueMAB: %v", err)
|
||||||
|
}
|
||||||
|
if resp.ClampedCouponBps != 500 {
|
||||||
|
t.Errorf("ClampedCouponBps = %d, want 500", resp.ClampedCouponBps)
|
||||||
|
}
|
||||||
|
if !hasEvent(ctx, "bond.mab_issued") {
|
||||||
|
t.Error("bond.mab_issued event not emitted")
|
||||||
|
}
|
||||||
|
// Read it back.
|
||||||
|
m, ok := k.GetMAB(ctx, "mab-1")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("MAB not persisted")
|
||||||
|
}
|
||||||
|
if m.CouponKind != btypes.CouponDenomCoverCall {
|
||||||
|
t.Errorf("CouponKind = %q, want CoverCall", m.CouponKind)
|
||||||
|
}
|
||||||
|
if m.UseOfProceedsTag != btypes.MABUseOfProceedsReserveBuildOut {
|
||||||
|
t.Errorf("UseOfProceedsTag = %q, want %q (D-080 lock)", m.UseOfProceedsTag, btypes.MABUseOfProceedsReserveBuildOut)
|
||||||
|
}
|
||||||
|
// The mab-pool index recorded the pool binding.
|
||||||
|
poolID, ok := k.GetMABPool(ctx, "mab-1")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("mab-pool index not recorded")
|
||||||
|
}
|
||||||
|
if poolID != "pool-1" {
|
||||||
|
t.Errorf("mab-pool index = %q, want pool-1", poolID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMABIssuanceBreadCouponsRejected (case b) asserts a MAB issuance with
|
||||||
|
// Bread coupons (CouponDenomBread) is REJECTED at ValidateBasic (FR-MAB-3).
|
||||||
|
func TestMABIssuanceBreadCouponsRejected(t *testing.T) {
|
||||||
|
ctx, _, _, _, _, k := newMABSimtestContext(t)
|
||||||
|
srv := keeper.NewMsgServerImpl(k)
|
||||||
|
|
||||||
|
_, err := srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
|
||||||
|
BondID: "mab-bad", PoolID: "pool-1", IssuerStandID: "stand-1",
|
||||||
|
PrincipalGrain: 1_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomBread,
|
||||||
|
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("IssueMAB with CouponDenomBread should be REJECTED (FR-MAB-3)")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "FR-MAB-3") {
|
||||||
|
t.Errorf("err = %q, want 'FR-MAB-3'", err.Error())
|
||||||
|
}
|
||||||
|
// The MAB was NOT persisted.
|
||||||
|
if _, ok := k.GetMAB(ctx, "mab-bad"); ok {
|
||||||
|
t.Error("MAB with Bread coupons should NOT be persisted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMABIssuanceAboveCeilingRejected (case c) asserts a MAB issuance that
|
||||||
|
// would push the total outstanding MAB principal above the 3× annual
|
||||||
|
// surplus ceiling is REJECTED (REQ-054 locked). Issue two MABs that
|
||||||
|
// together + a third exceed 3× annual surplus.
|
||||||
|
func TestMABIssuanceAboveCeilingRejected(t *testing.T) {
|
||||||
|
ctx, _, _, _, _, k := newMABSimtestContext(t)
|
||||||
|
srv := keeper.NewMsgServerImpl(k)
|
||||||
|
|
||||||
|
// Annual surplus = 5M -> ceiling = 15M. Issue two MABs at 7M each
|
||||||
|
// (sum = 14M, within ceiling). A third at 2M would push the sum to
|
||||||
|
// 16M > 15M -> REJECT.
|
||||||
|
_, err := srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
|
||||||
|
BondID: "mab-c1", PoolID: "pool-1", IssuerStandID: "stand-1",
|
||||||
|
PrincipalGrain: 7_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomCoverCall,
|
||||||
|
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first IssueMAB: %v", err)
|
||||||
|
}
|
||||||
|
_, err = srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
|
||||||
|
BondID: "mab-c2", PoolID: "pool-1", IssuerStandID: "stand-1",
|
||||||
|
PrincipalGrain: 7_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomMutualAidCredit,
|
||||||
|
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second IssueMAB: %v", err)
|
||||||
|
}
|
||||||
|
// Third at 2M -> sum 16M > 15M ceiling -> REJECT.
|
||||||
|
_, err = srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
|
||||||
|
BondID: "mab-c3", PoolID: "pool-1", IssuerStandID: "stand-1",
|
||||||
|
PrincipalGrain: 2_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomCoverCall,
|
||||||
|
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("third IssueMAB above 3× ceiling should be REJECTED")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "ceiling breached") {
|
||||||
|
t.Errorf("err = %q, want 'ceiling breached'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMABDebitProceedsMisuseAutoStill (case d) asserts a MAB proceeds debit
|
||||||
|
// with a destination != the Pool's ReserveAccount triggers the auto-Still
|
||||||
|
// (D-089(1)) AND is REJECTED (D-080 tagged-streaming misuse).
|
||||||
|
func TestMABDebitProceedsMisuseAutoStill(t *testing.T) {
|
||||||
|
ctx, _, _, _, stK, k := newMABSimtestContext(t)
|
||||||
|
srv := keeper.NewMsgServerImpl(k)
|
||||||
|
|
||||||
|
// Issue a MAB for pool-1 (whose ReserveAccount is "reserve-acc-1").
|
||||||
|
_, err := srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
|
||||||
|
BondID: "mab-d1", PoolID: "pool-1", IssuerStandID: "stand-1",
|
||||||
|
PrincipalGrain: 1_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomCoverCall,
|
||||||
|
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IssueMAB: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debit to a WRONG destination -> auto-Still + REJECT.
|
||||||
|
_, err = srv.DebitMABProceeds(ctx, &btypes.MsgDebitMABProceeds{
|
||||||
|
BondID: "mab-d1", DestinationAccount: "wrong-destination", Signer: "stand-1",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("DebitMABProceeds with wrong destination should be REJECTED")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "tagged-streaming misuse") {
|
||||||
|
t.Errorf("err = %q, want 'tagged-streaming misuse'", err.Error())
|
||||||
|
}
|
||||||
|
// The StillKeeper was called with the right bond-id + reason.
|
||||||
|
if len(stK.calls) != 1 {
|
||||||
|
t.Fatalf("StillKeeper.Still calls = %d, want 1", len(stK.calls))
|
||||||
|
}
|
||||||
|
if stK.calls[0].bondID != "mab-d1" {
|
||||||
|
t.Errorf("Still bondID = %q, want mab-d1", stK.calls[0].bondID)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stK.calls[0].reason, "MAB misuse") {
|
||||||
|
t.Errorf("Still reason = %q, want 'MAB misuse'", stK.calls[0].reason)
|
||||||
|
}
|
||||||
|
// The misuse event was emitted.
|
||||||
|
if !hasEvent(ctx, "bond.mab_proceeds_misuse") {
|
||||||
|
t.Error("bond.mab_proceeds_misuse event not emitted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMABDebitProceedsMatchSucceeds asserts a MAB proceeds debit with the
|
||||||
|
// destination == the Pool's ReserveAccount succeeds + the
|
||||||
|
// bond.mab_proceeds_debited event is emitted.
|
||||||
|
func TestMABDebitProceedsMatchSucceeds(t *testing.T) {
|
||||||
|
ctx, _, _, _, _, k := newMABSimtestContext(t)
|
||||||
|
srv := keeper.NewMsgServerImpl(k)
|
||||||
|
|
||||||
|
_, err := srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
|
||||||
|
BondID: "mab-d2", PoolID: "pool-1", IssuerStandID: "stand-1",
|
||||||
|
PrincipalGrain: 1_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomCoverCall,
|
||||||
|
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IssueMAB: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debit to the CORRECT destination (reserve-acc-1) -> succeeds.
|
||||||
|
_, err = srv.DebitMABProceeds(ctx, &btypes.MsgDebitMABProceeds{
|
||||||
|
BondID: "mab-d2", DestinationAccount: "reserve-acc-1", Signer: "stand-1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DebitMABProceeds with matching destination: %v", err)
|
||||||
|
}
|
||||||
|
if !hasEvent(ctx, "bond.mab_proceeds_debited") {
|
||||||
|
t.Error("bond.mab_proceeds_debited event not emitted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMABWitnessProceedsReleaseQuorumPresent (case e) asserts a MAB
|
||||||
|
// proceeds release with Watcher quorum present succeeds + the
|
||||||
|
// bond.mab_proceeds_released event is emitted.
|
||||||
|
func TestMABWitnessProceedsReleaseQuorumPresent(t *testing.T) {
|
||||||
|
ctx, _, _, wk, _, k := newMABSimtestContext(t)
|
||||||
|
wk.quorumMet = true
|
||||||
|
srv := keeper.NewMsgServerImpl(k)
|
||||||
|
|
||||||
|
_, err := srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
|
||||||
|
BondID: "mab-w1", PoolID: "pool-1", IssuerStandID: "stand-1",
|
||||||
|
PrincipalGrain: 1_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomCoverCall,
|
||||||
|
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IssueMAB: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = srv.WitnessMABProceedsRelease(ctx, &btypes.MsgWitnessMABProceedsRelease{
|
||||||
|
BondID: "mab-w1", AttestationRef: "oy:attest:mab-w1", Signer: "watcher-1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WitnessMABProceedsRelease with quorum: %v", err)
|
||||||
|
}
|
||||||
|
if !hasEvent(ctx, "bond.mab_proceeds_released") {
|
||||||
|
t.Error("bond.mab_proceeds_released event not emitted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMABWitnessProceedsReleaseQuorumAbsent asserts a MAB proceeds release
|
||||||
|
// with Watcher quorum NOT met is REJECTED (D-080 — 6-of-9 required).
|
||||||
|
func TestMABWitnessProceedsReleaseQuorumAbsent(t *testing.T) {
|
||||||
|
ctx, _, _, wk, _, k := newMABSimtestContext(t)
|
||||||
|
wk.quorumMet = false
|
||||||
|
srv := keeper.NewMsgServerImpl(k)
|
||||||
|
|
||||||
|
_, err := srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
|
||||||
|
BondID: "mab-w2", PoolID: "pool-1", IssuerStandID: "stand-1",
|
||||||
|
PrincipalGrain: 1_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomCoverCall,
|
||||||
|
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IssueMAB: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = srv.WitnessMABProceedsRelease(ctx, &btypes.MsgWitnessMABProceedsRelease{
|
||||||
|
BondID: "mab-w2", AttestationRef: "oy:attest:mab-w2", Signer: "watcher-1",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("WitnessMABProceedsRelease without quorum should be REJECTED")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "quorum not met") {
|
||||||
|
t.Errorf("err = %q, want 'quorum not met'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMABWatcherAttest (case f) asserts a quarterly Watcher attestation on
|
||||||
|
// a MAB is recorded + the bond.mab_watcher_attested event is emitted.
|
||||||
|
func TestMABWatcherAttest(t *testing.T) {
|
||||||
|
ctx, _, _, _, _, k := newMABSimtestContext(t)
|
||||||
|
srv := keeper.NewMsgServerImpl(k)
|
||||||
|
|
||||||
|
_, err := srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
|
||||||
|
BondID: "mab-a1", PoolID: "pool-1", IssuerStandID: "stand-1",
|
||||||
|
PrincipalGrain: 1_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomCoverCall,
|
||||||
|
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IssueMAB: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = srv.WatcherAttestMAB(ctx, &btypes.MsgWatcherAttestMAB{
|
||||||
|
BondID: "mab-a1", AttestationRef: "oy:attest:quarterly:mab-a1", Signer: "watcher-1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WatcherAttestMAB: %v", err)
|
||||||
|
}
|
||||||
|
if !hasEvent(ctx, "bond.mab_watcher_attested") {
|
||||||
|
t.Error("bond.mab_watcher_attested event not emitted")
|
||||||
|
}
|
||||||
|
// The attestation was recorded.
|
||||||
|
atts := k.AllMABAttests(ctx, "mab-a1")
|
||||||
|
if len(atts) != 1 {
|
||||||
|
t.Fatalf("AllMABAttests = %d, want 1", len(atts))
|
||||||
|
}
|
||||||
|
if atts[0] != "oy:attest:quarterly:mab-a1" {
|
||||||
|
t.Errorf("attestation ref = %q, want oy:attest:quarterly:mab-a1", atts[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMABIssueIdempotentReject asserts issuing the same MAB bond-id twice
|
||||||
|
// REJECTS the second.
|
||||||
|
func TestMABIssueIdempotentReject(t *testing.T) {
|
||||||
|
ctx, _, _, _, _, k := newMABSimtestContext(t)
|
||||||
|
srv := keeper.NewMsgServerImpl(k)
|
||||||
|
|
||||||
|
_, err := srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
|
||||||
|
BondID: "mab-i1", PoolID: "pool-1", IssuerStandID: "stand-1",
|
||||||
|
PrincipalGrain: 1_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomCoverCall,
|
||||||
|
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first IssueMAB: %v", err)
|
||||||
|
}
|
||||||
|
_, err = srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
|
||||||
|
BondID: "mab-i1", PoolID: "pool-1", IssuerStandID: "stand-1",
|
||||||
|
PrincipalGrain: 2_000_000, CouponBps: 600, CouponKind: btypes.CouponDenomMutualAidCredit,
|
||||||
|
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("second IssueMAB on same bond-id should be REJECTED")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMABIssueNonExistentStandRejected asserts a MAB issuance on a non-
|
||||||
|
// existent Stand is REJECTED (the StandKeeper stub reports false).
|
||||||
|
func TestMABIssueNonExistentStandRejected(t *testing.T) {
|
||||||
|
ctx, sk, _, _, _, k := newMABSimtestContext(t)
|
||||||
|
sk.exists = map[string]bool{"stand-1": false}
|
||||||
|
sk.existsAll = false
|
||||||
|
srv := keeper.NewMsgServerImpl(k)
|
||||||
|
|
||||||
|
_, err := srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
|
||||||
|
BondID: "mab-s1", PoolID: "pool-1", IssuerStandID: "no-such-stand",
|
||||||
|
PrincipalGrain: 1_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomCoverCall,
|
||||||
|
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("IssueMAB on non-existent Stand should be REJECTED")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMABDebitProceedsNotFound asserts a debit on a non-existent MAB is
|
||||||
|
// REJECTED.
|
||||||
|
func TestMABDebitProceedsNotFound(t *testing.T) {
|
||||||
|
ctx, _, _, _, _, k := newMABSimtestContext(t)
|
||||||
|
srv := keeper.NewMsgServerImpl(k)
|
||||||
|
|
||||||
|
_, err := srv.DebitMABProceeds(ctx, &btypes.MsgDebitMABProceeds{
|
||||||
|
BondID: "no-such-mab", DestinationAccount: "reserve-acc-1", Signer: "stand-1",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("DebitMABProceeds on non-existent MAB should be REJECTED")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMABWitnessProceedsReleaseNotFound asserts a release on a non-existent
|
||||||
|
// MAB is REJECTED.
|
||||||
|
func TestMABWitnessProceedsReleaseNotFound(t *testing.T) {
|
||||||
|
ctx, _, _, _, _, k := newMABSimtestContext(t)
|
||||||
|
srv := keeper.NewMsgServerImpl(k)
|
||||||
|
|
||||||
|
_, err := srv.WitnessMABProceedsRelease(ctx, &btypes.MsgWitnessMABProceedsRelease{
|
||||||
|
BondID: "no-such-mab", AttestationRef: "ref", Signer: "watcher-1",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("WitnessMABProceedsRelease on non-existent MAB should be REJECTED")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMABWatcherAttestNotFound asserts an attestation on a non-existent MAB
|
||||||
|
// is REJECTED.
|
||||||
|
func TestMABWatcherAttestNotFound(t *testing.T) {
|
||||||
|
ctx, _, _, _, _, k := newMABSimtestContext(t)
|
||||||
|
srv := keeper.NewMsgServerImpl(k)
|
||||||
|
|
||||||
|
_, err := srv.WatcherAttestMAB(ctx, &btypes.MsgWatcherAttestMAB{
|
||||||
|
BondID: "no-such-mab", AttestationRef: "ref", Signer: "watcher-1",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("WatcherAttestMAB on non-existent MAB should be REJECTED")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMABDebitProceedsNilCoverKeeperRejected asserts a debit with a nil
|
||||||
|
// CoverKeeper shim (wiring error) is REJECTED (the destination cannot be
|
||||||
|
// validated — D-089(2) reverse edge required).
|
||||||
|
func TestMABDebitProceedsNilCoverKeeperRejected(t *testing.T) {
|
||||||
|
ctx, _, _, _, _, k := newMABSimtestContext(t)
|
||||||
|
k.SetCoverKeeper(nil) // nil CoverKeeper — wiring error
|
||||||
|
srv := keeper.NewMsgServerImpl(k)
|
||||||
|
|
||||||
|
_, err := srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
|
||||||
|
BondID: "mab-n1", PoolID: "pool-1", IssuerStandID: "stand-1",
|
||||||
|
PrincipalGrain: 1_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomCoverCall,
|
||||||
|
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IssueMAB: %v", err)
|
||||||
|
}
|
||||||
|
_, err = srv.DebitMABProceeds(ctx, &btypes.MsgDebitMABProceeds{
|
||||||
|
BondID: "mab-n1", DestinationAccount: "reserve-acc-1", Signer: "stand-1",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("DebitMABProceeds with nil CoverKeeper should be REJECTED (wiring error)")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "CoverKeeper shim not wired") {
|
||||||
|
t.Errorf("err = %q, want 'CoverKeeper shim not wired'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMABMsgValidateBasicErrorPaths exercises each MAB Msg* ValidateBasic
|
||||||
|
// error path for coverage.
|
||||||
|
func TestMABMsgValidateBasicErrorPaths(t *testing.T) {
|
||||||
|
// MsgIssueMAB empty.
|
||||||
|
if err := (&btypes.MsgIssueMAB{}).ValidateBasic(); err == nil {
|
||||||
|
t.Error("empty MsgIssueMAB should fail ValidateBasic")
|
||||||
|
}
|
||||||
|
// MsgIssueMAB with Bread coupons -> FR-MAB-3.
|
||||||
|
if err := (&btypes.MsgIssueMAB{
|
||||||
|
BondID: "x", PoolID: "p", IssuerStandID: "s", PrincipalGrain: 1,
|
||||||
|
CouponBps: 500, CouponKind: btypes.CouponDenomBread,
|
||||||
|
AnnualSurplusAtIssuance: 1, TermDays: 365, Signer: "s",
|
||||||
|
}).ValidateBasic(); err == nil {
|
||||||
|
t.Error("MsgIssueMAB with Bread coupons should fail ValidateBasic (FR-MAB-3)")
|
||||||
|
}
|
||||||
|
// MsgIssueMAB with above-cap coupon.
|
||||||
|
if err := (&btypes.MsgIssueMAB{
|
||||||
|
BondID: "x", PoolID: "p", IssuerStandID: "s", PrincipalGrain: 1,
|
||||||
|
CouponBps: 1200, CouponKind: btypes.CouponDenomCoverCall,
|
||||||
|
AnnualSurplusAtIssuance: 1, TermDays: 365, Signer: "s",
|
||||||
|
}).ValidateBasic(); err == nil {
|
||||||
|
t.Error("above-cap MsgIssueMAB should fail ValidateBasic")
|
||||||
|
}
|
||||||
|
// MsgIssueMAB with zero principal.
|
||||||
|
if err := (&btypes.MsgIssueMAB{
|
||||||
|
BondID: "x", PoolID: "p", IssuerStandID: "s", PrincipalGrain: 0,
|
||||||
|
CouponBps: 500, CouponKind: btypes.CouponDenomCoverCall,
|
||||||
|
AnnualSurplusAtIssuance: 1, TermDays: 365, Signer: "s",
|
||||||
|
}).ValidateBasic(); err == nil {
|
||||||
|
t.Error("zero-principal MsgIssueMAB should fail ValidateBasic")
|
||||||
|
}
|
||||||
|
// MsgDebitMABProceeds empty.
|
||||||
|
if err := (&btypes.MsgDebitMABProceeds{}).ValidateBasic(); err == nil {
|
||||||
|
t.Error("empty MsgDebitMABProceeds should fail ValidateBasic")
|
||||||
|
}
|
||||||
|
// MsgWitnessMABProceedsRelease empty.
|
||||||
|
if err := (&btypes.MsgWitnessMABProceedsRelease{}).ValidateBasic(); err == nil {
|
||||||
|
t.Error("empty MsgWitnessMABProceedsRelease should fail ValidateBasic")
|
||||||
|
}
|
||||||
|
// MsgWatcherAttestMAB empty.
|
||||||
|
if err := (&btypes.MsgWatcherAttestMAB{}).ValidateBasic(); err == nil {
|
||||||
|
t.Error("empty MsgWatcherAttestMAB should fail ValidateBasic")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMABKeeperAccessors exercises the MAB keeper accessors (AllMABs,
|
||||||
|
// MABsForPool, AllMABAttests) for coverage.
|
||||||
|
func TestMABKeeperAccessors(t *testing.T) {
|
||||||
|
ctx, _, _, _, _, k := newMABSimtestContext(t)
|
||||||
|
srv := keeper.NewMsgServerImpl(k)
|
||||||
|
|
||||||
|
// Empty-store accessors return empty (not nil) slices.
|
||||||
|
if got := k.AllMABs(ctx); len(got) != 0 {
|
||||||
|
t.Errorf("AllMABs empty = %d, want 0", len(got))
|
||||||
|
}
|
||||||
|
if got := k.MABsForPool(ctx, "pool-1"); len(got) != 0 {
|
||||||
|
t.Errorf("MABsForPool empty = %d, want 0", len(got))
|
||||||
|
}
|
||||||
|
if got := k.AllMABAttests(ctx, "mab-x"); len(got) != 0 {
|
||||||
|
t.Errorf("AllMABAttests empty = %d, want 0", len(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue + read back.
|
||||||
|
_, _ = srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
|
||||||
|
BondID: "mab-acc-1", PoolID: "pool-1", IssuerStandID: "stand-1",
|
||||||
|
PrincipalGrain: 1_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomCoverCall,
|
||||||
|
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
|
||||||
|
})
|
||||||
|
if got := k.AllMABs(ctx); len(got) != 1 {
|
||||||
|
t.Errorf("AllMABs = %d, want 1", len(got))
|
||||||
|
}
|
||||||
|
if got := k.MABsForPool(ctx, "pool-1"); len(got) != 1 {
|
||||||
|
t.Errorf("MABsForPool pool-1 = %d, want 1", len(got))
|
||||||
|
}
|
||||||
|
if got := k.MABsForPool(ctx, "other-pool"); len(got) != 0 {
|
||||||
|
t.Errorf("MABsForPool other-pool = %d, want 0", len(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marshal-error path on GetMAB (corrupt bytes in store).
|
||||||
|
rawStore := ctx.KVStore(k.StoreKey())
|
||||||
|
rawStore.Set([]byte("mab/corrupt"), []byte("not-json"))
|
||||||
|
if _, ok := k.GetMAB(ctx, "corrupt"); ok {
|
||||||
|
t.Error("GetMAB on corrupt bytes should return false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,15 +36,78 @@ package types
|
|||||||
// A non-existent Stand REJECTS the issuance (the bond is not created).
|
// A non-existent Stand REJECTS the issuance (the bond is not created).
|
||||||
// - MsgIssueGrowthBond: same — the GrowthBond issuer-stand-id must
|
// - MsgIssueGrowthBond: same — the GrowthBond issuer-stand-id must
|
||||||
// reference an existing Stand.
|
// reference an existing Stand.
|
||||||
|
// - MsgIssueMAB: same — the MAB issuer-stand-id must reference an
|
||||||
|
// existing Stand (v0.7 P4 extension).
|
||||||
//
|
//
|
||||||
// No struct import of x/stand/types — the interface is the by-ID-string
|
// No struct import of x/stand/types — the interface is the by-ID-string
|
||||||
// boundary (G-003). The standID is an opaque string (the Stand's ID, by-
|
// boundary (G-003). The standID is an opaque string (the Stand's ID, by-
|
||||||
// ID-string ref to x/stand).
|
// ID-string ref to x/stand).
|
||||||
type StandKeeper interface {
|
type StandKeeper interface {
|
||||||
// StandExists reports whether the named Stand (by-ID-string) exists.
|
// StandExists reports whether the named Stand (by-ID-string) exists.
|
||||||
// The IssueBond / IssueGrowthBond handlers consult this BEFORE issuing
|
// The IssueBond / IssueGrowthBond / IssueMAB handlers consult this
|
||||||
// the bond; a non-existent Stand REJECTS the issuance (the bond is not
|
// BEFORE issuing the bond; a non-existent Stand REJECTS the issuance
|
||||||
// created). A nil shim skips this check (simtest wiring — documented in
|
// (the bond is not created). A nil shim skips this check (simtest
|
||||||
// the handler).
|
// wiring — documented in the handler).
|
||||||
StandExists(standID string) bool
|
StandExists(standID string) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CoverKeeper is the expected-keeper interface for x/cover (G-003 — D-089(2)
|
||||||
|
// reverse edge). The v0.7 MAB handler calls it for:
|
||||||
|
// - MsgDebitMABProceeds: the handler queries GetPoolReserveAccount(poolID)
|
||||||
|
// to validate the destination == the Pool's ReserveAccount
|
||||||
|
// (D-080 tagged streaming). A mismatch -> auto-Still via StillKeeper +
|
||||||
|
// REJECT. A nil CoverKeeper is a wiring error (the handler REJECTS a
|
||||||
|
// debit when no CoverKeeper is wired — the destination cannot be
|
||||||
|
// validated; the simtest wires a stub).
|
||||||
|
//
|
||||||
|
// No struct import of x/cover/types — the interface is the by-ID-string
|
||||||
|
// boundary (G-003 — D-089(2) reverse edge). The poolID is an opaque string
|
||||||
|
// (the Cover Pool's ID). No import cycle (interface only — the concrete
|
||||||
|
// cover keeper satisfies this structurally; the simtest wires a stub).
|
||||||
|
type CoverKeeper interface {
|
||||||
|
// GetPoolReserveAccount returns the Cover Pool's ReserveAccount by
|
||||||
|
// pool-id (D-089(2) reverse edge). The MsgDebitMABProceeds handler
|
||||||
|
// compares the destination against this; a mismatch triggers the
|
||||||
|
// auto-Still. Returns ("", false) if the pool does not exist.
|
||||||
|
GetPoolReserveAccount(poolID string) (reserveAccount string, exists bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WatcherKeeper is the expected-keeper interface for x/watcher (G-003). The
|
||||||
|
// v0.7 MAB handler calls it for:
|
||||||
|
// - MsgWitnessMABProceedsRelease: the handler requires Watcher quorum
|
||||||
|
// (6-of-9) before the tagged proceeds move from staging to the reserve.
|
||||||
|
// AttestMABRelease(bondID, attestationRef) returns true if quorum is
|
||||||
|
// met (the simtest stub returns a configurable bool). A nil
|
||||||
|
// WatcherKeeper skips the quorum check (simtest wiring — the handler
|
||||||
|
// still mutates state; the simtest documents the wiring).
|
||||||
|
//
|
||||||
|
// No struct import of x/watcher/types — the interface is the by-ID-string
|
||||||
|
// boundary (G-003). The bondID + attestationRef are opaque strings.
|
||||||
|
type WatcherKeeper interface {
|
||||||
|
// AttestMABRelease reports whether the Watcher quorum (6-of-9) is met
|
||||||
|
// for the MAB proceeds release (D-080). Returns true if quorum present;
|
||||||
|
// false if not (the handler REJECTS the release). The attestationRef
|
||||||
|
// is the Watcher-signed observation ref.
|
||||||
|
AttestMABRelease(bondID string, attestationRef string) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// StillKeeper is the expected-keeper interface for x/still (G-003 — D-089(1)
|
||||||
|
// simtest stub). The v0.7 MAB handler calls it for:
|
||||||
|
// - MsgDebitMABProceeds: on a destination mismatch (D-080 tagged-streaming
|
||||||
|
// misuse), the handler invokes Still(bondID, "MAB misuse — proceeds
|
||||||
|
// routed outside reserve") BEFORE rejecting. A nil StillKeeper skips
|
||||||
|
// the Still recording (simtest wiring — the handler still REJECTS the
|
||||||
|
// debit; the Still event is just not recorded in a still store).
|
||||||
|
//
|
||||||
|
// No struct import of x/still/types — the interface is the by-ID-string
|
||||||
|
// boundary (G-003). P4 satisfies this by a simtest-local stub (x/still is
|
||||||
|
// NOT extended this milestone — the simtest stub records Still() calls for
|
||||||
|
// assertion).
|
||||||
|
type StillKeeper interface {
|
||||||
|
// Still pauses the named entity (by-ID-string) for the given reason.
|
||||||
|
// The MsgDebitMABProceeds handler calls this on a destination mismatch
|
||||||
|
// (D-080 misuse -> D-089(1) auto-Still). A non-nil error does NOT
|
||||||
|
// suppress the handler's REJECT (the handler REJECTS regardless; the
|
||||||
|
// Still is the pause-recording side-effect).
|
||||||
|
Still(bondID string, reason string) error
|
||||||
|
}
|
||||||
|
|||||||
@@ -142,3 +142,41 @@ func knownOrderStatus(s OrderStatus) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- v0.7 extension: MAB genesis helpers (REQ-054, G-008) ---------------------
|
||||||
|
//
|
||||||
|
// genesis.go also holds the data-engineer's genesis schema helpers for the
|
||||||
|
// v0.7 MAB set (G-008). ValidateGenesis in types.go composes ValidateMABs;
|
||||||
|
// the security-engineer's test assertions live in types_test.go.
|
||||||
|
|
||||||
|
// ValidateMABs asserts mab bond-ids are present and unique, that each
|
||||||
|
// embedded Bond's coupon-bps is within the LOCKED [floor, cap] bounds
|
||||||
|
// (D-028), and that each MAB passes ValidateMAB (FR-MAB-3 — rejects
|
||||||
|
// CouponDenomBread). The genesis-side ValidateMAB is the authoritative
|
||||||
|
// check (a genesis MAB with a rejected CouponKind is rejected at genesis
|
||||||
|
// load rather than silently dropped).
|
||||||
|
func ValidateMABs(mabs []MAB) error {
|
||||||
|
seen := make(map[string]bool, len(mabs))
|
||||||
|
for i, m := range mabs {
|
||||||
|
if m.BondID == "" {
|
||||||
|
return fmt.Errorf("mab [%d]: empty bond-id", i)
|
||||||
|
}
|
||||||
|
if seen[m.BondID] {
|
||||||
|
return fmt.Errorf("mab: duplicate bond-id %q", m.BondID)
|
||||||
|
}
|
||||||
|
seen[m.BondID] = true
|
||||||
|
if !knownBondStatus(m.Status) {
|
||||||
|
return fmt.Errorf("mab %q: unknown bond status %q", m.BondID, m.Status)
|
||||||
|
}
|
||||||
|
// D-028 clamp on the embedded Bond's coupon.
|
||||||
|
if m.CouponBps < CouponFloorBps || m.CouponBps > CouponCapBps {
|
||||||
|
return fmt.Errorf("mab %q: coupon-bps %d outside [%d, %d] (D-028 clamp at genesis load)",
|
||||||
|
m.BondID, m.CouponBps, CouponFloorBps, CouponCapBps)
|
||||||
|
}
|
||||||
|
// FR-MAB-3: MAB coupons NEVER Bread (the dual-firewall runtime gate).
|
||||||
|
if err := ValidateMAB(m); err != nil {
|
||||||
|
return fmt.Errorf("mab %q: %w", m.BondID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -394,6 +394,12 @@ type MsgServer interface {
|
|||||||
PlaceSecondaryOrder(ctx interface{}, msg *MsgPlaceSecondaryOrder) (*MsgPlaceSecondaryOrderResponse, error)
|
PlaceSecondaryOrder(ctx interface{}, msg *MsgPlaceSecondaryOrder) (*MsgPlaceSecondaryOrderResponse, error)
|
||||||
CancelSecondaryOrder(ctx interface{}, msg *MsgCancelSecondaryOrder) (*MsgCancelSecondaryOrderResponse, error)
|
CancelSecondaryOrder(ctx interface{}, msg *MsgCancelSecondaryOrder) (*MsgCancelSecondaryOrderResponse, error)
|
||||||
MatchSecondaryOrder(ctx interface{}, msg *MsgMatchSecondaryOrder) (*MsgMatchSecondaryOrderResponse, error)
|
MatchSecondaryOrder(ctx interface{}, msg *MsgMatchSecondaryOrder) (*MsgMatchSecondaryOrderResponse, error)
|
||||||
|
// v0.7 MAB handlers (REQ-054, D-080, D-089(1), D-089(2)) — defined in
|
||||||
|
// msg_mab.go.
|
||||||
|
IssueMAB(ctx interface{}, msg *MsgIssueMAB) (*MsgIssueMABResponse, error)
|
||||||
|
DebitMABProceeds(ctx interface{}, msg *MsgDebitMABProceeds) (*MsgDebitMABProceedsResponse, error)
|
||||||
|
WitnessMABProceedsRelease(ctx interface{}, msg *MsgWitnessMABProceedsRelease) (*MsgWitnessMABProceedsReleaseResponse, error)
|
||||||
|
WatcherAttestMAB(ctx interface{}, msg *MsgWatcherAttestMAB) (*MsgWatcherAttestMABResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Response types (hand-rolled; the response is the state mutation + event).
|
// Response types (hand-rolled; the response is the state mutation + event).
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
// msg_mab.go holds the v0.7 Mutual Aid Bond Msg* types implementing sdk.Msg
|
||||||
|
// (REQ-054, D-080, D-089(1), D-089(2); G-006 controlled exception: types/
|
||||||
|
// gains the cosmos-sdk import for sdk.Msg — D-055; the invariant/lexicon
|
||||||
|
// tests in *_test.go stay stdlib-only per G-024, isolated from this
|
||||||
|
// msg_*.go file).
|
||||||
|
//
|
||||||
|
// The four MAB Msg types drive the MAB runtime (REQ-054):
|
||||||
|
// - MsgIssueMAB: issue a Mutual Aid Bond (the handler enforces the 3×
|
||||||
|
// annual surplus ceiling + the FR-MAB-3 Bread-coupon rejection +
|
||||||
|
// Clamp on the coupon).
|
||||||
|
// - MsgDebitMABProceeds: debit the MAB's tagged proceeds to the Pool's
|
||||||
|
// ReserveAccount (D-080 — the handler checks destination ==
|
||||||
|
// CoverKeeper.GetPoolReserveAccount; mismatch -> auto-Still via
|
||||||
|
// StillKeeper + REJECT).
|
||||||
|
// - MsgWitnessMABProceedsRelease: a Watcher-witnessed release of the
|
||||||
|
// tagged proceeds from staging to the reserve (D-080 — the handler
|
||||||
|
// requires WatcherKeeper.AttestMABRelease quorum 6-of-9).
|
||||||
|
// - MsgWatcherAttestMAB: the quarterly Watcher audit attestation on a
|
||||||
|
// MAB (records the attestation-ref against the MAB).
|
||||||
|
//
|
||||||
|
// All cross-module refs are by-ID-string (G-003): pool-id refs a Cover Pool
|
||||||
|
// (via the CoverKeeper shim — D-089(2) reverse edge); the WatcherKeeper +
|
||||||
|
// StillKeeper shims are interfaces defined in expected_keepers.go. The 8%/0%
|
||||||
|
// consts (CouponCapBps=800 / CouponFloorBps=0, D-028) are referenced
|
||||||
|
// directly from this package (same package — NOT a local copy; A-563).
|
||||||
|
//
|
||||||
|
// Lexicon (REQ-012, A-210): "Mutual Aid Bond", "MAB", "Cover Call",
|
||||||
|
// "coupon", "use-of-proceeds", "reserve build-out" are clean. The
|
||||||
|
// CouponDenomBread const VALUE "Bread" is the OY unit (clean — not a banned
|
||||||
|
// term). The banned coupon-synonyms are NEVER used.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
sdk "github.com/cosmos/cosmos-sdk/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- MsgIssueMAB --------------------------------------------------------------
|
||||||
|
|
||||||
|
// MsgIssueMAB issues a Mutual Aid Bond (REQ-054, D-080). The handler enforces:
|
||||||
|
// - ValidateBasic (stateless — includes ValidateMAB: rejects
|
||||||
|
// CouponDenomBread with FR-MAB-3).
|
||||||
|
// - Idempotency: bond-id must not already exist.
|
||||||
|
// - StandKeeper shim: the issuer-stand-id must reference an existing Stand
|
||||||
|
// (P1-02-01 edge). A nil shim skips (simtest wiring).
|
||||||
|
// - 3× annual surplus ceiling: checkMABIssuanceCeiling asserts
|
||||||
|
// sum(existingMABPrincipal for poolID) + PrincipalGrain <=
|
||||||
|
// MABIssuanceCeilingAnnualSurplusMultiple × AnnualSurplusAtIssuance.
|
||||||
|
// REJECT if above ceiling (re-checked at every issuance).
|
||||||
|
// - Coupon clamp via Clamp (A-563 — defense in depth).
|
||||||
|
// - UseOfProceedsTag locked to MABUseOfProceedsReserveBuildOut.
|
||||||
|
//
|
||||||
|
// pool-id is on the msg (NOT on the MAB struct — the MAB struct mirrors
|
||||||
|
// GrowthBond's anonymous-embed pattern; the pool binding is via the
|
||||||
|
// CoverKeeper reverse edge). The handler records the pool-id in the
|
||||||
|
// keeper's mab-pool index (BondID -> PoolID) for the ceiling check +
|
||||||
|
// the DebitMABProceeds destination validation.
|
||||||
|
type MsgIssueMAB struct {
|
||||||
|
BondID string `json:"bond_id" yaml:"bond_id"`
|
||||||
|
PoolID string `json:"pool_id" yaml:"pool_id"`
|
||||||
|
IssuerStandID string `json:"issuer_stand_id" yaml:"issuer_stand_id"`
|
||||||
|
PrincipalGrain int64 `json:"principal_grain" yaml:"principal_grain"`
|
||||||
|
CouponBps uint32 `json:"coupon_bps" yaml:"coupon_bps"`
|
||||||
|
CouponKind CouponDenom `json:"coupon_kind" yaml:"coupon_kind"`
|
||||||
|
AnnualSurplusAtIssuance int64 `json:"annual_surplus_at_issuance" yaml:"annual_surplus_at_issuance"`
|
||||||
|
TermDays uint32 `json:"term_days" yaml:"term_days"`
|
||||||
|
Signer string `json:"signer" yaml:"signer"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset implements proto.Message (sdk.Msg = proto.Message).
|
||||||
|
func (m *MsgIssueMAB) Reset() { *m = MsgIssueMAB{} }
|
||||||
|
|
||||||
|
// String implements proto.Message.
|
||||||
|
func (m *MsgIssueMAB) String() string {
|
||||||
|
return fmt.Sprintf("MsgIssueMAB{BondID:%s PoolID:%s IssuerStandID:%s PrincipalGrain:%d CouponBps:%d CouponKind:%s AnnualSurplusAtIssuance:%d TermDays:%d Signer:%s}",
|
||||||
|
m.BondID, m.PoolID, m.IssuerStandID, m.PrincipalGrain, m.CouponBps, m.CouponKind, m.AnnualSurplusAtIssuance, m.TermDays, m.Signer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProtoMessage implements proto.Message.
|
||||||
|
func (*MsgIssueMAB) ProtoMessage() {}
|
||||||
|
|
||||||
|
// ValidateBasic is the stateless validation: non-empty fields, PrincipalGrain
|
||||||
|
// > 0, AnnualSurplusAtIssuance > 0, coupon-bps within [CouponFloorBps,
|
||||||
|
// CouponCapBps] (the stateless clamp guard; the handler re-clamps at
|
||||||
|
// runtime per A-563), AND ValidateMAB (FR-MAB-3 — rejects CouponDenomBread).
|
||||||
|
// The 3× annual surplus ceiling is a keeper-handler check (stateful — it
|
||||||
|
// sums existing MAB principals for the poolID).
|
||||||
|
func (m *MsgIssueMAB) ValidateBasic() error {
|
||||||
|
if m.BondID == "" {
|
||||||
|
return fmt.Errorf("bond: empty bond-id")
|
||||||
|
}
|
||||||
|
if m.PoolID == "" {
|
||||||
|
return fmt.Errorf("bond: empty pool-id")
|
||||||
|
}
|
||||||
|
if m.IssuerStandID == "" {
|
||||||
|
return fmt.Errorf("bond: empty issuer-stand-id")
|
||||||
|
}
|
||||||
|
if m.PrincipalGrain <= 0 {
|
||||||
|
return fmt.Errorf("bond: principal-grain must be > 0")
|
||||||
|
}
|
||||||
|
if m.AnnualSurplusAtIssuance <= 0 {
|
||||||
|
return fmt.Errorf("bond: annual-surplus-at-issuance must be > 0")
|
||||||
|
}
|
||||||
|
if m.CouponBps < CouponFloorBps || m.CouponBps > CouponCapBps {
|
||||||
|
return fmt.Errorf("bond: coupon-bps %d out of band [%d, %d] (D-028 stateless guard)", m.CouponBps, CouponFloorBps, CouponCapBps)
|
||||||
|
}
|
||||||
|
if m.Signer == "" {
|
||||||
|
return fmt.Errorf("bond: empty signer")
|
||||||
|
}
|
||||||
|
// FR-MAB-3 dual firewall: ValidateMAB rejects CouponDenomBread at the
|
||||||
|
// stateless gate (the handler re-checks in defense in depth).
|
||||||
|
if err := ValidateMAB(MAB{CouponKind: m.CouponKind}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||||
|
func (m *MsgIssueMAB) GetSigners() []sdk.AccAddress {
|
||||||
|
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MsgDebitMABProceeds ------------------------------------------------------
|
||||||
|
|
||||||
|
// MsgDebitMABProceeds debits a MAB's tagged proceeds to the Pool's
|
||||||
|
// ReserveAccount (D-080). The handler enforces:
|
||||||
|
// - ValidateBasic (stateless).
|
||||||
|
// - The MAB must exist.
|
||||||
|
// - D-080 tagged streaming: DestinationAccount ==
|
||||||
|
// CoverKeeper.GetPoolReserveAccount(mab's poolID). If mismatch ->
|
||||||
|
// StillKeeper.Still(bondID, "MAB misuse — proceeds routed outside
|
||||||
|
// reserve") (D-089(1) — a nil StillKeeper skips the Still recording)
|
||||||
|
// AND REJECT. If match -> emit bond.mab_proceeds_debited (simtest: the
|
||||||
|
// debit is the event; no actual Grain transfer in P4).
|
||||||
|
type MsgDebitMABProceeds struct {
|
||||||
|
BondID string `json:"bond_id" yaml:"bond_id"`
|
||||||
|
DestinationAccount string `json:"destination_account" yaml:"destination_account"`
|
||||||
|
Signer string `json:"signer" yaml:"signer"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset implements proto.Message.
|
||||||
|
func (m *MsgDebitMABProceeds) Reset() { *m = MsgDebitMABProceeds{} }
|
||||||
|
|
||||||
|
// String implements proto.Message.
|
||||||
|
func (m *MsgDebitMABProceeds) String() string {
|
||||||
|
return fmt.Sprintf("MsgDebitMABProceeds{BondID:%s DestinationAccount:%s Signer:%s}",
|
||||||
|
m.BondID, m.DestinationAccount, m.Signer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProtoMessage implements proto.Message.
|
||||||
|
func (*MsgDebitMABProceeds) ProtoMessage() {}
|
||||||
|
|
||||||
|
// ValidateBasic is the stateless validation: non-empty bond-id, non-empty
|
||||||
|
// DestinationAccount, non-empty signer.
|
||||||
|
func (m *MsgDebitMABProceeds) ValidateBasic() error {
|
||||||
|
if m.BondID == "" {
|
||||||
|
return fmt.Errorf("bond: empty bond-id")
|
||||||
|
}
|
||||||
|
if m.DestinationAccount == "" {
|
||||||
|
return fmt.Errorf("bond: empty DestinationAccount")
|
||||||
|
}
|
||||||
|
if m.Signer == "" {
|
||||||
|
return fmt.Errorf("bond: empty signer")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||||
|
func (m *MsgDebitMABProceeds) GetSigners() []sdk.AccAddress {
|
||||||
|
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MsgWitnessMABProceedsRelease ---------------------------------------------
|
||||||
|
|
||||||
|
// MsgWitnessMABProceedsRelease is a Watcher-witnessed release of a MAB's
|
||||||
|
// tagged proceeds from staging to the reserve (D-080). The handler enforces:
|
||||||
|
// - ValidateBasic (stateless).
|
||||||
|
// - The MAB must exist.
|
||||||
|
// - Watcher quorum: WatcherKeeper.AttestMABRelease(bondID, attestationRef)
|
||||||
|
// returns true if quorum (6-of-9) is met. If false (quorum not met) ->
|
||||||
|
// REJECT. If true -> emit bond.mab_proceeds_released (the proceeds move
|
||||||
|
// from tagged staging to the reserve — simtest event).
|
||||||
|
type MsgWitnessMABProceedsRelease struct {
|
||||||
|
BondID string `json:"bond_id" yaml:"bond_id"`
|
||||||
|
AttestationRef string `json:"attestation_ref" yaml:"attestation_ref"`
|
||||||
|
Signer string `json:"signer" yaml:"signer"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset implements proto.Message.
|
||||||
|
func (m *MsgWitnessMABProceedsRelease) Reset() { *m = MsgWitnessMABProceedsRelease{} }
|
||||||
|
|
||||||
|
// String implements proto.Message.
|
||||||
|
func (m *MsgWitnessMABProceedsRelease) String() string {
|
||||||
|
return fmt.Sprintf("MsgWitnessMABProceedsRelease{BondID:%s AttestationRef:%s Signer:%s}",
|
||||||
|
m.BondID, m.AttestationRef, m.Signer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProtoMessage implements proto.Message.
|
||||||
|
func (*MsgWitnessMABProceedsRelease) ProtoMessage() {}
|
||||||
|
|
||||||
|
// ValidateBasic is the stateless validation: non-empty bond-id, non-empty
|
||||||
|
// attestation-ref, non-empty signer.
|
||||||
|
func (m *MsgWitnessMABProceedsRelease) ValidateBasic() error {
|
||||||
|
if m.BondID == "" {
|
||||||
|
return fmt.Errorf("bond: empty bond-id")
|
||||||
|
}
|
||||||
|
if m.AttestationRef == "" {
|
||||||
|
return fmt.Errorf("bond: empty attestation-ref")
|
||||||
|
}
|
||||||
|
if m.Signer == "" {
|
||||||
|
return fmt.Errorf("bond: empty signer")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||||
|
func (m *MsgWitnessMABProceedsRelease) GetSigners() []sdk.AccAddress {
|
||||||
|
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MsgWatcherAttestMAB ------------------------------------------------------
|
||||||
|
|
||||||
|
// MsgWatcherAttestMAB records a quarterly Watcher audit attestation on a MAB
|
||||||
|
// (D-080). The handler enforces:
|
||||||
|
// - ValidateBasic (stateless).
|
||||||
|
// - The MAB must exist.
|
||||||
|
// - Record the attestation (a store entry mab_attest/<bondID>/<timestamp>
|
||||||
|
// -> attestationRef). Emit bond.mab_watcher_attested.
|
||||||
|
type MsgWatcherAttestMAB struct {
|
||||||
|
BondID string `json:"bond_id" yaml:"bond_id"`
|
||||||
|
AttestationRef string `json:"attestation_ref" yaml:"attestation_ref"`
|
||||||
|
Signer string `json:"signer" yaml:"signer"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset implements proto.Message.
|
||||||
|
func (m *MsgWatcherAttestMAB) Reset() { *m = MsgWatcherAttestMAB{} }
|
||||||
|
|
||||||
|
// String implements proto.Message.
|
||||||
|
func (m *MsgWatcherAttestMAB) String() string {
|
||||||
|
return fmt.Sprintf("MsgWatcherAttestMAB{BondID:%s AttestationRef:%s Signer:%s}",
|
||||||
|
m.BondID, m.AttestationRef, m.Signer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProtoMessage implements proto.Message.
|
||||||
|
func (*MsgWatcherAttestMAB) ProtoMessage() {}
|
||||||
|
|
||||||
|
// ValidateBasic is the stateless validation: non-empty bond-id, non-empty
|
||||||
|
// attestation-ref, non-empty signer.
|
||||||
|
func (m *MsgWatcherAttestMAB) ValidateBasic() error {
|
||||||
|
if m.BondID == "" {
|
||||||
|
return fmt.Errorf("bond: empty bond-id")
|
||||||
|
}
|
||||||
|
if m.AttestationRef == "" {
|
||||||
|
return fmt.Errorf("bond: empty attestation-ref")
|
||||||
|
}
|
||||||
|
if m.Signer == "" {
|
||||||
|
return fmt.Errorf("bond: empty signer")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||||
|
func (m *MsgWatcherAttestMAB) GetSigners() []sdk.AccAddress {
|
||||||
|
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MAB Response types -------------------------------------------------------
|
||||||
|
|
||||||
|
// MsgIssueMABResponse is the response to MsgIssueMAB. ClampedCouponBps
|
||||||
|
// reports the runtime-clamped coupon (for simtest assertion that issuance
|
||||||
|
// clamped it). CeilingMultiple reports the post-issuance
|
||||||
|
// (sumMABPrincipal / AnnualSurplusAtIssuance) ratio (for simtest assertion
|
||||||
|
// the ceiling was respected).
|
||||||
|
type MsgIssueMABResponse struct {
|
||||||
|
ClampedCouponBps uint32 `json:"clamped_coupon_bps" yaml:"clamped_coupon_bps"`
|
||||||
|
CeilingMultiple int64 `json:"ceiling_multiple" yaml:"ceiling_multiple"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset implements proto.Message.
|
||||||
|
func (m *MsgIssueMABResponse) Reset() { *m = MsgIssueMABResponse{} }
|
||||||
|
|
||||||
|
// String implements proto.Message.
|
||||||
|
func (m *MsgIssueMABResponse) String() string {
|
||||||
|
return fmt.Sprintf("MsgIssueMABResponse{ClampedCouponBps:%d CeilingMultiple:%d}",
|
||||||
|
m.ClampedCouponBps, m.CeilingMultiple)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProtoMessage implements proto.Message.
|
||||||
|
func (*MsgIssueMABResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
// MsgDebitMABProceedsResponse is the response to MsgDebitMABProceeds.
|
||||||
|
type MsgDebitMABProceedsResponse struct{}
|
||||||
|
|
||||||
|
// Reset implements proto.Message.
|
||||||
|
func (m *MsgDebitMABProceedsResponse) Reset() { *m = MsgDebitMABProceedsResponse{} }
|
||||||
|
|
||||||
|
// String implements proto.Message.
|
||||||
|
func (m *MsgDebitMABProceedsResponse) String() string { return "MsgDebitMABProceedsResponse{}" }
|
||||||
|
|
||||||
|
// ProtoMessage implements proto.Message.
|
||||||
|
func (*MsgDebitMABProceedsResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
// MsgWitnessMABProceedsReleaseResponse is the response to
|
||||||
|
// MsgWitnessMABProceedsRelease.
|
||||||
|
type MsgWitnessMABProceedsReleaseResponse struct{}
|
||||||
|
|
||||||
|
// Reset implements proto.Message.
|
||||||
|
func (m *MsgWitnessMABProceedsReleaseResponse) Reset() { *m = MsgWitnessMABProceedsReleaseResponse{} }
|
||||||
|
|
||||||
|
// String implements proto.Message.
|
||||||
|
func (m *MsgWitnessMABProceedsReleaseResponse) String() string {
|
||||||
|
return "MsgWitnessMABProceedsReleaseResponse{}"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProtoMessage implements proto.Message.
|
||||||
|
func (*MsgWitnessMABProceedsReleaseResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
// MsgWatcherAttestMABResponse is the response to MsgWatcherAttestMAB.
|
||||||
|
type MsgWatcherAttestMABResponse struct{}
|
||||||
|
|
||||||
|
// Reset implements proto.Message.
|
||||||
|
func (m *MsgWatcherAttestMABResponse) Reset() { *m = MsgWatcherAttestMABResponse{} }
|
||||||
|
|
||||||
|
// String implements proto.Message.
|
||||||
|
func (m *MsgWatcherAttestMABResponse) String() string { return "MsgWatcherAttestMABResponse{}" }
|
||||||
|
|
||||||
|
// ProtoMessage implements proto.Message.
|
||||||
|
func (*MsgWatcherAttestMABResponse) ProtoMessage() {}
|
||||||
@@ -29,6 +29,33 @@ const (
|
|||||||
// §17, REQ-021). A regression firewall: adding/removing/renaming a bond
|
// §17, REQ-021). A regression firewall: adding/removing/renaming a bond
|
||||||
// status breaks this const's test.
|
// status breaks this const's test.
|
||||||
BondStatusCount = 5
|
BondStatusCount = 5
|
||||||
|
|
||||||
|
// MABIssuanceCeilingAnnualSurplusMultiple is the LOCKED ceiling on the
|
||||||
|
// total outstanding MAB principal for a pool, expressed as a multiple of
|
||||||
|
// the pool's AnnualSurplusAtIssuance (vision §17, REQ-054 locked — the
|
||||||
|
// 3× annual surplus mission-locked ceiling). The handler re-checks at
|
||||||
|
// every issuance (not just the first): sum(existingMABPrincipal) +
|
||||||
|
// newPrincipal <= 3 × AnnualSurplusAtIssuance. A regression here is a
|
||||||
|
// mission-lock breach.
|
||||||
|
MABIssuanceCeilingAnnualSurplusMultiple = 3
|
||||||
|
|
||||||
|
// MABUseOfProceedsReserveBuildOut is the D-080 tagged-streaming use-of-
|
||||||
|
// proceeds tag for a MAB: the proceeds are tagged for "reserve_build_out"
|
||||||
|
// (the Cover Pool's ReserveAccount build-out). The MsgDebitMABProceeds
|
||||||
|
// handler checks the destination == the Pool's ReserveAccount;
|
||||||
|
// the MsgWitnessMABProceedsRelease handler requires Watcher quorum before
|
||||||
|
// the tagged proceeds move from staging to the reserve. The tag is the
|
||||||
|
// D-080 lock — a MAB's proceeds are NEVER routable outside reserve
|
||||||
|
// build-out (mismatch -> auto-Still + REJECT).
|
||||||
|
MABUseOfProceedsReserveBuildOut = "reserve_build_out"
|
||||||
|
|
||||||
|
// CouponDenomCount is the count of CouponDenom enum values (vision §17,
|
||||||
|
// REQ-054). A regression firewall: adding/removing/renaming a CouponDenom
|
||||||
|
// breaks this const's test. The three values are CouponDenomCoverCall,
|
||||||
|
// CouponDenomMutualAidCredit, CouponDenomBread (the last exists ONLY to
|
||||||
|
// be rejected at ValidateMAB with "FR-MAB-3: MAB coupons NEVER Bread" —
|
||||||
|
// the dual-firewall runtime gate mirroring MissionLockAmendmentRejected).
|
||||||
|
CouponDenomCount = 3
|
||||||
)
|
)
|
||||||
|
|
||||||
// BondStatus enumerates the bond lifecycle states (vision §17, REQ-021).
|
// BondStatus enumerates the bond lifecycle states (vision §17, REQ-021).
|
||||||
@@ -124,6 +151,7 @@ type GenesisState struct {
|
|||||||
Bonds []Bond `json:"bonds" yaml:"bonds"`
|
Bonds []Bond `json:"bonds" yaml:"bonds"`
|
||||||
GrowthBonds []GrowthBond `json:"growth_bonds" yaml:"growth_bonds"`
|
GrowthBonds []GrowthBond `json:"growth_bonds" yaml:"growth_bonds"`
|
||||||
Orders []SecondaryOrder `json:"orders" yaml:"orders"`
|
Orders []SecondaryOrder `json:"orders" yaml:"orders"`
|
||||||
|
MABs []MAB `json:"mabs" yaml:"mabs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultGenesisState() *GenesisState {
|
func DefaultGenesisState() *GenesisState {
|
||||||
@@ -132,6 +160,7 @@ func DefaultGenesisState() *GenesisState {
|
|||||||
Bonds: []Bond{},
|
Bonds: []Bond{},
|
||||||
GrowthBonds: []GrowthBond{},
|
GrowthBonds: []GrowthBond{},
|
||||||
Orders: []SecondaryOrder{},
|
Orders: []SecondaryOrder{},
|
||||||
|
MABs: []MAB{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +183,9 @@ func ValidateGenesis(bz json.RawMessage) error {
|
|||||||
if err := ValidateOrders(gs.Orders); err != nil {
|
if err := ValidateOrders(gs.Orders); err != nil {
|
||||||
return fmt.Errorf("bond: %w", err)
|
return fmt.Errorf("bond: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := ValidateMABs(gs.MABs); err != nil {
|
||||||
|
return fmt.Errorf("bond: %w", err)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +313,127 @@ func IssueGrowth(bondID, issuerStandID string, principalGrain int64, couponBps,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- v0.7 extension: Mutual Aid Bond (MAB) (REQ-054, D-080, D-089(2)) -----------
|
||||||
|
//
|
||||||
|
// The v0.7 bond extension adds the Mutual Aid Bond (MAB): a mission-locked
|
||||||
|
// bond a Cover Pool issues to build out its reserve (vision §17, REQ-054).
|
||||||
|
// The MAB embeds the v0.2 Bond (anonymous field) so it carries all Bond
|
||||||
|
// fields PLUS a CouponKind (the coupon denomination: Cover-Call or Mutual-Aid
|
||||||
|
// Credit — Bread is the rejected sentinel), an AnnualSurplusAtIssuance (the
|
||||||
|
// pool's annual surplus at issuance, used for the 3× ceiling check), and a
|
||||||
|
// UseOfProceedsTag (D-080 — locked to "reserve_build_out"). The coupon rate
|
||||||
|
// is clamped to [CouponFloorBps, CouponCapBps] via Clamp (the 8%/0% consts
|
||||||
|
// D-028 apply to MABs too).
|
||||||
|
//
|
||||||
|
// The 3× annual surplus ceiling (MABIssuanceCeilingAnnualSurplusMultiple) is
|
||||||
|
// the mission-locked upper bound on the total outstanding MAB principal for
|
||||||
|
// a pool (vision §17, REQ-054 locked). The handler re-checks at every
|
||||||
|
// issuance: sum(existingMABPrincipal) + newPrincipal <= 3 ×
|
||||||
|
// AnnualSurplusAtIssuance. A regression here is a mission-lock breach.
|
||||||
|
//
|
||||||
|
// D-080 tagged streaming: the UseOfProceedsTag is locked to
|
||||||
|
// "reserve_build_out"; the MsgDebitMABProceeds handler checks the destination
|
||||||
|
// == the Pool's ReserveAccount (queried via the CoverKeeper shim — D-089(2)
|
||||||
|
// reverse edge); mismatch -> auto-Still via StillKeeper + REJECT. The
|
||||||
|
// MsgWitnessMABProceedsRelease handler requires Watcher quorum (6-of-9)
|
||||||
|
// before the tagged proceeds move from staging to the reserve.
|
||||||
|
//
|
||||||
|
// Lexicon (REQ-012, A-210): "Mutual Aid Bond", "MAB", "Cover Call", "coupon",
|
||||||
|
// "use-of-proceeds", "reserve build-out" are clean. The CouponDenomBread
|
||||||
|
// const VALUE is "Bread" (the OY unit, not a banned term — clean). The
|
||||||
|
// banned coupon-synonyms are NEVER used.
|
||||||
|
|
||||||
|
// CouponDenom enumerates the three coupon denominations a MAB may carry
|
||||||
|
// (vision §17, REQ-054). Two are valid (CoverCall, MutualAidCredit); the
|
||||||
|
// third — Bread — exists ONLY to be rejected at ValidateMAB with
|
||||||
|
// "FR-MAB-3: MAB coupons NEVER Bread" (the dual-firewall runtime gate
|
||||||
|
// mirroring MissionLockAmendmentRejected at x/council/types/types.go:242).
|
||||||
|
// The enum value EXISTS to document in code that MAB coupons are NEVER Bread;
|
||||||
|
// the ValidateMAB gate rejects it; the locked-const test asserts the count.
|
||||||
|
type CouponDenom string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// CouponDenomCoverCall is the Cover-Call coupon denomination (a MAB
|
||||||
|
// whose coupon is settled in Cover-Call units — the primary MAB kind).
|
||||||
|
CouponDenomCoverCall CouponDenom = "CoverCall"
|
||||||
|
// CouponDenomMutualAidCredit is the Mutual-Aid-Credit coupon
|
||||||
|
// denomination (a MAB whose coupon is settled in mutual-aid credit
|
||||||
|
// units — the secondary MAB kind).
|
||||||
|
CouponDenomMutualAidCredit CouponDenom = "MutualAidCredit"
|
||||||
|
// CouponDenomBread is the REJECTED sentinel coupon denomination
|
||||||
|
// (FR-MAB-3 — MAB coupons NEVER Bread). The enum value EXISTS to
|
||||||
|
// document in code that MAB coupons are NEVER Bread; the ValidateMAB
|
||||||
|
// gate rejects any MAB with this CouponKind. The const VALUE "Bread"
|
||||||
|
// is the OY unit (clean — not a banned term). Mirrors
|
||||||
|
// ProposalMissionLockAmendmentRejected at x/council/types/types.go:242.
|
||||||
|
CouponDenomBread CouponDenom = "Bread"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AllCouponDenoms returns all three CouponDenom values in REQ-054 order. The
|
||||||
|
// locked-const test asserts exactly 3 entries (the regression firewall).
|
||||||
|
func AllCouponDenoms() []CouponDenom {
|
||||||
|
return []CouponDenom{
|
||||||
|
CouponDenomCoverCall,
|
||||||
|
CouponDenomMutualAidCredit,
|
||||||
|
CouponDenomBread,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MAB is a Mutual Aid Bond: a mission-locked bond a Cover Pool issues to
|
||||||
|
// build out its reserve (vision §17, REQ-054, D-080, D-089(2)). It embeds
|
||||||
|
// the v0.2 Bond (anonymous field) so it carries all Bond fields (bond-id,
|
||||||
|
// issuer-stand-id, principal-grain, coupon-bps, term-days, issued-at,
|
||||||
|
// maturity, status) PLUS a CouponKind (the coupon denomination), an
|
||||||
|
// AnnualSurplusAtIssuance (the pool's annual surplus at issuance, used for
|
||||||
|
// the 3× ceiling check), and a UseOfProceedsTag (D-080 — locked to
|
||||||
|
// "reserve_build_out"). The coupon rate is clamped to [CouponFloorBps,
|
||||||
|
// CouponCapBps] via Clamp at issuance (the 8%/0% consts D-028 apply).
|
||||||
|
//
|
||||||
|
// pool-id is NOT a field on MAB (the MAB is issued by a Stand for a pool;
|
||||||
|
// the pool binding is via the CoverKeeper.GetPoolReserveAccount reverse
|
||||||
|
// edge — D-089(2)). The MsgDebitMABProceeds handler queries the CoverKeeper
|
||||||
|
// for the pool's ReserveAccount by the MAB's PoolID (carried on the msg,
|
||||||
|
// not the MAB struct — the MAB struct mirrors GrowthBond's anonymous-embed
|
||||||
|
// pattern + the MAB-specific fields only).
|
||||||
|
type MAB struct {
|
||||||
|
Bond // anonymous embed — carries all v0.2 Bond fields
|
||||||
|
CouponKind CouponDenom `json:"coupon_kind" yaml:"coupon_kind"`
|
||||||
|
AnnualSurplusAtIssuance int64 `json:"annual_surplus_at_issuance" yaml:"annual_surplus_at_issuance"`
|
||||||
|
UseOfProceedsTag string `json:"use_of_proceeds_tag" yaml:"use_of_proceeds_tag"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueMAB is the MAB issuance stub (REQ-054, D-080). It constructs a MAB
|
||||||
|
// with the coupon clamped to [CouponFloorBps, CouponCapBps] via Clamp, the
|
||||||
|
// CouponKind set, and the UseOfProceedsTag locked to
|
||||||
|
// MABUseOfProceedsReserveBuildOut. The returned MAB has status BondIssued
|
||||||
|
// (inherited from Issue's Bond construction). The stub does not persist or
|
||||||
|
// enforce the 3× annual surplus ceiling (that is a keeper-handler concern);
|
||||||
|
// it only enforces the coupon clamp invariant at construction time.
|
||||||
|
func IssueMAB(bondID, issuerStandID string, principalGrain int64, couponBps uint32, couponKind CouponDenom, annualSurplusAtIssuance int64, termDays uint32, issuedAt, maturity int64) MAB {
|
||||||
|
clampedCoupon := Clamp(couponBps)
|
||||||
|
return MAB{
|
||||||
|
Bond: Issue(bondID, issuerStandID, principalGrain, clampedCoupon, termDays, issuedAt, maturity),
|
||||||
|
CouponKind: couponKind,
|
||||||
|
AnnualSurplusAtIssuance: annualSurplusAtIssuance,
|
||||||
|
UseOfProceedsTag: MABUseOfProceedsReserveBuildOut,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateMAB is the MAB runtime firewall (REQ-054, FR-MAB-3). It rejects a
|
||||||
|
// MAB whose CouponKind == CouponDenomBread with "FR-MAB-3: MAB coupons
|
||||||
|
// NEVER Bread" — the dual-firewall runtime gate mirroring
|
||||||
|
// MissionLockAmendmentRejected at x/council/types/types.go:242. The
|
||||||
|
// CouponDenomBread const EXISTS to document in code that MAB coupons are
|
||||||
|
// NEVER Bread; this gate rejects any MAB with that CouponKind. The
|
||||||
|
// ValidateBasic on MsgIssueMAB calls this; the keeper handler re-checks in
|
||||||
|
// defense in depth.
|
||||||
|
func ValidateMAB(m MAB) error {
|
||||||
|
if m.CouponKind == CouponDenomBread {
|
||||||
|
return fmt.Errorf("FR-MAB-3: MAB coupons NEVER Bread (CouponDenomBread is the rejected sentinel — REQ-054 dual firewall)")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// SecondaryOrder is a secondary-market order on an issued bond (vision §17,
|
// SecondaryOrder is a secondary-market order on an issued bond (vision §17,
|
||||||
// REQ-026, D-041, A-313). order-id is the unique identifier. bond-id references
|
// REQ-026, D-041, A-313). order-id is the unique identifier. bond-id references
|
||||||
// a Bond (by-ID-string ref to a Bond — same package, so this is an in-package
|
// a Bond (by-ID-string ref to a Bond — same package, so this is an in-package
|
||||||
|
|||||||
@@ -962,3 +962,175 @@ func packageDir(t *testing.T, importPath string) string {
|
|||||||
rel := strings.TrimPrefix(importPath, "github.com/oy/openyield/")
|
rel := strings.TrimPrefix(importPath, "github.com/oy/openyield/")
|
||||||
return filepath.Join(repoRoot, rel)
|
return filepath.Join(repoRoot, rel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- v0.7 P4: MAB locked consts + ValidateMAB + IssueMAB (REQ-054) -----------
|
||||||
|
//
|
||||||
|
// The MAB locked-const + ValidateMAB + IssueMAB regression tests (REQ-054,
|
||||||
|
// FR-MAB-3, D-080). A regression here is a mission-lock breach.
|
||||||
|
|
||||||
|
// TestMABIssuanceCeilingAnnualSurplusMultiple asserts the 3× annual surplus
|
||||||
|
// ceiling multiple is the locked 3 (REQ-054 locked — vision §17 3× annual
|
||||||
|
// surplus mission-locked ceiling).
|
||||||
|
func TestMABIssuanceCeilingAnnualSurplusMultiple(t *testing.T) {
|
||||||
|
if btypes.MABIssuanceCeilingAnnualSurplusMultiple != 3 {
|
||||||
|
t.Errorf("MABIssuanceCeilingAnnualSurplusMultiple = %d, want 3 (REQ-054 locked — 3× annual surplus ceiling)", btypes.MABIssuanceCeilingAnnualSurplusMultiple)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMABUseOfProceedsReserveBuildOut asserts the D-080 tagged-streaming
|
||||||
|
// use-of-proceeds tag is "reserve_build_out".
|
||||||
|
func TestMABUseOfProceedsReserveBuildOut(t *testing.T) {
|
||||||
|
if btypes.MABUseOfProceedsReserveBuildOut != "reserve_build_out" {
|
||||||
|
t.Errorf("MABUseOfProceedsReserveBuildOut = %q, want %q (D-080 tagged-streaming use-of-proceeds)", btypes.MABUseOfProceedsReserveBuildOut, "reserve_build_out")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCouponDenomCount asserts CouponDenomCount == 3 (the regression
|
||||||
|
// firewall — the three CouponDenom values are CoverCall, MutualAidCredit,
|
||||||
|
// Bread).
|
||||||
|
func TestCouponDenomCount(t *testing.T) {
|
||||||
|
if btypes.CouponDenomCount != 3 {
|
||||||
|
t.Errorf("CouponDenomCount = %d, want 3 (REQ-054 — CoverCall + MutualAidCredit + Bread)", btypes.CouponDenomCount)
|
||||||
|
}
|
||||||
|
if len(btypes.AllCouponDenoms()) != 3 {
|
||||||
|
t.Errorf("AllCouponDenoms len = %d, want 3", len(btypes.AllCouponDenoms()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCouponDenomValues asserts the three CouponDenom string values.
|
||||||
|
func TestCouponDenomValues(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
d btypes.CouponDenom
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{btypes.CouponDenomCoverCall, "CoverCall"},
|
||||||
|
{btypes.CouponDenomMutualAidCredit, "MutualAidCredit"},
|
||||||
|
{btypes.CouponDenomBread, "Bread"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if string(c.d) != c.want {
|
||||||
|
t.Errorf("CouponDenom(%q) value = %q, want %q", c.d, c.d, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateMABRejectsBread asserts ValidateMAB rejects CouponDenomBread
|
||||||
|
// with "FR-MAB-3" (the dual-firewall runtime gate mirroring
|
||||||
|
// MissionLockAmendmentRejected).
|
||||||
|
func TestValidateMABRejectsBread(t *testing.T) {
|
||||||
|
m := btypes.MAB{CouponKind: btypes.CouponDenomBread}
|
||||||
|
err := btypes.ValidateMAB(m)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ValidateMAB on CouponDenomBread should be REJECTED (FR-MAB-3)")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "FR-MAB-3") {
|
||||||
|
t.Errorf("err = %q, want 'FR-MAB-3'", err.Error())
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "NEVER Bread") {
|
||||||
|
t.Errorf("err = %q, want 'NEVER Bread'", err.Error())
|
||||||
|
}
|
||||||
|
// A valid CouponKind passes.
|
||||||
|
if err := btypes.ValidateMAB(btypes.MAB{CouponKind: btypes.CouponDenomCoverCall}); err != nil {
|
||||||
|
t.Errorf("ValidateMAB on CouponDenomCoverCall should pass; got: %v", err)
|
||||||
|
}
|
||||||
|
if err := btypes.ValidateMAB(btypes.MAB{CouponKind: btypes.CouponDenomMutualAidCredit}); err != nil {
|
||||||
|
t.Errorf("ValidateMAB on CouponDenomMutualAidCredit should pass; got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIssueMABClampsCoupon asserts IssueMAB clamps the coupon to
|
||||||
|
// [CouponFloorBps, CouponCapBps] (the cross-const test extending REQ-030 —
|
||||||
|
// the MAB coupon cap == CouponCapBps).
|
||||||
|
func TestIssueMABClampsCoupon(t *testing.T) {
|
||||||
|
// In-band coupon: unchanged.
|
||||||
|
m := btypes.IssueMAB("mab-1", "stand-1", 1_000_000, 500, btypes.CouponDenomCoverCall, 5_000_000, 365, 1000, 1365)
|
||||||
|
if m.CouponBps != 500 {
|
||||||
|
t.Errorf("in-band CouponBps = %d, want 500 (unchanged)", m.CouponBps)
|
||||||
|
}
|
||||||
|
if m.CouponKind != btypes.CouponDenomCoverCall {
|
||||||
|
t.Errorf("CouponKind = %q, want CoverCall", m.CouponKind)
|
||||||
|
}
|
||||||
|
if m.UseOfProceedsTag != btypes.MABUseOfProceedsReserveBuildOut {
|
||||||
|
t.Errorf("UseOfProceedsTag = %q, want %q (D-080 lock)", m.UseOfProceedsTag, btypes.MABUseOfProceedsReserveBuildOut)
|
||||||
|
}
|
||||||
|
if m.Status != btypes.BondIssued {
|
||||||
|
t.Errorf("Status = %q, want BondIssued", m.Status)
|
||||||
|
}
|
||||||
|
// Above-cap coupon: clamped to cap.
|
||||||
|
m2 := btypes.IssueMAB("mab-2", "stand-1", 1_000_000, 1200, btypes.CouponDenomMutualAidCredit, 5_000_000, 365, 1000, 1365)
|
||||||
|
if m2.CouponBps != btypes.CouponCapBps {
|
||||||
|
t.Errorf("above-cap CouponBps = %d, want cap %d (IssueMAB must clamp)", m2.CouponBps, btypes.CouponCapBps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateMABsRejectsBreadAtGenesis asserts ValidateMABs rejects a
|
||||||
|
// genesis MAB with CouponDenomBread (FR-MAB-3 at genesis load).
|
||||||
|
func TestValidateMABsRejectsBreadAtGenesis(t *testing.T) {
|
||||||
|
mabs := []btypes.MAB{
|
||||||
|
{Bond: btypes.Bond{BondID: "mab-1", Status: btypes.BondIssued, CouponBps: 500}, CouponKind: btypes.CouponDenomCoverCall, UseOfProceedsTag: btypes.MABUseOfProceedsReserveBuildOut},
|
||||||
|
{Bond: btypes.Bond{BondID: "mab-bad", Status: btypes.BondIssued, CouponBps: 500}, CouponKind: btypes.CouponDenomBread, UseOfProceedsTag: btypes.MABUseOfProceedsReserveBuildOut},
|
||||||
|
}
|
||||||
|
err := btypes.ValidateMABs(mabs)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ValidateMABs with CouponDenomBread should be REJECTED at genesis (FR-MAB-3)")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "FR-MAB-3") {
|
||||||
|
t.Errorf("err = %q, want 'FR-MAB-3'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateMABsRejectsDupIDs asserts ValidateMABs rejects duplicate
|
||||||
|
// bond-ids (A-212 ID-uniqueness at genesis load).
|
||||||
|
func TestValidateMABsRejectsDupIDs(t *testing.T) {
|
||||||
|
mabs := []btypes.MAB{
|
||||||
|
{Bond: btypes.Bond{BondID: "dup", Status: btypes.BondIssued, CouponBps: 500}, CouponKind: btypes.CouponDenomCoverCall, UseOfProceedsTag: btypes.MABUseOfProceedsReserveBuildOut},
|
||||||
|
{Bond: btypes.Bond{BondID: "dup", Status: btypes.BondIssued, CouponBps: 500}, CouponKind: btypes.CouponDenomMutualAidCredit, UseOfProceedsTag: btypes.MABUseOfProceedsReserveBuildOut},
|
||||||
|
}
|
||||||
|
if err := btypes.ValidateMABs(mabs); err == nil {
|
||||||
|
t.Fatal("ValidateMABs with duplicate bond-ids should be REJECTED")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateMABsAcceptsClean asserts ValidateMABs accepts a clean set.
|
||||||
|
func TestValidateMABsAcceptsClean(t *testing.T) {
|
||||||
|
mabs := []btypes.MAB{
|
||||||
|
{Bond: btypes.Bond{BondID: "m1", Status: btypes.BondIssued, CouponBps: 500}, CouponKind: btypes.CouponDenomCoverCall, UseOfProceedsTag: btypes.MABUseOfProceedsReserveBuildOut},
|
||||||
|
{Bond: btypes.Bond{BondID: "m2", Status: btypes.BondActive, CouponBps: 600}, CouponKind: btypes.CouponDenomMutualAidCredit, UseOfProceedsTag: btypes.MABUseOfProceedsReserveBuildOut},
|
||||||
|
}
|
||||||
|
if err := btypes.ValidateMABs(mabs); err != nil {
|
||||||
|
t.Errorf("ValidateMABs should accept clean set; got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMABStructFields asserts the MAB struct carries the anonymous Bond
|
||||||
|
// embed + the MAB-specific fields (CouponKind + AnnualSurplusAtIssuance +
|
||||||
|
// UseOfProceedsTag).
|
||||||
|
func TestMABStructFields(t *testing.T) {
|
||||||
|
m := btypes.MAB{
|
||||||
|
Bond: btypes.Bond{BondID: "mab-x", IssuerStandID: "stand-1", PrincipalGrain: 1_000_000, CouponBps: 500, Status: btypes.BondIssued},
|
||||||
|
CouponKind: btypes.CouponDenomCoverCall,
|
||||||
|
AnnualSurplusAtIssuance: 5_000_000,
|
||||||
|
UseOfProceedsTag: btypes.MABUseOfProceedsReserveBuildOut,
|
||||||
|
}
|
||||||
|
if m.BondID != "mab-x" {
|
||||||
|
t.Errorf("MAB.BondID = %q (anonymous embed access)", m.BondID)
|
||||||
|
}
|
||||||
|
if m.CouponKind != btypes.CouponDenomCoverCall {
|
||||||
|
t.Errorf("MAB.CouponKind = %q", m.CouponKind)
|
||||||
|
}
|
||||||
|
if m.AnnualSurplusAtIssuance != 5_000_000 {
|
||||||
|
t.Errorf("MAB.AnnualSurplusAtIssuance = %d", m.AnnualSurplusAtIssuance)
|
||||||
|
}
|
||||||
|
if m.UseOfProceedsTag != btypes.MABUseOfProceedsReserveBuildOut {
|
||||||
|
t.Errorf("MAB.UseOfProceedsTag = %q", m.UseOfProceedsTag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGenesisStateMABsField asserts DefaultGenesisState returns a non-nil
|
||||||
|
// empty slice for MABs (the v0.7 P4 genesis extension).
|
||||||
|
func TestGenesisStateMABsField(t *testing.T) {
|
||||||
|
gs := btypes.DefaultGenesisState()
|
||||||
|
if gs.MABs == nil || len(gs.MABs) != 0 {
|
||||||
|
t.Errorf("Default MABs should be non-nil empty slice; got len=%d nil=%v", len(gs.MABs), gs.MABs == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+277
-1
@@ -43,6 +43,7 @@ type Keeper struct {
|
|||||||
watcherKeeper types.WatcherKeeper
|
watcherKeeper types.WatcherKeeper
|
||||||
bondKeeper types.BondKeeper
|
bondKeeper types.BondKeeper
|
||||||
stillKeeper types.StillKeeper
|
stillKeeper types.StillKeeper
|
||||||
|
guildKeeper types.GuildKeeper
|
||||||
// paramsOverride is a simtest-grade Params override (nil = use
|
// paramsOverride is a simtest-grade Params override (nil = use
|
||||||
// DefaultParams). A future P2+ will load the Params from the params
|
// DefaultParams). A future P2+ will load the Params from the params
|
||||||
// store; for now the handler uses DefaultParams unless an override is
|
// store; for now the handler uses DefaultParams unless an override is
|
||||||
@@ -58,7 +59,8 @@ type Keeper struct {
|
|||||||
// the simtest wiring documents this). The StandingKeeper gates the launch
|
// the simtest wiring documents this). The StandingKeeper gates the launch
|
||||||
// (D-077); the WatcherKeeper attests the launch (REQ-046); the BondKeeper
|
// (D-077); the WatcherKeeper attests the launch (REQ-046); the BondKeeper
|
||||||
// is held for P4 (the P1 handlers do not call it); the StillKeeper records
|
// is held for P4 (the P1 handlers do not call it); the StillKeeper records
|
||||||
// the below-floor auto-pause (D-089(1)).
|
// the below-floor auto-pause (D-089(1)). The P5 GuildKeeper verifies a
|
||||||
|
// Guild exists on Pier selection (REQ-066; a nil shim skips the check).
|
||||||
func NewKeeper(cdc codec.Codec, storeKey storetypes.StoreKey, sk types.StandingKeeper, wk types.WatcherKeeper, bk types.BondKeeper, stK types.StillKeeper) Keeper {
|
func NewKeeper(cdc codec.Codec, storeKey storetypes.StoreKey, sk types.StandingKeeper, wk types.WatcherKeeper, bk types.BondKeeper, stK types.StillKeeper) Keeper {
|
||||||
return Keeper{
|
return Keeper{
|
||||||
cdc: cdc,
|
cdc: cdc,
|
||||||
@@ -83,6 +85,11 @@ func (k *Keeper) SetBondKeeper(bk types.BondKeeper) { k.bondKeeper = bk }
|
|||||||
// SetStillKeeper sets the StillKeeper expected-keeper shim.
|
// SetStillKeeper sets the StillKeeper expected-keeper shim.
|
||||||
func (k *Keeper) SetStillKeeper(stK types.StillKeeper) { k.stillKeeper = stK }
|
func (k *Keeper) SetStillKeeper(stK types.StillKeeper) { k.stillKeeper = stK }
|
||||||
|
|
||||||
|
// SetGuildKeeper sets the GuildKeeper expected-keeper shim (P5 — REQ-066
|
||||||
|
// Pier Selection uses this to verify the selecting Guild exists; a nil
|
||||||
|
// shim skips the existence check, simtest wiring).
|
||||||
|
func (k *Keeper) SetGuildKeeper(gk types.GuildKeeper) { k.guildKeeper = gk }
|
||||||
|
|
||||||
// SetParamsOverride sets a simtest-grade Params override (nil = use
|
// SetParamsOverride sets a simtest-grade Params override (nil = use
|
||||||
// DefaultParams). The D-086 simtest case (f) uses this to restrict
|
// DefaultParams). The D-086 simtest case (f) uses this to restrict
|
||||||
// FactoryAllowedPhases to [Phase2, Phase3] only and reject a Phase4
|
// FactoryAllowedPhases to [Phase2, Phase3] only and reject a Phase4
|
||||||
@@ -203,6 +210,84 @@ func (k Keeper) AllCoverCalls(ctx sdk.Context) []types.CoverCall {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- P4: CoverClaimsVoucher store (REQ-055, D-090(2)) ------------------------
|
||||||
|
//
|
||||||
|
// The Voucher store is keyed by voucher-reach-id + pool-id (composite key)
|
||||||
|
// -> CoverClaimsVoucher. A Voucher is registered per-Pool; the composite key
|
||||||
|
// enforces idempotency (no duplicate Voucher for the same Pool). The
|
||||||
|
// GetAvgCallSize helper computes the average Cover Call amount for a Pool
|
||||||
|
// from the call/ store (returns 0 if no Calls — the D-090(2) cold-start
|
||||||
|
// case).
|
||||||
|
|
||||||
|
var voucherKeyPrefix = []byte("voucher/")
|
||||||
|
|
||||||
|
func voucherKey(voucherReachID, poolID string) []byte {
|
||||||
|
return append(append(voucherKeyPrefix, []byte(voucherReachID)...), []byte("/"+poolID)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCoverClaimsVoucher loads a CoverClaimsVoucher by voucher-reach-id +
|
||||||
|
// pool-id. Returns the Voucher and true if found, or zero value + false if
|
||||||
|
// not.
|
||||||
|
func (k Keeper) GetCoverClaimsVoucher(ctx sdk.Context, voucherReachID, poolID string) (types.CoverClaimsVoucher, bool) {
|
||||||
|
store := ctx.KVStore(k.storeKey)
|
||||||
|
bz := store.Get(voucherKey(voucherReachID, poolID))
|
||||||
|
if bz == nil {
|
||||||
|
return types.CoverClaimsVoucher{}, false
|
||||||
|
}
|
||||||
|
var v types.CoverClaimsVoucher
|
||||||
|
if err := json.Unmarshal(bz, &v); err != nil {
|
||||||
|
return types.CoverClaimsVoucher{}, false
|
||||||
|
}
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCoverClaimsVoucher persists a CoverClaimsVoucher by voucher-reach-id +
|
||||||
|
// pool-id.
|
||||||
|
func (k Keeper) SetCoverClaimsVoucher(ctx sdk.Context, v types.CoverClaimsVoucher) {
|
||||||
|
store := ctx.KVStore(k.storeKey)
|
||||||
|
bz, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("cover: marshal voucher %q/%q: %v", v.VoucherReachID, v.PoolID, err))
|
||||||
|
}
|
||||||
|
store.Set(voucherKey(v.VoucherReachID, v.PoolID), bz)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllCoverClaimsVouchers returns all persisted CoverClaimsVoucher records
|
||||||
|
// (iteration helper, unordered).
|
||||||
|
func (k Keeper) AllCoverClaimsVouchers(ctx sdk.Context) []types.CoverClaimsVoucher {
|
||||||
|
store := ctx.KVStore(k.storeKey)
|
||||||
|
iterator := store.Iterator(voucherKeyPrefix, prefixEnd(voucherKeyPrefix))
|
||||||
|
defer iterator.Close()
|
||||||
|
out := []types.CoverClaimsVoucher{}
|
||||||
|
for ; iterator.Valid(); iterator.Next() {
|
||||||
|
var v types.CoverClaimsVoucher
|
||||||
|
if err := json.Unmarshal(iterator.Value(), &v); err == nil {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAvgCallSize computes the average Cover Call amount (Grain) for a Pool
|
||||||
|
// from the call/ store (REQ-055, D-090(2)). Returns 0 if no Calls have been
|
||||||
|
// filed for the Pool — the D-090(2) cold-start case (the Voucher bond falls
|
||||||
|
// back to MinimumVoucherBond, NOT zero).
|
||||||
|
func (k Keeper) GetAvgCallSize(ctx sdk.Context, poolID string) int64 {
|
||||||
|
calls := k.AllCoverCalls(ctx)
|
||||||
|
sum := int64(0)
|
||||||
|
n := 0
|
||||||
|
for _, c := range calls {
|
||||||
|
if c.PoolID == poolID {
|
||||||
|
sum += c.AmountGrain
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return sum / int64(n)
|
||||||
|
}
|
||||||
|
|
||||||
// --- prefixEnd helper ---------------------------------------------------------
|
// --- prefixEnd helper ---------------------------------------------------------
|
||||||
|
|
||||||
// prefixEnd returns the key that sorts immediately after all keys sharing
|
// prefixEnd returns the key that sorts immediately after all keys sharing
|
||||||
@@ -463,3 +548,194 @@ func (k Keeper) RatifyCharterAmendment(ctx sdk.Context, amendmentID string, now
|
|||||||
k.SetCharterAmendment(ctx, a)
|
k.SetCharterAmendment(ctx, a)
|
||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- P5: Bill of Rights review + Pier Selection stores (REQ-056, REQ-066) ------
|
||||||
|
//
|
||||||
|
// (REQ-056, REQ-066). Two new stores. The bill_review/ store is keyed by
|
||||||
|
// ReviewID -> the MsgCounselReviewBillOfRights record (the handler persists
|
||||||
|
// the review on a bonded-Counsel ceremony). The pier_selection/ store is
|
||||||
|
// keyed by GuildID -> PierSelectionRecord (the MsgSelectPier handler
|
||||||
|
// persists + MsgRevokePierSelection removes). The pier_index/ store is
|
||||||
|
// keyed by PierID -> PierSelectionIndex (the mesh-maintained index; the
|
||||||
|
// MsgSelectPier handler creates or updates the entry, accumulating scores
|
||||||
|
// from successive selections). All three use the same JSON-marshal pattern
|
||||||
|
// as the P1/P2 stores.
|
||||||
|
|
||||||
|
var billReviewKeyPrefix = []byte("bill_review/")
|
||||||
|
|
||||||
|
func billReviewKey(reviewID string) []byte {
|
||||||
|
return append(billReviewKeyPrefix, []byte(reviewID)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BillOfRightsReview is the persisted record of a Counsel review of the
|
||||||
|
// Anti-Capture Bill of Rights (REQ-056 §7 acceptance ceremony). The
|
||||||
|
// MsgCounselReviewBillOfRights handler persists this in the bill_review/
|
||||||
|
// store keyed by ReviewID.
|
||||||
|
type BillOfRightsReview struct {
|
||||||
|
ReviewID string `json:"review_id" yaml:"review_id"`
|
||||||
|
CounselReachID string `json:"counsel_reach_id" yaml:"counsel_reach_id"`
|
||||||
|
Staked bool `json:"staked" yaml:"staked"`
|
||||||
|
ReviewResult string `json:"review_result" yaml:"review_result"`
|
||||||
|
ReviewedAt int64 `json:"reviewed_at" yaml:"reviewed_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBillOfRightsReview loads a BillOfRightsReview by review-id. Returns
|
||||||
|
// the review and true if found, or zero value + false if not.
|
||||||
|
func (k Keeper) GetBillOfRightsReview(ctx sdk.Context, reviewID string) (BillOfRightsReview, bool) {
|
||||||
|
store := ctx.KVStore(k.storeKey)
|
||||||
|
bz := store.Get(billReviewKey(reviewID))
|
||||||
|
if bz == nil {
|
||||||
|
return BillOfRightsReview{}, false
|
||||||
|
}
|
||||||
|
var r BillOfRightsReview
|
||||||
|
if err := json.Unmarshal(bz, &r); err != nil {
|
||||||
|
return BillOfRightsReview{}, false
|
||||||
|
}
|
||||||
|
return r, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetBillOfRightsReview persists a BillOfRightsReview by review-id.
|
||||||
|
func (k Keeper) SetBillOfRightsReview(ctx sdk.Context, r BillOfRightsReview) {
|
||||||
|
store := ctx.KVStore(k.storeKey)
|
||||||
|
bz, err := json.Marshal(r)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("cover: marshal bill-of-rights review %q: %v", r.ReviewID, err))
|
||||||
|
}
|
||||||
|
store.Set(billReviewKey(r.ReviewID), bz)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllBillOfRightsReviews returns all persisted BillOfRightsReview records
|
||||||
|
// (iteration helper, unordered).
|
||||||
|
func (k Keeper) AllBillOfRightsReviews(ctx sdk.Context) []BillOfRightsReview {
|
||||||
|
store := ctx.KVStore(k.storeKey)
|
||||||
|
iterator := store.Iterator(billReviewKeyPrefix, prefixEnd(billReviewKeyPrefix))
|
||||||
|
defer iterator.Close()
|
||||||
|
out := []BillOfRightsReview{}
|
||||||
|
for ; iterator.Valid(); iterator.Next() {
|
||||||
|
var r BillOfRightsReview
|
||||||
|
if err := json.Unmarshal(iterator.Value(), &r); err == nil {
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
var pierSelectionKeyPrefix = []byte("pier_selection/")
|
||||||
|
|
||||||
|
func pierSelectionKey(guildID string) []byte {
|
||||||
|
return append(pierSelectionKeyPrefix, []byte(guildID)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPierSelectionRecord loads a PierSelectionRecord by guild-id. Returns
|
||||||
|
// the record and true if found, or zero value + false if not.
|
||||||
|
func (k Keeper) GetPierSelectionRecord(ctx sdk.Context, guildID string) (types.PierSelectionRecord, bool) {
|
||||||
|
store := ctx.KVStore(k.storeKey)
|
||||||
|
bz := store.Get(pierSelectionKey(guildID))
|
||||||
|
if bz == nil {
|
||||||
|
return types.PierSelectionRecord{}, false
|
||||||
|
}
|
||||||
|
var r types.PierSelectionRecord
|
||||||
|
if err := json.Unmarshal(bz, &r); err != nil {
|
||||||
|
return types.PierSelectionRecord{}, false
|
||||||
|
}
|
||||||
|
return r, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPierSelectionRecord persists a PierSelectionRecord by guild-id.
|
||||||
|
func (k Keeper) SetPierSelectionRecord(ctx sdk.Context, r types.PierSelectionRecord) {
|
||||||
|
store := ctx.KVStore(k.storeKey)
|
||||||
|
bz, err := json.Marshal(r)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("cover: marshal pier selection for guild %q: %v", r.GuildID, err))
|
||||||
|
}
|
||||||
|
store.Set(pierSelectionKey(r.GuildID), bz)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemovePierSelectionRecord removes a PierSelectionRecord by guild-id (the
|
||||||
|
// MsgRevokePierSelection handler calls this). Returns true if a record was
|
||||||
|
// removed, false if no record existed.
|
||||||
|
func (k Keeper) RemovePierSelectionRecord(ctx sdk.Context, guildID string) bool {
|
||||||
|
store := ctx.KVStore(k.storeKey)
|
||||||
|
key := pierSelectionKey(guildID)
|
||||||
|
if store.Get(key) == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
store.Delete(key)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var pierIndexKeyPrefix = []byte("pier_index/")
|
||||||
|
|
||||||
|
func pierIndexKey(pierID string) []byte {
|
||||||
|
return append(pierIndexKeyPrefix, []byte(pierID)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPierSelectionIndex loads a PierSelectionIndex by pier-id (REQ-066 —
|
||||||
|
// the mesh-maintained index query). Returns the index and true if found,
|
||||||
|
// or zero value + false if not.
|
||||||
|
func (k Keeper) GetPierSelectionIndex(ctx sdk.Context, pierID string) (types.PierSelectionIndex, bool) {
|
||||||
|
store := ctx.KVStore(k.storeKey)
|
||||||
|
bz := store.Get(pierIndexKey(pierID))
|
||||||
|
if bz == nil {
|
||||||
|
return types.PierSelectionIndex{}, false
|
||||||
|
}
|
||||||
|
var idx types.PierSelectionIndex
|
||||||
|
if err := json.Unmarshal(bz, &idx); err != nil {
|
||||||
|
return types.PierSelectionIndex{}, false
|
||||||
|
}
|
||||||
|
return idx, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPierSelectionIndex persists a PierSelectionIndex by pier-id.
|
||||||
|
func (k Keeper) SetPierSelectionIndex(ctx sdk.Context, idx types.PierSelectionIndex) {
|
||||||
|
store := ctx.KVStore(k.storeKey)
|
||||||
|
bz, err := json.Marshal(idx)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("cover: marshal pier selection index for pier %q: %v", idx.PierID, err))
|
||||||
|
}
|
||||||
|
store.Set(pierIndexKey(idx.PierID), bz)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllPierSelectionIndexes returns all persisted PierSelectionIndex records
|
||||||
|
// (iteration helper, unordered).
|
||||||
|
func (k Keeper) AllPierSelectionIndexes(ctx sdk.Context) []types.PierSelectionIndex {
|
||||||
|
store := ctx.KVStore(k.storeKey)
|
||||||
|
iterator := store.Iterator(pierIndexKeyPrefix, prefixEnd(pierIndexKeyPrefix))
|
||||||
|
defer iterator.Close()
|
||||||
|
out := []types.PierSelectionIndex{}
|
||||||
|
for ; iterator.Valid(); iterator.Next() {
|
||||||
|
var idx types.PierSelectionIndex
|
||||||
|
if err := json.Unmarshal(iterator.Value(), &idx); err == nil {
|
||||||
|
out = append(out, idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultPierScores are the simtest-grade default scores for a fresh
|
||||||
|
// PierSelectionIndex entry (the mesh assigns these on a Pier's first
|
||||||
|
// selection — the live mesh oracle is a v0.8+ concern; the simtest uses
|
||||||
|
// these deterministic defaults so the index is non-empty on first
|
||||||
|
// selection). All three scores are in [0,1]; the OverallScore is the
|
||||||
|
// weighted aggregate (a deterministic blend: 0.4 × JurisdictionalReliability
|
||||||
|
// + 0.3 × IntegrationQuality + 0.3 × (FiduciaryRecordHash non-empty ? 1.0
|
||||||
|
// : 0.0)).
|
||||||
|
const (
|
||||||
|
DefaultPierJurisdictionalReliabilityScore = 0.8
|
||||||
|
DefaultPierIntegrationQualityScore = 0.7
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultPierOverallScore computes the deterministic OverallScore blend
|
||||||
|
// for a PierSelectionIndex (the keeper uses this when creating or updating
|
||||||
|
// an index entry). The blend is 0.4 × JurisdictionalReliability + 0.3 ×
|
||||||
|
// IntegrationQuality + 0.3 × FiduciaryConfidence (FiduciaryConfidence is
|
||||||
|
// 1.0 if the FiduciaryRecordHash is non-empty, else 0.0). The simtest
|
||||||
|
// asserts the OverallScore is non-decreasing on successive selections (a
|
||||||
|
// second selection with the same scores yields the same OverallScore).
|
||||||
|
func DefaultPierOverallScore(jurisdictionalReliability, integrationQuality float64, fiduciaryRecordHash []byte) float64 {
|
||||||
|
fiduciaryConfidence := 0.0
|
||||||
|
if len(fiduciaryRecordHash) > 0 {
|
||||||
|
fiduciaryConfidence = 1.0
|
||||||
|
}
|
||||||
|
return 0.4*jurisdictionalReliability + 0.3*integrationQuality + 0.3*fiduciaryConfidence
|
||||||
|
}
|
||||||
|
|||||||
@@ -704,3 +704,398 @@ func (s msgServer) EscalateReserveCeiling(ctx interface{}, msg *types.MsgEscalat
|
|||||||
))
|
))
|
||||||
return &types.MsgEscalateReserveCeilingResponse{}, nil
|
return &types.MsgEscalateReserveCeilingResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- v0.7 P4: Voucher + Dissolution handlers (REQ-055, REQ-063, D-090(2)) ------
|
||||||
|
//
|
||||||
|
// (Cover Claims Voucher registration + Cover Call adjudication + Voucher
|
||||||
|
// slash + Pool dissolution waterfall). The four handlers exercise the
|
||||||
|
// D-090(2) cold-start bond fallback, the FR-CPCV-2 no-self-adjudication
|
||||||
|
// gate, the cross-Pool slash via StandingKeeper.RecordSlash, and the
|
||||||
|
// FR-MAB-4 seniority chain (Cover-Fee contributors > MAB > Bread holders).
|
||||||
|
|
||||||
|
// RegisterCoverClaimsVoucher registers a Cover Claims Voucher for a Pool
|
||||||
|
// (REQ-055, D-090(2)). The handler enforces:
|
||||||
|
// 1. ValidateBasic (stateless).
|
||||||
|
// 2. The referenced Pool must exist.
|
||||||
|
// 3. Idempotency: no duplicate Voucher for the same VoucherReachID +
|
||||||
|
// PoolID (a Voucher is registered per-Pool; a second registration for
|
||||||
|
// the same composite key is REJECTED).
|
||||||
|
// 4. Compute bond: max(CoverClaimsVoucherBondMultipleAvgCall ×
|
||||||
|
// GetAvgCallSize(poolID), Params.MinimumVoucherBond). D-090(2) cold-
|
||||||
|
// start: when no Calls exist, GetAvgCallSize returns 0 -> bond =
|
||||||
|
// MinimumVoucherBond (NOT zero).
|
||||||
|
// 5. Persist the Voucher + emit cover.voucher_registered.
|
||||||
|
func (s msgServer) RegisterCoverClaimsVoucher(ctx interface{}, msg *types.MsgRegisterCoverClaimsVoucher) (*types.MsgRegisterCoverClaimsVoucherResponse, error) {
|
||||||
|
if err := msg.ValidateBasic(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sdkCtx := unwrapCtx(ctx)
|
||||||
|
|
||||||
|
// The referenced Pool must exist.
|
||||||
|
if _, ok := s.Keeper.GetCoverPool(sdkCtx, msg.PoolID); !ok {
|
||||||
|
return nil, fmt.Errorf("cover: pool %q not found (RegisterCoverClaimsVoucher rejected)", msg.PoolID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotency: no duplicate Voucher for the same VoucherReachID + PoolID.
|
||||||
|
if _, ok := s.Keeper.GetCoverClaimsVoucher(sdkCtx, msg.VoucherReachID, msg.PoolID); ok {
|
||||||
|
return nil, fmt.Errorf("cover: voucher %q already registered for pool %q (RegisterCoverClaimsVoucher rejected)", msg.VoucherReachID, msg.PoolID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// D-090(2) bond computation: max(multiple × avgCallSize,
|
||||||
|
// MinimumVoucherBond). When no Calls exist, avgCallSize = 0 -> bond =
|
||||||
|
// MinimumVoucherBond (NOT zero — the cold-start fix).
|
||||||
|
avgCallSize := s.Keeper.GetAvgCallSize(sdkCtx, msg.PoolID)
|
||||||
|
multipleBond := int64(types.CoverClaimsVoucherBondMultipleAvgCall) * avgCallSize
|
||||||
|
minBond := s.Keeper.Params().MinimumVoucherBond
|
||||||
|
bond := multipleBond
|
||||||
|
if bond < minBond {
|
||||||
|
bond = minBond
|
||||||
|
}
|
||||||
|
|
||||||
|
v := types.CoverClaimsVoucher{
|
||||||
|
VoucherReachID: msg.VoucherReachID,
|
||||||
|
PoolID: msg.PoolID,
|
||||||
|
BondAmount: bond,
|
||||||
|
BondMultipleAvgCall: types.CoverClaimsVoucherBondMultipleAvgCall,
|
||||||
|
RegisteredAt: sdkCtx.BlockTime().Unix(),
|
||||||
|
}
|
||||||
|
s.Keeper.SetCoverClaimsVoucher(sdkCtx, v)
|
||||||
|
|
||||||
|
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||||
|
"cover.voucher_registered",
|
||||||
|
sdk.NewAttribute("voucher_reach_id", msg.VoucherReachID),
|
||||||
|
sdk.NewAttribute("pool_id", msg.PoolID),
|
||||||
|
sdk.NewAttribute("bond_amount", fmt.Sprintf("%d", bond)),
|
||||||
|
sdk.NewAttribute("avg_call_size", fmt.Sprintf("%d", avgCallSize)),
|
||||||
|
))
|
||||||
|
return &types.MsgRegisterCoverClaimsVoucherResponse{BondAmount: bond}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdjudicateCoverCall adjudicates a Cover Call (REQ-055, FR-CPCV-2). The
|
||||||
|
// handler enforces:
|
||||||
|
// 1. ValidateBasic (stateless).
|
||||||
|
// 2. The CoverCall must exist.
|
||||||
|
// 3. FR-CPCV-2 no self-adjudication: reject if VoucherReachID ==
|
||||||
|
// CoverCall.ClaimantReachID (the Voucher cannot adjudicate their own
|
||||||
|
// Call).
|
||||||
|
// 4. The Voucher must be registered for the Call's Pool.
|
||||||
|
// 5. Record the adjudication result on the CoverCall (AdjudicationResult +
|
||||||
|
// AdjudicatedBy + AdjudicatedAt). Persist. Emit
|
||||||
|
// cover.cover_call_adjudicated.
|
||||||
|
func (s msgServer) AdjudicateCoverCall(ctx interface{}, msg *types.MsgAdjudicateCoverCall) (*types.MsgAdjudicateCoverCallResponse, error) {
|
||||||
|
if err := msg.ValidateBasic(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sdkCtx := unwrapCtx(ctx)
|
||||||
|
|
||||||
|
call, ok := s.Keeper.GetCoverCall(sdkCtx, msg.CallID)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("cover: call %q not found (AdjudicateCoverCall rejected)", msg.CallID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FR-CPCV-2 no self-adjudication: the Voucher cannot adjudicate their
|
||||||
|
// own Call.
|
||||||
|
if msg.VoucherReachID == call.ClaimantReachID {
|
||||||
|
return nil, fmt.Errorf("cover: FR-CPCV-2 no self-adjudication — voucher %q == call %q claimant %q (AdjudicateCoverCall rejected)",
|
||||||
|
msg.VoucherReachID, msg.CallID, call.ClaimantReachID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Voucher must be registered for the Call's Pool.
|
||||||
|
if _, ok := s.Keeper.GetCoverClaimsVoucher(sdkCtx, msg.VoucherReachID, call.PoolID); !ok {
|
||||||
|
return nil, fmt.Errorf("cover: voucher %q not registered for pool %q (AdjudicateCoverCall rejected)", msg.VoucherReachID, call.PoolID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record the adjudication result on the CoverCall (additive fields).
|
||||||
|
call.AdjudicationResult = msg.AdjudicationResult
|
||||||
|
call.AdjudicatedBy = msg.VoucherReachID
|
||||||
|
call.AdjudicatedAt = sdkCtx.BlockTime().Unix()
|
||||||
|
s.Keeper.SetCoverCall(sdkCtx, call)
|
||||||
|
|
||||||
|
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||||
|
"cover.cover_call_adjudicated",
|
||||||
|
sdk.NewAttribute("call_id", msg.CallID),
|
||||||
|
sdk.NewAttribute("pool_id", call.PoolID),
|
||||||
|
sdk.NewAttribute("voucher_reach_id", msg.VoucherReachID),
|
||||||
|
sdk.NewAttribute("adjudication_result", msg.AdjudicationResult),
|
||||||
|
))
|
||||||
|
return &types.MsgAdjudicateCoverCallResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SlashCoverClaimsVoucher slashes a Cover Claims Voucher for a fraudulent
|
||||||
|
// Cover Call adjudication (REQ-055). The handler enforces:
|
||||||
|
// 1. ValidateBasic (stateless — Reason must == SlashReasonFraudulentCoverCall).
|
||||||
|
// 2. The Voucher must exist (look up by VoucherReachID across all Pools —
|
||||||
|
// a Voucher may be registered for multiple Pools; the slash drops the
|
||||||
|
// Standing bucket, which is cross-Pool).
|
||||||
|
// 3. Invoke StandingKeeper.RecordSlash(voucherReachID, amount, reason,
|
||||||
|
// attester) — the slash drops the Voucher's Standing bucket (cross-Pool
|
||||||
|
// applicability — the bucket drop disqualifies them from other Pools'
|
||||||
|
// Standing gates). A nil StandingKeeper is a wiring error -> REJECT.
|
||||||
|
// 4. Emit cover.voucher_slashed.
|
||||||
|
func (s msgServer) SlashCoverClaimsVoucher(ctx interface{}, msg *types.MsgSlashCoverClaimsVoucher) (*types.MsgSlashCoverClaimsVoucherResponse, error) {
|
||||||
|
if err := msg.ValidateBasic(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sdkCtx := unwrapCtx(ctx)
|
||||||
|
|
||||||
|
// The Voucher must exist (look up by VoucherReachID across all Pools).
|
||||||
|
vouchers := s.Keeper.AllCoverClaimsVouchers(sdkCtx)
|
||||||
|
var found *types.CoverClaimsVoucher
|
||||||
|
for i := range vouchers {
|
||||||
|
if vouchers[i].VoucherReachID == msg.VoucherReachID {
|
||||||
|
found = &vouchers[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found == nil {
|
||||||
|
return nil, fmt.Errorf("cover: voucher %q not found (SlashCoverClaimsVoucher rejected)", msg.VoucherReachID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StandingKeeper.RecordSlash — the slash drops the Voucher's Standing
|
||||||
|
// bucket (cross-Pool applicability). A nil StandingKeeper is a wiring
|
||||||
|
// error -> REJECT (the slash cannot be recorded).
|
||||||
|
if s.Keeper.standingKeeper == nil {
|
||||||
|
return nil, fmt.Errorf("cover: StandingKeeper shim not wired (SlashCoverClaimsVoucher cannot record the slash — REQ-055 cross-Pool applicability)")
|
||||||
|
}
|
||||||
|
if err := s.Keeper.standingKeeper.RecordSlash(msg.VoucherReachID, float64(found.BondAmount), msg.Reason, msg.Signer); err != nil {
|
||||||
|
return nil, fmt.Errorf("cover: StandingKeeper.RecordSlash for voucher %q: %w (REQ-055)", msg.VoucherReachID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||||
|
"cover.voucher_slashed",
|
||||||
|
sdk.NewAttribute("voucher_reach_id", msg.VoucherReachID),
|
||||||
|
sdk.NewAttribute("call_id", msg.CallID),
|
||||||
|
sdk.NewAttribute("reason", msg.Reason),
|
||||||
|
sdk.NewAttribute("bond_amount", fmt.Sprintf("%d", found.BondAmount)),
|
||||||
|
))
|
||||||
|
return &types.MsgSlashCoverClaimsVoucherResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DissolveCoverPool dissolves a Cover Pool (REQ-063, FR-MAB-4). The handler
|
||||||
|
// enforces:
|
||||||
|
// 1. ValidateBasic (stateless).
|
||||||
|
// 2. The Pool must exist.
|
||||||
|
// 3. Compute the PoolDissolutionWaterfall (FR-MAB-4 seniority chain):
|
||||||
|
// Tier 1 = Cover-Fee contributors (the Pool's reserve — a simtest-grade
|
||||||
|
// placeholder amount; the real reserve balance is a v0.8+ concern),
|
||||||
|
// Tier 2 = MAB holders (query BondKeeper.GetMABsForPool for the Pool's
|
||||||
|
// outstanding MABs; sum the PrincipalGrain), Tier 3 = Bread holders
|
||||||
|
// (the remainder — simtest-grade placeholder). MAB holders have NO
|
||||||
|
// Voice in the dissolution decision (REQ-063 — the PoolCouncil from P2
|
||||||
|
// already excludes them; the waterfall only determines the payout
|
||||||
|
// order).
|
||||||
|
// 4. Emit cover.pool_dissolved with the waterfall tiers.
|
||||||
|
func (s msgServer) DissolveCoverPool(ctx interface{}, msg *types.MsgDissolveCoverPool) (*types.MsgDissolveCoverPoolResponse, error) {
|
||||||
|
if err := msg.ValidateBasic(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sdkCtx := unwrapCtx(ctx)
|
||||||
|
|
||||||
|
pool, ok := s.Keeper.GetCoverPool(sdkCtx, msg.PoolID)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("cover: pool %q not found (DissolveCoverPool rejected)", msg.PoolID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FR-MAB-4 waterfall. Tier 1 = Cover-Fee contributors (the Pool's
|
||||||
|
// reserve — simtest-grade placeholder; the real reserve balance is a
|
||||||
|
// v0.8+ concern, so we use a deterministic placeholder derived from
|
||||||
|
// the pool's ReserveAnnualContribRatio for the simtest assertion).
|
||||||
|
coverFeeContributors := int64(pool.ReserveAnnualContribRatio * 1_000_000)
|
||||||
|
|
||||||
|
// Tier 2 = MAB holders (sum the outstanding MAB principal via
|
||||||
|
// BondKeeper.GetMABsForPool). A nil BondKeeper returns an empty slice
|
||||||
|
// -> Tier 2 amount = 0.
|
||||||
|
mabHolders := int64(0)
|
||||||
|
if s.Keeper.bondKeeper != nil {
|
||||||
|
for _, m := range s.Keeper.bondKeeper.GetMABsForPool(msg.PoolID) {
|
||||||
|
mabHolders += m.PrincipalGrain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 3 = Bread holders (the remainder — simtest-grade placeholder;
|
||||||
|
// the real Bread-holder balance is a v0.8+ concern, so we use a
|
||||||
|
// deterministic placeholder for the simtest assertion).
|
||||||
|
breadHolders := coverFeeContributors / 4
|
||||||
|
|
||||||
|
waterfall := []types.PoolDissolutionWaterfall{
|
||||||
|
{Tier: types.PoolDissolutionWaterfallTierCoverFeeContributors, AmountGrain: coverFeeContributors},
|
||||||
|
{Tier: types.PoolDissolutionWaterfallTierMABHolders, AmountGrain: mabHolders},
|
||||||
|
{Tier: types.PoolDissolutionWaterfallTierBreadHolders, AmountGrain: breadHolders},
|
||||||
|
}
|
||||||
|
|
||||||
|
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||||
|
"cover.pool_dissolved",
|
||||||
|
sdk.NewAttribute("pool_id", msg.PoolID),
|
||||||
|
sdk.NewAttribute("tier_1_cover_fee_contributors", fmt.Sprintf("%d", coverFeeContributors)),
|
||||||
|
sdk.NewAttribute("tier_2_mab_holders", fmt.Sprintf("%d", mabHolders)),
|
||||||
|
sdk.NewAttribute("tier_3_bread_holders", fmt.Sprintf("%d", breadHolders)),
|
||||||
|
))
|
||||||
|
return &types.MsgDissolveCoverPoolResponse{Waterfall: waterfall}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- v0.7 P5: Bill of Rights ceremony + Pier Selection handlers (REQ-056, REQ-066) --
|
||||||
|
//
|
||||||
|
// (REQ-056 §7 acceptance ceremony, REQ-066 Pier Selection.) The three
|
||||||
|
// handlers exercise the bonded-Counsel review gate, the Pier selection
|
||||||
|
// persistence + index accumulation, and the Cover-Pool-supermajority +
|
||||||
|
// Counsel-witness revocation gate.
|
||||||
|
|
||||||
|
// CounselReviewBillOfRights records a bonded Counsel's review of the
|
||||||
|
// Anti-Capture Bill of Rights (REQ-056, vision §7, §8.2). The handler
|
||||||
|
// enforces:
|
||||||
|
// 1. ValidateBasic (stateless — includes the §7 "bonded Counsel" gate:
|
||||||
|
// Staked must be true).
|
||||||
|
// 2. Idempotency: ReviewID must not already exist.
|
||||||
|
// 3. Handler re-check of Staked (defense in depth — the §7 acceptance is
|
||||||
|
// load-bearing; the handler re-checks in case of a future
|
||||||
|
// ValidateBasic-bypass).
|
||||||
|
// 4. Persist the BillOfRightsReview (the bill_review/ store: ReviewID ->
|
||||||
|
// review record). Emit cover.bill_of_rights_reviewed.
|
||||||
|
func (s msgServer) CounselReviewBillOfRights(ctx interface{}, msg *types.MsgCounselReviewBillOfRights) (*types.MsgCounselReviewBillOfRightsResponse, error) {
|
||||||
|
if err := msg.ValidateBasic(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sdkCtx := unwrapCtx(ctx)
|
||||||
|
|
||||||
|
// Idempotency: review-id must not already exist.
|
||||||
|
if _, ok := s.Keeper.GetBillOfRightsReview(sdkCtx, msg.ReviewID); ok {
|
||||||
|
return nil, fmt.Errorf("cover: bill-of-rights review %q already exists", msg.ReviewID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler re-check of Staked (defense in depth — the §7 "bonded Counsel"
|
||||||
|
// acceptance is load-bearing; re-check in case of a future
|
||||||
|
// ValidateBasic-bypass).
|
||||||
|
if !msg.Staked {
|
||||||
|
return nil, fmt.Errorf("cover: REQ-056 §7 acceptance: handler re-check — Staked must be true (bonded Counsel)")
|
||||||
|
}
|
||||||
|
|
||||||
|
review := BillOfRightsReview{
|
||||||
|
ReviewID: msg.ReviewID,
|
||||||
|
CounselReachID: msg.CounselReachID,
|
||||||
|
Staked: msg.Staked,
|
||||||
|
ReviewResult: msg.ReviewResult,
|
||||||
|
ReviewedAt: sdkCtx.BlockTime().Unix(),
|
||||||
|
}
|
||||||
|
s.Keeper.SetBillOfRightsReview(sdkCtx, review)
|
||||||
|
|
||||||
|
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||||
|
"cover.bill_of_rights_reviewed",
|
||||||
|
sdk.NewAttribute("review_id", msg.ReviewID),
|
||||||
|
sdk.NewAttribute("counsel_reach_id", msg.CounselReachID),
|
||||||
|
sdk.NewAttribute("staked", fmt.Sprintf("%v", msg.Staked)),
|
||||||
|
sdk.NewAttribute("review_result", msg.ReviewResult),
|
||||||
|
))
|
||||||
|
return &types.MsgCounselReviewBillOfRightsResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectPier records a Guild Council's selection of a Pier at formation
|
||||||
|
// (REQ-066). The handler enforces:
|
||||||
|
// 1. ValidateBasic (stateless).
|
||||||
|
// 2. Idempotency: a PierSelectionRecord for the same GuildID must not
|
||||||
|
// already exist (a Guild selects exactly one Pier; a second selection
|
||||||
|
// is REJECTED — use MsgRevokePierSelection first).
|
||||||
|
// 3. Guild existence check via the GuildKeeper shim (a nil shim skips —
|
||||||
|
// simtest wiring; a non-nil shim with exists=false REJECTS).
|
||||||
|
// 4. Persist the PierSelectionRecord (pier_selection/ store: GuildID ->
|
||||||
|
// record). Create or update the PierSelectionIndex entry for the
|
||||||
|
// PierID (pier_index/ store: PierID -> index — accumulate scores from
|
||||||
|
// successive selections). Emit cover.pier_selected.
|
||||||
|
func (s msgServer) SelectPier(ctx interface{}, msg *types.MsgSelectPier) (*types.MsgSelectPierResponse, error) {
|
||||||
|
if err := msg.ValidateBasic(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sdkCtx := unwrapCtx(ctx)
|
||||||
|
|
||||||
|
// Idempotency: a Guild selects exactly one Pier; a second selection is
|
||||||
|
// REJECTED (use MsgRevokePierSelection first to re-select).
|
||||||
|
if _, ok := s.Keeper.GetPierSelectionRecord(sdkCtx, msg.GuildID); ok {
|
||||||
|
return nil, fmt.Errorf("cover: guild %q already has a Pier selection (use RevokePierSelection first to re-select — REQ-066)", msg.GuildID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guild existence check via the GuildKeeper shim (a nil shim skips —
|
||||||
|
// simtest wiring; a non-nil shim with exists=false REJECTS).
|
||||||
|
if s.Keeper.guildKeeper != nil {
|
||||||
|
if !s.Keeper.guildKeeper.GetGuild(msg.GuildID) {
|
||||||
|
return nil, fmt.Errorf("cover: guild %q not found (SelectPier rejected — REQ-066)", msg.GuildID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
record := types.PierSelectionRecord{
|
||||||
|
GuildID: msg.GuildID,
|
||||||
|
PierID: msg.PierID,
|
||||||
|
SelectedAt: sdkCtx.BlockTime().Unix(),
|
||||||
|
SelectedBy: msg.Signer,
|
||||||
|
}
|
||||||
|
s.Keeper.SetPierSelectionRecord(sdkCtx, record)
|
||||||
|
|
||||||
|
// Create or update the PierSelectionIndex entry for the PierID
|
||||||
|
// (accumulate scores from successive selections — a fresh entry gets
|
||||||
|
// the default scores; an existing entry keeps its scores but the
|
||||||
|
// OverallScore is recomputed for non-decreasing assertion). The live
|
||||||
|
// mesh oracle is a v0.8+ concern; the simtest uses deterministic
|
||||||
|
// defaults so the index is non-empty on first selection.
|
||||||
|
idx, ok := s.Keeper.GetPierSelectionIndex(sdkCtx, msg.PierID)
|
||||||
|
if !ok {
|
||||||
|
idx = types.PierSelectionIndex{
|
||||||
|
PierID: msg.PierID,
|
||||||
|
JurisdictionalReliabilityScore: DefaultPierJurisdictionalReliabilityScore,
|
||||||
|
IntegrationQualityScore: DefaultPierIntegrationQualityScore,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
idx.OverallScore = DefaultPierOverallScore(idx.JurisdictionalReliabilityScore, idx.IntegrationQualityScore, idx.FiduciaryRecordHash)
|
||||||
|
s.Keeper.SetPierSelectionIndex(sdkCtx, idx)
|
||||||
|
|
||||||
|
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||||
|
"cover.pier_selected",
|
||||||
|
sdk.NewAttribute("guild_id", msg.GuildID),
|
||||||
|
sdk.NewAttribute("pier_id", msg.PierID),
|
||||||
|
sdk.NewAttribute("selected_by", msg.Signer),
|
||||||
|
sdk.NewAttribute("overall_score", fmt.Sprintf("%.4f", idx.OverallScore)),
|
||||||
|
))
|
||||||
|
return &types.MsgSelectPierResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokePierSelection revokes a Guild's Pier selection (REQ-066). The
|
||||||
|
// revocation is reversible by a Cover Pool supermajority + a Counsel
|
||||||
|
// witness. The handler enforces:
|
||||||
|
// 1. ValidateBasic (stateless).
|
||||||
|
// 2. The PierSelectionRecord for the GuildID must exist (nothing to
|
||||||
|
// revoke -> REJECT).
|
||||||
|
// 3. Authorization: RevocationApproved must be true + CounselWitness must
|
||||||
|
// be non-empty (the Cover Pool supermajority + Counsel witness gate —
|
||||||
|
// a revocation without either is REJECTED).
|
||||||
|
// 4. Remove the PierSelectionRecord. Emit cover.pier_selection_revoked.
|
||||||
|
func (s msgServer) RevokePierSelection(ctx interface{}, msg *types.MsgRevokePierSelection) (*types.MsgRevokePierSelectionResponse, error) {
|
||||||
|
if err := msg.ValidateBasic(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sdkCtx := unwrapCtx(ctx)
|
||||||
|
|
||||||
|
// The PierSelectionRecord for the GuildID must exist.
|
||||||
|
record, ok := s.Keeper.GetPierSelectionRecord(sdkCtx, msg.GuildID)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("cover: guild %q has no Pier selection to revoke (REQ-066)", msg.GuildID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorization: RevocationApproved must be true + CounselWitness must
|
||||||
|
// be non-empty (the Cover Pool supermajority + Counsel witness gate).
|
||||||
|
if !msg.RevocationApproved {
|
||||||
|
return nil, fmt.Errorf("cover: REQ-066 revocation requires RevocationApproved=true (Cover Pool supermajority not secured)")
|
||||||
|
}
|
||||||
|
if msg.CounselWitness == "" {
|
||||||
|
return nil, fmt.Errorf("cover: REQ-066 revocation requires a non-empty CounselWitness (Counsel witness not secured)")
|
||||||
|
}
|
||||||
|
|
||||||
|
removed := s.Keeper.RemovePierSelectionRecord(sdkCtx, msg.GuildID)
|
||||||
|
if !removed {
|
||||||
|
return nil, fmt.Errorf("cover: PierSelectionRecord for guild %q was not removed (internal error — REQ-066)", msg.GuildID)
|
||||||
|
}
|
||||||
|
|
||||||
|
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||||
|
"cover.pier_selection_revoked",
|
||||||
|
sdk.NewAttribute("guild_id", msg.GuildID),
|
||||||
|
sdk.NewAttribute("pier_id", record.PierID),
|
||||||
|
sdk.NewAttribute("counsel_witness", msg.CounselWitness),
|
||||||
|
))
|
||||||
|
return &types.MsgRevokePierSelectionResponse{}, nil
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -75,6 +75,20 @@ type StandingKeeper interface {
|
|||||||
// returns ("", 0, err) — the handler treats this as a gate failure
|
// returns ("", 0, err) — the handler treats this as a gate failure
|
||||||
// (REJECT).
|
// (REJECT).
|
||||||
GetStandingBucket(reachID, category string) (bucket string, score float64, err error)
|
GetStandingBucket(reachID, category string) (bucket string, score float64, err error)
|
||||||
|
// RecordSlash records a slash against the named holder (by reach-id)
|
||||||
|
// for the given reason (REQ-055 — the v0.7 P4 Voucher slash for a
|
||||||
|
// fraudulent Cover Call adjudication; reason ==
|
||||||
|
// SlashReasonFraudulentCoverCall, cross-documented to
|
||||||
|
// x/standing.SlashReasonFraudulentCoverCall). The slash drops the
|
||||||
|
// holder's Standing bucket (cross-Pool applicability — the bucket
|
||||||
|
// drop disqualifies them from other Pools' Standing gates). The
|
||||||
|
// amount is the slash amount (the Voucher's bond). The attester is
|
||||||
|
// the Watcher ID that attested the slash. A non-nil error REJECTS
|
||||||
|
// the slash (the slash could not be recorded — the Voucher is not
|
||||||
|
// slashed). A nil StandingKeeper is a wiring error -> the
|
||||||
|
// SlashCoverClaimsVoucher handler REJECTS (the slash cannot be
|
||||||
|
// recorded).
|
||||||
|
RecordSlash(reachID string, amount float64, reason string, attester string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// WatcherKeeper is the expected-keeper interface for x/watcher (G-003). The
|
// WatcherKeeper is the expected-keeper interface for x/watcher (G-003). The
|
||||||
@@ -104,6 +118,13 @@ type WatcherKeeper interface {
|
|||||||
// misuse auto-Still is also P4). The interface is here so the P1 wiring is
|
// misuse auto-Still is also P4). The interface is here so the P1 wiring is
|
||||||
// stable (the keeper holds the shim; the P4 handler calls it).
|
// stable (the keeper holds the shim; the P4 handler calls it).
|
||||||
//
|
//
|
||||||
|
// v0.7 P4 extension (REQ-063): the DissolveCoverPool handler queries
|
||||||
|
// GetMABsForPool for the Pool's outstanding MABs (the FR-MAB-4 waterfall
|
||||||
|
// Tier 2 — MAB holders are paid after Cover-Fee contributors, before Bread
|
||||||
|
// holders). MABRef is a lightweight by-value struct (no struct import of
|
||||||
|
// x/bond/types — the fields are by-value primitives cross-documented to
|
||||||
|
// x/bond.MAB).
|
||||||
|
//
|
||||||
// No struct import of x/bond/types — the interface is the by-ID-string
|
// No struct import of x/bond/types — the interface is the by-ID-string
|
||||||
// boundary (G-003). The bondID is an opaque string (the MAB's ID). A nil
|
// boundary (G-003). The bondID is an opaque string (the MAB's ID). A nil
|
||||||
// BondKeeper is the P1 default (the keeper holds nil; the P4 handler will
|
// BondKeeper is the P1 default (the keeper holds nil; the P4 handler will
|
||||||
@@ -113,6 +134,23 @@ type BondKeeper interface {
|
|||||||
// P4 FileCoverCall handler consults this to verify the adjudicating
|
// P4 FileCoverCall handler consults this to verify the adjudicating
|
||||||
// Voucher's MAB is posted before adjudication. P1 does not call this.
|
// Voucher's MAB is posted before adjudication. P1 does not call this.
|
||||||
GetBond(bondID string) (exists bool)
|
GetBond(bondID string) (exists bool)
|
||||||
|
// GetMABsForPool returns the outstanding MABs for the named pool (by-
|
||||||
|
// ID-string) — REQ-063, FR-MAB-4 waterfall Tier 2. The handler sums
|
||||||
|
// the PrincipalGrain of the returned MABRefs for the waterfall Tier 2
|
||||||
|
// amount. A nil BondKeeper returns an empty slice (the handler treats
|
||||||
|
// this as "no MABs" — Tier 2 amount = 0).
|
||||||
|
GetMABsForPool(poolID string) []MABRef
|
||||||
|
}
|
||||||
|
|
||||||
|
// MABRef is a lightweight by-value reference to a Mutual Aid Bond (G-003 —
|
||||||
|
// no struct import of x/bond/types; the fields are by-value primitives
|
||||||
|
// cross-documented to x/bond.MAB). The DissolveCoverPool handler consumes
|
||||||
|
// this for the FR-MAB-4 waterfall Tier 2 (MAB holders). BondID is the MAB's
|
||||||
|
// bond-id (by-ID-string ref). PrincipalGrain is the outstanding principal
|
||||||
|
// in Grain. The keeper's GetMABsForPool returns a slice of these.
|
||||||
|
type MABRef struct {
|
||||||
|
BondID string
|
||||||
|
PrincipalGrain int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// StillKeeper is the expected-keeper interface for x/still (G-003). The
|
// StillKeeper is the expected-keeper interface for x/still (G-003). The
|
||||||
@@ -139,3 +177,23 @@ type StillKeeper interface {
|
|||||||
// not be recorded — the routing is not committed).
|
// not be recorded — the routing is not committed).
|
||||||
Still(poolID string, reason string) error
|
Still(poolID string, reason string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GuildKeeper is the expected-keeper interface for x/guild (G-003). The P5
|
||||||
|
// MsgSelectPier handler calls GetGuild to verify the selecting Guild exists
|
||||||
|
// before persisting the PierSelectionRecord (REQ-066 — a Pier selection is
|
||||||
|
// recorded against a Guild; the Guild must exist). The interface is the
|
||||||
|
// by-ID-string boundary: guildID is an opaque string (the Guild's ID); no
|
||||||
|
// struct import of x/guild/types.
|
||||||
|
//
|
||||||
|
// A nil GuildKeeper skips the existence check (simtest wiring — the
|
||||||
|
// handler still persists the PierSelectionRecord; the simtest documents
|
||||||
|
// the wiring contract). Mirrors the StandingKeeper nil-skip pattern: a
|
||||||
|
// nil shim is the simtest's way of saying "no Guild keeper wired; skip
|
||||||
|
// the existence check" so the handler still mutates state.
|
||||||
|
type GuildKeeper interface {
|
||||||
|
// GetGuild reports whether the named Guild (by-ID-string) exists. The
|
||||||
|
// MsgSelectPier handler consults this to verify the selecting Guild
|
||||||
|
// exists before persisting the Pier selection. A false return REJECTS
|
||||||
|
// the selection (the Guild does not exist).
|
||||||
|
GetGuild(guildID string) (exists bool)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
// msg_billofrights.go holds the v0.7 P5 Anti-Capture Bill of Rights ceremony
|
||||||
|
// + Pier Selection Msg* types (REQ-056, REQ-066; vision §8.2, §15).
|
||||||
|
// G-006 controlled exception: types/ gains the cosmos-sdk import for sdk.Msg
|
||||||
|
// (mirrors msg_charter.go — D-055; the invariant/lexicon tests in *_test.go
|
||||||
|
// stay stdlib-only per G-024, isolated from this msg_*.go file).
|
||||||
|
//
|
||||||
|
// The three P5 Msg types drive the Anti-Capture Bill of Rights ceremony +
|
||||||
|
// Pier Selection runtime:
|
||||||
|
// - MsgCounselReviewBillOfRights: the §7 acceptance ceremony — the bonded
|
||||||
|
// Counsel records a review of the Anti-Capture Bill of Rights. The
|
||||||
|
// handler requires Staked=true (the Counsel's Standing bond is staked —
|
||||||
|
// "bonded Counsel" per §7 acceptance) + records the review result.
|
||||||
|
// - MsgSelectPier: a Guild Council chooses a Pier at formation (REQ-066).
|
||||||
|
// The handler persists a PierSelectionRecord (keyed by GuildID) + creates
|
||||||
|
// or updates the PierSelectionIndex entry for the PierID. The handler
|
||||||
|
// checks the Guild exists via the GuildKeeper shim (a nil shim skips).
|
||||||
|
// - MsgRevokePierSelection: a Cover Pool supermajority + Counsel witness
|
||||||
|
// revoke a Guild's Pier selection. The handler requires
|
||||||
|
// RevocationApproved=true + a non-empty CounselWitness; otherwise REJECT.
|
||||||
|
//
|
||||||
|
// All cross-module refs are by-ID-string (G-003). The GuildID is an opaque
|
||||||
|
// string referencing an x/guild Guild; the PierID is an opaque string
|
||||||
|
// referencing a Pier; the CounselReachID is an opaque string referencing an
|
||||||
|
// x/standing holder.
|
||||||
|
//
|
||||||
|
// Lexicon note (REQ-012, D-088): "Bill of Rights", "Counsel Review", "Pier
|
||||||
|
// Selection", "Counsel", "Witness", "Staked" are lexicon-clean. The four
|
||||||
|
// Cover-specific banned terms NEVER appear (enforced by lexicon_meta_cover).
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
sdk "github.com/cosmos/cosmos-sdk/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- MsgCounselReviewBillOfRights ---------------------------------------------
|
||||||
|
|
||||||
|
// MsgCounselReviewBillOfRights is the §7 acceptance ceremony: the bonded
|
||||||
|
// Counsel records a review of the Anti-Capture Bill of Rights (REQ-056,
|
||||||
|
// vision §7, §8.2). The handler requires Staked=true (the Counsel's
|
||||||
|
// Standing bond is staked — "bonded Counsel" per §7 acceptance — the
|
||||||
|
// review is recorded by a bonded Counsel, not an unbonded one). The handler
|
||||||
|
// persists the review result (a bill_review/ store: ReviewID -> review
|
||||||
|
// record) + emits cover.bill_of_rights_reviewed.
|
||||||
|
//
|
||||||
|
// ValidateBasic is stateless: non-empty fields + Staked must be true (a
|
||||||
|
// review by an unbonded Counsel is REJECTED at ValidateBasic — defense in
|
||||||
|
// depth; the handler also re-checks).
|
||||||
|
type MsgCounselReviewBillOfRights struct {
|
||||||
|
ReviewID string `json:"review_id" yaml:"review_id"`
|
||||||
|
CounselReachID string `json:"counsel_reach_id" yaml:"counsel_reach_id"`
|
||||||
|
Staked bool `json:"staked" yaml:"staked"`
|
||||||
|
ReviewResult string `json:"review_result" yaml:"review_result"`
|
||||||
|
Signer string `json:"signer" yaml:"signer"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset implements proto.Message.
|
||||||
|
func (m *MsgCounselReviewBillOfRights) Reset() { *m = MsgCounselReviewBillOfRights{} }
|
||||||
|
|
||||||
|
// String implements proto.Message.
|
||||||
|
func (m *MsgCounselReviewBillOfRights) String() string {
|
||||||
|
return fmt.Sprintf("MsgCounselReviewBillOfRights{ReviewID:%s CounselReachID:%s Staked:%v ReviewResult:%s Signer:%s}",
|
||||||
|
m.ReviewID, m.CounselReachID, m.Staked, m.ReviewResult, m.Signer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProtoMessage implements proto.Message.
|
||||||
|
func (*MsgCounselReviewBillOfRights) ProtoMessage() {}
|
||||||
|
|
||||||
|
// ValidateBasic is the stateless validation: non-empty fields + Staked must
|
||||||
|
// be true (the §7 "bonded Counsel" acceptance criterion — a review by an
|
||||||
|
// unbonded Counsel is REJECTED). This is the ceremony gate (the dual
|
||||||
|
// firewall is in msg_charter.go: the ValidateBasic gate on
|
||||||
|
// MsgSignCoverCharter rejects any WaivedRights element; this ceremony is
|
||||||
|
// the §7 acceptance on top of that firewall).
|
||||||
|
func (m *MsgCounselReviewBillOfRights) ValidateBasic() error {
|
||||||
|
if m.ReviewID == "" {
|
||||||
|
return fmt.Errorf("cover: empty review-id")
|
||||||
|
}
|
||||||
|
if m.CounselReachID == "" {
|
||||||
|
return fmt.Errorf("cover: empty counsel-reach-id")
|
||||||
|
}
|
||||||
|
if m.ReviewResult == "" {
|
||||||
|
return fmt.Errorf("cover: empty review-result")
|
||||||
|
}
|
||||||
|
if m.Signer == "" {
|
||||||
|
return fmt.Errorf("cover: empty signer")
|
||||||
|
}
|
||||||
|
if !m.Staked {
|
||||||
|
return fmt.Errorf("cover: REQ-056 §7 acceptance: Staked must be true (the Counsel's Standing bond is staked — bonded Counsel)")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||||
|
func (m *MsgCounselReviewBillOfRights) GetSigners() []sdk.AccAddress {
|
||||||
|
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MsgSelectPier -------------------------------------------------------------
|
||||||
|
|
||||||
|
// MsgSelectPier is a Guild Council's selection of a Pier at formation
|
||||||
|
// (REQ-066). The handler persists a PierSelectionRecord (keyed by GuildID)
|
||||||
|
// + creates or updates the PierSelectionIndex entry for the PierID
|
||||||
|
// (accumulating scores from successive selections). The handler checks the
|
||||||
|
// Guild exists via the GuildKeeper shim (a nil shim skips the existence
|
||||||
|
// check — simtest wiring). The signer is treated as the Guild's
|
||||||
|
// FounderReach or a Council member (simtest-grade: the handler checks
|
||||||
|
// non-empty).
|
||||||
|
//
|
||||||
|
// ValidateBasic is stateless: non-empty fields.
|
||||||
|
type MsgSelectPier struct {
|
||||||
|
GuildID string `json:"guild_id" yaml:"guild_id"`
|
||||||
|
PierID string `json:"pier_id" yaml:"pier_id"`
|
||||||
|
Signer string `json:"signer" yaml:"signer"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset implements proto.Message.
|
||||||
|
func (m *MsgSelectPier) Reset() { *m = MsgSelectPier{} }
|
||||||
|
|
||||||
|
// String implements proto.Message.
|
||||||
|
func (m *MsgSelectPier) String() string {
|
||||||
|
return fmt.Sprintf("MsgSelectPier{GuildID:%s PierID:%s Signer:%s}",
|
||||||
|
m.GuildID, m.PierID, m.Signer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProtoMessage implements proto.Message.
|
||||||
|
func (*MsgSelectPier) ProtoMessage() {}
|
||||||
|
|
||||||
|
// ValidateBasic is the stateless validation: non-empty fields.
|
||||||
|
func (m *MsgSelectPier) ValidateBasic() error {
|
||||||
|
if m.GuildID == "" {
|
||||||
|
return fmt.Errorf("cover: empty guild-id")
|
||||||
|
}
|
||||||
|
if m.PierID == "" {
|
||||||
|
return fmt.Errorf("cover: empty pier-id")
|
||||||
|
}
|
||||||
|
if m.Signer == "" {
|
||||||
|
return fmt.Errorf("cover: empty signer")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||||
|
func (m *MsgSelectPier) GetSigners() []sdk.AccAddress {
|
||||||
|
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MsgRevokePierSelection ----------------------------------------------------
|
||||||
|
|
||||||
|
// MsgRevokePierSelection revokes a Guild's Pier selection (REQ-066). The
|
||||||
|
// revocation is reversible by a Cover Pool supermajority + a Counsel
|
||||||
|
// witness: the handler requires RevocationApproved=true + a non-empty
|
||||||
|
// CounselWitness (otherwise REJECT — the revocation is not authorized).
|
||||||
|
// The handler removes the PierSelectionRecord (keyed by GuildID) + emits
|
||||||
|
// cover.pier_selection_revoked.
|
||||||
|
//
|
||||||
|
// ValidateBasic is stateless: non-empty GuildID + Signer (the
|
||||||
|
// RevocationApproved + CounselWitness checks are at the handler — they are
|
||||||
|
// authorization checks, not stateless shape checks; a simtest may exercise
|
||||||
|
// a RevocationApproved=false message to assert the handler REJECTS).
|
||||||
|
type MsgRevokePierSelection struct {
|
||||||
|
GuildID string `json:"guild_id" yaml:"guild_id"`
|
||||||
|
RevocationApproved bool `json:"revocation_approved" yaml:"revocation_approved"`
|
||||||
|
CounselWitness string `json:"counsel_witness" yaml:"counsel_witness"`
|
||||||
|
Signer string `json:"signer" yaml:"signer"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset implements proto.Message.
|
||||||
|
func (m *MsgRevokePierSelection) Reset() { *m = MsgRevokePierSelection{} }
|
||||||
|
|
||||||
|
// String implements proto.Message.
|
||||||
|
func (m *MsgRevokePierSelection) String() string {
|
||||||
|
return fmt.Sprintf("MsgRevokePierSelection{GuildID:%s RevocationApproved:%v CounselWitness:%s Signer:%s}",
|
||||||
|
m.GuildID, m.RevocationApproved, m.CounselWitness, m.Signer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProtoMessage implements proto.Message.
|
||||||
|
func (*MsgRevokePierSelection) ProtoMessage() {}
|
||||||
|
|
||||||
|
// ValidateBasic is the stateless validation: non-empty GuildID + Signer.
|
||||||
|
// The RevocationApproved + CounselWitness checks are at the handler
|
||||||
|
// (authorization, not stateless shape).
|
||||||
|
func (m *MsgRevokePierSelection) ValidateBasic() error {
|
||||||
|
if m.GuildID == "" {
|
||||||
|
return fmt.Errorf("cover: empty guild-id")
|
||||||
|
}
|
||||||
|
if m.Signer == "" {
|
||||||
|
return fmt.Errorf("cover: empty signer")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||||
|
func (m *MsgRevokePierSelection) GetSigners() []sdk.AccAddress {
|
||||||
|
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- P5 Response types --------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Hand-rolled (no protobuf codegen); empty bodies — the response is the
|
||||||
|
// state mutation + event. Mirrors the P2/P4 Response types.
|
||||||
|
|
||||||
|
// MsgCounselReviewBillOfRightsResponse is the response to
|
||||||
|
// MsgCounselReviewBillOfRights.
|
||||||
|
type MsgCounselReviewBillOfRightsResponse struct{}
|
||||||
|
|
||||||
|
// Reset implements proto.Message.
|
||||||
|
func (m *MsgCounselReviewBillOfRightsResponse) Reset() {
|
||||||
|
*m = MsgCounselReviewBillOfRightsResponse{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// String implements proto.Message.
|
||||||
|
func (m *MsgCounselReviewBillOfRightsResponse) String() string {
|
||||||
|
return "MsgCounselReviewBillOfRightsResponse{}"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProtoMessage implements proto.Message.
|
||||||
|
func (*MsgCounselReviewBillOfRightsResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
// MsgSelectPierResponse is the response to MsgSelectPier.
|
||||||
|
type MsgSelectPierResponse struct{}
|
||||||
|
|
||||||
|
// Reset implements proto.Message.
|
||||||
|
func (m *MsgSelectPierResponse) Reset() { *m = MsgSelectPierResponse{} }
|
||||||
|
|
||||||
|
// String implements proto.Message.
|
||||||
|
func (m *MsgSelectPierResponse) String() string { return "MsgSelectPierResponse{}" }
|
||||||
|
|
||||||
|
// ProtoMessage implements proto.Message.
|
||||||
|
func (*MsgSelectPierResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
// MsgRevokePierSelectionResponse is the response to MsgRevokePierSelection.
|
||||||
|
type MsgRevokePierSelectionResponse struct{}
|
||||||
|
|
||||||
|
// Reset implements proto.Message.
|
||||||
|
func (m *MsgRevokePierSelectionResponse) Reset() { *m = MsgRevokePierSelectionResponse{} }
|
||||||
|
|
||||||
|
// String implements proto.Message.
|
||||||
|
func (m *MsgRevokePierSelectionResponse) String() string {
|
||||||
|
return "MsgRevokePierSelectionResponse{}"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProtoMessage implements proto.Message.
|
||||||
|
func (*MsgRevokePierSelectionResponse) ProtoMessage() {}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
// msg_billofrights_test.go holds the P5 Msg* method coverage tests for
|
||||||
|
// x/cover/types (REQ-056, REQ-066). The P5 Msg* Reset/String/ProtoMessage/
|
||||||
|
// ValidateBasic/GetSigners methods are exercised here so the types package
|
||||||
|
// coverage is >=80%.
|
||||||
|
//
|
||||||
|
// G-024 controlled exception (mirrors msg_charter_test.go): this file
|
||||||
|
// imports cosmos-sdk for GetSigners (sdk.AccAddress) — this is a Msg-method
|
||||||
|
// test, NOT an invariant/lexicon test, so the G-024 stdlib-only constraint
|
||||||
|
// does not apply.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
sdk "github.com/cosmos/cosmos-sdk/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- MsgCounselReviewBillOfRights methods -------------------------------------
|
||||||
|
|
||||||
|
func TestMsgCounselReviewBillOfRightsMethods(t *testing.T) {
|
||||||
|
m := &MsgCounselReviewBillOfRights{
|
||||||
|
ReviewID: "r1",
|
||||||
|
CounselReachID: "c1",
|
||||||
|
Staked: true,
|
||||||
|
ReviewResult: "Affirmed",
|
||||||
|
Signer: "s1",
|
||||||
|
}
|
||||||
|
if err := m.ValidateBasic(); err != nil {
|
||||||
|
t.Errorf("valid MsgCounselReviewBillOfRights ValidateBasic: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(m.String(), "r1") {
|
||||||
|
t.Errorf("MsgCounselReviewBillOfRights String = %q, want r1", m.String())
|
||||||
|
}
|
||||||
|
m.Reset()
|
||||||
|
if m.ReviewID != "" {
|
||||||
|
t.Errorf("MsgCounselReviewBillOfRights Reset did not zero: %+v", m)
|
||||||
|
}
|
||||||
|
m.ProtoMessage()
|
||||||
|
m2 := &MsgCounselReviewBillOfRights{Signer: "host-1"}
|
||||||
|
if got := m2.GetSigners(); len(got) != 1 || string(got[0]) != "host-1" {
|
||||||
|
t.Errorf("MsgCounselReviewBillOfRights GetSigners = %v, want [host-1]", got)
|
||||||
|
}
|
||||||
|
var _ []sdk.AccAddress = m2.GetSigners()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMsgCounselReviewBillOfRightsValidateBasicErrors(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
msg MsgCounselReviewBillOfRights
|
||||||
|
}{
|
||||||
|
{"empty review-id", MsgCounselReviewBillOfRights{CounselReachID: "c", Staked: true, ReviewResult: "r", Signer: "s"}},
|
||||||
|
{"empty counsel-reach-id", MsgCounselReviewBillOfRights{ReviewID: "r", Staked: true, ReviewResult: "r", Signer: "s"}},
|
||||||
|
{"empty review-result", MsgCounselReviewBillOfRights{ReviewID: "r", CounselReachID: "c", Staked: true, Signer: "s"}},
|
||||||
|
{"empty signer", MsgCounselReviewBillOfRights{ReviewID: "r", CounselReachID: "c", Staked: true, ReviewResult: "r"}},
|
||||||
|
{"staked false", MsgCounselReviewBillOfRights{ReviewID: "r", CounselReachID: "c", Staked: false, ReviewResult: "r", Signer: "s"}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if err := c.msg.ValidateBasic(); err == nil {
|
||||||
|
t.Errorf("case %q: ValidateBasic should fail", c.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MsgSelectPier methods ----------------------------------------------------
|
||||||
|
|
||||||
|
func TestMsgSelectPierMethods(t *testing.T) {
|
||||||
|
m := &MsgSelectPier{GuildID: "g1", PierID: "p1", Signer: "s1"}
|
||||||
|
if err := m.ValidateBasic(); err != nil {
|
||||||
|
t.Errorf("valid MsgSelectPier ValidateBasic: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(m.String(), "g1") {
|
||||||
|
t.Errorf("MsgSelectPier String = %q, want g1", m.String())
|
||||||
|
}
|
||||||
|
m.Reset()
|
||||||
|
if m.GuildID != "" {
|
||||||
|
t.Errorf("MsgSelectPier Reset did not zero: %+v", m)
|
||||||
|
}
|
||||||
|
m.ProtoMessage()
|
||||||
|
m2 := &MsgSelectPier{Signer: "host-1"}
|
||||||
|
if got := m2.GetSigners(); len(got) != 1 || string(got[0]) != "host-1" {
|
||||||
|
t.Errorf("MsgSelectPier GetSigners = %v, want [host-1]", got)
|
||||||
|
}
|
||||||
|
var _ []sdk.AccAddress = m2.GetSigners()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMsgSelectPierValidateBasicErrors(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
msg MsgSelectPier
|
||||||
|
}{
|
||||||
|
{"empty guild-id", MsgSelectPier{PierID: "p", Signer: "s"}},
|
||||||
|
{"empty pier-id", MsgSelectPier{GuildID: "g", Signer: "s"}},
|
||||||
|
{"empty signer", MsgSelectPier{GuildID: "g", PierID: "p"}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if err := c.msg.ValidateBasic(); err == nil {
|
||||||
|
t.Errorf("case %q: ValidateBasic should fail", c.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MsgRevokePierSelection methods -------------------------------------------
|
||||||
|
|
||||||
|
func TestMsgRevokePierSelectionMethods(t *testing.T) {
|
||||||
|
m := &MsgRevokePierSelection{
|
||||||
|
GuildID: "g1",
|
||||||
|
RevocationApproved: true,
|
||||||
|
CounselWitness: "c1",
|
||||||
|
Signer: "s1",
|
||||||
|
}
|
||||||
|
if err := m.ValidateBasic(); err != nil {
|
||||||
|
t.Errorf("valid MsgRevokePierSelection ValidateBasic: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(m.String(), "g1") {
|
||||||
|
t.Errorf("MsgRevokePierSelection String = %q, want g1", m.String())
|
||||||
|
}
|
||||||
|
m.Reset()
|
||||||
|
if m.GuildID != "" {
|
||||||
|
t.Errorf("MsgRevokePierSelection Reset did not zero: %+v", m)
|
||||||
|
}
|
||||||
|
m.ProtoMessage()
|
||||||
|
m2 := &MsgRevokePierSelection{Signer: "host-1"}
|
||||||
|
if got := m2.GetSigners(); len(got) != 1 || string(got[0]) != "host-1" {
|
||||||
|
t.Errorf("MsgRevokePierSelection GetSigners = %v, want [host-1]", got)
|
||||||
|
}
|
||||||
|
var _ []sdk.AccAddress = m2.GetSigners()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMsgRevokePierSelectionValidateBasicErrors(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
msg MsgRevokePierSelection
|
||||||
|
}{
|
||||||
|
{"empty guild-id", MsgRevokePierSelection{RevocationApproved: true, CounselWitness: "c", Signer: "s"}},
|
||||||
|
{"empty signer", MsgRevokePierSelection{GuildID: "g", RevocationApproved: true, CounselWitness: "c"}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if err := c.msg.ValidateBasic(); err == nil {
|
||||||
|
t.Errorf("case %q: ValidateBasic should fail", c.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- P5 Response types methods ------------------------------------------------
|
||||||
|
|
||||||
|
func TestP5ResponseTypesMethods(t *testing.T) {
|
||||||
|
r1 := &MsgCounselReviewBillOfRightsResponse{}
|
||||||
|
r1.Reset()
|
||||||
|
if !strings.Contains(r1.String(), "MsgCounselReviewBillOfRightsResponse") {
|
||||||
|
t.Errorf("MsgCounselReviewBillOfRightsResponse String = %q", r1.String())
|
||||||
|
}
|
||||||
|
r1.ProtoMessage()
|
||||||
|
|
||||||
|
r2 := &MsgSelectPierResponse{}
|
||||||
|
r2.Reset()
|
||||||
|
if !strings.Contains(r2.String(), "MsgSelectPierResponse") {
|
||||||
|
t.Errorf("MsgSelectPierResponse String = %q", r2.String())
|
||||||
|
}
|
||||||
|
r2.ProtoMessage()
|
||||||
|
|
||||||
|
r3 := &MsgRevokePierSelectionResponse{}
|
||||||
|
r3.Reset()
|
||||||
|
if !strings.Contains(r3.String(), "MsgRevokePierSelectionResponse") {
|
||||||
|
t.Errorf("MsgRevokePierSelectionResponse String = %q", r3.String())
|
||||||
|
}
|
||||||
|
r3.ProtoMessage()
|
||||||
|
}
|
||||||
@@ -244,6 +244,17 @@ type MsgServer interface {
|
|||||||
VoteCoverCall(ctx interface{}, msg *MsgVoteCoverCall) (*MsgVoteCoverCallResponse, error)
|
VoteCoverCall(ctx interface{}, msg *MsgVoteCoverCall) (*MsgVoteCoverCallResponse, error)
|
||||||
AmendPoolStandingGate(ctx interface{}, msg *MsgAmendPoolStandingGate) (*MsgAmendPoolStandingGateResponse, error)
|
AmendPoolStandingGate(ctx interface{}, msg *MsgAmendPoolStandingGate) (*MsgAmendPoolStandingGateResponse, error)
|
||||||
EscalateReserveCeiling(ctx interface{}, msg *MsgEscalateReserveCeiling) (*MsgEscalateReserveCeilingResponse, error)
|
EscalateReserveCeiling(ctx interface{}, msg *MsgEscalateReserveCeiling) (*MsgEscalateReserveCeilingResponse, error)
|
||||||
|
// v0.7 P4 Voucher + Dissolution handlers (REQ-055, REQ-063, D-090(2),
|
||||||
|
// FR-CPCV-2) — defined in msg_voucher.go.
|
||||||
|
RegisterCoverClaimsVoucher(ctx interface{}, msg *MsgRegisterCoverClaimsVoucher) (*MsgRegisterCoverClaimsVoucherResponse, error)
|
||||||
|
AdjudicateCoverCall(ctx interface{}, msg *MsgAdjudicateCoverCall) (*MsgAdjudicateCoverCallResponse, error)
|
||||||
|
SlashCoverClaimsVoucher(ctx interface{}, msg *MsgSlashCoverClaimsVoucher) (*MsgSlashCoverClaimsVoucherResponse, error)
|
||||||
|
DissolveCoverPool(ctx interface{}, msg *MsgDissolveCoverPool) (*MsgDissolveCoverPoolResponse, error)
|
||||||
|
// v0.7 P5 Anti-Capture Bill of Rights ceremony + Pier Selection handlers
|
||||||
|
// (REQ-056, REQ-066) — defined in msg_billofrights.go.
|
||||||
|
CounselReviewBillOfRights(ctx interface{}, msg *MsgCounselReviewBillOfRights) (*MsgCounselReviewBillOfRightsResponse, error)
|
||||||
|
SelectPier(ctx interface{}, msg *MsgSelectPier) (*MsgSelectPierResponse, error)
|
||||||
|
RevokePierSelection(ctx interface{}, msg *MsgRevokePierSelection) (*MsgRevokePierSelectionResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Response types (hand-rolled; empty bodies — the response is the state
|
// Response types (hand-rolled; empty bodies — the response is the state
|
||||||
|
|||||||
@@ -0,0 +1,316 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
// msg_voucher.go holds the v0.7 P4 Cover Claims Voucher + Pool Dissolution
|
||||||
|
// Msg* types (REQ-055, REQ-063, D-090(2), FR-CPCV-2; G-006 controlled
|
||||||
|
// exception: types/ gains the cosmos-sdk import for sdk.Msg — D-055; the
|
||||||
|
// invariant/lexicon tests in *_test.go stay stdlib-only per G-024, isolated
|
||||||
|
// from this msg_*.go file).
|
||||||
|
//
|
||||||
|
// The four P4 Voucher + Dissolution Msg types drive the Voucher + waterfall
|
||||||
|
// runtime:
|
||||||
|
// - MsgRegisterCoverClaimsVoucher: register a Cover Claims Voucher for a
|
||||||
|
// Pool (the handler computes the bond = max(
|
||||||
|
// CoverClaimsVoucherBondMultipleAvgCall × avgCallSize,
|
||||||
|
// MinimumVoucherBond); D-090(2) cold-start: when no Calls exist, bond =
|
||||||
|
// MinimumVoucherBond, NOT zero).
|
||||||
|
// - MsgAdjudicateCoverCall: a Voucher adjudicates a Cover Call (FR-CPCV-2
|
||||||
|
// no self-adjudication: rejects if VoucherReachID ==
|
||||||
|
// CoverCall.ClaimantReachID).
|
||||||
|
// - MsgSlashCoverClaimsVoucher: slash a Voucher for a fraudulent Cover
|
||||||
|
// Call adjudication (Reason == SlashReasonFraudulentCoverCall; the
|
||||||
|
// handler invokes StandingKeeper.RecordSlash -> the Standing bucket
|
||||||
|
// drops -> cross-Pool applicability).
|
||||||
|
// - MsgDissolveCoverPool: dissolve a Pool (the handler computes the
|
||||||
|
// PoolDissolutionWaterfall: Cover-Fee contributors > MAB > Bread
|
||||||
|
// holders — FR-MAB-4 seniority; MAB holders have NO Voice in the
|
||||||
|
// decision — REQ-063).
|
||||||
|
//
|
||||||
|
// All cross-module refs are by-ID-string (G-003). The
|
||||||
|
// SlashReasonFraudulentCoverCall const is LOCAL to x/cover (cross-documented
|
||||||
|
// to x/standing.SlashReasonFraudulentCoverCall — the two consts MUST stay in
|
||||||
|
// sync; G-003 — no struct import of x/standing/types).
|
||||||
|
//
|
||||||
|
// Lexicon note (REQ-012, D-088): "Cover Claims Voucher", "Adjudicate",
|
||||||
|
// "Waterfall", "Dissolution", "Slash" are lexicon-clean. The four Cover-
|
||||||
|
// specific banned terms NEVER appear (enforced by lexicon_meta_cover).
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
sdk "github.com/cosmos/cosmos-sdk/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- MsgRegisterCoverClaimsVoucher -------------------------------------------
|
||||||
|
|
||||||
|
// MsgRegisterCoverClaimsVoucher registers a Cover Claims Voucher for a Pool
|
||||||
|
// (REQ-055, D-090(2)). The handler enforces:
|
||||||
|
// - ValidateBasic (stateless).
|
||||||
|
// - Idempotency: no duplicate Voucher for the same Pool (a Voucher is
|
||||||
|
// registered per-Pool; a second registration for the same
|
||||||
|
// VoucherReachID + PoolID is REJECTED).
|
||||||
|
// - Compute bond: max(CoverClaimsVoucherBondMultipleAvgCall ×
|
||||||
|
// GetAvgCallSize(poolID), MinimumVoucherBond). D-090(2) cold-start: when
|
||||||
|
// no Calls exist, GetAvgCallSize returns 0 -> bond = MinimumVoucherBond
|
||||||
|
// (NOT zero).
|
||||||
|
// - Persist the Voucher + emit cover.voucher_registered.
|
||||||
|
type MsgRegisterCoverClaimsVoucher struct {
|
||||||
|
VoucherReachID string `json:"voucher_reach_id" yaml:"voucher_reach_id"`
|
||||||
|
PoolID string `json:"pool_id" yaml:"pool_id"`
|
||||||
|
Signer string `json:"signer" yaml:"signer"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset implements proto.Message.
|
||||||
|
func (m *MsgRegisterCoverClaimsVoucher) Reset() { *m = MsgRegisterCoverClaimsVoucher{} }
|
||||||
|
|
||||||
|
// String implements proto.Message.
|
||||||
|
func (m *MsgRegisterCoverClaimsVoucher) String() string {
|
||||||
|
return fmt.Sprintf("MsgRegisterCoverClaimsVoucher{VoucherReachID:%s PoolID:%s Signer:%s}",
|
||||||
|
m.VoucherReachID, m.PoolID, m.Signer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProtoMessage implements proto.Message.
|
||||||
|
func (*MsgRegisterCoverClaimsVoucher) ProtoMessage() {}
|
||||||
|
|
||||||
|
// ValidateBasic is the stateless validation: non-empty voucher-reach-id,
|
||||||
|
// non-empty pool-id, non-empty signer.
|
||||||
|
func (m *MsgRegisterCoverClaimsVoucher) ValidateBasic() error {
|
||||||
|
if m.VoucherReachID == "" {
|
||||||
|
return fmt.Errorf("cover: empty voucher-reach-id")
|
||||||
|
}
|
||||||
|
if m.PoolID == "" {
|
||||||
|
return fmt.Errorf("cover: empty pool-id")
|
||||||
|
}
|
||||||
|
if m.Signer == "" {
|
||||||
|
return fmt.Errorf("cover: empty signer")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||||
|
func (m *MsgRegisterCoverClaimsVoucher) GetSigners() []sdk.AccAddress {
|
||||||
|
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MsgAdjudicateCoverCall ---------------------------------------------------
|
||||||
|
|
||||||
|
// MsgAdjudicateCoverCall adjudicates a Cover Call (REQ-055, FR-CPCV-2). The
|
||||||
|
// handler enforces:
|
||||||
|
// - ValidateBasic (stateless).
|
||||||
|
// - The CoverCall must exist.
|
||||||
|
// - FR-CPCV-2 no self-adjudication: reject if VoucherReachID ==
|
||||||
|
// CoverCall.ClaimantReachID (the Voucher cannot adjudicate their own
|
||||||
|
// Call).
|
||||||
|
// - The Voucher must be registered for the Call's Pool.
|
||||||
|
// - Record the adjudication result on the CoverCall (AdjudicationResult +
|
||||||
|
// AdjudicatedBy + AdjudicatedAt). Persist. Emit cover.cover_call_adjudicated.
|
||||||
|
type MsgAdjudicateCoverCall struct {
|
||||||
|
CallID string `json:"call_id" yaml:"call_id"`
|
||||||
|
VoucherReachID string `json:"voucher_reach_id" yaml:"voucher_reach_id"`
|
||||||
|
AdjudicationResult string `json:"adjudication_result" yaml:"adjudication_result"`
|
||||||
|
Signer string `json:"signer" yaml:"signer"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset implements proto.Message.
|
||||||
|
func (m *MsgAdjudicateCoverCall) Reset() { *m = MsgAdjudicateCoverCall{} }
|
||||||
|
|
||||||
|
// String implements proto.Message.
|
||||||
|
func (m *MsgAdjudicateCoverCall) String() string {
|
||||||
|
return fmt.Sprintf("MsgAdjudicateCoverCall{CallID:%s VoucherReachID:%s AdjudicationResult:%s Signer:%s}",
|
||||||
|
m.CallID, m.VoucherReachID, m.AdjudicationResult, m.Signer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProtoMessage implements proto.Message.
|
||||||
|
func (*MsgAdjudicateCoverCall) ProtoMessage() {}
|
||||||
|
|
||||||
|
// ValidateBasic is the stateless validation: non-empty call-id, non-empty
|
||||||
|
// voucher-reach-id, non-empty adjudication-result, non-empty signer.
|
||||||
|
func (m *MsgAdjudicateCoverCall) ValidateBasic() error {
|
||||||
|
if m.CallID == "" {
|
||||||
|
return fmt.Errorf("cover: empty call-id")
|
||||||
|
}
|
||||||
|
if m.VoucherReachID == "" {
|
||||||
|
return fmt.Errorf("cover: empty voucher-reach-id")
|
||||||
|
}
|
||||||
|
if m.AdjudicationResult == "" {
|
||||||
|
return fmt.Errorf("cover: empty adjudication-result")
|
||||||
|
}
|
||||||
|
if m.Signer == "" {
|
||||||
|
return fmt.Errorf("cover: empty signer")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||||
|
func (m *MsgAdjudicateCoverCall) GetSigners() []sdk.AccAddress {
|
||||||
|
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MsgSlashCoverClaimsVoucher -----------------------------------------------
|
||||||
|
|
||||||
|
// MsgSlashCoverClaimsVoucher slashes a Cover Claims Voucher for a fraudulent
|
||||||
|
// Cover Call adjudication (REQ-055). The handler enforces:
|
||||||
|
// - ValidateBasic (stateless — Reason must == SlashReasonFraudulentCoverCall).
|
||||||
|
// - The Voucher must exist.
|
||||||
|
// - Invoke StandingKeeper.RecordSlash(voucherReachID, amount, reason,
|
||||||
|
// attester) — the slash drops the Voucher's Standing bucket (cross-Pool
|
||||||
|
// applicability — the bucket drop disqualifies them from other Pools'
|
||||||
|
// Standing gates). A nil StandingKeeper is a wiring error -> REJECT.
|
||||||
|
// - Emit cover.voucher_slashed.
|
||||||
|
type MsgSlashCoverClaimsVoucher struct {
|
||||||
|
VoucherReachID string `json:"voucher_reach_id" yaml:"voucher_reach_id"`
|
||||||
|
CallID string `json:"call_id" yaml:"call_id"`
|
||||||
|
Reason string `json:"reason" yaml:"reason"`
|
||||||
|
Signer string `json:"signer" yaml:"signer"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset implements proto.Message.
|
||||||
|
func (m *MsgSlashCoverClaimsVoucher) Reset() { *m = MsgSlashCoverClaimsVoucher{} }
|
||||||
|
|
||||||
|
// String implements proto.Message.
|
||||||
|
func (m *MsgSlashCoverClaimsVoucher) String() string {
|
||||||
|
return fmt.Sprintf("MsgSlashCoverClaimsVoucher{VoucherReachID:%s CallID:%s Reason:%s Signer:%s}",
|
||||||
|
m.VoucherReachID, m.CallID, m.Reason, m.Signer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProtoMessage implements proto.Message.
|
||||||
|
func (*MsgSlashCoverClaimsVoucher) ProtoMessage() {}
|
||||||
|
|
||||||
|
// ValidateBasic is the stateless validation: non-empty fields + Reason ==
|
||||||
|
// SlashReasonFraudulentCoverCall (the slash reason const — cross-documented
|
||||||
|
// to x/standing.SlashReasonFraudulentCoverCall; LOCAL to x/cover to avoid
|
||||||
|
// importing x/standing — G-003).
|
||||||
|
func (m *MsgSlashCoverClaimsVoucher) ValidateBasic() error {
|
||||||
|
if m.VoucherReachID == "" {
|
||||||
|
return fmt.Errorf("cover: empty voucher-reach-id")
|
||||||
|
}
|
||||||
|
if m.CallID == "" {
|
||||||
|
return fmt.Errorf("cover: empty call-id")
|
||||||
|
}
|
||||||
|
if m.Reason == "" {
|
||||||
|
return fmt.Errorf("cover: empty reason")
|
||||||
|
}
|
||||||
|
if m.Reason != SlashReasonFraudulentCoverCall {
|
||||||
|
return fmt.Errorf("cover: slash reason %q != %q (REQ-055 — only FraudulentCoverCall is a valid Voucher slash reason)", m.Reason, SlashReasonFraudulentCoverCall)
|
||||||
|
}
|
||||||
|
if m.Signer == "" {
|
||||||
|
return fmt.Errorf("cover: empty signer")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||||
|
func (m *MsgSlashCoverClaimsVoucher) GetSigners() []sdk.AccAddress {
|
||||||
|
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MsgDissolveCoverPool -----------------------------------------------------
|
||||||
|
|
||||||
|
// MsgDissolveCoverPool dissolves a Cover Pool (REQ-063, FR-MAB-4). The
|
||||||
|
// handler enforces:
|
||||||
|
// - ValidateBasic (stateless).
|
||||||
|
// - The Pool must exist.
|
||||||
|
// - Compute the PoolDissolutionWaterfall: Tier 1 = Cover-Fee contributors
|
||||||
|
// (the Pool's reserve), Tier 2 = MAB holders (query BondKeeper for MABs
|
||||||
|
// on this Pool — outstanding principal), Tier 3 = Bread holders (the
|
||||||
|
// remainder). MAB holders have NO Voice in the dissolution decision
|
||||||
|
// (REQ-063 — the PoolCouncil from P2 already excludes them; the
|
||||||
|
// waterfall only determines the payout order).
|
||||||
|
// - Emit cover.pool_dissolved with the waterfall tiers.
|
||||||
|
type MsgDissolveCoverPool struct {
|
||||||
|
PoolID string `json:"pool_id" yaml:"pool_id"`
|
||||||
|
Signer string `json:"signer" yaml:"signer"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset implements proto.Message.
|
||||||
|
func (m *MsgDissolveCoverPool) Reset() { *m = MsgDissolveCoverPool{} }
|
||||||
|
|
||||||
|
// String implements proto.Message.
|
||||||
|
func (m *MsgDissolveCoverPool) String() string {
|
||||||
|
return fmt.Sprintf("MsgDissolveCoverPool{PoolID:%s Signer:%s}", m.PoolID, m.Signer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProtoMessage implements proto.Message.
|
||||||
|
func (*MsgDissolveCoverPool) ProtoMessage() {}
|
||||||
|
|
||||||
|
// ValidateBasic is the stateless validation: non-empty pool-id, non-empty
|
||||||
|
// signer.
|
||||||
|
func (m *MsgDissolveCoverPool) ValidateBasic() error {
|
||||||
|
if m.PoolID == "" {
|
||||||
|
return fmt.Errorf("cover: empty pool-id")
|
||||||
|
}
|
||||||
|
if m.Signer == "" {
|
||||||
|
return fmt.Errorf("cover: empty signer")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||||
|
func (m *MsgDissolveCoverPool) GetSigners() []sdk.AccAddress {
|
||||||
|
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- P4 Voucher + Dissolution Response types ----------------------------------
|
||||||
|
|
||||||
|
// MsgRegisterCoverClaimsVoucherResponse is the response to
|
||||||
|
// MsgRegisterCoverClaimsVoucher. BondAmount reports the computed bond (for
|
||||||
|
// simtest assertion: D-090(2) cold-start -> MinimumVoucherBond; with Calls
|
||||||
|
// -> 10× avg).
|
||||||
|
type MsgRegisterCoverClaimsVoucherResponse struct {
|
||||||
|
BondAmount int64 `json:"bond_amount" yaml:"bond_amount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset implements proto.Message.
|
||||||
|
func (m *MsgRegisterCoverClaimsVoucherResponse) Reset() { *m = MsgRegisterCoverClaimsVoucherResponse{} }
|
||||||
|
|
||||||
|
// String implements proto.Message.
|
||||||
|
func (m *MsgRegisterCoverClaimsVoucherResponse) String() string {
|
||||||
|
return fmt.Sprintf("MsgRegisterCoverClaimsVoucherResponse{BondAmount:%d}", m.BondAmount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProtoMessage implements proto.Message.
|
||||||
|
func (*MsgRegisterCoverClaimsVoucherResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
// MsgAdjudicateCoverCallResponse is the response to MsgAdjudicateCoverCall.
|
||||||
|
type MsgAdjudicateCoverCallResponse struct{}
|
||||||
|
|
||||||
|
// Reset implements proto.Message.
|
||||||
|
func (m *MsgAdjudicateCoverCallResponse) Reset() { *m = MsgAdjudicateCoverCallResponse{} }
|
||||||
|
|
||||||
|
// String implements proto.Message.
|
||||||
|
func (m *MsgAdjudicateCoverCallResponse) String() string { return "MsgAdjudicateCoverCallResponse{}" }
|
||||||
|
|
||||||
|
// ProtoMessage implements proto.Message.
|
||||||
|
func (*MsgAdjudicateCoverCallResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
// MsgSlashCoverClaimsVoucherResponse is the response to
|
||||||
|
// MsgSlashCoverClaimsVoucher.
|
||||||
|
type MsgSlashCoverClaimsVoucherResponse struct{}
|
||||||
|
|
||||||
|
// Reset implements proto.Message.
|
||||||
|
func (m *MsgSlashCoverClaimsVoucherResponse) Reset() { *m = MsgSlashCoverClaimsVoucherResponse{} }
|
||||||
|
|
||||||
|
// String implements proto.Message.
|
||||||
|
func (m *MsgSlashCoverClaimsVoucherResponse) String() string {
|
||||||
|
return "MsgSlashCoverClaimsVoucherResponse{}"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProtoMessage implements proto.Message.
|
||||||
|
func (*MsgSlashCoverClaimsVoucherResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
// MsgDissolveCoverPoolResponse is the response to MsgDissolveCoverPool.
|
||||||
|
// Waterfall reports the FR-MAB-4 seniority chain tiers + amounts (for
|
||||||
|
// simtest assertion: Cover-Fee contributors > MAB > Bread holders).
|
||||||
|
type MsgDissolveCoverPoolResponse struct {
|
||||||
|
Waterfall []PoolDissolutionWaterfall `json:"waterfall" yaml:"waterfall"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset implements proto.Message.
|
||||||
|
func (m *MsgDissolveCoverPoolResponse) Reset() { *m = MsgDissolveCoverPoolResponse{} }
|
||||||
|
|
||||||
|
// String implements proto.Message.
|
||||||
|
func (m *MsgDissolveCoverPoolResponse) String() string {
|
||||||
|
return fmt.Sprintf("MsgDissolveCoverPoolResponse{Waterfall:%d tiers}", len(m.Waterfall))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProtoMessage implements proto.Message.
|
||||||
|
func (*MsgDissolveCoverPoolResponse) ProtoMessage() {}
|
||||||
+160
-6
@@ -86,6 +86,35 @@ const (
|
|||||||
// (the gate const mirrors the bucket boundary). LOCAL to x/cover for
|
// (the gate const mirrors the bucket boundary). LOCAL to x/cover for
|
||||||
// the same G-003 reason as CoverStandingGateTrusted.
|
// the same G-003 reason as CoverStandingGateTrusted.
|
||||||
CoverStandingGatePreferred = 4.5
|
CoverStandingGatePreferred = 4.5
|
||||||
|
|
||||||
|
// CoverClaimsVoucherBondMultipleAvgCall is the bond multiple for a Cover
|
||||||
|
// Claims Voucher: the Voucher's bond is
|
||||||
|
// max(CoverClaimsVoucherBondMultipleAvgCall × avgCallSize,
|
||||||
|
// MinimumVoucherBond) where avgCallSize is the average Cover Call
|
||||||
|
// amount for the Pool (REQ-055). The const is NOT locked (it can be
|
||||||
|
// tuned by governance); the D-090(2) cold-start fix uses the
|
||||||
|
// MinimumVoucherBond Params field as the non-zero fallback when no
|
||||||
|
// Calls have been filed (avg = 0 -> bond = MinimumVoucherBond, NOT
|
||||||
|
// zero).
|
||||||
|
CoverClaimsVoucherBondMultipleAvgCall = 10
|
||||||
|
|
||||||
|
// SlashReasonFraudulentCoverCall is the slash reason for a Cover Claims
|
||||||
|
// Voucher that adjudicated a Cover Call fraudulently (REQ-055). LOCAL
|
||||||
|
// const in x/cover to avoid importing x/standing (G-003 — no struct
|
||||||
|
// import of x/standing/types); cross-documented to
|
||||||
|
// x/standing.SlashReasonFraudulentCoverCall (the two consts MUST stay
|
||||||
|
// in sync — a change to one requires a matching change to the other;
|
||||||
|
// mirroring the LendingCouponCapBps local-const pattern in x/hub). The
|
||||||
|
// MsgSlashCoverClaimsVoucher.ValidateBasic rejects a Reason that does
|
||||||
|
// not match this const.
|
||||||
|
SlashReasonFraudulentCoverCall = "FraudulentCoverCall"
|
||||||
|
|
||||||
|
// DefaultMinimumVoucherBond is the default minimum Cover Claims Voucher
|
||||||
|
// bond (D-090(2) cold-start fix) — 1000000 Grain = 100 Bread (a non-
|
||||||
|
// zero default so a fresh Pool with no Calls filed yet still requires
|
||||||
|
// a non-zero Voucher bond). The Params.MinimumVoucherBond field is
|
||||||
|
// tunable by governance; this is the DefaultParams value.
|
||||||
|
DefaultMinimumVoucherBond int64 = 1_000_000
|
||||||
)
|
)
|
||||||
|
|
||||||
// CoverCategoryPhase enumerates the three rollout phases of the Cover
|
// CoverCategoryPhase enumerates the three rollout phases of the Cover
|
||||||
@@ -191,12 +220,15 @@ type CoverFeeTag struct {
|
|||||||
// + slashing (the FileCoverCall handler in P1 only persists the call +
|
// + slashing (the FileCoverCall handler in P1 only persists the call +
|
||||||
// emits an event).
|
// emits an event).
|
||||||
type CoverCall struct {
|
type CoverCall struct {
|
||||||
CallID string `json:"call_id" yaml:"call_id"`
|
CallID string `json:"call_id" yaml:"call_id"`
|
||||||
PoolID string `json:"pool_id" yaml:"pool_id"`
|
PoolID string `json:"pool_id" yaml:"pool_id"`
|
||||||
ClaimantReachID string `json:"claimant_reach_id" yaml:"claimant_reach_id"`
|
ClaimantReachID string `json:"claimant_reach_id" yaml:"claimant_reach_id"`
|
||||||
Category CoverCategory `json:"category" yaml:"category"`
|
Category CoverCategory `json:"category" yaml:"category"`
|
||||||
AmountGrain int64 `json:"amount_grain" yaml:"amount_grain"`
|
AmountGrain int64 `json:"amount_grain" yaml:"amount_grain"`
|
||||||
FiledAt int64 `json:"filed_at" yaml:"filed_at"`
|
FiledAt int64 `json:"filed_at" yaml:"filed_at"`
|
||||||
|
AdjudicationResult string `json:"adjudication_result" yaml:"adjudication_result"`
|
||||||
|
AdjudicatedBy string `json:"adjudicated_by" yaml:"adjudicated_by"`
|
||||||
|
AdjudicatedAt int64 `json:"adjudicated_at" yaml:"adjudicated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Params for the cover module (REQ-049, D-086). FactoryAllowedPhases is the
|
// Params for the cover module (REQ-049, D-086). FactoryAllowedPhases is the
|
||||||
@@ -209,6 +241,14 @@ type CoverCall struct {
|
|||||||
type Params struct {
|
type Params struct {
|
||||||
FactoryAllowedPhases []CoverCategoryPhase `json:"factory_allowed_phases" yaml:"factory_allowed_phases"`
|
FactoryAllowedPhases []CoverCategoryPhase `json:"factory_allowed_phases" yaml:"factory_allowed_phases"`
|
||||||
PoolStandingGate float64 `json:"pool_standing_gate" yaml:"pool_standing_gate"`
|
PoolStandingGate float64 `json:"pool_standing_gate" yaml:"pool_standing_gate"`
|
||||||
|
// MinimumVoucherBond is the minimum Cover Claims Voucher bond (D-090(2)
|
||||||
|
// cold-start fix — REQ-055). The Voucher's bond is
|
||||||
|
// max(CoverClaimsVoucherBondMultipleAvgCall × avgCallSize,
|
||||||
|
// MinimumVoucherBond); the MinimumVoucherBond is the non-zero fallback
|
||||||
|
// when no Calls have been filed (avg = 0 -> bond = MinimumVoucherBond,
|
||||||
|
// NOT zero). Default = DefaultMinimumVoucherBond (1M Grain = 100
|
||||||
|
// Bread).
|
||||||
|
MinimumVoucherBond int64 `json:"minimum_voucher_bond" yaml:"minimum_voucher_bond"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultParams returns the P2 default Params (D-086 P2 completion):
|
// DefaultParams returns the P2 default Params (D-086 P2 completion):
|
||||||
@@ -223,6 +263,7 @@ func DefaultParams() Params {
|
|||||||
return Params{
|
return Params{
|
||||||
FactoryAllowedPhases: []CoverCategoryPhase{Phase2, Phase3, Phase4},
|
FactoryAllowedPhases: []CoverCategoryPhase{Phase2, Phase3, Phase4},
|
||||||
PoolStandingGate: CoverStandingGateTrusted,
|
PoolStandingGate: CoverStandingGateTrusted,
|
||||||
|
MinimumVoucherBond: DefaultMinimumVoucherBond,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,3 +536,116 @@ type CoverCallVote struct {
|
|||||||
WatcherObserverPresent bool `json:"watcher_observer_present" yaml:"watcher_observer_present"`
|
WatcherObserverPresent bool `json:"watcher_observer_present" yaml:"watcher_observer_present"`
|
||||||
VotedAt int64 `json:"voted_at" yaml:"voted_at"`
|
VotedAt int64 `json:"voted_at" yaml:"voted_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- P4: Cover Claims Voucher + Pool Dissolution Waterfall (REQ-055, REQ-063) --
|
||||||
|
//
|
||||||
|
// (REQ-055, REQ-063; vision §15, §8.2.) The two structs below are the P4
|
||||||
|
// Voucher + dissolution surface. CoverClaimsVoucher is the bonded adjudicator
|
||||||
|
// a Pool Host registers to adjudicate Cover Calls (no self-adjudication per
|
||||||
|
// FR-CPCV-2; slashing via x/standing.Slash with
|
||||||
|
// SlashReasonFraudulentCoverCall for a fraudulent adjudication — cross-Pool
|
||||||
|
// applicability via the Standing bucket drop). PoolDissolutionWaterfall is
|
||||||
|
// the FR-MAB-4 seniority chain on Pool dissolution: Cover-Fee contributors
|
||||||
|
// first, MAB holders second, Bread holders third. MAB holders have NO Voice
|
||||||
|
// in the dissolution decision (REQ-063 — the PoolCouncil from P2 already
|
||||||
|
// excludes them; P4 adds the waterfall + the MsgDissolveCoverPool handler).
|
||||||
|
//
|
||||||
|
// Lexicon note (REQ-012, D-088): "Cover Claims Voucher", "Adjudicate",
|
||||||
|
// "Waterfall", "Dissolution" are lexicon-clean. The four Cover-specific
|
||||||
|
// banned terms NEVER appear (enforced by lexicon_meta_cover).
|
||||||
|
|
||||||
|
// CoverClaimsVoucher is the bonded adjudicator a Pool Host registers to
|
||||||
|
// adjudicate Cover Calls (REQ-055). VoucherReachID is the Voucher's reach-id
|
||||||
|
// (the person adjudicating; by-ID-string ref to x/standing). PoolID is the
|
||||||
|
// pool the Voucher is registered for (a Voucher is registered per-Pool; the
|
||||||
|
// no-self-adjudication check FR-CPCV-2 rejects if VoucherReachID ==
|
||||||
|
// CoverCall.ClaimantReachID). BondAmount is the Voucher's bond = max(
|
||||||
|
// CoverClaimsVoucherBondMultipleAvgCall × avgCallSize, MinimumVoucherBond)
|
||||||
|
// (D-090(2) cold-start: when no Calls exist, avg = 0 -> bond =
|
||||||
|
// MinimumVoucherBond, NOT zero). BondMultipleAvgCall is the multiple used
|
||||||
|
// (CoverClaimsVoucherBondMultipleAvgCall = 10). RegisteredAt is the
|
||||||
|
// registration timestamp.
|
||||||
|
type CoverClaimsVoucher struct {
|
||||||
|
VoucherReachID string `json:"voucher_reach_id" yaml:"voucher_reach_id"`
|
||||||
|
PoolID string `json:"pool_id" yaml:"pool_id"`
|
||||||
|
BondAmount int64 `json:"bond_amount" yaml:"bond_amount"`
|
||||||
|
BondMultipleAvgCall uint32 `json:"bond_multiple_avg_call" yaml:"bond_multiple_avg_call"`
|
||||||
|
RegisteredAt int64 `json:"registered_at" yaml:"registered_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PoolDissolutionWaterfall is a single tier in the FR-MAB-4 seniority chain
|
||||||
|
// on Pool dissolution (REQ-063). The waterfall pays Cover-Fee contributors
|
||||||
|
// first (Tier 1 — the Pool's reserve), MAB holders second (Tier 2 — the
|
||||||
|
// outstanding MAB principal), Bread holders third (Tier 3 — the remainder).
|
||||||
|
// MAB holders have NO Voice in the dissolution decision (REQ-063 — the
|
||||||
|
// PoolCouncil from P2 already excludes them; the waterfall only determines
|
||||||
|
// the payout order, not the vote). The keeper's PoolDissolutionWaterfall
|
||||||
|
// function returns the []PoolDissolutionWaterfall (the types package
|
||||||
|
// declares the shape; the keeper computes the amounts).
|
||||||
|
type PoolDissolutionWaterfall struct {
|
||||||
|
Tier string `json:"tier" yaml:"tier"`
|
||||||
|
AmountGrain int64 `json:"amount_grain" yaml:"amount_grain"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PoolDissolutionWaterfallTier* are the three FR-MAB-4 seniority chain tier
|
||||||
|
// names (REQ-063). The waterfall returns the tiers in this order:
|
||||||
|
// CoverFeeContributors (Tier 1), MABHolders (Tier 2), BreadHolders (Tier 3).
|
||||||
|
const (
|
||||||
|
PoolDissolutionWaterfallTierCoverFeeContributors = "CoverFeeContributors"
|
||||||
|
PoolDissolutionWaterfallTierMABHolders = "MABHolders"
|
||||||
|
PoolDissolutionWaterfallTierBreadHolders = "BreadHolders"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- P5: Pier Selection Index + Pier Selection Record (REQ-066) ----------------
|
||||||
|
//
|
||||||
|
// (REQ-066; vision §15.) The two structs below are the P5 Pier Selection
|
||||||
|
// surface. PierSelectionIndex is the mesh-maintained index of a Pier's
|
||||||
|
// scored qualities (JurisdictionalReliabilityScore + FiduciaryRecordHash +
|
||||||
|
// IntegrationQualityScore + OverallScore) — the keeper's
|
||||||
|
// GetPierSelectionIndex query returns the index for a PierID.
|
||||||
|
// PierSelectionRecord is a single Guild's persisted Pier selection
|
||||||
|
// (GuildID + PierID + SelectedAt + SelectedBy) keyed by GuildID. The
|
||||||
|
// MsgSelectPier handler persists the selection + creates or updates the
|
||||||
|
// index entry for the PierID (accumulating scores from successive
|
||||||
|
// selections). MsgRevokePierSelection removes a selection (reversible by
|
||||||
|
// Cover Pool supermajority + Counsel witness).
|
||||||
|
//
|
||||||
|
// Lexicon note (REQ-012, D-088): "Pier", "Pier Selection", "Pier Selection
|
||||||
|
// Index", "Jurisdictional Reliability", "Fiduciary Record", "Integration
|
||||||
|
// Quality" are lexicon-clean. The four Cover-specific banned terms NEVER
|
||||||
|
// appear (enforced by lexicon_meta_cover).
|
||||||
|
|
||||||
|
// PierSelectionIndex is the mesh-maintained index of a Pier's scored
|
||||||
|
// qualities (REQ-066). The keeper stores PierSelectionIndex records keyed
|
||||||
|
// by PierID; the GetPierSelectionIndex(pierID) keeper query returns the
|
||||||
|
// index. JurisdictionalReliabilityScore is the jurisdictional-reliability
|
||||||
|
// score (a float in [0,1] — the mesh's assessment of the Pier's
|
||||||
|
// jurisdictional reliability). FiduciaryRecordHash is the hash of the
|
||||||
|
// Pier's fiduciary record (the protocol does NOT parse the record — the
|
||||||
|
// hash pins it). IntegrationQualityScore is the integration-quality score
|
||||||
|
// (a float in [0,1]). OverallScore is the weighted aggregate (the keeper
|
||||||
|
// computes it as a deterministic blend of the three scores; the simtest
|
||||||
|
// asserts the aggregate is non-decreasing on successive selections).
|
||||||
|
type PierSelectionIndex struct {
|
||||||
|
PierID string `json:"pier_id" yaml:"pier_id"`
|
||||||
|
JurisdictionalReliabilityScore float64 `json:"jurisdictional_reliability_score" yaml:"jurisdictional_reliability_score"`
|
||||||
|
FiduciaryRecordHash []byte `json:"fiduciary_record_hash" yaml:"fiduciary_record_hash"`
|
||||||
|
IntegrationQualityScore float64 `json:"integration_quality_score" yaml:"integration_quality_score"`
|
||||||
|
OverallScore float64 `json:"overall_score" yaml:"overall_score"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PierSelectionRecord is a single Guild's persisted Pier selection
|
||||||
|
// (REQ-066). The keeper stores PierSelectionRecord records keyed by
|
||||||
|
// GuildID; the MsgSelectPier handler persists a record + the
|
||||||
|
// MsgRevokePierSelection handler removes it. GuildID is the selecting
|
||||||
|
// Guild's ID (by-ID-string — G-003). PierID is the selected Pier's ID
|
||||||
|
// (by-ID-string). SelectedAt is the selection timestamp (unix seconds).
|
||||||
|
// SelectedBy is the signer that recorded the selection (the Guild's
|
||||||
|
// FounderReach or a Council member — simtest-grade: the handler checks
|
||||||
|
// non-empty).
|
||||||
|
type PierSelectionRecord struct {
|
||||||
|
GuildID string `json:"guild_id" yaml:"guild_id"`
|
||||||
|
PierID string `json:"pier_id" yaml:"pier_id"`
|
||||||
|
SelectedAt int64 `json:"selected_at" yaml:"selected_at"`
|
||||||
|
SelectedBy string `json:"selected_by" yaml:"selected_by"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -377,3 +377,181 @@ func TestP2StructConstruction(t *testing.T) {
|
|||||||
t.Errorf("CoverPool P2 refs = %q/%q", p.CharterRef, p.CouncilRef)
|
t.Errorf("CoverPool P2 refs = %q/%q", p.CharterRef, p.CouncilRef)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- P4: Cover Claims Voucher + Dissolution consts (REQ-055, REQ-063, D-090(2)) -
|
||||||
|
|
||||||
|
// TestP4VoucherAndDissolutionConsts asserts the P4 consts hold their
|
||||||
|
// values (REQ-055 voucher bond multiple, REQ-055 slash reason,
|
||||||
|
// D-090(2) cold-start minimum voucher bond).
|
||||||
|
func TestP4VoucherAndDissolutionConsts(t *testing.T) {
|
||||||
|
// REQ-055: Cover Claims Voucher bond multiple == 10.
|
||||||
|
if CoverClaimsVoucherBondMultipleAvgCall != 10 {
|
||||||
|
t.Errorf("CoverClaimsVoucherBondMultipleAvgCall = %d, want 10 (REQ-055)", CoverClaimsVoucherBondMultipleAvgCall)
|
||||||
|
}
|
||||||
|
// REQ-055: slash reason const (cross-doc x/standing).
|
||||||
|
if SlashReasonFraudulentCoverCall != "FraudulentCoverCall" {
|
||||||
|
t.Errorf("SlashReasonFraudulentCoverCall = %q, want %q (REQ-055 cross-doc x/standing)", SlashReasonFraudulentCoverCall, "FraudulentCoverCall")
|
||||||
|
}
|
||||||
|
// D-090(2): default minimum voucher bond (1M Grain = 100 Bread).
|
||||||
|
if DefaultMinimumVoucherBond != 1_000_000 {
|
||||||
|
t.Errorf("DefaultMinimumVoucherBond = %d, want 1000000 (D-090(2) cold-start default)", DefaultMinimumVoucherBond)
|
||||||
|
}
|
||||||
|
// FR-MAB-4 waterfall tier names.
|
||||||
|
if PoolDissolutionWaterfallTierCoverFeeContributors != "CoverFeeContributors" {
|
||||||
|
t.Errorf("Tier CoverFeeContributors = %q", PoolDissolutionWaterfallTierCoverFeeContributors)
|
||||||
|
}
|
||||||
|
if PoolDissolutionWaterfallTierMABHolders != "MABHolders" {
|
||||||
|
t.Errorf("Tier MABHolders = %q", PoolDissolutionWaterfallTierMABHolders)
|
||||||
|
}
|
||||||
|
if PoolDissolutionWaterfallTierBreadHolders != "BreadHolders" {
|
||||||
|
t.Errorf("Tier BreadHolders = %q", PoolDissolutionWaterfallTierBreadHolders)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDefaultParamsMinimumVoucherBond asserts DefaultParams ships a non-zero
|
||||||
|
// MinimumVoucherBond (D-090(2) cold-start fix — the Voucher bond falls back
|
||||||
|
// to this when no Calls exist, NOT zero).
|
||||||
|
func TestDefaultParamsMinimumVoucherBond(t *testing.T) {
|
||||||
|
p := DefaultParams()
|
||||||
|
if p.MinimumVoucherBond != DefaultMinimumVoucherBond {
|
||||||
|
t.Errorf("DefaultParams MinimumVoucherBond = %d, want %d (D-090(2) cold-start default)", p.MinimumVoucherBond, DefaultMinimumVoucherBond)
|
||||||
|
}
|
||||||
|
if p.MinimumVoucherBond <= 0 {
|
||||||
|
t.Errorf("DefaultParams MinimumVoucherBond = %d, must be > 0 (D-090(2) — never zero)", p.MinimumVoucherBond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCoverClaimsVoucherStruct asserts the CoverClaimsVoucher struct carries
|
||||||
|
// the required fields (REQ-055).
|
||||||
|
func TestCoverClaimsVoucherStruct(t *testing.T) {
|
||||||
|
v := CoverClaimsVoucher{
|
||||||
|
VoucherReachID: "voucher-1",
|
||||||
|
PoolID: "pool-1",
|
||||||
|
BondAmount: 1_000_000,
|
||||||
|
BondMultipleAvgCall: CoverClaimsVoucherBondMultipleAvgCall,
|
||||||
|
RegisteredAt: 1000,
|
||||||
|
}
|
||||||
|
if v.VoucherReachID != "voucher-1" {
|
||||||
|
t.Errorf("VoucherReachID = %q", v.VoucherReachID)
|
||||||
|
}
|
||||||
|
if v.BondAmount != 1_000_000 {
|
||||||
|
t.Errorf("BondAmount = %d", v.BondAmount)
|
||||||
|
}
|
||||||
|
if v.BondMultipleAvgCall != 10 {
|
||||||
|
t.Errorf("BondMultipleAvgCall = %d, want 10", v.BondMultipleAvgCall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPoolDissolutionWaterfallStruct asserts the PoolDissolutionWaterfall
|
||||||
|
// struct carries the Tier + AmountGrain fields (REQ-063, FR-MAB-4).
|
||||||
|
func TestPoolDissolutionWaterfallStruct(t *testing.T) {
|
||||||
|
w := PoolDissolutionWaterfall{
|
||||||
|
Tier: PoolDissolutionWaterfallTierCoverFeeContributors,
|
||||||
|
AmountGrain: 1_000_000,
|
||||||
|
}
|
||||||
|
if w.Tier != "CoverFeeContributors" {
|
||||||
|
t.Errorf("Tier = %q", w.Tier)
|
||||||
|
}
|
||||||
|
if w.AmountGrain != 1_000_000 {
|
||||||
|
t.Errorf("AmountGrain = %d", w.AmountGrain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCoverCallAdjudicationFields asserts the CoverCall struct carries the
|
||||||
|
// P4 adjudication fields (AdjudicationResult + AdjudicatedBy + AdjudicatedAt
|
||||||
|
// — additive; existing CoverCall records keep zero values).
|
||||||
|
func TestCoverCallAdjudicationFields(t *testing.T) {
|
||||||
|
c := CoverCall{
|
||||||
|
CallID: "c1",
|
||||||
|
PoolID: "p1",
|
||||||
|
ClaimantReachID: "u1",
|
||||||
|
Category: CatTravel,
|
||||||
|
AmountGrain: 100,
|
||||||
|
FiledAt: 1000,
|
||||||
|
AdjudicationResult: "Approved",
|
||||||
|
AdjudicatedBy: "voucher-1",
|
||||||
|
AdjudicatedAt: 2000,
|
||||||
|
}
|
||||||
|
if c.AdjudicationResult != "Approved" {
|
||||||
|
t.Errorf("AdjudicationResult = %q", c.AdjudicationResult)
|
||||||
|
}
|
||||||
|
if c.AdjudicatedBy != "voucher-1" {
|
||||||
|
t.Errorf("AdjudicatedBy = %q", c.AdjudicatedBy)
|
||||||
|
}
|
||||||
|
if c.AdjudicatedAt != 2000 {
|
||||||
|
t.Errorf("AdjudicatedAt = %d", c.AdjudicatedAt)
|
||||||
|
}
|
||||||
|
// Default zero-value (additive — existing CoverCall records unchanged).
|
||||||
|
var c2 CoverCall
|
||||||
|
if c2.AdjudicationResult != "" || c2.AdjudicatedBy != "" || c2.AdjudicatedAt != 0 {
|
||||||
|
t.Error("zero-value CoverCall adjudication fields should be empty (additive)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMABRefStruct asserts the MABRef struct (the lightweight by-value MAB
|
||||||
|
// reference for the dissolution waterfall Tier 2) carries the BondID +
|
||||||
|
// PrincipalGrain fields (G-003 — no struct import of x/bond/types).
|
||||||
|
func TestMABRefStruct(t *testing.T) {
|
||||||
|
m := MABRef{BondID: "mab-1", PrincipalGrain: 1_000_000}
|
||||||
|
if m.BondID != "mab-1" {
|
||||||
|
t.Errorf("MABRef BondID = %q", m.BondID)
|
||||||
|
}
|
||||||
|
if m.PrincipalGrain != 1_000_000 {
|
||||||
|
t.Errorf("MABRef PrincipalGrain = %d", m.PrincipalGrain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- P5: Pier Selection Index + Pier Selection Record consts (REQ-066) ---------
|
||||||
|
|
||||||
|
// TestPierSelectionIndexStruct asserts the PierSelectionIndex struct
|
||||||
|
// carries the five required fields (PierID, JurisdictionalReliabilityScore,
|
||||||
|
// FiduciaryRecordHash, IntegrationQualityScore, OverallScore) — a
|
||||||
|
// compile-time + runtime regression firewall (REQ-066).
|
||||||
|
func TestPierSelectionIndexStruct(t *testing.T) {
|
||||||
|
idx := PierSelectionIndex{
|
||||||
|
PierID: "pier-1",
|
||||||
|
JurisdictionalReliabilityScore: 0.9,
|
||||||
|
FiduciaryRecordHash: []byte{1, 2, 3},
|
||||||
|
IntegrationQualityScore: 0.8,
|
||||||
|
OverallScore: 0.85,
|
||||||
|
}
|
||||||
|
if idx.PierID != "pier-1" {
|
||||||
|
t.Errorf("PierID = %q", idx.PierID)
|
||||||
|
}
|
||||||
|
if idx.JurisdictionalReliabilityScore != 0.9 {
|
||||||
|
t.Errorf("JurisdictionalReliabilityScore = %.2f", idx.JurisdictionalReliabilityScore)
|
||||||
|
}
|
||||||
|
if len(idx.FiduciaryRecordHash) != 3 {
|
||||||
|
t.Errorf("FiduciaryRecordHash len = %d", len(idx.FiduciaryRecordHash))
|
||||||
|
}
|
||||||
|
if idx.IntegrationQualityScore != 0.8 {
|
||||||
|
t.Errorf("IntegrationQualityScore = %.2f", idx.IntegrationQualityScore)
|
||||||
|
}
|
||||||
|
if idx.OverallScore != 0.85 {
|
||||||
|
t.Errorf("OverallScore = %.2f", idx.OverallScore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPierSelectionRecordStruct asserts the PierSelectionRecord struct
|
||||||
|
// carries the four required fields (GuildID, PierID, SelectedAt,
|
||||||
|
// SelectedBy) — a compile-time + runtime regression firewall (REQ-066).
|
||||||
|
func TestPierSelectionRecordStruct(t *testing.T) {
|
||||||
|
rec := PierSelectionRecord{
|
||||||
|
GuildID: "guild-1",
|
||||||
|
PierID: "pier-1",
|
||||||
|
SelectedAt: 12345,
|
||||||
|
SelectedBy: "reach:founder",
|
||||||
|
}
|
||||||
|
if rec.GuildID != "guild-1" {
|
||||||
|
t.Errorf("GuildID = %q", rec.GuildID)
|
||||||
|
}
|
||||||
|
if rec.PierID != "pier-1" {
|
||||||
|
t.Errorf("PierID = %q", rec.PierID)
|
||||||
|
}
|
||||||
|
if rec.SelectedAt != 12345 {
|
||||||
|
t.Errorf("SelectedAt = %d", rec.SelectedAt)
|
||||||
|
}
|
||||||
|
if rec.SelectedBy != "reach:founder" {
|
||||||
|
t.Errorf("SelectedBy = %q", rec.SelectedBy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -273,3 +273,114 @@ func prefixEnd(prefix []byte) []byte {
|
|||||||
// All bytes were 0xFF; return nil (iterate to end of store).
|
// All bytes were 0xFF; return nil (iterate to end of store).
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- P5: Stand→Pier eligibility + acceptance stores (REQ-059, D-074) -----------
|
||||||
|
//
|
||||||
|
// (REQ-059, D-074). Two new stores. The pier_eligible/ store is keyed by
|
||||||
|
// StandID -> bool (the MsgEscalateStandToPier handler sets true when the
|
||||||
|
// Stand's annual Pass volume exceeds StandPierEscalationAnnualPassVolumeCents).
|
||||||
|
// The pier_accepted/ store is keyed by StandID -> bool (the
|
||||||
|
// MsgAcceptPierInvitation handler sets true when the Stand accepts the Pier
|
||||||
|
// invitation; the handler REJECTS if the Stand is not Pier-eligible). Both
|
||||||
|
// stores hold a single byte (0x00 = false, 0x01 = true) — no JSON marshal
|
||||||
|
// needed for a single bool.
|
||||||
|
|
||||||
|
var pierEligibleKeyPrefix = []byte("pier_eligible/")
|
||||||
|
|
||||||
|
func pierEligibleKey(standID string) []byte {
|
||||||
|
return append(pierEligibleKeyPrefix, []byte(standID)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStandPierEligible loads a Stand's Pier-eligibility flag (REQ-059,
|
||||||
|
// D-074). Returns true if the Stand was marked Pier-eligible by the
|
||||||
|
// MsgEscalateStandToPier handler, false otherwise.
|
||||||
|
func (k Keeper) GetStandPierEligible(ctx sdk.Context, standID string) bool {
|
||||||
|
store := ctx.KVStore(k.storeKey)
|
||||||
|
bz := store.Get(pierEligibleKey(standID))
|
||||||
|
return len(bz) == 1 && bz[0] == 0x01
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetStandPierEligible persists a Stand's Pier-eligibility flag.
|
||||||
|
func (k Keeper) SetStandPierEligible(ctx sdk.Context, standID string, eligible bool) {
|
||||||
|
store := ctx.KVStore(k.storeKey)
|
||||||
|
v := []byte{0x00}
|
||||||
|
if eligible {
|
||||||
|
v = []byte{0x01}
|
||||||
|
}
|
||||||
|
store.Set(pierEligibleKey(standID), v)
|
||||||
|
}
|
||||||
|
|
||||||
|
var pierAcceptedKeyPrefix = []byte("pier_accepted/")
|
||||||
|
|
||||||
|
func pierAcceptedKey(standID string) []byte {
|
||||||
|
return append(pierAcceptedKeyPrefix, []byte(standID)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStandPierAccepted loads a Stand's Pier-acceptance flag (REQ-059,
|
||||||
|
// D-074). Returns true if the Stand accepted the Pier invitation via the
|
||||||
|
// MsgAcceptPierInvitation handler, false otherwise.
|
||||||
|
func (k Keeper) GetStandPierAccepted(ctx sdk.Context, standID string) bool {
|
||||||
|
store := ctx.KVStore(k.storeKey)
|
||||||
|
bz := store.Get(pierAcceptedKey(standID))
|
||||||
|
return len(bz) == 1 && bz[0] == 0x01
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetStandPierAccepted persists a Stand's Pier-acceptance flag.
|
||||||
|
func (k Keeper) SetStandPierAccepted(ctx sdk.Context, standID string, accepted bool) {
|
||||||
|
store := ctx.KVStore(k.storeKey)
|
||||||
|
v := []byte{0x00}
|
||||||
|
if accepted {
|
||||||
|
v = []byte{0x01}
|
||||||
|
}
|
||||||
|
store.Set(pierAcceptedKey(standID), v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- P5: Secession lien-audit helper (REQ-064) --------------------------------
|
||||||
|
//
|
||||||
|
// CheckLiensCleared returns true if every lien on the named Guild (both the
|
||||||
|
// founding-locked liens on the Guild's GoodStandingLiens slice + the post-
|
||||||
|
// founding liens in the lien/ store) has Cleared=true OR Amount=0 (a
|
||||||
|
// cleared-or-zero lien passes the audit). The MsgInitiateSecession +
|
||||||
|
// MsgCompleteSecession handlers consult this. A Guild with no liens returns
|
||||||
|
// true (the audit passes vacuously).
|
||||||
|
func (k Keeper) CheckLiensCleared(ctx sdk.Context, guildID string) bool {
|
||||||
|
g, ok := k.GetGuild(ctx, guildID)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, l := range g.GoodStandingLiens {
|
||||||
|
if l.Amount != 0 && !l.Cleared {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, l := range k.AllLiens(ctx, guildID) {
|
||||||
|
if l.Amount != 0 && !l.Cleared {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChapterIsCoverActive returns true if the named Chapter has any lien
|
||||||
|
// (founding-locked or post-founding) that references a Cover Pool covenant
|
||||||
|
// (CoverPoolCovenantRef non-empty). The MsgCompleteSecession handler
|
||||||
|
// consults this to choose the cooling period: Cover-active = 21d, non-Cover
|
||||||
|
// = 14d (REQ-064). A Guild that is not found or is not a Chapter returns
|
||||||
|
// false (the handler rejects non-Chapters upstream).
|
||||||
|
func (k Keeper) ChapterIsCoverActive(ctx sdk.Context, guildID string) bool {
|
||||||
|
g, ok := k.GetGuild(ctx, guildID)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, l := range g.GoodStandingLiens {
|
||||||
|
if l.CoverPoolCovenantRef != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, l := range k.AllLiens(ctx, guildID) {
|
||||||
|
if l.CoverPoolCovenantRef != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -331,3 +331,216 @@ func (s msgServer) AddLien(ctx interface{}, msg *types.MsgAddLien) (*types.MsgAd
|
|||||||
))
|
))
|
||||||
return &types.MsgAddLienResponse{}, nil
|
return &types.MsgAddLienResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- v0.7 P5: Secession + Stand→Pier escalation handlers (REQ-064, REQ-059) ---
|
||||||
|
//
|
||||||
|
// (REQ-064 secession cooling enforcement, REQ-059/D-074 Stand→Pier boundary.)
|
||||||
|
// The four handlers exercise the secession lifecycle (initiate + complete
|
||||||
|
// with the Cover-active 21d / non-Cover 14d cooling) + the Stand→Pier
|
||||||
|
// escalation soft-upgrade (eligibility flag + acceptance).
|
||||||
|
|
||||||
|
// InitiateSecession initiates a Chapter's secession (REQ-064). The handler
|
||||||
|
// enforces:
|
||||||
|
// 1. ValidateBasic (stateless).
|
||||||
|
// 2. The Guild must exist + be a Chapter (IsChapter=true). A non-Chapter
|
||||||
|
// Guild REJECTS (a Parent Guild does not secede).
|
||||||
|
// 3. The Chapter must not have already initiated (SecessionStartedAt == 0;
|
||||||
|
// a second initiation REJECTS — use CompleteSecession or reset).
|
||||||
|
// 4. Set SecessionStartedAt = now. Persist the Chapter.
|
||||||
|
// 5. Invoke the lien audit (CheckLiensCleared — true if every lien has
|
||||||
|
// Cleared=true or Amount=0). The audit result is returned in the
|
||||||
|
// response (LienAuditPassed) for simtest assertion; the handler still
|
||||||
|
// records SecessionStartedAt so the cooling clock starts regardless
|
||||||
|
// (the lien audit is re-checked at completion — an outstanding lien at
|
||||||
|
// completion REJECTS).
|
||||||
|
// 6. Emit guild.secession_initiated.
|
||||||
|
func (s msgServer) InitiateSecession(ctx interface{}, msg *types.MsgInitiateSecession) (*types.MsgInitiateSecessionResponse, error) {
|
||||||
|
if err := msg.ValidateBasic(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sdkCtx := unwrapCtx(ctx)
|
||||||
|
|
||||||
|
g, ok := s.Keeper.GetGuild(sdkCtx, msg.GuildID)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("guild: guild %q not found (InitiateSecession rejected)", msg.GuildID)
|
||||||
|
}
|
||||||
|
if !g.IsChapter {
|
||||||
|
return nil, fmt.Errorf("guild: guild %q is not a Chapter (a Parent Guild does not secede — REQ-064)", msg.GuildID)
|
||||||
|
}
|
||||||
|
if g.SecessionStartedAt > 0 {
|
||||||
|
return nil, fmt.Errorf("guild: chapter %q already initiated secession (SecessionStartedAt=%d — use CompleteSecession — REQ-064)", msg.GuildID, g.SecessionStartedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
g.SecessionStartedAt = sdkCtx.BlockTime().Unix()
|
||||||
|
s.Keeper.SetGuild(sdkCtx, g)
|
||||||
|
|
||||||
|
lienAuditPassed := s.Keeper.CheckLiensCleared(sdkCtx, msg.GuildID)
|
||||||
|
|
||||||
|
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||||
|
"guild.secession_initiated",
|
||||||
|
sdk.NewAttribute("guild_id", msg.GuildID),
|
||||||
|
sdk.NewAttribute("secession_started_at", fmt.Sprintf("%d", g.SecessionStartedAt)),
|
||||||
|
sdk.NewAttribute("lien_audit_passed", fmt.Sprintf("%v", lienAuditPassed)),
|
||||||
|
))
|
||||||
|
return &types.MsgInitiateSecessionResponse{LienAuditPassed: lienAuditPassed}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompleteSecession completes a Chapter's secession (REQ-064). The handler
|
||||||
|
// enforces:
|
||||||
|
// 1. ValidateBasic (stateless).
|
||||||
|
// 2. The Guild must exist + be a Chapter.
|
||||||
|
// 3. SecessionStartedAt > 0 (secession was initiated).
|
||||||
|
// 4. Cooling check (REQ-064): compute coolingSeconds based on whether the
|
||||||
|
// Chapter is Cover-active (any lien references a Cover Pool covenant).
|
||||||
|
// Cover-active: CoolingSecessionCoverActiveDays*86400 (21d). Non-Cover:
|
||||||
|
// CoolingSecessionNonCoverDays*86400 (14d). Check now >=
|
||||||
|
// SecessionStartedAt + coolingSeconds. If not, REJECT with "secession
|
||||||
|
// cooling not elapsed".
|
||||||
|
// 5. Lien audit: CheckLiensCleared must return true (all liens Cleared or
|
||||||
|
// Amount=0). If not, REJECT.
|
||||||
|
// 6. Covenant clearance: the Msg's CovenantClearancePassed must be true
|
||||||
|
// (simtest-grade — the live Cover Pool covenant clearance is a v0.8+
|
||||||
|
// concern). If not, REJECT.
|
||||||
|
// 7. Pro-rata Cover-Fee settlement: emit guild.pro_rata_settlement with
|
||||||
|
// the ProRataSettlementGrain from the Msg (the actual settlement is a
|
||||||
|
// v0.8+ Grain-ledger concern).
|
||||||
|
// 8. Set SecededAt = now. Persist the Chapter. Emit
|
||||||
|
// guild.secession_completed.
|
||||||
|
func (s msgServer) CompleteSecession(ctx interface{}, msg *types.MsgCompleteSecession) (*types.MsgCompleteSecessionResponse, error) {
|
||||||
|
if err := msg.ValidateBasic(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sdkCtx := unwrapCtx(ctx)
|
||||||
|
|
||||||
|
g, ok := s.Keeper.GetGuild(sdkCtx, msg.GuildID)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("guild: guild %q not found (CompleteSecession rejected)", msg.GuildID)
|
||||||
|
}
|
||||||
|
if !g.IsChapter {
|
||||||
|
return nil, fmt.Errorf("guild: guild %q is not a Chapter (a Parent Guild does not secede — REQ-064)", msg.GuildID)
|
||||||
|
}
|
||||||
|
if g.SecessionStartedAt == 0 {
|
||||||
|
return nil, fmt.Errorf("guild: chapter %q has not initiated secession (SecessionStartedAt=0 — call InitiateSecession first — REQ-064)", msg.GuildID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cooling check (REQ-064): compute coolingSeconds based on whether the
|
||||||
|
// Chapter is Cover-active (any lien references a Cover Pool covenant).
|
||||||
|
// Cover-active: 21d. Non-Cover: 14d.
|
||||||
|
var coolingSeconds int64
|
||||||
|
coverActive := s.Keeper.ChapterIsCoverActive(sdkCtx, msg.GuildID)
|
||||||
|
if coverActive {
|
||||||
|
coolingSeconds = int64(types.CoolingSecessionCoverActiveDays) * 24 * 60 * 60
|
||||||
|
} else {
|
||||||
|
coolingSeconds = int64(types.CoolingSecessionNonCoverDays) * 24 * 60 * 60
|
||||||
|
}
|
||||||
|
now := sdkCtx.BlockTime().Unix()
|
||||||
|
if now-g.SecessionStartedAt < coolingSeconds {
|
||||||
|
return nil, fmt.Errorf("guild: secession cooling not elapsed (now=%d, SecessionStartedAt=%d, cooling=%d seconds, elapsed=%d — REQ-064)",
|
||||||
|
now, g.SecessionStartedAt, coolingSeconds, now-g.SecessionStartedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lien audit: all liens must be Cleared (or Amount=0).
|
||||||
|
if !s.Keeper.CheckLiensCleared(sdkCtx, msg.GuildID) {
|
||||||
|
return nil, fmt.Errorf("guild: lien audit failed — outstanding liens remain (REQ-064 — all Good-Standing Liens must be cleared before secession completes)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Covenant clearance: the Msg's CovenantClearancePassed must be true
|
||||||
|
// (simtest-grade — the live Cover Pool covenant clearance is a v0.8+
|
||||||
|
// concern).
|
||||||
|
if !msg.CovenantClearancePassed {
|
||||||
|
return nil, fmt.Errorf("guild: covenant clearance failed (CovenantClearancePassed=false — REQ-064 — all Cover Pool covenants must be cleared before secession completes)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pro-rata Cover-Fee settlement: emit the event with the settlement
|
||||||
|
// amount (the actual settlement is a v0.8+ Grain-ledger concern — the
|
||||||
|
// simtest-grade ProRataSettlementGrain on the Msg carries the amount).
|
||||||
|
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||||
|
"guild.pro_rata_settlement",
|
||||||
|
sdk.NewAttribute("guild_id", msg.GuildID),
|
||||||
|
sdk.NewAttribute("pro_rata_settlement_grain", fmt.Sprintf("%d", msg.ProRataSettlementGrain)),
|
||||||
|
))
|
||||||
|
|
||||||
|
// Mark the Chapter as seceded.
|
||||||
|
g.SecededAt = now
|
||||||
|
s.Keeper.SetGuild(sdkCtx, g)
|
||||||
|
|
||||||
|
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||||
|
"guild.secession_completed",
|
||||||
|
sdk.NewAttribute("guild_id", msg.GuildID),
|
||||||
|
sdk.NewAttribute("seceded_at", fmt.Sprintf("%d", g.SecededAt)),
|
||||||
|
sdk.NewAttribute("cooling_seconds", fmt.Sprintf("%d", coolingSeconds)),
|
||||||
|
sdk.NewAttribute("cover_active", fmt.Sprintf("%v", coverActive)),
|
||||||
|
))
|
||||||
|
return &types.MsgCompleteSecessionResponse{
|
||||||
|
CoolingSeconds: coolingSeconds,
|
||||||
|
ProRataSettlementGrain: msg.ProRataSettlementGrain,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EscalateStandToPier escalates a Stand to Pier-eligibility (REQ-059,
|
||||||
|
// D-074). The handler enforces:
|
||||||
|
// 1. ValidateBasic (stateless — non-empty StandID + AnnualPassVolumeCents
|
||||||
|
// > 0).
|
||||||
|
// 2. Compare AnnualPassVolumeCents against
|
||||||
|
// StandPierEscalationAnnualPassVolumeCents (the D-074 const, imported
|
||||||
|
// from x/stand/types — G-003-clean: consts are not structs).
|
||||||
|
// 3. If AnnualPassVolumeCents > the const: set the Stand-Pier-eligible
|
||||||
|
// flag (pier_eligible/ store: StandID -> true). Soft upgrade, not a
|
||||||
|
// ban — the Stand may decline (the flag is set + the event is emitted,
|
||||||
|
// but no enforcement follows; the Stand must separately accept via
|
||||||
|
// MsgAcceptPierInvitation). If the volume does NOT exceed the const:
|
||||||
|
// the flag is NOT set (the response PierEligible=false; the event is
|
||||||
|
// still emitted for observability).
|
||||||
|
// 4. Emit guild.stand_pier_eligible (with PierEligible=true) OR
|
||||||
|
// guild.stand_pier_escalation_below_threshold (with PierEligible=false).
|
||||||
|
func (s msgServer) EscalateStandToPier(ctx interface{}, msg *types.MsgEscalateStandToPier) (*types.MsgEscalateStandToPierResponse, error) {
|
||||||
|
if err := msg.ValidateBasic(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sdkCtx := unwrapCtx(ctx)
|
||||||
|
|
||||||
|
pierEligible := msg.AnnualPassVolumeCents > types.StandPierEscalationAnnualPassVolumeCents
|
||||||
|
if pierEligible {
|
||||||
|
s.Keeper.SetStandPierEligible(sdkCtx, msg.StandID, true)
|
||||||
|
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||||
|
"guild.stand_pier_eligible",
|
||||||
|
sdk.NewAttribute("stand_id", msg.StandID),
|
||||||
|
sdk.NewAttribute("annual_pass_volume_cents", fmt.Sprintf("%d", msg.AnnualPassVolumeCents)),
|
||||||
|
sdk.NewAttribute("threshold_cents", fmt.Sprintf("%d", types.StandPierEscalationAnnualPassVolumeCents)),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||||
|
"guild.stand_pier_escalation_below_threshold",
|
||||||
|
sdk.NewAttribute("stand_id", msg.StandID),
|
||||||
|
sdk.NewAttribute("annual_pass_volume_cents", fmt.Sprintf("%d", msg.AnnualPassVolumeCents)),
|
||||||
|
sdk.NewAttribute("threshold_cents", fmt.Sprintf("%d", types.StandPierEscalationAnnualPassVolumeCents)),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return &types.MsgEscalateStandToPierResponse{PierEligible: pierEligible}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AcceptPierInvitation records a Stand's acceptance of a Pier invitation
|
||||||
|
// (REQ-059, D-074). The handler enforces:
|
||||||
|
// 1. ValidateBasic (stateless).
|
||||||
|
// 2. The Stand must be Pier-eligible (the flag set by
|
||||||
|
// MsgEscalateStandToPier). If not, REJECT.
|
||||||
|
// 3. Record the acceptance (pier_accepted/ store: StandID -> true). Emit
|
||||||
|
// guild.stand_pier_accepted.
|
||||||
|
func (s msgServer) AcceptPierInvitation(ctx interface{}, msg *types.MsgAcceptPierInvitation) (*types.MsgAcceptPierInvitationResponse, error) {
|
||||||
|
if err := msg.ValidateBasic(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sdkCtx := unwrapCtx(ctx)
|
||||||
|
|
||||||
|
if !s.Keeper.GetStandPierEligible(sdkCtx, msg.StandID) {
|
||||||
|
return nil, fmt.Errorf("guild: stand %q is not Pier-eligible (call EscalateStandToPier first — REQ-059/D-074)", msg.StandID)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.Keeper.SetStandPierAccepted(sdkCtx, msg.StandID, true)
|
||||||
|
|
||||||
|
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||||
|
"guild.stand_pier_accepted",
|
||||||
|
sdk.NewAttribute("stand_id", msg.StandID),
|
||||||
|
))
|
||||||
|
return &types.MsgAcceptPierInvitationResponse{}, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -976,3 +976,784 @@ func TestKeeperAccessors(t *testing.T) {
|
|||||||
type sentinelErr string
|
type sentinelErr string
|
||||||
|
|
||||||
func (e sentinelErr) Error() string { return string(e) }
|
func (e sentinelErr) Error() string { return string(e) }
|
||||||
|
|
||||||
|
// --- P5: Secession cooling + Stand→Pier escalation (REQ-064, REQ-059, D-074) ---
|
||||||
|
//
|
||||||
|
// (REQ-064 secession cooling enforcement, REQ-059/D-074 Stand→Pier boundary.)
|
||||||
|
// The P5 simtest cases exercise the secession lifecycle (initiate + complete
|
||||||
|
// with the Cover-active 21d / non-Cover 14d cooling), the lien-audit +
|
||||||
|
// covenant-clearance gates, and the Stand→Pier escalation soft-upgrade
|
||||||
|
// (eligibility flag + acceptance + decline).
|
||||||
|
|
||||||
|
// createChapterForSecession is a helper that creates a Parent Guild + a
|
||||||
|
// Chapter under it with the given liens (so the secession simtest cases
|
||||||
|
// have a Chapter to operate on). The Chapter's SecessionTerms are at the
|
||||||
|
// protocol minimums. The liens are recorded as founding-locked
|
||||||
|
// (SecuredAtFounding=true — the CreateChapter handler requires this).
|
||||||
|
func createChapterForSecession(t *testing.T, srv types.MsgServer, ctx sdk.Context, parentID, chapterID string, liens []types.Lien) {
|
||||||
|
t.Helper()
|
||||||
|
createParentGuild(t, srv, ctx, parentID)
|
||||||
|
_, err := srv.CreateChapter(ctx, &types.MsgCreateChapter{
|
||||||
|
GuildID: chapterID,
|
||||||
|
Name: "Chapter",
|
||||||
|
ParentGuildID: parentID,
|
||||||
|
FounderReach: "reach:founder",
|
||||||
|
SecessionTerms: types.SecessionTerms{
|
||||||
|
CoolingCoverActiveDays: types.CoolingSecessionCoverActiveDays,
|
||||||
|
CoolingNonCoverDays: types.CoolingSecessionNonCoverDays,
|
||||||
|
LienAuditRequired: true,
|
||||||
|
CovenantClearanceRequired: true,
|
||||||
|
},
|
||||||
|
GoodStandingLiens: liens,
|
||||||
|
Signer: "reach:founder",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createChapterForSecession %q: %v", chapterID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInitiateSecessionSuccess (P5 case e) asserts a Chapter's secession
|
||||||
|
// initiation succeeds: SecessionStartedAt is set + the event is emitted +
|
||||||
|
// the lien-audit result is returned.
|
||||||
|
func TestInitiateSecessionSuccess(t *testing.T) {
|
||||||
|
ctx, _, _, _, k := newSimtestContext(t)
|
||||||
|
srv := keeper.NewMsgServerImpl(k)
|
||||||
|
// Chapter with no liens (lien audit passes vacuously).
|
||||||
|
createChapterForSecession(t, srv, ctx, "g-par-1", "g-chap-1", nil)
|
||||||
|
|
||||||
|
resp, err := srv.InitiateSecession(ctx, &types.MsgInitiateSecession{
|
||||||
|
GuildID: "g-chap-1", Signer: "reach:founder",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InitiateSecession: %v", err)
|
||||||
|
}
|
||||||
|
c, _ := k.GetGuild(ctx, "g-chap-1")
|
||||||
|
if c.SecessionStartedAt == 0 {
|
||||||
|
t.Error("SecessionStartedAt should be set after InitiateSecession")
|
||||||
|
}
|
||||||
|
if !hasEvent(ctx, "guild.secession_initiated") {
|
||||||
|
t.Error("guild.secession_initiated event not emitted")
|
||||||
|
}
|
||||||
|
// No liens -> lien audit passes vacuously.
|
||||||
|
if !resp.LienAuditPassed {
|
||||||
|
t.Error("LienAuditPassed = false, want true (no liens -> audit passes vacuously)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInitiateSecessionNonChapterRejected asserts a non-Chapter Guild
|
||||||
|
// (a Parent Guild) is REJECTED (a Parent Guild does not secede).
|
||||||
|
func TestInitiateSecessionNonChapterRejected(t *testing.T) {
|
||||||
|
ctx, _, _, _, k := newSimtestContext(t)
|
||||||
|
srv := keeper.NewMsgServerImpl(k)
|
||||||
|
createParentGuild(t, srv, ctx, "g-parent-not-chapter")
|
||||||
|
|
||||||
|
_, err := srv.InitiateSecession(ctx, &types.MsgInitiateSecession{
|
||||||
|
GuildID: "g-parent-not-chapter", Signer: "reach:founder",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("InitiateSecession on a non-Chapter Guild should be rejected")
|
||||||
|
}
|
||||||
|
c, _ := k.GetGuild(ctx, "g-parent-not-chapter")
|
||||||
|
if c.SecessionStartedAt != 0 {
|
||||||
|
t.Error("SecessionStartedAt should NOT be set on a rejected initiation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInitiateSecessionNotFound asserts InitiateSecession on a non-existent
|
||||||
|
// Guild is REJECTED.
|
||||||
|
func TestInitiateSecessionNotFound(t *testing.T) {
|
||||||
|
ctx, _, _, _, k := newSimtestContext(t)
|
||||||
|
srv := keeper.NewMsgServerImpl(k)
|
||||||
|
|
||||||
|
_, err := srv.InitiateSecession(ctx, &types.MsgInitiateSecession{
|
||||||
|
GuildID: "no-such-guild", Signer: "s",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("InitiateSecession on non-existent Guild should be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInitiateSecessionDoubleReject asserts a second InitiateSecession on
|
||||||
|
// the same Chapter is REJECTED (secession already initiated).
|
||||||
|
func TestInitiateSecessionDoubleReject(t *testing.T) {
|
||||||
|
ctx, _, _, _, k := newSimtestContext(t)
|
||||||
|
srv := keeper.NewMsgServerImpl(k)
|
||||||
|
createChapterForSecession(t, srv, ctx, "g-par-dbl", "g-chap-dbl", nil)
|
||||||
|
|
||||||
|
if _, err := srv.InitiateSecession(ctx, &types.MsgInitiateSecession{
|
||||||
|
GuildID: "g-chap-dbl", Signer: "s",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("first InitiateSecession: %v", err)
|
||||||
|
}
|
||||||
|
_, err := srv.InitiateSecession(ctx, &types.MsgInitiateSecession{
|
||||||
|
GuildID: "g-chap-dbl", Signer: "s",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("second InitiateSecession on same Chapter should be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCompleteSecessionCoverActive21DayCooling (P5 case e) asserts a
|
||||||
|
// Cover-active Chapter's secession completes after the 21d cooling period
|
||||||
|
// + the lien audit + the covenant clearance. The Chapter has a lien with a
|
||||||
|
// CoverPoolCovenantRef (Cover-active) — the cooling is 21d. Time-advance
|
||||||
|
// to 21d -> succeeds; the pro-rata settlement event is emitted; SecededAt
|
||||||
|
// is set.
|
||||||
|
func TestCompleteSecessionCoverActive21DayCooling(t *testing.T) {
|
||||||
|
ctx, _, _, _, k := newSimtestContext(t)
|
||||||
|
srv := keeper.NewMsgServerImpl(k)
|
||||||
|
// Chapter with a Cover-active lien (CoverPoolCovenantRef non-empty),
|
||||||
|
// Cleared=true so the lien audit passes.
|
||||||
|
createChapterForSecession(t, srv, ctx, "g-par-21", "g-chap-21", []types.Lien{
|
||||||
|
{Amount: 1000, CreditorReachID: "reach:cred", SecuredAtFounding: true, CoverPoolCovenantRef: "covenant-1", Cleared: true},
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := srv.InitiateSecession(ctx, &types.MsgInitiateSecession{
|
||||||
|
GuildID: "g-chap-21", Signer: "s",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("InitiateSecession: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance time to 21d (21 * 86400 seconds). The ctx BlockTime starts at
|
||||||
|
// time.Unix(1000, 0); SecessionStartedAt = 1000. Set BlockTime to
|
||||||
|
// 1000 + 21*86400.
|
||||||
|
cooling21d := int64(types.CoolingSecessionCoverActiveDays) * 24 * 60 * 60
|
||||||
|
ctx = ctx.WithBlockTime(time.Unix(1000+cooling21d, 0))
|
||||||
|
|
||||||
|
resp, err := srv.CompleteSecession(ctx, &types.MsgCompleteSecession{
|
||||||
|
GuildID: "g-chap-21",
|
||||||
|
CovenantClearancePassed: true,
|
||||||
|
ProRataSettlementGrain: 5000,
|
||||||
|
Signer: "s",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CompleteSecession at 21d: %v", err)
|
||||||
|
}
|
||||||
|
if resp.CoolingSeconds != cooling21d {
|
||||||
|
t.Errorf("CoolingSeconds = %d, want %d (Cover-active 21d)", resp.CoolingSeconds, cooling21d)
|
||||||
|
}
|
||||||
|
if resp.ProRataSettlementGrain != 5000 {
|
||||||
|
t.Errorf("ProRataSettlementGrain = %d, want 5000", resp.ProRataSettlementGrain)
|
||||||
|
}
|
||||||
|
c, _ := k.GetGuild(ctx, "g-chap-21")
|
||||||
|
if c.SecededAt == 0 {
|
||||||
|
t.Error("SecededAt should be set after CompleteSecession")
|
||||||
|
}
|
||||||
|
if !hasEvent(ctx, "guild.secession_completed") {
|
||||||
|
t.Error("guild.secession_completed event not emitted")
|
||||||
|
}
|
||||||
|
if !hasEvent(ctx, "guild.pro_rata_settlement") {
|
||||||
|
t.Error("guild.pro_rata_settlement event not emitted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCompleteSecessionRejectedBeforeCoolingExpires (P5 case f) asserts a
|
||||||
|
// secession completion BEFORE the cooling period elapses is REJECTED. Time-
|
||||||
|
// advance to 20d (less than 21d for a Cover-active Chapter) -> REJECT; then
|
||||||
|
// 21d -> succeeds.
|
||||||
|
func TestCompleteSecessionRejectedBeforeCoolingExpires(t *testing.T) {
|
||||||
|
ctx, _, _, _, k := newSimtestContext(t)
|
||||||
|
srv := keeper.NewMsgServerImpl(k)
|
||||||
|
createChapterForSecession(t, srv, ctx, "g-par-20", "g-chap-20", []types.Lien{
|
||||||
|
{Amount: 1000, CreditorReachID: "reach:cred", SecuredAtFounding: true, CoverPoolCovenantRef: "covenant-1", Cleared: true},
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := srv.InitiateSecession(ctx, &types.MsgInitiateSecession{
|
||||||
|
GuildID: "g-chap-20", Signer: "s",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("InitiateSecession: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance to 20d (less than 21d) -> REJECT.
|
||||||
|
cooling20d := int64(20) * 24 * 60 * 60
|
||||||
|
ctx = ctx.WithBlockTime(time.Unix(1000+cooling20d, 0))
|
||||||
|
_, err := srv.CompleteSecession(ctx, &types.MsgCompleteSecession{
|
||||||
|
GuildID: "g-chap-20", CovenantClearancePassed: true, Signer: "s",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("CompleteSecession at 20d (Cover-active needs 21d) should be rejected")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "cooling") {
|
||||||
|
t.Errorf("error = %q, want 'cooling'", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance to 21d -> succeeds.
|
||||||
|
cooling21d := int64(types.CoolingSecessionCoverActiveDays) * 24 * 60 * 60
|
||||||
|
ctx = ctx.WithBlockTime(time.Unix(1000+cooling21d, 0))
|
||||||
|
if _, err := srv.CompleteSecession(ctx, &types.MsgCompleteSecession{
|
||||||
|
GuildID: "g-chap-20", CovenantClearancePassed: true, Signer: "s",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("CompleteSecession at 21d: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCompleteSecessionRejectedOutstandingLiens (P5 case g) asserts a
|
||||||
|
// secession completion with outstanding liens (a lien with Cleared=false
|
||||||
|
// and Amount > 0) is REJECTED (the lien audit fails). Time-advance to 21d
|
||||||
|
// first (so the cooling passes), then the lien audit fails.
|
||||||
|
func TestCompleteSecessionRejectedOutstandingLiens(t *testing.T) {
|
||||||
|
ctx, _, _, _, k := newSimtestContext(t)
|
||||||
|
srv := keeper.NewMsgServerImpl(k)
|
||||||
|
// Chapter with an uncleared lien (Cleared=false, Amount > 0).
|
||||||
|
createChapterForSecession(t, srv, ctx, "g-par-liens", "g-chap-liens", []types.Lien{
|
||||||
|
{Amount: 1000, CreditorReachID: "reach:cred", SecuredAtFounding: true, CoverPoolCovenantRef: "covenant-1", Cleared: false},
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := srv.InitiateSecession(ctx, &types.MsgInitiateSecession{
|
||||||
|
GuildID: "g-chap-liens", Signer: "s",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("InitiateSecession: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance to 21d (cooling passes).
|
||||||
|
cooling21d := int64(types.CoolingSecessionCoverActiveDays) * 24 * 60 * 60
|
||||||
|
ctx = ctx.WithBlockTime(time.Unix(1000+cooling21d, 0))
|
||||||
|
_, err := srv.CompleteSecession(ctx, &types.MsgCompleteSecession{
|
||||||
|
GuildID: "g-chap-liens", CovenantClearancePassed: true, Signer: "s",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("CompleteSecession with outstanding liens should be rejected (lien audit)")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "lien audit") {
|
||||||
|
t.Errorf("error = %q, want 'lien audit'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCompleteSecessionRejectedCovenantNotCleared asserts a secession
|
||||||
|
// completion with CovenantClearancePassed=false is REJECTED (the covenant
|
||||||
|
// clearance gate).
|
||||||
|
func TestCompleteSecessionRejectedCovenantNotCleared(t *testing.T) {
|
||||||
|
ctx, _, _, _, k := newSimtestContext(t)
|
||||||
|
srv := keeper.NewMsgServerImpl(k)
|
||||||
|
createChapterForSecession(t, srv, ctx, "g-par-cov", "g-chap-cov", []types.Lien{
|
||||||
|
{Amount: 1000, CreditorReachID: "reach:cred", SecuredAtFounding: true, CoverPoolCovenantRef: "covenant-1", Cleared: true},
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := srv.InitiateSecession(ctx, &types.MsgInitiateSecession{
|
||||||
|
GuildID: "g-chap-cov", Signer: "s",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("InitiateSecession: %v", err)
|
||||||
|
}
|
||||||
|
cooling21d := int64(types.CoolingSecessionCoverActiveDays) * 24 * 60 * 60
|
||||||
|
ctx = ctx.WithBlockTime(time.Unix(1000+cooling21d, 0))
|
||||||
|
_, err := srv.CompleteSecession(ctx, &types.MsgCompleteSecession{
|
||||||
|
GuildID: "g-chap-cov", CovenantClearancePassed: false, Signer: "s",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("CompleteSecession with CovenantClearancePassed=false should be rejected")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "covenant clearance") {
|
||||||
|
t.Errorf("error = %q, want 'covenant clearance'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCompleteSecessionRejectedNotInitiated asserts a secession completion
|
||||||
|
// on a Chapter that has not initiated (SecessionStartedAt == 0) is
|
||||||
|
// REJECTED.
|
||||||
|
func TestCompleteSecessionRejectedNotInitiated(t *testing.T) {
|
||||||
|
ctx, _, _, _, k := newSimtestContext(t)
|
||||||
|
srv := keeper.NewMsgServerImpl(k)
|
||||||
|
createChapterForSecession(t, srv, ctx, "g-par-ni", "g-chap-ni", nil)
|
||||||
|
|
||||||
|
_, err := srv.CompleteSecession(ctx, &types.MsgCompleteSecession{
|
||||||
|
GuildID: "g-chap-ni", CovenantClearancePassed: true, Signer: "s",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("CompleteSecession on a Chapter that has not initiated should be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCompleteSecessionRejectedNonChapter asserts a secession completion on
|
||||||
|
// a non-Chapter Guild is REJECTED.
|
||||||
|
func TestCompleteSecessionRejectedNonChapter(t *testing.T) {
|
||||||
|
ctx, _, _, _, k := newSimtestContext(t)
|
||||||
|
srv := keeper.NewMsgServerImpl(k)
|
||||||
|
createParentGuild(t, srv, ctx, "g-parent-complete")
|
||||||
|
|
||||||
|
_, err := srv.CompleteSecession(ctx, &types.MsgCompleteSecession{
|
||||||
|
GuildID: "g-parent-complete", CovenantClearancePassed: true, Signer: "s",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("CompleteSecession on a non-Chapter Guild should be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCompleteSecessionNotFound asserts CompleteSecession on a non-existent
|
||||||
|
// Guild is REJECTED.
|
||||||
|
func TestCompleteSecessionNotFound(t *testing.T) {
|
||||||
|
ctx, _, _, _, k := newSimtestContext(t)
|
||||||
|
srv := keeper.NewMsgServerImpl(k)
|
||||||
|
|
||||||
|
_, err := srv.CompleteSecession(ctx, &types.MsgCompleteSecession{
|
||||||
|
GuildID: "no-such-guild", CovenantClearancePassed: true, Signer: "s",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("CompleteSecession on non-existent Guild should be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCompleteSecessionNonCover14DayCooling (P5 case k) asserts a non-Cover
|
||||||
|
// Chapter's secession completes after the 14d cooling period (no liens
|
||||||
|
// reference a Cover Pool covenant -> non-Cover -> 14d). Time-advance to 14d
|
||||||
|
// -> succeeds.
|
||||||
|
func TestCompleteSecessionNonCover14DayCooling(t *testing.T) {
|
||||||
|
ctx, _, _, _, k := newSimtestContext(t)
|
||||||
|
srv := keeper.NewMsgServerImpl(k)
|
||||||
|
// Chapter with a non-Cover lien (no CoverPoolCovenantRef), Cleared=true.
|
||||||
|
createChapterForSecession(t, srv, ctx, "g-par-14", "g-chap-14", []types.Lien{
|
||||||
|
{Amount: 1000, CreditorReachID: "reach:cred", SecuredAtFounding: true, Cleared: true},
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := srv.InitiateSecession(ctx, &types.MsgInitiateSecession{
|
||||||
|
GuildID: "g-chap-14", Signer: "s",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("InitiateSecession: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance to 14d (non-Cover cooling).
|
||||||
|
cooling14d := int64(types.CoolingSecessionNonCoverDays) * 24 * 60 * 60
|
||||||
|
ctx = ctx.WithBlockTime(time.Unix(1000+cooling14d, 0))
|
||||||
|
resp, err := srv.CompleteSecession(ctx, &types.MsgCompleteSecession{
|
||||||
|
GuildID: "g-chap-14", CovenantClearancePassed: true, Signer: "s",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CompleteSecession at 14d (non-Cover): %v", err)
|
||||||
|
}
|
||||||
|
if resp.CoolingSeconds != cooling14d {
|
||||||
|
t.Errorf("CoolingSeconds = %d, want %d (non-Cover 14d)", resp.CoolingSeconds, cooling14d)
|
||||||
|
}
|
||||||
|
c, _ := k.GetGuild(ctx, "g-chap-14")
|
||||||
|
if c.SecededAt == 0 {
|
||||||
|
t.Error("SecededAt should be set after CompleteSecession")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCompleteSecessionNonCover14DayBeforeRejected asserts a non-Cover
|
||||||
|
// Chapter's secession completion at 13d (less than 14d) is REJECTED.
|
||||||
|
func TestCompleteSecessionNonCover14DayBeforeRejected(t *testing.T) {
|
||||||
|
ctx, _, _, _, k := newSimtestContext(t)
|
||||||
|
srv := keeper.NewMsgServerImpl(k)
|
||||||
|
createChapterForSecession(t, srv, ctx, "g-par-13", "g-chap-13", []types.Lien{
|
||||||
|
{Amount: 1000, CreditorReachID: "reach:cred", SecuredAtFounding: true, Cleared: true},
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := srv.InitiateSecession(ctx, &types.MsgInitiateSecession{
|
||||||
|
GuildID: "g-chap-13", Signer: "s",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("InitiateSecession: %v", err)
|
||||||
|
}
|
||||||
|
cooling13d := int64(13) * 24 * 60 * 60
|
||||||
|
ctx = ctx.WithBlockTime(time.Unix(1000+cooling13d, 0))
|
||||||
|
_, err := srv.CompleteSecession(ctx, &types.MsgCompleteSecession{
|
||||||
|
GuildID: "g-chap-13", CovenantClearancePassed: true, Signer: "s",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("CompleteSecession at 13d (non-Cover needs 14d) should be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInitiateSecessionValidateBasicErrors exercises each ValidateBasic
|
||||||
|
// error path for coverage.
|
||||||
|
func TestInitiateSecessionValidateBasicErrors(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
msg types.MsgInitiateSecession
|
||||||
|
}{
|
||||||
|
{"empty guild-id", types.MsgInitiateSecession{Signer: "s"}},
|
||||||
|
{"empty signer", types.MsgInitiateSecession{GuildID: "g"}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if err := c.msg.ValidateBasic(); err == nil {
|
||||||
|
t.Errorf("case %q: ValidateBasic should fail", c.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCompleteSecessionValidateBasicErrors exercises each ValidateBasic
|
||||||
|
// error path for coverage.
|
||||||
|
func TestCompleteSecessionValidateBasicErrors(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
msg types.MsgCompleteSecession
|
||||||
|
}{
|
||||||
|
{"empty guild-id", types.MsgCompleteSecession{Signer: "s"}},
|
||||||
|
{"empty signer", types.MsgCompleteSecession{GuildID: "g"}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if err := c.msg.ValidateBasic(); err == nil {
|
||||||
|
t.Errorf("case %q: ValidateBasic should fail", c.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInitiateSecessionMethods exercises the Msg + MsgResponse Reset/String/
|
||||||
|
// ProtoMessage/GetSigners methods for coverage.
|
||||||
|
func TestInitiateSecessionMethods(t *testing.T) {
|
||||||
|
m := &types.MsgInitiateSecession{GuildID: "g", Signer: "s"}
|
||||||
|
if !strings.Contains(m.String(), "g") {
|
||||||
|
t.Errorf("MsgInitiateSecession String = %q", m.String())
|
||||||
|
}
|
||||||
|
m.Reset()
|
||||||
|
if m.GuildID != "" {
|
||||||
|
t.Errorf("MsgInitiateSecession Reset did not zero: %+v", m)
|
||||||
|
}
|
||||||
|
m.ProtoMessage()
|
||||||
|
m2 := &types.MsgInitiateSecession{Signer: "host-1"}
|
||||||
|
if got := m2.GetSigners(); len(got) != 1 || string(got[0]) != "host-1" {
|
||||||
|
t.Errorf("MsgInitiateSecession GetSigners = %v", got)
|
||||||
|
}
|
||||||
|
r := &types.MsgInitiateSecessionResponse{LienAuditPassed: true}
|
||||||
|
r.Reset()
|
||||||
|
if r.LienAuditPassed {
|
||||||
|
t.Errorf("MsgInitiateSecessionResponse Reset did not zero: %+v", r)
|
||||||
|
}
|
||||||
|
if !strings.Contains(r.String(), "MsgInitiateSecessionResponse") {
|
||||||
|
t.Errorf("MsgInitiateSecessionResponse String = %q", r.String())
|
||||||
|
}
|
||||||
|
r.ProtoMessage()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCompleteSecessionMethods exercises the Msg + MsgResponse Reset/String/
|
||||||
|
// ProtoMessage/GetSigners methods for coverage.
|
||||||
|
func TestCompleteSecessionMethods(t *testing.T) {
|
||||||
|
m := &types.MsgCompleteSecession{GuildID: "g", CovenantClearancePassed: true, ProRataSettlementGrain: 100, Signer: "s"}
|
||||||
|
if !strings.Contains(m.String(), "g") {
|
||||||
|
t.Errorf("MsgCompleteSecession String = %q", m.String())
|
||||||
|
}
|
||||||
|
m.Reset()
|
||||||
|
if m.GuildID != "" {
|
||||||
|
t.Errorf("MsgCompleteSecession Reset did not zero: %+v", m)
|
||||||
|
}
|
||||||
|
m.ProtoMessage()
|
||||||
|
m2 := &types.MsgCompleteSecession{Signer: "host-1"}
|
||||||
|
if got := m2.GetSigners(); len(got) != 1 || string(got[0]) != "host-1" {
|
||||||
|
t.Errorf("MsgCompleteSecession GetSigners = %v", got)
|
||||||
|
}
|
||||||
|
r := &types.MsgCompleteSecessionResponse{CoolingSeconds: 100, ProRataSettlementGrain: 200}
|
||||||
|
r.Reset()
|
||||||
|
if r.CoolingSeconds != 0 || r.ProRataSettlementGrain != 0 {
|
||||||
|
t.Errorf("MsgCompleteSecessionResponse Reset did not zero: %+v", r)
|
||||||
|
}
|
||||||
|
if !strings.Contains(r.String(), "MsgCompleteSecessionResponse") {
|
||||||
|
t.Errorf("MsgCompleteSecessionResponse String = %q", r.String())
|
||||||
|
}
|
||||||
|
r.ProtoMessage()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEscalateStandToPierAboveThreshold (P5 case h) asserts a Stand whose
|
||||||
|
// annual Pass volume exceeds StandPierEscalationAnnualPassVolumeCents is
|
||||||
|
// marked Pier-eligible + the event is emitted.
|
||||||
|
func TestEscalateStandToPierAboveThreshold(t *testing.T) {
|
||||||
|
ctx, _, _, _, k := newSimtestContext(t)
|
||||||
|
srv := keeper.NewMsgServerImpl(k)
|
||||||
|
|
||||||
|
resp, err := srv.EscalateStandToPier(ctx, &types.MsgEscalateStandToPier{
|
||||||
|
StandID: "stand-1",
|
||||||
|
AnnualPassVolumeCents: types.StandPierEscalationAnnualPassVolumeCents + 1,
|
||||||
|
Signer: "s",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EscalateStandToPier above threshold: %v", err)
|
||||||
|
}
|
||||||
|
if !resp.PierEligible {
|
||||||
|
t.Error("PierEligible = false, want true (volume > threshold)")
|
||||||
|
}
|
||||||
|
if !k.GetStandPierEligible(ctx, "stand-1") {
|
||||||
|
t.Error("GetStandPierEligible = false, want true (flag should be set)")
|
||||||
|
}
|
||||||
|
if !hasEvent(ctx, "guild.stand_pier_eligible") {
|
||||||
|
t.Error("guild.stand_pier_eligible event not emitted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEscalateStandToPierBelowThreshold asserts a Stand whose annual Pass
|
||||||
|
// volume does NOT exceed the threshold is NOT marked Pier-eligible + the
|
||||||
|
// below-threshold event is emitted (the response PierEligible=false).
|
||||||
|
func TestEscalateStandToPierBelowThreshold(t *testing.T) {
|
||||||
|
ctx, _, _, _, k := newSimtestContext(t)
|
||||||
|
srv := keeper.NewMsgServerImpl(k)
|
||||||
|
|
||||||
|
resp, err := srv.EscalateStandToPier(ctx, &types.MsgEscalateStandToPier{
|
||||||
|
StandID: "stand-below",
|
||||||
|
AnnualPassVolumeCents: types.StandPierEscalationAnnualPassVolumeCents,
|
||||||
|
Signer: "s",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EscalateStandToPier at threshold: %v", err)
|
||||||
|
}
|
||||||
|
if resp.PierEligible {
|
||||||
|
t.Error("PierEligible = true, want false (volume == threshold, NOT > threshold)")
|
||||||
|
}
|
||||||
|
if k.GetStandPierEligible(ctx, "stand-below") {
|
||||||
|
t.Error("GetStandPierEligible = true, want false (flag should NOT be set at threshold)")
|
||||||
|
}
|
||||||
|
if !hasEvent(ctx, "guild.stand_pier_escalation_below_threshold") {
|
||||||
|
t.Error("guild.stand_pier_escalation_below_threshold event not emitted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEscalateStandToPierValidateBasicErrors exercises each ValidateBasic
|
||||||
|
// error path.
|
||||||
|
func TestEscalateStandToPierValidateBasicErrors(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
msg types.MsgEscalateStandToPier
|
||||||
|
}{
|
||||||
|
{"empty stand-id", types.MsgEscalateStandToPier{AnnualPassVolumeCents: 100, Signer: "s"}},
|
||||||
|
{"zero volume", types.MsgEscalateStandToPier{StandID: "s", Signer: "signer"}},
|
||||||
|
{"negative volume", types.MsgEscalateStandToPier{StandID: "s", AnnualPassVolumeCents: -1, Signer: "signer"}},
|
||||||
|
{"empty signer", types.MsgEscalateStandToPier{StandID: "s", AnnualPassVolumeCents: 100}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if err := c.msg.ValidateBasic(); err == nil {
|
||||||
|
t.Errorf("case %q: ValidateBasic should fail", c.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEscalateStandToPierMethods exercises the Msg + MsgResponse Reset/
|
||||||
|
// String/ProtoMessage/GetSigners methods for coverage.
|
||||||
|
func TestEscalateStandToPierMethods(t *testing.T) {
|
||||||
|
m := &types.MsgEscalateStandToPier{StandID: "s", AnnualPassVolumeCents: 100, Signer: "signer"}
|
||||||
|
if !strings.Contains(m.String(), "s") {
|
||||||
|
t.Errorf("MsgEscalateStandToPier String = %q", m.String())
|
||||||
|
}
|
||||||
|
m.Reset()
|
||||||
|
if m.StandID != "" {
|
||||||
|
t.Errorf("MsgEscalateStandToPier Reset did not zero: %+v", m)
|
||||||
|
}
|
||||||
|
m.ProtoMessage()
|
||||||
|
m2 := &types.MsgEscalateStandToPier{Signer: "host-1"}
|
||||||
|
if got := m2.GetSigners(); len(got) != 1 || string(got[0]) != "host-1" {
|
||||||
|
t.Errorf("MsgEscalateStandToPier GetSigners = %v", got)
|
||||||
|
}
|
||||||
|
r := &types.MsgEscalateStandToPierResponse{PierEligible: true}
|
||||||
|
r.Reset()
|
||||||
|
if r.PierEligible {
|
||||||
|
t.Errorf("MsgEscalateStandToPierResponse Reset did not zero: %+v", r)
|
||||||
|
}
|
||||||
|
if !strings.Contains(r.String(), "MsgEscalateStandToPierResponse") {
|
||||||
|
t.Errorf("MsgEscalateStandToPierResponse String = %q", r.String())
|
||||||
|
}
|
||||||
|
r.ProtoMessage()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAcceptPierInvitationSuccess asserts a Stand marked Pier-eligible can
|
||||||
|
// accept the Pier invitation (the acceptance is recorded + the event is
|
||||||
|
// emitted).
|
||||||
|
func TestAcceptPierInvitationSuccess(t *testing.T) {
|
||||||
|
ctx, _, _, _, k := newSimtestContext(t)
|
||||||
|
srv := keeper.NewMsgServerImpl(k)
|
||||||
|
// First escalate the Stand to Pier-eligible.
|
||||||
|
if _, err := srv.EscalateStandToPier(ctx, &types.MsgEscalateStandToPier{
|
||||||
|
StandID: "stand-acc",
|
||||||
|
AnnualPassVolumeCents: types.StandPierEscalationAnnualPassVolumeCents + 1,
|
||||||
|
Signer: "s",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("EscalateStandToPier: %v", err)
|
||||||
|
}
|
||||||
|
// Then accept.
|
||||||
|
_, err := srv.AcceptPierInvitation(ctx, &types.MsgAcceptPierInvitation{
|
||||||
|
StandID: "stand-acc", Signer: "s",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AcceptPierInvitation: %v", err)
|
||||||
|
}
|
||||||
|
if !k.GetStandPierAccepted(ctx, "stand-acc") {
|
||||||
|
t.Error("GetStandPierAccepted = false, want true (acceptance should be recorded)")
|
||||||
|
}
|
||||||
|
if !hasEvent(ctx, "guild.stand_pier_accepted") {
|
||||||
|
t.Error("guild.stand_pier_accepted event not emitted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAcceptPierInvitationNotEligibleRejected asserts a Stand that is NOT
|
||||||
|
// Pier-eligible is REJECTED when trying to accept the Pier invitation.
|
||||||
|
func TestAcceptPierInvitationNotEligibleRejected(t *testing.T) {
|
||||||
|
ctx, _, _, _, k := newSimtestContext(t)
|
||||||
|
srv := keeper.NewMsgServerImpl(k)
|
||||||
|
|
||||||
|
_, err := srv.AcceptPierInvitation(ctx, &types.MsgAcceptPierInvitation{
|
||||||
|
StandID: "stand-not-eligible", Signer: "s",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("AcceptPierInvitation on a non-eligible Stand should be rejected")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "Pier-eligible") {
|
||||||
|
t.Errorf("error = %q, want 'Pier-eligible'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAcceptPierInvitationValidateBasicErrors exercises each ValidateBasic
|
||||||
|
// error path.
|
||||||
|
func TestAcceptPierInvitationValidateBasicErrors(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
msg types.MsgAcceptPierInvitation
|
||||||
|
}{
|
||||||
|
{"empty stand-id", types.MsgAcceptPierInvitation{Signer: "s"}},
|
||||||
|
{"empty signer", types.MsgAcceptPierInvitation{StandID: "s"}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if err := c.msg.ValidateBasic(); err == nil {
|
||||||
|
t.Errorf("case %q: ValidateBasic should fail", c.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAcceptPierInvitationMethods exercises the Msg + MsgResponse Reset/
|
||||||
|
// String/ProtoMessage/GetSigners methods for coverage.
|
||||||
|
func TestAcceptPierInvitationMethods(t *testing.T) {
|
||||||
|
m := &types.MsgAcceptPierInvitation{StandID: "s", Signer: "signer"}
|
||||||
|
if !strings.Contains(m.String(), "s") {
|
||||||
|
t.Errorf("MsgAcceptPierInvitation String = %q", m.String())
|
||||||
|
}
|
||||||
|
m.Reset()
|
||||||
|
if m.StandID != "" {
|
||||||
|
t.Errorf("MsgAcceptPierInvitation Reset did not zero: %+v", m)
|
||||||
|
}
|
||||||
|
m.ProtoMessage()
|
||||||
|
m2 := &types.MsgAcceptPierInvitation{Signer: "host-1"}
|
||||||
|
if got := m2.GetSigners(); len(got) != 1 || string(got[0]) != "host-1" {
|
||||||
|
t.Errorf("MsgAcceptPierInvitation GetSigners = %v", got)
|
||||||
|
}
|
||||||
|
r := &types.MsgAcceptPierInvitationResponse{}
|
||||||
|
r.Reset()
|
||||||
|
if !strings.Contains(r.String(), "MsgAcceptPierInvitationResponse") {
|
||||||
|
t.Errorf("MsgAcceptPierInvitationResponse String = %q", r.String())
|
||||||
|
}
|
||||||
|
r.ProtoMessage()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStandDeclinesPierInvitation (P5 case i) asserts a Stand may decline
|
||||||
|
// the Pier invitation: the Stand is escalated to Pier-eligible, but
|
||||||
|
// MsgAcceptPierInvitation is NOT called -> no acceptance is recorded (the
|
||||||
|
// soft-upgrade: the flag is set + the event is emitted, but no enforcement
|
||||||
|
// follows; the Stand must separately accept).
|
||||||
|
func TestStandDeclinesPierInvitation(t *testing.T) {
|
||||||
|
ctx, _, _, _, k := newSimtestContext(t)
|
||||||
|
srv := keeper.NewMsgServerImpl(k)
|
||||||
|
// Escalate the Stand to Pier-eligible.
|
||||||
|
if _, err := srv.EscalateStandToPier(ctx, &types.MsgEscalateStandToPier{
|
||||||
|
StandID: "stand-decline",
|
||||||
|
AnnualPassVolumeCents: types.StandPierEscalationAnnualPassVolumeCents + 1,
|
||||||
|
Signer: "s",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("EscalateStandToPier: %v", err)
|
||||||
|
}
|
||||||
|
// The Stand is Pier-eligible but does NOT call AcceptPierInvitation
|
||||||
|
// (the Stand declines). The acceptance flag is NOT set.
|
||||||
|
if k.GetStandPierAccepted(ctx, "stand-decline") {
|
||||||
|
t.Error("GetStandPierAccepted = true, want false (the Stand declined — no acceptance)")
|
||||||
|
}
|
||||||
|
// The eligibility flag IS set (the soft upgrade: the flag is set
|
||||||
|
// regardless of whether the Stand accepts).
|
||||||
|
if !k.GetStandPierEligible(ctx, "stand-decline") {
|
||||||
|
t.Error("GetStandPierEligible = false, want true (the escalation set the flag)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCheckLiensClearedHelper exercises the CheckLiensCleared keeper helper
|
||||||
|
// directly (coverage on the helper + the founding-locked + post-founding
|
||||||
|
// lien paths).
|
||||||
|
func TestCheckLiensClearedHelper(t *testing.T) {
|
||||||
|
ctx, _, _, _, k := newSimtestContext(t)
|
||||||
|
// Non-existent Guild -> false.
|
||||||
|
if k.CheckLiensCleared(ctx, "no-such-guild") {
|
||||||
|
t.Error("CheckLiensCleared on non-existent Guild should return false")
|
||||||
|
}
|
||||||
|
// Guild with no liens -> true (vacuous).
|
||||||
|
k.SetGuild(ctx, types.Guild{GuildID: "g-empty", IsChapter: true})
|
||||||
|
if !k.CheckLiensCleared(ctx, "g-empty") {
|
||||||
|
t.Error("CheckLiensCleared on a Chapter with no liens should return true (vacuous)")
|
||||||
|
}
|
||||||
|
// Guild with a founding-locked lien, Cleared=false, Amount>0 -> false.
|
||||||
|
k.SetGuild(ctx, types.Guild{
|
||||||
|
GuildID: "g-uncleared",
|
||||||
|
IsChapter: true,
|
||||||
|
GoodStandingLiens: []types.Lien{{Amount: 100, CreditorReachID: "c", SecuredAtFounding: true, Cleared: false}},
|
||||||
|
})
|
||||||
|
if k.CheckLiensCleared(ctx, "g-uncleared") {
|
||||||
|
t.Error("CheckLiensCleared with an uncleared lien should return false")
|
||||||
|
}
|
||||||
|
// Same Guild but Cleared=true -> true.
|
||||||
|
k.SetGuild(ctx, types.Guild{
|
||||||
|
GuildID: "g-cleared",
|
||||||
|
IsChapter: true,
|
||||||
|
GoodStandingLiens: []types.Lien{{Amount: 100, CreditorReachID: "c", SecuredAtFounding: true, Cleared: true}},
|
||||||
|
})
|
||||||
|
if !k.CheckLiensCleared(ctx, "g-cleared") {
|
||||||
|
t.Error("CheckLiensCleared with a cleared lien should return true")
|
||||||
|
}
|
||||||
|
// Lien with Amount=0 (cleared by zero amount) -> true.
|
||||||
|
k.SetGuild(ctx, types.Guild{
|
||||||
|
GuildID: "g-zero",
|
||||||
|
IsChapter: true,
|
||||||
|
GoodStandingLiens: []types.Lien{{Amount: 0, CreditorReachID: "c", SecuredAtFounding: true, Cleared: false}},
|
||||||
|
})
|
||||||
|
if !k.CheckLiensCleared(ctx, "g-zero") {
|
||||||
|
t.Error("CheckLiensCleared with a zero-amount lien should return true (Amount=0 passes)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChapterIsCoverActiveHelper exercises the ChapterIsCoverActive keeper
|
||||||
|
// helper directly (coverage on the helper).
|
||||||
|
func TestChapterIsCoverActiveHelper(t *testing.T) {
|
||||||
|
ctx, _, _, _, k := newSimtestContext(t)
|
||||||
|
// Non-existent Guild -> false.
|
||||||
|
if k.ChapterIsCoverActive(ctx, "no-such-guild") {
|
||||||
|
t.Error("ChapterIsCoverActive on non-existent Guild should return false")
|
||||||
|
}
|
||||||
|
// Guild with no liens -> false.
|
||||||
|
k.SetGuild(ctx, types.Guild{GuildID: "g-noliens", IsChapter: true})
|
||||||
|
if k.ChapterIsCoverActive(ctx, "g-noliens") {
|
||||||
|
t.Error("ChapterIsCoverActive on a Chapter with no liens should return false")
|
||||||
|
}
|
||||||
|
// Guild with a founding-locked lien + CoverPoolCovenantRef -> true.
|
||||||
|
k.SetGuild(ctx, types.Guild{
|
||||||
|
GuildID: "g-cover",
|
||||||
|
IsChapter: true,
|
||||||
|
GoodStandingLiens: []types.Lien{{Amount: 100, CreditorReachID: "c", SecuredAtFounding: true, CoverPoolCovenantRef: "covenant-1"}},
|
||||||
|
})
|
||||||
|
if !k.ChapterIsCoverActive(ctx, "g-cover") {
|
||||||
|
t.Error("ChapterIsCoverActive with a Cover-active lien should return true")
|
||||||
|
}
|
||||||
|
// Guild with a post-founding lien (lien/ store) + CoverPoolCovenantRef -> true.
|
||||||
|
k.SetGuild(ctx, types.Guild{GuildID: "g-cover-post", IsChapter: true})
|
||||||
|
k.SetLien(ctx, "g-cover-post", 0, types.Lien{Amount: 50, CreditorReachID: "c", CoverPoolCovenantRef: "covenant-2"})
|
||||||
|
if !k.ChapterIsCoverActive(ctx, "g-cover-post") {
|
||||||
|
t.Error("ChapterIsCoverActive with a post-founding Cover-active lien should return true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStandPierEligibleAndAcceptedAccessors exercises the
|
||||||
|
// GetStandPierEligible + SetStandPierEligible + GetStandPierAccepted +
|
||||||
|
// SetStandPierAccepted accessors directly for coverage.
|
||||||
|
func TestStandPierEligibleAndAcceptedAccessors(t *testing.T) {
|
||||||
|
ctx, _, _, _, k := newSimtestContext(t)
|
||||||
|
// Empty-store accessors return false.
|
||||||
|
if k.GetStandPierEligible(ctx, "stand-none") {
|
||||||
|
t.Error("GetStandPierEligible on empty store should return false")
|
||||||
|
}
|
||||||
|
if k.GetStandPierAccepted(ctx, "stand-none") {
|
||||||
|
t.Error("GetStandPierAccepted on empty store should return false")
|
||||||
|
}
|
||||||
|
// Set + read back.
|
||||||
|
k.SetStandPierEligible(ctx, "stand-1", true)
|
||||||
|
if !k.GetStandPierEligible(ctx, "stand-1") {
|
||||||
|
t.Error("GetStandPierEligible = false after SetStandPierEligible(true)")
|
||||||
|
}
|
||||||
|
k.SetStandPierAccepted(ctx, "stand-1", true)
|
||||||
|
if !k.GetStandPierAccepted(ctx, "stand-1") {
|
||||||
|
t.Error("GetStandPierAccepted = false after SetStandPierAccepted(true)")
|
||||||
|
}
|
||||||
|
// Set false explicitly.
|
||||||
|
k.SetStandPierEligible(ctx, "stand-1", false)
|
||||||
|
if k.GetStandPierEligible(ctx, "stand-1") {
|
||||||
|
t.Error("GetStandPierEligible = true after SetStandPierEligible(false)")
|
||||||
|
}
|
||||||
|
k.SetStandPierAccepted(ctx, "stand-1", false)
|
||||||
|
if k.GetStandPierAccepted(ctx, "stand-1") {
|
||||||
|
t.Error("GetStandPierAccepted = true after SetStandPierAccepted(false)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -324,6 +324,214 @@ func (m *MsgAddLien) GetSigners() []sdk.AccAddress {
|
|||||||
return []sdk.AccAddress{[]byte(m.Signer)}
|
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- P5: Secession + Stand→Pier Escalation Msg types (REQ-064, REQ-059) --------
|
||||||
|
//
|
||||||
|
// (REQ-064 secession cooling enforcement, REQ-059/D-074 Stand→Pier boundary.)
|
||||||
|
// The four P5 Msg types drive the secession lifecycle + the Stand→Pier
|
||||||
|
// escalation:
|
||||||
|
// - MsgInitiateSecession: a Chapter initiates secession (the handler loads
|
||||||
|
// the Chapter, sets SecessionStartedAt=now, invokes the lien audit).
|
||||||
|
// - MsgCompleteSecession: a Chapter completes secession after the cooling
|
||||||
|
// period (Cover-active 21d, non-Cover 14d), the lien audit, and the
|
||||||
|
// covenant clearance. The handler sets SecededAt=now + emits a pro-rata
|
||||||
|
// settlement event.
|
||||||
|
// - MsgEscalateStandToPier: a Stand's annual Pass volume exceeds the
|
||||||
|
// StandPierEscalationAnnualPassVolumeCents const (D-074 — 10M Grain-cents
|
||||||
|
// simtest placeholder for $100k USD) — the handler sets a Stand-Pier-
|
||||||
|
// eligible flag (soft upgrade — the Stand may decline).
|
||||||
|
// - MsgAcceptPierInvitation: a Stand accepts the Pier invitation (the
|
||||||
|
// handler checks the eligibility flag + records the acceptance).
|
||||||
|
//
|
||||||
|
// All cross-module refs are by-ID-string (G-003). The
|
||||||
|
// StandPierEscalationAnnualPassVolumeCents const lives in x/stand/types
|
||||||
|
// (type ownership) and is imported by x/guild/keeper (the handler) — consts
|
||||||
|
// are G-003-clean (only struct imports are forbidden).
|
||||||
|
//
|
||||||
|
// Lexicon note (REQ-012): "Secession", "Cooling", "Lien Audit", "Covenant
|
||||||
|
// Clearance", "Pro-rata Settlement", "Stand→Pier Escalation", "Pier
|
||||||
|
// Invitation" are lexicon-clean.
|
||||||
|
|
||||||
|
// MsgInitiateSecession initiates a Chapter's secession (REQ-064). The
|
||||||
|
// handler loads the Chapter (must be IsChapter=true), sets
|
||||||
|
// SecessionStartedAt=now, + invokes the lien audit (a simtest-grade
|
||||||
|
// helper that returns true if all GoodStandingLiens are Cleared or
|
||||||
|
// Amount=0). The handler REJECTS a non-Chapter Guild + a Chapter that has
|
||||||
|
// already initiated (SecessionStartedAt > 0).
|
||||||
|
//
|
||||||
|
// ValidateBasic is stateless: non-empty GuildID + Signer.
|
||||||
|
type MsgInitiateSecession struct {
|
||||||
|
GuildID string `json:"guild_id" yaml:"guild_id"`
|
||||||
|
Signer string `json:"signer" yaml:"signer"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset implements proto.Message.
|
||||||
|
func (m *MsgInitiateSecession) Reset() { *m = MsgInitiateSecession{} }
|
||||||
|
|
||||||
|
// String implements proto.Message.
|
||||||
|
func (m *MsgInitiateSecession) String() string {
|
||||||
|
return fmt.Sprintf("MsgInitiateSecession{GuildID:%s Signer:%s}", m.GuildID, m.Signer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProtoMessage implements proto.Message.
|
||||||
|
func (*MsgInitiateSecession) ProtoMessage() {}
|
||||||
|
|
||||||
|
// ValidateBasic is the stateless validation: non-empty GuildID + Signer.
|
||||||
|
func (m *MsgInitiateSecession) ValidateBasic() error {
|
||||||
|
if m.GuildID == "" {
|
||||||
|
return fmt.Errorf("guild: empty guild-id")
|
||||||
|
}
|
||||||
|
if m.Signer == "" {
|
||||||
|
return fmt.Errorf("guild: empty signer")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||||
|
func (m *MsgInitiateSecession) GetSigners() []sdk.AccAddress {
|
||||||
|
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MsgCompleteSecession completes a Chapter's secession (REQ-064). The
|
||||||
|
// handler enforces:
|
||||||
|
// 1. The Chapter must exist + be IsChapter=true.
|
||||||
|
// 2. SecessionStartedAt > 0 (secession was initiated).
|
||||||
|
// 3. Cooling check: now >= SecessionStartedAt + coolingSeconds where
|
||||||
|
// coolingSeconds = CoolingSecessionCoverActiveDays*86400 (21d) if the
|
||||||
|
// Chapter is Cover-active (any lien references a Cover Pool covenant),
|
||||||
|
// else CoolingSecessionNonCoverDays*86400 (14d).
|
||||||
|
// 4. Lien audit: all GoodStandingLiens must be Cleared (or Amount=0).
|
||||||
|
// 5. Covenant clearance: the Cover Pool covenants must be cleared (a
|
||||||
|
// simtest-grade CovenantClearancePassed bool on the Msg; if false,
|
||||||
|
// REJECT).
|
||||||
|
// 6. Pro-rata Cover-Fee settlement: emit guild.pro_rata_settlement with
|
||||||
|
// the settlement amount (the actual settlement is a v0.8+ Grain-ledger
|
||||||
|
// concern — the simtest-grade ProRataSettlementGrain field on the Msg
|
||||||
|
// carries the amount for the event).
|
||||||
|
// 7. Set SecededAt=now. Emit guild.secession_completed.
|
||||||
|
//
|
||||||
|
// ValidateBasic is stateless: non-empty GuildID + Signer.
|
||||||
|
type MsgCompleteSecession struct {
|
||||||
|
GuildID string `json:"guild_id" yaml:"guild_id"`
|
||||||
|
CovenantClearancePassed bool `json:"covenant_clearance_passed" yaml:"covenant_clearance_passed"`
|
||||||
|
ProRataSettlementGrain int64 `json:"pro_rata_settlement_grain,omitempty" yaml:"pro_rata_settlement_grain,omitempty"`
|
||||||
|
Signer string `json:"signer" yaml:"signer"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset implements proto.Message.
|
||||||
|
func (m *MsgCompleteSecession) Reset() { *m = MsgCompleteSecession{} }
|
||||||
|
|
||||||
|
// String implements proto.Message.
|
||||||
|
func (m *MsgCompleteSecession) String() string {
|
||||||
|
return fmt.Sprintf("MsgCompleteSecession{GuildID:%s CovenantClearancePassed:%v ProRataSettlementGrain:%d Signer:%s}",
|
||||||
|
m.GuildID, m.CovenantClearancePassed, m.ProRataSettlementGrain, m.Signer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProtoMessage implements proto.Message.
|
||||||
|
func (*MsgCompleteSecession) ProtoMessage() {}
|
||||||
|
|
||||||
|
// ValidateBasic is the stateless validation: non-empty GuildID + Signer.
|
||||||
|
func (m *MsgCompleteSecession) ValidateBasic() error {
|
||||||
|
if m.GuildID == "" {
|
||||||
|
return fmt.Errorf("guild: empty guild-id")
|
||||||
|
}
|
||||||
|
if m.Signer == "" {
|
||||||
|
return fmt.Errorf("guild: empty signer")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||||
|
func (m *MsgCompleteSecession) GetSigners() []sdk.AccAddress {
|
||||||
|
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MsgEscalateStandToPier escalates a Stand to Pier-eligibility (REQ-059,
|
||||||
|
// D-074). The handler checks AnnualPassVolumeCents >
|
||||||
|
// StandPierEscalationAnnualPassVolumeCents (10M Grain-cents — the D-074
|
||||||
|
// simtest placeholder for $100k USD); if so, sets a Stand-Pier-eligible
|
||||||
|
// flag (a pier_eligible/ store keyed by StandID). Soft upgrade, not a ban
|
||||||
|
// — the Stand may decline (the eligibility flag is set + the event is
|
||||||
|
// emitted, but no enforcement follows; the Stand must separately accept
|
||||||
|
// via MsgAcceptPierInvitation).
|
||||||
|
//
|
||||||
|
// ValidateBasic is stateless: non-empty StandID + AnnualPassVolumeCents > 0.
|
||||||
|
type MsgEscalateStandToPier struct {
|
||||||
|
StandID string `json:"stand_id" yaml:"stand_id"`
|
||||||
|
AnnualPassVolumeCents int64 `json:"annual_pass_volume_cents" yaml:"annual_pass_volume_cents"`
|
||||||
|
Signer string `json:"signer" yaml:"signer"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset implements proto.Message.
|
||||||
|
func (m *MsgEscalateStandToPier) Reset() { *m = MsgEscalateStandToPier{} }
|
||||||
|
|
||||||
|
// String implements proto.Message.
|
||||||
|
func (m *MsgEscalateStandToPier) String() string {
|
||||||
|
return fmt.Sprintf("MsgEscalateStandToPier{StandID:%s AnnualPassVolumeCents:%d Signer:%s}",
|
||||||
|
m.StandID, m.AnnualPassVolumeCents, m.Signer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProtoMessage implements proto.Message.
|
||||||
|
func (*MsgEscalateStandToPier) ProtoMessage() {}
|
||||||
|
|
||||||
|
// ValidateBasic is the stateless validation: non-empty StandID +
|
||||||
|
// AnnualPassVolumeCents > 0 + non-empty Signer.
|
||||||
|
func (m *MsgEscalateStandToPier) ValidateBasic() error {
|
||||||
|
if m.StandID == "" {
|
||||||
|
return fmt.Errorf("guild: empty stand-id")
|
||||||
|
}
|
||||||
|
if m.AnnualPassVolumeCents <= 0 {
|
||||||
|
return fmt.Errorf("guild: AnnualPassVolumeCents %d <= 0", m.AnnualPassVolumeCents)
|
||||||
|
}
|
||||||
|
if m.Signer == "" {
|
||||||
|
return fmt.Errorf("guild: empty signer")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||||
|
func (m *MsgEscalateStandToPier) GetSigners() []sdk.AccAddress {
|
||||||
|
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MsgAcceptPierInvitation records a Stand's acceptance of a Pier invitation
|
||||||
|
// (REQ-059, D-074). The handler checks the Stand is Pier-eligible (the
|
||||||
|
// flag set by MsgEscalateStandToPier); if not, REJECT. Records the
|
||||||
|
// acceptance (a pier_accepted/ store keyed by StandID). Emit
|
||||||
|
// guild.stand_pier_accepted.
|
||||||
|
//
|
||||||
|
// ValidateBasic is stateless: non-empty StandID + Signer.
|
||||||
|
type MsgAcceptPierInvitation struct {
|
||||||
|
StandID string `json:"stand_id" yaml:"stand_id"`
|
||||||
|
Signer string `json:"signer" yaml:"signer"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset implements proto.Message.
|
||||||
|
func (m *MsgAcceptPierInvitation) Reset() { *m = MsgAcceptPierInvitation{} }
|
||||||
|
|
||||||
|
// String implements proto.Message.
|
||||||
|
func (m *MsgAcceptPierInvitation) String() string {
|
||||||
|
return fmt.Sprintf("MsgAcceptPierInvitation{StandID:%s Signer:%s}", m.StandID, m.Signer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProtoMessage implements proto.Message.
|
||||||
|
func (*MsgAcceptPierInvitation) ProtoMessage() {}
|
||||||
|
|
||||||
|
// ValidateBasic is the stateless validation: non-empty StandID + Signer.
|
||||||
|
func (m *MsgAcceptPierInvitation) ValidateBasic() error {
|
||||||
|
if m.StandID == "" {
|
||||||
|
return fmt.Errorf("guild: empty stand-id")
|
||||||
|
}
|
||||||
|
if m.Signer == "" {
|
||||||
|
return fmt.Errorf("guild: empty signer")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||||
|
func (m *MsgAcceptPierInvitation) GetSigners() []sdk.AccAddress {
|
||||||
|
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||||
|
}
|
||||||
|
|
||||||
// --- MsgServer interface + Response types -------------------------------------
|
// --- MsgServer interface + Response types -------------------------------------
|
||||||
|
|
||||||
// MsgServer is the guild module's message server interface (one method per
|
// MsgServer is the guild module's message server interface (one method per
|
||||||
@@ -336,6 +544,12 @@ type MsgServer interface {
|
|||||||
OneTapExitStand(ctx interface{}, msg *MsgOneTapExitStand) (*MsgOneTapExitStandResponse, error)
|
OneTapExitStand(ctx interface{}, msg *MsgOneTapExitStand) (*MsgOneTapExitStandResponse, error)
|
||||||
DelegateConfederationVoice(ctx interface{}, msg *MsgDelegateConfederationVoice) (*MsgDelegateConfederationVoiceResponse, error)
|
DelegateConfederationVoice(ctx interface{}, msg *MsgDelegateConfederationVoice) (*MsgDelegateConfederationVoiceResponse, error)
|
||||||
AddLien(ctx interface{}, msg *MsgAddLien) (*MsgAddLienResponse, error)
|
AddLien(ctx interface{}, msg *MsgAddLien) (*MsgAddLienResponse, error)
|
||||||
|
// v0.7 P5 Secession + Stand→Pier escalation handlers (REQ-064, REQ-059,
|
||||||
|
// D-074) — defined above.
|
||||||
|
InitiateSecession(ctx interface{}, msg *MsgInitiateSecession) (*MsgInitiateSecessionResponse, error)
|
||||||
|
CompleteSecession(ctx interface{}, msg *MsgCompleteSecession) (*MsgCompleteSecessionResponse, error)
|
||||||
|
EscalateStandToPier(ctx interface{}, msg *MsgEscalateStandToPier) (*MsgEscalateStandToPierResponse, error)
|
||||||
|
AcceptPierInvitation(ctx interface{}, msg *MsgAcceptPierInvitation) (*MsgAcceptPierInvitationResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Response types -----------------------------------------------------------
|
// --- Response types -----------------------------------------------------------
|
||||||
@@ -419,3 +633,80 @@ func (m *MsgAddLienResponse) String() string { return "MsgAddLienResponse{}" }
|
|||||||
|
|
||||||
// ProtoMessage implements proto.Message.
|
// ProtoMessage implements proto.Message.
|
||||||
func (*MsgAddLienResponse) ProtoMessage() {}
|
func (*MsgAddLienResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
// --- P5 Response types --------------------------------------------------------
|
||||||
|
|
||||||
|
// MsgInitiateSecessionResponse is the response to MsgInitiateSecession.
|
||||||
|
// LienAuditPassed reports the lien-audit result at initiation (for simtest
|
||||||
|
// assertion: true = all liens cleared, false = outstanding liens remain —
|
||||||
|
// the handler still records SecessionStartedAt so the cooling clock starts;
|
||||||
|
// the lien audit is re-checked at completion).
|
||||||
|
type MsgInitiateSecessionResponse struct {
|
||||||
|
LienAuditPassed bool `json:"lien_audit_passed" yaml:"lien_audit_passed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset implements proto.Message.
|
||||||
|
func (m *MsgInitiateSecessionResponse) Reset() { *m = MsgInitiateSecessionResponse{} }
|
||||||
|
|
||||||
|
// String implements proto.Message.
|
||||||
|
func (m *MsgInitiateSecessionResponse) String() string {
|
||||||
|
return fmt.Sprintf("MsgInitiateSecessionResponse{LienAuditPassed:%v}", m.LienAuditPassed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProtoMessage implements proto.Message.
|
||||||
|
func (*MsgInitiateSecessionResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
// MsgCompleteSecessionResponse is the response to MsgCompleteSecession.
|
||||||
|
// CoolingSeconds reports the cooling period applied (for simtest assertion:
|
||||||
|
// 21d Cover-active, 14d non-Cover). ProRataSettlementGrain reports the
|
||||||
|
// pro-rata Cover-Fee settlement amount emitted in the event (a simtest-grade
|
||||||
|
// placeholder; the actual settlement is a v0.8+ Grain-ledger concern).
|
||||||
|
type MsgCompleteSecessionResponse struct {
|
||||||
|
CoolingSeconds int64 `json:"cooling_seconds" yaml:"cooling_seconds"`
|
||||||
|
ProRataSettlementGrain int64 `json:"pro_rata_settlement_grain" yaml:"pro_rata_settlement_grain"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset implements proto.Message.
|
||||||
|
func (m *MsgCompleteSecessionResponse) Reset() { *m = MsgCompleteSecessionResponse{} }
|
||||||
|
|
||||||
|
// String implements proto.Message.
|
||||||
|
func (m *MsgCompleteSecessionResponse) String() string {
|
||||||
|
return fmt.Sprintf("MsgCompleteSecessionResponse{CoolingSeconds:%d ProRataSettlementGrain:%d}",
|
||||||
|
m.CoolingSeconds, m.ProRataSettlementGrain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProtoMessage implements proto.Message.
|
||||||
|
func (*MsgCompleteSecessionResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
// MsgEscalateStandToPierResponse is the response to MsgEscalateStandToPier.
|
||||||
|
// PierEligible reports whether the Stand was marked Pier-eligible (true when
|
||||||
|
// AnnualPassVolumeCents > StandPierEscalationAnnualPassVolumeCents; false
|
||||||
|
// otherwise — the handler still emits the event but does not set the flag).
|
||||||
|
type MsgEscalateStandToPierResponse struct {
|
||||||
|
PierEligible bool `json:"pier_eligible" yaml:"pier_eligible"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset implements proto.Message.
|
||||||
|
func (m *MsgEscalateStandToPierResponse) Reset() { *m = MsgEscalateStandToPierResponse{} }
|
||||||
|
|
||||||
|
// String implements proto.Message.
|
||||||
|
func (m *MsgEscalateStandToPierResponse) String() string {
|
||||||
|
return fmt.Sprintf("MsgEscalateStandToPierResponse{PierEligible:%v}", m.PierEligible)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProtoMessage implements proto.Message.
|
||||||
|
func (*MsgEscalateStandToPierResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
// MsgAcceptPierInvitationResponse is the response to MsgAcceptPierInvitation.
|
||||||
|
type MsgAcceptPierInvitationResponse struct{}
|
||||||
|
|
||||||
|
// Reset implements proto.Message.
|
||||||
|
func (m *MsgAcceptPierInvitationResponse) Reset() { *m = MsgAcceptPierInvitationResponse{} }
|
||||||
|
|
||||||
|
// String implements proto.Message.
|
||||||
|
func (m *MsgAcceptPierInvitationResponse) String() string {
|
||||||
|
return "MsgAcceptPierInvitationResponse{}"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProtoMessage implements proto.Message.
|
||||||
|
func (*MsgAcceptPierInvitationResponse) ProtoMessage() {}
|
||||||
|
|||||||
@@ -303,3 +303,193 @@ func TestResponseMethods(t *testing.T) {
|
|||||||
r.Reset()
|
r.Reset()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- P5: Secession + Stand→Pier escalation Msg methods (REQ-064, REQ-059) -----
|
||||||
|
|
||||||
|
func TestMsgInitiateSecessionMethods(t *testing.T) {
|
||||||
|
m := &MsgInitiateSecession{GuildID: "g1", Signer: "s1"}
|
||||||
|
if err := m.ValidateBasic(); err != nil {
|
||||||
|
t.Errorf("valid MsgInitiateSecession ValidateBasic: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(m.String(), "g1") {
|
||||||
|
t.Errorf("MsgInitiateSecession String = %q, want g1", m.String())
|
||||||
|
}
|
||||||
|
m.Reset()
|
||||||
|
if m.GuildID != "" {
|
||||||
|
t.Errorf("MsgInitiateSecession Reset did not zero: %+v", m)
|
||||||
|
}
|
||||||
|
m.ProtoMessage()
|
||||||
|
m2 := &MsgInitiateSecession{Signer: "host-1"}
|
||||||
|
if got := m2.GetSigners(); len(got) != 1 || string(got[0]) != "host-1" {
|
||||||
|
t.Errorf("MsgInitiateSecession GetSigners = %v, want [host-1]", got)
|
||||||
|
}
|
||||||
|
var _ []sdk.AccAddress = m2.GetSigners()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMsgInitiateSecessionValidateBasicErrors(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
msg MsgInitiateSecession
|
||||||
|
}{
|
||||||
|
{"empty guild-id", MsgInitiateSecession{Signer: "s"}},
|
||||||
|
{"empty signer", MsgInitiateSecession{GuildID: "g"}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if err := c.msg.ValidateBasic(); err == nil {
|
||||||
|
t.Errorf("case %q: ValidateBasic should fail", c.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMsgCompleteSecessionMethods(t *testing.T) {
|
||||||
|
m := &MsgCompleteSecession{
|
||||||
|
GuildID: "g1",
|
||||||
|
CovenantClearancePassed: true,
|
||||||
|
ProRataSettlementGrain: 5000,
|
||||||
|
Signer: "s1",
|
||||||
|
}
|
||||||
|
if err := m.ValidateBasic(); err != nil {
|
||||||
|
t.Errorf("valid MsgCompleteSecession ValidateBasic: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(m.String(), "g1") {
|
||||||
|
t.Errorf("MsgCompleteSecession String = %q, want g1", m.String())
|
||||||
|
}
|
||||||
|
m.Reset()
|
||||||
|
if m.GuildID != "" {
|
||||||
|
t.Errorf("MsgCompleteSecession Reset did not zero: %+v", m)
|
||||||
|
}
|
||||||
|
m.ProtoMessage()
|
||||||
|
m2 := &MsgCompleteSecession{Signer: "host-1"}
|
||||||
|
if got := m2.GetSigners(); len(got) != 1 || string(got[0]) != "host-1" {
|
||||||
|
t.Errorf("MsgCompleteSecession GetSigners = %v, want [host-1]", got)
|
||||||
|
}
|
||||||
|
var _ []sdk.AccAddress = m2.GetSigners()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMsgCompleteSecessionValidateBasicErrors(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
msg MsgCompleteSecession
|
||||||
|
}{
|
||||||
|
{"empty guild-id", MsgCompleteSecession{Signer: "s"}},
|
||||||
|
{"empty signer", MsgCompleteSecession{GuildID: "g"}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if err := c.msg.ValidateBasic(); err == nil {
|
||||||
|
t.Errorf("case %q: ValidateBasic should fail", c.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMsgEscalateStandToPierMethods(t *testing.T) {
|
||||||
|
m := &MsgEscalateStandToPier{StandID: "s1", AnnualPassVolumeCents: 100, Signer: "signer"}
|
||||||
|
if err := m.ValidateBasic(); err != nil {
|
||||||
|
t.Errorf("valid MsgEscalateStandToPier ValidateBasic: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(m.String(), "s1") {
|
||||||
|
t.Errorf("MsgEscalateStandToPier String = %q, want s1", m.String())
|
||||||
|
}
|
||||||
|
m.Reset()
|
||||||
|
if m.StandID != "" {
|
||||||
|
t.Errorf("MsgEscalateStandToPier Reset did not zero: %+v", m)
|
||||||
|
}
|
||||||
|
m.ProtoMessage()
|
||||||
|
m2 := &MsgEscalateStandToPier{Signer: "host-1"}
|
||||||
|
if got := m2.GetSigners(); len(got) != 1 || string(got[0]) != "host-1" {
|
||||||
|
t.Errorf("MsgEscalateStandToPier GetSigners = %v, want [host-1]", got)
|
||||||
|
}
|
||||||
|
var _ []sdk.AccAddress = m2.GetSigners()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMsgEscalateStandToPierValidateBasicErrors(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
msg MsgEscalateStandToPier
|
||||||
|
}{
|
||||||
|
{"empty stand-id", MsgEscalateStandToPier{AnnualPassVolumeCents: 100, Signer: "s"}},
|
||||||
|
{"zero volume", MsgEscalateStandToPier{StandID: "s", Signer: "signer"}},
|
||||||
|
{"negative volume", MsgEscalateStandToPier{StandID: "s", AnnualPassVolumeCents: -1, Signer: "signer"}},
|
||||||
|
{"empty signer", MsgEscalateStandToPier{StandID: "s", AnnualPassVolumeCents: 100}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if err := c.msg.ValidateBasic(); err == nil {
|
||||||
|
t.Errorf("case %q: ValidateBasic should fail", c.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMsgAcceptPierInvitationMethods(t *testing.T) {
|
||||||
|
m := &MsgAcceptPierInvitation{StandID: "s1", Signer: "signer"}
|
||||||
|
if err := m.ValidateBasic(); err != nil {
|
||||||
|
t.Errorf("valid MsgAcceptPierInvitation ValidateBasic: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(m.String(), "s1") {
|
||||||
|
t.Errorf("MsgAcceptPierInvitation String = %q, want s1", m.String())
|
||||||
|
}
|
||||||
|
m.Reset()
|
||||||
|
if m.StandID != "" {
|
||||||
|
t.Errorf("MsgAcceptPierInvitation Reset did not zero: %+v", m)
|
||||||
|
}
|
||||||
|
m.ProtoMessage()
|
||||||
|
m2 := &MsgAcceptPierInvitation{Signer: "host-1"}
|
||||||
|
if got := m2.GetSigners(); len(got) != 1 || string(got[0]) != "host-1" {
|
||||||
|
t.Errorf("MsgAcceptPierInvitation GetSigners = %v, want [host-1]", got)
|
||||||
|
}
|
||||||
|
var _ []sdk.AccAddress = m2.GetSigners()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMsgAcceptPierInvitationValidateBasicErrors(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
msg MsgAcceptPierInvitation
|
||||||
|
}{
|
||||||
|
{"empty stand-id", MsgAcceptPierInvitation{Signer: "s"}},
|
||||||
|
{"empty signer", MsgAcceptPierInvitation{StandID: "s"}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if err := c.msg.ValidateBasic(); err == nil {
|
||||||
|
t.Errorf("case %q: ValidateBasic should fail", c.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestP5ResponseMethods exercises the P5 Msg* Response Reset/String/ProtoMessage
|
||||||
|
// methods for coverage.
|
||||||
|
func TestP5ResponseMethods(t *testing.T) {
|
||||||
|
r1 := &MsgInitiateSecessionResponse{LienAuditPassed: true}
|
||||||
|
if !strings.Contains(r1.String(), "MsgInitiateSecessionResponse") {
|
||||||
|
t.Errorf("MsgInitiateSecessionResponse String = %q", r1.String())
|
||||||
|
}
|
||||||
|
r1.Reset()
|
||||||
|
if r1.LienAuditPassed {
|
||||||
|
t.Errorf("MsgInitiateSecessionResponse Reset did not zero: %+v", r1)
|
||||||
|
}
|
||||||
|
r1.ProtoMessage()
|
||||||
|
|
||||||
|
r2 := &MsgCompleteSecessionResponse{CoolingSeconds: 100, ProRataSettlementGrain: 200}
|
||||||
|
if !strings.Contains(r2.String(), "MsgCompleteSecessionResponse") {
|
||||||
|
t.Errorf("MsgCompleteSecessionResponse String = %q", r2.String())
|
||||||
|
}
|
||||||
|
r2.Reset()
|
||||||
|
if r2.CoolingSeconds != 0 || r2.ProRataSettlementGrain != 0 {
|
||||||
|
t.Errorf("MsgCompleteSecessionResponse Reset did not zero: %+v", r2)
|
||||||
|
}
|
||||||
|
r2.ProtoMessage()
|
||||||
|
|
||||||
|
r3 := &MsgEscalateStandToPierResponse{PierEligible: true}
|
||||||
|
if !strings.Contains(r3.String(), "MsgEscalateStandToPierResponse") {
|
||||||
|
t.Errorf("MsgEscalateStandToPierResponse String = %q", r3.String())
|
||||||
|
}
|
||||||
|
r3.Reset()
|
||||||
|
if r3.PierEligible {
|
||||||
|
t.Errorf("MsgEscalateStandToPierResponse Reset did not zero: %+v", r3)
|
||||||
|
}
|
||||||
|
r3.ProtoMessage()
|
||||||
|
|
||||||
|
r4 := &MsgAcceptPierInvitationResponse{}
|
||||||
|
r4.Reset()
|
||||||
|
if !strings.Contains(r4.String(), "MsgAcceptPierInvitationResponse") {
|
||||||
|
t.Errorf("MsgAcceptPierInvitationResponse String = %q", r4.String())
|
||||||
|
}
|
||||||
|
r4.ProtoMessage()
|
||||||
|
}
|
||||||
|
|||||||
+35
-1
@@ -39,6 +39,25 @@ const (
|
|||||||
// (the CreateChapter handler rejects shorter). Locked-const regression in
|
// (the CreateChapter handler rejects shorter). Locked-const regression in
|
||||||
// types_test.go.
|
// types_test.go.
|
||||||
CoolingSecessionNonCoverDays = uint32(14)
|
CoolingSecessionNonCoverDays = uint32(14)
|
||||||
|
|
||||||
|
// StandPierEscalationAnnualPassVolumeCents is the LOCAL cross-documented
|
||||||
|
// const for the D-074 Stand→Pier escalation threshold (REQ-059). The
|
||||||
|
// canonical const lives in x/stand/types (type ownership); this LOCAL
|
||||||
|
// const mirrors it so x/guild/keeper can reference the threshold WITHOUT
|
||||||
|
// importing x/stand/types (G-003 — no cross-module struct import; consts
|
||||||
|
// are G-003-clean in principle, but the project's G-003 regression test
|
||||||
|
// blocks ALL x/<other>/types imports, so the local-const mirror pattern
|
||||||
|
// is used — mirroring the LendingCouponCapBps local-const pattern in
|
||||||
|
// x/hub). The two consts MUST stay in sync (a change to
|
||||||
|
// x/stand/types.StandPierEscalationAnnualPassVolumeCents requires a
|
||||||
|
// matching change here). The value 10_000_000 is the D-074 simtest
|
||||||
|
// placeholder for $100k USD in Grain-cents (no oracle exists in simtest
|
||||||
|
// — the live v0.8+ mesh converts at the oracle rate). NOT LOCKED — it
|
||||||
|
// is a simtest default; the live governance may tune it. A Stand whose
|
||||||
|
// annual Pass volume exceeds this threshold is marked Pier-eligible (a
|
||||||
|
// soft upgrade — the Stand may decline the Pier invitation via not
|
||||||
|
// calling MsgAcceptPierInvitation).
|
||||||
|
StandPierEscalationAnnualPassVolumeCents int64 = 10_000_000
|
||||||
)
|
)
|
||||||
|
|
||||||
// Guild is a task-oriented collective (vision §16, REQ-017). A Guild may
|
// Guild is a task-oriented collective (vision §16, REQ-017). A Guild may
|
||||||
@@ -56,6 +75,14 @@ const (
|
|||||||
// with SecuredAtFounding=true; post-founding liens are SecuredAtFounding=false
|
// with SecuredAtFounding=true; post-founding liens are SecuredAtFounding=false
|
||||||
// (the AddLien handler rejects any new SecuredAtFounding=true lien — founding
|
// (the AddLien handler rejects any new SecuredAtFounding=true lien — founding
|
||||||
// is a one-time event).
|
// is a one-time event).
|
||||||
|
//
|
||||||
|
// P5 extension (REQ-064): the Guild carries two additive secession-lifecycle
|
||||||
|
// fields. SecessionStartedAt is the unix-seconds timestamp the secession was
|
||||||
|
// initiated (0 = not seceding — the MsgInitiateSecession handler sets it).
|
||||||
|
// SecededAt is the unix-seconds timestamp the secession completed (0 = not
|
||||||
|
// yet seceded — the MsgCompleteSecession handler sets it). Both default to 0
|
||||||
|
// (additive — existing Guild records keep zero values until a secession is
|
||||||
|
// initiated/completed).
|
||||||
type Guild struct {
|
type Guild struct {
|
||||||
GuildID string `json:"guild_id" yaml:"guild_id"`
|
GuildID string `json:"guild_id" yaml:"guild_id"`
|
||||||
Name string `json:"name" yaml:"name"`
|
Name string `json:"name" yaml:"name"`
|
||||||
@@ -68,6 +95,8 @@ type Guild struct {
|
|||||||
IsChapter bool `json:"is_chapter,omitempty" yaml:"is_chapter,omitempty"`
|
IsChapter bool `json:"is_chapter,omitempty" yaml:"is_chapter,omitempty"`
|
||||||
SecessionTermsHash []byte `json:"secession_terms_hash,omitempty" yaml:"secession_terms_hash,omitempty"`
|
SecessionTermsHash []byte `json:"secession_terms_hash,omitempty" yaml:"secession_terms_hash,omitempty"`
|
||||||
GoodStandingLiens []Lien `json:"good_standing_liens,omitempty" yaml:"good_standing_liens,omitempty"`
|
GoodStandingLiens []Lien `json:"good_standing_liens,omitempty" yaml:"good_standing_liens,omitempty"`
|
||||||
|
SecessionStartedAt int64 `json:"secession_started_at,omitempty" yaml:"secession_started_at,omitempty"`
|
||||||
|
SecededAt int64 `json:"seceded_at,omitempty" yaml:"seceded_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GuildPublicProfile is a Guild's published profile (REQ-051). BondSummary is
|
// GuildPublicProfile is a Guild's published profile (REQ-051). BondSummary is
|
||||||
@@ -92,12 +121,17 @@ type GuildPublicProfile struct {
|
|||||||
// Guild/Chapter creation; NOT freely increasable post-founding — the AddLien
|
// Guild/Chapter creation; NOT freely increasable post-founding — the AddLien
|
||||||
// handler rejects any new SecuredAtFounding=true lien). CoverPoolCovenantRef
|
// handler rejects any new SecuredAtFounding=true lien). CoverPoolCovenantRef
|
||||||
// references a Cover Pool covenant by-ID-string (G-003); empty for a lien
|
// references a Cover Pool covenant by-ID-string (G-003); empty for a lien
|
||||||
// with no Cover Pool covenant backing.
|
// with no Cover Pool covenant backing. Cleared is the P5 secession lien-audit
|
||||||
|
// flag (REQ-064): the MsgInitiateSecession + MsgCompleteSecession handlers
|
||||||
|
// consult the LienAudit (CheckLiensCleared) which returns true only when
|
||||||
|
// every lien on the Chapter has Cleared=true (or Amount=0). Cleared defaults
|
||||||
|
// to false (additive — existing liens keep false until cleared).
|
||||||
type Lien struct {
|
type Lien struct {
|
||||||
Amount int64 `json:"amount" yaml:"amount"`
|
Amount int64 `json:"amount" yaml:"amount"`
|
||||||
CreditorReachID string `json:"creditor_reach_id" yaml:"creditor_reach_id"`
|
CreditorReachID string `json:"creditor_reach_id" yaml:"creditor_reach_id"`
|
||||||
SecuredAtFounding bool `json:"secured_at_founding" yaml:"secured_at_founding"`
|
SecuredAtFounding bool `json:"secured_at_founding" yaml:"secured_at_founding"`
|
||||||
CoverPoolCovenantRef string `json:"cover_pool_covenant_ref,omitempty" yaml:"cover_pool_covenant_ref,omitempty"`
|
CoverPoolCovenantRef string `json:"cover_pool_covenant_ref,omitempty" yaml:"cover_pool_covenant_ref,omitempty"`
|
||||||
|
Cleared bool `json:"cleared,omitempty" yaml:"cleared,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SecessionTerms is a Chapter's secession cooling terms (REQ-053, REQ-064).
|
// SecessionTerms is a Chapter's secession cooling terms (REQ-053, REQ-064).
|
||||||
|
|||||||
@@ -579,3 +579,59 @@ func packageDir(t *testing.T, importPath string) string {
|
|||||||
rel := strings.TrimPrefix(importPath, "github.com/oy/openyield/")
|
rel := strings.TrimPrefix(importPath, "github.com/oy/openyield/")
|
||||||
return filepath.Join(repoRoot, rel)
|
return filepath.Join(repoRoot, rel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- P5 locked-const + struct regression (REQ-064, REQ-059, D-074) -------------
|
||||||
|
|
||||||
|
// TestStandPierEscalationAnnualPassVolumeCentsLocalConst asserts the LOCAL
|
||||||
|
// cross-documented Stand→Pier escalation threshold const (REQ-059, D-074)
|
||||||
|
// holds its value + matches the canonical const in x/stand/types. The two
|
||||||
|
// consts MUST stay in sync (the LOCAL const is the G-003 mirror of
|
||||||
|
// x/stand/types.StandPierEscalationAnnualPassVolumeCents — the x/guild
|
||||||
|
// keeper references the LOCAL const to avoid the cross-module struct
|
||||||
|
// import).
|
||||||
|
func TestStandPierEscalationAnnualPassVolumeCentsLocalConst(t *testing.T) {
|
||||||
|
if types.StandPierEscalationAnnualPassVolumeCents != 10_000_000 {
|
||||||
|
t.Errorf("StandPierEscalationAnnualPassVolumeCents (LOCAL) = %d, want 10000000 (D-074 — REQ-059)",
|
||||||
|
types.StandPierEscalationAnnualPassVolumeCents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGuildP5SecessionFields asserts the Guild struct carries the P5
|
||||||
|
// additive secession-lifecycle fields (SecessionStartedAt + SecededAt) —
|
||||||
|
// a compile-time + runtime regression firewall (removing either field
|
||||||
|
// breaks this test). Both default to 0 (additive — existing Guild records
|
||||||
|
// keep zero values until a secession is initiated/completed).
|
||||||
|
func TestGuildP5SecessionFields(t *testing.T) {
|
||||||
|
g := types.Guild{
|
||||||
|
GuildID: "g1",
|
||||||
|
SecessionStartedAt: 1000,
|
||||||
|
SecededAt: 2000,
|
||||||
|
}
|
||||||
|
if g.SecessionStartedAt != 1000 {
|
||||||
|
t.Errorf("SecessionStartedAt = %d, want 1000", g.SecessionStartedAt)
|
||||||
|
}
|
||||||
|
if g.SecededAt != 2000 {
|
||||||
|
t.Errorf("SecededAt = %d, want 2000", g.SecededAt)
|
||||||
|
}
|
||||||
|
// Default zero-value (additive — existing Guild records unchanged).
|
||||||
|
var g2 types.Guild
|
||||||
|
if g2.SecessionStartedAt != 0 || g2.SecededAt != 0 {
|
||||||
|
t.Error("zero-value Guild secession fields should be 0 (additive — existing records unchanged)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLienP5ClearedField asserts the Lien struct carries the P5 additive
|
||||||
|
// Cleared field (REQ-064 secession lien-audit flag) — a compile-time +
|
||||||
|
// runtime regression firewall. Cleared defaults to false (additive —
|
||||||
|
// existing liens keep false until cleared).
|
||||||
|
func TestLienP5ClearedField(t *testing.T) {
|
||||||
|
l := types.Lien{Amount: 100, CreditorReachID: "c", SecuredAtFounding: true, Cleared: true}
|
||||||
|
if !l.Cleared {
|
||||||
|
t.Error("Lien Cleared = false, want true")
|
||||||
|
}
|
||||||
|
// Default zero-value (additive — existing liens unchanged).
|
||||||
|
var l2 types.Lien
|
||||||
|
if l2.Cleared {
|
||||||
|
t.Error("zero-value Lien Cleared should be false (additive — existing liens unchanged until cleared)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,20 @@ const (
|
|||||||
// A regression firewall: adding/removing/renaming a Stand type breaks this
|
// A regression firewall: adding/removing/renaming a Stand type breaks this
|
||||||
// const's test.
|
// const's test.
|
||||||
StandTypeCount = 9
|
StandTypeCount = 9
|
||||||
|
|
||||||
|
// StandPierEscalationAnnualPassVolumeCents is the D-074 simtest
|
||||||
|
// placeholder for the $100k USD Stand→Pier escalation threshold
|
||||||
|
// (REQ-059). The const is expressed in Grain-cents (the OY internal
|
||||||
|
// unit); the value 10_000_000 is the simtest placeholder for $100k USD
|
||||||
|
// at the current USD/Grain oracle rate (no oracle exists in simtest —
|
||||||
|
// the live v0.8+ mesh converts at the oracle rate). NOT LOCKED — it is
|
||||||
|
// a simtest default; the live governance may tune it. The
|
||||||
|
// x/guild/keeper MsgEscalateStandToPier handler imports this const
|
||||||
|
// (G-003-clean: consts are not structs — only struct imports are
|
||||||
|
// forbidden). A Stand whose annual Pass volume exceeds this threshold
|
||||||
|
// is marked Pier-eligible (a soft upgrade — the Stand may decline the
|
||||||
|
// Pier invitation via not calling MsgAcceptPierInvitation).
|
||||||
|
StandPierEscalationAnnualPassVolumeCents int64 = 10_000_000
|
||||||
)
|
)
|
||||||
|
|
||||||
// StandType enumerates the nine organizational forms (vision §11, REQ-016).
|
// StandType enumerates the nine organizational forms (vision §11, REQ-016).
|
||||||
|
|||||||
@@ -244,6 +244,19 @@ func TestModuleConsts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestStandPierEscalationAnnualPassVolumeCents (P5, D-074) asserts the
|
||||||
|
// Stand→Pier escalation threshold const holds its simtest-placeholder value
|
||||||
|
// (10M Grain-cents = the D-074 placeholder for $100k USD). NOT LOCKED — it
|
||||||
|
// is a simtest default; the live governance may tune it. A regression
|
||||||
|
// here breaks the x/guild keeper's EscalateStandToPier handler threshold
|
||||||
|
// (the LOCAL cross-documented const in x/guild/types MUST stay in sync).
|
||||||
|
func TestStandPierEscalationAnnualPassVolumeCents(t *testing.T) {
|
||||||
|
if types.StandPierEscalationAnnualPassVolumeCents != 10_000_000 {
|
||||||
|
t.Errorf("StandPierEscalationAnnualPassVolumeCents = %d, want 10000000 (D-074 simtest placeholder for $100k USD — REQ-059)",
|
||||||
|
types.StandPierEscalationAnnualPassVolumeCents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestDefaultParams asserts DefaultParams returns a zero-value Params.
|
// TestDefaultParams asserts DefaultParams returns a zero-value Params.
|
||||||
func TestDefaultParams(t *testing.T) {
|
func TestDefaultParams(t *testing.T) {
|
||||||
_ = types.DefaultParams() // no panics
|
_ = types.DefaultParams() // no panics
|
||||||
|
|||||||
+59
-14
@@ -38,6 +38,29 @@ const (
|
|||||||
FreeholderStashMaxGapDays = 30 // no gap > 30 days
|
FreeholderStashMaxGapDays = 30 // no gap > 30 days
|
||||||
FreeholderMinStandingScore = 4.5 // 4.5+ in at least 3 service categories
|
FreeholderMinStandingScore = 4.5 // 4.5+ in at least 3 service categories
|
||||||
FreeholderMinCategories = 3 // at least 3 service categories
|
FreeholderMinCategories = 3 // at least 3 service categories
|
||||||
|
|
||||||
|
// ShadowVouchWeightMultiplier is the LOCKED weight multiplier applied to
|
||||||
|
// a Shadow vouch (vision §9.1, REQ-060 locked). A Shadow vouch is a
|
||||||
|
// vouch from a holder whose identity is not publicly linked to their
|
||||||
|
// vouching activity (the vouch carries skin-in-the-game but the
|
||||||
|
// voucher's standing is not publicly attributable). The multiplier
|
||||||
|
// halves the vouch weight: a Shadow Freeholder vouch weighs 0.75 (1.5
|
||||||
|
// × 0.5) instead of 1.5. The const makes the 0.5× mission-locked
|
||||||
|
// (REQ-060 locked) and regression-testable. A regression here is a
|
||||||
|
// mission-lock breach.
|
||||||
|
ShadowVouchWeightMultiplier = 0.5
|
||||||
|
|
||||||
|
// SlashReasonFraudulentCoverCall is the slash reason for a Cover Claims
|
||||||
|
// Voucher that adjudicated a Cover Call fraudulently (REQ-055, vision
|
||||||
|
// §9.4). The slash drops the Voucher's Standing bucket (cross-Pool
|
||||||
|
// applicability — the bucket drop disqualifies them from other Pools'
|
||||||
|
// Standing gates). The const value is the string recorded on
|
||||||
|
// x/standing.Slash.Reason. Cross-documented to
|
||||||
|
// x/cover.types.SlashReasonFraudulentCoverCall (a LOCAL const in
|
||||||
|
// x/cover to avoid importing x/standing — G-003 — the two consts MUST
|
||||||
|
// stay in sync; a change to one requires a matching change to the
|
||||||
|
// other).
|
||||||
|
SlashReasonFraudulentCoverCall = "FraudulentCoverCall"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Rating is a single rating event (§9.2)
|
// Rating is a single rating event (§9.2)
|
||||||
@@ -52,16 +75,28 @@ type Rating struct {
|
|||||||
DecayBucket uint8 `json:"decay_bucket" yaml:"decay_bucket"`
|
DecayBucket uint8 `json:"decay_bucket" yaml:"decay_bucket"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vouch is a Freeholder vouch with skin-in-the-game (§9.1)
|
// Vouch is a Freeholder vouch with skin-in-the-game (§9.1). IsShadow records
|
||||||
|
// whether this is a Shadow vouch (REQ-060 — a vouch from a holder whose
|
||||||
|
// identity is not publicly linked to their vouching activity; the vouch
|
||||||
|
// carries skin-in-the-game but the voucher's standing is not publicly
|
||||||
|
// attributable). A Shadow vouch's weight is halved by
|
||||||
|
// ShadowVouchWeightMultiplier (0.5×) in GetVoucherWeight (the post-step
|
||||||
|
// multiplier). The field is additive (existing non-Shadow vouches keep
|
||||||
|
// IsShadow=false -> the same weight as before).
|
||||||
type Vouch struct {
|
type Vouch struct {
|
||||||
VoucherID string `json:"voucher_id" yaml:"voucher_id"`
|
VoucherID string `json:"voucher_id" yaml:"voucher_id"`
|
||||||
VoucheeID string `json:"vouchee_id" yaml:"vouchee_id"`
|
VoucheeID string `json:"vouchee_id" yaml:"vouchee_id"`
|
||||||
Category string `json:"category" yaml:"category"`
|
Category string `json:"category" yaml:"category"`
|
||||||
BondAmount int64 `json:"bond_amount" yaml:"bond_amount"` // voucher skin-in-the-game
|
BondAmount int64 `json:"bond_amount" yaml:"bond_amount"` // voucher skin-in-the-game
|
||||||
Timestamp int64 `json:"timestamp" yaml:"timestamp"`
|
Timestamp int64 `json:"timestamp" yaml:"timestamp"`
|
||||||
|
IsShadow bool `json:"is_shadow" yaml:"is_shadow"` // REQ-060 Shadow vouch flag
|
||||||
}
|
}
|
||||||
|
|
||||||
// Slash penalizes a Holder (§9.4)
|
// Slash penalizes a Holder (§9.4). Reason is one of "Crack",
|
||||||
|
// "FraudulentCoverCall" (the SlashReasonFraudulentCoverCall const — REQ-055,
|
||||||
|
// for a Cover Claims Voucher that adjudicated a Cover Call fraudulently;
|
||||||
|
// cross-Pool applicability via the Standing bucket drop), or
|
||||||
|
// "InactivityTimeout".
|
||||||
type Slash struct {
|
type Slash struct {
|
||||||
ReachID string `json:"reach_id" yaml:"reach_id"`
|
ReachID string `json:"reach_id" yaml:"reach_id"`
|
||||||
Amount float64 `json:"amount" yaml:"amount"`
|
Amount float64 `json:"amount" yaml:"amount"`
|
||||||
@@ -108,21 +143,31 @@ func ComputeDiversityBonus(categoryCount int) float64 {
|
|||||||
return 0.0
|
return 0.0
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetVoucherWeight returns the weight for a given rater profile (§9.2)
|
// GetVoucherWeight returns the weight for a given rater profile (§9.2,
|
||||||
func GetVoucherWeight(isFreeholder bool, standingScore float64, ratingCount int) float64 {
|
// REQ-060). The base weight is computed from isFreeholder + standingScore +
|
||||||
|
// ratingCount as before; the post-step applies the Shadow vouch multiplier:
|
||||||
|
// if isShadow is true, the base weight is multiplied by
|
||||||
|
// ShadowVouchWeightMultiplier (0.5× — a Shadow vouch weighs half). The
|
||||||
|
// isShadow parameter is the vouch's Shadow flag (x/standing.Vouch.IsShadow);
|
||||||
|
// existing non-Shadow vouches pass false -> the same weight as before
|
||||||
|
// (additive — REQ-060).
|
||||||
|
func GetVoucherWeight(isFreeholder bool, standingScore float64, ratingCount int, isShadow bool) float64 {
|
||||||
|
var w float64
|
||||||
if isFreeholder {
|
if isFreeholder {
|
||||||
return VoucherWeightFreeholder
|
w = VoucherWeightFreeholder
|
||||||
|
} else if ratingCount < 10 {
|
||||||
|
w = VoucherWeightBelow10Ratings
|
||||||
|
} else if standingScore >= 4.5 {
|
||||||
|
w = VoucherWeight45Plus
|
||||||
|
} else if standingScore >= 4.0 {
|
||||||
|
w = VoucherWeight40To45
|
||||||
|
} else {
|
||||||
|
w = VoucherWeightBelow40
|
||||||
}
|
}
|
||||||
if ratingCount < 10 {
|
if isShadow {
|
||||||
return VoucherWeightBelow10Ratings
|
w *= ShadowVouchWeightMultiplier
|
||||||
}
|
}
|
||||||
if standingScore >= 4.5 {
|
return w
|
||||||
return VoucherWeight45Plus
|
|
||||||
}
|
|
||||||
if standingScore >= 4.0 {
|
|
||||||
return VoucherWeight40To45
|
|
||||||
}
|
|
||||||
return VoucherWeightBelow40
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStandingBucket returns the display bucket for a score (§9.2)
|
// GetStandingBucket returns the display bucket for a score (§9.2)
|
||||||
|
|||||||
@@ -46,19 +46,19 @@ func TestDiversityBonus(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestVoucherWeights(t *testing.T) {
|
func TestVoucherWeights(t *testing.T) {
|
||||||
if types.GetVoucherWeight(true, 4.0, 100) != 1.5 {
|
if types.GetVoucherWeight(true, 4.0, 100, false) != 1.5 {
|
||||||
t.Error("Freeholder weight should be 1.5x (§9.2)")
|
t.Error("Freeholder weight should be 1.5x (§9.2)")
|
||||||
}
|
}
|
||||||
if types.GetVoucherWeight(false, 4.6, 100) != 1.2 {
|
if types.GetVoucherWeight(false, 4.6, 100, false) != 1.2 {
|
||||||
t.Error("4.5+ with 1-2 cats should be 1.2x (§9.2)")
|
t.Error("4.5+ with 1-2 cats should be 1.2x (§9.2)")
|
||||||
}
|
}
|
||||||
if types.GetVoucherWeight(false, 4.2, 100) != 1.0 {
|
if types.GetVoucherWeight(false, 4.2, 100, false) != 1.0 {
|
||||||
t.Error("4.0-4.5 should be 1.0x (§9.2)")
|
t.Error("4.0-4.5 should be 1.0x (§9.2)")
|
||||||
}
|
}
|
||||||
if types.GetVoucherWeight(false, 3.5, 100) != 0.5 {
|
if types.GetVoucherWeight(false, 3.5, 100, false) != 0.5 {
|
||||||
t.Error("Below 4.0 should be 0.5x (§9.2)")
|
t.Error("Below 4.0 should be 0.5x (§9.2)")
|
||||||
}
|
}
|
||||||
if types.GetVoucherWeight(false, 4.0, 5) != 0.3 {
|
if types.GetVoucherWeight(false, 4.0, 5, false) != 0.3 {
|
||||||
t.Error("Below 10 ratings should be 0.3x (§9.2)")
|
t.Error("Below 10 ratings should be 0.3x (§9.2)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,3 +98,70 @@ func TestLockedConstants(t *testing.T) {
|
|||||||
t.Error("Min counterparties for Freeholder status should be 30 (§9.2)")
|
t.Error("Min counterparties for Freeholder status should be 30 (§9.2)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- P4: Shadow vouch 50% weight (REQ-060 locked) + SlashReason const ---------
|
||||||
|
|
||||||
|
// TestShadowVouchWeightMultiplier asserts the Shadow vouch weight multiplier
|
||||||
|
// is the locked 0.5 (REQ-060 locked — vision §9.1). A regression here is a
|
||||||
|
// mission-lock breach.
|
||||||
|
func TestShadowVouchWeightMultiplier(t *testing.T) {
|
||||||
|
if types.ShadowVouchWeightMultiplier != 0.5 {
|
||||||
|
t.Errorf("ShadowVouchWeightMultiplier = %v, want 0.5 (REQ-060 locked — Shadow vouch weighs half)", types.ShadowVouchWeightMultiplier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestShadowVouchWeight asserts GetVoucherWeight applies the 0.5× Shadow
|
||||||
|
// multiplier as a post-step (REQ-060):
|
||||||
|
// - non-Shadow vouch: GetVoucherWeight(false, 4.5, 100, false) ==
|
||||||
|
// VoucherWeight45Plus (unchanged — the additive field keeps existing
|
||||||
|
// vouches at the same weight).
|
||||||
|
// - Shadow vouch: GetVoucherWeight(false, 4.5, 100, true) ==
|
||||||
|
// VoucherWeight45Plus * 0.5 (Shadow halves the weight).
|
||||||
|
// - Shadow Freeholder: GetVoucherWeight(true, 4.0, 100, true) ==
|
||||||
|
// VoucherWeightFreeholder * 0.5 (Shadow Freeholder).
|
||||||
|
func TestShadowVouchWeight(t *testing.T) {
|
||||||
|
// Non-Shadow 4.5+ vouch: weight unchanged (VoucherWeight45Plus).
|
||||||
|
got := types.GetVoucherWeight(false, 4.5, 100, false)
|
||||||
|
if got != types.VoucherWeight45Plus {
|
||||||
|
t.Errorf("non-Shadow 4.5+ weight = %v, want %v (unchanged — additive)", got, types.VoucherWeight45Plus)
|
||||||
|
}
|
||||||
|
// Shadow 4.5+ vouch: weight halved.
|
||||||
|
got = types.GetVoucherWeight(false, 4.5, 100, true)
|
||||||
|
if got != types.VoucherWeight45Plus*0.5 {
|
||||||
|
t.Errorf("Shadow 4.5+ weight = %v, want %v (VoucherWeight45Plus * 0.5 — REQ-060)", got, types.VoucherWeight45Plus*0.5)
|
||||||
|
}
|
||||||
|
// Shadow Freeholder: weight halved.
|
||||||
|
got = types.GetVoucherWeight(true, 4.0, 100, true)
|
||||||
|
if got != types.VoucherWeightFreeholder*0.5 {
|
||||||
|
t.Errorf("Shadow Freeholder weight = %v, want %v (VoucherWeightFreeholder * 0.5 — REQ-060)", got, types.VoucherWeightFreeholder*0.5)
|
||||||
|
}
|
||||||
|
// Non-Shadow Freeholder: weight unchanged.
|
||||||
|
got = types.GetVoucherWeight(true, 4.0, 100, false)
|
||||||
|
if got != types.VoucherWeightFreeholder {
|
||||||
|
t.Errorf("non-Shadow Freeholder weight = %v, want %v (unchanged — additive)", got, types.VoucherWeightFreeholder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSlashReasonFraudulentCoverCall asserts the slash reason const for a
|
||||||
|
// fraudulent Cover Call adjudication (REQ-055 — cross-documented to
|
||||||
|
// x/cover.types.SlashReasonFraudulentCoverCall, a LOCAL const in x/cover to
|
||||||
|
// avoid importing x/standing — G-003; the two consts MUST stay in sync).
|
||||||
|
func TestSlashReasonFraudulentCoverCall(t *testing.T) {
|
||||||
|
if types.SlashReasonFraudulentCoverCall != "FraudulentCoverCall" {
|
||||||
|
t.Errorf("SlashReasonFraudulentCoverCall = %q, want %q (REQ-055 — cross-doc x/cover)", types.SlashReasonFraudulentCoverCall, "FraudulentCoverCall")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVouchIsShadowField asserts the Vouch struct carries the IsShadow field
|
||||||
|
// (REQ-060 — additive; existing non-Shadow vouches keep IsShadow=false).
|
||||||
|
func TestVouchIsShadowField(t *testing.T) {
|
||||||
|
v := types.Vouch{VoucherID: "v1", VoucheeID: "u1", Category: "Travel", BondAmount: 100, Timestamp: 1000, IsShadow: true}
|
||||||
|
if !v.IsShadow {
|
||||||
|
t.Error("Vouch.IsShadow should be true when set (REQ-060)")
|
||||||
|
}
|
||||||
|
// Default zero-value is false (existing non-Shadow vouches keep false).
|
||||||
|
var v2 types.Vouch
|
||||||
|
if v2.IsShadow {
|
||||||
|
t.Error("zero-value Vouch.IsShadow should be false (additive — existing vouches unchanged)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user