Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f47c89c6b | |||
| 101a750452 | |||
| 9924a4853a | |||
| 9abda8d01e | |||
| dd84e24a51 |
+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
|
||||
> 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
|
||||
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 |
|
||||
|----|-------------|----------|----------|--------|-------|
|
||||
| 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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | Complete | v0.7/P4 |
|
||||
| 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 | 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 | 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 | 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 | 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 | Complete | v0.7/P5 |
|
||||
|
||||
> 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,
|
||||
|
||||
@@ -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
|
||||
- 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
|
||||
**Target**: $10B annual volume → fee auto-declines to 0.07%
|
||||
|
||||
|
||||
+199
-1
@@ -43,6 +43,7 @@ type Keeper struct {
|
||||
watcherKeeper types.WatcherKeeper
|
||||
bondKeeper types.BondKeeper
|
||||
stillKeeper types.StillKeeper
|
||||
guildKeeper types.GuildKeeper
|
||||
// paramsOverride is a simtest-grade Params override (nil = use
|
||||
// DefaultParams). A future P2+ will load the Params from the params
|
||||
// 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
|
||||
// (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
|
||||
// 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 {
|
||||
return Keeper{
|
||||
cdc: cdc,
|
||||
@@ -83,6 +85,11 @@ func (k *Keeper) SetBondKeeper(bk types.BondKeeper) { k.bondKeeper = bk }
|
||||
// SetStillKeeper sets the StillKeeper expected-keeper shim.
|
||||
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
|
||||
// DefaultParams). The D-086 simtest case (f) uses this to restrict
|
||||
// FactoryAllowedPhases to [Phase2, Phase3] only and reject a Phase4
|
||||
@@ -541,3 +548,194 @@ func (k Keeper) RatifyCharterAmendment(ctx sdk.Context, amendmentID string, now
|
||||
k.SetCharterAmendment(ctx, a)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -932,3 +932,170 @@ func (s msgServer) DissolveCoverPool(ctx interface{}, msg *types.MsgDissolveCove
|
||||
))
|
||||
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
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ package keeper_test
|
||||
// Coverage target: >=80% on x/cover/keeper.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -2470,3 +2471,595 @@ func TestVoucherKeeperAccessors(t *testing.T) {
|
||||
t.Error("GetCoverClaimsVoucher on corrupt bytes should return false")
|
||||
}
|
||||
}
|
||||
|
||||
// --- P5: Anti-Capture Bill of Rights ceremony + Pier Selection (REQ-056, REQ-066) --
|
||||
//
|
||||
// (REQ-056 §7 acceptance ceremony, REQ-066 Pier Selection.) The P5 simtest
|
||||
// cases exercise the Counsel review ceremony (bonded Counsel Staked=true ->
|
||||
// succeeds; unbonded Staked=false -> REJECTED), the Pier selection by a
|
||||
// Guild Council (SelectPier -> succeeds; PierSelectionIndex updated), the
|
||||
// revocation by Cover Pool supermajority + Counsel witness, and the
|
||||
// keeper-level GetPierSelectionIndex query.
|
||||
|
||||
// stubGuildKeeper satisfies types.GuildKeeper for the P5 simtest. It
|
||||
// returns a configurable exists-bool per guild-id (a missing key returns
|
||||
// false — the non-existent Guild case).
|
||||
type stubGuildKeeper struct {
|
||||
guilds map[string]bool
|
||||
}
|
||||
|
||||
func (s *stubGuildKeeper) GetGuild(guildID string) bool {
|
||||
if s.guilds == nil {
|
||||
return false
|
||||
}
|
||||
return s.guilds[guildID]
|
||||
}
|
||||
|
||||
// TestSignCoverCharterWaivedRightsEachOf13Rights (P5 case c) asserts that
|
||||
// signing a Cover-Charter with ANY of the 13 rights in WaivedRights is
|
||||
// REJECTED at ValidateBasic (the D-090(1) dual-firewall gate — re-verified
|
||||
// from P2). This is the full 13-rights regression: each right is tried in
|
||||
// isolation; all 13 must REJECT.
|
||||
func TestSignCoverCharterWaivedRightsEachOf13Rights(t *testing.T) {
|
||||
for _, right := range types.AllRights() {
|
||||
m := &types.MsgSignCoverCharter{
|
||||
CharterID: "c-" + string(right),
|
||||
PoolID: "p",
|
||||
HostReachID: "h",
|
||||
DisputePath: "dp",
|
||||
Gate: "g",
|
||||
HoldingPeriodDays: 30,
|
||||
Signer: "s",
|
||||
WaivedRights: []types.RightID{right},
|
||||
}
|
||||
err := m.ValidateBasic()
|
||||
if err == nil {
|
||||
t.Errorf("ValidateBasic with WaivedRights=[%q] should be rejected (D-090(1) — all 13 rights non-waivable)", right)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), "REQ-056") {
|
||||
t.Errorf("ValidateBasic with WaivedRights=[%q]: error = %q, want 'REQ-056'", right, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCounselReviewBillOfRightsStakedSuccess (P5 case d) asserts a bonded
|
||||
// Counsel review (Staked=true) succeeds + the review is recorded + the
|
||||
// event is emitted.
|
||||
func TestCounselReviewBillOfRightsStakedSuccess(t *testing.T) {
|
||||
ctx, _, _, _, _, _, k := newSimtestContext(t)
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
|
||||
_, err := srv.CounselReviewBillOfRights(ctx, &types.MsgCounselReviewBillOfRights{
|
||||
ReviewID: "review-1",
|
||||
CounselReachID: "counsel-1",
|
||||
Staked: true,
|
||||
ReviewResult: "Affirmed",
|
||||
Signer: "counsel-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CounselReviewBillOfRights Staked=true: %v", err)
|
||||
}
|
||||
r, ok := k.GetBillOfRightsReview(ctx, "review-1")
|
||||
if !ok {
|
||||
t.Fatal("BillOfRightsReview not persisted")
|
||||
}
|
||||
if r.CounselReachID != "counsel-1" || r.ReviewResult != "Affirmed" || !r.Staked {
|
||||
t.Errorf("BillOfRightsReview = %+v", r)
|
||||
}
|
||||
if !hasEvent(ctx, "cover.bill_of_rights_reviewed") {
|
||||
t.Error("cover.bill_of_rights_reviewed event not emitted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCounselReviewBillOfRightsUnbondedRejected (P5 case d) asserts an
|
||||
// unbonded Counsel review (Staked=false) is REJECTED at ValidateBasic (the
|
||||
// §7 "bonded Counsel" acceptance criterion).
|
||||
func TestCounselReviewBillOfRightsUnbondedRejected(t *testing.T) {
|
||||
ctx, _, _, _, _, _, k := newSimtestContext(t)
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
|
||||
_, err := srv.CounselReviewBillOfRights(ctx, &types.MsgCounselReviewBillOfRights{
|
||||
ReviewID: "review-unbonded",
|
||||
CounselReachID: "counsel-1",
|
||||
Staked: false,
|
||||
ReviewResult: "Affirmed",
|
||||
Signer: "counsel-1",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("CounselReviewBillOfRights with Staked=false should be rejected (§7 bonded Counsel)")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Staked") {
|
||||
t.Errorf("error = %q, want 'Staked'", err.Error())
|
||||
}
|
||||
if _, ok := k.GetBillOfRightsReview(ctx, "review-unbonded"); ok {
|
||||
t.Error("BillOfRightsReview should NOT be persisted on reject")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCounselReviewBillOfRightsIdempotentReject asserts a second review on
|
||||
// the same ReviewID is REJECTED.
|
||||
func TestCounselReviewBillOfRightsIdempotentReject(t *testing.T) {
|
||||
ctx, _, _, _, _, _, k := newSimtestContext(t)
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
|
||||
first := &types.MsgCounselReviewBillOfRights{
|
||||
ReviewID: "review-dup", CounselReachID: "c", Staked: true, ReviewResult: "Affirmed", Signer: "c",
|
||||
}
|
||||
if _, err := srv.CounselReviewBillOfRights(ctx, first); err != nil {
|
||||
t.Fatalf("first CounselReviewBillOfRights: %v", err)
|
||||
}
|
||||
_, err := srv.CounselReviewBillOfRights(ctx, first)
|
||||
if err == nil {
|
||||
t.Error("second CounselReviewBillOfRights on same ReviewID should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCounselReviewBillOfRightsValidateBasicErrors exercises each
|
||||
// ValidateBasic error path for coverage.
|
||||
func TestCounselReviewBillOfRightsValidateBasicErrors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
msg types.MsgCounselReviewBillOfRights
|
||||
}{
|
||||
{"empty review-id", types.MsgCounselReviewBillOfRights{CounselReachID: "c", Staked: true, ReviewResult: "r", Signer: "s"}},
|
||||
{"empty counsel-reach-id", types.MsgCounselReviewBillOfRights{ReviewID: "r", Staked: true, ReviewResult: "r", Signer: "s"}},
|
||||
{"empty review-result", types.MsgCounselReviewBillOfRights{ReviewID: "r", CounselReachID: "c", Staked: true, Signer: "s"}},
|
||||
{"empty signer", types.MsgCounselReviewBillOfRights{ReviewID: "r", CounselReachID: "c", Staked: true, ReviewResult: "r"}},
|
||||
{"staked false", types.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCounselReviewBillOfRightsMethods exercises the Msg +
|
||||
// MsgResponse Reset/String/ProtoMessage/GetSigners methods for coverage.
|
||||
func TestCounselReviewBillOfRightsMethods(t *testing.T) {
|
||||
m := &types.MsgCounselReviewBillOfRights{ReviewID: "r", CounselReachID: "c", Staked: true, ReviewResult: "res", Signer: "s"}
|
||||
if !strings.Contains(m.String(), "r") {
|
||||
t.Errorf("MsgCounselReviewBillOfRights String = %q", m.String())
|
||||
}
|
||||
m.Reset()
|
||||
if m.ReviewID != "" {
|
||||
t.Errorf("MsgCounselReviewBillOfRights Reset did not zero: %+v", m)
|
||||
}
|
||||
m.ProtoMessage()
|
||||
m2 := &types.MsgCounselReviewBillOfRights{Signer: "host-1"}
|
||||
if got := m2.GetSigners(); len(got) != 1 || string(got[0]) != "host-1" {
|
||||
t.Errorf("MsgCounselReviewBillOfRights GetSigners = %v", got)
|
||||
}
|
||||
r := &types.MsgCounselReviewBillOfRightsResponse{}
|
||||
r.Reset()
|
||||
if !strings.Contains(r.String(), "MsgCounselReviewBillOfRightsResponse") {
|
||||
t.Errorf("MsgCounselReviewBillOfRightsResponse String = %q", r.String())
|
||||
}
|
||||
r.ProtoMessage()
|
||||
}
|
||||
|
||||
// TestSelectPierSuccess (P5 case e) asserts a Guild Council's Pier selection
|
||||
// succeeds: the PierSelectionRecord is persisted + the PierSelectionIndex is
|
||||
// created + the event is emitted. A nil GuildKeeper skips the existence
|
||||
// check (simtest wiring).
|
||||
func TestSelectPierSuccess(t *testing.T) {
|
||||
ctx, _, _, _, _, _, k := newSimtestContext(t)
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
|
||||
_, err := srv.SelectPier(ctx, &types.MsgSelectPier{
|
||||
GuildID: "guild-1", PierID: "pier-1", Signer: "reach:founder",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SelectPier: %v", err)
|
||||
}
|
||||
rec, ok := k.GetPierSelectionRecord(ctx, "guild-1")
|
||||
if !ok {
|
||||
t.Fatal("PierSelectionRecord not persisted")
|
||||
}
|
||||
if rec.PierID != "pier-1" || rec.SelectedBy != "reach:founder" {
|
||||
t.Errorf("PierSelectionRecord = %+v", rec)
|
||||
}
|
||||
idx, ok := k.GetPierSelectionIndex(ctx, "pier-1")
|
||||
if !ok {
|
||||
t.Fatal("PierSelectionIndex not created")
|
||||
}
|
||||
if idx.PierID != "pier-1" {
|
||||
t.Errorf("PierSelectionIndex PierID = %q", idx.PierID)
|
||||
}
|
||||
if idx.OverallScore <= 0 {
|
||||
t.Errorf("PierSelectionIndex OverallScore = %.4f, want > 0", idx.OverallScore)
|
||||
}
|
||||
if !hasEvent(ctx, "cover.pier_selected") {
|
||||
t.Error("cover.pier_selected event not emitted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelectPierGuildExistsCheck asserts a non-nil GuildKeeper shim with
|
||||
// exists=false REJECTS the selection (the Guild must exist).
|
||||
func TestSelectPierGuildExistsCheck(t *testing.T) {
|
||||
ctx, _, _, _, _, _, k := newSimtestContext(t)
|
||||
k.SetGuildKeeper(&stubGuildKeeper{guilds: map[string]bool{"guild-known": true}})
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
|
||||
// Unknown Guild -> REJECT.
|
||||
_, err := srv.SelectPier(ctx, &types.MsgSelectPier{
|
||||
GuildID: "guild-unknown", PierID: "pier-1", Signer: "reach:founder",
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("SelectPier on unknown Guild should be rejected when GuildKeeper is wired")
|
||||
}
|
||||
if _, ok := k.GetPierSelectionRecord(ctx, "guild-unknown"); ok {
|
||||
t.Error("PierSelectionRecord should NOT be persisted on reject")
|
||||
}
|
||||
|
||||
// Known Guild -> succeeds.
|
||||
_, err = srv.SelectPier(ctx, &types.MsgSelectPier{
|
||||
GuildID: "guild-known", PierID: "pier-1", Signer: "reach:founder",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SelectPier on known Guild with wired GuildKeeper: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelectPierIdempotentReject asserts a second SelectPier on the same
|
||||
// GuildID is REJECTED (a Guild selects exactly one Pier; use
|
||||
// RevokePierSelection first to re-select).
|
||||
func TestSelectPierIdempotentReject(t *testing.T) {
|
||||
ctx, _, _, _, _, _, k := newSimtestContext(t)
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
|
||||
first := &types.MsgSelectPier{GuildID: "guild-dup", PierID: "pier-1", Signer: "s"}
|
||||
if _, err := srv.SelectPier(ctx, first); err != nil {
|
||||
t.Fatalf("first SelectPier: %v", err)
|
||||
}
|
||||
_, err := srv.SelectPier(ctx, first)
|
||||
if err == nil {
|
||||
t.Error("second SelectPier on same GuildID should be rejected (one Pier per Guild)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelectPierValidateBasicErrors exercises each ValidateBasic error path.
|
||||
func TestSelectPierValidateBasicErrors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
msg types.MsgSelectPier
|
||||
}{
|
||||
{"empty guild-id", types.MsgSelectPier{PierID: "p", Signer: "s"}},
|
||||
{"empty pier-id", types.MsgSelectPier{GuildID: "g", Signer: "s"}},
|
||||
{"empty signer", types.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRevokePierSelectionSuccess (P5 case f) asserts a revocation with
|
||||
// RevocationApproved=true + a non-empty CounselWitness succeeds: the
|
||||
// PierSelectionRecord is removed + the event is emitted.
|
||||
func TestRevokePierSelectionSuccess(t *testing.T) {
|
||||
ctx, _, _, _, _, _, k := newSimtestContext(t)
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
|
||||
// First select a Pier.
|
||||
if _, err := srv.SelectPier(ctx, &types.MsgSelectPier{
|
||||
GuildID: "guild-rev", PierID: "pier-rev", Signer: "s",
|
||||
}); err != nil {
|
||||
t.Fatalf("SelectPier: %v", err)
|
||||
}
|
||||
// Then revoke it.
|
||||
_, err := srv.RevokePierSelection(ctx, &types.MsgRevokePierSelection{
|
||||
GuildID: "guild-rev",
|
||||
RevocationApproved: true,
|
||||
CounselWitness: "counsel-witness-1",
|
||||
Signer: "s",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RevokePierSelection: %v", err)
|
||||
}
|
||||
if _, ok := k.GetPierSelectionRecord(ctx, "guild-rev"); ok {
|
||||
t.Error("PierSelectionRecord should be removed after revoke")
|
||||
}
|
||||
if !hasEvent(ctx, "cover.pier_selection_revoked") {
|
||||
t.Error("cover.pier_selection_revoked event not emitted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRevokePierSelectionNotApprovedRejected (P5 case f) asserts a
|
||||
// revocation with RevocationApproved=false is REJECTED (the Cover Pool
|
||||
// supermajority gate).
|
||||
func TestRevokePierSelectionNotApprovedRejected(t *testing.T) {
|
||||
ctx, _, _, _, _, _, k := newSimtestContext(t)
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
|
||||
if _, err := srv.SelectPier(ctx, &types.MsgSelectPier{
|
||||
GuildID: "guild-rev-na", PierID: "pier-1", Signer: "s",
|
||||
}); err != nil {
|
||||
t.Fatalf("SelectPier: %v", err)
|
||||
}
|
||||
_, err := srv.RevokePierSelection(ctx, &types.MsgRevokePierSelection{
|
||||
GuildID: "guild-rev-na",
|
||||
RevocationApproved: false,
|
||||
CounselWitness: "counsel-witness-1",
|
||||
Signer: "s",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("RevokePierSelection with RevocationApproved=false should be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "RevocationApproved") {
|
||||
t.Errorf("error = %q, want 'RevocationApproved'", err.Error())
|
||||
}
|
||||
// The record is NOT removed.
|
||||
if _, ok := k.GetPierSelectionRecord(ctx, "guild-rev-na"); !ok {
|
||||
t.Error("PierSelectionRecord should NOT be removed on reject")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRevokePierSelectionNoWitnessRejected asserts a revocation with an
|
||||
// empty CounselWitness is REJECTED (the Counsel witness gate).
|
||||
func TestRevokePierSelectionNoWitnessRejected(t *testing.T) {
|
||||
ctx, _, _, _, _, _, k := newSimtestContext(t)
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
|
||||
if _, err := srv.SelectPier(ctx, &types.MsgSelectPier{
|
||||
GuildID: "guild-rev-nw", PierID: "pier-1", Signer: "s",
|
||||
}); err != nil {
|
||||
t.Fatalf("SelectPier: %v", err)
|
||||
}
|
||||
_, err := srv.RevokePierSelection(ctx, &types.MsgRevokePierSelection{
|
||||
GuildID: "guild-rev-nw",
|
||||
RevocationApproved: true,
|
||||
CounselWitness: "",
|
||||
Signer: "s",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("RevokePierSelection with empty CounselWitness should be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "CounselWitness") {
|
||||
t.Errorf("error = %q, want 'CounselWitness'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRevokePierSelectionNoRecordRejected asserts a revocation on a Guild
|
||||
// with no Pier selection is REJECTED (nothing to revoke).
|
||||
func TestRevokePierSelectionNoRecordRejected(t *testing.T) {
|
||||
ctx, _, _, _, _, _, k := newSimtestContext(t)
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
|
||||
_, err := srv.RevokePierSelection(ctx, &types.MsgRevokePierSelection{
|
||||
GuildID: "guild-no-selection",
|
||||
RevocationApproved: true,
|
||||
CounselWitness: "counsel-1",
|
||||
Signer: "s",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("RevokePierSelection on a Guild with no Pier selection should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRevokePierSelectionValidateBasicErrors exercises each ValidateBasic
|
||||
// error path.
|
||||
func TestRevokePierSelectionValidateBasicErrors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
msg types.MsgRevokePierSelection
|
||||
}{
|
||||
{"empty guild-id", types.MsgRevokePierSelection{RevocationApproved: true, CounselWitness: "c", Signer: "s"}},
|
||||
{"empty signer", types.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRevokePierSelectionMethods exercises the Msg + MsgResponse
|
||||
// Reset/String/ProtoMessage/GetSigners methods for coverage.
|
||||
func TestRevokePierSelectionMethods(t *testing.T) {
|
||||
m := &types.MsgRevokePierSelection{GuildID: "g", RevocationApproved: true, CounselWitness: "c", Signer: "s"}
|
||||
if !strings.Contains(m.String(), "g") {
|
||||
t.Errorf("MsgRevokePierSelection String = %q", m.String())
|
||||
}
|
||||
m.Reset()
|
||||
if m.GuildID != "" {
|
||||
t.Errorf("MsgRevokePierSelection Reset did not zero: %+v", m)
|
||||
}
|
||||
m.ProtoMessage()
|
||||
m2 := &types.MsgRevokePierSelection{Signer: "host-1"}
|
||||
if got := m2.GetSigners(); len(got) != 1 || string(got[0]) != "host-1" {
|
||||
t.Errorf("MsgRevokePierSelection GetSigners = %v", got)
|
||||
}
|
||||
r := &types.MsgRevokePierSelectionResponse{}
|
||||
r.Reset()
|
||||
if !strings.Contains(r.String(), "MsgRevokePierSelectionResponse") {
|
||||
t.Errorf("MsgRevokePierSelectionResponse String = %q", r.String())
|
||||
}
|
||||
r.ProtoMessage()
|
||||
}
|
||||
|
||||
// TestSelectPierMethods exercises the Msg + MsgResponse Reset/String/
|
||||
// ProtoMessage/GetSigners methods for coverage.
|
||||
func TestSelectPierMethods(t *testing.T) {
|
||||
m := &types.MsgSelectPier{GuildID: "g", PierID: "p", Signer: "s"}
|
||||
if !strings.Contains(m.String(), "g") {
|
||||
t.Errorf("MsgSelectPier String = %q", m.String())
|
||||
}
|
||||
m.Reset()
|
||||
if m.GuildID != "" {
|
||||
t.Errorf("MsgSelectPier Reset did not zero: %+v", m)
|
||||
}
|
||||
m.ProtoMessage()
|
||||
m2 := &types.MsgSelectPier{Signer: "host-1"}
|
||||
if got := m2.GetSigners(); len(got) != 1 || string(got[0]) != "host-1" {
|
||||
t.Errorf("MsgSelectPier GetSigners = %v", got)
|
||||
}
|
||||
r := &types.MsgSelectPierResponse{}
|
||||
r.Reset()
|
||||
if !strings.Contains(r.String(), "MsgSelectPierResponse") {
|
||||
t.Errorf("MsgSelectPierResponse String = %q", r.String())
|
||||
}
|
||||
r.ProtoMessage()
|
||||
}
|
||||
|
||||
// TestGetPierSelectionIndexQuery (P5 case g) asserts the keeper-level
|
||||
// GetPierSelectionIndex query returns the index after a SelectPier. Also
|
||||
// exercises the AllPierSelectionIndexes iteration helper + the
|
||||
// DefaultPierOverallScore computation.
|
||||
func TestGetPierSelectionIndexQuery(t *testing.T) {
|
||||
ctx, _, _, _, _, _, k := newSimtestContext(t)
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
|
||||
// No index for an unknown Pier.
|
||||
if _, ok := k.GetPierSelectionIndex(ctx, "pier-unknown"); ok {
|
||||
t.Error("GetPierSelectionIndex on unknown Pier should return false")
|
||||
}
|
||||
if got := k.AllPierSelectionIndexes(ctx); len(got) != 0 {
|
||||
t.Errorf("AllPierSelectionIndexes empty = %d, want 0", len(got))
|
||||
}
|
||||
|
||||
// Select a Pier -> index created.
|
||||
if _, err := srv.SelectPier(ctx, &types.MsgSelectPier{
|
||||
GuildID: "guild-idx", PierID: "pier-idx", Signer: "s",
|
||||
}); err != nil {
|
||||
t.Fatalf("SelectPier: %v", err)
|
||||
}
|
||||
idx, ok := k.GetPierSelectionIndex(ctx, "pier-idx")
|
||||
if !ok {
|
||||
t.Fatal("GetPierSelectionIndex after SelectPier should return true")
|
||||
}
|
||||
// The OverallScore is the deterministic blend of the default scores.
|
||||
wantOverall := keeper.DefaultPierOverallScore(
|
||||
keeper.DefaultPierJurisdictionalReliabilityScore,
|
||||
keeper.DefaultPierIntegrationQualityScore,
|
||||
nil, // no FiduciaryRecordHash on a fresh index
|
||||
)
|
||||
if idx.OverallScore != wantOverall {
|
||||
t.Errorf("OverallScore = %.4f, want %.4f", idx.OverallScore, wantOverall)
|
||||
}
|
||||
if got := k.AllPierSelectionIndexes(ctx); len(got) != 1 {
|
||||
t.Errorf("AllPierSelectionIndexes = %d, want 1", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestPierSelectionIndexCorruptBytes asserts GetPierSelectionIndex on
|
||||
// corrupt store bytes returns false (marshal-error coverage).
|
||||
func TestPierSelectionIndexCorruptBytes(t *testing.T) {
|
||||
ctx, _, _, _, _, _, k := newSimtestContext(t)
|
||||
rawStore := ctx.KVStore(k.StoreKey())
|
||||
rawStore.Set([]byte("pier_index/corrupt"), []byte("not-json"))
|
||||
if _, ok := k.GetPierSelectionIndex(ctx, "corrupt"); ok {
|
||||
t.Error("GetPierSelectionIndex on corrupt bytes should return false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPierSelectionRecordCorruptBytes asserts GetPierSelectionRecord on
|
||||
// corrupt store bytes returns false (marshal-error coverage).
|
||||
func TestPierSelectionRecordCorruptBytes(t *testing.T) {
|
||||
ctx, _, _, _, _, _, k := newSimtestContext(t)
|
||||
rawStore := ctx.KVStore(k.StoreKey())
|
||||
rawStore.Set([]byte("pier_selection/corrupt"), []byte("not-json"))
|
||||
if _, ok := k.GetPierSelectionRecord(ctx, "corrupt"); ok {
|
||||
t.Error("GetPierSelectionRecord on corrupt bytes should return false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillOfRightsReviewCorruptBytes asserts GetBillOfRightsReview on
|
||||
// corrupt store bytes returns false (marshal-error coverage).
|
||||
func TestBillOfRightsReviewCorruptBytes(t *testing.T) {
|
||||
ctx, _, _, _, _, _, k := newSimtestContext(t)
|
||||
rawStore := ctx.KVStore(k.StoreKey())
|
||||
rawStore.Set([]byte("bill_review/corrupt"), []byte("not-json"))
|
||||
if _, ok := k.GetBillOfRightsReview(ctx, "corrupt"); ok {
|
||||
t.Error("GetBillOfRightsReview on corrupt bytes should return false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllBillOfRightsReviewsAccessor exercises the
|
||||
// AllBillOfRightsReviews iteration helper for coverage.
|
||||
func TestAllBillOfRightsReviewsAccessor(t *testing.T) {
|
||||
ctx, _, _, _, _, _, k := newSimtestContext(t)
|
||||
if got := k.AllBillOfRightsReviews(ctx); len(got) != 0 {
|
||||
t.Errorf("AllBillOfRightsReviews empty = %d, want 0", len(got))
|
||||
}
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
for i := 0; i < 2; i++ {
|
||||
_, err := srv.CounselReviewBillOfRights(ctx, &types.MsgCounselReviewBillOfRights{
|
||||
ReviewID: fmt.Sprintf("review-acc-%d", i),
|
||||
CounselReachID: "counsel-1",
|
||||
Staked: true,
|
||||
ReviewResult: "Affirmed",
|
||||
Signer: "counsel-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CounselReviewBillOfRights[%d]: %v", i, err)
|
||||
}
|
||||
}
|
||||
if got := k.AllBillOfRightsReviews(ctx); len(got) != 2 {
|
||||
t.Errorf("AllBillOfRightsReviews = %d, want 2", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemovePierSelectionRecordNoOp asserts RemovePierSelectionRecord
|
||||
// returns false when no record exists (coverage on the no-op path).
|
||||
func TestRemovePierSelectionRecordNoOp(t *testing.T) {
|
||||
ctx, _, _, _, _, _, k := newSimtestContext(t)
|
||||
if k.RemovePierSelectionRecord(ctx, "guild-noop") {
|
||||
t.Error("RemovePierSelectionRecord on non-existent record should return false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetGuildKeeperAccessor exercises the SetGuildKeeper post-construction
|
||||
// wiring accessor for coverage.
|
||||
func TestSetGuildKeeperAccessor(t *testing.T) {
|
||||
_, _, _, _, _, _, k := newSimtestContext(t)
|
||||
gk := &stubGuildKeeper{guilds: map[string]bool{"g": true}}
|
||||
k.SetGuildKeeper(gk)
|
||||
// No direct accessor on Keeper for the guildKeeper field (it is an
|
||||
// internal wiring field); the SelectPier handler exercises it. This
|
||||
// test just covers the SetGuildKeeper method call.
|
||||
}
|
||||
|
||||
// TestPierSelectionIndexStruct asserts the PierSelectionIndex struct
|
||||
// carries the required fields (REQ-066) — a runtime regression firewall.
|
||||
func TestPierSelectionIndexStruct(t *testing.T) {
|
||||
idx := types.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 required fields (REQ-066) — a runtime regression firewall.
|
||||
func TestPierSelectionRecordStruct(t *testing.T) {
|
||||
rec := types.PierSelectionRecord{
|
||||
GuildID: "guild-1",
|
||||
PierID: "pier-1",
|
||||
SelectedAt: 12345,
|
||||
SelectedBy: "reach:founder",
|
||||
}
|
||||
if rec.GuildID != "guild-1" || rec.PierID != "pier-1" ||
|
||||
rec.SelectedAt != 12345 || rec.SelectedBy != "reach:founder" {
|
||||
t.Errorf("PierSelectionRecord = %+v", rec)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,3 +177,23 @@ type StillKeeper interface {
|
||||
// not be recorded — the routing is not committed).
|
||||
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()
|
||||
}
|
||||
@@ -250,6 +250,11 @@ type MsgServer interface {
|
||||
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
|
||||
|
||||
@@ -595,3 +595,57 @@ const (
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -500,3 +500,58 @@ func TestMABRefStruct(t *testing.T) {
|
||||
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).
|
||||
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
|
||||
}
|
||||
|
||||
// --- 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
|
||||
|
||||
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)}
|
||||
}
|
||||
|
||||
// --- 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 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)
|
||||
DelegateConfederationVoice(ctx interface{}, msg *MsgDelegateConfederationVoice) (*MsgDelegateConfederationVoiceResponse, 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 -----------------------------------------------------------
|
||||
@@ -419,3 +633,80 @@ func (m *MsgAddLienResponse) String() string { return "MsgAddLienResponse{}" }
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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
|
||||
// types_test.go.
|
||||
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
|
||||
@@ -56,6 +75,14 @@ const (
|
||||
// with SecuredAtFounding=true; post-founding liens are SecuredAtFounding=false
|
||||
// (the AddLien handler rejects any new SecuredAtFounding=true lien — founding
|
||||
// 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 {
|
||||
GuildID string `json:"guild_id" yaml:"guild_id"`
|
||||
Name string `json:"name" yaml:"name"`
|
||||
@@ -68,6 +95,8 @@ type Guild struct {
|
||||
IsChapter bool `json:"is_chapter,omitempty" yaml:"is_chapter,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"`
|
||||
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
|
||||
@@ -92,12 +121,17 @@ type GuildPublicProfile struct {
|
||||
// Guild/Chapter creation; NOT freely increasable post-founding — the AddLien
|
||||
// handler rejects any new SecuredAtFounding=true lien). CoverPoolCovenantRef
|
||||
// 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 {
|
||||
Amount int64 `json:"amount" yaml:"amount"`
|
||||
CreditorReachID string `json:"creditor_reach_id" yaml:"creditor_reach_id"`
|
||||
SecuredAtFounding bool `json:"secured_at_founding" yaml:"secured_at_founding"`
|
||||
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).
|
||||
|
||||
@@ -579,3 +579,59 @@ func packageDir(t *testing.T, importPath string) string {
|
||||
rel := strings.TrimPrefix(importPath, "github.com/oy/openyield/")
|
||||
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
|
||||
// const's test.
|
||||
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).
|
||||
|
||||
@@ -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.
|
||||
func TestDefaultParams(t *testing.T) {
|
||||
_ = types.DefaultParams() // no panics
|
||||
|
||||
Reference in New Issue
Block a user