Compare commits

..

6 Commits

Author SHA1 Message Date
cloudinit-bot 53ad56e3d2 test(bond,cover,standing): P4 verify — GREEN
docs-build / go test ./... (lexicon firewall + all x/* tests) (push) Has been cancelled
docs-build / mkdocs build (docs site artifact) (push) Has been cancelled
---ci---
project: oy
phase: 4
milestone: v0.7
status: verify
---/ci---
2026-08-19 02:31:53 +00:00
cloudinit-bot 9e7fc403f5 feat(bond,cover,standing): P4 MAB + Cover Claims Voucher + Shadow vouch
v0.7 P4 (REQ-054, REQ-055, REQ-060, REQ-063, D-080, D-089, D-090):

x/bond (Mutual Aid Bond): MAB struct (Bond anonymous embed) mirroring
GrowthBond; CouponDenom enum (CoverCall/MutualAidCredit/Bread-rejected);
MABIssuanceCeilingAnnualSurplusMultiple=3 locked const; ValidateMAB
rejects CouponDenomBread (FR-MAB-3 dual firewall); D-080 tagged streaming
(reserve_build_out); 4 handlers (IssueMAB with 3x ceiling check,
DebitMABProceeds with auto-Still on misuse, WitnessMABProceedsRelease
with Watcher quorum, WatcherAttestMAB); CoverKeeper reverse edge (D-089).

x/cover (Cover Claims Voucher + dissolution): CoverClaimsVoucher struct;
D-090(2) cold-start bond = max(10x avgCallSize, MinimumVoucherBond); 4
handlers (RegisterCoverClaimsVoucher, AdjudicateCoverCall with FR-CPCV-2
no self-adjudication, SlashCoverClaimsVoucher with cross-Pool bucket
drop, DissolveCoverPool with FR-MAB-4 waterfall Cover-Fee > MAB > Bread);
MAB holders have NO Voice (REQ-063).

x/standing (Shadow vouch): Vouch.IsShadow field; ShadowVouchWeightMultiplier
=0.5 locked const (REQ-060); GetVoucherWeight extended with isShadow param
(post-step 0.5x multiplier; all call sites updated); SlashReasonFraudulent
CoverCall const (REQ-055).

Coverage: bond/keeper 92.1%, cover/keeper 95.0%, standing/types 90.3%.
G-006/G-028 intact. go.mod/go.sum diff EMPTY. go vet clean. Lexicon green.

---ci---
project: oy
phase: 4
milestone: v0.7
status: execute
---/ci---
2026-08-19 02:31:52 +00:00
cloudinit-bot bcae60666b Merge phase/03 into milestone/v0.7 (P3 complete → v0.6.3)
docs-build / go test ./... (lexicon firewall + all x/* tests) (push) Has been cancelled
docs-build / mkdocs build (docs site artifact) (push) Has been cancelled
2026-08-19 02:16:46 +00:00
cloudinit-bot fff74b2de1 test(guild): P3 verify — GREEN (vet+race+coverage+lexicon+G-003+G-028)
docs-build / go test ./... (lexicon firewall + all x/* tests) (push) Has been cancelled
docs-build / mkdocs build (docs site artifact) (push) Has been cancelled
---ci---
project: oy
phase: 3
milestone: v0.7
status: verify
---/ci---
2026-08-19 02:16:46 +00:00
cloudinit-bot b6d7b1a9ec feat(guild): P3 Guild Charter + Chapter Federation + Household/Confederation
Extends x/guild with P3 (v0.7) Guild Charter + Chapter Federation runtime
+ Household one-tap exit + Confederation Voice delegation + D-087
PierCarriesVoice locked const + REQ-064 cooling consts.

- x/guild/types: extend Guild (CommonBondHash, PublicProfile,
  ParentGuildID, IsChapter, SecessionTermsHash, GoodStandingLiens); add
  GuildPublicProfile, Lien, SecessionTerms, ConfederationVoice structs;
  add PierCarriesVoice=false (D-087), CoolingSecessionCoverActiveDays=21,
  CoolingSecessionNonCoverDays=14 consts; extend Params + GenesisState
  (Chapters slice + Chapter->ParentGuildID ref check).
- x/guild/types/msg_guild.go: 5 Msg* (CreateGuild, CreateChapter,
  OneTapExitStand, DelegateConfederationVoice, AddLien) + MsgServer
  interface + Response types (Disclaimer surfaced per REQ-061).
- x/guild/types/expected_keepers.go: StandKeeper + StashKeeper G-003 shims.
- x/guild/keeper: NEW store-backed Keeper (guild/lien/delegation stores)
  + MsgServer handlers + simtest (8 cases).
- x/guild/module.go: AppModule (D-054 simtest-grade).
- x/stand/types: IsHousehold + IsConfederation helpers + ConfederationVoice
  type-level scaffold (REQ-057/REQ-058).

Coverage: x/guild 85.7%, keeper 94.3%, types 97.1%.
G-006/G-028 intact. go.mod/go.sum diff EMPTY. go vet clean.

REQs: REQ-051, REQ-053, REQ-057, REQ-058, REQ-061

---ci---
project: oy
phase: 3
milestone: v0.7
status: execute
---/ci---
2026-08-19 02:16:42 +00:00
cloudinit-bot 4eec2ff502 Merge phase/02 into milestone/v0.7 (P2 complete → v0.6.2)
docs-build / go test ./... (lexicon firewall + all x/* tests) (push) Has been cancelled
docs-build / mkdocs build (docs site artifact) (push) Has been cancelled
2026-08-19 02:08:43 +00:00
32 changed files with 6507 additions and 56 deletions
+1 -1
View File
@@ -351,7 +351,7 @@ func (s *Store) ComputeStandingScore(reachID string) (float64, standingtypes.Sta
sum := 0.0
categories := map[string]bool{}
for _, r := range ratings {
w := standingtypes.GetVoucherWeight(false, r.Score, len(ratings))
w := standingtypes.GetVoucherWeight(false, r.Score, len(ratings), false)
sum += r.Score * w
categories[r.Category] = true
}
+155 -5
View File
@@ -42,17 +42,23 @@ import (
// Keeper is the store-backed bond market keeper.
type Keeper struct {
cdc codec.Codec
storeKey storetypes.StoreKey
standKeeper types.StandKeeper
seq uint64 // monotonic sequence for price-time priority (CLOB)
cdc codec.Codec
storeKey storetypes.StoreKey
standKeeper types.StandKeeper
coverKeeper types.CoverKeeper
watcherKeeper types.WatcherKeeper
stillKeeper types.StillKeeper
seq uint64 // monotonic sequence for price-time priority (CLOB)
}
// NewKeeper constructs a new store-backed bond Keeper. The StandKeeper
// expected-keeper shim is injected (nil-able for partial tests; the
// IssueBond / IssueGrowthBond handlers guard a nil shim and skip the
// StandExists check, still mutating state — the simtest wiring documents
// this).
// this). The v0.7 P4 MAB shims (CoverKeeper, WatcherKeeper, StillKeeper)
// are wired via the Set* methods (post-construction wiring for app wiring
// or test setup); the MAB handlers guard nil shims per the documented
// contract.
func NewKeeper(cdc codec.Codec, storeKey storetypes.StoreKey, sk types.StandKeeper) Keeper {
return Keeper{
cdc: cdc,
@@ -65,6 +71,19 @@ func NewKeeper(cdc codec.Codec, storeKey storetypes.StoreKey, sk types.StandKeep
// construction wiring, e.g., app wiring or test setup).
func (k *Keeper) SetStandKeeper(sk types.StandKeeper) { k.standKeeper = sk }
// SetCoverKeeper sets the CoverKeeper expected-keeper shim (D-089(2) reverse
// edge — for post-construction wiring, e.g., app wiring or test setup).
func (k *Keeper) SetCoverKeeper(ck types.CoverKeeper) { k.coverKeeper = ck }
// SetWatcherKeeper sets the WatcherKeeper expected-keeper shim (for the MAB
// proceeds-release quorum check — post-construction wiring).
func (k *Keeper) SetWatcherKeeper(wk types.WatcherKeeper) { k.watcherKeeper = wk }
// SetStillKeeper sets the StillKeeper expected-keeper shim (D-089(1) — for
// the MAB misuse auto-Still on a destination mismatch; post-construction
// wiring).
func (k *Keeper) SetStillKeeper(stK types.StillKeeper) { k.stillKeeper = stK }
// StoreKey returns the keeper's store key (exported for simtest access to
// the raw KVStore for corrupt-byte injection in marshal-error coverage
// paths).
@@ -177,6 +196,137 @@ func (k Keeper) AllGrowthBonds(ctx sdk.Context) []types.GrowthBond {
return out
}
// --- MAB store (v0.7 P4 — REQ-054, D-080, D-089(2)) ---------------------------
//
// The MAB store is keyed by bond-id -> MAB. A separate mab-pool index
// (bond-id -> pool-id) records the pool each MAB was issued for, so the
// 3× annual surplus ceiling check can sum the MAB principals for a pool,
// and the MsgDebitMABProceeds handler can query the CoverKeeper for the
// pool's ReserveAccount. The mab-attest store records the quarterly Watcher
// attestations (mab_attest/<bondID>/<timestamp> -> attestationRef).
var mabKeyPrefix = []byte("mab/")
func mabKey(bondID string) []byte {
return append(mabKeyPrefix, []byte(bondID)...)
}
// GetMAB loads an issued MAB by bond-id. Returns the MAB and true if found,
// or zero value + false if not.
func (k Keeper) GetMAB(ctx sdk.Context, bondID string) (types.MAB, bool) {
store := ctx.KVStore(k.storeKey)
bz := store.Get(mabKey(bondID))
if bz == nil {
return types.MAB{}, false
}
var m types.MAB
if err := json.Unmarshal(bz, &m); err != nil {
return types.MAB{}, false
}
return m, true
}
// SetMAB persists an issued MAB by bond-id.
func (k Keeper) SetMAB(ctx sdk.Context, m types.MAB) {
store := ctx.KVStore(k.storeKey)
bz, err := json.Marshal(m)
if err != nil {
panic(fmt.Sprintf("bond: marshal mab %q: %v", m.BondID, err))
}
store.Set(mabKey(m.BondID), bz)
}
// AllMABs returns all issued MABs (iteration helper, unordered).
func (k Keeper) AllMABs(ctx sdk.Context) []types.MAB {
store := ctx.KVStore(k.storeKey)
iterator := store.Iterator(mabKeyPrefix, prefixEnd(mabKeyPrefix))
defer iterator.Close()
out := []types.MAB{}
for ; iterator.Valid(); iterator.Next() {
var m types.MAB
if err := json.Unmarshal(iterator.Value(), &m); err == nil {
out = append(out, m)
}
}
return out
}
// --- MAB pool index (bond-id -> pool-id) --------------------------------------
var mabPoolKeyPrefix = []byte("mab-pool/")
func mabPoolKey(bondID string) []byte {
return append(mabPoolKeyPrefix, []byte(bondID)...)
}
// setMABPool records the pool-id a MAB was issued for (bond-id -> pool-id).
func (k Keeper) setMABPool(ctx sdk.Context, bondID, poolID string) {
store := ctx.KVStore(k.storeKey)
store.Set(mabPoolKey(bondID), []byte(poolID))
}
// GetMABPool returns the pool-id a MAB was issued for (bond-id -> pool-id).
// Returns the pool-id and true if found, or "" + false if not.
func (k Keeper) GetMABPool(ctx sdk.Context, bondID string) (string, bool) {
store := ctx.KVStore(k.storeKey)
bz := store.Get(mabPoolKey(bondID))
if bz == nil {
return "", false
}
return string(bz), true
}
// MABsForPool returns all MABs issued for the given pool-id (the 3× annual
// surplus ceiling check sums their principals). Iterates the mab-pool index
// + loads each MAB by bond-id.
func (k Keeper) MABsForPool(ctx sdk.Context, poolID string) []types.MAB {
store := ctx.KVStore(k.storeKey)
iterator := store.Iterator(mabPoolKeyPrefix, prefixEnd(mabPoolKeyPrefix))
defer iterator.Close()
out := []types.MAB{}
for ; iterator.Valid(); iterator.Next() {
if string(iterator.Value()) != poolID {
continue
}
// The key is mab-pool/<bondID>; extract the bondID (strip the
// prefix) and load the MAB.
bondID := string(iterator.Key()[len(mabPoolKeyPrefix):])
if m, ok := k.GetMAB(ctx, bondID); ok {
out = append(out, m)
}
}
return out
}
// --- MAB attestation store (mab_attest/<bondID>/<timestamp> -> ref) -----------
var mabAttestKeyPrefix = []byte("mab_attest/")
func mabAttestKey(bondID string, ts int64) []byte {
return append(append(mabAttestKeyPrefix, []byte(bondID)...), []byte(fmt.Sprintf("/%d", ts))...)
}
// SetMABAttest records a quarterly Watcher attestation on a MAB (bond-id +
// timestamp -> attestation-ref).
func (k Keeper) SetMABAttest(ctx sdk.Context, bondID string, ts int64, attestationRef string) {
store := ctx.KVStore(k.storeKey)
store.Set(mabAttestKey(bondID, ts), []byte(attestationRef))
}
// AllMABAttests returns all recorded Watcher attestations for a MAB
// (bond-id -> []attestationRef, unordered).
func (k Keeper) AllMABAttests(ctx sdk.Context, bondID string) []string {
store := ctx.KVStore(k.storeKey)
prefix := append(mabAttestKeyPrefix, []byte(bondID+"/")...)
iterator := store.Iterator(prefix, prefixEnd(prefix))
defer iterator.Close()
out := []string{}
for ; iterator.Valid(); iterator.Next() {
out = append(out, string(iterator.Value()))
}
return out
}
// --- Order store (CLOB resting book) -----------------------------------------
//
// The resting book is keyed by order-id → restingOrder (the in-keeper book
+238
View File
@@ -426,3 +426,241 @@ func (s msgServer) MatchSecondaryOrder(ctx interface{}, msg *types.MsgMatchSecon
Rejected: false,
}, nil
}
// --- v0.7 P4: MAB handlers (REQ-054, D-080, D-089(1), D-089(2)) ----------------
//
// (Mutual Aid Bond runtime — IssueMAB + DebitMABProceeds +
// WitnessMABProceedsRelease + WatcherAttestMAB). The four handlers exercise
// the 3× annual surplus ceiling, the FR-MAB-3 Bread-coupon rejection, the
// D-080 tagged-streaming destination check (CoverKeeper reverse edge —
// D-089(2)), the D-089(1) auto-Still on misuse, and the Watcher quorum
// (6-of-9) on proceeds release.
// checkMABIssuanceCeiling asserts the 3× annual surplus ceiling (REQ-054
// locked). It sums the existing MAB principals for the poolID + the new
// principal and asserts the sum <= MABIssuanceCeilingAnnualSurplusMultiple ×
// annualSurplusAtIssuance. Returns the post-issuance
// (sumMABPrincipal / annualSurplusAtIssuance) ratio (for the response) and
// an error if above ceiling. The check re-runs at every issuance (not just
// the first), so a pool that issues up to the ceiling cannot issue more.
func (s msgServer) checkMABIssuanceCeiling(ctx sdk.Context, poolID string, newPrincipal int64, annualSurplusAtIssuance int64) (int64, error) {
existing := int64(0)
for _, m := range s.Keeper.MABsForPool(ctx, poolID) {
existing += m.PrincipalGrain
}
total := existing + newPrincipal
ceiling := int64(types.MABIssuanceCeilingAnnualSurplusMultiple) * annualSurplusAtIssuance
if total > ceiling {
return 0, fmt.Errorf("bond: MAB issuance ceiling breached (sum %d + new %d = %d > 3× annual-surplus %d = %d — REQ-054 locked)",
existing, newPrincipal, total, annualSurplusAtIssuance, ceiling)
}
if annualSurplusAtIssuance == 0 {
return 0, nil
}
return total / annualSurplusAtIssuance, nil
}
// IssueMAB issues a Mutual Aid Bond (REQ-054, D-080). The handler enforces:
// 1. ValidateBasic (stateless — includes ValidateMAB: rejects
// CouponDenomBread with FR-MAB-3).
// 2. Idempotency: bond-id must not already exist (as a Bond, GrowthBond, or
// MAB).
// 3. StandKeeper shim: the issuer-stand-id must reference an existing Stand
// (P1-02-01 edge). A nil shim skips (simtest wiring).
// 4. FR-MAB-3 defense-in-depth: ValidateMAB re-check (rejects
// CouponDenomBread — the handler re-checks in case of a future
// ValidateBasic bypass).
// 5. 3× annual surplus ceiling: checkMABIssuanceCeiling asserts
// sum(existingMABPrincipal for poolID) + PrincipalGrain <=
// MABIssuanceCeilingAnnualSurplusMultiple × AnnualSurplusAtIssuance.
// REJECT if above ceiling.
// 6. Coupon clamp via Clamp (A-563 — defense in depth).
// 7. Persist the MAB with UseOfProceedsTag = MABUseOfProceedsReserveBuildOut
// + record the pool-id in the mab-pool index. Emit bond.mab_issued.
func (s msgServer) IssueMAB(ctx interface{}, msg *types.MsgIssueMAB) (*types.MsgIssueMABResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
// Idempotency: bond-id must not already exist (as Bond, GrowthBond, or MAB).
if _, ok := s.Keeper.GetBond(sdkCtx, msg.BondID); ok {
return nil, fmt.Errorf("bond: bond-id %q already exists (as a Bond)", msg.BondID)
}
if _, ok := s.Keeper.GetGrowthBond(sdkCtx, msg.BondID); ok {
return nil, fmt.Errorf("bond: bond-id %q already exists (as a GrowthBond)", msg.BondID)
}
if _, ok := s.Keeper.GetMAB(sdkCtx, msg.BondID); ok {
return nil, fmt.Errorf("bond: bond-id %q already exists (as a MAB)", msg.BondID)
}
// StandKeeper: issuer-stand-id must reference an existing Stand.
if s.Keeper.standKeeper != nil {
if !s.Keeper.standKeeper.StandExists(msg.IssuerStandID) {
return nil, fmt.Errorf("bond: issuer-stand-id %q does not exist (IssueMAB rejected)", msg.IssuerStandID)
}
}
// FR-MAB-3 defense-in-depth: re-run ValidateMAB (the handler re-checks
// in case of a future ValidateBasic bypass).
if err := types.ValidateMAB(types.MAB{CouponKind: msg.CouponKind}); err != nil {
return nil, err
}
// 3× annual surplus ceiling (REQ-054 locked).
ceilingMultiple, err := s.checkMABIssuanceCeiling(sdkCtx, msg.PoolID, msg.PrincipalGrain, msg.AnnualSurplusAtIssuance)
if err != nil {
return nil, err
}
// Coupon clamp (A-563 — defense in depth; ValidateBasic already
// rejected out-of-band, so Clamp is a no-op here).
clamped := types.Clamp(msg.CouponBps)
m := types.IssueMAB(msg.BondID, msg.IssuerStandID, msg.PrincipalGrain, clamped, msg.CouponKind, msg.AnnualSurplusAtIssuance, msg.TermDays, sdkCtx.BlockTime().Unix(), sdkCtx.BlockTime().Unix()+int64(msg.TermDays)*24*60*60)
s.Keeper.SetMAB(sdkCtx, m)
s.Keeper.setMABPool(sdkCtx, msg.BondID, msg.PoolID)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"bond.mab_issued",
sdk.NewAttribute("bond_id", msg.BondID),
sdk.NewAttribute("pool_id", msg.PoolID),
sdk.NewAttribute("issuer_stand_id", msg.IssuerStandID),
sdk.NewAttribute("coupon_bps", fmt.Sprintf("%d", clamped)),
sdk.NewAttribute("coupon_kind", string(msg.CouponKind)),
sdk.NewAttribute("use_of_proceeds_tag", m.UseOfProceedsTag),
sdk.NewAttribute("ceiling_multiple", fmt.Sprintf("%d", ceilingMultiple)),
))
return &types.MsgIssueMABResponse{
ClampedCouponBps: clamped,
CeilingMultiple: ceilingMultiple,
}, nil
}
// DebitMABProceeds debits a MAB's tagged proceeds to the Pool's
// ReserveAccount (D-080). The handler enforces:
// 1. ValidateBasic (stateless).
// 2. The MAB must exist.
// 3. D-080 tagged streaming: query the mab-pool index for the MAB's poolID,
// then query CoverKeeper.GetPoolReserveAccount(poolID). If the
// DestinationAccount != the pool's ReserveAccount -> StillKeeper.Still(
// bondID, "MAB misuse — proceeds routed outside reserve") (D-089(1) — a
// nil StillKeeper skips the Still recording but the handler STILL
// REJECTS) AND REJECT. A nil CoverKeeper is a wiring error -> REJECT
// (the destination cannot be validated). If match -> emit
// bond.mab_proceeds_debited (simtest: the debit is the event; no actual
// Grain transfer in P4).
func (s msgServer) DebitMABProceeds(ctx interface{}, msg *types.MsgDebitMABProceeds) (*types.MsgDebitMABProceedsResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
m, ok := s.Keeper.GetMAB(sdkCtx, msg.BondID)
if !ok {
return nil, fmt.Errorf("bond: mab %q not found (DebitMABProceeds rejected)", msg.BondID)
}
_ = m
poolID, ok := s.Keeper.GetMABPool(sdkCtx, msg.BondID)
if !ok {
return nil, fmt.Errorf("bond: mab %q has no pool binding (DebitMABProceeds rejected)", msg.BondID)
}
// D-080 tagged streaming: the destination must == the pool's
// ReserveAccount. A nil CoverKeeper is a wiring error -> REJECT (the
// destination cannot be validated).
if s.Keeper.coverKeeper == nil {
return nil, fmt.Errorf("bond: CoverKeeper shim not wired (DebitMABProceeds cannot validate destination — D-089(2) reverse edge required)")
}
reserveAccount, exists := s.Keeper.coverKeeper.GetPoolReserveAccount(poolID)
if !exists {
return nil, fmt.Errorf("bond: pool %q ReserveAccount not found (DebitMABProceeds rejected)", poolID)
}
if msg.DestinationAccount != reserveAccount {
// D-080 misuse -> D-089(1) auto-Still. A nil StillKeeper skips the
// Still recording but the handler STILL REJECTS (the debit is not
// committed regardless).
if s.Keeper.stillKeeper != nil {
_ = s.Keeper.stillKeeper.Still(msg.BondID, "MAB misuse — proceeds routed outside reserve")
}
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"bond.mab_proceeds_misuse",
sdk.NewAttribute("bond_id", msg.BondID),
sdk.NewAttribute("pool_id", poolID),
sdk.NewAttribute("destination_account", msg.DestinationAccount),
sdk.NewAttribute("expected_reserve_account", reserveAccount),
))
return nil, fmt.Errorf("bond: MAB %q proceeds destination %q != pool %q ReserveAccount %q (D-080 tagged-streaming misuse — auto-Still + REJECT)", msg.BondID, msg.DestinationAccount, poolID, reserveAccount)
}
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"bond.mab_proceeds_debited",
sdk.NewAttribute("bond_id", msg.BondID),
sdk.NewAttribute("pool_id", poolID),
sdk.NewAttribute("destination_account", msg.DestinationAccount),
))
return &types.MsgDebitMABProceedsResponse{}, nil
}
// WitnessMABProceedsRelease is a Watcher-witnessed release of a MAB's tagged
// proceeds from staging to the reserve (D-080). The handler enforces:
// 1. ValidateBasic (stateless).
// 2. The MAB must exist.
// 3. Watcher quorum: WatcherKeeper.AttestMABRelease(bondID, attestationRef)
// returns true if quorum (6-of-9) is met. If false (quorum not met) ->
// REJECT. If true -> emit bond.mab_proceeds_released. A nil WatcherKeeper
// skips the quorum check (simtest wiring — the handler still mutates
// state; the simtest documents the wiring).
func (s msgServer) WitnessMABProceedsRelease(ctx interface{}, msg *types.MsgWitnessMABProceedsRelease) (*types.MsgWitnessMABProceedsReleaseResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
if _, ok := s.Keeper.GetMAB(sdkCtx, msg.BondID); !ok {
return nil, fmt.Errorf("bond: mab %q not found (WitnessMABProceedsRelease rejected)", msg.BondID)
}
// Watcher quorum (D-080). A nil WatcherKeeper skips the quorum check
// (simtest wiring — the handler still mutates state).
if s.Keeper.watcherKeeper != nil {
if !s.Keeper.watcherKeeper.AttestMABRelease(msg.BondID, msg.AttestationRef) {
return nil, fmt.Errorf("bond: MAB %q proceeds release rejected (Watcher quorum not met — D-080 6-of-9 required)", msg.BondID)
}
}
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"bond.mab_proceeds_released",
sdk.NewAttribute("bond_id", msg.BondID),
sdk.NewAttribute("attestation_ref", msg.AttestationRef),
))
return &types.MsgWitnessMABProceedsReleaseResponse{}, nil
}
// WatcherAttestMAB records a quarterly Watcher audit attestation on a MAB
// (D-080). The handler enforces:
// 1. ValidateBasic (stateless).
// 2. The MAB must exist.
// 3. Record the attestation (a store entry mab_attest/<bondID>/<timestamp>
// -> attestationRef). Emit bond.mab_watcher_attested.
func (s msgServer) WatcherAttestMAB(ctx interface{}, msg *types.MsgWatcherAttestMAB) (*types.MsgWatcherAttestMABResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
if _, ok := s.Keeper.GetMAB(sdkCtx, msg.BondID); !ok {
return nil, fmt.Errorf("bond: mab %q not found (WatcherAttestMAB rejected)", msg.BondID)
}
ts := sdkCtx.BlockTime().Unix()
s.Keeper.SetMABAttest(sdkCtx, msg.BondID, ts, msg.AttestationRef)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"bond.mab_watcher_attested",
sdk.NewAttribute("bond_id", msg.BondID),
sdk.NewAttribute("attestation_ref", msg.AttestationRef),
sdk.NewAttribute("timestamp", fmt.Sprintf("%d", ts)),
))
return &types.MsgWatcherAttestMABResponse{}, nil
}
+541
View File
@@ -94,6 +94,51 @@ func (s *stubStandKeeper) StandExists(standID string) bool {
return s.existsAll
}
// stubCoverKeeper satisfies btypes.CoverKeeper for the v0.7 P4 MAB simtest
// (D-089(2) reverse edge). It returns the configured ReserveAccount per
// pool-id.
type stubCoverKeeper struct {
reserveAccounts map[string]string
}
func (s *stubCoverKeeper) GetPoolReserveAccount(poolID string) (string, bool) {
if s.reserveAccounts == nil {
return "", false
}
acc, ok := s.reserveAccounts[poolID]
return acc, ok
}
// stubWatcherKeeperBond satisfies btypes.WatcherKeeper for the v0.7 P4 MAB
// simtest. It returns a configurable quorum-met bool per
// AttestMABRelease call.
type stubWatcherKeeperBond struct {
quorumMet bool
}
func (s *stubWatcherKeeperBond) AttestMABRelease(bondID string, attestationRef string) bool {
return s.quorumMet
}
// stubStillKeeperBond satisfies btypes.StillKeeper for the v0.7 P4 MAB
// simtest (D-089(1)). It records every Still() call for assertion (the
// tagged-streaming misuse simtest asserts Still was called with the right
// bond-id + reason).
type stubStillKeeperBond struct {
calls []struct {
bondID string
reason string
}
}
func (s *stubStillKeeperBond) Still(bondID string, reason string) error {
s.calls = append(s.calls, struct {
bondID string
reason string
}{bondID, reason})
return nil
}
// --- Simtest context helper --------------------------------------------------
// newSimtestContext constructs an in-memory sdk.Context with a KVStore
@@ -165,6 +210,32 @@ func freshCtx(t *testing.T) (sdk.Context, *stubStandKeeper, keeper.Keeper) {
return newSimtestContext(t)
}
// newMABSimtestContext constructs an in-memory sdk.Context with the MAB
// shims (CoverKeeper + WatcherKeeper + StillKeeper) wired for the v0.7 P4
// MAB simtest (D-089(1) + D-089(2)). Returns the ctx, the four stubs, and
// the Keeper.
func newMABSimtestContext(t *testing.T) (sdk.Context, *stubStandKeeper, *stubCoverKeeper, *stubWatcherKeeperBond, *stubStillKeeperBond, keeper.Keeper) {
t.Helper()
db := dbm.NewMemDB()
cdc := newTestCodec()
storeKey := storetypes.NewKVStoreKey(btypes.StoreKey)
cms := store.NewCommitMultiStore(db, log.NewNopLogger(), nil)
cms.MountStoreWithDB(storeKey, storetypes.StoreTypeDB, nil)
if err := cms.LoadLatestVersion(); err != nil {
t.Fatalf("load latest version: %v", err)
}
ctx := sdk.NewContext(cms, cmtproto.Header{Time: time.Unix(1000, 0)}, false, log.NewNopLogger())
sk := &stubStandKeeper{existsAll: true}
ck := &stubCoverKeeper{reserveAccounts: map[string]string{"pool-1": "reserve-acc-1"}}
wk := &stubWatcherKeeperBond{quorumMet: true}
stK := &stubStillKeeperBond{}
k := keeper.NewKeeper(cdc, storeKey, sk)
k.SetCoverKeeper(ck)
k.SetWatcherKeeper(wk)
k.SetStillKeeper(stK)
return ctx, sk, ck, wk, stK, k
}
// --- Bond issuance (coupon clamp at issuance) --------------------------------
// TestIssueBondInBand asserts an in-band coupon (500) is recorded unchanged
@@ -1292,3 +1363,473 @@ func TestMatchAboveCapRejectStopsMatching(t *testing.T) {
t.Errorf("sell-inband RemainingQuantityGrain = %d, want 50 (untouched)", ro.RemainingQuantityGrain)
}
}
// --- v0.7 P4: MAB simtest (REQ-054, D-080, D-089(1), D-089(2)) ----------------
//
// (Mutual Aid Bond runtime — issuance + Bread-coupon rejection + 3× annual
// surplus ceiling + tagged-streaming misuse -> auto-Still + Watcher-witnessed
// release + quarterly attestation).
// TestMABIssuanceValidCoverCallCoupons (case a) asserts a MAB issuance with
// valid Cover-Call coupons (CouponDenomCoverCall) succeeds + the
// bond.mab_issued event is emitted + the UseOfProceedsTag is locked to
// "reserve_build_out".
func TestMABIssuanceValidCoverCallCoupons(t *testing.T) {
ctx, _, _, _, _, k := newMABSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
resp, err := srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
BondID: "mab-1", PoolID: "pool-1", IssuerStandID: "stand-1",
PrincipalGrain: 1_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomCoverCall,
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
})
if err != nil {
t.Fatalf("IssueMAB: %v", err)
}
if resp.ClampedCouponBps != 500 {
t.Errorf("ClampedCouponBps = %d, want 500", resp.ClampedCouponBps)
}
if !hasEvent(ctx, "bond.mab_issued") {
t.Error("bond.mab_issued event not emitted")
}
// Read it back.
m, ok := k.GetMAB(ctx, "mab-1")
if !ok {
t.Fatal("MAB not persisted")
}
if m.CouponKind != btypes.CouponDenomCoverCall {
t.Errorf("CouponKind = %q, want CoverCall", m.CouponKind)
}
if m.UseOfProceedsTag != btypes.MABUseOfProceedsReserveBuildOut {
t.Errorf("UseOfProceedsTag = %q, want %q (D-080 lock)", m.UseOfProceedsTag, btypes.MABUseOfProceedsReserveBuildOut)
}
// The mab-pool index recorded the pool binding.
poolID, ok := k.GetMABPool(ctx, "mab-1")
if !ok {
t.Fatal("mab-pool index not recorded")
}
if poolID != "pool-1" {
t.Errorf("mab-pool index = %q, want pool-1", poolID)
}
}
// TestMABIssuanceBreadCouponsRejected (case b) asserts a MAB issuance with
// Bread coupons (CouponDenomBread) is REJECTED at ValidateBasic (FR-MAB-3).
func TestMABIssuanceBreadCouponsRejected(t *testing.T) {
ctx, _, _, _, _, k := newMABSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
_, err := srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
BondID: "mab-bad", PoolID: "pool-1", IssuerStandID: "stand-1",
PrincipalGrain: 1_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomBread,
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
})
if err == nil {
t.Fatal("IssueMAB with CouponDenomBread should be REJECTED (FR-MAB-3)")
}
if !strings.Contains(err.Error(), "FR-MAB-3") {
t.Errorf("err = %q, want 'FR-MAB-3'", err.Error())
}
// The MAB was NOT persisted.
if _, ok := k.GetMAB(ctx, "mab-bad"); ok {
t.Error("MAB with Bread coupons should NOT be persisted")
}
}
// TestMABIssuanceAboveCeilingRejected (case c) asserts a MAB issuance that
// would push the total outstanding MAB principal above the 3× annual
// surplus ceiling is REJECTED (REQ-054 locked). Issue two MABs that
// together + a third exceed 3× annual surplus.
func TestMABIssuanceAboveCeilingRejected(t *testing.T) {
ctx, _, _, _, _, k := newMABSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
// Annual surplus = 5M -> ceiling = 15M. Issue two MABs at 7M each
// (sum = 14M, within ceiling). A third at 2M would push the sum to
// 16M > 15M -> REJECT.
_, err := srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
BondID: "mab-c1", PoolID: "pool-1", IssuerStandID: "stand-1",
PrincipalGrain: 7_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomCoverCall,
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
})
if err != nil {
t.Fatalf("first IssueMAB: %v", err)
}
_, err = srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
BondID: "mab-c2", PoolID: "pool-1", IssuerStandID: "stand-1",
PrincipalGrain: 7_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomMutualAidCredit,
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
})
if err != nil {
t.Fatalf("second IssueMAB: %v", err)
}
// Third at 2M -> sum 16M > 15M ceiling -> REJECT.
_, err = srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
BondID: "mab-c3", PoolID: "pool-1", IssuerStandID: "stand-1",
PrincipalGrain: 2_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomCoverCall,
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
})
if err == nil {
t.Fatal("third IssueMAB above 3× ceiling should be REJECTED")
}
if !strings.Contains(err.Error(), "ceiling breached") {
t.Errorf("err = %q, want 'ceiling breached'", err.Error())
}
}
// TestMABDebitProceedsMisuseAutoStill (case d) asserts a MAB proceeds debit
// with a destination != the Pool's ReserveAccount triggers the auto-Still
// (D-089(1)) AND is REJECTED (D-080 tagged-streaming misuse).
func TestMABDebitProceedsMisuseAutoStill(t *testing.T) {
ctx, _, _, _, stK, k := newMABSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
// Issue a MAB for pool-1 (whose ReserveAccount is "reserve-acc-1").
_, err := srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
BondID: "mab-d1", PoolID: "pool-1", IssuerStandID: "stand-1",
PrincipalGrain: 1_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomCoverCall,
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
})
if err != nil {
t.Fatalf("IssueMAB: %v", err)
}
// Debit to a WRONG destination -> auto-Still + REJECT.
_, err = srv.DebitMABProceeds(ctx, &btypes.MsgDebitMABProceeds{
BondID: "mab-d1", DestinationAccount: "wrong-destination", Signer: "stand-1",
})
if err == nil {
t.Fatal("DebitMABProceeds with wrong destination should be REJECTED")
}
if !strings.Contains(err.Error(), "tagged-streaming misuse") {
t.Errorf("err = %q, want 'tagged-streaming misuse'", err.Error())
}
// The StillKeeper was called with the right bond-id + reason.
if len(stK.calls) != 1 {
t.Fatalf("StillKeeper.Still calls = %d, want 1", len(stK.calls))
}
if stK.calls[0].bondID != "mab-d1" {
t.Errorf("Still bondID = %q, want mab-d1", stK.calls[0].bondID)
}
if !strings.Contains(stK.calls[0].reason, "MAB misuse") {
t.Errorf("Still reason = %q, want 'MAB misuse'", stK.calls[0].reason)
}
// The misuse event was emitted.
if !hasEvent(ctx, "bond.mab_proceeds_misuse") {
t.Error("bond.mab_proceeds_misuse event not emitted")
}
}
// TestMABDebitProceedsMatchSucceeds asserts a MAB proceeds debit with the
// destination == the Pool's ReserveAccount succeeds + the
// bond.mab_proceeds_debited event is emitted.
func TestMABDebitProceedsMatchSucceeds(t *testing.T) {
ctx, _, _, _, _, k := newMABSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
_, err := srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
BondID: "mab-d2", PoolID: "pool-1", IssuerStandID: "stand-1",
PrincipalGrain: 1_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomCoverCall,
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
})
if err != nil {
t.Fatalf("IssueMAB: %v", err)
}
// Debit to the CORRECT destination (reserve-acc-1) -> succeeds.
_, err = srv.DebitMABProceeds(ctx, &btypes.MsgDebitMABProceeds{
BondID: "mab-d2", DestinationAccount: "reserve-acc-1", Signer: "stand-1",
})
if err != nil {
t.Fatalf("DebitMABProceeds with matching destination: %v", err)
}
if !hasEvent(ctx, "bond.mab_proceeds_debited") {
t.Error("bond.mab_proceeds_debited event not emitted")
}
}
// TestMABWitnessProceedsReleaseQuorumPresent (case e) asserts a MAB
// proceeds release with Watcher quorum present succeeds + the
// bond.mab_proceeds_released event is emitted.
func TestMABWitnessProceedsReleaseQuorumPresent(t *testing.T) {
ctx, _, _, wk, _, k := newMABSimtestContext(t)
wk.quorumMet = true
srv := keeper.NewMsgServerImpl(k)
_, err := srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
BondID: "mab-w1", PoolID: "pool-1", IssuerStandID: "stand-1",
PrincipalGrain: 1_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomCoverCall,
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
})
if err != nil {
t.Fatalf("IssueMAB: %v", err)
}
_, err = srv.WitnessMABProceedsRelease(ctx, &btypes.MsgWitnessMABProceedsRelease{
BondID: "mab-w1", AttestationRef: "oy:attest:mab-w1", Signer: "watcher-1",
})
if err != nil {
t.Fatalf("WitnessMABProceedsRelease with quorum: %v", err)
}
if !hasEvent(ctx, "bond.mab_proceeds_released") {
t.Error("bond.mab_proceeds_released event not emitted")
}
}
// TestMABWitnessProceedsReleaseQuorumAbsent asserts a MAB proceeds release
// with Watcher quorum NOT met is REJECTED (D-080 — 6-of-9 required).
func TestMABWitnessProceedsReleaseQuorumAbsent(t *testing.T) {
ctx, _, _, wk, _, k := newMABSimtestContext(t)
wk.quorumMet = false
srv := keeper.NewMsgServerImpl(k)
_, err := srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
BondID: "mab-w2", PoolID: "pool-1", IssuerStandID: "stand-1",
PrincipalGrain: 1_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomCoverCall,
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
})
if err != nil {
t.Fatalf("IssueMAB: %v", err)
}
_, err = srv.WitnessMABProceedsRelease(ctx, &btypes.MsgWitnessMABProceedsRelease{
BondID: "mab-w2", AttestationRef: "oy:attest:mab-w2", Signer: "watcher-1",
})
if err == nil {
t.Fatal("WitnessMABProceedsRelease without quorum should be REJECTED")
}
if !strings.Contains(err.Error(), "quorum not met") {
t.Errorf("err = %q, want 'quorum not met'", err.Error())
}
}
// TestMABWatcherAttest (case f) asserts a quarterly Watcher attestation on
// a MAB is recorded + the bond.mab_watcher_attested event is emitted.
func TestMABWatcherAttest(t *testing.T) {
ctx, _, _, _, _, k := newMABSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
_, err := srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
BondID: "mab-a1", PoolID: "pool-1", IssuerStandID: "stand-1",
PrincipalGrain: 1_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomCoverCall,
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
})
if err != nil {
t.Fatalf("IssueMAB: %v", err)
}
_, err = srv.WatcherAttestMAB(ctx, &btypes.MsgWatcherAttestMAB{
BondID: "mab-a1", AttestationRef: "oy:attest:quarterly:mab-a1", Signer: "watcher-1",
})
if err != nil {
t.Fatalf("WatcherAttestMAB: %v", err)
}
if !hasEvent(ctx, "bond.mab_watcher_attested") {
t.Error("bond.mab_watcher_attested event not emitted")
}
// The attestation was recorded.
atts := k.AllMABAttests(ctx, "mab-a1")
if len(atts) != 1 {
t.Fatalf("AllMABAttests = %d, want 1", len(atts))
}
if atts[0] != "oy:attest:quarterly:mab-a1" {
t.Errorf("attestation ref = %q, want oy:attest:quarterly:mab-a1", atts[0])
}
}
// TestMABIssueIdempotentReject asserts issuing the same MAB bond-id twice
// REJECTS the second.
func TestMABIssueIdempotentReject(t *testing.T) {
ctx, _, _, _, _, k := newMABSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
_, err := srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
BondID: "mab-i1", PoolID: "pool-1", IssuerStandID: "stand-1",
PrincipalGrain: 1_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomCoverCall,
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
})
if err != nil {
t.Fatalf("first IssueMAB: %v", err)
}
_, err = srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
BondID: "mab-i1", PoolID: "pool-1", IssuerStandID: "stand-1",
PrincipalGrain: 2_000_000, CouponBps: 600, CouponKind: btypes.CouponDenomMutualAidCredit,
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
})
if err == nil {
t.Fatal("second IssueMAB on same bond-id should be REJECTED")
}
}
// TestMABIssueNonExistentStandRejected asserts a MAB issuance on a non-
// existent Stand is REJECTED (the StandKeeper stub reports false).
func TestMABIssueNonExistentStandRejected(t *testing.T) {
ctx, sk, _, _, _, k := newMABSimtestContext(t)
sk.exists = map[string]bool{"stand-1": false}
sk.existsAll = false
srv := keeper.NewMsgServerImpl(k)
_, err := srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
BondID: "mab-s1", PoolID: "pool-1", IssuerStandID: "no-such-stand",
PrincipalGrain: 1_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomCoverCall,
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
})
if err == nil {
t.Fatal("IssueMAB on non-existent Stand should be REJECTED")
}
}
// TestMABDebitProceedsNotFound asserts a debit on a non-existent MAB is
// REJECTED.
func TestMABDebitProceedsNotFound(t *testing.T) {
ctx, _, _, _, _, k := newMABSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
_, err := srv.DebitMABProceeds(ctx, &btypes.MsgDebitMABProceeds{
BondID: "no-such-mab", DestinationAccount: "reserve-acc-1", Signer: "stand-1",
})
if err == nil {
t.Error("DebitMABProceeds on non-existent MAB should be REJECTED")
}
}
// TestMABWitnessProceedsReleaseNotFound asserts a release on a non-existent
// MAB is REJECTED.
func TestMABWitnessProceedsReleaseNotFound(t *testing.T) {
ctx, _, _, _, _, k := newMABSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
_, err := srv.WitnessMABProceedsRelease(ctx, &btypes.MsgWitnessMABProceedsRelease{
BondID: "no-such-mab", AttestationRef: "ref", Signer: "watcher-1",
})
if err == nil {
t.Error("WitnessMABProceedsRelease on non-existent MAB should be REJECTED")
}
}
// TestMABWatcherAttestNotFound asserts an attestation on a non-existent MAB
// is REJECTED.
func TestMABWatcherAttestNotFound(t *testing.T) {
ctx, _, _, _, _, k := newMABSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
_, err := srv.WatcherAttestMAB(ctx, &btypes.MsgWatcherAttestMAB{
BondID: "no-such-mab", AttestationRef: "ref", Signer: "watcher-1",
})
if err == nil {
t.Error("WatcherAttestMAB on non-existent MAB should be REJECTED")
}
}
// TestMABDebitProceedsNilCoverKeeperRejected asserts a debit with a nil
// CoverKeeper shim (wiring error) is REJECTED (the destination cannot be
// validated — D-089(2) reverse edge required).
func TestMABDebitProceedsNilCoverKeeperRejected(t *testing.T) {
ctx, _, _, _, _, k := newMABSimtestContext(t)
k.SetCoverKeeper(nil) // nil CoverKeeper — wiring error
srv := keeper.NewMsgServerImpl(k)
_, err := srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
BondID: "mab-n1", PoolID: "pool-1", IssuerStandID: "stand-1",
PrincipalGrain: 1_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomCoverCall,
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
})
if err != nil {
t.Fatalf("IssueMAB: %v", err)
}
_, err = srv.DebitMABProceeds(ctx, &btypes.MsgDebitMABProceeds{
BondID: "mab-n1", DestinationAccount: "reserve-acc-1", Signer: "stand-1",
})
if err == nil {
t.Error("DebitMABProceeds with nil CoverKeeper should be REJECTED (wiring error)")
}
if !strings.Contains(err.Error(), "CoverKeeper shim not wired") {
t.Errorf("err = %q, want 'CoverKeeper shim not wired'", err.Error())
}
}
// TestMABMsgValidateBasicErrorPaths exercises each MAB Msg* ValidateBasic
// error path for coverage.
func TestMABMsgValidateBasicErrorPaths(t *testing.T) {
// MsgIssueMAB empty.
if err := (&btypes.MsgIssueMAB{}).ValidateBasic(); err == nil {
t.Error("empty MsgIssueMAB should fail ValidateBasic")
}
// MsgIssueMAB with Bread coupons -> FR-MAB-3.
if err := (&btypes.MsgIssueMAB{
BondID: "x", PoolID: "p", IssuerStandID: "s", PrincipalGrain: 1,
CouponBps: 500, CouponKind: btypes.CouponDenomBread,
AnnualSurplusAtIssuance: 1, TermDays: 365, Signer: "s",
}).ValidateBasic(); err == nil {
t.Error("MsgIssueMAB with Bread coupons should fail ValidateBasic (FR-MAB-3)")
}
// MsgIssueMAB with above-cap coupon.
if err := (&btypes.MsgIssueMAB{
BondID: "x", PoolID: "p", IssuerStandID: "s", PrincipalGrain: 1,
CouponBps: 1200, CouponKind: btypes.CouponDenomCoverCall,
AnnualSurplusAtIssuance: 1, TermDays: 365, Signer: "s",
}).ValidateBasic(); err == nil {
t.Error("above-cap MsgIssueMAB should fail ValidateBasic")
}
// MsgIssueMAB with zero principal.
if err := (&btypes.MsgIssueMAB{
BondID: "x", PoolID: "p", IssuerStandID: "s", PrincipalGrain: 0,
CouponBps: 500, CouponKind: btypes.CouponDenomCoverCall,
AnnualSurplusAtIssuance: 1, TermDays: 365, Signer: "s",
}).ValidateBasic(); err == nil {
t.Error("zero-principal MsgIssueMAB should fail ValidateBasic")
}
// MsgDebitMABProceeds empty.
if err := (&btypes.MsgDebitMABProceeds{}).ValidateBasic(); err == nil {
t.Error("empty MsgDebitMABProceeds should fail ValidateBasic")
}
// MsgWitnessMABProceedsRelease empty.
if err := (&btypes.MsgWitnessMABProceedsRelease{}).ValidateBasic(); err == nil {
t.Error("empty MsgWitnessMABProceedsRelease should fail ValidateBasic")
}
// MsgWatcherAttestMAB empty.
if err := (&btypes.MsgWatcherAttestMAB{}).ValidateBasic(); err == nil {
t.Error("empty MsgWatcherAttestMAB should fail ValidateBasic")
}
}
// TestMABKeeperAccessors exercises the MAB keeper accessors (AllMABs,
// MABsForPool, AllMABAttests) for coverage.
func TestMABKeeperAccessors(t *testing.T) {
ctx, _, _, _, _, k := newMABSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
// Empty-store accessors return empty (not nil) slices.
if got := k.AllMABs(ctx); len(got) != 0 {
t.Errorf("AllMABs empty = %d, want 0", len(got))
}
if got := k.MABsForPool(ctx, "pool-1"); len(got) != 0 {
t.Errorf("MABsForPool empty = %d, want 0", len(got))
}
if got := k.AllMABAttests(ctx, "mab-x"); len(got) != 0 {
t.Errorf("AllMABAttests empty = %d, want 0", len(got))
}
// Issue + read back.
_, _ = srv.IssueMAB(ctx, &btypes.MsgIssueMAB{
BondID: "mab-acc-1", PoolID: "pool-1", IssuerStandID: "stand-1",
PrincipalGrain: 1_000_000, CouponBps: 500, CouponKind: btypes.CouponDenomCoverCall,
AnnualSurplusAtIssuance: 5_000_000, TermDays: 365, Signer: "stand-1",
})
if got := k.AllMABs(ctx); len(got) != 1 {
t.Errorf("AllMABs = %d, want 1", len(got))
}
if got := k.MABsForPool(ctx, "pool-1"); len(got) != 1 {
t.Errorf("MABsForPool pool-1 = %d, want 1", len(got))
}
if got := k.MABsForPool(ctx, "other-pool"); len(got) != 0 {
t.Errorf("MABsForPool other-pool = %d, want 0", len(got))
}
// Marshal-error path on GetMAB (corrupt bytes in store).
rawStore := ctx.KVStore(k.StoreKey())
rawStore.Set([]byte("mab/corrupt"), []byte("not-json"))
if _, ok := k.GetMAB(ctx, "corrupt"); ok {
t.Error("GetMAB on corrupt bytes should return false")
}
}
+67 -4
View File
@@ -36,15 +36,78 @@ package types
// A non-existent Stand REJECTS the issuance (the bond is not created).
// - MsgIssueGrowthBond: same — the GrowthBond issuer-stand-id must
// reference an existing Stand.
// - MsgIssueMAB: same — the MAB issuer-stand-id must reference an
// existing Stand (v0.7 P4 extension).
//
// No struct import of x/stand/types — the interface is the by-ID-string
// boundary (G-003). The standID is an opaque string (the Stand's ID, by-
// ID-string ref to x/stand).
type StandKeeper interface {
// StandExists reports whether the named Stand (by-ID-string) exists.
// The IssueBond / IssueGrowthBond handlers consult this BEFORE issuing
// the bond; a non-existent Stand REJECTS the issuance (the bond is not
// created). A nil shim skips this check (simtest wiring — documented in
// the handler).
// The IssueBond / IssueGrowthBond / IssueMAB handlers consult this
// BEFORE issuing the bond; a non-existent Stand REJECTS the issuance
// (the bond is not created). A nil shim skips this check (simtest
// wiring — documented in the handler).
StandExists(standID string) bool
}
// CoverKeeper is the expected-keeper interface for x/cover (G-003 — D-089(2)
// reverse edge). The v0.7 MAB handler calls it for:
// - MsgDebitMABProceeds: the handler queries GetPoolReserveAccount(poolID)
// to validate the destination == the Pool's ReserveAccount
// (D-080 tagged streaming). A mismatch -> auto-Still via StillKeeper +
// REJECT. A nil CoverKeeper is a wiring error (the handler REJECTS a
// debit when no CoverKeeper is wired — the destination cannot be
// validated; the simtest wires a stub).
//
// No struct import of x/cover/types — the interface is the by-ID-string
// boundary (G-003 — D-089(2) reverse edge). The poolID is an opaque string
// (the Cover Pool's ID). No import cycle (interface only — the concrete
// cover keeper satisfies this structurally; the simtest wires a stub).
type CoverKeeper interface {
// GetPoolReserveAccount returns the Cover Pool's ReserveAccount by
// pool-id (D-089(2) reverse edge). The MsgDebitMABProceeds handler
// compares the destination against this; a mismatch triggers the
// auto-Still. Returns ("", false) if the pool does not exist.
GetPoolReserveAccount(poolID string) (reserveAccount string, exists bool)
}
// WatcherKeeper is the expected-keeper interface for x/watcher (G-003). The
// v0.7 MAB handler calls it for:
// - MsgWitnessMABProceedsRelease: the handler requires Watcher quorum
// (6-of-9) before the tagged proceeds move from staging to the reserve.
// AttestMABRelease(bondID, attestationRef) returns true if quorum is
// met (the simtest stub returns a configurable bool). A nil
// WatcherKeeper skips the quorum check (simtest wiring — the handler
// still mutates state; the simtest documents the wiring).
//
// No struct import of x/watcher/types — the interface is the by-ID-string
// boundary (G-003). The bondID + attestationRef are opaque strings.
type WatcherKeeper interface {
// AttestMABRelease reports whether the Watcher quorum (6-of-9) is met
// for the MAB proceeds release (D-080). Returns true if quorum present;
// false if not (the handler REJECTS the release). The attestationRef
// is the Watcher-signed observation ref.
AttestMABRelease(bondID string, attestationRef string) bool
}
// StillKeeper is the expected-keeper interface for x/still (G-003 — D-089(1)
// simtest stub). The v0.7 MAB handler calls it for:
// - MsgDebitMABProceeds: on a destination mismatch (D-080 tagged-streaming
// misuse), the handler invokes Still(bondID, "MAB misuse — proceeds
// routed outside reserve") BEFORE rejecting. A nil StillKeeper skips
// the Still recording (simtest wiring — the handler still REJECTS the
// debit; the Still event is just not recorded in a still store).
//
// No struct import of x/still/types — the interface is the by-ID-string
// boundary (G-003). P4 satisfies this by a simtest-local stub (x/still is
// NOT extended this milestone — the simtest stub records Still() calls for
// assertion).
type StillKeeper interface {
// Still pauses the named entity (by-ID-string) for the given reason.
// The MsgDebitMABProceeds handler calls this on a destination mismatch
// (D-080 misuse -> D-089(1) auto-Still). A non-nil error does NOT
// suppress the handler's REJECT (the handler REJECTS regardless; the
// Still is the pause-recording side-effect).
Still(bondID string, reason string) error
}
+38
View File
@@ -142,3 +142,41 @@ func knownOrderStatus(s OrderStatus) bool {
}
return false
}
// --- v0.7 extension: MAB genesis helpers (REQ-054, G-008) ---------------------
//
// genesis.go also holds the data-engineer's genesis schema helpers for the
// v0.7 MAB set (G-008). ValidateGenesis in types.go composes ValidateMABs;
// the security-engineer's test assertions live in types_test.go.
// ValidateMABs asserts mab bond-ids are present and unique, that each
// embedded Bond's coupon-bps is within the LOCKED [floor, cap] bounds
// (D-028), and that each MAB passes ValidateMAB (FR-MAB-3 — rejects
// CouponDenomBread). The genesis-side ValidateMAB is the authoritative
// check (a genesis MAB with a rejected CouponKind is rejected at genesis
// load rather than silently dropped).
func ValidateMABs(mabs []MAB) error {
seen := make(map[string]bool, len(mabs))
for i, m := range mabs {
if m.BondID == "" {
return fmt.Errorf("mab [%d]: empty bond-id", i)
}
if seen[m.BondID] {
return fmt.Errorf("mab: duplicate bond-id %q", m.BondID)
}
seen[m.BondID] = true
if !knownBondStatus(m.Status) {
return fmt.Errorf("mab %q: unknown bond status %q", m.BondID, m.Status)
}
// D-028 clamp on the embedded Bond's coupon.
if m.CouponBps < CouponFloorBps || m.CouponBps > CouponCapBps {
return fmt.Errorf("mab %q: coupon-bps %d outside [%d, %d] (D-028 clamp at genesis load)",
m.BondID, m.CouponBps, CouponFloorBps, CouponCapBps)
}
// FR-MAB-3: MAB coupons NEVER Bread (the dual-firewall runtime gate).
if err := ValidateMAB(m); err != nil {
return fmt.Errorf("mab %q: %w", m.BondID, err)
}
}
return nil
}
+6
View File
@@ -394,6 +394,12 @@ type MsgServer interface {
PlaceSecondaryOrder(ctx interface{}, msg *MsgPlaceSecondaryOrder) (*MsgPlaceSecondaryOrderResponse, error)
CancelSecondaryOrder(ctx interface{}, msg *MsgCancelSecondaryOrder) (*MsgCancelSecondaryOrderResponse, error)
MatchSecondaryOrder(ctx interface{}, msg *MsgMatchSecondaryOrder) (*MsgMatchSecondaryOrderResponse, error)
// v0.7 MAB handlers (REQ-054, D-080, D-089(1), D-089(2)) — defined in
// msg_mab.go.
IssueMAB(ctx interface{}, msg *MsgIssueMAB) (*MsgIssueMABResponse, error)
DebitMABProceeds(ctx interface{}, msg *MsgDebitMABProceeds) (*MsgDebitMABProceedsResponse, error)
WitnessMABProceedsRelease(ctx interface{}, msg *MsgWitnessMABProceedsRelease) (*MsgWitnessMABProceedsReleaseResponse, error)
WatcherAttestMAB(ctx interface{}, msg *MsgWatcherAttestMAB) (*MsgWatcherAttestMABResponse, error)
}
// Response types (hand-rolled; the response is the state mutation + event).
+330
View File
@@ -0,0 +1,330 @@
package types
// msg_mab.go holds the v0.7 Mutual Aid Bond Msg* types implementing sdk.Msg
// (REQ-054, D-080, D-089(1), D-089(2); G-006 controlled exception: types/
// gains the cosmos-sdk import for sdk.Msg — D-055; the invariant/lexicon
// tests in *_test.go stay stdlib-only per G-024, isolated from this
// msg_*.go file).
//
// The four MAB Msg types drive the MAB runtime (REQ-054):
// - MsgIssueMAB: issue a Mutual Aid Bond (the handler enforces the 3×
// annual surplus ceiling + the FR-MAB-3 Bread-coupon rejection +
// Clamp on the coupon).
// - MsgDebitMABProceeds: debit the MAB's tagged proceeds to the Pool's
// ReserveAccount (D-080 — the handler checks destination ==
// CoverKeeper.GetPoolReserveAccount; mismatch -> auto-Still via
// StillKeeper + REJECT).
// - MsgWitnessMABProceedsRelease: a Watcher-witnessed release of the
// tagged proceeds from staging to the reserve (D-080 — the handler
// requires WatcherKeeper.AttestMABRelease quorum 6-of-9).
// - MsgWatcherAttestMAB: the quarterly Watcher audit attestation on a
// MAB (records the attestation-ref against the MAB).
//
// All cross-module refs are by-ID-string (G-003): pool-id refs a Cover Pool
// (via the CoverKeeper shim — D-089(2) reverse edge); the WatcherKeeper +
// StillKeeper shims are interfaces defined in expected_keepers.go. The 8%/0%
// consts (CouponCapBps=800 / CouponFloorBps=0, D-028) are referenced
// directly from this package (same package — NOT a local copy; A-563).
//
// Lexicon (REQ-012, A-210): "Mutual Aid Bond", "MAB", "Cover Call",
// "coupon", "use-of-proceeds", "reserve build-out" are clean. The
// CouponDenomBread const VALUE "Bread" is the OY unit (clean — not a banned
// term). The banned coupon-synonyms are NEVER used.
import (
"fmt"
sdk "github.com/cosmos/cosmos-sdk/types"
)
// --- MsgIssueMAB --------------------------------------------------------------
// MsgIssueMAB issues a Mutual Aid Bond (REQ-054, D-080). The handler enforces:
// - ValidateBasic (stateless — includes ValidateMAB: rejects
// CouponDenomBread with FR-MAB-3).
// - Idempotency: bond-id must not already exist.
// - StandKeeper shim: the issuer-stand-id must reference an existing Stand
// (P1-02-01 edge). A nil shim skips (simtest wiring).
// - 3× annual surplus ceiling: checkMABIssuanceCeiling asserts
// sum(existingMABPrincipal for poolID) + PrincipalGrain <=
// MABIssuanceCeilingAnnualSurplusMultiple × AnnualSurplusAtIssuance.
// REJECT if above ceiling (re-checked at every issuance).
// - Coupon clamp via Clamp (A-563 — defense in depth).
// - UseOfProceedsTag locked to MABUseOfProceedsReserveBuildOut.
//
// pool-id is on the msg (NOT on the MAB struct — the MAB struct mirrors
// GrowthBond's anonymous-embed pattern; the pool binding is via the
// CoverKeeper reverse edge). The handler records the pool-id in the
// keeper's mab-pool index (BondID -> PoolID) for the ceiling check +
// the DebitMABProceeds destination validation.
type MsgIssueMAB struct {
BondID string `json:"bond_id" yaml:"bond_id"`
PoolID string `json:"pool_id" yaml:"pool_id"`
IssuerStandID string `json:"issuer_stand_id" yaml:"issuer_stand_id"`
PrincipalGrain int64 `json:"principal_grain" yaml:"principal_grain"`
CouponBps uint32 `json:"coupon_bps" yaml:"coupon_bps"`
CouponKind CouponDenom `json:"coupon_kind" yaml:"coupon_kind"`
AnnualSurplusAtIssuance int64 `json:"annual_surplus_at_issuance" yaml:"annual_surplus_at_issuance"`
TermDays uint32 `json:"term_days" yaml:"term_days"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message (sdk.Msg = proto.Message).
func (m *MsgIssueMAB) Reset() { *m = MsgIssueMAB{} }
// String implements proto.Message.
func (m *MsgIssueMAB) String() string {
return fmt.Sprintf("MsgIssueMAB{BondID:%s PoolID:%s IssuerStandID:%s PrincipalGrain:%d CouponBps:%d CouponKind:%s AnnualSurplusAtIssuance:%d TermDays:%d Signer:%s}",
m.BondID, m.PoolID, m.IssuerStandID, m.PrincipalGrain, m.CouponBps, m.CouponKind, m.AnnualSurplusAtIssuance, m.TermDays, m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgIssueMAB) ProtoMessage() {}
// ValidateBasic is the stateless validation: non-empty fields, PrincipalGrain
// > 0, AnnualSurplusAtIssuance > 0, coupon-bps within [CouponFloorBps,
// CouponCapBps] (the stateless clamp guard; the handler re-clamps at
// runtime per A-563), AND ValidateMAB (FR-MAB-3 — rejects CouponDenomBread).
// The 3× annual surplus ceiling is a keeper-handler check (stateful — it
// sums existing MAB principals for the poolID).
func (m *MsgIssueMAB) ValidateBasic() error {
if m.BondID == "" {
return fmt.Errorf("bond: empty bond-id")
}
if m.PoolID == "" {
return fmt.Errorf("bond: empty pool-id")
}
if m.IssuerStandID == "" {
return fmt.Errorf("bond: empty issuer-stand-id")
}
if m.PrincipalGrain <= 0 {
return fmt.Errorf("bond: principal-grain must be > 0")
}
if m.AnnualSurplusAtIssuance <= 0 {
return fmt.Errorf("bond: annual-surplus-at-issuance must be > 0")
}
if m.CouponBps < CouponFloorBps || m.CouponBps > CouponCapBps {
return fmt.Errorf("bond: coupon-bps %d out of band [%d, %d] (D-028 stateless guard)", m.CouponBps, CouponFloorBps, CouponCapBps)
}
if m.Signer == "" {
return fmt.Errorf("bond: empty signer")
}
// FR-MAB-3 dual firewall: ValidateMAB rejects CouponDenomBread at the
// stateless gate (the handler re-checks in defense in depth).
if err := ValidateMAB(MAB{CouponKind: m.CouponKind}); err != nil {
return err
}
return nil
}
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
func (m *MsgIssueMAB) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{[]byte(m.Signer)}
}
// --- MsgDebitMABProceeds ------------------------------------------------------
// MsgDebitMABProceeds debits a MAB's tagged proceeds to the Pool's
// ReserveAccount (D-080). The handler enforces:
// - ValidateBasic (stateless).
// - The MAB must exist.
// - D-080 tagged streaming: DestinationAccount ==
// CoverKeeper.GetPoolReserveAccount(mab's poolID). If mismatch ->
// StillKeeper.Still(bondID, "MAB misuse — proceeds routed outside
// reserve") (D-089(1) — a nil StillKeeper skips the Still recording)
// AND REJECT. If match -> emit bond.mab_proceeds_debited (simtest: the
// debit is the event; no actual Grain transfer in P4).
type MsgDebitMABProceeds struct {
BondID string `json:"bond_id" yaml:"bond_id"`
DestinationAccount string `json:"destination_account" yaml:"destination_account"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message.
func (m *MsgDebitMABProceeds) Reset() { *m = MsgDebitMABProceeds{} }
// String implements proto.Message.
func (m *MsgDebitMABProceeds) String() string {
return fmt.Sprintf("MsgDebitMABProceeds{BondID:%s DestinationAccount:%s Signer:%s}",
m.BondID, m.DestinationAccount, m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgDebitMABProceeds) ProtoMessage() {}
// ValidateBasic is the stateless validation: non-empty bond-id, non-empty
// DestinationAccount, non-empty signer.
func (m *MsgDebitMABProceeds) ValidateBasic() error {
if m.BondID == "" {
return fmt.Errorf("bond: empty bond-id")
}
if m.DestinationAccount == "" {
return fmt.Errorf("bond: empty DestinationAccount")
}
if m.Signer == "" {
return fmt.Errorf("bond: empty signer")
}
return nil
}
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
func (m *MsgDebitMABProceeds) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{[]byte(m.Signer)}
}
// --- MsgWitnessMABProceedsRelease ---------------------------------------------
// MsgWitnessMABProceedsRelease is a Watcher-witnessed release of a MAB's
// tagged proceeds from staging to the reserve (D-080). The handler enforces:
// - ValidateBasic (stateless).
// - The MAB must exist.
// - Watcher quorum: WatcherKeeper.AttestMABRelease(bondID, attestationRef)
// returns true if quorum (6-of-9) is met. If false (quorum not met) ->
// REJECT. If true -> emit bond.mab_proceeds_released (the proceeds move
// from tagged staging to the reserve — simtest event).
type MsgWitnessMABProceedsRelease struct {
BondID string `json:"bond_id" yaml:"bond_id"`
AttestationRef string `json:"attestation_ref" yaml:"attestation_ref"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message.
func (m *MsgWitnessMABProceedsRelease) Reset() { *m = MsgWitnessMABProceedsRelease{} }
// String implements proto.Message.
func (m *MsgWitnessMABProceedsRelease) String() string {
return fmt.Sprintf("MsgWitnessMABProceedsRelease{BondID:%s AttestationRef:%s Signer:%s}",
m.BondID, m.AttestationRef, m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgWitnessMABProceedsRelease) ProtoMessage() {}
// ValidateBasic is the stateless validation: non-empty bond-id, non-empty
// attestation-ref, non-empty signer.
func (m *MsgWitnessMABProceedsRelease) ValidateBasic() error {
if m.BondID == "" {
return fmt.Errorf("bond: empty bond-id")
}
if m.AttestationRef == "" {
return fmt.Errorf("bond: empty attestation-ref")
}
if m.Signer == "" {
return fmt.Errorf("bond: empty signer")
}
return nil
}
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
func (m *MsgWitnessMABProceedsRelease) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{[]byte(m.Signer)}
}
// --- MsgWatcherAttestMAB ------------------------------------------------------
// MsgWatcherAttestMAB records a quarterly Watcher audit attestation on a MAB
// (D-080). The handler enforces:
// - ValidateBasic (stateless).
// - The MAB must exist.
// - Record the attestation (a store entry mab_attest/<bondID>/<timestamp>
// -> attestationRef). Emit bond.mab_watcher_attested.
type MsgWatcherAttestMAB struct {
BondID string `json:"bond_id" yaml:"bond_id"`
AttestationRef string `json:"attestation_ref" yaml:"attestation_ref"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message.
func (m *MsgWatcherAttestMAB) Reset() { *m = MsgWatcherAttestMAB{} }
// String implements proto.Message.
func (m *MsgWatcherAttestMAB) String() string {
return fmt.Sprintf("MsgWatcherAttestMAB{BondID:%s AttestationRef:%s Signer:%s}",
m.BondID, m.AttestationRef, m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgWatcherAttestMAB) ProtoMessage() {}
// ValidateBasic is the stateless validation: non-empty bond-id, non-empty
// attestation-ref, non-empty signer.
func (m *MsgWatcherAttestMAB) ValidateBasic() error {
if m.BondID == "" {
return fmt.Errorf("bond: empty bond-id")
}
if m.AttestationRef == "" {
return fmt.Errorf("bond: empty attestation-ref")
}
if m.Signer == "" {
return fmt.Errorf("bond: empty signer")
}
return nil
}
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
func (m *MsgWatcherAttestMAB) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{[]byte(m.Signer)}
}
// --- MAB Response types -------------------------------------------------------
// MsgIssueMABResponse is the response to MsgIssueMAB. ClampedCouponBps
// reports the runtime-clamped coupon (for simtest assertion that issuance
// clamped it). CeilingMultiple reports the post-issuance
// (sumMABPrincipal / AnnualSurplusAtIssuance) ratio (for simtest assertion
// the ceiling was respected).
type MsgIssueMABResponse struct {
ClampedCouponBps uint32 `json:"clamped_coupon_bps" yaml:"clamped_coupon_bps"`
CeilingMultiple int64 `json:"ceiling_multiple" yaml:"ceiling_multiple"`
}
// Reset implements proto.Message.
func (m *MsgIssueMABResponse) Reset() { *m = MsgIssueMABResponse{} }
// String implements proto.Message.
func (m *MsgIssueMABResponse) String() string {
return fmt.Sprintf("MsgIssueMABResponse{ClampedCouponBps:%d CeilingMultiple:%d}",
m.ClampedCouponBps, m.CeilingMultiple)
}
// ProtoMessage implements proto.Message.
func (*MsgIssueMABResponse) ProtoMessage() {}
// MsgDebitMABProceedsResponse is the response to MsgDebitMABProceeds.
type MsgDebitMABProceedsResponse struct{}
// Reset implements proto.Message.
func (m *MsgDebitMABProceedsResponse) Reset() { *m = MsgDebitMABProceedsResponse{} }
// String implements proto.Message.
func (m *MsgDebitMABProceedsResponse) String() string { return "MsgDebitMABProceedsResponse{}" }
// ProtoMessage implements proto.Message.
func (*MsgDebitMABProceedsResponse) ProtoMessage() {}
// MsgWitnessMABProceedsReleaseResponse is the response to
// MsgWitnessMABProceedsRelease.
type MsgWitnessMABProceedsReleaseResponse struct{}
// Reset implements proto.Message.
func (m *MsgWitnessMABProceedsReleaseResponse) Reset() { *m = MsgWitnessMABProceedsReleaseResponse{} }
// String implements proto.Message.
func (m *MsgWitnessMABProceedsReleaseResponse) String() string {
return "MsgWitnessMABProceedsReleaseResponse{}"
}
// ProtoMessage implements proto.Message.
func (*MsgWitnessMABProceedsReleaseResponse) ProtoMessage() {}
// MsgWatcherAttestMABResponse is the response to MsgWatcherAttestMAB.
type MsgWatcherAttestMABResponse struct{}
// Reset implements proto.Message.
func (m *MsgWatcherAttestMABResponse) Reset() { *m = MsgWatcherAttestMABResponse{} }
// String implements proto.Message.
func (m *MsgWatcherAttestMABResponse) String() string { return "MsgWatcherAttestMABResponse{}" }
// ProtoMessage implements proto.Message.
func (*MsgWatcherAttestMABResponse) ProtoMessage() {}
+153
View File
@@ -29,6 +29,33 @@ const (
// §17, REQ-021). A regression firewall: adding/removing/renaming a bond
// status breaks this const's test.
BondStatusCount = 5
// MABIssuanceCeilingAnnualSurplusMultiple is the LOCKED ceiling on the
// total outstanding MAB principal for a pool, expressed as a multiple of
// the pool's AnnualSurplusAtIssuance (vision §17, REQ-054 locked — the
// 3× annual surplus mission-locked ceiling). The handler re-checks at
// every issuance (not just the first): sum(existingMABPrincipal) +
// newPrincipal <= 3 × AnnualSurplusAtIssuance. A regression here is a
// mission-lock breach.
MABIssuanceCeilingAnnualSurplusMultiple = 3
// MABUseOfProceedsReserveBuildOut is the D-080 tagged-streaming use-of-
// proceeds tag for a MAB: the proceeds are tagged for "reserve_build_out"
// (the Cover Pool's ReserveAccount build-out). The MsgDebitMABProceeds
// handler checks the destination == the Pool's ReserveAccount;
// the MsgWitnessMABProceedsRelease handler requires Watcher quorum before
// the tagged proceeds move from staging to the reserve. The tag is the
// D-080 lock — a MAB's proceeds are NEVER routable outside reserve
// build-out (mismatch -> auto-Still + REJECT).
MABUseOfProceedsReserveBuildOut = "reserve_build_out"
// CouponDenomCount is the count of CouponDenom enum values (vision §17,
// REQ-054). A regression firewall: adding/removing/renaming a CouponDenom
// breaks this const's test. The three values are CouponDenomCoverCall,
// CouponDenomMutualAidCredit, CouponDenomBread (the last exists ONLY to
// be rejected at ValidateMAB with "FR-MAB-3: MAB coupons NEVER Bread" —
// the dual-firewall runtime gate mirroring MissionLockAmendmentRejected).
CouponDenomCount = 3
)
// BondStatus enumerates the bond lifecycle states (vision §17, REQ-021).
@@ -124,6 +151,7 @@ type GenesisState struct {
Bonds []Bond `json:"bonds" yaml:"bonds"`
GrowthBonds []GrowthBond `json:"growth_bonds" yaml:"growth_bonds"`
Orders []SecondaryOrder `json:"orders" yaml:"orders"`
MABs []MAB `json:"mabs" yaml:"mabs"`
}
func DefaultGenesisState() *GenesisState {
@@ -132,6 +160,7 @@ func DefaultGenesisState() *GenesisState {
Bonds: []Bond{},
GrowthBonds: []GrowthBond{},
Orders: []SecondaryOrder{},
MABs: []MAB{},
}
}
@@ -154,6 +183,9 @@ func ValidateGenesis(bz json.RawMessage) error {
if err := ValidateOrders(gs.Orders); err != nil {
return fmt.Errorf("bond: %w", err)
}
if err := ValidateMABs(gs.MABs); err != nil {
return fmt.Errorf("bond: %w", err)
}
return nil
}
@@ -281,6 +313,127 @@ func IssueGrowth(bondID, issuerStandID string, principalGrain int64, couponBps,
}
}
// --- v0.7 extension: Mutual Aid Bond (MAB) (REQ-054, D-080, D-089(2)) -----------
//
// The v0.7 bond extension adds the Mutual Aid Bond (MAB): a mission-locked
// bond a Cover Pool issues to build out its reserve (vision §17, REQ-054).
// The MAB embeds the v0.2 Bond (anonymous field) so it carries all Bond
// fields PLUS a CouponKind (the coupon denomination: Cover-Call or Mutual-Aid
// Credit — Bread is the rejected sentinel), an AnnualSurplusAtIssuance (the
// pool's annual surplus at issuance, used for the 3× ceiling check), and a
// UseOfProceedsTag (D-080 — locked to "reserve_build_out"). The coupon rate
// is clamped to [CouponFloorBps, CouponCapBps] via Clamp (the 8%/0% consts
// D-028 apply to MABs too).
//
// The 3× annual surplus ceiling (MABIssuanceCeilingAnnualSurplusMultiple) is
// the mission-locked upper bound on the total outstanding MAB principal for
// a pool (vision §17, REQ-054 locked). The handler re-checks at every
// issuance: sum(existingMABPrincipal) + newPrincipal <= 3 ×
// AnnualSurplusAtIssuance. A regression here is a mission-lock breach.
//
// D-080 tagged streaming: the UseOfProceedsTag is locked to
// "reserve_build_out"; the MsgDebitMABProceeds handler checks the destination
// == the Pool's ReserveAccount (queried via the CoverKeeper shim — D-089(2)
// reverse edge); mismatch -> auto-Still via StillKeeper + REJECT. The
// MsgWitnessMABProceedsRelease handler requires Watcher quorum (6-of-9)
// before the tagged proceeds move from staging to the reserve.
//
// Lexicon (REQ-012, A-210): "Mutual Aid Bond", "MAB", "Cover Call", "coupon",
// "use-of-proceeds", "reserve build-out" are clean. The CouponDenomBread
// const VALUE is "Bread" (the OY unit, not a banned term — clean). The
// banned coupon-synonyms are NEVER used.
// CouponDenom enumerates the three coupon denominations a MAB may carry
// (vision §17, REQ-054). Two are valid (CoverCall, MutualAidCredit); the
// third — Bread — exists ONLY to be rejected at ValidateMAB with
// "FR-MAB-3: MAB coupons NEVER Bread" (the dual-firewall runtime gate
// mirroring MissionLockAmendmentRejected at x/council/types/types.go:242).
// The enum value EXISTS to document in code that MAB coupons are NEVER Bread;
// the ValidateMAB gate rejects it; the locked-const test asserts the count.
type CouponDenom string
const (
// CouponDenomCoverCall is the Cover-Call coupon denomination (a MAB
// whose coupon is settled in Cover-Call units — the primary MAB kind).
CouponDenomCoverCall CouponDenom = "CoverCall"
// CouponDenomMutualAidCredit is the Mutual-Aid-Credit coupon
// denomination (a MAB whose coupon is settled in mutual-aid credit
// units — the secondary MAB kind).
CouponDenomMutualAidCredit CouponDenom = "MutualAidCredit"
// CouponDenomBread is the REJECTED sentinel coupon denomination
// (FR-MAB-3 — MAB coupons NEVER Bread). The enum value EXISTS to
// document in code that MAB coupons are NEVER Bread; the ValidateMAB
// gate rejects any MAB with this CouponKind. The const VALUE "Bread"
// is the OY unit (clean — not a banned term). Mirrors
// ProposalMissionLockAmendmentRejected at x/council/types/types.go:242.
CouponDenomBread CouponDenom = "Bread"
)
// AllCouponDenoms returns all three CouponDenom values in REQ-054 order. The
// locked-const test asserts exactly 3 entries (the regression firewall).
func AllCouponDenoms() []CouponDenom {
return []CouponDenom{
CouponDenomCoverCall,
CouponDenomMutualAidCredit,
CouponDenomBread,
}
}
// MAB is a Mutual Aid Bond: a mission-locked bond a Cover Pool issues to
// build out its reserve (vision §17, REQ-054, D-080, D-089(2)). It embeds
// the v0.2 Bond (anonymous field) so it carries all Bond fields (bond-id,
// issuer-stand-id, principal-grain, coupon-bps, term-days, issued-at,
// maturity, status) PLUS a CouponKind (the coupon denomination), an
// AnnualSurplusAtIssuance (the pool's annual surplus at issuance, used for
// the 3× ceiling check), and a UseOfProceedsTag (D-080 — locked to
// "reserve_build_out"). The coupon rate is clamped to [CouponFloorBps,
// CouponCapBps] via Clamp at issuance (the 8%/0% consts D-028 apply).
//
// pool-id is NOT a field on MAB (the MAB is issued by a Stand for a pool;
// the pool binding is via the CoverKeeper.GetPoolReserveAccount reverse
// edge — D-089(2)). The MsgDebitMABProceeds handler queries the CoverKeeper
// for the pool's ReserveAccount by the MAB's PoolID (carried on the msg,
// not the MAB struct — the MAB struct mirrors GrowthBond's anonymous-embed
// pattern + the MAB-specific fields only).
type MAB struct {
Bond // anonymous embed — carries all v0.2 Bond fields
CouponKind CouponDenom `json:"coupon_kind" yaml:"coupon_kind"`
AnnualSurplusAtIssuance int64 `json:"annual_surplus_at_issuance" yaml:"annual_surplus_at_issuance"`
UseOfProceedsTag string `json:"use_of_proceeds_tag" yaml:"use_of_proceeds_tag"`
}
// IssueMAB is the MAB issuance stub (REQ-054, D-080). It constructs a MAB
// with the coupon clamped to [CouponFloorBps, CouponCapBps] via Clamp, the
// CouponKind set, and the UseOfProceedsTag locked to
// MABUseOfProceedsReserveBuildOut. The returned MAB has status BondIssued
// (inherited from Issue's Bond construction). The stub does not persist or
// enforce the 3× annual surplus ceiling (that is a keeper-handler concern);
// it only enforces the coupon clamp invariant at construction time.
func IssueMAB(bondID, issuerStandID string, principalGrain int64, couponBps uint32, couponKind CouponDenom, annualSurplusAtIssuance int64, termDays uint32, issuedAt, maturity int64) MAB {
clampedCoupon := Clamp(couponBps)
return MAB{
Bond: Issue(bondID, issuerStandID, principalGrain, clampedCoupon, termDays, issuedAt, maturity),
CouponKind: couponKind,
AnnualSurplusAtIssuance: annualSurplusAtIssuance,
UseOfProceedsTag: MABUseOfProceedsReserveBuildOut,
}
}
// ValidateMAB is the MAB runtime firewall (REQ-054, FR-MAB-3). It rejects a
// MAB whose CouponKind == CouponDenomBread with "FR-MAB-3: MAB coupons
// NEVER Bread" — the dual-firewall runtime gate mirroring
// MissionLockAmendmentRejected at x/council/types/types.go:242. The
// CouponDenomBread const EXISTS to document in code that MAB coupons are
// NEVER Bread; this gate rejects any MAB with that CouponKind. The
// ValidateBasic on MsgIssueMAB calls this; the keeper handler re-checks in
// defense in depth.
func ValidateMAB(m MAB) error {
if m.CouponKind == CouponDenomBread {
return fmt.Errorf("FR-MAB-3: MAB coupons NEVER Bread (CouponDenomBread is the rejected sentinel — REQ-054 dual firewall)")
}
return nil
}
// SecondaryOrder is a secondary-market order on an issued bond (vision §17,
// REQ-026, D-041, A-313). order-id is the unique identifier. bond-id references
// a Bond (by-ID-string ref to a Bond — same package, so this is an in-package
+172
View File
@@ -962,3 +962,175 @@ func packageDir(t *testing.T, importPath string) string {
rel := strings.TrimPrefix(importPath, "github.com/oy/openyield/")
return filepath.Join(repoRoot, rel)
}
// --- v0.7 P4: MAB locked consts + ValidateMAB + IssueMAB (REQ-054) -----------
//
// The MAB locked-const + ValidateMAB + IssueMAB regression tests (REQ-054,
// FR-MAB-3, D-080). A regression here is a mission-lock breach.
// TestMABIssuanceCeilingAnnualSurplusMultiple asserts the 3× annual surplus
// ceiling multiple is the locked 3 (REQ-054 locked — vision §17 3× annual
// surplus mission-locked ceiling).
func TestMABIssuanceCeilingAnnualSurplusMultiple(t *testing.T) {
if btypes.MABIssuanceCeilingAnnualSurplusMultiple != 3 {
t.Errorf("MABIssuanceCeilingAnnualSurplusMultiple = %d, want 3 (REQ-054 locked — 3× annual surplus ceiling)", btypes.MABIssuanceCeilingAnnualSurplusMultiple)
}
}
// TestMABUseOfProceedsReserveBuildOut asserts the D-080 tagged-streaming
// use-of-proceeds tag is "reserve_build_out".
func TestMABUseOfProceedsReserveBuildOut(t *testing.T) {
if btypes.MABUseOfProceedsReserveBuildOut != "reserve_build_out" {
t.Errorf("MABUseOfProceedsReserveBuildOut = %q, want %q (D-080 tagged-streaming use-of-proceeds)", btypes.MABUseOfProceedsReserveBuildOut, "reserve_build_out")
}
}
// TestCouponDenomCount asserts CouponDenomCount == 3 (the regression
// firewall — the three CouponDenom values are CoverCall, MutualAidCredit,
// Bread).
func TestCouponDenomCount(t *testing.T) {
if btypes.CouponDenomCount != 3 {
t.Errorf("CouponDenomCount = %d, want 3 (REQ-054 — CoverCall + MutualAidCredit + Bread)", btypes.CouponDenomCount)
}
if len(btypes.AllCouponDenoms()) != 3 {
t.Errorf("AllCouponDenoms len = %d, want 3", len(btypes.AllCouponDenoms()))
}
}
// TestCouponDenomValues asserts the three CouponDenom string values.
func TestCouponDenomValues(t *testing.T) {
cases := []struct {
d btypes.CouponDenom
want string
}{
{btypes.CouponDenomCoverCall, "CoverCall"},
{btypes.CouponDenomMutualAidCredit, "MutualAidCredit"},
{btypes.CouponDenomBread, "Bread"},
}
for _, c := range cases {
if string(c.d) != c.want {
t.Errorf("CouponDenom(%q) value = %q, want %q", c.d, c.d, c.want)
}
}
}
// TestValidateMABRejectsBread asserts ValidateMAB rejects CouponDenomBread
// with "FR-MAB-3" (the dual-firewall runtime gate mirroring
// MissionLockAmendmentRejected).
func TestValidateMABRejectsBread(t *testing.T) {
m := btypes.MAB{CouponKind: btypes.CouponDenomBread}
err := btypes.ValidateMAB(m)
if err == nil {
t.Fatal("ValidateMAB on CouponDenomBread should be REJECTED (FR-MAB-3)")
}
if !strings.Contains(err.Error(), "FR-MAB-3") {
t.Errorf("err = %q, want 'FR-MAB-3'", err.Error())
}
if !strings.Contains(err.Error(), "NEVER Bread") {
t.Errorf("err = %q, want 'NEVER Bread'", err.Error())
}
// A valid CouponKind passes.
if err := btypes.ValidateMAB(btypes.MAB{CouponKind: btypes.CouponDenomCoverCall}); err != nil {
t.Errorf("ValidateMAB on CouponDenomCoverCall should pass; got: %v", err)
}
if err := btypes.ValidateMAB(btypes.MAB{CouponKind: btypes.CouponDenomMutualAidCredit}); err != nil {
t.Errorf("ValidateMAB on CouponDenomMutualAidCredit should pass; got: %v", err)
}
}
// TestIssueMABClampsCoupon asserts IssueMAB clamps the coupon to
// [CouponFloorBps, CouponCapBps] (the cross-const test extending REQ-030 —
// the MAB coupon cap == CouponCapBps).
func TestIssueMABClampsCoupon(t *testing.T) {
// In-band coupon: unchanged.
m := btypes.IssueMAB("mab-1", "stand-1", 1_000_000, 500, btypes.CouponDenomCoverCall, 5_000_000, 365, 1000, 1365)
if m.CouponBps != 500 {
t.Errorf("in-band CouponBps = %d, want 500 (unchanged)", m.CouponBps)
}
if m.CouponKind != btypes.CouponDenomCoverCall {
t.Errorf("CouponKind = %q, want CoverCall", m.CouponKind)
}
if m.UseOfProceedsTag != btypes.MABUseOfProceedsReserveBuildOut {
t.Errorf("UseOfProceedsTag = %q, want %q (D-080 lock)", m.UseOfProceedsTag, btypes.MABUseOfProceedsReserveBuildOut)
}
if m.Status != btypes.BondIssued {
t.Errorf("Status = %q, want BondIssued", m.Status)
}
// Above-cap coupon: clamped to cap.
m2 := btypes.IssueMAB("mab-2", "stand-1", 1_000_000, 1200, btypes.CouponDenomMutualAidCredit, 5_000_000, 365, 1000, 1365)
if m2.CouponBps != btypes.CouponCapBps {
t.Errorf("above-cap CouponBps = %d, want cap %d (IssueMAB must clamp)", m2.CouponBps, btypes.CouponCapBps)
}
}
// TestValidateMABsRejectsBreadAtGenesis asserts ValidateMABs rejects a
// genesis MAB with CouponDenomBread (FR-MAB-3 at genesis load).
func TestValidateMABsRejectsBreadAtGenesis(t *testing.T) {
mabs := []btypes.MAB{
{Bond: btypes.Bond{BondID: "mab-1", Status: btypes.BondIssued, CouponBps: 500}, CouponKind: btypes.CouponDenomCoverCall, UseOfProceedsTag: btypes.MABUseOfProceedsReserveBuildOut},
{Bond: btypes.Bond{BondID: "mab-bad", Status: btypes.BondIssued, CouponBps: 500}, CouponKind: btypes.CouponDenomBread, UseOfProceedsTag: btypes.MABUseOfProceedsReserveBuildOut},
}
err := btypes.ValidateMABs(mabs)
if err == nil {
t.Fatal("ValidateMABs with CouponDenomBread should be REJECTED at genesis (FR-MAB-3)")
}
if !strings.Contains(err.Error(), "FR-MAB-3") {
t.Errorf("err = %q, want 'FR-MAB-3'", err.Error())
}
}
// TestValidateMABsRejectsDupIDs asserts ValidateMABs rejects duplicate
// bond-ids (A-212 ID-uniqueness at genesis load).
func TestValidateMABsRejectsDupIDs(t *testing.T) {
mabs := []btypes.MAB{
{Bond: btypes.Bond{BondID: "dup", Status: btypes.BondIssued, CouponBps: 500}, CouponKind: btypes.CouponDenomCoverCall, UseOfProceedsTag: btypes.MABUseOfProceedsReserveBuildOut},
{Bond: btypes.Bond{BondID: "dup", Status: btypes.BondIssued, CouponBps: 500}, CouponKind: btypes.CouponDenomMutualAidCredit, UseOfProceedsTag: btypes.MABUseOfProceedsReserveBuildOut},
}
if err := btypes.ValidateMABs(mabs); err == nil {
t.Fatal("ValidateMABs with duplicate bond-ids should be REJECTED")
}
}
// TestValidateMABsAcceptsClean asserts ValidateMABs accepts a clean set.
func TestValidateMABsAcceptsClean(t *testing.T) {
mabs := []btypes.MAB{
{Bond: btypes.Bond{BondID: "m1", Status: btypes.BondIssued, CouponBps: 500}, CouponKind: btypes.CouponDenomCoverCall, UseOfProceedsTag: btypes.MABUseOfProceedsReserveBuildOut},
{Bond: btypes.Bond{BondID: "m2", Status: btypes.BondActive, CouponBps: 600}, CouponKind: btypes.CouponDenomMutualAidCredit, UseOfProceedsTag: btypes.MABUseOfProceedsReserveBuildOut},
}
if err := btypes.ValidateMABs(mabs); err != nil {
t.Errorf("ValidateMABs should accept clean set; got: %v", err)
}
}
// TestMABStructFields asserts the MAB struct carries the anonymous Bond
// embed + the MAB-specific fields (CouponKind + AnnualSurplusAtIssuance +
// UseOfProceedsTag).
func TestMABStructFields(t *testing.T) {
m := btypes.MAB{
Bond: btypes.Bond{BondID: "mab-x", IssuerStandID: "stand-1", PrincipalGrain: 1_000_000, CouponBps: 500, Status: btypes.BondIssued},
CouponKind: btypes.CouponDenomCoverCall,
AnnualSurplusAtIssuance: 5_000_000,
UseOfProceedsTag: btypes.MABUseOfProceedsReserveBuildOut,
}
if m.BondID != "mab-x" {
t.Errorf("MAB.BondID = %q (anonymous embed access)", m.BondID)
}
if m.CouponKind != btypes.CouponDenomCoverCall {
t.Errorf("MAB.CouponKind = %q", m.CouponKind)
}
if m.AnnualSurplusAtIssuance != 5_000_000 {
t.Errorf("MAB.AnnualSurplusAtIssuance = %d", m.AnnualSurplusAtIssuance)
}
if m.UseOfProceedsTag != btypes.MABUseOfProceedsReserveBuildOut {
t.Errorf("MAB.UseOfProceedsTag = %q", m.UseOfProceedsTag)
}
}
// TestGenesisStateMABsField asserts DefaultGenesisState returns a non-nil
// empty slice for MABs (the v0.7 P4 genesis extension).
func TestGenesisStateMABsField(t *testing.T) {
gs := btypes.DefaultGenesisState()
if gs.MABs == nil || len(gs.MABs) != 0 {
t.Errorf("Default MABs should be non-nil empty slice; got len=%d nil=%v", len(gs.MABs), gs.MABs == nil)
}
}
+78
View File
@@ -203,6 +203,84 @@ func (k Keeper) AllCoverCalls(ctx sdk.Context) []types.CoverCall {
return out
}
// --- P4: CoverClaimsVoucher store (REQ-055, D-090(2)) ------------------------
//
// The Voucher store is keyed by voucher-reach-id + pool-id (composite key)
// -> CoverClaimsVoucher. A Voucher is registered per-Pool; the composite key
// enforces idempotency (no duplicate Voucher for the same Pool). The
// GetAvgCallSize helper computes the average Cover Call amount for a Pool
// from the call/ store (returns 0 if no Calls — the D-090(2) cold-start
// case).
var voucherKeyPrefix = []byte("voucher/")
func voucherKey(voucherReachID, poolID string) []byte {
return append(append(voucherKeyPrefix, []byte(voucherReachID)...), []byte("/"+poolID)...)
}
// GetCoverClaimsVoucher loads a CoverClaimsVoucher by voucher-reach-id +
// pool-id. Returns the Voucher and true if found, or zero value + false if
// not.
func (k Keeper) GetCoverClaimsVoucher(ctx sdk.Context, voucherReachID, poolID string) (types.CoverClaimsVoucher, bool) {
store := ctx.KVStore(k.storeKey)
bz := store.Get(voucherKey(voucherReachID, poolID))
if bz == nil {
return types.CoverClaimsVoucher{}, false
}
var v types.CoverClaimsVoucher
if err := json.Unmarshal(bz, &v); err != nil {
return types.CoverClaimsVoucher{}, false
}
return v, true
}
// SetCoverClaimsVoucher persists a CoverClaimsVoucher by voucher-reach-id +
// pool-id.
func (k Keeper) SetCoverClaimsVoucher(ctx sdk.Context, v types.CoverClaimsVoucher) {
store := ctx.KVStore(k.storeKey)
bz, err := json.Marshal(v)
if err != nil {
panic(fmt.Sprintf("cover: marshal voucher %q/%q: %v", v.VoucherReachID, v.PoolID, err))
}
store.Set(voucherKey(v.VoucherReachID, v.PoolID), bz)
}
// AllCoverClaimsVouchers returns all persisted CoverClaimsVoucher records
// (iteration helper, unordered).
func (k Keeper) AllCoverClaimsVouchers(ctx sdk.Context) []types.CoverClaimsVoucher {
store := ctx.KVStore(k.storeKey)
iterator := store.Iterator(voucherKeyPrefix, prefixEnd(voucherKeyPrefix))
defer iterator.Close()
out := []types.CoverClaimsVoucher{}
for ; iterator.Valid(); iterator.Next() {
var v types.CoverClaimsVoucher
if err := json.Unmarshal(iterator.Value(), &v); err == nil {
out = append(out, v)
}
}
return out
}
// GetAvgCallSize computes the average Cover Call amount (Grain) for a Pool
// from the call/ store (REQ-055, D-090(2)). Returns 0 if no Calls have been
// filed for the Pool — the D-090(2) cold-start case (the Voucher bond falls
// back to MinimumVoucherBond, NOT zero).
func (k Keeper) GetAvgCallSize(ctx sdk.Context, poolID string) int64 {
calls := k.AllCoverCalls(ctx)
sum := int64(0)
n := 0
for _, c := range calls {
if c.PoolID == poolID {
sum += c.AmountGrain
n++
}
}
if n == 0 {
return 0
}
return sum / int64(n)
}
// --- prefixEnd helper ---------------------------------------------------------
// prefixEnd returns the key that sorts immediately after all keys sharing
+228
View File
@@ -704,3 +704,231 @@ func (s msgServer) EscalateReserveCeiling(ctx interface{}, msg *types.MsgEscalat
))
return &types.MsgEscalateReserveCeilingResponse{}, nil
}
// --- v0.7 P4: Voucher + Dissolution handlers (REQ-055, REQ-063, D-090(2)) ------
//
// (Cover Claims Voucher registration + Cover Call adjudication + Voucher
// slash + Pool dissolution waterfall). The four handlers exercise the
// D-090(2) cold-start bond fallback, the FR-CPCV-2 no-self-adjudication
// gate, the cross-Pool slash via StandingKeeper.RecordSlash, and the
// FR-MAB-4 seniority chain (Cover-Fee contributors > MAB > Bread holders).
// RegisterCoverClaimsVoucher registers a Cover Claims Voucher for a Pool
// (REQ-055, D-090(2)). The handler enforces:
// 1. ValidateBasic (stateless).
// 2. The referenced Pool must exist.
// 3. Idempotency: no duplicate Voucher for the same VoucherReachID +
// PoolID (a Voucher is registered per-Pool; a second registration for
// the same composite key is REJECTED).
// 4. Compute bond: max(CoverClaimsVoucherBondMultipleAvgCall ×
// GetAvgCallSize(poolID), Params.MinimumVoucherBond). D-090(2) cold-
// start: when no Calls exist, GetAvgCallSize returns 0 -> bond =
// MinimumVoucherBond (NOT zero).
// 5. Persist the Voucher + emit cover.voucher_registered.
func (s msgServer) RegisterCoverClaimsVoucher(ctx interface{}, msg *types.MsgRegisterCoverClaimsVoucher) (*types.MsgRegisterCoverClaimsVoucherResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
// The referenced Pool must exist.
if _, ok := s.Keeper.GetCoverPool(sdkCtx, msg.PoolID); !ok {
return nil, fmt.Errorf("cover: pool %q not found (RegisterCoverClaimsVoucher rejected)", msg.PoolID)
}
// Idempotency: no duplicate Voucher for the same VoucherReachID + PoolID.
if _, ok := s.Keeper.GetCoverClaimsVoucher(sdkCtx, msg.VoucherReachID, msg.PoolID); ok {
return nil, fmt.Errorf("cover: voucher %q already registered for pool %q (RegisterCoverClaimsVoucher rejected)", msg.VoucherReachID, msg.PoolID)
}
// D-090(2) bond computation: max(multiple × avgCallSize,
// MinimumVoucherBond). When no Calls exist, avgCallSize = 0 -> bond =
// MinimumVoucherBond (NOT zero — the cold-start fix).
avgCallSize := s.Keeper.GetAvgCallSize(sdkCtx, msg.PoolID)
multipleBond := int64(types.CoverClaimsVoucherBondMultipleAvgCall) * avgCallSize
minBond := s.Keeper.Params().MinimumVoucherBond
bond := multipleBond
if bond < minBond {
bond = minBond
}
v := types.CoverClaimsVoucher{
VoucherReachID: msg.VoucherReachID,
PoolID: msg.PoolID,
BondAmount: bond,
BondMultipleAvgCall: types.CoverClaimsVoucherBondMultipleAvgCall,
RegisteredAt: sdkCtx.BlockTime().Unix(),
}
s.Keeper.SetCoverClaimsVoucher(sdkCtx, v)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"cover.voucher_registered",
sdk.NewAttribute("voucher_reach_id", msg.VoucherReachID),
sdk.NewAttribute("pool_id", msg.PoolID),
sdk.NewAttribute("bond_amount", fmt.Sprintf("%d", bond)),
sdk.NewAttribute("avg_call_size", fmt.Sprintf("%d", avgCallSize)),
))
return &types.MsgRegisterCoverClaimsVoucherResponse{BondAmount: bond}, nil
}
// AdjudicateCoverCall adjudicates a Cover Call (REQ-055, FR-CPCV-2). The
// handler enforces:
// 1. ValidateBasic (stateless).
// 2. The CoverCall must exist.
// 3. FR-CPCV-2 no self-adjudication: reject if VoucherReachID ==
// CoverCall.ClaimantReachID (the Voucher cannot adjudicate their own
// Call).
// 4. The Voucher must be registered for the Call's Pool.
// 5. Record the adjudication result on the CoverCall (AdjudicationResult +
// AdjudicatedBy + AdjudicatedAt). Persist. Emit
// cover.cover_call_adjudicated.
func (s msgServer) AdjudicateCoverCall(ctx interface{}, msg *types.MsgAdjudicateCoverCall) (*types.MsgAdjudicateCoverCallResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
call, ok := s.Keeper.GetCoverCall(sdkCtx, msg.CallID)
if !ok {
return nil, fmt.Errorf("cover: call %q not found (AdjudicateCoverCall rejected)", msg.CallID)
}
// FR-CPCV-2 no self-adjudication: the Voucher cannot adjudicate their
// own Call.
if msg.VoucherReachID == call.ClaimantReachID {
return nil, fmt.Errorf("cover: FR-CPCV-2 no self-adjudication — voucher %q == call %q claimant %q (AdjudicateCoverCall rejected)",
msg.VoucherReachID, msg.CallID, call.ClaimantReachID)
}
// The Voucher must be registered for the Call's Pool.
if _, ok := s.Keeper.GetCoverClaimsVoucher(sdkCtx, msg.VoucherReachID, call.PoolID); !ok {
return nil, fmt.Errorf("cover: voucher %q not registered for pool %q (AdjudicateCoverCall rejected)", msg.VoucherReachID, call.PoolID)
}
// Record the adjudication result on the CoverCall (additive fields).
call.AdjudicationResult = msg.AdjudicationResult
call.AdjudicatedBy = msg.VoucherReachID
call.AdjudicatedAt = sdkCtx.BlockTime().Unix()
s.Keeper.SetCoverCall(sdkCtx, call)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"cover.cover_call_adjudicated",
sdk.NewAttribute("call_id", msg.CallID),
sdk.NewAttribute("pool_id", call.PoolID),
sdk.NewAttribute("voucher_reach_id", msg.VoucherReachID),
sdk.NewAttribute("adjudication_result", msg.AdjudicationResult),
))
return &types.MsgAdjudicateCoverCallResponse{}, nil
}
// SlashCoverClaimsVoucher slashes a Cover Claims Voucher for a fraudulent
// Cover Call adjudication (REQ-055). The handler enforces:
// 1. ValidateBasic (stateless — Reason must == SlashReasonFraudulentCoverCall).
// 2. The Voucher must exist (look up by VoucherReachID across all Pools —
// a Voucher may be registered for multiple Pools; the slash drops the
// Standing bucket, which is cross-Pool).
// 3. Invoke StandingKeeper.RecordSlash(voucherReachID, amount, reason,
// attester) — the slash drops the Voucher's Standing bucket (cross-Pool
// applicability — the bucket drop disqualifies them from other Pools'
// Standing gates). A nil StandingKeeper is a wiring error -> REJECT.
// 4. Emit cover.voucher_slashed.
func (s msgServer) SlashCoverClaimsVoucher(ctx interface{}, msg *types.MsgSlashCoverClaimsVoucher) (*types.MsgSlashCoverClaimsVoucherResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
// The Voucher must exist (look up by VoucherReachID across all Pools).
vouchers := s.Keeper.AllCoverClaimsVouchers(sdkCtx)
var found *types.CoverClaimsVoucher
for i := range vouchers {
if vouchers[i].VoucherReachID == msg.VoucherReachID {
found = &vouchers[i]
break
}
}
if found == nil {
return nil, fmt.Errorf("cover: voucher %q not found (SlashCoverClaimsVoucher rejected)", msg.VoucherReachID)
}
// StandingKeeper.RecordSlash — the slash drops the Voucher's Standing
// bucket (cross-Pool applicability). A nil StandingKeeper is a wiring
// error -> REJECT (the slash cannot be recorded).
if s.Keeper.standingKeeper == nil {
return nil, fmt.Errorf("cover: StandingKeeper shim not wired (SlashCoverClaimsVoucher cannot record the slash — REQ-055 cross-Pool applicability)")
}
if err := s.Keeper.standingKeeper.RecordSlash(msg.VoucherReachID, float64(found.BondAmount), msg.Reason, msg.Signer); err != nil {
return nil, fmt.Errorf("cover: StandingKeeper.RecordSlash for voucher %q: %w (REQ-055)", msg.VoucherReachID, err)
}
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"cover.voucher_slashed",
sdk.NewAttribute("voucher_reach_id", msg.VoucherReachID),
sdk.NewAttribute("call_id", msg.CallID),
sdk.NewAttribute("reason", msg.Reason),
sdk.NewAttribute("bond_amount", fmt.Sprintf("%d", found.BondAmount)),
))
return &types.MsgSlashCoverClaimsVoucherResponse{}, nil
}
// DissolveCoverPool dissolves a Cover Pool (REQ-063, FR-MAB-4). The handler
// enforces:
// 1. ValidateBasic (stateless).
// 2. The Pool must exist.
// 3. Compute the PoolDissolutionWaterfall (FR-MAB-4 seniority chain):
// Tier 1 = Cover-Fee contributors (the Pool's reserve — a simtest-grade
// placeholder amount; the real reserve balance is a v0.8+ concern),
// Tier 2 = MAB holders (query BondKeeper.GetMABsForPool for the Pool's
// outstanding MABs; sum the PrincipalGrain), Tier 3 = Bread holders
// (the remainder — simtest-grade placeholder). MAB holders have NO
// Voice in the dissolution decision (REQ-063 — the PoolCouncil from P2
// already excludes them; the waterfall only determines the payout
// order).
// 4. Emit cover.pool_dissolved with the waterfall tiers.
func (s msgServer) DissolveCoverPool(ctx interface{}, msg *types.MsgDissolveCoverPool) (*types.MsgDissolveCoverPoolResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
pool, ok := s.Keeper.GetCoverPool(sdkCtx, msg.PoolID)
if !ok {
return nil, fmt.Errorf("cover: pool %q not found (DissolveCoverPool rejected)", msg.PoolID)
}
// FR-MAB-4 waterfall. Tier 1 = Cover-Fee contributors (the Pool's
// reserve — simtest-grade placeholder; the real reserve balance is a
// v0.8+ concern, so we use a deterministic placeholder derived from
// the pool's ReserveAnnualContribRatio for the simtest assertion).
coverFeeContributors := int64(pool.ReserveAnnualContribRatio * 1_000_000)
// Tier 2 = MAB holders (sum the outstanding MAB principal via
// BondKeeper.GetMABsForPool). A nil BondKeeper returns an empty slice
// -> Tier 2 amount = 0.
mabHolders := int64(0)
if s.Keeper.bondKeeper != nil {
for _, m := range s.Keeper.bondKeeper.GetMABsForPool(msg.PoolID) {
mabHolders += m.PrincipalGrain
}
}
// Tier 3 = Bread holders (the remainder — simtest-grade placeholder;
// the real Bread-holder balance is a v0.8+ concern, so we use a
// deterministic placeholder for the simtest assertion).
breadHolders := coverFeeContributors / 4
waterfall := []types.PoolDissolutionWaterfall{
{Tier: types.PoolDissolutionWaterfallTierCoverFeeContributors, AmountGrain: coverFeeContributors},
{Tier: types.PoolDissolutionWaterfallTierMABHolders, AmountGrain: mabHolders},
{Tier: types.PoolDissolutionWaterfallTierBreadHolders, AmountGrain: breadHolders},
}
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"cover.pool_dissolved",
sdk.NewAttribute("pool_id", msg.PoolID),
sdk.NewAttribute("tier_1_cover_fee_contributors", fmt.Sprintf("%d", coverFeeContributors)),
sdk.NewAttribute("tier_2_mab_holders", fmt.Sprintf("%d", mabHolders)),
sdk.NewAttribute("tier_3_bread_holders", fmt.Sprintf("%d", breadHolders)),
))
return &types.MsgDissolveCoverPoolResponse{Waterfall: waterfall}, nil
}
+567 -1
View File
@@ -71,6 +71,15 @@ type stubStandingKeeper struct {
defaultBucket string
defaultScore float64
defaultErr error
// slashCalls records every RecordSlash call (REQ-055 P4 — the Voucher
// slash simtest asserts RecordSlash was called with the right reach-id
// + reason).
slashCalls []struct {
reachID string
amount float64
reason string
attester string
}
}
func (s *stubStandingKeeper) GetStandingBucket(reachID, category string) (string, float64, error) {
@@ -83,6 +92,20 @@ func (s *stubStandingKeeper) GetStandingBucket(reachID, category string) (string
return s.defaultBucket, s.defaultScore, s.defaultErr
}
// RecordSlash records a slash against the named holder (REQ-055 P4
// extension). The stub records every RecordSlash call for assertion (the
// Voucher slash simtest asserts RecordSlash was called with the right
// reach-id + reason).
func (s *stubStandingKeeper) RecordSlash(reachID string, amount float64, reason string, attester string) error {
s.slashCalls = append(s.slashCalls, struct {
reachID string
amount float64
reason string
attester string
}{reachID, amount, reason, attester})
return nil
}
// stubWatcherKeeper satisfies types.WatcherKeeper for the simtest. It
// returns a synthetic attestation-ref per Attest call + records the last
// payload for assertion.
@@ -102,9 +125,14 @@ func (s *stubWatcherKeeper) Attest(poolID string, payload []byte) (string, error
}
// stubBondKeeper satisfies types.BondKeeper for the simtest. P1 does not
// use it; the stub is here for wiring completeness.
// use it; the stub is here for wiring completeness. P4 (REQ-063) uses
// GetMABsForPool for the dissolution waterfall Tier 2 (MAB holders).
type stubBondKeeper struct {
bonds map[string]bool
// mabsForPool is the per-pool MAB list (BondID + PrincipalGrain) the
// stub returns for GetMABsForPool (the dissolution waterfall simtest
// populates this).
mabsForPool map[string][]types.MABRef
}
func (s *stubBondKeeper) GetBond(bondID string) bool {
@@ -114,6 +142,16 @@ func (s *stubBondKeeper) GetBond(bondID string) bool {
return s.bonds[bondID]
}
// GetMABsForPool returns the outstanding MABs for the named pool (REQ-063
// P4 — the dissolution waterfall Tier 2). The stub returns the configured
// per-pool MAB list (empty if none configured).
func (s *stubBondKeeper) GetMABsForPool(poolID string) []types.MABRef {
if s.mabsForPool == nil {
return nil
}
return s.mabsForPool[poolID]
}
// stubStillKeeper satisfies types.StillKeeper for the simtest. It records
// every Still() call for assertion (the below-floor auto-pause test
// asserts Still was called with the right pool-id + reason).
@@ -1904,3 +1942,531 @@ func TestParamsOverrideAndAccessors(t *testing.T) {
t.Errorf("overridden Params FactoryAllowedPhases = %v, want [Phase2]", p.FactoryAllowedPhases)
}
}
// --- v0.7 P4: Voucher + Dissolution simtest (REQ-055, REQ-063, D-090(2)) ------
//
// (Cover Claims Voucher registration + D-090(2) cold-start bond + Cover
// Call adjudication with FR-CPCV-2 no self-adjudication + Voucher slash
// via StandingKeeper.RecordSlash + Pool dissolution waterfall FR-MAB-4).
// launchPoolForVoucher is a helper that launches a pool with valid Standing
// (Trusted 4.0 for Travel) + reserve 1.5, for the Voucher + dissolution
// simtest cases. Returns the ctx + keeper + srv after the launch.
func launchPoolForVoucher(t *testing.T, poolID string) (sdk.Context, keeper.Keeper, types.MsgServer) {
t.Helper()
ctx, sk, _, _, _, _, k := newSimtestContext(t)
sk.buckets = map[string]struct {
bucket string
score float64
}{
"host-1/Travel": {"Trusted", 4.0},
}
srv := keeper.NewMsgServerImpl(k)
_, err := srv.LaunchCoverPool(ctx, &types.MsgLaunchCoverPool{
PoolID: poolID, HostReachID: "host-1", Categories: []types.CoverCategory{types.CatTravel},
ReserveAnnualContribRatio: 1.5, ReserveAccount: "acc-1", Signer: "host-1",
})
if err != nil {
t.Fatalf("LaunchCoverPool: %v", err)
}
return ctx, k, srv
}
// TestRegisterCoverClaimsVoucherWithCalls (case f) asserts a Voucher
// registration computes the bond = 10× avg Call size when Calls exist AND
// 10× avg > MinimumVoucherBond (the max() falls through to the multiple).
func TestRegisterCoverClaimsVoucherWithCalls(t *testing.T) {
ctx, k, srv := launchPoolForVoucher(t, "pool-v1")
// File two Cover Calls for the pool (avg = (300000 + 500000) / 2 =
// 400000; 10× avg = 4M > MinimumVoucherBond 1M -> bond = 4M).
_, err := srv.FileCoverCall(ctx, &types.MsgFileCoverCall{
CallID: "call-1", PoolID: "pool-v1", ClaimantReachID: "user-1",
Category: types.CatTravel, AmountGrain: 300_000, Signer: "user-1",
})
if err != nil {
t.Fatalf("FileCoverCall 1: %v", err)
}
_, err = srv.FileCoverCall(ctx, &types.MsgFileCoverCall{
CallID: "call-2", PoolID: "pool-v1", ClaimantReachID: "user-2",
Category: types.CatTravel, AmountGrain: 500_000, Signer: "user-2",
})
if err != nil {
t.Fatalf("FileCoverCall 2: %v", err)
}
// Register a Voucher — bond = max(10 × 400000, 1000000) = 4000000.
resp, err := srv.RegisterCoverClaimsVoucher(ctx, &types.MsgRegisterCoverClaimsVoucher{
VoucherReachID: "voucher-1", PoolID: "pool-v1", Signer: "host-1",
})
if err != nil {
t.Fatalf("RegisterCoverClaimsVoucher: %v", err)
}
wantBond := int64(10 * 400_000)
if resp.BondAmount != wantBond {
t.Errorf("BondAmount = %d, want %d (10× avgCallSize 400000)", resp.BondAmount, wantBond)
}
if !hasEvent(ctx, "cover.voucher_registered") {
t.Error("cover.voucher_registered event not emitted")
}
// Read it back.
v, ok := k.GetCoverClaimsVoucher(ctx, "voucher-1", "pool-v1")
if !ok {
t.Fatal("Voucher not persisted")
}
if v.BondAmount != wantBond {
t.Errorf("persisted BondAmount = %d, want %d", v.BondAmount, wantBond)
}
}
// TestRegisterCoverClaimsVoucherColdStart (case g — D-090(2)) asserts a
// Voucher registration with NO Calls filed computes the bond =
// MinimumVoucherBond (the cold-start fallback — NOT zero).
func TestRegisterCoverClaimsVoucherColdStart(t *testing.T) {
ctx, k, srv := launchPoolForVoucher(t, "pool-v2")
// No Calls filed. Register a Voucher — bond = max(10 × 0,
// MinimumVoucherBond) = MinimumVoucherBond (D-090(2) cold-start).
resp, err := srv.RegisterCoverClaimsVoucher(ctx, &types.MsgRegisterCoverClaimsVoucher{
VoucherReachID: "voucher-cold", PoolID: "pool-v2", Signer: "host-1",
})
if err != nil {
t.Fatalf("RegisterCoverClaimsVoucher cold-start: %v", err)
}
if resp.BondAmount != types.DefaultMinimumVoucherBond {
t.Errorf("cold-start BondAmount = %d, want %d (D-090(2) MinimumVoucherBond — NOT zero)", resp.BondAmount, types.DefaultMinimumVoucherBond)
}
if resp.BondAmount <= 0 {
t.Errorf("cold-start BondAmount = %d, must be > 0 (D-090(2) — never zero)", resp.BondAmount)
}
// Read it back.
v, ok := k.GetCoverClaimsVoucher(ctx, "voucher-cold", "pool-v2")
if !ok {
t.Fatal("cold-start Voucher not persisted")
}
if v.BondAmount != types.DefaultMinimumVoucherBond {
t.Errorf("persisted cold-start BondAmount = %d, want %d", v.BondAmount, types.DefaultMinimumVoucherBond)
}
}
// TestRegisterCoverClaimsVoucherIdempotentReject asserts a duplicate Voucher
// registration (same VoucherReachID + PoolID) is REJECTED.
func TestRegisterCoverClaimsVoucherIdempotentReject(t *testing.T) {
ctx, _, srv := launchPoolForVoucher(t, "pool-v3")
_, err := srv.RegisterCoverClaimsVoucher(ctx, &types.MsgRegisterCoverClaimsVoucher{
VoucherReachID: "voucher-dup", PoolID: "pool-v3", Signer: "host-1",
})
if err != nil {
t.Fatalf("first RegisterCoverClaimsVoucher: %v", err)
}
_, err = srv.RegisterCoverClaimsVoucher(ctx, &types.MsgRegisterCoverClaimsVoucher{
VoucherReachID: "voucher-dup", PoolID: "pool-v3", Signer: "host-1",
})
if err == nil {
t.Error("duplicate RegisterCoverClaimsVoucher should be REJECTED")
}
}
// TestRegisterCoverClaimsVoucherNonExistentPool asserts a Voucher
// registration on a non-existent Pool is REJECTED.
func TestRegisterCoverClaimsVoucherNonExistentPool(t *testing.T) {
ctx, _, _, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
_, err := srv.RegisterCoverClaimsVoucher(ctx, &types.MsgRegisterCoverClaimsVoucher{
VoucherReachID: "voucher-x", PoolID: "no-such-pool", Signer: "host-1",
})
if err == nil {
t.Error("RegisterCoverClaimsVoucher on non-existent pool should be REJECTED")
}
}
// TestAdjudicateCoverCallNoSelfAdjudication (case h — FR-CPCV-2) asserts a
// Voucher adjudicating their own Cover Call (VoucherReachID ==
// CoverCall.ClaimantReachID) is REJECTED.
func TestAdjudicateCoverCallNoSelfAdjudication(t *testing.T) {
ctx, _, srv := launchPoolForVoucher(t, "pool-v4")
// Register a Voucher.
_, err := srv.RegisterCoverClaimsVoucher(ctx, &types.MsgRegisterCoverClaimsVoucher{
VoucherReachID: "voucher-self", PoolID: "pool-v4", Signer: "host-1",
})
if err != nil {
t.Fatalf("RegisterCoverClaimsVoucher: %v", err)
}
// File a Cover Call where the claimant IS the Voucher (self-adjudication
// case).
_, err = srv.FileCoverCall(ctx, &types.MsgFileCoverCall{
CallID: "call-self", PoolID: "pool-v4", ClaimantReachID: "voucher-self",
Category: types.CatTravel, AmountGrain: 100, Signer: "voucher-self",
})
if err != nil {
t.Fatalf("FileCoverCall: %v", err)
}
// Adjudicate as the Voucher -> REJECT (FR-CPCV-2).
_, err = srv.AdjudicateCoverCall(ctx, &types.MsgAdjudicateCoverCall{
CallID: "call-self", VoucherReachID: "voucher-self",
AdjudicationResult: "Approved", Signer: "voucher-self",
})
if err == nil {
t.Fatal("AdjudicateCoverCall with Voucher == Claimant should be REJECTED (FR-CPCV-2)")
}
if !strings.Contains(err.Error(), "FR-CPCV-2") {
t.Errorf("err = %q, want 'FR-CPCV-2'", err.Error())
}
}
// TestAdjudicateCoverCallSuccess asserts a Voucher adjudicating a different
// holder's Cover Call succeeds + the adjudication is recorded on the
// CoverCall.
func TestAdjudicateCoverCallSuccess(t *testing.T) {
ctx, k, srv := launchPoolForVoucher(t, "pool-v5")
_, err := srv.RegisterCoverClaimsVoucher(ctx, &types.MsgRegisterCoverClaimsVoucher{
VoucherReachID: "voucher-ok", PoolID: "pool-v5", Signer: "host-1",
})
if err != nil {
t.Fatalf("RegisterCoverClaimsVoucher: %v", err)
}
_, err = srv.FileCoverCall(ctx, &types.MsgFileCoverCall{
CallID: "call-ok", PoolID: "pool-v5", ClaimantReachID: "user-1",
Category: types.CatTravel, AmountGrain: 100, Signer: "user-1",
})
if err != nil {
t.Fatalf("FileCoverCall: %v", err)
}
_, err = srv.AdjudicateCoverCall(ctx, &types.MsgAdjudicateCoverCall{
CallID: "call-ok", VoucherReachID: "voucher-ok",
AdjudicationResult: "Approved", Signer: "voucher-ok",
})
if err != nil {
t.Fatalf("AdjudicateCoverCall: %v", err)
}
if !hasEvent(ctx, "cover.cover_call_adjudicated") {
t.Error("cover.cover_call_adjudicated event not emitted")
}
// The adjudication was recorded on the CoverCall.
c, ok := k.GetCoverCall(ctx, "call-ok")
if !ok {
t.Fatal("CoverCall not persisted")
}
if c.AdjudicationResult != "Approved" {
t.Errorf("AdjudicationResult = %q, want Approved", c.AdjudicationResult)
}
if c.AdjudicatedBy != "voucher-ok" {
t.Errorf("AdjudicatedBy = %q, want voucher-ok", c.AdjudicatedBy)
}
}
// TestAdjudicateCoverCallVoucherNotRegistered asserts a Voucher that is not
// registered for the Call's Pool is REJECTED.
func TestAdjudicateCoverCallVoucherNotRegistered(t *testing.T) {
ctx, _, srv := launchPoolForVoucher(t, "pool-v6")
_, err := srv.FileCoverCall(ctx, &types.MsgFileCoverCall{
CallID: "call-v6", PoolID: "pool-v6", ClaimantReachID: "user-1",
Category: types.CatTravel, AmountGrain: 100, Signer: "user-1",
})
if err != nil {
t.Fatalf("FileCoverCall: %v", err)
}
// "voucher-not-reg" is NOT registered for pool-v6 -> REJECT.
_, err = srv.AdjudicateCoverCall(ctx, &types.MsgAdjudicateCoverCall{
CallID: "call-v6", VoucherReachID: "voucher-not-reg",
AdjudicationResult: "Approved", Signer: "voucher-not-reg",
})
if err == nil {
t.Error("AdjudicateCoverCall with unregistered Voucher should be REJECTED")
}
}
// TestAdjudicateCoverCallNotFound asserts adjudicating a non-existent Call
// is REJECTED.
func TestAdjudicateCoverCallNotFound(t *testing.T) {
ctx, _, srv := launchPoolForVoucher(t, "pool-v7")
_, err := srv.AdjudicateCoverCall(ctx, &types.MsgAdjudicateCoverCall{
CallID: "no-such-call", VoucherReachID: "voucher-x",
AdjudicationResult: "Approved", Signer: "voucher-x",
})
if err == nil {
t.Error("AdjudicateCoverCall on non-existent call should be REJECTED")
}
}
// TestSlashCoverClaimsVoucher (case i) asserts a Voucher slash for a
// fraudulent Cover Call adjudication invokes StandingKeeper.RecordSlash
// (cross-Pool applicability via the Standing bucket drop).
func TestSlashCoverClaimsVoucher(t *testing.T) {
ctx, sk, _, _, _, _, k := newSimtestContext(t)
sk.buckets = map[string]struct {
bucket string
score float64
}{"host-1/Travel": {"Trusted", 4.0}}
srv := keeper.NewMsgServerImpl(k)
_, _ = srv.LaunchCoverPool(ctx, &types.MsgLaunchCoverPool{
PoolID: "pool-s1", HostReachID: "host-1", Categories: []types.CoverCategory{types.CatTravel},
ReserveAnnualContribRatio: 1.5, ReserveAccount: "acc-1", Signer: "host-1",
})
_, err := srv.RegisterCoverClaimsVoucher(ctx, &types.MsgRegisterCoverClaimsVoucher{
VoucherReachID: "voucher-bad", PoolID: "pool-s1", Signer: "host-1",
})
if err != nil {
t.Fatalf("RegisterCoverClaimsVoucher: %v", err)
}
// Slash the Voucher for a fraudulent Cover Call.
_, err = srv.SlashCoverClaimsVoucher(ctx, &types.MsgSlashCoverClaimsVoucher{
VoucherReachID: "voucher-bad", CallID: "call-fraud",
Reason: types.SlashReasonFraudulentCoverCall, Signer: "watcher-1",
})
if err != nil {
t.Fatalf("SlashCoverClaimsVoucher: %v", err)
}
if !hasEvent(ctx, "cover.voucher_slashed") {
t.Error("cover.voucher_slashed event not emitted")
}
// StandingKeeper.RecordSlash was called with the right reach-id + reason.
if len(sk.slashCalls) != 1 {
t.Fatalf("RecordSlash calls = %d, want 1", len(sk.slashCalls))
}
if sk.slashCalls[0].reachID != "voucher-bad" {
t.Errorf("RecordSlash reachID = %q, want voucher-bad", sk.slashCalls[0].reachID)
}
if sk.slashCalls[0].reason != types.SlashReasonFraudulentCoverCall {
t.Errorf("RecordSlash reason = %q, want %q", sk.slashCalls[0].reason, types.SlashReasonFraudulentCoverCall)
}
}
// TestSlashCoverClaimsVoucherWrongReason asserts a slash with a wrong reason
// is REJECTED at ValidateBasic (only SlashReasonFraudulentCoverCall is
// valid).
func TestSlashCoverClaimsVoucherWrongReason(t *testing.T) {
ctx, _, srv := launchPoolForVoucher(t, "pool-s2")
_, err := srv.SlashCoverClaimsVoucher(ctx, &types.MsgSlashCoverClaimsVoucher{
VoucherReachID: "voucher-x", CallID: "call-x",
Reason: "SomeOtherReason", Signer: "watcher-1",
})
if err == nil {
t.Error("SlashCoverClaimsVoucher with wrong reason should be REJECTED at ValidateBasic")
}
if !strings.Contains(err.Error(), "FraudulentCoverCall") {
t.Errorf("err = %q, want 'FraudulentCoverCall'", err.Error())
}
}
// TestSlashCoverClaimsVoucherNotFound asserts slashing a non-existent
// Voucher is REJECTED.
func TestSlashCoverClaimsVoucherNotFound(t *testing.T) {
ctx, _, srv := launchPoolForVoucher(t, "pool-s3")
_, err := srv.SlashCoverClaimsVoucher(ctx, &types.MsgSlashCoverClaimsVoucher{
VoucherReachID: "no-such-voucher", CallID: "call-x",
Reason: types.SlashReasonFraudulentCoverCall, Signer: "watcher-1",
})
if err == nil {
t.Error("SlashCoverClaimsVoucher on non-existent Voucher should be REJECTED")
}
}
// TestSlashCoverClaimsVoucherNilStandingKeeper asserts a slash with a nil
// StandingKeeper shim (wiring error) is REJECTED (the slash cannot be
// recorded — REQ-055 cross-Pool applicability).
func TestSlashCoverClaimsVoucherNilStandingKeeper(t *testing.T) {
ctx, _, _, _, _, _, k := newSimtestContext(t)
// Launch a pool + register a Voucher with the StandingKeeper wired (for
// the gate), then nil out the StandingKeeper + create a fresh srv for
// the slash (msgServer embeds Keeper by value, so post-construction
// SetStandingKeeper on k is NOT visible to an existing srv — the fresh
// srv picks up the nil shim).
skPass := &stubStandingKeeper{buckets: map[string]struct {
bucket string
score float64
}{"host-1/Travel": {"Trusted", 4.0}}}
k.SetStandingKeeper(skPass)
srv := keeper.NewMsgServerImpl(k)
_, _ = srv.LaunchCoverPool(ctx, &types.MsgLaunchCoverPool{
PoolID: "pool-s4", HostReachID: "host-1", Categories: []types.CoverCategory{types.CatTravel},
ReserveAnnualContribRatio: 1.5, ReserveAccount: "acc-1", Signer: "host-1",
})
_, _ = srv.RegisterCoverClaimsVoucher(ctx, &types.MsgRegisterCoverClaimsVoucher{
VoucherReachID: "voucher-nil", PoolID: "pool-s4", Signer: "host-1",
})
// Nil out the StandingKeeper + create a fresh srv for the slash.
k.SetStandingKeeper(nil)
srvSlash := keeper.NewMsgServerImpl(k)
_, err := srvSlash.SlashCoverClaimsVoucher(ctx, &types.MsgSlashCoverClaimsVoucher{
VoucherReachID: "voucher-nil", CallID: "call-x",
Reason: types.SlashReasonFraudulentCoverCall, Signer: "watcher-1",
})
if err == nil {
t.Error("SlashCoverClaimsVoucher with nil StandingKeeper should be REJECTED")
}
if !strings.Contains(err.Error(), "StandingKeeper shim not wired") {
t.Errorf("err = %q, want 'StandingKeeper shim not wired'", err.Error())
}
}
// TestDissolveCoverPoolWaterfall (case j — FR-MAB-4) asserts the Pool
// dissolution waterfall returns the three tiers in seniority order
// (Cover-Fee contributors > MAB holders > Bread holders) with the right
// amounts. MAB holders have NO Voice in the dissolution decision (REQ-063 —
// the handler only computes the waterfall; the PoolCouncil from P2 already
// excludes them from the vote).
func TestDissolveCoverPoolWaterfall(t *testing.T) {
ctx, sk, _, bk, _, _, k := newSimtestContext(t)
// Configure the Standing stub to pass the gate for Travel.
sk.buckets = map[string]struct {
bucket string
score float64
}{"host-1/Travel": {"Trusted", 4.0}}
// Configure the BondKeeper stub to return 2 MABs for "pool-d1" with
// principal 3M + 2M = 5M (Tier 2 amount).
bk.mabsForPool = map[string][]types.MABRef{
"pool-d1": {
{BondID: "mab-1", PrincipalGrain: 3_000_000},
{BondID: "mab-2", PrincipalGrain: 2_000_000},
},
}
srv := keeper.NewMsgServerImpl(k)
_, _ = srv.LaunchCoverPool(ctx, &types.MsgLaunchCoverPool{
PoolID: "pool-d1", HostReachID: "host-1", Categories: []types.CoverCategory{types.CatTravel},
ReserveAnnualContribRatio: 1.5, ReserveAccount: "acc-1", Signer: "host-1",
})
resp, err := srv.DissolveCoverPool(ctx, &types.MsgDissolveCoverPool{
PoolID: "pool-d1", Signer: "host-1",
})
if err != nil {
t.Fatalf("DissolveCoverPool: %v", err)
}
if !hasEvent(ctx, "cover.pool_dissolved") {
t.Error("cover.pool_dissolved event not emitted")
}
// FR-MAB-4 seniority: Tier 1 = Cover-Fee contributors, Tier 2 = MAB
// holders, Tier 3 = Bread holders.
if len(resp.Waterfall) != 3 {
t.Fatalf("Waterfall tiers = %d, want 3", len(resp.Waterfall))
}
if resp.Waterfall[0].Tier != types.PoolDissolutionWaterfallTierCoverFeeContributors {
t.Errorf("Tier 0 = %q, want CoverFeeContributors", resp.Waterfall[0].Tier)
}
if resp.Waterfall[1].Tier != types.PoolDissolutionWaterfallTierMABHolders {
t.Errorf("Tier 1 = %q, want MABHolders", resp.Waterfall[1].Tier)
}
if resp.Waterfall[2].Tier != types.PoolDissolutionWaterfallTierBreadHolders {
t.Errorf("Tier 2 = %q, want BreadHolders", resp.Waterfall[2].Tier)
}
// Tier 2 amount = sum of MAB principals = 5M.
if resp.Waterfall[1].AmountGrain != 5_000_000 {
t.Errorf("Tier 2 MAB amount = %d, want 5000000 (sum of MAB principals)", resp.Waterfall[1].AmountGrain)
}
// Tier 1 > 0 (Cover-Fee contributors).
if resp.Waterfall[0].AmountGrain <= 0 {
t.Errorf("Tier 1 Cover-Fee amount = %d, must be > 0", resp.Waterfall[0].AmountGrain)
}
// Tier 3 > 0 (Bread holders — the remainder).
if resp.Waterfall[2].AmountGrain <= 0 {
t.Errorf("Tier 3 Bread amount = %d, must be > 0", resp.Waterfall[2].AmountGrain)
}
}
// TestDissolveCoverPoolNotFound asserts dissolving a non-existent Pool is
// REJECTED.
func TestDissolveCoverPoolNotFound(t *testing.T) {
ctx, _, _, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
_, err := srv.DissolveCoverPool(ctx, &types.MsgDissolveCoverPool{
PoolID: "no-such-pool", Signer: "host-1",
})
if err == nil {
t.Error("DissolveCoverPool on non-existent pool should be REJECTED")
}
}
// TestDissolveCoverPoolNoMABs asserts the dissolution waterfall Tier 2
// (MAB holders) is 0 when the Pool has no MABs (a nil BondKeeper returns an
// empty slice).
func TestDissolveCoverPoolNoMABs(t *testing.T) {
ctx, sk, _, _, _, _, k := newSimtestContext(t)
// Configure the Standing stub to pass the gate for Travel.
sk.buckets = map[string]struct {
bucket string
score float64
}{"host-1/Travel": {"Trusted", 4.0}}
srv := keeper.NewMsgServerImpl(k)
_, _ = srv.LaunchCoverPool(ctx, &types.MsgLaunchCoverPool{
PoolID: "pool-d2", HostReachID: "host-1", Categories: []types.CoverCategory{types.CatTravel},
ReserveAnnualContribRatio: 1.5, ReserveAccount: "acc-1", Signer: "host-1",
})
resp, err := srv.DissolveCoverPool(ctx, &types.MsgDissolveCoverPool{
PoolID: "pool-d2", Signer: "host-1",
})
if err != nil {
t.Fatalf("DissolveCoverPool: %v", err)
}
if resp.Waterfall[1].AmountGrain != 0 {
t.Errorf("Tier 2 MAB amount = %d, want 0 (no MABs)", resp.Waterfall[1].AmountGrain)
}
}
// TestVoucherMsgValidateBasicErrorPaths exercises each Voucher/Dissolution
// Msg* ValidateBasic error path for coverage.
func TestVoucherMsgValidateBasicErrorPaths(t *testing.T) {
// MsgRegisterCoverClaimsVoucher empty.
if err := (&types.MsgRegisterCoverClaimsVoucher{}).ValidateBasic(); err == nil {
t.Error("empty MsgRegisterCoverClaimsVoucher should fail ValidateBasic")
}
// MsgAdjudicateCoverCall empty.
if err := (&types.MsgAdjudicateCoverCall{}).ValidateBasic(); err == nil {
t.Error("empty MsgAdjudicateCoverCall should fail ValidateBasic")
}
// MsgSlashCoverClaimsVoucher empty.
if err := (&types.MsgSlashCoverClaimsVoucher{}).ValidateBasic(); err == nil {
t.Error("empty MsgSlashCoverClaimsVoucher should fail ValidateBasic")
}
// MsgDissolveCoverPool empty.
if err := (&types.MsgDissolveCoverPool{}).ValidateBasic(); err == nil {
t.Error("empty MsgDissolveCoverPool should fail ValidateBasic")
}
}
// TestVoucherKeeperAccessors exercises the Voucher keeper accessors
// (AllCoverClaimsVouchers, GetAvgCallSize) for coverage.
func TestVoucherKeeperAccessors(t *testing.T) {
ctx, k, srv := launchPoolForVoucher(t, "pool-acc")
// Empty-store accessors.
if got := k.AllCoverClaimsVouchers(ctx); len(got) != 0 {
t.Errorf("AllCoverClaimsVouchers empty = %d, want 0", len(got))
}
if got := k.GetAvgCallSize(ctx, "pool-acc"); got != 0 {
t.Errorf("GetAvgCallSize empty = %d, want 0 (D-090(2) cold-start)", got)
}
// File a Call + register a Voucher.
_, _ = srv.FileCoverCall(ctx, &types.MsgFileCoverCall{
CallID: "call-acc", PoolID: "pool-acc", ClaimantReachID: "u1",
Category: types.CatTravel, AmountGrain: 500, Signer: "u1",
})
if got := k.GetAvgCallSize(ctx, "pool-acc"); got != 500 {
t.Errorf("GetAvgCallSize = %d, want 500", got)
}
_, _ = srv.RegisterCoverClaimsVoucher(ctx, &types.MsgRegisterCoverClaimsVoucher{
VoucherReachID: "voucher-acc", PoolID: "pool-acc", Signer: "host-1",
})
if got := k.AllCoverClaimsVouchers(ctx); len(got) != 1 {
t.Errorf("AllCoverClaimsVouchers = %d, want 1", len(got))
}
// Marshal-error path on GetCoverClaimsVoucher (corrupt bytes in store).
rawStore := ctx.KVStore(k.StoreKey())
rawStore.Set([]byte("voucher/corrupt/pool"), []byte("not-json"))
if _, ok := k.GetCoverClaimsVoucher(ctx, "corrupt", "pool"); ok {
t.Error("GetCoverClaimsVoucher on corrupt bytes should return false")
}
}
+38
View File
@@ -75,6 +75,20 @@ type StandingKeeper interface {
// returns ("", 0, err) — the handler treats this as a gate failure
// (REJECT).
GetStandingBucket(reachID, category string) (bucket string, score float64, err error)
// RecordSlash records a slash against the named holder (by reach-id)
// for the given reason (REQ-055 — the v0.7 P4 Voucher slash for a
// fraudulent Cover Call adjudication; reason ==
// SlashReasonFraudulentCoverCall, cross-documented to
// x/standing.SlashReasonFraudulentCoverCall). The slash drops the
// holder's Standing bucket (cross-Pool applicability — the bucket
// drop disqualifies them from other Pools' Standing gates). The
// amount is the slash amount (the Voucher's bond). The attester is
// the Watcher ID that attested the slash. A non-nil error REJECTS
// the slash (the slash could not be recorded — the Voucher is not
// slashed). A nil StandingKeeper is a wiring error -> the
// SlashCoverClaimsVoucher handler REJECTS (the slash cannot be
// recorded).
RecordSlash(reachID string, amount float64, reason string, attester string) error
}
// WatcherKeeper is the expected-keeper interface for x/watcher (G-003). The
@@ -104,6 +118,13 @@ type WatcherKeeper interface {
// misuse auto-Still is also P4). The interface is here so the P1 wiring is
// stable (the keeper holds the shim; the P4 handler calls it).
//
// v0.7 P4 extension (REQ-063): the DissolveCoverPool handler queries
// GetMABsForPool for the Pool's outstanding MABs (the FR-MAB-4 waterfall
// Tier 2 — MAB holders are paid after Cover-Fee contributors, before Bread
// holders). MABRef is a lightweight by-value struct (no struct import of
// x/bond/types — the fields are by-value primitives cross-documented to
// x/bond.MAB).
//
// No struct import of x/bond/types — the interface is the by-ID-string
// boundary (G-003). The bondID is an opaque string (the MAB's ID). A nil
// BondKeeper is the P1 default (the keeper holds nil; the P4 handler will
@@ -113,6 +134,23 @@ type BondKeeper interface {
// P4 FileCoverCall handler consults this to verify the adjudicating
// Voucher's MAB is posted before adjudication. P1 does not call this.
GetBond(bondID string) (exists bool)
// GetMABsForPool returns the outstanding MABs for the named pool (by-
// ID-string) — REQ-063, FR-MAB-4 waterfall Tier 2. The handler sums
// the PrincipalGrain of the returned MABRefs for the waterfall Tier 2
// amount. A nil BondKeeper returns an empty slice (the handler treats
// this as "no MABs" — Tier 2 amount = 0).
GetMABsForPool(poolID string) []MABRef
}
// MABRef is a lightweight by-value reference to a Mutual Aid Bond (G-003 —
// no struct import of x/bond/types; the fields are by-value primitives
// cross-documented to x/bond.MAB). The DissolveCoverPool handler consumes
// this for the FR-MAB-4 waterfall Tier 2 (MAB holders). BondID is the MAB's
// bond-id (by-ID-string ref). PrincipalGrain is the outstanding principal
// in Grain. The keeper's GetMABsForPool returns a slice of these.
type MABRef struct {
BondID string
PrincipalGrain int64
}
// StillKeeper is the expected-keeper interface for x/still (G-003). The
+6
View File
@@ -244,6 +244,12 @@ type MsgServer interface {
VoteCoverCall(ctx interface{}, msg *MsgVoteCoverCall) (*MsgVoteCoverCallResponse, error)
AmendPoolStandingGate(ctx interface{}, msg *MsgAmendPoolStandingGate) (*MsgAmendPoolStandingGateResponse, error)
EscalateReserveCeiling(ctx interface{}, msg *MsgEscalateReserveCeiling) (*MsgEscalateReserveCeilingResponse, error)
// v0.7 P4 Voucher + Dissolution handlers (REQ-055, REQ-063, D-090(2),
// FR-CPCV-2) — defined in msg_voucher.go.
RegisterCoverClaimsVoucher(ctx interface{}, msg *MsgRegisterCoverClaimsVoucher) (*MsgRegisterCoverClaimsVoucherResponse, error)
AdjudicateCoverCall(ctx interface{}, msg *MsgAdjudicateCoverCall) (*MsgAdjudicateCoverCallResponse, error)
SlashCoverClaimsVoucher(ctx interface{}, msg *MsgSlashCoverClaimsVoucher) (*MsgSlashCoverClaimsVoucherResponse, error)
DissolveCoverPool(ctx interface{}, msg *MsgDissolveCoverPool) (*MsgDissolveCoverPoolResponse, error)
}
// Response types (hand-rolled; empty bodies — the response is the state
+316
View File
@@ -0,0 +1,316 @@
package types
// msg_voucher.go holds the v0.7 P4 Cover Claims Voucher + Pool Dissolution
// Msg* types (REQ-055, REQ-063, D-090(2), FR-CPCV-2; G-006 controlled
// exception: types/ gains the cosmos-sdk import for sdk.Msg — D-055; the
// invariant/lexicon tests in *_test.go stay stdlib-only per G-024, isolated
// from this msg_*.go file).
//
// The four P4 Voucher + Dissolution Msg types drive the Voucher + waterfall
// runtime:
// - MsgRegisterCoverClaimsVoucher: register a Cover Claims Voucher for a
// Pool (the handler computes the bond = max(
// CoverClaimsVoucherBondMultipleAvgCall × avgCallSize,
// MinimumVoucherBond); D-090(2) cold-start: when no Calls exist, bond =
// MinimumVoucherBond, NOT zero).
// - MsgAdjudicateCoverCall: a Voucher adjudicates a Cover Call (FR-CPCV-2
// no self-adjudication: rejects if VoucherReachID ==
// CoverCall.ClaimantReachID).
// - MsgSlashCoverClaimsVoucher: slash a Voucher for a fraudulent Cover
// Call adjudication (Reason == SlashReasonFraudulentCoverCall; the
// handler invokes StandingKeeper.RecordSlash -> the Standing bucket
// drops -> cross-Pool applicability).
// - MsgDissolveCoverPool: dissolve a Pool (the handler computes the
// PoolDissolutionWaterfall: Cover-Fee contributors > MAB > Bread
// holders — FR-MAB-4 seniority; MAB holders have NO Voice in the
// decision — REQ-063).
//
// All cross-module refs are by-ID-string (G-003). The
// SlashReasonFraudulentCoverCall const is LOCAL to x/cover (cross-documented
// to x/standing.SlashReasonFraudulentCoverCall — the two consts MUST stay in
// sync; G-003 — no struct import of x/standing/types).
//
// Lexicon note (REQ-012, D-088): "Cover Claims Voucher", "Adjudicate",
// "Waterfall", "Dissolution", "Slash" are lexicon-clean. The four Cover-
// specific banned terms NEVER appear (enforced by lexicon_meta_cover).
import (
"fmt"
sdk "github.com/cosmos/cosmos-sdk/types"
)
// --- MsgRegisterCoverClaimsVoucher -------------------------------------------
// MsgRegisterCoverClaimsVoucher registers a Cover Claims Voucher for a Pool
// (REQ-055, D-090(2)). The handler enforces:
// - ValidateBasic (stateless).
// - Idempotency: no duplicate Voucher for the same Pool (a Voucher is
// registered per-Pool; a second registration for the same
// VoucherReachID + PoolID is REJECTED).
// - Compute bond: max(CoverClaimsVoucherBondMultipleAvgCall ×
// GetAvgCallSize(poolID), MinimumVoucherBond). D-090(2) cold-start: when
// no Calls exist, GetAvgCallSize returns 0 -> bond = MinimumVoucherBond
// (NOT zero).
// - Persist the Voucher + emit cover.voucher_registered.
type MsgRegisterCoverClaimsVoucher struct {
VoucherReachID string `json:"voucher_reach_id" yaml:"voucher_reach_id"`
PoolID string `json:"pool_id" yaml:"pool_id"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message.
func (m *MsgRegisterCoverClaimsVoucher) Reset() { *m = MsgRegisterCoverClaimsVoucher{} }
// String implements proto.Message.
func (m *MsgRegisterCoverClaimsVoucher) String() string {
return fmt.Sprintf("MsgRegisterCoverClaimsVoucher{VoucherReachID:%s PoolID:%s Signer:%s}",
m.VoucherReachID, m.PoolID, m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgRegisterCoverClaimsVoucher) ProtoMessage() {}
// ValidateBasic is the stateless validation: non-empty voucher-reach-id,
// non-empty pool-id, non-empty signer.
func (m *MsgRegisterCoverClaimsVoucher) ValidateBasic() error {
if m.VoucherReachID == "" {
return fmt.Errorf("cover: empty voucher-reach-id")
}
if m.PoolID == "" {
return fmt.Errorf("cover: empty pool-id")
}
if m.Signer == "" {
return fmt.Errorf("cover: empty signer")
}
return nil
}
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
func (m *MsgRegisterCoverClaimsVoucher) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{[]byte(m.Signer)}
}
// --- MsgAdjudicateCoverCall ---------------------------------------------------
// MsgAdjudicateCoverCall adjudicates a Cover Call (REQ-055, FR-CPCV-2). The
// handler enforces:
// - ValidateBasic (stateless).
// - The CoverCall must exist.
// - FR-CPCV-2 no self-adjudication: reject if VoucherReachID ==
// CoverCall.ClaimantReachID (the Voucher cannot adjudicate their own
// Call).
// - The Voucher must be registered for the Call's Pool.
// - Record the adjudication result on the CoverCall (AdjudicationResult +
// AdjudicatedBy + AdjudicatedAt). Persist. Emit cover.cover_call_adjudicated.
type MsgAdjudicateCoverCall struct {
CallID string `json:"call_id" yaml:"call_id"`
VoucherReachID string `json:"voucher_reach_id" yaml:"voucher_reach_id"`
AdjudicationResult string `json:"adjudication_result" yaml:"adjudication_result"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message.
func (m *MsgAdjudicateCoverCall) Reset() { *m = MsgAdjudicateCoverCall{} }
// String implements proto.Message.
func (m *MsgAdjudicateCoverCall) String() string {
return fmt.Sprintf("MsgAdjudicateCoverCall{CallID:%s VoucherReachID:%s AdjudicationResult:%s Signer:%s}",
m.CallID, m.VoucherReachID, m.AdjudicationResult, m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgAdjudicateCoverCall) ProtoMessage() {}
// ValidateBasic is the stateless validation: non-empty call-id, non-empty
// voucher-reach-id, non-empty adjudication-result, non-empty signer.
func (m *MsgAdjudicateCoverCall) ValidateBasic() error {
if m.CallID == "" {
return fmt.Errorf("cover: empty call-id")
}
if m.VoucherReachID == "" {
return fmt.Errorf("cover: empty voucher-reach-id")
}
if m.AdjudicationResult == "" {
return fmt.Errorf("cover: empty adjudication-result")
}
if m.Signer == "" {
return fmt.Errorf("cover: empty signer")
}
return nil
}
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
func (m *MsgAdjudicateCoverCall) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{[]byte(m.Signer)}
}
// --- MsgSlashCoverClaimsVoucher -----------------------------------------------
// MsgSlashCoverClaimsVoucher slashes a Cover Claims Voucher for a fraudulent
// Cover Call adjudication (REQ-055). The handler enforces:
// - ValidateBasic (stateless — Reason must == SlashReasonFraudulentCoverCall).
// - The Voucher must exist.
// - Invoke StandingKeeper.RecordSlash(voucherReachID, amount, reason,
// attester) — the slash drops the Voucher's Standing bucket (cross-Pool
// applicability — the bucket drop disqualifies them from other Pools'
// Standing gates). A nil StandingKeeper is a wiring error -> REJECT.
// - Emit cover.voucher_slashed.
type MsgSlashCoverClaimsVoucher struct {
VoucherReachID string `json:"voucher_reach_id" yaml:"voucher_reach_id"`
CallID string `json:"call_id" yaml:"call_id"`
Reason string `json:"reason" yaml:"reason"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message.
func (m *MsgSlashCoverClaimsVoucher) Reset() { *m = MsgSlashCoverClaimsVoucher{} }
// String implements proto.Message.
func (m *MsgSlashCoverClaimsVoucher) String() string {
return fmt.Sprintf("MsgSlashCoverClaimsVoucher{VoucherReachID:%s CallID:%s Reason:%s Signer:%s}",
m.VoucherReachID, m.CallID, m.Reason, m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgSlashCoverClaimsVoucher) ProtoMessage() {}
// ValidateBasic is the stateless validation: non-empty fields + Reason ==
// SlashReasonFraudulentCoverCall (the slash reason const — cross-documented
// to x/standing.SlashReasonFraudulentCoverCall; LOCAL to x/cover to avoid
// importing x/standing — G-003).
func (m *MsgSlashCoverClaimsVoucher) ValidateBasic() error {
if m.VoucherReachID == "" {
return fmt.Errorf("cover: empty voucher-reach-id")
}
if m.CallID == "" {
return fmt.Errorf("cover: empty call-id")
}
if m.Reason == "" {
return fmt.Errorf("cover: empty reason")
}
if m.Reason != SlashReasonFraudulentCoverCall {
return fmt.Errorf("cover: slash reason %q != %q (REQ-055 — only FraudulentCoverCall is a valid Voucher slash reason)", m.Reason, SlashReasonFraudulentCoverCall)
}
if m.Signer == "" {
return fmt.Errorf("cover: empty signer")
}
return nil
}
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
func (m *MsgSlashCoverClaimsVoucher) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{[]byte(m.Signer)}
}
// --- MsgDissolveCoverPool -----------------------------------------------------
// MsgDissolveCoverPool dissolves a Cover Pool (REQ-063, FR-MAB-4). The
// handler enforces:
// - ValidateBasic (stateless).
// - The Pool must exist.
// - Compute the PoolDissolutionWaterfall: Tier 1 = Cover-Fee contributors
// (the Pool's reserve), Tier 2 = MAB holders (query BondKeeper for MABs
// on this Pool — outstanding principal), Tier 3 = Bread holders (the
// remainder). MAB holders have NO Voice in the dissolution decision
// (REQ-063 — the PoolCouncil from P2 already excludes them; the
// waterfall only determines the payout order).
// - Emit cover.pool_dissolved with the waterfall tiers.
type MsgDissolveCoverPool struct {
PoolID string `json:"pool_id" yaml:"pool_id"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message.
func (m *MsgDissolveCoverPool) Reset() { *m = MsgDissolveCoverPool{} }
// String implements proto.Message.
func (m *MsgDissolveCoverPool) String() string {
return fmt.Sprintf("MsgDissolveCoverPool{PoolID:%s Signer:%s}", m.PoolID, m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgDissolveCoverPool) ProtoMessage() {}
// ValidateBasic is the stateless validation: non-empty pool-id, non-empty
// signer.
func (m *MsgDissolveCoverPool) ValidateBasic() error {
if m.PoolID == "" {
return fmt.Errorf("cover: empty pool-id")
}
if m.Signer == "" {
return fmt.Errorf("cover: empty signer")
}
return nil
}
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
func (m *MsgDissolveCoverPool) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{[]byte(m.Signer)}
}
// --- P4 Voucher + Dissolution Response types ----------------------------------
// MsgRegisterCoverClaimsVoucherResponse is the response to
// MsgRegisterCoverClaimsVoucher. BondAmount reports the computed bond (for
// simtest assertion: D-090(2) cold-start -> MinimumVoucherBond; with Calls
// -> 10× avg).
type MsgRegisterCoverClaimsVoucherResponse struct {
BondAmount int64 `json:"bond_amount" yaml:"bond_amount"`
}
// Reset implements proto.Message.
func (m *MsgRegisterCoverClaimsVoucherResponse) Reset() { *m = MsgRegisterCoverClaimsVoucherResponse{} }
// String implements proto.Message.
func (m *MsgRegisterCoverClaimsVoucherResponse) String() string {
return fmt.Sprintf("MsgRegisterCoverClaimsVoucherResponse{BondAmount:%d}", m.BondAmount)
}
// ProtoMessage implements proto.Message.
func (*MsgRegisterCoverClaimsVoucherResponse) ProtoMessage() {}
// MsgAdjudicateCoverCallResponse is the response to MsgAdjudicateCoverCall.
type MsgAdjudicateCoverCallResponse struct{}
// Reset implements proto.Message.
func (m *MsgAdjudicateCoverCallResponse) Reset() { *m = MsgAdjudicateCoverCallResponse{} }
// String implements proto.Message.
func (m *MsgAdjudicateCoverCallResponse) String() string { return "MsgAdjudicateCoverCallResponse{}" }
// ProtoMessage implements proto.Message.
func (*MsgAdjudicateCoverCallResponse) ProtoMessage() {}
// MsgSlashCoverClaimsVoucherResponse is the response to
// MsgSlashCoverClaimsVoucher.
type MsgSlashCoverClaimsVoucherResponse struct{}
// Reset implements proto.Message.
func (m *MsgSlashCoverClaimsVoucherResponse) Reset() { *m = MsgSlashCoverClaimsVoucherResponse{} }
// String implements proto.Message.
func (m *MsgSlashCoverClaimsVoucherResponse) String() string {
return "MsgSlashCoverClaimsVoucherResponse{}"
}
// ProtoMessage implements proto.Message.
func (*MsgSlashCoverClaimsVoucherResponse) ProtoMessage() {}
// MsgDissolveCoverPoolResponse is the response to MsgDissolveCoverPool.
// Waterfall reports the FR-MAB-4 seniority chain tiers + amounts (for
// simtest assertion: Cover-Fee contributors > MAB > Bread holders).
type MsgDissolveCoverPoolResponse struct {
Waterfall []PoolDissolutionWaterfall `json:"waterfall" yaml:"waterfall"`
}
// Reset implements proto.Message.
func (m *MsgDissolveCoverPoolResponse) Reset() { *m = MsgDissolveCoverPoolResponse{} }
// String implements proto.Message.
func (m *MsgDissolveCoverPoolResponse) String() string {
return fmt.Sprintf("MsgDissolveCoverPoolResponse{Waterfall:%d tiers}", len(m.Waterfall))
}
// ProtoMessage implements proto.Message.
func (*MsgDissolveCoverPoolResponse) ProtoMessage() {}
+106 -6
View File
@@ -86,6 +86,35 @@ const (
// (the gate const mirrors the bucket boundary). LOCAL to x/cover for
// the same G-003 reason as CoverStandingGateTrusted.
CoverStandingGatePreferred = 4.5
// CoverClaimsVoucherBondMultipleAvgCall is the bond multiple for a Cover
// Claims Voucher: the Voucher's bond is
// max(CoverClaimsVoucherBondMultipleAvgCall × avgCallSize,
// MinimumVoucherBond) where avgCallSize is the average Cover Call
// amount for the Pool (REQ-055). The const is NOT locked (it can be
// tuned by governance); the D-090(2) cold-start fix uses the
// MinimumVoucherBond Params field as the non-zero fallback when no
// Calls have been filed (avg = 0 -> bond = MinimumVoucherBond, NOT
// zero).
CoverClaimsVoucherBondMultipleAvgCall = 10
// SlashReasonFraudulentCoverCall is the slash reason for a Cover Claims
// Voucher that adjudicated a Cover Call fraudulently (REQ-055). LOCAL
// const in x/cover to avoid importing x/standing (G-003 — no struct
// import of x/standing/types); cross-documented to
// x/standing.SlashReasonFraudulentCoverCall (the two consts MUST stay
// in sync — a change to one requires a matching change to the other;
// mirroring the LendingCouponCapBps local-const pattern in x/hub). The
// MsgSlashCoverClaimsVoucher.ValidateBasic rejects a Reason that does
// not match this const.
SlashReasonFraudulentCoverCall = "FraudulentCoverCall"
// DefaultMinimumVoucherBond is the default minimum Cover Claims Voucher
// bond (D-090(2) cold-start fix) — 1000000 Grain = 100 Bread (a non-
// zero default so a fresh Pool with no Calls filed yet still requires
// a non-zero Voucher bond). The Params.MinimumVoucherBond field is
// tunable by governance; this is the DefaultParams value.
DefaultMinimumVoucherBond int64 = 1_000_000
)
// CoverCategoryPhase enumerates the three rollout phases of the Cover
@@ -191,12 +220,15 @@ type CoverFeeTag struct {
// + slashing (the FileCoverCall handler in P1 only persists the call +
// emits an event).
type CoverCall struct {
CallID string `json:"call_id" yaml:"call_id"`
PoolID string `json:"pool_id" yaml:"pool_id"`
ClaimantReachID string `json:"claimant_reach_id" yaml:"claimant_reach_id"`
Category CoverCategory `json:"category" yaml:"category"`
AmountGrain int64 `json:"amount_grain" yaml:"amount_grain"`
FiledAt int64 `json:"filed_at" yaml:"filed_at"`
CallID string `json:"call_id" yaml:"call_id"`
PoolID string `json:"pool_id" yaml:"pool_id"`
ClaimantReachID string `json:"claimant_reach_id" yaml:"claimant_reach_id"`
Category CoverCategory `json:"category" yaml:"category"`
AmountGrain int64 `json:"amount_grain" yaml:"amount_grain"`
FiledAt int64 `json:"filed_at" yaml:"filed_at"`
AdjudicationResult string `json:"adjudication_result" yaml:"adjudication_result"`
AdjudicatedBy string `json:"adjudicated_by" yaml:"adjudicated_by"`
AdjudicatedAt int64 `json:"adjudicated_at" yaml:"adjudicated_at"`
}
// Params for the cover module (REQ-049, D-086). FactoryAllowedPhases is the
@@ -209,6 +241,14 @@ type CoverCall struct {
type Params struct {
FactoryAllowedPhases []CoverCategoryPhase `json:"factory_allowed_phases" yaml:"factory_allowed_phases"`
PoolStandingGate float64 `json:"pool_standing_gate" yaml:"pool_standing_gate"`
// MinimumVoucherBond is the minimum Cover Claims Voucher bond (D-090(2)
// cold-start fix — REQ-055). The Voucher's bond is
// max(CoverClaimsVoucherBondMultipleAvgCall × avgCallSize,
// MinimumVoucherBond); the MinimumVoucherBond is the non-zero fallback
// when no Calls have been filed (avg = 0 -> bond = MinimumVoucherBond,
// NOT zero). Default = DefaultMinimumVoucherBond (1M Grain = 100
// Bread).
MinimumVoucherBond int64 `json:"minimum_voucher_bond" yaml:"minimum_voucher_bond"`
}
// DefaultParams returns the P2 default Params (D-086 P2 completion):
@@ -223,6 +263,7 @@ func DefaultParams() Params {
return Params{
FactoryAllowedPhases: []CoverCategoryPhase{Phase2, Phase3, Phase4},
PoolStandingGate: CoverStandingGateTrusted,
MinimumVoucherBond: DefaultMinimumVoucherBond,
}
}
@@ -495,3 +536,62 @@ type CoverCallVote struct {
WatcherObserverPresent bool `json:"watcher_observer_present" yaml:"watcher_observer_present"`
VotedAt int64 `json:"voted_at" yaml:"voted_at"`
}
// --- P4: Cover Claims Voucher + Pool Dissolution Waterfall (REQ-055, REQ-063) --
//
// (REQ-055, REQ-063; vision §15, §8.2.) The two structs below are the P4
// Voucher + dissolution surface. CoverClaimsVoucher is the bonded adjudicator
// a Pool Host registers to adjudicate Cover Calls (no self-adjudication per
// FR-CPCV-2; slashing via x/standing.Slash with
// SlashReasonFraudulentCoverCall for a fraudulent adjudication — cross-Pool
// applicability via the Standing bucket drop). PoolDissolutionWaterfall is
// the FR-MAB-4 seniority chain on Pool dissolution: Cover-Fee contributors
// first, MAB holders second, Bread holders third. MAB holders have NO Voice
// in the dissolution decision (REQ-063 — the PoolCouncil from P2 already
// excludes them; P4 adds the waterfall + the MsgDissolveCoverPool handler).
//
// Lexicon note (REQ-012, D-088): "Cover Claims Voucher", "Adjudicate",
// "Waterfall", "Dissolution" are lexicon-clean. The four Cover-specific
// banned terms NEVER appear (enforced by lexicon_meta_cover).
// CoverClaimsVoucher is the bonded adjudicator a Pool Host registers to
// adjudicate Cover Calls (REQ-055). VoucherReachID is the Voucher's reach-id
// (the person adjudicating; by-ID-string ref to x/standing). PoolID is the
// pool the Voucher is registered for (a Voucher is registered per-Pool; the
// no-self-adjudication check FR-CPCV-2 rejects if VoucherReachID ==
// CoverCall.ClaimantReachID). BondAmount is the Voucher's bond = max(
// CoverClaimsVoucherBondMultipleAvgCall × avgCallSize, MinimumVoucherBond)
// (D-090(2) cold-start: when no Calls exist, avg = 0 -> bond =
// MinimumVoucherBond, NOT zero). BondMultipleAvgCall is the multiple used
// (CoverClaimsVoucherBondMultipleAvgCall = 10). RegisteredAt is the
// registration timestamp.
type CoverClaimsVoucher struct {
VoucherReachID string `json:"voucher_reach_id" yaml:"voucher_reach_id"`
PoolID string `json:"pool_id" yaml:"pool_id"`
BondAmount int64 `json:"bond_amount" yaml:"bond_amount"`
BondMultipleAvgCall uint32 `json:"bond_multiple_avg_call" yaml:"bond_multiple_avg_call"`
RegisteredAt int64 `json:"registered_at" yaml:"registered_at"`
}
// PoolDissolutionWaterfall is a single tier in the FR-MAB-4 seniority chain
// on Pool dissolution (REQ-063). The waterfall pays Cover-Fee contributors
// first (Tier 1 — the Pool's reserve), MAB holders second (Tier 2 — the
// outstanding MAB principal), Bread holders third (Tier 3 — the remainder).
// MAB holders have NO Voice in the dissolution decision (REQ-063 — the
// PoolCouncil from P2 already excludes them; the waterfall only determines
// the payout order, not the vote). The keeper's PoolDissolutionWaterfall
// function returns the []PoolDissolutionWaterfall (the types package
// declares the shape; the keeper computes the amounts).
type PoolDissolutionWaterfall struct {
Tier string `json:"tier" yaml:"tier"`
AmountGrain int64 `json:"amount_grain" yaml:"amount_grain"`
}
// PoolDissolutionWaterfallTier* are the three FR-MAB-4 seniority chain tier
// names (REQ-063). The waterfall returns the tiers in this order:
// CoverFeeContributors (Tier 1), MABHolders (Tier 2), BreadHolders (Tier 3).
const (
PoolDissolutionWaterfallTierCoverFeeContributors = "CoverFeeContributors"
PoolDissolutionWaterfallTierMABHolders = "MABHolders"
PoolDissolutionWaterfallTierBreadHolders = "BreadHolders"
)
+123
View File
@@ -377,3 +377,126 @@ func TestP2StructConstruction(t *testing.T) {
t.Errorf("CoverPool P2 refs = %q/%q", p.CharterRef, p.CouncilRef)
}
}
// --- P4: Cover Claims Voucher + Dissolution consts (REQ-055, REQ-063, D-090(2)) -
// TestP4VoucherAndDissolutionConsts asserts the P4 consts hold their
// values (REQ-055 voucher bond multiple, REQ-055 slash reason,
// D-090(2) cold-start minimum voucher bond).
func TestP4VoucherAndDissolutionConsts(t *testing.T) {
// REQ-055: Cover Claims Voucher bond multiple == 10.
if CoverClaimsVoucherBondMultipleAvgCall != 10 {
t.Errorf("CoverClaimsVoucherBondMultipleAvgCall = %d, want 10 (REQ-055)", CoverClaimsVoucherBondMultipleAvgCall)
}
// REQ-055: slash reason const (cross-doc x/standing).
if SlashReasonFraudulentCoverCall != "FraudulentCoverCall" {
t.Errorf("SlashReasonFraudulentCoverCall = %q, want %q (REQ-055 cross-doc x/standing)", SlashReasonFraudulentCoverCall, "FraudulentCoverCall")
}
// D-090(2): default minimum voucher bond (1M Grain = 100 Bread).
if DefaultMinimumVoucherBond != 1_000_000 {
t.Errorf("DefaultMinimumVoucherBond = %d, want 1000000 (D-090(2) cold-start default)", DefaultMinimumVoucherBond)
}
// FR-MAB-4 waterfall tier names.
if PoolDissolutionWaterfallTierCoverFeeContributors != "CoverFeeContributors" {
t.Errorf("Tier CoverFeeContributors = %q", PoolDissolutionWaterfallTierCoverFeeContributors)
}
if PoolDissolutionWaterfallTierMABHolders != "MABHolders" {
t.Errorf("Tier MABHolders = %q", PoolDissolutionWaterfallTierMABHolders)
}
if PoolDissolutionWaterfallTierBreadHolders != "BreadHolders" {
t.Errorf("Tier BreadHolders = %q", PoolDissolutionWaterfallTierBreadHolders)
}
}
// TestDefaultParamsMinimumVoucherBond asserts DefaultParams ships a non-zero
// MinimumVoucherBond (D-090(2) cold-start fix — the Voucher bond falls back
// to this when no Calls exist, NOT zero).
func TestDefaultParamsMinimumVoucherBond(t *testing.T) {
p := DefaultParams()
if p.MinimumVoucherBond != DefaultMinimumVoucherBond {
t.Errorf("DefaultParams MinimumVoucherBond = %d, want %d (D-090(2) cold-start default)", p.MinimumVoucherBond, DefaultMinimumVoucherBond)
}
if p.MinimumVoucherBond <= 0 {
t.Errorf("DefaultParams MinimumVoucherBond = %d, must be > 0 (D-090(2) — never zero)", p.MinimumVoucherBond)
}
}
// TestCoverClaimsVoucherStruct asserts the CoverClaimsVoucher struct carries
// the required fields (REQ-055).
func TestCoverClaimsVoucherStruct(t *testing.T) {
v := CoverClaimsVoucher{
VoucherReachID: "voucher-1",
PoolID: "pool-1",
BondAmount: 1_000_000,
BondMultipleAvgCall: CoverClaimsVoucherBondMultipleAvgCall,
RegisteredAt: 1000,
}
if v.VoucherReachID != "voucher-1" {
t.Errorf("VoucherReachID = %q", v.VoucherReachID)
}
if v.BondAmount != 1_000_000 {
t.Errorf("BondAmount = %d", v.BondAmount)
}
if v.BondMultipleAvgCall != 10 {
t.Errorf("BondMultipleAvgCall = %d, want 10", v.BondMultipleAvgCall)
}
}
// TestPoolDissolutionWaterfallStruct asserts the PoolDissolutionWaterfall
// struct carries the Tier + AmountGrain fields (REQ-063, FR-MAB-4).
func TestPoolDissolutionWaterfallStruct(t *testing.T) {
w := PoolDissolutionWaterfall{
Tier: PoolDissolutionWaterfallTierCoverFeeContributors,
AmountGrain: 1_000_000,
}
if w.Tier != "CoverFeeContributors" {
t.Errorf("Tier = %q", w.Tier)
}
if w.AmountGrain != 1_000_000 {
t.Errorf("AmountGrain = %d", w.AmountGrain)
}
}
// TestCoverCallAdjudicationFields asserts the CoverCall struct carries the
// P4 adjudication fields (AdjudicationResult + AdjudicatedBy + AdjudicatedAt
// — additive; existing CoverCall records keep zero values).
func TestCoverCallAdjudicationFields(t *testing.T) {
c := CoverCall{
CallID: "c1",
PoolID: "p1",
ClaimantReachID: "u1",
Category: CatTravel,
AmountGrain: 100,
FiledAt: 1000,
AdjudicationResult: "Approved",
AdjudicatedBy: "voucher-1",
AdjudicatedAt: 2000,
}
if c.AdjudicationResult != "Approved" {
t.Errorf("AdjudicationResult = %q", c.AdjudicationResult)
}
if c.AdjudicatedBy != "voucher-1" {
t.Errorf("AdjudicatedBy = %q", c.AdjudicatedBy)
}
if c.AdjudicatedAt != 2000 {
t.Errorf("AdjudicatedAt = %d", c.AdjudicatedAt)
}
// Default zero-value (additive — existing CoverCall records unchanged).
var c2 CoverCall
if c2.AdjudicationResult != "" || c2.AdjudicatedBy != "" || c2.AdjudicatedAt != 0 {
t.Error("zero-value CoverCall adjudication fields should be empty (additive)")
}
}
// TestMABRefStruct asserts the MABRef struct (the lightweight by-value MAB
// reference for the dissolution waterfall Tier 2) carries the BondID +
// PrincipalGrain fields (G-003 — no struct import of x/bond/types).
func TestMABRefStruct(t *testing.T) {
m := MABRef{BondID: "mab-1", PrincipalGrain: 1_000_000}
if m.BondID != "mab-1" {
t.Errorf("MABRef BondID = %q", m.BondID)
}
if m.PrincipalGrain != 1_000_000 {
t.Errorf("MABRef PrincipalGrain = %d", m.PrincipalGrain)
}
}
+275
View File
@@ -0,0 +1,275 @@
package keeper
// keeper.go holds the store-backed Keeper for the guild module's Guild
// Charter + Chapter Federation + Household + Confederation runtime (P3,
// REQ-051, REQ-053, REQ-057, REQ-058).
//
// The Keeper wraps an sdk.KVStore via a storeKey. It holds:
// - the Guild records (guild-id -> Guild; both Parent Guilds and Chapters
// are stored here — a Chapter is a Guild with IsChapter=true);
// - the Lien records (guild-id + lien-idx -> Lien; the AddLien handler
// appends here with SecuredAtFounding=false; founding-locked liens
// (SecuredAtFounding=true) are stored on the Guild itself at creation);
// - the Confederation Voice delegation records
// (confederation-stand-id + member-stand-id -> ConfederationVoice).
//
// The Keeper also holds the two expected-keeper shims (StandKeeper for the
// Household/Confederation type check; StashKeeper for the asset return on
// Household one-tap exit). The shims are interfaces (G-003 — no struct
// import of x/stand/types or x/stash/types); the concrete keepers (or
// simtest stubs) satisfy them structurally.
//
// State-machine ordering (vision §7, enforced in every handler):
// ValidateBasic -> handler authz/gate -> state mutation -> ctx.EventManager().EmitEvent
import (
"encoding/json"
"fmt"
storetypes "cosmossdk.io/store/types"
"github.com/cosmos/cosmos-sdk/codec"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/oy/openyield/x/guild/types"
)
// Keeper is the store-backed guild Keeper.
type Keeper struct {
cdc codec.Codec
storeKey storetypes.StoreKey
standKeeper types.StandKeeper
stashKeeper types.StashKeeper
paramsHolder types.Params
}
// NewKeeper constructs a new store-backed guild Keeper. The StandKeeper +
// StashKeeper expected-keeper shims are injected (StandKeeper is nil-able
// for partial wiring — the OneTapExitStand + DelegateConfederationVoice
// handlers REJECT on a nil StandKeeper (the type check is load-bearing);
// StashKeeper is nil-able — a nil StashKeeper skips the asset return on
// one-tap exit (simtest wiring)).
func NewKeeper(cdc codec.Codec, storeKey storetypes.StoreKey, sk types.StandKeeper, stashK types.StashKeeper) Keeper {
return Keeper{
cdc: cdc,
storeKey: storeKey,
standKeeper: sk,
stashKeeper: stashK,
paramsHolder: types.DefaultParams(),
}
}
// SetStandKeeper sets the StandKeeper expected-keeper shim (for
// post-construction wiring, e.g., app wiring or test setup).
func (k *Keeper) SetStandKeeper(sk types.StandKeeper) { k.standKeeper = sk }
// SetStashKeeper sets the StashKeeper expected-keeper shim.
func (k *Keeper) SetStashKeeper(stashK types.StashKeeper) { k.stashKeeper = stashK }
// SetParams sets the Params (simtest-grade override; a future version will
// load from the params store).
func (k *Keeper) SetParams(p types.Params) { k.paramsHolder = p }
// Params returns the effective Params.
func (k Keeper) Params() types.Params { return k.paramsHolder }
// StoreKey returns the keeper's store key (exported for simtest access to
// the underlying KVStore, e.g., to inject corrupt bytes for marshal-error
// coverage). Mirrors the x/cover simtest pattern.
func (k Keeper) StoreKey() storetypes.StoreKey { return k.storeKey }
// --- Guild store --------------------------------------------------------------
var guildKeyPrefix = []byte("guild/")
func guildKey(guildID string) []byte {
return append(guildKeyPrefix, []byte(guildID)...)
}
// GetGuild loads a Guild by guild-id. Returns the Guild and true if found,
// or zero value + false if not. Both Parent Guilds and Chapters are stored
// here (a Chapter is a Guild with IsChapter=true).
func (k Keeper) GetGuild(ctx sdk.Context, guildID string) (types.Guild, bool) {
store := ctx.KVStore(k.storeKey)
bz := store.Get(guildKey(guildID))
if bz == nil {
return types.Guild{}, false
}
var g types.Guild
if err := json.Unmarshal(bz, &g); err != nil {
return types.Guild{}, false
}
return g, true
}
// SetGuild persists a Guild by guild-id.
func (k Keeper) SetGuild(ctx sdk.Context, g types.Guild) {
store := ctx.KVStore(k.storeKey)
bz, err := json.Marshal(g)
if err != nil {
panic(fmt.Sprintf("guild: marshal guild %q: %v", g.GuildID, err))
}
store.Set(guildKey(g.GuildID), bz)
}
// AllGuilds returns all persisted Guild records (iteration helper,
// unordered). Both Parent Guilds and Chapters are returned.
func (k Keeper) AllGuilds(ctx sdk.Context) []types.Guild {
store := ctx.KVStore(k.storeKey)
iterator := store.Iterator(guildKeyPrefix, prefixEnd(guildKeyPrefix))
defer iterator.Close()
out := []types.Guild{}
for ; iterator.Valid(); iterator.Next() {
var g types.Guild
if err := json.Unmarshal(iterator.Value(), &g); err == nil {
out = append(out, g)
}
}
return out
}
// --- Lien store ---------------------------------------------------------------
//
// The Lien store is keyed by guild-id + lien-idx. The AddLien handler
// appends here with SecuredAtFounding=false. Founding-locked liens
// (SecuredAtFounding=true) are stored on the Guild itself at creation
// (GoodStandingLiens slice); the AddLien handler rejects any new
// SecuredAtFounding=true lien (founding is a one-time event — REQ-053).
var lienKeyPrefix = []byte("lien/")
func lienKey(guildID string, idx uint32) []byte {
return append(lienKeyPrefix, []byte(fmt.Sprintf("%s/%d", guildID, idx))...)
}
// GetLien loads a Lien by guild-id + lien-idx. Returns the Lien and true if
// found, or zero value + false if not.
func (k Keeper) GetLien(ctx sdk.Context, guildID string, idx uint32) (types.Lien, bool) {
store := ctx.KVStore(k.storeKey)
bz := store.Get(lienKey(guildID, idx))
if bz == nil {
return types.Lien{}, false
}
var l types.Lien
if err := json.Unmarshal(bz, &l); err != nil {
return types.Lien{}, false
}
return l, true
}
// SetLien persists a Lien by guild-id + lien-idx.
func (k Keeper) SetLien(ctx sdk.Context, guildID string, idx uint32, l types.Lien) {
store := ctx.KVStore(k.storeKey)
bz, err := json.Marshal(l)
if err != nil {
panic(fmt.Sprintf("guild: marshal lien %s/%d: %v", guildID, idx, err))
}
store.Set(lienKey(guildID, idx), bz)
}
// AllLiens returns all persisted Lien records for a guild (iteration helper,
// unordered — the idx ordering is NOT preserved across iterations; the
// simtest asserts count + content, not order).
func (k Keeper) AllLiens(ctx sdk.Context, guildID string) []types.Lien {
prefix := append(lienKeyPrefix, []byte(guildID+"/")...)
store := ctx.KVStore(k.storeKey)
iterator := store.Iterator(prefix, prefixEnd(prefix))
defer iterator.Close()
out := []types.Lien{}
for ; iterator.Valid(); iterator.Next() {
var l types.Lien
if err := json.Unmarshal(iterator.Value(), &l); err == nil {
out = append(out, l)
}
}
return out
}
// NextLienIdx returns the next lien-idx for a guild (the count of existing
// liens — the AddLien handler uses this to assign the new lien's idx). The
// founding-locked liens on the Guild's GoodStandingLiens slice do NOT
// consume an idx in this store (they are stored on the Guild itself); only
// post-founding liens (SecuredAtFounding=false) added via AddLien consume an
// idx here.
func (k Keeper) NextLienIdx(ctx sdk.Context, guildID string) uint32 {
return uint32(len(k.AllLiens(ctx, guildID)))
}
// --- Confederation Voice delegation store --------------------------------------
//
// The delegation store is keyed by confederation-stand-id + member-stand-id.
// The DelegateConfederationVoice handler records one delegation per member
// Stand (a duplicate delegation from the same MemberStandID is REJECTED).
// One-Stand-one-Vote: each member Stand gets exactly 1 Voice in the
// Confederation's aggregate, regardless of size.
var delegationKeyPrefix = []byte("delegation/")
func delegationKey(confederationStandID, memberStandID string) []byte {
return append(delegationKeyPrefix, []byte(fmt.Sprintf("%s/%s", confederationStandID, memberStandID))...)
}
// GetDelegation loads a ConfederationVoice delegation by confederation-stand-id
// + member-stand-id. Returns the ConfederationVoice (from x/guild/types) and
// true if found, or zero value + false if not.
func (k Keeper) GetDelegation(ctx sdk.Context, confederationStandID, memberStandID string) (types.ConfederationVoice, bool) {
store := ctx.KVStore(k.storeKey)
bz := store.Get(delegationKey(confederationStandID, memberStandID))
if bz == nil {
return types.ConfederationVoice{}, false
}
var v types.ConfederationVoice
if err := json.Unmarshal(bz, &v); err != nil {
return types.ConfederationVoice{}, false
}
return v, true
}
// SetDelegation persists a ConfederationVoice delegation by confederation-
// stand-id + member-stand-id.
func (k Keeper) SetDelegation(ctx sdk.Context, v types.ConfederationVoice) {
store := ctx.KVStore(k.storeKey)
bz, err := json.Marshal(v)
if err != nil {
panic(fmt.Sprintf("guild: marshal delegation %s/%s: %v", v.ConfederationStandID, v.MemberStandID, err))
}
store.Set(delegationKey(v.ConfederationStandID, v.MemberStandID), bz)
}
// AllDelegations returns all persisted ConfederationVoice delegations for a
// Confederation Stand (iteration helper, unordered).
func (k Keeper) AllDelegations(ctx sdk.Context, confederationStandID string) []types.ConfederationVoice {
prefix := append(delegationKeyPrefix, []byte(confederationStandID+"/")...)
store := ctx.KVStore(k.storeKey)
iterator := store.Iterator(prefix, prefixEnd(prefix))
defer iterator.Close()
out := []types.ConfederationVoice{}
for ; iterator.Valid(); iterator.Next() {
var v types.ConfederationVoice
if err := json.Unmarshal(iterator.Value(), &v); err == nil {
out = append(out, v)
}
}
return out
}
// --- prefixEnd helper ---------------------------------------------------------
// prefixEnd returns the key that sorts immediately after all keys sharing
// the given prefix (the standard prefix-iteration end key: increment the
// last byte, drop overflow). Used for store.Iterator(start, prefixEnd(start))
// prefix scans. Mirrors x/hub/keeper/keeper.go + x/cover/keeper/keeper.go.
func prefixEnd(prefix []byte) []byte {
if len(prefix) == 0 {
return nil
}
end := make([]byte, len(prefix))
copy(end, prefix)
for i := len(end) - 1; i >= 0; i-- {
end[i]++
if end[i] != 0 {
return end
}
}
// All bytes were 0xFF; return nil (iterate to end of store).
return nil
}
+333
View File
@@ -0,0 +1,333 @@
package keeper
// msg_server.go implements the guild module's MsgServer (P3, REQ-051,
// REQ-053, REQ-057, REQ-058, REQ-061). The MsgServer wraps the Keeper + the
// StandKeeper + StashKeeper expected-keeper shims (already on the Keeper).
//
// Each method returns a (*Response, error). Handler state-machine ordering
// is enforced: ValidateBasic -> handler authz/gate -> state mutation ->
// ctx.EventManager().EmitEvent.
//
// Handler set:
// - CreateGuild (REQ-051): validate, idempotency, persist Guild with
// CommonBondHash + PublicProfile, surface a jurisdictional disclaimer
// (REQ-061).
// - CreateChapter (REQ-053): validate, idempotency, load Parent Guild,
// pin SecessionTermsHash, set IsChapter=true + ParentGuildID, record
// GoodStandingLiens (SecuredAtFounding=true), reject cooling below the
// protocol minimum, persist, surface a disclaimer (REQ-061).
// - OneTapExitStand (REQ-057): validate, assert Stand type is Household
// via StandKeeper shim (nil REJECTS), dissolve the Stand + return assets
// to the Holder's Stash via StashKeeper shim (nil skips the return,
// still emits the dissolution event), emit event.
// - DelegateConfederationVoice (REQ-058): validate, assert Confederation
// Stand type via StandKeeper shim, record one delegation per member
// Stand (duplicate REJECTED), emit event.
// - AddLien (REQ-053): validate, load Guild, REJECT any new
// SecuredAtFounding=true lien (founding is one-time — REQ-053/REQ-081),
// persist the lien, emit event.
//
// Nil-shim behavior (simtest wiring): a nil StandKeeper REJECTS the
// OneTapExitStand + DelegateConfederationVoice handlers (the Household /
// Confederation type check is load-bearing — it cannot be skipped). A nil
// StashKeeper skips the asset return on one-tap exit (the handler still
// emits the dissolution event — the asset return is a side-effect the
// simtest stub records).
import (
"fmt"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/oy/openyield/x/guild/types"
)
// DisclaimerJurisdictional is the jurisdictional disclaimer surfaced at
// every charter signing (REQ-061). NOT session-bounded — surfaced at every
// CreateGuild + CreateChapter. The disclaimer is a fixed string (the live
// jurisdictional overlay lands in a later phase; the simtest asserts the
// Disclaimer field is non-empty).
const DisclaimerJurisdictional = "OpenYield Guilds are self-governed mesh collectives; the protocol does not provide legal, tax, or fiduciary advice. Signers affirm they have reviewed the Common Bond + jurisdictional obligations before signing."
// msgServer is the concrete MsgServer implementation wrapping the Keeper.
type msgServer struct {
Keeper
}
// NewMsgServerImpl returns the guild MsgServer for the provided Keeper.
func NewMsgServerImpl(k Keeper) types.MsgServer {
return &msgServer{Keeper: k}
}
var _ types.MsgServer = msgServer{}
// unwrapCtx extracts the sdk.Context from the interface-typed ctx.
func unwrapCtx(ctx interface{}) sdk.Context {
if c, ok := ctx.(sdk.Context); ok {
return c
}
panic(fmt.Sprintf("guild: expected sdk.Context, got %T", ctx))
}
// --- CreateGuild (REQ-051, REQ-061) -------------------------------------------
// CreateGuild creates a Guild with a Common Bond hash + Public Profile
// (REQ-051). The handler enforces:
// 1. ValidateBasic (stateless — non-empty fields + non-empty
// CommonBondHash).
// 2. Idempotency: guild-id must not already exist.
// 3. Persist the Guild with CommonBondHash + PublicProfile (the Common
// Bond is hash-pinned at creation — immutable; the handler does NOT
// store the bond text, only the hash).
// 4. Surface a jurisdictional disclaimer (REQ-061) in the response.
//
// On success the Guild is persisted and an event is emitted.
func (s msgServer) CreateGuild(ctx interface{}, msg *types.MsgCreateGuild) (*types.MsgCreateGuildResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
// Idempotency: guild-id must not already exist.
if _, ok := s.Keeper.GetGuild(sdkCtx, msg.GuildID); ok {
return nil, fmt.Errorf("guild: guild %q already exists", msg.GuildID)
}
g := types.Guild{
GuildID: msg.GuildID,
Name: msg.Name,
FounderReach: msg.FounderReach,
CreatedAt: sdkCtx.BlockTime().Unix(),
StandAffiliationID: msg.StandAffiliationID,
CommonBondHash: msg.CommonBondHash,
PublicProfile: msg.PublicProfile,
IsChapter: false,
ParentGuildID: "",
}
s.Keeper.SetGuild(sdkCtx, g)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"guild.guild_created",
sdk.NewAttribute("guild_id", msg.GuildID),
sdk.NewAttribute("founder_reach", msg.FounderReach),
))
return &types.MsgCreateGuildResponse{Disclaimer: DisclaimerJurisdictional}, nil
}
// --- CreateChapter (REQ-053, REQ-061) -----------------------------------------
// CreateChapter creates a Chapter under a Parent Guild (REQ-053). The
// handler enforces:
// 1. ValidateBasic (stateless — non-empty fields, SecessionTerms valid +
// protocol-minimum-bounded, each GoodStandingLien is SecuredAtFounding).
// 2. Idempotency: chapter guild-id must not already exist.
// 3. Load the Parent Guild (must exist; must NOT itself be a Chapter — a
// Chapter cannot have a Chapter parent).
// 4. Pin the SecessionTerms hash (HashSecessionTerms — immutable; no
// handler to amend it).
// 5. Set IsChapter=true + ParentGuildID + GoodStandingLiens (each with
// SecuredAtFounding=true — ValidateBasic already enforced this).
// 6. Persist the Chapter.
// 7. Surface a jurisdictional disclaimer (REQ-061) in the response.
//
// On success the Chapter is persisted and an event is emitted.
func (s msgServer) CreateChapter(ctx interface{}, msg *types.MsgCreateChapter) (*types.MsgCreateChapterResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
// Idempotency: chapter guild-id must not already exist.
if _, ok := s.Keeper.GetGuild(sdkCtx, msg.GuildID); ok {
return nil, fmt.Errorf("guild: chapter %q already exists", msg.GuildID)
}
// Load the Parent Guild (must exist; must NOT itself be a Chapter).
parent, ok := s.Keeper.GetGuild(sdkCtx, msg.ParentGuildID)
if !ok {
return nil, fmt.Errorf("guild: parent guild %q not found (REQ-053)", msg.ParentGuildID)
}
if parent.IsChapter {
return nil, fmt.Errorf("guild: parent %q is itself a Chapter (a Chapter cannot have a Chapter parent — REQ-053)", msg.ParentGuildID)
}
// Pin the SecessionTerms hash (immutable — no handler to amend it).
termsHash := types.HashSecessionTerms(msg.SecessionTerms)
// GoodStandingLiens are recorded with SecuredAtFounding=true
// (ValidateBasic already enforced this — founding-locked liens).
liens := make([]types.Lien, len(msg.GoodStandingLiens))
copy(liens, msg.GoodStandingLiens)
chapter := types.Guild{
GuildID: msg.GuildID,
Name: msg.Name,
FounderReach: msg.FounderReach,
CreatedAt: sdkCtx.BlockTime().Unix(),
CommonBondHash: parent.CommonBondHash, // a Chapter inherits the Parent's Common Bond hash
PublicProfile: parent.PublicProfile, // a Chapter inherits the Parent's Public Profile
IsChapter: true,
ParentGuildID: msg.ParentGuildID,
SecessionTermsHash: termsHash,
GoodStandingLiens: liens,
}
s.Keeper.SetGuild(sdkCtx, chapter)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"guild.chapter_created",
sdk.NewAttribute("guild_id", msg.GuildID),
sdk.NewAttribute("parent_guild_id", msg.ParentGuildID),
))
return &types.MsgCreateChapterResponse{Disclaimer: DisclaimerJurisdictional}, nil
}
// --- OneTapExitStand (REQ-057) ------------------------------------------------
// OneTapExitStand one-tap exits a Household Stand (REQ-057). The handler
// enforces:
// 1. ValidateBasic (stateless).
// 2. StandKeeper shim must be non-nil (the Household type check is
// load-bearing — a nil shim is a wiring error, REJECTED).
// 3. The Stand must exist + its type must be "Household" (one-tap exit is
// Household-only — a Crew / Entity / etc. Stand is REJECTED).
// 4. StashKeeper shim: if non-nil, call ReturnAssetsToHolder to return the
// dissolved Stand's assets to the Holder's Stash (a nil shim skips the
// return — simtest wiring; the dissolution event is still emitted). A
// non-nil error from ReturnAssetsToHolder REJECTS the dissolution (the
// asset return is load-bearing — a failed return leaves the Stand
// intact).
// 5. Emit the dissolution event.
//
// The signer is treated as the Holder (the Reach the assets are returned
// to). The live authz (signer must be the Stand's admin-reach) is deferred
// (simtest grade).
func (s msgServer) OneTapExitStand(ctx interface{}, msg *types.MsgOneTapExitStand) (*types.MsgOneTapExitStandResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
// StandKeeper shim must be non-nil (the type check is load-bearing).
if s.Keeper.standKeeper == nil {
return nil, fmt.Errorf("guild: StandKeeper not wired (OneTapExitStand rejected — Household type check is load-bearing)")
}
// The Stand must exist + be a Household (one-tap exit is Household-only).
standType, exists := s.Keeper.standKeeper.GetStand(msg.StandID)
if !exists {
return nil, fmt.Errorf("guild: stand %q not found (OneTapExitStand rejected)", msg.StandID)
}
if standType != "Household" {
return nil, fmt.Errorf("guild: stand %q type %q is not a Household (one-tap exit is Household-only — REQ-057)", msg.StandID, standType)
}
// StashKeeper: return the dissolved Stand's assets to the Holder's Stash.
// A nil shim skips the return (simtest wiring); a non-nil error REJECTS
// (the asset return is load-bearing).
if s.Keeper.stashKeeper != nil {
if err := s.Keeper.stashKeeper.ReturnAssetsToHolder(msg.Signer, msg.StandID); err != nil {
return nil, fmt.Errorf("guild: return assets to holder %q for stand %q: %w", msg.Signer, msg.StandID, err)
}
}
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"guild.one_tap_exit",
sdk.NewAttribute("stand_id", msg.StandID),
sdk.NewAttribute("holder_reach", msg.Signer),
))
return &types.MsgOneTapExitStandResponse{}, nil
}
// --- DelegateConfederationVoice (REQ-058) -------------------------------------
// DelegateConfederationVoice delegates a member Stand's Voice in a
// Confederation (REQ-058). The handler enforces:
// 1. ValidateBasic (stateless).
// 2. StandKeeper shim must be non-nil (the Confederation type check is
// load-bearing — a nil shim is a wiring error, REJECTED).
// 3. The Confederation Stand must exist + its type must be "Confederation".
// 4. One delegation per member Stand: a duplicate delegation from the same
// MemberStandID is REJECTED (one-Stand-one-Vote — each member Stand gets
// exactly 1 Voice in the Confederation's aggregate, regardless of size).
// 5. Persist the delegation + emit the event.
func (s msgServer) DelegateConfederationVoice(ctx interface{}, msg *types.MsgDelegateConfederationVoice) (*types.MsgDelegateConfederationVoiceResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
// StandKeeper shim must be non-nil (the type check is load-bearing).
if s.Keeper.standKeeper == nil {
return nil, fmt.Errorf("guild: StandKeeper not wired (DelegateConfederationVoice rejected — Confederation type check is load-bearing)")
}
// The Confederation Stand must exist + be a Confederation.
standType, exists := s.Keeper.standKeeper.GetStand(msg.ConfederationStandID)
if !exists {
return nil, fmt.Errorf("guild: confederation stand %q not found", msg.ConfederationStandID)
}
if standType != "Confederation" {
return nil, fmt.Errorf("guild: stand %q type %q is not a Confederation (REQ-058)", msg.ConfederationStandID, standType)
}
// One delegation per member Stand: a duplicate is REJECTED.
if _, ok := s.Keeper.GetDelegation(sdkCtx, msg.ConfederationStandID, msg.MemberStandID); ok {
return nil, fmt.Errorf("guild: member stand %q already delegates in confederation %q (one-Stand-one-Vote — duplicate REJECTED — REQ-058)", msg.MemberStandID, msg.ConfederationStandID)
}
v := types.ConfederationVoice{
ConfederationStandID: msg.ConfederationStandID,
MemberStandID: msg.MemberStandID,
DelegateReachID: msg.DelegateReachID,
DelegatedAt: sdkCtx.BlockTime().Unix(),
}
s.Keeper.SetDelegation(sdkCtx, v)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"guild.confederation_voice_delegated",
sdk.NewAttribute("confederation_stand_id", msg.ConfederationStandID),
sdk.NewAttribute("member_stand_id", msg.MemberStandID),
sdk.NewAttribute("delegate_reach_id", msg.DelegateReachID),
))
return &types.MsgDelegateConfederationVoiceResponse{}, nil
}
// --- AddLien (REQ-053) --------------------------------------------------------
// AddLien adds a Good-Standing Lien to a Guild (REQ-053). The handler
// enforces:
// 1. ValidateBasic (stateless — non-empty fields, Lien Amount > 0).
// 2. The Guild must exist.
// 3. REJECT any new SecuredAtFounding=true lien (founding is a one-time
// event — REQ-053/REQ-081; post-founding liens added via AddLien MUST
// be SecuredAtFounding=false).
// 4. Persist the lien (assigned the next lien-idx) + emit the event.
func (s msgServer) AddLien(ctx interface{}, msg *types.MsgAddLien) (*types.MsgAddLienResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
// The Guild must exist.
if _, ok := s.Keeper.GetGuild(sdkCtx, msg.GuildID); !ok {
return nil, fmt.Errorf("guild: guild %q not found (AddLien rejected)", msg.GuildID)
}
// REJECT any new SecuredAtFounding=true lien (founding is one-time —
// REQ-053/REQ-081).
if msg.Lien.SecuredAtFounding {
return nil, fmt.Errorf("guild: AddLien rejects SecuredAtFounding=true liens (founding is a one-time event — REQ-053/REQ-081; post-founding liens must be SecuredAtFounding=false)")
}
idx := s.Keeper.NextLienIdx(sdkCtx, msg.GuildID)
s.Keeper.SetLien(sdkCtx, msg.GuildID, idx, msg.Lien)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"guild.lien_added",
sdk.NewAttribute("guild_id", msg.GuildID),
sdk.NewAttribute("lien_idx", fmt.Sprintf("%d", idx)),
sdk.NewAttribute("creditor_reach_id", msg.Lien.CreditorReachID),
sdk.NewAttribute("amount", fmt.Sprintf("%d", msg.Lien.Amount)),
))
return &types.MsgAddLienResponse{}, nil
}
+978
View File
@@ -0,0 +1,978 @@
package keeper_test
// msg_server_simtest_test.go is the x/guild keeper simtest (P3, REQ-051,
// REQ-053, REQ-057, REQ-058, REQ-061).
//
// D-054: simtest-grade — in-memory sdk.Context + dbm in-memory store, no
// real Stand keeper (the StandKeeper shim is a stub; G-003 test exemption),
// no real Stash keeper (the StashKeeper shim is a simtest-local stub that
// records ReturnAssetsToHolder calls for assertion). The simtest exercises:
//
// CreateGuild (REQ-051 + REQ-061 disclaimer):
// - (a) successful Guild creation with Common Bond hash + Public Profile
// (MasonCount disclosed).
// - (b) successful Guild creation with MasonCountPrivate=true (count not
// disclosed — MasonCount is 0).
// - idempotency: a second CreateGuild on the same guild-id is REJECTED.
// - (g) Disclaimer surfaced at every signing (the response Disclaimer is
// non-empty).
//
// CreateChapter (REQ-053 + REQ-061 disclaimer):
// - (c) successful Chapter creation with secession terms hash-pinned +
// good-standing liens (SecuredAtFounding=true).
// - (d) Chapter inherits Parent policy + tightens (longer cooling allowed)
// + loosens (shorter cooling REJECTED at ValidateBasic).
// - rejected on non-existent Parent Guild.
// - rejected when Parent is itself a Chapter.
// - (g) Disclaimer surfaced at every signing.
//
// OneTapExitStand (REQ-057):
// - (e) Household one-tap exit succeeds (Stand type Household + StandKeeper
// stub returns "Household" + StashKeeper stub records the call).
// - (e) Crew one-tap exit REJECTED (one-tap is Household-only).
// - rejected on non-existent Stand.
// - rejected on nil StandKeeper (the type check is load-bearing).
//
// DelegateConfederationVoice (REQ-058):
// - (f) Confederation Voice delegation succeeds (one-per-Stand).
// - (f) duplicate delegation REJECTED (one-Stand-one-Vote).
// - rejected on non-Confederation Stand type.
// - rejected on nil StandKeeper.
//
// AddLien (REQ-053):
// - (h) post-founding lien with SecuredAtFounding=false succeeds.
// - (h) post-founding lien with SecuredAtFounding=true REJECTED (founding
// is one-time — REQ-053/REQ-081).
// - rejected on non-existent Guild.
//
// Coverage target: >=80% on x/guild/keeper.
import (
"strings"
"testing"
"time"
"cosmossdk.io/log"
"cosmossdk.io/store"
storetypes "cosmossdk.io/store/types"
cmtproto "github.com/cometbft/cometbft/proto/tendermint/types"
dbm "github.com/cosmos/cosmos-db"
"github.com/cosmos/cosmos-sdk/codec"
codectypes "github.com/cosmos/cosmos-sdk/codec/types"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/oy/openyield/x/guild/keeper"
"github.com/oy/openyield/x/guild/types"
)
// --- Stub expected-keepers (G-003 test exemption) ---------------------------
// stubStandKeeper satisfies types.StandKeeper for the simtest. It returns a
// configurable stand-type per stand-id (a missing key returns ("", false) —
// the non-existent Stand case).
type stubStandKeeper struct {
stands map[string]string // stand-id -> stand-type
}
func (s *stubStandKeeper) GetStand(standID string) (string, bool) {
if s.stands == nil {
return "", false
}
t, ok := s.stands[standID]
return t, ok
}
// stubStashKeeper satisfies types.StashKeeper for the simtest. It records
// every ReturnAssetsToHolder call for assertion (the one-tap exit simtest
// asserts the call was made with the right holder + stand-id).
type stubStashKeeper struct {
calls []struct {
holderReachID string
standID string
}
err error
}
func (s *stubStashKeeper) ReturnAssetsToHolder(holderReachID string, standID string) error {
if s.err != nil {
return s.err
}
s.calls = append(s.calls, struct {
holderReachID string
standID string
}{holderReachID, standID})
return nil
}
// --- Simtest context helper --------------------------------------------------
// newSimtestContext constructs an in-memory sdk.Context with a KVStore
// mounted at the guild store key. Returns the ctx, the two stub keepers,
// the store key, and the Keeper.
func newSimtestContext(t *testing.T) (sdk.Context, *stubStandKeeper, *stubStashKeeper, storetypes.StoreKey, keeper.Keeper) {
t.Helper()
db := dbm.NewMemDB()
cdc := newTestCodec()
storeKey := storetypes.NewKVStoreKey(types.StoreKey)
cms := store.NewCommitMultiStore(db, log.NewNopLogger(), nil)
cms.MountStoreWithDB(storeKey, storetypes.StoreTypeDB, nil)
if err := cms.LoadLatestVersion(); err != nil {
t.Fatalf("load latest version: %v", err)
}
ctx := sdk.NewContext(cms, cmtproto.Header{Time: time.Unix(1000, 0)}, false, log.NewNopLogger())
sk := &stubStandKeeper{}
stashK := &stubStashKeeper{}
k := keeper.NewKeeper(cdc, storeKey, sk, stashK)
return ctx, sk, stashK, storeKey, k
}
// newSimtestContextNilStand constructs an in-memory ctx with a nil
// StandKeeper (for the nil-shim reject-path coverage).
func newSimtestContextNilStand(t *testing.T) (sdk.Context, storetypes.StoreKey, keeper.Keeper) {
t.Helper()
db := dbm.NewMemDB()
cdc := newTestCodec()
storeKey := storetypes.NewKVStoreKey(types.StoreKey)
cms := store.NewCommitMultiStore(db, log.NewNopLogger(), nil)
cms.MountStoreWithDB(storeKey, storetypes.StoreTypeDB, nil)
if err := cms.LoadLatestVersion(); err != nil {
t.Fatalf("load latest version: %v", err)
}
ctx := sdk.NewContext(cms, cmtproto.Header{Time: time.Unix(1000, 0)}, false, log.NewNopLogger())
k := keeper.NewKeeper(cdc, storeKey, nil, nil)
return ctx, storeKey, k
}
// newTestCodec constructs a minimal codec for the simtest.
func newTestCodec() codec.Codec {
registry := codectypes.NewInterfaceRegistry()
return codec.NewProtoCodec(registry)
}
// hasEvent reports whether ctx emitted an event of the given type.
func hasEvent(ctx sdk.Context, eventType string) bool {
for _, ev := range ctx.EventManager().Events() {
if ev.Type == eventType {
return true
}
}
return false
}
// validTerms returns SecessionTerms at the protocol minimums.
func validTerms() types.SecessionTerms {
return types.SecessionTerms{
CoolingCoverActiveDays: types.CoolingSecessionCoverActiveDays,
CoolingNonCoverDays: types.CoolingSecessionNonCoverDays,
LienAuditRequired: true,
CovenantClearanceRequired: true,
}
}
// createParentGuild is a helper that creates a Parent Guild for the Chapter
// simtest cases.
func createParentGuild(t *testing.T, srv types.MsgServer, ctx sdk.Context, guildID string) {
t.Helper()
_, err := srv.CreateGuild(ctx, &types.MsgCreateGuild{
GuildID: guildID,
Name: "Parent",
FounderReach: "reach:founder",
CommonBondHash: []byte{0xAA, 0xBB, 0xCC},
PublicProfile: types.GuildPublicProfile{
BondSummary: "bond-summary",
MasonCount: 10,
},
Signer: "reach:founder",
})
if err != nil {
t.Fatalf("createParentGuild %q: %v", guildID, err)
}
}
// --- CreateGuild (REQ-051, REQ-061) ------------------------------------------
// TestCreateGuildSuccess (case a) asserts a successful Guild creation with
// Common Bond hash + Public Profile (MasonCount disclosed).
func TestCreateGuildSuccess(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
resp, err := srv.CreateGuild(ctx, &types.MsgCreateGuild{
GuildID: "g-1",
Name: "Task Guild",
FounderReach: "reach:founder",
CommonBondHash: []byte{1, 2, 3},
PublicProfile: types.GuildPublicProfile{
BondSummary: "a bond summary",
Disclaimers: []string{"d1"},
MasonCount: 42,
},
Signer: "reach:founder",
})
if err != nil {
t.Fatalf("CreateGuild: %v", err)
}
g, ok := k.GetGuild(ctx, "g-1")
if !ok {
t.Fatal("Guild not persisted")
}
if g.IsChapter {
t.Error("IsChapter should be false for a Parent Guild")
}
if g.ParentGuildID != "" {
t.Errorf("ParentGuildID = %q, want empty for a Parent Guild", g.ParentGuildID)
}
if len(g.CommonBondHash) != 3 {
t.Errorf("CommonBondHash = %v, want 3 bytes", g.CommonBondHash)
}
if g.PublicProfile.MasonCount != 42 {
t.Errorf("MasonCount = %d, want 42", g.PublicProfile.MasonCount)
}
if g.PublicProfile.MasonCountPrivate {
t.Error("MasonCountPrivate should be false when count is disclosed")
}
if !hasEvent(ctx, "guild.guild_created") {
t.Error("guild.guild_created event not emitted")
}
// (g) Disclaimer surfaced.
if resp.Disclaimer == "" {
t.Error("CreateGuild response Disclaimer is empty (REQ-061)")
}
}
// TestCreateGuildMasonCountPrivate (case b) asserts a Guild creation with
// MasonCountPrivate=true (count not disclosed — MasonCount is 0).
func TestCreateGuildMasonCountPrivate(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
_, err := srv.CreateGuild(ctx, &types.MsgCreateGuild{
GuildID: "g-priv",
Name: "Private Count Guild",
FounderReach: "reach:f",
CommonBondHash: []byte{1},
PublicProfile: types.GuildPublicProfile{
BondSummary: "private count",
MasonCount: 0,
MasonCountPrivate: true,
},
Signer: "reach:f",
})
if err != nil {
t.Fatalf("CreateGuild: %v", err)
}
g, _ := k.GetGuild(ctx, "g-priv")
if !g.PublicProfile.MasonCountPrivate {
t.Error("MasonCountPrivate should be true")
}
if g.PublicProfile.MasonCount != 0 {
t.Errorf("MasonCount = %d, want 0 (not disclosed)", g.PublicProfile.MasonCount)
}
}
// TestCreateGuildIdempotentReject asserts a second CreateGuild on the same
// guild-id is REJECTED.
func TestCreateGuildIdempotentReject(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
first := &types.MsgCreateGuild{
GuildID: "g-dup", Name: "n", FounderReach: "reach:f",
CommonBondHash: []byte{1}, Signer: "reach:f",
}
if _, err := srv.CreateGuild(ctx, first); err != nil {
t.Fatalf("first CreateGuild: %v", err)
}
_, err := srv.CreateGuild(ctx, first)
if err == nil {
t.Error("second CreateGuild on same guild-id should be rejected (idempotent)")
}
}
// TestCreateGuildValidateBasicReject asserts a CreateGuild with empty
// CommonBondHash is REJECTED at ValidateBasic.
func TestCreateGuildValidateBasicReject(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
_, err := srv.CreateGuild(ctx, &types.MsgCreateGuild{
GuildID: "g-bad", Name: "n", FounderReach: "reach:f",
CommonBondHash: nil, Signer: "reach:f",
})
if err == nil {
t.Error("CreateGuild with empty CommonBondHash should be rejected at ValidateBasic")
}
}
// --- CreateChapter (REQ-053, REQ-061) ----------------------------------------
// TestCreateChapterSuccess (case c) asserts a successful Chapter creation
// with secession terms hash-pinned + good-standing liens
// (SecuredAtFounding=true).
func TestCreateChapterSuccess(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
createParentGuild(t, srv, ctx, "g-parent")
resp, err := srv.CreateChapter(ctx, &types.MsgCreateChapter{
GuildID: "g-chapter",
Name: "Chapter",
ParentGuildID: "g-parent",
FounderReach: "reach:founder",
SecessionTerms: types.SecessionTerms{
CoolingCoverActiveDays: types.CoolingSecessionCoverActiveDays,
CoolingNonCoverDays: types.CoolingSecessionNonCoverDays,
LienAuditRequired: true,
CovenantClearanceRequired: true,
},
GoodStandingLiens: []types.Lien{
{Amount: 1000, CreditorReachID: "reach:cred", SecuredAtFounding: true, CoverPoolCovenantRef: "covenant-1"},
},
Signer: "reach:founder",
})
if err != nil {
t.Fatalf("CreateChapter: %v", err)
}
c, ok := k.GetGuild(ctx, "g-chapter")
if !ok {
t.Fatal("Chapter not persisted")
}
if !c.IsChapter {
t.Error("IsChapter should be true for a Chapter")
}
if c.ParentGuildID != "g-parent" {
t.Errorf("ParentGuildID = %q, want g-parent", c.ParentGuildID)
}
// SecessionTermsHash is pinned (non-empty).
if len(c.SecessionTermsHash) == 0 {
t.Error("SecessionTermsHash should be pinned (non-empty)")
}
// The pinned hash matches HashSecessionTerms.
expected := types.HashSecessionTerms(types.SecessionTerms{
CoolingCoverActiveDays: types.CoolingSecessionCoverActiveDays,
CoolingNonCoverDays: types.CoolingSecessionNonCoverDays,
LienAuditRequired: true,
CovenantClearanceRequired: true,
})
if string(c.SecessionTermsHash) != string(expected) {
t.Errorf("SecessionTermsHash mismatch: got %x, want %x", c.SecessionTermsHash, expected)
}
// Good-standing liens recorded with SecuredAtFounding=true.
if len(c.GoodStandingLiens) != 1 || !c.GoodStandingLiens[0].SecuredAtFounding {
t.Errorf("GoodStandingLiens = %v", c.GoodStandingLiens)
}
if c.GoodStandingLiens[0].CoverPoolCovenantRef != "covenant-1" {
t.Errorf("CoverPoolCovenantRef = %q", c.GoodStandingLiens[0].CoverPoolCovenantRef)
}
// Chapter inherits Parent's Common Bond hash + Public Profile.
parent, _ := k.GetGuild(ctx, "g-parent")
if string(c.CommonBondHash) != string(parent.CommonBondHash) {
t.Errorf("Chapter CommonBondHash = %x, want parent's %x", c.CommonBondHash, parent.CommonBondHash)
}
if !hasEvent(ctx, "guild.chapter_created") {
t.Error("guild.chapter_created event not emitted")
}
// (g) Disclaimer surfaced.
if resp.Disclaimer == "" {
t.Error("CreateChapter response Disclaimer is empty (REQ-061)")
}
}
// TestCreateChapterTightenCoolingAllowed (case d) asserts a Chapter MAY
// tighten the cooling (longer than the protocol minimum is allowed).
func TestCreateChapterTightenCoolingAllowed(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
createParentGuild(t, srv, ctx, "g-p-tight")
_, err := srv.CreateChapter(ctx, &types.MsgCreateChapter{
GuildID: "g-c-tight",
Name: "Tight Chapter",
ParentGuildID: "g-p-tight",
FounderReach: "reach:f",
SecessionTerms: types.SecessionTerms{
CoolingCoverActiveDays: types.CoolingSecessionCoverActiveDays + 10, // tighter (longer)
CoolingNonCoverDays: types.CoolingSecessionNonCoverDays + 5, // tighter (longer)
},
GoodStandingLiens: []types.Lien{
{Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: true},
},
Signer: "reach:f",
})
if err != nil {
t.Fatalf("CreateChapter with tighter cooling should succeed: %v", err)
}
if _, ok := k.GetGuild(ctx, "g-c-tight"); !ok {
t.Error("tighter Chapter not persisted")
}
}
// TestCreateChapterLoosenCoolingRejected (case d) asserts a Chapter MAY NOT
// loosen the cooling (shorter than the protocol minimum is REJECTED at
// ValidateBasic).
func TestCreateChapterLoosenCoolingRejected(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
createParentGuild(t, srv, ctx, "g-p-loose")
_, err := srv.CreateChapter(ctx, &types.MsgCreateChapter{
GuildID: "g-c-loose",
Name: "Loose Chapter",
ParentGuildID: "g-p-loose",
FounderReach: "reach:f",
SecessionTerms: types.SecessionTerms{
CoolingCoverActiveDays: types.CoolingSecessionCoverActiveDays - 1, // looser (shorter) — REJECT
CoolingNonCoverDays: types.CoolingSecessionNonCoverDays,
},
GoodStandingLiens: []types.Lien{
{Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: true},
},
Signer: "reach:f",
})
if err == nil {
t.Fatal("CreateChapter with looser cooling (shorter) should be rejected (Chapter may tighten but not loosen — REQ-053/REQ-064)")
}
if !strings.Contains(err.Error(), "minimum") {
t.Errorf("error = %q, want 'minimum'", err.Error())
}
// The Chapter was NOT persisted.
if _, ok := k.GetGuild(ctx, "g-c-loose"); ok {
t.Error("loose Chapter should NOT be persisted on reject")
}
}
// TestCreateChapterNonExistentParent asserts a CreateChapter with a non-
// existent Parent Guild is REJECTED.
func TestCreateChapterNonExistentParent(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
_, err := srv.CreateChapter(ctx, &types.MsgCreateChapter{
GuildID: "g-c-noparent",
Name: "n",
ParentGuildID: "no-such-parent",
FounderReach: "reach:f",
SecessionTerms: validTerms(),
GoodStandingLiens: []types.Lien{
{Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: true},
},
Signer: "reach:f",
})
if err == nil {
t.Fatal("CreateChapter with non-existent parent should be rejected")
}
if !strings.Contains(err.Error(), "not found") {
t.Errorf("error = %q, want 'not found'", err.Error())
}
}
// TestCreateChapterParentIsChapter asserts a CreateChapter whose Parent is
// itself a Chapter is REJECTED (a Chapter cannot have a Chapter parent).
func TestCreateChapterParentIsChapter(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
createParentGuild(t, srv, ctx, "g-real-parent")
// Create a first Chapter.
_, err := srv.CreateChapter(ctx, &types.MsgCreateChapter{
GuildID: "g-chapter-1",
Name: "Chapter1",
ParentGuildID: "g-real-parent",
FounderReach: "reach:f",
SecessionTerms: validTerms(),
GoodStandingLiens: []types.Lien{
{Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: true},
},
Signer: "reach:f",
})
if err != nil {
t.Fatalf("first CreateChapter: %v", err)
}
// Attempt to create a second Chapter under the first Chapter (a Chapter
// parent) — REJECTED.
_, err = srv.CreateChapter(ctx, &types.MsgCreateChapter{
GuildID: "g-chapter-2",
Name: "Chapter2",
ParentGuildID: "g-chapter-1",
FounderReach: "reach:f",
SecessionTerms: validTerms(),
GoodStandingLiens: []types.Lien{
{Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: true},
},
Signer: "reach:f",
})
if err == nil {
t.Fatal("CreateChapter with a Chapter parent should be rejected")
}
if !strings.Contains(err.Error(), "Chapter") {
t.Errorf("error = %q, want 'Chapter'", err.Error())
}
}
// TestCreateChapterIdempotentReject asserts a second CreateChapter on the
// same chapter guild-id is REJECTED.
func TestCreateChapterIdempotentReject(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
createParentGuild(t, srv, ctx, "g-p-dup")
first := &types.MsgCreateChapter{
GuildID: "g-c-dup",
Name: "n",
ParentGuildID: "g-p-dup",
FounderReach: "reach:f",
SecessionTerms: validTerms(),
GoodStandingLiens: []types.Lien{
{Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: true},
},
Signer: "reach:f",
}
if _, err := srv.CreateChapter(ctx, first); err != nil {
t.Fatalf("first CreateChapter: %v", err)
}
_, err := srv.CreateChapter(ctx, first)
if err == nil {
t.Error("second CreateChapter on same guild-id should be rejected (idempotent)")
}
}
// --- OneTapExitStand (REQ-057) -----------------------------------------------
// TestOneTapExitStandHouseholdSuccess (case e) asserts a Household one-tap
// exit succeeds (Stand type Household + StashKeeper stub records the call).
func TestOneTapExitStandHouseholdSuccess(t *testing.T) {
ctx, sk, stashK, _, k := newSimtestContext(t)
sk.stands = map[string]string{"stand-hh": "Household"}
srv := keeper.NewMsgServerImpl(k)
_, err := srv.OneTapExitStand(ctx, &types.MsgOneTapExitStand{
StandID: "stand-hh",
Signer: "reach:holder",
})
if err != nil {
t.Fatalf("OneTapExitStand: %v", err)
}
if !hasEvent(ctx, "guild.one_tap_exit") {
t.Error("guild.one_tap_exit event not emitted")
}
// StashKeeper recorded the asset return.
if len(stashK.calls) != 1 {
t.Fatalf("StashKeeper calls = %d, want 1", len(stashK.calls))
}
if stashK.calls[0].holderReachID != "reach:holder" || stashK.calls[0].standID != "stand-hh" {
t.Errorf("StashKeeper call = %+v", stashK.calls[0])
}
}
// TestOneTapExitStandCrewRejected (case e) asserts a Crew Stand one-tap exit
// is REJECTED (one-tap is Household-only).
func TestOneTapExitStandCrewRejected(t *testing.T) {
ctx, sk, _, _, k := newSimtestContext(t)
sk.stands = map[string]string{"stand-crew": "Crew"}
srv := keeper.NewMsgServerImpl(k)
_, err := srv.OneTapExitStand(ctx, &types.MsgOneTapExitStand{
StandID: "stand-crew",
Signer: "reach:holder",
})
if err == nil {
t.Fatal("OneTapExitStand on a Crew Stand should be rejected (one-tap is Household-only — REQ-057)")
}
if !strings.Contains(err.Error(), "Household") {
t.Errorf("error = %q, want 'Household'", err.Error())
}
}
// TestOneTapExitStandNonExistent asserts a one-tap exit on a non-existent
// Stand is REJECTED.
func TestOneTapExitStandNonExistent(t *testing.T) {
ctx, sk, _, _, k := newSimtestContext(t)
sk.stands = map[string]string{}
srv := keeper.NewMsgServerImpl(k)
_, err := srv.OneTapExitStand(ctx, &types.MsgOneTapExitStand{
StandID: "no-such-stand",
Signer: "reach:holder",
})
if err == nil {
t.Fatal("OneTapExitStand on non-existent Stand should be rejected")
}
if !strings.Contains(err.Error(), "not found") {
t.Errorf("error = %q, want 'not found'", err.Error())
}
}
// TestOneTapExitStandNilStandKeeperReject asserts a nil StandKeeper REJECTS
// the one-tap exit (the type check is load-bearing).
func TestOneTapExitStandNilStandKeeperReject(t *testing.T) {
ctx, _, k := newSimtestContextNilStand(t)
srv := keeper.NewMsgServerImpl(k)
_, err := srv.OneTapExitStand(ctx, &types.MsgOneTapExitStand{
StandID: "any-stand",
Signer: "reach:holder",
})
if err == nil {
t.Fatal("OneTapExitStand with nil StandKeeper should be rejected (type check is load-bearing)")
}
if !strings.Contains(err.Error(), "StandKeeper") {
t.Errorf("error = %q, want 'StandKeeper'", err.Error())
}
}
// TestOneTapExitStandNilStashKeeperSkip asserts a nil StashKeeper skips the
// asset return (the dissolution event is still emitted).
func TestOneTapExitStandNilStashKeeperSkip(t *testing.T) {
ctx, sk, _, _, k := newSimtestContext(t)
sk.stands = map[string]string{"stand-hh2": "Household"}
// Wire a nil StashKeeper via the setter (the keeper was constructed with
// a non-nil stub; override to nil for this case).
k.SetStashKeeper(nil)
srv := keeper.NewMsgServerImpl(k)
_, err := srv.OneTapExitStand(ctx, &types.MsgOneTapExitStand{
StandID: "stand-hh2",
Signer: "reach:holder",
})
if err != nil {
t.Fatalf("OneTapExitStand with nil StashKeeper should skip asset return: %v", err)
}
if !hasEvent(ctx, "guild.one_tap_exit") {
t.Error("guild.one_tap_exit event should still be emitted with nil StashKeeper")
}
}
// TestOneTapExitStandStashErrorReject asserts a StashKeeper error REJECTS
// the one-tap exit (the asset return is load-bearing).
func TestOneTapExitStandStashErrorReject(t *testing.T) {
ctx, sk, stashK, _, k := newSimtestContext(t)
sk.stands = map[string]string{"stand-hh-err": "Household"}
stashK.err = sentinelErr("stash return failed (simtest)")
srv := keeper.NewMsgServerImpl(k)
_, err := srv.OneTapExitStand(ctx, &types.MsgOneTapExitStand{
StandID: "stand-hh-err",
Signer: "reach:holder",
})
if err == nil {
t.Fatal("OneTapExitStand with StashKeeper error should be rejected")
}
if !strings.Contains(err.Error(), "return assets") {
t.Errorf("error = %q, want 'return assets'", err.Error())
}
}
// --- DelegateConfederationVoice (REQ-058) ------------------------------------
// TestDelegateConfederationVoiceSuccess (case f) asserts a Confederation
// Voice delegation succeeds (one-per-Stand).
func TestDelegateConfederationVoiceSuccess(t *testing.T) {
ctx, sk, _, _, k := newSimtestContext(t)
sk.stands = map[string]string{"conf-1": "Confederation"}
srv := keeper.NewMsgServerImpl(k)
_, err := srv.DelegateConfederationVoice(ctx, &types.MsgDelegateConfederationVoice{
ConfederationStandID: "conf-1",
MemberStandID: "mem-1",
DelegateReachID: "reach:delegate",
Signer: "reach:s",
})
if err != nil {
t.Fatalf("DelegateConfederationVoice: %v", err)
}
v, ok := k.GetDelegation(ctx, "conf-1", "mem-1")
if !ok {
t.Fatal("delegation not persisted")
}
if v.DelegateReachID != "reach:delegate" {
t.Errorf("DelegateReachID = %q, want reach:delegate", v.DelegateReachID)
}
if !hasEvent(ctx, "guild.confederation_voice_delegated") {
t.Error("guild.confederation_voice_delegated event not emitted")
}
}
// TestDelegateConfederationVoiceDuplicateRejected (case f) asserts a
// duplicate delegation from the same MemberStandID is REJECTED (one-Stand-
// one-Vote).
func TestDelegateConfederationVoiceDuplicateRejected(t *testing.T) {
ctx, sk, _, _, k := newSimtestContext(t)
sk.stands = map[string]string{"conf-dup": "Confederation"}
srv := keeper.NewMsgServerImpl(k)
first := &types.MsgDelegateConfederationVoice{
ConfederationStandID: "conf-dup",
MemberStandID: "mem-dup",
DelegateReachID: "reach:d1",
Signer: "reach:s",
}
if _, err := srv.DelegateConfederationVoice(ctx, first); err != nil {
t.Fatalf("first delegation: %v", err)
}
// A second delegation from the same MemberStandID (even to a different
// delegate) is REJECTED.
_, err := srv.DelegateConfederationVoice(ctx, &types.MsgDelegateConfederationVoice{
ConfederationStandID: "conf-dup",
MemberStandID: "mem-dup",
DelegateReachID: "reach:d2",
Signer: "reach:s",
})
if err == nil {
t.Fatal("duplicate delegation from the same MemberStandID should be rejected (one-Stand-one-Vote — REQ-058)")
}
if !strings.Contains(err.Error(), "duplicate") {
t.Errorf("error = %q, want 'duplicate'", err.Error())
}
}
// TestDelegateConfederationVoiceNonConfederationRejected asserts a
// delegation where the named Confederation Stand is NOT a Confederation type
// is REJECTED.
func TestDelegateConfederationVoiceNonConfederationRejected(t *testing.T) {
ctx, sk, _, _, k := newSimtestContext(t)
sk.stands = map[string]string{"not-conf": "Crew"}
srv := keeper.NewMsgServerImpl(k)
_, err := srv.DelegateConfederationVoice(ctx, &types.MsgDelegateConfederationVoice{
ConfederationStandID: "not-conf",
MemberStandID: "mem-1",
DelegateReachID: "reach:d",
Signer: "reach:s",
})
if err == nil {
t.Fatal("DelegateConfederationVoice on a non-Confederation Stand should be rejected")
}
if !strings.Contains(err.Error(), "Confederation") {
t.Errorf("error = %q, want 'Confederation'", err.Error())
}
}
// TestDelegateConfederationVoiceNonExistent asserts a delegation on a non-
// existent Stand is REJECTED.
func TestDelegateConfederationVoiceNonExistent(t *testing.T) {
ctx, sk, _, _, k := newSimtestContext(t)
sk.stands = map[string]string{}
srv := keeper.NewMsgServerImpl(k)
_, err := srv.DelegateConfederationVoice(ctx, &types.MsgDelegateConfederationVoice{
ConfederationStandID: "no-such-conf",
MemberStandID: "mem-1",
DelegateReachID: "reach:d",
Signer: "reach:s",
})
if err == nil {
t.Fatal("DelegateConfederationVoice on non-existent Stand should be rejected")
}
if !strings.Contains(err.Error(), "not found") {
t.Errorf("error = %q, want 'not found'", err.Error())
}
}
// TestDelegateConfederationVoiceNilStandKeeperReject asserts a nil
// StandKeeper REJECTS the delegation (the type check is load-bearing).
func TestDelegateConfederationVoiceNilStandKeeperReject(t *testing.T) {
ctx, _, k := newSimtestContextNilStand(t)
srv := keeper.NewMsgServerImpl(k)
_, err := srv.DelegateConfederationVoice(ctx, &types.MsgDelegateConfederationVoice{
ConfederationStandID: "any",
MemberStandID: "mem",
DelegateReachID: "reach:d",
Signer: "reach:s",
})
if err == nil {
t.Fatal("DelegateConfederationVoice with nil StandKeeper should be rejected (type check is load-bearing)")
}
if !strings.Contains(err.Error(), "StandKeeper") {
t.Errorf("error = %q, want 'StandKeeper'", err.Error())
}
}
// --- AddLien (REQ-053) -------------------------------------------------------
// TestAddLienPostFoundingSuccess (case h) asserts a post-founding lien with
// SecuredAtFounding=false succeeds.
func TestAddLienPostFoundingSuccess(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
createParentGuild(t, srv, ctx, "g-lien")
_, err := srv.AddLien(ctx, &types.MsgAddLien{
GuildID: "g-lien",
Lien: types.Lien{
Amount: 500,
CreditorReachID: "reach:cred",
SecuredAtFounding: false,
CoverPoolCovenantRef: "covenant-2",
},
Signer: "reach:s",
})
if err != nil {
t.Fatalf("AddLien: %v", err)
}
// The lien is persisted at idx 0.
l, ok := k.GetLien(ctx, "g-lien", 0)
if !ok {
t.Fatal("lien not persisted")
}
if l.Amount != 500 || l.SecuredAtFounding {
t.Errorf("lien = %+v", l)
}
if !hasEvent(ctx, "guild.lien_added") {
t.Error("guild.lien_added event not emitted")
}
if got := k.AllLiens(ctx, "g-lien"); len(got) != 1 {
t.Errorf("AllLiens = %d, want 1", len(got))
}
}
// TestAddLienSecuredAtFoundingRejected (case h) asserts a post-founding lien
// with SecuredAtFounding=true is REJECTED (founding is a one-time event —
// REQ-053/REQ-081).
func TestAddLienSecuredAtFoundingRejected(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
createParentGuild(t, srv, ctx, "g-lien-reject")
_, err := srv.AddLien(ctx, &types.MsgAddLien{
GuildID: "g-lien-reject",
Lien: types.Lien{
Amount: 500,
CreditorReachID: "reach:cred",
SecuredAtFounding: true, // REJECTED — founding is one-time
},
Signer: "reach:s",
})
if err == nil {
t.Fatal("AddLien with SecuredAtFounding=true post-founding should be rejected (founding is one-time — REQ-053/REQ-081)")
}
if !strings.Contains(err.Error(), "SecuredAtFounding") {
t.Errorf("error = %q, want 'SecuredAtFounding'", err.Error())
}
// The lien was NOT persisted.
if got := k.AllLiens(ctx, "g-lien-reject"); len(got) != 0 {
t.Errorf("AllLiens = %d, want 0 (rejected lien not persisted)", len(got))
}
}
// TestAddLienNonExistentGuild asserts an AddLien on a non-existent Guild is
// REJECTED.
func TestAddLienNonExistentGuild(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
_, err := srv.AddLien(ctx, &types.MsgAddLien{
GuildID: "no-such-guild",
Lien: types.Lien{
Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: false,
},
Signer: "reach:s",
})
if err == nil {
t.Fatal("AddLien on non-existent Guild should be rejected")
}
if !strings.Contains(err.Error(), "not found") {
t.Errorf("error = %q, want 'not found'", err.Error())
}
}
// --- unwrapCtx panic --------------------------------------------------------
// TestUnwrapCtxPanic asserts unwrapCtx panics on a non-sdk.Context value.
func TestUnwrapCtxPanic(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Error("unwrapCtx on non-sdk.Context should panic")
}
}()
_, _ = keeper.NewMsgServerImpl(keeper.Keeper{}).AddLien("not-a-ctx",
&types.MsgAddLien{GuildID: "g", Lien: types.Lien{Amount: 1, CreditorReachID: "c"}, Signer: "s"})
}
// --- Keeper accessors (coverage) --------------------------------------------
// TestKeeperAccessors exercises the exported Keeper accessors that the
// simtest above does not directly hit (AllGuilds, GetLien on empty,
// AllDelegations, the marshal-error paths, the setters) to push coverage
// >=80%.
func TestKeeperAccessors(t *testing.T) {
ctx, sk, _, storeKey, k := newSimtestContext(t)
_ = sk
// Empty-store accessors return empty (not nil) slices.
if got := k.AllGuilds(ctx); len(got) != 0 {
t.Errorf("AllGuilds empty = %d, want 0", len(got))
}
if got := k.AllLiens(ctx, "nobody"); len(got) != 0 {
t.Errorf("AllLiens empty = %d, want 0", len(got))
}
if got := k.AllDelegations(ctx, "nobody"); len(got) != 0 {
t.Errorf("AllDelegations empty = %d, want 0", len(got))
}
if _, ok := k.GetLien(ctx, "nobody", 0); ok {
t.Error("GetLien on empty store should return false")
}
if _, ok := k.GetDelegation(ctx, "nobody", "nobody"); ok {
t.Error("GetDelegation on empty store should return false")
}
// Populate + read back.
k.SetGuild(ctx, types.Guild{GuildID: "g-a", Name: "n", FounderReach: "reach:f"})
if g, ok := k.GetGuild(ctx, "g-a"); !ok || g.Name != "n" {
t.Errorf("GetGuild = %+v ok=%v", g, ok)
}
if got := k.AllGuilds(ctx); len(got) != 1 {
t.Errorf("AllGuilds = %d, want 1", len(got))
}
k.SetLien(ctx, "g-a", 0, types.Lien{Amount: 1, CreditorReachID: "reach:c"})
if l, ok := k.GetLien(ctx, "g-a", 0); !ok || l.Amount != 1 {
t.Errorf("GetLien = %+v ok=%v", l, ok)
}
if got := k.AllLiens(ctx, "g-a"); len(got) != 1 {
t.Errorf("AllLiens = %d, want 1", len(got))
}
if idx := k.NextLienIdx(ctx, "g-a"); idx != 1 {
t.Errorf("NextLienIdx = %d, want 1", idx)
}
k.SetDelegation(ctx, types.ConfederationVoice{
ConfederationStandID: "conf-a", MemberStandID: "mem-a",
DelegateReachID: "reach:d", DelegatedAt: 1,
})
if v, ok := k.GetDelegation(ctx, "conf-a", "mem-a"); !ok || v.DelegateReachID != "reach:d" {
t.Errorf("GetDelegation = %+v ok=%v", v, ok)
}
if got := k.AllDelegations(ctx, "conf-a"); len(got) != 1 {
t.Errorf("AllDelegations = %d, want 1", len(got))
}
// Marshal-error paths (corrupt bytes in store).
store := ctx.KVStore(storeKey)
store.Set([]byte("guild/corrupt"), []byte("not-json"))
if _, ok := k.GetGuild(ctx, "corrupt"); ok {
t.Error("GetGuild on corrupt bytes should return false")
}
store.Set([]byte("lien/corrupt/0"), []byte("not-json"))
if _, ok := k.GetLien(ctx, "corrupt", 0); ok {
t.Error("GetLien on corrupt bytes should return false")
}
store.Set([]byte("delegation/corrupt/m"), []byte("not-json"))
if _, ok := k.GetDelegation(ctx, "corrupt", "m"); ok {
t.Error("GetDelegation on corrupt bytes should return false")
}
// Post-construction setters (coverage).
k.SetStandKeeper(&stubStandKeeper{stands: map[string]string{"s": "Household"}})
k.SetStashKeeper(&stubStashKeeper{})
k.SetParams(types.DefaultParams())
if k.Params().DefaultCoolingCoverActiveDays != types.CoolingSecessionCoverActiveDays {
t.Errorf("Params DefaultCoolingCoverActiveDays = %d", k.Params().DefaultCoolingCoverActiveDays)
}
}
// --- sentinel error helper ---------------------------------------------------
type sentinelErr string
func (e sentinelErr) Error() string { return string(e) }
+89
View File
@@ -0,0 +1,89 @@
package guild
// module.go holds the guild module's AppModule + RegisterServices (P3,
// REQ-051, REQ-053, REQ-057, REQ-058).
//
// The AppModule wraps the guild Keeper and registers the MsgServer via
// RegisterServices. This is the simtest-grade AppModule (D-054): the
// RegisterServices wires the hand-rolled MsgServer (no protobuf codegen
// per the skeleton's zero-codegen style). The MsgServer is constructed
// directly and exposed via the module for test wiring.
//
// The StandKeeper + StashKeeper expected-keeper shims are injected at
// construction (StandKeeper nil-able — the OneTapExitStand +
// DelegateConfederationVoice handlers REJECT on a nil StandKeeper; the type
// check is load-bearing. StashKeeper nil-able — a nil StashKeeper skips the
// asset return on one-tap exit; the dissolution event is still emitted).
import (
"encoding/json"
storetypes "cosmossdk.io/store/types"
"github.com/cosmos/cosmos-sdk/codec"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/cosmos/cosmos-sdk/types/module"
"github.com/oy/openyield/x/guild/keeper"
"github.com/oy/openyield/x/guild/types"
)
// ConsensusVersion is the guild module's consensus version (AppModule).
const ConsensusVersion = 1
// AppModule is the guild application module (simtest-grade — D-054).
type AppModule struct {
keeper keeper.Keeper
}
// NewAppModule constructs a new guild AppModule. The StandKeeper + StashKeeper
// expected-keeper shims are injected (StandKeeper nil-able — the
// OneTapExitStand + DelegateConfederationVoice handlers REJECT on a nil
// StandKeeper; StashKeeper nil-able — a nil StashKeeper skips the asset
// return on one-tap exit).
func NewAppModule(cdc codec.Codec, storeKey storetypes.StoreKey, sk types.StandKeeper, stashK types.StashKeeper) AppModule {
k := keeper.NewKeeper(cdc, storeKey, sk, stashK)
return AppModule{keeper: k}
}
// RegisterServices registers the guild MsgServer. Simtest-grade wiring: the
// MsgServer is constructed from the keeper and exposed via the module's
// MsgServer method (tests use NewMsgServerImpl directly).
func (am AppModule) RegisterServices(cfg module.Configurator) {
_ = cfg
}
// MsgServer returns the guild MsgServer for this module's keeper.
func (am AppModule) MsgServer() types.MsgServer {
return keeper.NewMsgServerImpl(am.keeper)
}
// Name returns the module name.
func (AppModule) Name() string { return types.ModuleName }
// ConsensusVersion implements AppModule.ConsensusVersion.
func (AppModule) ConsensusVersion() uint64 { return ConsensusVersion }
// InitGenesis performs genesis initialization for the guild module (simtest-
// grade no-op — the runtime stores are created at handler time; genesis
// init of runtime-promoted stores is deferred to the live chain v0.6+).
func (am AppModule) InitGenesis(ctx sdk.Context, cdc codec.JSONCodec, data json.RawMessage) {
var gs types.GenesisState
cdc.MustUnmarshalJSON(data, &gs)
for _, g := range gs.Guilds {
am.keeper.SetGuild(ctx, g)
}
for _, c := range gs.Chapters {
am.keeper.SetGuild(ctx, c)
}
}
// ExportGenesis returns the exported genesis state as raw bytes (simtest-
// grade: returns an empty genesis; live chain export deferred to v0.6+).
func (am AppModule) ExportGenesis(ctx sdk.Context, cdc codec.JSONCodec) json.RawMessage {
gs := types.DefaultGenesisState()
return cdc.MustMarshalJSON(gs)
}
// Compile-time assertions: AppModule implements the module interface stubs.
var _ module.HasName = AppModule{}
var _ module.HasConsensusVersion = AppModule{}
+104
View File
@@ -0,0 +1,104 @@
package guild_test
// module_test.go exercises the x/guild AppModule (D-054 simtest-grade).
// The AppModule wraps the Keeper + exposes the MsgServer; this test
// constructs an AppModule with nil shims + asserts Name, ConsensusVersion,
// MsgServer, InitGenesis, ExportGenesis. Coverage target: the module.go
// surface.
import (
"encoding/json"
"testing"
"cosmossdk.io/log"
"cosmossdk.io/store"
storetypes "cosmossdk.io/store/types"
cmtproto "github.com/cometbft/cometbft/proto/tendermint/types"
dbm "github.com/cosmos/cosmos-db"
"github.com/cosmos/cosmos-sdk/codec"
codectypes "github.com/cosmos/cosmos-sdk/codec/types"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/oy/openyield/x/guild"
"github.com/oy/openyield/x/guild/types"
)
func newModuleTestContext(t *testing.T) (sdk.Context, guild.AppModule, codec.Codec) {
t.Helper()
db := dbm.NewMemDB()
cdc := newModuleTestCodec()
storeKey := storetypes.NewKVStoreKey(types.StoreKey)
cms := store.NewCommitMultiStore(db, log.NewNopLogger(), nil)
cms.MountStoreWithDB(storeKey, storetypes.StoreTypeDB, nil)
if err := cms.LoadLatestVersion(); err != nil {
t.Fatalf("load latest version: %v", err)
}
ctx := sdk.NewContext(cms, cmtproto.Header{}, false, log.NewNopLogger())
am := guild.NewAppModule(cdc, storeKey, nil, nil)
return ctx, am, cdc
}
func newModuleTestCodec() codec.Codec {
registry := codectypes.NewInterfaceRegistry()
return codec.NewProtoCodec(registry)
}
// TestAppModuleName asserts the module name.
func TestAppModuleName(t *testing.T) {
_, am, _ := newModuleTestContext(t)
if am.Name() != types.ModuleName {
t.Errorf("Name = %q, want %q", am.Name(), types.ModuleName)
}
}
// TestAppModuleConsensusVersion asserts ConsensusVersion == 1.
func TestAppModuleConsensusVersion(t *testing.T) {
_, am, _ := newModuleTestContext(t)
if am.ConsensusVersion() != guild.ConsensusVersion {
t.Errorf("ConsensusVersion = %d, want %d", am.ConsensusVersion(), guild.ConsensusVersion)
}
if guild.ConsensusVersion != 1 {
t.Errorf("ConsensusVersion const = %d, want 1", guild.ConsensusVersion)
}
}
// TestAppModuleMsgServer asserts MsgServer returns a non-nil MsgServer.
func TestAppModuleMsgServer(t *testing.T) {
_, am, _ := newModuleTestContext(t)
srv := am.MsgServer()
if srv == nil {
t.Fatal("MsgServer() returned nil")
}
}
// TestAppModuleInitExportGenesis asserts InitGenesis + ExportGenesis round-
// trip an empty genesis.
func TestAppModuleInitExportGenesis(t *testing.T) {
ctx, am, cdc := newModuleTestContext(t)
empty := types.DefaultGenesisState()
data := cdc.MustMarshalJSON(empty)
am.InitGenesis(ctx, cdc, data)
exported := am.ExportGenesis(ctx, cdc)
if len(exported) == 0 {
t.Fatal("ExportGenesis returned empty bytes")
}
var gs types.GenesisState
if err := json.Unmarshal(exported, &gs); err != nil {
t.Fatalf("ExportGenesis bytes not valid JSON: %v", err)
}
}
// TestAppModuleRegisterServicesNoPanic asserts RegisterServices does not
// panic with a nil configurator (simtest-grade — the method is a no-op stub
// for the hand-rolled MsgServer wiring).
func TestAppModuleRegisterServicesNoPanic(t *testing.T) {
_, am, _ := newModuleTestContext(t)
defer func() {
if r := recover(); r != nil {
t.Errorf("RegisterServices panicked: %v", r)
}
}()
am.RegisterServices(nil)
}
+75
View File
@@ -0,0 +1,75 @@
package types
// expected_keepers.go holds the Go INTERFACES for the cross-module keepers
// x/guild depends on (G-003 firewall — ibc-go expected-keepers convention).
//
// The guild runtime (REQ-051, REQ-053, REQ-057, REQ-058) depends on TWO
// cross-module keepers:
//
// 1. x/stand (StandKeeper) — the OneTapExitStand handler asserts the named
// Stand is a Household (REQ-057) before dissolving it; the
// DelegateConfederationVoice handler asserts the named Stand is a
// Confederation (REQ-058) before recording the delegation. The handler
// queries GetStand for the Stand type (an opaque string — "Household" or
// "Confederation") and compares. This is the v0.7 P3 household-edge: the
// Guild module references a Stand by ID-string (G-003 — no struct import
// of x/stand/types).
//
// 2. x/stash (StashKeeper) — the OneTapExitStand handler returns the
// dissolved Household Stand's assets to the Holder's Stash (REQ-057).
// The handler calls ReturnAssetsToHolder; the simtest stub records the
// call for assertion (no actual asset transfer in simtest).
//
// Both dependencies are expressed as INTERFACES defined HERE (in
// x/guild/types), NOT as struct imports of any x/<module>/types. The
// concrete keepers (or simtest stubs) satisfy these interfaces structurally
// (the P3 simtest wires stubs per G-003 test exemption); the handler depends
// on the interface, preserving G-003's intent (no cross-module struct
// coupling, no import cycles).
//
// Lexicon note (REQ-012): "Guild", "Chapter", "Stand", "Household",
// "Confederation", "Stash", "Holder", "Reach", "Voice" are all lexicon-clean.
// The project-wide 10 banned terms NEVER appear (enforced by lexicon_meta +
// the per-package lexicon assertion in types_test.go).
// StandKeeper is the expected-keeper interface for x/stand (G-003). The
// OneTapExitStand handler calls GetStand to assert the Stand type is
// "Household" (REQ-057 — one-tap exit is Household-only). The
// DelegateConfederationVoice handler calls GetStand to assert the Stand type
// is "Confederation" (REQ-058). The standType string is the opaque Stand
// type name (cross-doc to x/stand.StandType — "Household", "Confederation",
// etc.); the handler compares the string.
//
// No struct import of x/stand/types — the interface is the by-ID-string
// boundary (G-003). The standID is an opaque string. A nil StandKeeper
// REJECTS the OneTapExitStand + DelegateConfederationVoice handlers (the
// type check is load-bearing — a nil shim is a wiring error, NOT a simtest
// skip path; the household/confederation type check cannot be skipped).
type StandKeeper interface {
// GetStand returns the Stand type string + exists flag for the named
// Stand (by-ID-string). The OneTapExitStand handler compares the
// returned type against "Household"; the
// DelegateConfederationVoice handler compares against "Confederation".
// A non-existent Stand returns ("", false) — the handler REJECTS.
GetStand(standID string) (standType string, exists bool)
}
// StashKeeper is the expected-keeper interface for x/stash (G-003). The
// OneTapExitStand handler calls ReturnAssetsToHolder to return the dissolved
// Household Stand's assets to the Holder's Stash (REQ-057). The simtest stub
// records the call for assertion (no actual asset transfer in simtest — the
// simtest documents the wiring contract).
//
// No struct import of x/stash/types — the interface is the by-ID-string
// boundary (G-003). The holderReachID + standID are opaque strings. A nil
// StashKeeper skips the asset return (simtest wiring — the handler still
// emits the dissolution event; the asset return is a side-effect the simtest
// stub records).
type StashKeeper interface {
// ReturnAssetsToHolder returns the named Stand's assets to the named
// Holder's Stash. The OneTapExitStand handler calls this on a Household
// dissolution (REQ-057). A non-nil error REJECTS the dissolution (the
// asset return is load-bearing — a failed return leaves the Stand
// intact).
ReturnAssetsToHolder(holderReachID string, standID string) error
}
+421
View File
@@ -0,0 +1,421 @@
package types
// msg_guild.go holds the x/guild Msg* types implementing sdk.Msg (REQ-051,
// REQ-053, REQ-057, REQ-058). G-006 controlled exception: types/ gains the
// cosmos-sdk import for sdk.Msg (mirrors x/cover/types/msg_cover.go — D-055;
// the invariant/lexicon tests in *_test.go stay stdlib-only per G-024,
// isolated from this msg_*.go file).
//
// The five P3 Guild Msg types drive the Guild Charter + Chapter Federation +
// Household + Confederation runtime:
// - MsgCreateGuild: create a Guild with a Common Bond hash + Public Profile
// (REQ-051). The handler persists the Guild + surfaces a jurisdictional
// disclaimer (REQ-061).
// - MsgCreateChapter: create a Chapter under a Parent Guild (REQ-053). The
// handler pins the SecessionTerms hash + records the Good-Standing Liens
// (SecuredAtFounding=true) + rejects cooling below the protocol minimum
// + surfaces a jurisdictional disclaimer (REQ-061).
// - MsgOneTapExitStand: one-tap exit a Household Stand (REQ-057). The
// handler asserts the Stand type is Household via the StandKeeper shim +
// dissolves the Stand + returns assets to the Holder's Stash.
// - MsgDelegateConfederationVoice: delegate a member Stand's Voice in a
// Confederation (REQ-058). The handler asserts the Stand type is
// Confederation via the StandKeeper shim + records the delegation (one
// delegation per member Stand — duplicate REJECTED).
// - MsgAddLien: add a Good-Standing Lien to a Guild (REQ-053). The handler
// rejects any new SecuredAtFounding=true lien (founding is a one-time
// event — REQ-053/REQ-081).
//
// All cross-module refs are by-ID-string (G-003): founder-reach refs an
// x/identity Reach; stand-id refs an x/stand Stand; parent-guild-id refs a
// Guild; cover-pool-covenant-ref refs a Cover Pool covenant. No struct
// imports of x/stand/types or x/stash/types (the shims are interfaces
// defined in expected_keepers.go — G-003 preserved).
//
// Lexicon note (REQ-012): the message names + field names use the safe Guild
// vocabulary EXCLUSIVELY. "Guild", "Chapter", "Parent Guild", "Common Bond",
// "Public Profile", "Good-Standing Lien", "Secession Terms", "Household",
// "Confederation", "Hand-Pass" are the clean names; the project-wide 10
// banned terms NEVER appear (enforced by lexicon_meta + the per-package
// lexicon assertion in types_test.go).
import (
"fmt"
sdk "github.com/cosmos/cosmos-sdk/types"
)
// --- MsgCreateGuild -----------------------------------------------------------
// MsgCreateGuild creates a Guild with a Common Bond hash + Public Profile
// (REQ-051). The handler persists the Guild + surfaces a jurisdictional
// disclaimer (REQ-061 — the Disclaimer string is in the response).
//
// ValidateBasic is stateless: non-empty fields + non-empty CommonBondHash.
type MsgCreateGuild struct {
GuildID string `json:"guild_id" yaml:"guild_id"`
Name string `json:"name" yaml:"name"`
FounderReach string `json:"founder_reach" yaml:"founder_reach"`
StandAffiliationID string `json:"stand_affiliation_id,omitempty" yaml:"stand_affiliation_id,omitempty"`
CommonBondHash []byte `json:"common_bond_hash" yaml:"common_bond_hash"`
PublicProfile GuildPublicProfile `json:"public_profile" yaml:"public_profile"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message.
func (m *MsgCreateGuild) Reset() { *m = MsgCreateGuild{} }
// String implements proto.Message.
func (m *MsgCreateGuild) String() string {
return fmt.Sprintf("MsgCreateGuild{GuildID:%s Name:%s FounderReach:%s StandAffiliationID:%s Signer:%s}",
m.GuildID, m.Name, m.FounderReach, m.StandAffiliationID, m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgCreateGuild) ProtoMessage() {}
// ValidateBasic is the stateless validation: non-empty guild-id, name,
// founder-reach, signer, non-empty CommonBondHash.
func (m *MsgCreateGuild) ValidateBasic() error {
if m.GuildID == "" {
return fmt.Errorf("guild: empty guild-id")
}
if m.Name == "" {
return fmt.Errorf("guild: empty name")
}
if m.FounderReach == "" {
return fmt.Errorf("guild: empty founder-reach")
}
if m.Signer == "" {
return fmt.Errorf("guild: empty signer")
}
if len(m.CommonBondHash) == 0 {
return fmt.Errorf("guild: empty common-bond-hash (REQ-051 — hash-pinned at creation)")
}
return nil
}
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
func (m *MsgCreateGuild) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{[]byte(m.Signer)}
}
// --- MsgCreateChapter ---------------------------------------------------------
// MsgCreateChapter creates a Chapter under a Parent Guild (REQ-053). The
// handler pins the SecessionTerms hash (HashSecessionTerms) + records the
// Good-Standing Liens (SecuredAtFounding=true) + rejects cooling below the
// protocol minimum (CoolingSecessionCoverActiveDays / NonCoverDays) +
// surfaces a jurisdictional disclaimer (REQ-061).
//
// ValidateBasic is stateless: non-empty fields, non-empty ParentGuildID,
// SecessionTerms valid (non-zero + protocol-minimum-bounded via
// SecessionTerms.Validate), each GoodStandingLien has SecuredAtFounding=true
// + non-empty CreditorReachID + Amount > 0.
type MsgCreateChapter struct {
GuildID string `json:"guild_id" yaml:"guild_id"`
Name string `json:"name" yaml:"name"`
ParentGuildID string `json:"parent_guild_id" yaml:"parent_guild_id"`
FounderReach string `json:"founder_reach" yaml:"founder_reach"`
SecessionTerms SecessionTerms `json:"secession_terms" yaml:"secession_terms"`
GoodStandingLiens []Lien `json:"good_standing_liens" yaml:"good_standing_liens"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message.
func (m *MsgCreateChapter) Reset() { *m = MsgCreateChapter{} }
// String implements proto.Message.
func (m *MsgCreateChapter) String() string {
return fmt.Sprintf("MsgCreateChapter{GuildID:%s Name:%s ParentGuildID:%s FounderReach:%s SecessionTerms:%+v Liens:%d Signer:%s}",
m.GuildID, m.Name, m.ParentGuildID, m.FounderReach, m.SecessionTerms, len(m.GoodStandingLiens), m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgCreateChapter) ProtoMessage() {}
// ValidateBasic is the stateless validation: non-empty fields, non-empty
// ParentGuildID, SecessionTerms valid, each GoodStandingLien is
// SecuredAtFounding=true with non-empty CreditorReachID + Amount > 0
// (founding-locked liens are recorded ONCE at founding — REQ-053).
func (m *MsgCreateChapter) ValidateBasic() error {
if m.GuildID == "" {
return fmt.Errorf("guild: empty chapter guild-id")
}
if m.Name == "" {
return fmt.Errorf("guild: empty chapter name")
}
if m.ParentGuildID == "" {
return fmt.Errorf("guild: empty parent-guild-id (REQ-053 — Chapter requires a Parent)")
}
if m.ParentGuildID == m.GuildID {
return fmt.Errorf("guild: Chapter %q cannot be its own parent", m.GuildID)
}
if m.FounderReach == "" {
return fmt.Errorf("guild: empty founder-reach")
}
if m.Signer == "" {
return fmt.Errorf("guild: empty signer")
}
if err := m.SecessionTerms.Validate(); err != nil {
return fmt.Errorf("guild: secession terms: %w", err)
}
for i, l := range m.GoodStandingLiens {
if !l.SecuredAtFounding {
return fmt.Errorf("guild: GoodStandingLien[%d] has SecuredAtFounding=false (founding liens must be secured at founding — REQ-053)", i)
}
if l.CreditorReachID == "" {
return fmt.Errorf("guild: GoodStandingLien[%d] has empty CreditorReachID", i)
}
if l.Amount <= 0 {
return fmt.Errorf("guild: GoodStandingLien[%d] Amount %d <= 0", i, l.Amount)
}
}
return nil
}
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
func (m *MsgCreateChapter) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{[]byte(m.Signer)}
}
// --- MsgOneTapExitStand -------------------------------------------------------
// MsgOneTapExitStand one-tap exits a Household Stand (REQ-057). The handler
// asserts the Stand type is Household via the StandKeeper shim + dissolves
// the Stand + returns assets to the Holder's Stash via the StashKeeper shim.
// One-tap exit is the Household dispute path (no Council vote required —
// Household skips the formal-Council requirement).
//
// ValidateBasic is stateless: non-empty stand-id + signer.
type MsgOneTapExitStand struct {
StandID string `json:"stand_id" yaml:"stand_id"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message.
func (m *MsgOneTapExitStand) Reset() { *m = MsgOneTapExitStand{} }
// String implements proto.Message.
func (m *MsgOneTapExitStand) String() string {
return fmt.Sprintf("MsgOneTapExitStand{StandID:%s Signer:%s}", m.StandID, m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgOneTapExitStand) ProtoMessage() {}
// ValidateBasic is the stateless validation: non-empty stand-id + signer.
func (m *MsgOneTapExitStand) 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 *MsgOneTapExitStand) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{[]byte(m.Signer)}
}
// --- MsgDelegateConfederationVoice --------------------------------------------
// MsgDelegateConfederationVoice delegates a member Stand's Voice in a
// Confederation (REQ-058). The handler asserts the ConfederationStandID
// references a Confederation Stand via the StandKeeper shim + records the
// delegation (one delegation per member Stand — a duplicate delegation from
// the same MemberStandID is REJECTED). One-Stand-one-Vote: each member Stand
// gets exactly 1 Voice in the Confederation's aggregate, regardless of size.
//
// ValidateBasic is stateless: non-empty fields.
type MsgDelegateConfederationVoice struct {
ConfederationStandID string `json:"confederation_stand_id" yaml:"confederation_stand_id"`
MemberStandID string `json:"member_stand_id" yaml:"member_stand_id"`
DelegateReachID string `json:"delegate_reach_id" yaml:"delegate_reach_id"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message.
func (m *MsgDelegateConfederationVoice) Reset() { *m = MsgDelegateConfederationVoice{} }
// String implements proto.Message.
func (m *MsgDelegateConfederationVoice) String() string {
return fmt.Sprintf("MsgDelegateConfederationVoice{ConfederationStandID:%s MemberStandID:%s DelegateReachID:%s Signer:%s}",
m.ConfederationStandID, m.MemberStandID, m.DelegateReachID, m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgDelegateConfederationVoice) ProtoMessage() {}
// ValidateBasic is the stateless validation: non-empty fields.
func (m *MsgDelegateConfederationVoice) ValidateBasic() error {
if m.ConfederationStandID == "" {
return fmt.Errorf("guild: empty confederation-stand-id")
}
if m.MemberStandID == "" {
return fmt.Errorf("guild: empty member-stand-id")
}
if m.DelegateReachID == "" {
return fmt.Errorf("guild: empty delegate-reach-id")
}
if m.Signer == "" {
return fmt.Errorf("guild: empty signer")
}
if m.ConfederationStandID == m.MemberStandID {
return fmt.Errorf("guild: ConfederationStandID %q cannot delegate to itself", m.ConfederationStandID)
}
return nil
}
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
func (m *MsgDelegateConfederationVoice) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{[]byte(m.Signer)}
}
// --- MsgAddLien ---------------------------------------------------------------
// MsgAddLien adds a Good-Standing Lien to a Guild (REQ-053). The handler
// rejects any new SecuredAtFounding=true lien (founding is a one-time event —
// REQ-053/REQ-081; post-founding liens are SecuredAtFounding=false). The
// handler loads the Guild + persists the lien.
//
// ValidateBasic is stateless: non-empty guild-id, non-empty signer, Lien
// Amount > 0, non-empty CreditorReachID.
type MsgAddLien struct {
GuildID string `json:"guild_id" yaml:"guild_id"`
Lien Lien `json:"lien" yaml:"lien"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message.
func (m *MsgAddLien) Reset() { *m = MsgAddLien{} }
// String implements proto.Message.
func (m *MsgAddLien) String() string {
return fmt.Sprintf("MsgAddLien{GuildID:%s Lien:{Amount:%d CreditorReachID:%s SecuredAtFounding:%v} Signer:%s}",
m.GuildID, m.Lien.Amount, m.Lien.CreditorReachID, m.Lien.SecuredAtFounding, m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgAddLien) ProtoMessage() {}
// ValidateBasic is the stateless validation: non-empty guild-id + signer,
// Lien Amount > 0, non-empty CreditorReachID.
func (m *MsgAddLien) ValidateBasic() error {
if m.GuildID == "" {
return fmt.Errorf("guild: empty guild-id")
}
if m.Signer == "" {
return fmt.Errorf("guild: empty signer")
}
if m.Lien.Amount <= 0 {
return fmt.Errorf("guild: lien Amount %d <= 0", m.Lien.Amount)
}
if m.Lien.CreditorReachID == "" {
return fmt.Errorf("guild: empty lien CreditorReachID")
}
return nil
}
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
func (m *MsgAddLien) 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
// Msg*). The keeper's msg_server.go implements this; module.go's
// RegisterServices wires the implementation. Hand-rolled (no protobuf
// codegen per the skeleton's zero-codegen style).
type MsgServer interface {
CreateGuild(ctx interface{}, msg *MsgCreateGuild) (*MsgCreateGuildResponse, error)
CreateChapter(ctx interface{}, msg *MsgCreateChapter) (*MsgCreateChapterResponse, error)
OneTapExitStand(ctx interface{}, msg *MsgOneTapExitStand) (*MsgOneTapExitStandResponse, error)
DelegateConfederationVoice(ctx interface{}, msg *MsgDelegateConfederationVoice) (*MsgDelegateConfederationVoiceResponse, error)
AddLien(ctx interface{}, msg *MsgAddLien) (*MsgAddLienResponse, error)
}
// --- Response types -----------------------------------------------------------
//
// Hand-rolled (no protobuf codegen). The CreateGuild + CreateChapter
// responses carry a Disclaimer string (REQ-061 — the jurisdictional
// disclaimer surfaced at every charter signing). The other responses are
// empty bodies (the response is the state mutation + event).
// MsgCreateGuildResponse is the response to MsgCreateGuild. Disclaimer is
// the jurisdictional disclaimer surfaced at signing (REQ-061).
type MsgCreateGuildResponse struct {
Disclaimer string `json:"disclaimer" yaml:"disclaimer"`
}
// Reset implements proto.Message.
func (m *MsgCreateGuildResponse) Reset() { *m = MsgCreateGuildResponse{} }
// String implements proto.Message.
func (m *MsgCreateGuildResponse) String() string {
return fmt.Sprintf("MsgCreateGuildResponse{Disclaimer:%s}", m.Disclaimer)
}
// ProtoMessage implements proto.Message.
func (*MsgCreateGuildResponse) ProtoMessage() {}
// MsgCreateChapterResponse is the response to MsgCreateChapter. Disclaimer
// is the jurisdictional disclaimer surfaced at signing (REQ-061).
type MsgCreateChapterResponse struct {
Disclaimer string `json:"disclaimer" yaml:"disclaimer"`
}
// Reset implements proto.Message.
func (m *MsgCreateChapterResponse) Reset() { *m = MsgCreateChapterResponse{} }
// String implements proto.Message.
func (m *MsgCreateChapterResponse) String() string {
return fmt.Sprintf("MsgCreateChapterResponse{Disclaimer:%s}", m.Disclaimer)
}
// ProtoMessage implements proto.Message.
func (*MsgCreateChapterResponse) ProtoMessage() {}
// MsgOneTapExitStandResponse is the response to MsgOneTapExitStand.
type MsgOneTapExitStandResponse struct{}
// Reset implements proto.Message.
func (m *MsgOneTapExitStandResponse) Reset() { *m = MsgOneTapExitStandResponse{} }
// String implements proto.Message.
func (m *MsgOneTapExitStandResponse) String() string { return "MsgOneTapExitStandResponse{}" }
// ProtoMessage implements proto.Message.
func (*MsgOneTapExitStandResponse) ProtoMessage() {}
// MsgDelegateConfederationVoiceResponse is the response to
// MsgDelegateConfederationVoice.
type MsgDelegateConfederationVoiceResponse struct{}
// Reset implements proto.Message.
func (m *MsgDelegateConfederationVoiceResponse) Reset() {
*m = MsgDelegateConfederationVoiceResponse{}
}
// String implements proto.Message.
func (m *MsgDelegateConfederationVoiceResponse) String() string {
return "MsgDelegateConfederationVoiceResponse{}"
}
// ProtoMessage implements proto.Message.
func (*MsgDelegateConfederationVoiceResponse) ProtoMessage() {}
// MsgAddLienResponse is the response to MsgAddLien.
type MsgAddLienResponse struct{}
// Reset implements proto.Message.
func (m *MsgAddLienResponse) Reset() { *m = MsgAddLienResponse{} }
// String implements proto.Message.
func (m *MsgAddLienResponse) String() string { return "MsgAddLienResponse{}" }
// ProtoMessage implements proto.Message.
func (*MsgAddLienResponse) ProtoMessage() {}
+305
View File
@@ -0,0 +1,305 @@
package types
// msg_guild_test.go holds the Msg* method coverage tests for x/guild/types
// (REQ-051, REQ-053, REQ-057, REQ-058). The Msg* Reset/String/ProtoMessage/
// ValidateBasic/GetSigners methods are exercised here so the types package
// coverage is >=80% (the keeper simtest exercises the handlers but its
// coverage counts toward the keeper package, not types).
//
// G-024: 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 (the invariant + lexicon assertions
// live in types_test.go, which stays stdlib + lexicon-only).
import (
"strings"
"testing"
sdk "github.com/cosmos/cosmos-sdk/types"
)
// --- MsgCreateGuild methods ---------------------------------------------------
func TestMsgCreateGuildMethods(t *testing.T) {
m := &MsgCreateGuild{
GuildID: "g1", Name: "Guild", FounderReach: "reach:f",
CommonBondHash: []byte{1, 2, 3},
PublicProfile: GuildPublicProfile{BondSummary: "s", MasonCount: 7},
Signer: "reach:f",
}
if err := m.ValidateBasic(); err != nil {
t.Errorf("valid MsgCreateGuild ValidateBasic: %v", err)
}
if !strings.Contains(m.String(), "g1") {
t.Errorf("MsgCreateGuild String = %q, want to contain g1", m.String())
}
m.Reset()
if m.GuildID != "" || len(m.CommonBondHash) != 0 {
t.Errorf("MsgCreateGuild Reset did not zero: %+v", m)
}
m.ProtoMessage() // no-op coverage
m2 := &MsgCreateGuild{Signer: "reach:s"}
if got := m2.GetSigners(); len(got) != 1 || string(got[0]) != "reach:s" {
t.Errorf("MsgCreateGuild GetSigners = %v, want [reach:s]", got)
}
var _ []sdk.AccAddress = m2.GetSigners()
}
func TestMsgCreateGuildValidateBasicErrors(t *testing.T) {
cases := []struct {
name string
mut func(*MsgCreateGuild)
}{
{"empty guild-id", func(m *MsgCreateGuild) { m.GuildID = "" }},
{"empty name", func(m *MsgCreateGuild) { m.Name = "" }},
{"empty founder-reach", func(m *MsgCreateGuild) { m.FounderReach = "" }},
{"empty signer", func(m *MsgCreateGuild) { m.Signer = "" }},
{"empty common-bond-hash", func(m *MsgCreateGuild) { m.CommonBondHash = nil }},
}
for _, c := range cases {
m := &MsgCreateGuild{GuildID: "g", Name: "n", FounderReach: "r", CommonBondHash: []byte{1}, Signer: "s"}
c.mut(m)
if err := m.ValidateBasic(); err == nil {
t.Errorf("MsgCreateGuild %s: expected error, got nil", c.name)
}
}
}
// --- MsgCreateChapter methods -------------------------------------------------
func TestMsgCreateChapterMethods(t *testing.T) {
m := &MsgCreateChapter{
GuildID: "c1", Name: "Chapter", ParentGuildID: "g1", FounderReach: "reach:f",
SecessionTerms: SecessionTerms{
CoolingCoverActiveDays: CoolingSecessionCoverActiveDays,
CoolingNonCoverDays: CoolingSecessionNonCoverDays,
},
GoodStandingLiens: []Lien{{Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: true}},
Signer: "reach:f",
}
if err := m.ValidateBasic(); err != nil {
t.Errorf("valid MsgCreateChapter ValidateBasic: %v", err)
}
if !strings.Contains(m.String(), "c1") || !strings.Contains(m.String(), "g1") {
t.Errorf("MsgCreateChapter String = %q", m.String())
}
m.Reset()
if m.GuildID != "" || m.ParentGuildID != "" {
t.Errorf("MsgCreateChapter Reset did not zero: %+v", m)
}
m.ProtoMessage()
m2 := &MsgCreateChapter{Signer: "reach:s"}
if got := m2.GetSigners(); len(got) != 1 || string(got[0]) != "reach:s" {
t.Errorf("MsgCreateChapter GetSigners = %v", got)
}
}
func TestMsgCreateChapterValidateBasicErrors(t *testing.T) {
validTerms := SecessionTerms{
CoolingCoverActiveDays: CoolingSecessionCoverActiveDays,
CoolingNonCoverDays: CoolingSecessionNonCoverDays,
}
validLiens := []Lien{{Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: true}}
cases := []struct {
name string
mut func(*MsgCreateChapter)
}{
{"empty guild-id", func(m *MsgCreateChapter) { m.GuildID = "" }},
{"empty name", func(m *MsgCreateChapter) { m.Name = "" }},
{"empty parent-guild-id", func(m *MsgCreateChapter) { m.ParentGuildID = "" }},
{"self parent", func(m *MsgCreateChapter) { m.ParentGuildID = m.GuildID }},
{"empty founder-reach", func(m *MsgCreateChapter) { m.FounderReach = "" }},
{"empty signer", func(m *MsgCreateChapter) { m.Signer = "" }},
{"loose cooling (cover)", func(m *MsgCreateChapter) {
m.SecessionTerms.CoolingCoverActiveDays = CoolingSecessionCoverActiveDays - 1
}},
{"loose cooling (non-cover)", func(m *MsgCreateChapter) {
m.SecessionTerms.CoolingNonCoverDays = CoolingSecessionNonCoverDays - 1
}},
{"zero cooling (cover)", func(m *MsgCreateChapter) { m.SecessionTerms.CoolingCoverActiveDays = 0 }},
{"lien not secured at founding", func(m *MsgCreateChapter) {
m.GoodStandingLiens = []Lien{{Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: false}}
}},
{"lien empty creditor", func(m *MsgCreateChapter) {
m.GoodStandingLiens = []Lien{{Amount: 100, CreditorReachID: "", SecuredAtFounding: true}}
}},
{"lien zero amount", func(m *MsgCreateChapter) {
m.GoodStandingLiens = []Lien{{Amount: 0, CreditorReachID: "reach:c", SecuredAtFounding: true}}
}},
}
for _, c := range cases {
m := &MsgCreateChapter{
GuildID: "c", Name: "n", ParentGuildID: "g", FounderReach: "r",
SecessionTerms: validTerms, GoodStandingLiens: validLiens, Signer: "s",
}
c.mut(m)
if err := m.ValidateBasic(); err == nil {
t.Errorf("MsgCreateChapter %s: expected error, got nil", c.name)
}
}
}
// --- MsgOneTapExitStand methods -----------------------------------------------
func TestMsgOneTapExitStandMethods(t *testing.T) {
m := &MsgOneTapExitStand{StandID: "s1", Signer: "reach:h"}
if err := m.ValidateBasic(); err != nil {
t.Errorf("valid MsgOneTapExitStand ValidateBasic: %v", err)
}
if !strings.Contains(m.String(), "s1") {
t.Errorf("MsgOneTapExitStand String = %q", m.String())
}
m.Reset()
if m.StandID != "" {
t.Errorf("MsgOneTapExitStand Reset did not zero: %+v", m)
}
m.ProtoMessage()
m2 := &MsgOneTapExitStand{Signer: "reach:s"}
if got := m2.GetSigners(); len(got) != 1 || string(got[0]) != "reach:s" {
t.Errorf("MsgOneTapExitStand GetSigners = %v", got)
}
}
func TestMsgOneTapExitStandValidateBasicErrors(t *testing.T) {
if err := (&MsgOneTapExitStand{}).ValidateBasic(); err == nil {
t.Error("empty MsgOneTapExitStand should fail ValidateBasic")
}
if err := (&MsgOneTapExitStand{StandID: "s"}).ValidateBasic(); err == nil {
t.Error("MsgOneTapExitStand with empty signer should fail ValidateBasic")
}
if err := (&MsgOneTapExitStand{Signer: "s"}).ValidateBasic(); err == nil {
t.Error("MsgOneTapExitStand with empty stand-id should fail ValidateBasic")
}
}
// --- MsgDelegateConfederationVoice methods ------------------------------------
func TestMsgDelegateConfederationVoiceMethods(t *testing.T) {
m := &MsgDelegateConfederationVoice{
ConfederationStandID: "conf-1", MemberStandID: "mem-1",
DelegateReachID: "reach:d", Signer: "reach:s",
}
if err := m.ValidateBasic(); err != nil {
t.Errorf("valid MsgDelegateConfederationVoice ValidateBasic: %v", err)
}
if !strings.Contains(m.String(), "conf-1") {
t.Errorf("MsgDelegateConfederationVoice String = %q", m.String())
}
m.Reset()
if m.ConfederationStandID != "" {
t.Errorf("MsgDelegateConfederationVoice Reset did not zero: %+v", m)
}
m.ProtoMessage()
m2 := &MsgDelegateConfederationVoice{Signer: "reach:s"}
if got := m2.GetSigners(); len(got) != 1 || string(got[0]) != "reach:s" {
t.Errorf("MsgDelegateConfederationVoice GetSigners = %v", got)
}
}
func TestMsgDelegateConfederationVoiceValidateBasicErrors(t *testing.T) {
cases := []struct {
name string
mut func(*MsgDelegateConfederationVoice)
}{
{"empty confederation", func(m *MsgDelegateConfederationVoice) { m.ConfederationStandID = "" }},
{"empty member", func(m *MsgDelegateConfederationVoice) { m.MemberStandID = "" }},
{"empty delegate", func(m *MsgDelegateConfederationVoice) { m.DelegateReachID = "" }},
{"empty signer", func(m *MsgDelegateConfederationVoice) { m.Signer = "" }},
{"self-delegate", func(m *MsgDelegateConfederationVoice) { m.MemberStandID = m.ConfederationStandID }},
}
for _, c := range cases {
m := &MsgDelegateConfederationVoice{
ConfederationStandID: "c", MemberStandID: "m",
DelegateReachID: "d", Signer: "s",
}
c.mut(m)
if err := m.ValidateBasic(); err == nil {
t.Errorf("MsgDelegateConfederationVoice %s: expected error", c.name)
}
}
}
// --- MsgAddLien methods -------------------------------------------------------
func TestMsgAddLienMethods(t *testing.T) {
m := &MsgAddLien{
GuildID: "g1",
Lien: Lien{Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: false},
Signer: "reach:s",
}
if err := m.ValidateBasic(); err != nil {
t.Errorf("valid MsgAddLien ValidateBasic: %v", err)
}
if !strings.Contains(m.String(), "g1") {
t.Errorf("MsgAddLien String = %q", m.String())
}
m.Reset()
if m.GuildID != "" {
t.Errorf("MsgAddLien Reset did not zero: %+v", m)
}
m.ProtoMessage()
m2 := &MsgAddLien{Signer: "reach:s"}
if got := m2.GetSigners(); len(got) != 1 || string(got[0]) != "reach:s" {
t.Errorf("MsgAddLien GetSigners = %v", got)
}
}
func TestMsgAddLienValidateBasicErrors(t *testing.T) {
cases := []struct {
name string
mut func(*MsgAddLien)
}{
{"empty guild-id", func(m *MsgAddLien) { m.GuildID = "" }},
{"empty signer", func(m *MsgAddLien) { m.Signer = "" }},
{"zero amount", func(m *MsgAddLien) { m.Lien.Amount = 0 }},
{"negative amount", func(m *MsgAddLien) { m.Lien.Amount = -1 }},
{"empty creditor", func(m *MsgAddLien) { m.Lien.CreditorReachID = "" }},
}
for _, c := range cases {
m := &MsgAddLien{
GuildID: "g", Lien: Lien{Amount: 100, CreditorReachID: "reach:c"}, Signer: "s",
}
c.mut(m)
if err := m.ValidateBasic(); err == nil {
t.Errorf("MsgAddLien %s: expected error", c.name)
}
}
}
// --- Response type methods ----------------------------------------------------
func TestResponseMethods(t *testing.T) {
r1 := &MsgCreateGuildResponse{Disclaimer: "d"}
if !strings.Contains(r1.String(), "d") {
t.Errorf("MsgCreateGuildResponse String = %q", r1.String())
}
r1.Reset()
if r1.Disclaimer != "" {
t.Errorf("MsgCreateGuildResponse Reset did not zero: %+v", r1)
}
r1.ProtoMessage()
r2 := &MsgCreateChapterResponse{Disclaimer: "d"}
if !strings.Contains(r2.String(), "d") {
t.Errorf("MsgCreateChapterResponse String = %q", r2.String())
}
r2.Reset()
if r2.Disclaimer != "" {
t.Errorf("MsgCreateChapterResponse Reset did not zero: %+v", r2)
}
r2.ProtoMessage()
for _, r := range []interface {
Reset()
String() string
ProtoMessage()
}{
&MsgOneTapExitStandResponse{},
&MsgDelegateConfederationVoiceResponse{},
&MsgAddLienResponse{},
} {
r.ProtoMessage()
_ = r.String()
r.Reset()
}
}
+226 -13
View File
@@ -1,6 +1,7 @@
package types
import (
"crypto/sha256"
"encoding/json"
"fmt"
)
@@ -17,18 +18,162 @@ const (
// (v0.1 already encodes HandPassGuild as a 0-fee waiver reason). v0.2's Guild
// module references that waiver, doesn't redefine the fee.
HandPassFeeBps = 0
// PierCarriesVoice is the 12th locked const (GRILL D-087, FR-VOICE-6):
// the Pier wrapper does NOT carry Voice, regardless of fiduciary role.
// This is a mission-locked invariant: a Chapter retains mesh-level Voice
// (the const enforces that the optional Pier-Routed Legal Wrapper does
// NOT carry Voice). Locked-const regression in types_test.go.
PierCarriesVoice = false
// CoolingSecessionCoverActiveDays is the LOCKED protocol minimum (REQ-064)
// for a Cover-active Chapter's secession cooling period: 21 Mesh-days. A
// Chapter's SecessionTerms MAY specify a longer cooling but NOT shorter
// (the CreateChapter handler rejects shorter). Locked-const regression in
// types_test.go.
CoolingSecessionCoverActiveDays = uint32(21)
// CoolingSecessionNonCoverDays is the LOCKED protocol minimum (REQ-064)
// for a non-Cover-active Chapter's secession cooling period: 14 Mesh-days.
// A Chapter's SecessionTerms MAY specify a longer cooling but NOT shorter
// (the CreateChapter handler rejects shorter). Locked-const regression in
// types_test.go.
CoolingSecessionNonCoverDays = uint32(14)
)
// Guild is a task-oriented collective (vision §16, REQ-017). A Guild may
// optionally affiliate with a Stand (stand-affiliation-id references x/stand
// by ID string — G-003 by-ID-string invariant). founder-reach references
// x/identity Reach by string.
//
// P3 extension (REQ-051, REQ-053): the Guild carries a Common Bond
// (hash-pinned at creation — CommonBondHash) + a Public Profile
// (GuildPublicProfile). A Parent Guild (IsChapter=false, ParentGuildID="")
// may have Chapters (IsChapter=true, ParentGuildID by-ID-string). A Chapter
// pins its SecessionTerms at creation (SecessionTermsHash — the hash of the
// JSON-encoded SecessionTerms; immutable — no handler to amend it). A
// Chapter's Good-Standing Liens (GoodStandingLiens) are recorded at founding
// with SecuredAtFounding=true; post-founding liens are SecuredAtFounding=false
// (the AddLien handler rejects any new SecuredAtFounding=true lien — founding
// is a one-time event).
type Guild struct {
GuildID string `json:"guild_id" yaml:"guild_id"`
Name string `json:"name" yaml:"name"`
FounderReach string `json:"founder_reach" yaml:"founder_reach"`
CreatedAt int64 `json:"created_at" yaml:"created_at"`
StandAffiliationID string `json:"stand_affiliation_id,omitempty" yaml:"stand_affiliation_id,omitempty"`
GuildID string `json:"guild_id" yaml:"guild_id"`
Name string `json:"name" yaml:"name"`
FounderReach string `json:"founder_reach" yaml:"founder_reach"`
CreatedAt int64 `json:"created_at" yaml:"created_at"`
StandAffiliationID string `json:"stand_affiliation_id,omitempty" yaml:"stand_affiliation_id,omitempty"`
CommonBondHash []byte `json:"common_bond_hash,omitempty" yaml:"common_bond_hash,omitempty"`
PublicProfile GuildPublicProfile `json:"public_profile,omitempty" yaml:"public_profile,omitempty"`
ParentGuildID string `json:"parent_guild_id,omitempty" yaml:"parent_guild_id,omitempty"`
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"`
}
// GuildPublicProfile is a Guild's published profile (REQ-051). BondSummary is
// a short, human-readable summary of the Common Bond (the protocol does NOT
// parse it — FR-CHTR-5). Disclaimers is the list of jurisdictional
// disclaimers the Guild publishes. MasonCount is the member count when
// disclosed; MasonCountPrivate=true means the count is NOT disclosed
// (MasonCount is 0; consumers check the bool). PierWrapperID references a
// Pier wrapper by-ID-string (G-003); empty means no Pier wrapper (the §5
// default-no-wrapper — D-087: the Pier wrapper does NOT carry Voice).
type GuildPublicProfile struct {
BondSummary string `json:"bond_summary" yaml:"bond_summary"`
Disclaimers []string `json:"disclaimers" yaml:"disclaimers"`
MasonCount uint32 `json:"mason_count" yaml:"mason_count"`
MasonCountPrivate bool `json:"mason_count_private" yaml:"mason_count_private"`
PierWrapperID string `json:"pier_wrapper_id,omitempty" yaml:"pier_wrapper_id,omitempty"`
}
// Lien is a Good-Standing Lien on a Guild (REQ-053). Amount is the lien
// amount in Grain. CreditorReachID references the creditor's Reach by string
// (G-003). SecuredAtFounding=true marks a founding-locked lien (recorded at
// 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.
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"`
}
// SecessionTerms is a Chapter's secession cooling terms (REQ-053, REQ-064).
// Hash-pinned at Guild creation (the SecessionTermsHash on the Guild is the
// SHA-256 of this struct's JSON; immutable — no handler to amend it). The
// cooling periods are protocol-minimum-bounded: the Chapter MAY specify
// longer but NOT shorter than CoolingSecessionCoverActiveDays /
// CoolingSecessionNonCoverDays (the CreateChapter handler rejects shorter).
// LienAuditRequired marks whether a lien audit must pass before secession
// completes. CovenantClearanceRequired marks whether Cover Call / Bond
// covenant clearance must pass before secession completes.
type SecessionTerms struct {
CoolingCoverActiveDays uint32 `json:"cooling_cover_active_days" yaml:"cooling_cover_active_days"`
CoolingNonCoverDays uint32 `json:"cooling_non_cover_days" yaml:"cooling_non_cover_days"`
LienAuditRequired bool `json:"lien_audit_required" yaml:"lien_audit_required"`
CovenantClearanceRequired bool `json:"covenant_clearance_required" yaml:"covenant_clearance_required"`
}
// HashSecessionTerms returns the SHA-256 hash of the JSON-encoded
// SecessionTerms. This is the value stored on Guild.SecessionTermsHash at
// Chapter creation (immutable). The handler pins the hash, NOT the terms
// themselves (the terms are recoverable from genesis; the hash pins them
// against amendment — REQ-053 immutability).
func HashSecessionTerms(t SecessionTerms) []byte {
bz, err := json.Marshal(t)
if err != nil {
// SecessionTerms is a plain struct with only uint32/bool fields;
// json.Marshal never errors here. Panic is the defensive path.
panic(fmt.Sprintf("guild: marshal secession terms: %v", err))
}
sum := sha256.Sum256(bz)
return sum[:]
}
// ConfederationVoice is a Confederation Voice delegation record (REQ-058).
// One-Stand-one-Vote: each member Stand gets exactly 1 Voice in the
// Confederation's aggregate, regardless of size. ConfederationStandID +
// MemberStandID reference x/stand Stands by-ID-string (G-003).
// DelegateReachID references the Reach the member Stand's Voice is delegated
// to. DelegatedAt is the delegation timestamp (block time). The guild
// keeper persists this (the DelegateConfederationVoice handler records one
// delegation per member Stand — a duplicate is REJECTED).
//
// NOTE: x/stand/types defines a type-level ConfederationVoice struct too
// (the type-level addition); this guild-side struct is the persisted record
// (the guild keeper owns the delegation store). The two structs share the
// same JSON field names so a value of one round-trips through the other
// (the simtest asserts against this struct; the x/stand/types struct is the
// type-level scaffold for the aggregation logic landing in a later phase).
type ConfederationVoice struct {
ConfederationStandID string `json:"confederation_stand_id" yaml:"confederation_stand_id"`
MemberStandID string `json:"member_stand_id" yaml:"member_stand_id"`
DelegateReachID string `json:"delegate_reach_id" yaml:"delegate_reach_id"`
DelegatedAt int64 `json:"delegated_at" yaml:"delegated_at"`
}
// Validate asserts a SecessionTerms is non-zero + protocol-minimum-bounded
// (the Chapter MAY tighten the cooling but NOT loosen it below
// CoolingSecessionCoverActiveDays / CoolingSecessionNonCoverDays). The
// CreateChapter handler calls this BEFORE pinning the hash.
func (t SecessionTerms) Validate() error {
if t.CoolingCoverActiveDays == 0 {
return fmt.Errorf("guild: CoolingCoverActiveDays must be non-zero")
}
if t.CoolingNonCoverDays == 0 {
return fmt.Errorf("guild: CoolingNonCoverDays must be non-zero")
}
if t.CoolingCoverActiveDays < CoolingSecessionCoverActiveDays {
return fmt.Errorf("guild: CoolingCoverActiveDays %d < protocol minimum %d (Chapter may tighten but not loosen — REQ-053/REQ-064)",
t.CoolingCoverActiveDays, CoolingSecessionCoverActiveDays)
}
if t.CoolingNonCoverDays < CoolingSecessionNonCoverDays {
return fmt.Errorf("guild: CoolingNonCoverDays %d < protocol minimum %d (Chapter may tighten but not loosen — REQ-053/REQ-064)",
t.CoolingNonCoverDays, CoolingSecessionNonCoverDays)
}
return nil
}
// HandPass is a free (0% protocol fee) Pass-Act issued by a Guild (REQ-017).
@@ -60,17 +205,38 @@ func IssueHandPass(passID, guildID, issuerReach, recipientReach string, amountGr
}
}
// Params for the guild module (skeleton — no tunables in v0.2).
type Params struct{}
// Params for the guild module (P3 extension — REQ-064 cooling defaults).
// DefaultCoolingCoverActiveDays + DefaultCoolingNonCoverDays are the
// protocol-default cooling periods for a Chapter with no SecessionTerms
// override (the Chapter's own SecessionTerms MAY specify longer but NOT
// shorter than the protocol minimums CoolingSecessionCoverActiveDays /
// CoolingSecessionNonCoverDays).
type Params struct {
DefaultCoolingCoverActiveDays uint32 `json:"default_cooling_cover_active_days" yaml:"default_cooling_cover_active_days"`
DefaultCoolingNonCoverDays uint32 `json:"default_cooling_non_cover_days" yaml:"default_cooling_non_cover_days"`
}
func DefaultParams() Params { return Params{} }
// DefaultParams returns the Params with the protocol-minimum cooling defaults
// (CoolingSecessionCoverActiveDays / CoolingSecessionNonCoverDays — the
// Chapter MAY tighten but NOT loosen).
func DefaultParams() Params {
return Params{
DefaultCoolingCoverActiveDays: CoolingSecessionCoverActiveDays,
DefaultCoolingNonCoverDays: CoolingSecessionNonCoverDays,
}
}
// GenesisState defines the guild module genesis state (REQ-017).
// Guilds + HandPasses are the two top-level sets; ValidateGenesis enforces
// guild-id uniqueness and pass-id uniqueness.
// GenesisState defines the guild module genesis state (REQ-017, REQ-053).
// Guilds + HandPasses + Chapters are the three top-level sets; Chapters is a
// separate slice for genesis validation clarity (a Chapter is a Guild with
// IsChapter=true — the separate slice makes the Chapter→ParentGuildID
// reference check unambiguous). ValidateGenesis enforces guild-id + pass-id
// uniqueness + the Chapter→ParentGuildID reference check (a Chapter's
// ParentGuildID must reference an existing Guild in the genesis — REQ-053).
type GenesisState struct {
Params Params `json:"params" yaml:"params"`
Guilds []Guild `json:"guilds" yaml:"guilds"`
Chapters []Guild `json:"chapters,omitempty" yaml:"chapters,omitempty"`
HandPasses []HandPass `json:"hand_passes" yaml:"hand_passes"`
}
@@ -78,19 +244,40 @@ func DefaultGenesisState() *GenesisState {
return &GenesisState{
Params: DefaultParams(),
Guilds: []Guild{},
Chapters: []Guild{},
HandPasses: []HandPass{},
}
}
// Reset implements proto.Message (required by codec.JSONCodec for
// InitGenesis/ExportGenesis).
func (m *GenesisState) Reset() { *m = GenesisState{} }
// String implements proto.Message.
func (m *GenesisState) String() string {
return fmt.Sprintf("GenesisState{Guilds:%d Chapters:%d HandPasses:%d}",
len(m.Guilds), len(m.Chapters), len(m.HandPasses))
}
// ProtoMessage implements proto.Message.
func (*GenesisState) ProtoMessage() {}
// ValidateGenesis performs ID-uniqueness checks (A-212 upgrade from v0.1
// no-op): rejects duplicate guild-ids and duplicate pass-ids. Also enforces
// the 0-fee covenant on genesis HandPasses (FeeGrain must be 0).
// the 0-fee covenant on genesis HandPasses (FeeGrain must be 0). P3
// extension (REQ-053): a Chapter (Guild with IsChapter=true, in either the
// Guilds or Chapters slice) must have a non-empty ParentGuildID referencing
// an existing Guild in the genesis (the parent must be a non-Chapter Guild).
func ValidateGenesis(bz json.RawMessage) error {
var gs GenesisState
if err := json.Unmarshal(bz, &gs); err != nil {
return fmt.Errorf("guild: invalid genesis: %w", err)
}
seenGuild := make(map[string]bool, len(gs.Guilds))
// Index all guild-ids across the Guilds + Chapters slices for the
// Chapter→ParentGuildID reference check. Reject duplicate guild-ids
// across BOTH slices (a Chapter may not share a guild-id with a Parent
// Guild).
seenGuild := make(map[string]bool, len(gs.Guilds)+len(gs.Chapters))
for _, g := range gs.Guilds {
if g.GuildID == "" {
return fmt.Errorf("guild: empty guild-id")
@@ -99,6 +286,32 @@ func ValidateGenesis(bz json.RawMessage) error {
return fmt.Errorf("guild: duplicate guild-id %q", g.GuildID)
}
seenGuild[g.GuildID] = true
// A Guild in the Guilds slice with IsChapter=true is rejected (a
// Chapter must live in the Chapters slice — the split is for genesis
// validation clarity).
if g.IsChapter {
return fmt.Errorf("guild: Guild %q has IsChapter=true but is in the Guilds slice (move to Chapters)", g.GuildID)
}
}
for _, c := range gs.Chapters {
if c.GuildID == "" {
return fmt.Errorf("guild: empty chapter guild-id")
}
if seenGuild[c.GuildID] {
return fmt.Errorf("guild: duplicate guild-id %q (Chapter)", c.GuildID)
}
seenGuild[c.GuildID] = true
// REQ-053: a Chapter must have IsChapter=true + a non-empty
// ParentGuildID referencing an existing Guild.
if !c.IsChapter {
return fmt.Errorf("guild: Chapter %q has IsChapter=false (Chapters slice requires IsChapter=true)", c.GuildID)
}
if c.ParentGuildID == "" {
return fmt.Errorf("guild: Chapter %q has empty ParentGuildID (REQ-053)", c.GuildID)
}
if !seenGuild[c.ParentGuildID] {
return fmt.Errorf("guild: Chapter %q ParentGuildID %q not found in genesis (REQ-053)", c.GuildID, c.ParentGuildID)
}
}
seenPass := make(map[string]bool, len(gs.HandPasses))
for _, p := range gs.HandPasses {
+324 -7
View File
@@ -221,14 +221,16 @@ func TestDefaultParams(t *testing.T) {
// --- Lexicon assertion (REQ-012) -------------------------------------------------
// TestLexiconNoBannedTermsInGuildPackage scans every non-test .go file in
// the guild/types package directory for the 9 banned terms (case-insensitive).
// Production files only — the test file contains the banned terms as the list
// of things to forbid (standard lexicon-test bootstrapping pattern).
// the x/guild module tree (types + keeper + module.go) for the 10 banned
// terms (case-insensitive). Production files only — the test file contains
// the banned terms as the list of things to forbid (standard lexicon-test
// bootstrapping pattern). The scan walks x/guild/**/*.go (the spec's
// `x/guild/**/*.go` lexicon assertion for P3).
func TestLexiconNoBannedTermsInGuildPackage(t *testing.T) {
pkgDir := packageDir(t, "github.com/oy/openyield/x/guild/types")
files, err := filepath.Glob(filepath.Join(pkgDir, "*.go"))
guildDir := packageDir(t, "github.com/oy/openyield/x/guild")
files, err := walkGoFiles(guildDir)
if err != nil {
t.Fatalf("glob: %v", err)
t.Fatalf("walk: %v", err)
}
prodFiles := []string{}
for _, f := range files {
@@ -238,7 +240,7 @@ func TestLexiconNoBannedTermsInGuildPackage(t *testing.T) {
prodFiles = append(prodFiles, f)
}
if len(prodFiles) == 0 {
t.Fatal("no production .go files found in guild/types")
t.Fatal("no production .go files found in x/guild")
}
for _, f := range prodFiles {
bz, err := os.ReadFile(f)
@@ -251,6 +253,321 @@ func TestLexiconNoBannedTermsInGuildPackage(t *testing.T) {
}
}
// walkGoFiles returns all .go files under dir (recursively).
func walkGoFiles(dir string) ([]string, error) {
var out []string
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
if strings.HasSuffix(path, ".go") {
out = append(out, path)
}
return nil
})
return out, err
}
// --- P3 locked-const regression (REQ-064, D-087) -------------------------------
// TestPierCarriesVoiceLockedConst asserts D-087: PierCarriesVoice == false
// (FR-VOICE-6: the Pier wrapper does NOT carry Voice, regardless of
// fiduciary role — mission-locked invariant). A regression firewall:
// changing PierCarriesVoice to true breaks this test.
func TestPierCarriesVoiceLockedConst(t *testing.T) {
if types.PierCarriesVoice {
t.Errorf("PierCarriesVoice = true, expected false (D-087 FR-VOICE-6: Pier does NOT carry Voice)")
}
}
// TestCoolingSecessionLockedConsts asserts REQ-064: the protocol-minimum
// cooling periods for Chapter secession (21d Cover-active, 14d non-Cover).
// A Chapter's SecessionTerms MAY specify longer but NOT shorter (the
// CreateChapter handler rejects shorter). Regression firewall: changing
// these consts breaks this test.
func TestCoolingSecessionLockedConsts(t *testing.T) {
if types.CoolingSecessionCoverActiveDays != 21 {
t.Errorf("CoolingSecessionCoverActiveDays = %d, expected 21 (REQ-064 LOCKED)",
types.CoolingSecessionCoverActiveDays)
}
if types.CoolingSecessionNonCoverDays != 14 {
t.Errorf("CoolingSecessionNonCoverDays = %d, expected 14 (REQ-064 LOCKED)",
types.CoolingSecessionNonCoverDays)
}
}
// --- P3 Guild struct extension (REQ-051, REQ-053) ------------------------------
// TestGuildP3Fields asserts the Guild struct carries the P3 extension fields
// (CommonBondHash, PublicProfile, ParentGuildID, IsChapter,
// SecessionTermsHash, GoodStandingLiens) — a compile-time + runtime
// regression firewall (removing any field breaks this test).
func TestGuildP3Fields(t *testing.T) {
g := types.Guild{
GuildID: "g1",
Name: "Parent",
FounderReach: "reach:f",
CommonBondHash: []byte{1, 2, 3},
PublicProfile: types.GuildPublicProfile{BondSummary: "sum", MasonCount: 7},
ParentGuildID: "",
IsChapter: false,
SecessionTermsHash: nil,
GoodStandingLiens: []types.Lien{{Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: true}},
}
if g.CommonBondHash == nil || len(g.CommonBondHash) != 3 {
t.Errorf("CommonBondHash = %v, want 3 bytes", g.CommonBondHash)
}
if g.PublicProfile.BondSummary != "sum" || g.PublicProfile.MasonCount != 7 {
t.Errorf("PublicProfile = %+v", g.PublicProfile)
}
if g.IsChapter {
t.Errorf("IsChapter = true, want false for a Parent Guild")
}
if g.ParentGuildID != "" {
t.Errorf("ParentGuildID = %q, want empty for a Parent Guild", g.ParentGuildID)
}
if len(g.GoodStandingLiens) != 1 || !g.GoodStandingLiens[0].SecuredAtFounding {
t.Errorf("GoodStandingLiens = %v", g.GoodStandingLiens)
}
// Chapter variant.
c := types.Guild{
GuildID: "c1",
Name: "Chapter",
FounderReach: "reach:f",
ParentGuildID: "g1",
IsChapter: true,
SecessionTermsHash: []byte{9, 9, 9},
GoodStandingLiens: []types.Lien{{Amount: 50, CreditorReachID: "reach:c2", SecuredAtFounding: true}},
}
if !c.IsChapter || c.ParentGuildID != "g1" {
t.Errorf("Chapter fields: IsChapter=%v ParentGuildID=%q", c.IsChapter, c.ParentGuildID)
}
if len(c.SecessionTermsHash) != 3 {
t.Errorf("SecessionTermsHash = %v, want 3 bytes", c.SecessionTermsHash)
}
}
// TestGuildPublicProfileMasonCountPrivate asserts the MasonCountPrivate bool:
// when true, the MasonCount is NOT disclosed (the field is 0; consumers
// check the bool).
func TestGuildPublicProfileMasonCountPrivate(t *testing.T) {
disclosed := types.GuildPublicProfile{BondSummary: "s", MasonCount: 42, MasonCountPrivate: false}
if disclosed.MasonCountPrivate || disclosed.MasonCount != 42 {
t.Errorf("disclosed profile: %+v", disclosed)
}
private := types.GuildPublicProfile{BondSummary: "s", MasonCount: 0, MasonCountPrivate: true}
if !private.MasonCountPrivate {
t.Errorf("private profile: MasonCountPrivate = false, want true")
}
if private.MasonCount != 0 {
t.Errorf("private profile: MasonCount = %d, want 0 (not disclosed)", private.MasonCount)
}
}
// TestLienStruct asserts the Lien struct carries the four required fields
// (Amount, CreditorReachID, SecuredAtFounding, CoverPoolCovenantRef).
func TestLienStruct(t *testing.T) {
l := types.Lien{
Amount: 1000,
CreditorReachID: "reach:cred",
SecuredAtFounding: true,
CoverPoolCovenantRef: "covenant-1",
}
if l.Amount != 1000 || l.CreditorReachID != "reach:cred" ||
!l.SecuredAtFounding || l.CoverPoolCovenantRef != "covenant-1" {
t.Errorf("Lien fields: %+v", l)
}
// A lien with no Cover Pool covenant backing (empty ref) is valid.
l2 := types.Lien{Amount: 500, CreditorReachID: "reach:c", SecuredAtFounding: false}
if l2.CoverPoolCovenantRef != "" {
t.Errorf("Lien2 CoverPoolCovenantRef = %q, want empty", l2.CoverPoolCovenantRef)
}
}
// TestSecessionTermsStruct asserts the SecessionTerms struct + its Validate
// method (non-zero + protocol-minimum-bounded).
func TestSecessionTermsStruct(t *testing.T) {
// Valid: exactly the protocol minimums.
valid := types.SecessionTerms{
CoolingCoverActiveDays: types.CoolingSecessionCoverActiveDays,
CoolingNonCoverDays: types.CoolingSecessionNonCoverDays,
LienAuditRequired: true,
CovenantClearanceRequired: true,
}
if err := valid.Validate(); err != nil {
t.Errorf("valid SecessionTerms Validate: %v", err)
}
// Valid: tighter than the protocol minimum (longer cooling allowed).
tighter := types.SecessionTerms{
CoolingCoverActiveDays: types.CoolingSecessionCoverActiveDays + 10,
CoolingNonCoverDays: types.CoolingSecessionNonCoverDays + 5,
}
if err := tighter.Validate(); err != nil {
t.Errorf("tighter SecessionTerms Validate: %v", err)
}
// Invalid: zero CoolingCoverActiveDays.
if err := (types.SecessionTerms{CoolingNonCoverDays: 14}).Validate(); err == nil {
t.Error("SecessionTerms with zero CoolingCoverActiveDays should fail Validate")
}
// Invalid: zero CoolingNonCoverDays.
if err := (types.SecessionTerms{CoolingCoverActiveDays: 21}).Validate(); err == nil {
t.Error("SecessionTerms with zero CoolingNonCoverDays should fail Validate")
}
// Invalid: CoolingCoverActiveDays below protocol minimum (looser).
loose := types.SecessionTerms{
CoolingCoverActiveDays: types.CoolingSecessionCoverActiveDays - 1,
CoolingNonCoverDays: types.CoolingSecessionNonCoverDays,
}
if err := loose.Validate(); err == nil {
t.Error("SecessionTerms with CoolingCoverActiveDays below minimum should fail Validate (Chapter may tighten but not loosen)")
}
// Invalid: CoolingNonCoverDays below protocol minimum (looser).
loose2 := types.SecessionTerms{
CoolingCoverActiveDays: types.CoolingSecessionCoverActiveDays,
CoolingNonCoverDays: types.CoolingSecessionNonCoverDays - 1,
}
if err := loose2.Validate(); err == nil {
t.Error("SecessionTerms with CoolingNonCoverDays below minimum should fail Validate (Chapter may tighten but not loosen)")
}
}
// TestHashSecessionTermsDeterministic asserts HashSecessionTerms is
// deterministic (the same terms produce the same hash; different terms
// produce a different hash). This is the immutability pin: the
// SecessionTermsHash on a Chapter is the hash of its SecessionTerms JSON.
func TestHashSecessionTermsDeterministic(t *testing.T) {
t1 := types.SecessionTerms{CoolingCoverActiveDays: 21, CoolingNonCoverDays: 14}
t2 := types.SecessionTerms{CoolingCoverActiveDays: 21, CoolingNonCoverDays: 14}
if !bytesEqual(types.HashSecessionTerms(t1), types.HashSecessionTerms(t2)) {
t.Error("HashSecessionTerms not deterministic for equal terms")
}
t3 := types.SecessionTerms{CoolingCoverActiveDays: 31, CoolingNonCoverDays: 14}
if bytesEqual(types.HashSecessionTerms(t1), types.HashSecessionTerms(t3)) {
t.Error("HashSecessionTerms collided for different terms")
}
}
// bytesEqual is a stdlib-only byte-slice equality helper (the types_test.go
// stays stdlib-only per G-024 — no bytes import needed for this trivial
// comparison).
func bytesEqual(a, b []byte) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// --- P3 Params + GenesisState extension ---------------------------------------
// TestDefaultParamsCooling asserts DefaultParams returns the protocol-minimum
// cooling defaults (CoolingSecessionCoverActiveDays / NonCoverDays).
func TestDefaultParamsCooling(t *testing.T) {
p := types.DefaultParams()
if p.DefaultCoolingCoverActiveDays != types.CoolingSecessionCoverActiveDays {
t.Errorf("DefaultCoolingCoverActiveDays = %d, want %d",
p.DefaultCoolingCoverActiveDays, types.CoolingSecessionCoverActiveDays)
}
if p.DefaultCoolingNonCoverDays != types.CoolingSecessionNonCoverDays {
t.Errorf("DefaultCoolingNonCoverDays = %d, want %d",
p.DefaultCoolingNonCoverDays, types.CoolingSecessionNonCoverDays)
}
}
// TestDefaultGenesisStateChapters asserts DefaultGenesisState returns a
// non-nil empty Chapters slice.
func TestDefaultGenesisStateChapters(t *testing.T) {
gs := types.DefaultGenesisState()
if gs.Chapters == nil || len(gs.Chapters) != 0 {
t.Errorf("Default Chapters should be non-nil empty slice, got %v", gs.Chapters)
}
}
// TestValidateGenesisRejectsChapterMissingParent asserts REQ-053: a Chapter
// (in the Chapters slice) with an empty ParentGuildID is REJECTED.
func TestValidateGenesisRejectsChapterMissingParent(t *testing.T) {
gs := types.GenesisState{
Guilds: []types.Guild{{GuildID: "g1"}},
Chapters: []types.Guild{{GuildID: "c1", IsChapter: true, ParentGuildID: ""}},
}
bz, _ := json.Marshal(gs)
if err := types.ValidateGenesis(bz); err == nil {
t.Error("ValidateGenesis should reject Chapter with empty ParentGuildID (REQ-053)")
}
}
// TestValidateGenesisRejectsChapterParentNotFound asserts REQ-053: a Chapter
// whose ParentGuildID does not reference an existing Guild is REJECTED.
func TestValidateGenesisRejectsChapterParentNotFound(t *testing.T) {
gs := types.GenesisState{
Chapters: []types.Guild{{GuildID: "c1", IsChapter: true, ParentGuildID: "no-such-parent"}},
}
bz, _ := json.Marshal(gs)
if err := types.ValidateGenesis(bz); err == nil {
t.Error("ValidateGenesis should reject Chapter with ParentGuildID not in genesis (REQ-053)")
}
}
// TestValidateGenesisAcceptsChapterWithParent asserts a Chapter with a
// valid ParentGuildID (referencing an existing Guild) is accepted.
func TestValidateGenesisAcceptsChapterWithParent(t *testing.T) {
gs := types.GenesisState{
Guilds: []types.Guild{{GuildID: "g1"}},
Chapters: []types.Guild{{GuildID: "c1", IsChapter: true, ParentGuildID: "g1"}},
HandPasses: []types.HandPass{{PassID: "p1", GuildID: "g1", FeeGrain: 0}},
}
bz, _ := json.Marshal(gs)
if err := types.ValidateGenesis(bz); err != nil {
t.Errorf("ValidateGenesis should accept Chapter with valid parent, got: %v", err)
}
}
// TestValidateGenesisRejectsChapterInGuildsSlice asserts a Guild in the
// Guilds slice with IsChapter=true is REJECTED (a Chapter must live in the
// Chapters slice — the split is for genesis validation clarity).
func TestValidateGenesisRejectsChapterInGuildsSlice(t *testing.T) {
gs := types.GenesisState{
Guilds: []types.Guild{{GuildID: "g1", IsChapter: true}},
}
bz, _ := json.Marshal(gs)
if err := types.ValidateGenesis(bz); err == nil {
t.Error("ValidateGenesis should reject a Chapter in the Guilds slice (move to Chapters)")
}
}
// TestValidateGenesisRejectsNonChapterInChaptersSlice asserts a Guild in
// the Chapters slice with IsChapter=false is REJECTED.
func TestValidateGenesisRejectsNonChapterInChaptersSlice(t *testing.T) {
gs := types.GenesisState{
Chapters: []types.Guild{{GuildID: "c1", IsChapter: false, ParentGuildID: "g1"}},
}
bz, _ := json.Marshal(gs)
if err := types.ValidateGenesis(bz); err == nil {
t.Error("ValidateGenesis should reject a non-Chapter in the Chapters slice")
}
}
// TestValidateGenesisRejectsDupChapterID asserts a duplicate guild-id across
// the Guilds + Chapters slices is REJECTED.
func TestValidateGenesisRejectsDupChapterID(t *testing.T) {
gs := types.GenesisState{
Guilds: []types.Guild{{GuildID: "g1"}},
Chapters: []types.Guild{{GuildID: "g1", IsChapter: true, ParentGuildID: "g1"}},
}
bz, _ := json.Marshal(gs)
if err := types.ValidateGenesis(bz); err == nil {
t.Error("ValidateGenesis should reject duplicate guild-id across Guilds + Chapters")
}
}
// packageDir resolves a Go import path to its filesystem directory.
func packageDir(t *testing.T, importPath string) string {
t.Helper()
+34
View File
@@ -50,6 +50,40 @@ func AllStandTypes() []StandType {
}
}
// IsHousehold reports whether a StandType is a Household (REQ-057). The
// x/guild OneTapExitStand handler (via the StandKeeper shim) consults this
// to assert one-tap exit is Household-only. By-ID-string boundary (G-003):
// the handler compares the stand-type string against "Household"; this
// helper is the type-level scaffold.
func IsHousehold(t StandType) bool { return t == StandHousehold }
// IsConfederation reports whether a StandType is a Confederation (REQ-058).
// The x/guild DelegateConfederationVoice handler (via the StandKeeper shim)
// consults this to assert the named Stand is a Confederation before
// recording a delegation. By-ID-string boundary (G-003): the handler
// compares the stand-type string against "Confederation"; this helper is the
// type-level scaffold.
func IsConfederation(t StandType) bool { return t == StandConfederation }
// ConfederationVoice is a Confederation Voice delegation record (REQ-058).
// One-Stand-one-Vote: each member Stand gets exactly 1 Voice in the
// Confederation's aggregate, regardless of size. ConfederationStandID +
// MemberStandID reference Stands by-ID-string (G-003). DelegateReachID
// references the Reach the member Stand's Voice is delegated to.
// DelegatedAt is the delegation timestamp (block time).
//
// NOTE: the x/guild keeper owns the persisted delegation record (the
// x/guild/types.ConfederationVoice struct is the persisted shape — same JSON
// field names so a value of one round-trips through the other). This
// x/stand/types struct is the type-level scaffold for the Confederation
// Voice aggregation logic landing in a later phase.
type ConfederationVoice struct {
ConfederationStandID string `json:"confederation_stand_id" yaml:"confederation_stand_id"`
MemberStandID string `json:"member_stand_id" yaml:"member_stand_id"`
DelegateReachID string `json:"delegate_reach_id" yaml:"delegate_reach_id"`
DelegatedAt int64 `json:"delegated_at" yaml:"delegated_at"`
}
// Stand is a governed group holding a Vault (vision §11, REQ-016).
// Modeled on Cosmos SDK x/group (a group of members with a decision policy
// governing a Vault). admin-reach references a Reach ID (by-ID-string, G-003);
+49
View File
@@ -249,6 +249,55 @@ func TestDefaultParams(t *testing.T) {
_ = types.DefaultParams() // no panics
}
// --- P3 Household / Confederation helpers (REQ-057, REQ-058) ------------------
// TestIsHousehold asserts IsHousehold returns true only for StandHousehold.
func TestIsHousehold(t *testing.T) {
if !types.IsHousehold(types.StandHousehold) {
t.Error("IsHousehold(Household) should be true")
}
for _, s := range types.AllStandTypes() {
if s == types.StandHousehold {
continue
}
if types.IsHousehold(s) {
t.Errorf("IsHousehold(%q) should be false", s)
}
}
}
// TestIsConfederation asserts IsConfederation returns true only for
// StandConfederation.
func TestIsConfederation(t *testing.T) {
if !types.IsConfederation(types.StandConfederation) {
t.Error("IsConfederation(Confederation) should be true")
}
for _, s := range types.AllStandTypes() {
if s == types.StandConfederation {
continue
}
if types.IsConfederation(s) {
t.Errorf("IsConfederation(%q) should be false", s)
}
}
}
// TestConfederationVoiceStruct asserts the ConfederationVoice struct carries
// the four required fields (ConfederationStandID, MemberStandID,
// DelegateReachID, DelegatedAt — REQ-058).
func TestConfederationVoiceStruct(t *testing.T) {
v := types.ConfederationVoice{
ConfederationStandID: "conf-1",
MemberStandID: "mem-1",
DelegateReachID: "reach:delegate",
DelegatedAt: 12345,
}
if v.ConfederationStandID != "conf-1" || v.MemberStandID != "mem-1" ||
v.DelegateReachID != "reach:delegate" || v.DelegatedAt != 12345 {
t.Errorf("ConfederationVoice fields: %+v", v)
}
}
// --- Lexicon assertion (REQ-012) -------------------------------------------------
// TestLexiconNoBannedTermsInStandPackage scans every non-test .go file in
+59 -14
View File
@@ -38,6 +38,29 @@ const (
FreeholderStashMaxGapDays = 30 // no gap > 30 days
FreeholderMinStandingScore = 4.5 // 4.5+ in at least 3 service categories
FreeholderMinCategories = 3 // at least 3 service categories
// ShadowVouchWeightMultiplier is the LOCKED weight multiplier applied to
// a Shadow vouch (vision §9.1, REQ-060 locked). A Shadow vouch is a
// vouch from a holder whose identity is not publicly linked to their
// vouching activity (the vouch carries skin-in-the-game but the
// voucher's standing is not publicly attributable). The multiplier
// halves the vouch weight: a Shadow Freeholder vouch weighs 0.75 (1.5
// × 0.5) instead of 1.5. The const makes the 0.5× mission-locked
// (REQ-060 locked) and regression-testable. A regression here is a
// mission-lock breach.
ShadowVouchWeightMultiplier = 0.5
// SlashReasonFraudulentCoverCall is the slash reason for a Cover Claims
// Voucher that adjudicated a Cover Call fraudulently (REQ-055, vision
// §9.4). The slash drops the Voucher's Standing bucket (cross-Pool
// applicability — the bucket drop disqualifies them from other Pools'
// Standing gates). The const value is the string recorded on
// x/standing.Slash.Reason. Cross-documented to
// x/cover.types.SlashReasonFraudulentCoverCall (a LOCAL const in
// x/cover to avoid importing x/standing — G-003 — the two consts MUST
// stay in sync; a change to one requires a matching change to the
// other).
SlashReasonFraudulentCoverCall = "FraudulentCoverCall"
)
// Rating is a single rating event (§9.2)
@@ -52,16 +75,28 @@ type Rating struct {
DecayBucket uint8 `json:"decay_bucket" yaml:"decay_bucket"`
}
// Vouch is a Freeholder vouch with skin-in-the-game (§9.1)
// Vouch is a Freeholder vouch with skin-in-the-game (§9.1). IsShadow records
// whether this is a Shadow vouch (REQ-060 — a vouch from a holder whose
// identity is not publicly linked to their vouching activity; the vouch
// carries skin-in-the-game but the voucher's standing is not publicly
// attributable). A Shadow vouch's weight is halved by
// ShadowVouchWeightMultiplier (0.5×) in GetVoucherWeight (the post-step
// multiplier). The field is additive (existing non-Shadow vouches keep
// IsShadow=false -> the same weight as before).
type Vouch struct {
VoucherID string `json:"voucher_id" yaml:"voucher_id"`
VoucheeID string `json:"vouchee_id" yaml:"vouchee_id"`
Category string `json:"category" yaml:"category"`
BondAmount int64 `json:"bond_amount" yaml:"bond_amount"` // voucher skin-in-the-game
Timestamp int64 `json:"timestamp" yaml:"timestamp"`
IsShadow bool `json:"is_shadow" yaml:"is_shadow"` // REQ-060 Shadow vouch flag
}
// Slash penalizes a Holder (§9.4)
// Slash penalizes a Holder (§9.4). Reason is one of "Crack",
// "FraudulentCoverCall" (the SlashReasonFraudulentCoverCall const — REQ-055,
// for a Cover Claims Voucher that adjudicated a Cover Call fraudulently;
// cross-Pool applicability via the Standing bucket drop), or
// "InactivityTimeout".
type Slash struct {
ReachID string `json:"reach_id" yaml:"reach_id"`
Amount float64 `json:"amount" yaml:"amount"`
@@ -108,21 +143,31 @@ func ComputeDiversityBonus(categoryCount int) float64 {
return 0.0
}
// GetVoucherWeight returns the weight for a given rater profile (§9.2)
func GetVoucherWeight(isFreeholder bool, standingScore float64, ratingCount int) float64 {
// GetVoucherWeight returns the weight for a given rater profile (§9.2,
// REQ-060). The base weight is computed from isFreeholder + standingScore +
// ratingCount as before; the post-step applies the Shadow vouch multiplier:
// if isShadow is true, the base weight is multiplied by
// ShadowVouchWeightMultiplier (0.5× — a Shadow vouch weighs half). The
// isShadow parameter is the vouch's Shadow flag (x/standing.Vouch.IsShadow);
// existing non-Shadow vouches pass false -> the same weight as before
// (additive — REQ-060).
func GetVoucherWeight(isFreeholder bool, standingScore float64, ratingCount int, isShadow bool) float64 {
var w float64
if isFreeholder {
return VoucherWeightFreeholder
w = VoucherWeightFreeholder
} else if ratingCount < 10 {
w = VoucherWeightBelow10Ratings
} else if standingScore >= 4.5 {
w = VoucherWeight45Plus
} else if standingScore >= 4.0 {
w = VoucherWeight40To45
} else {
w = VoucherWeightBelow40
}
if ratingCount < 10 {
return VoucherWeightBelow10Ratings
if isShadow {
w *= ShadowVouchWeightMultiplier
}
if standingScore >= 4.5 {
return VoucherWeight45Plus
}
if standingScore >= 4.0 {
return VoucherWeight40To45
}
return VoucherWeightBelow40
return w
}
// GetStandingBucket returns the display bucket for a score (§9.2)
+72 -5
View File
@@ -46,19 +46,19 @@ func TestDiversityBonus(t *testing.T) {
}
func TestVoucherWeights(t *testing.T) {
if types.GetVoucherWeight(true, 4.0, 100) != 1.5 {
if types.GetVoucherWeight(true, 4.0, 100, false) != 1.5 {
t.Error("Freeholder weight should be 1.5x (§9.2)")
}
if types.GetVoucherWeight(false, 4.6, 100) != 1.2 {
if types.GetVoucherWeight(false, 4.6, 100, false) != 1.2 {
t.Error("4.5+ with 1-2 cats should be 1.2x (§9.2)")
}
if types.GetVoucherWeight(false, 4.2, 100) != 1.0 {
if types.GetVoucherWeight(false, 4.2, 100, false) != 1.0 {
t.Error("4.0-4.5 should be 1.0x (§9.2)")
}
if types.GetVoucherWeight(false, 3.5, 100) != 0.5 {
if types.GetVoucherWeight(false, 3.5, 100, false) != 0.5 {
t.Error("Below 4.0 should be 0.5x (§9.2)")
}
if types.GetVoucherWeight(false, 4.0, 5) != 0.3 {
if types.GetVoucherWeight(false, 4.0, 5, false) != 0.3 {
t.Error("Below 10 ratings should be 0.3x (§9.2)")
}
}
@@ -98,3 +98,70 @@ func TestLockedConstants(t *testing.T) {
t.Error("Min counterparties for Freeholder status should be 30 (§9.2)")
}
}
// --- P4: Shadow vouch 50% weight (REQ-060 locked) + SlashReason const ---------
// TestShadowVouchWeightMultiplier asserts the Shadow vouch weight multiplier
// is the locked 0.5 (REQ-060 locked — vision §9.1). A regression here is a
// mission-lock breach.
func TestShadowVouchWeightMultiplier(t *testing.T) {
if types.ShadowVouchWeightMultiplier != 0.5 {
t.Errorf("ShadowVouchWeightMultiplier = %v, want 0.5 (REQ-060 locked — Shadow vouch weighs half)", types.ShadowVouchWeightMultiplier)
}
}
// TestShadowVouchWeight asserts GetVoucherWeight applies the 0.5× Shadow
// multiplier as a post-step (REQ-060):
// - non-Shadow vouch: GetVoucherWeight(false, 4.5, 100, false) ==
// VoucherWeight45Plus (unchanged — the additive field keeps existing
// vouches at the same weight).
// - Shadow vouch: GetVoucherWeight(false, 4.5, 100, true) ==
// VoucherWeight45Plus * 0.5 (Shadow halves the weight).
// - Shadow Freeholder: GetVoucherWeight(true, 4.0, 100, true) ==
// VoucherWeightFreeholder * 0.5 (Shadow Freeholder).
func TestShadowVouchWeight(t *testing.T) {
// Non-Shadow 4.5+ vouch: weight unchanged (VoucherWeight45Plus).
got := types.GetVoucherWeight(false, 4.5, 100, false)
if got != types.VoucherWeight45Plus {
t.Errorf("non-Shadow 4.5+ weight = %v, want %v (unchanged — additive)", got, types.VoucherWeight45Plus)
}
// Shadow 4.5+ vouch: weight halved.
got = types.GetVoucherWeight(false, 4.5, 100, true)
if got != types.VoucherWeight45Plus*0.5 {
t.Errorf("Shadow 4.5+ weight = %v, want %v (VoucherWeight45Plus * 0.5 — REQ-060)", got, types.VoucherWeight45Plus*0.5)
}
// Shadow Freeholder: weight halved.
got = types.GetVoucherWeight(true, 4.0, 100, true)
if got != types.VoucherWeightFreeholder*0.5 {
t.Errorf("Shadow Freeholder weight = %v, want %v (VoucherWeightFreeholder * 0.5 — REQ-060)", got, types.VoucherWeightFreeholder*0.5)
}
// Non-Shadow Freeholder: weight unchanged.
got = types.GetVoucherWeight(true, 4.0, 100, false)
if got != types.VoucherWeightFreeholder {
t.Errorf("non-Shadow Freeholder weight = %v, want %v (unchanged — additive)", got, types.VoucherWeightFreeholder)
}
}
// TestSlashReasonFraudulentCoverCall asserts the slash reason const for a
// fraudulent Cover Call adjudication (REQ-055 — cross-documented to
// x/cover.types.SlashReasonFraudulentCoverCall, a LOCAL const in x/cover to
// avoid importing x/standing — G-003; the two consts MUST stay in sync).
func TestSlashReasonFraudulentCoverCall(t *testing.T) {
if types.SlashReasonFraudulentCoverCall != "FraudulentCoverCall" {
t.Errorf("SlashReasonFraudulentCoverCall = %q, want %q (REQ-055 — cross-doc x/cover)", types.SlashReasonFraudulentCoverCall, "FraudulentCoverCall")
}
}
// TestVouchIsShadowField asserts the Vouch struct carries the IsShadow field
// (REQ-060 — additive; existing non-Shadow vouches keep IsShadow=false).
func TestVouchIsShadowField(t *testing.T) {
v := types.Vouch{VoucherID: "v1", VoucheeID: "u1", Category: "Travel", BondAmount: 100, Timestamp: 1000, IsShadow: true}
if !v.IsShadow {
t.Error("Vouch.IsShadow should be true when set (REQ-060)")
}
// Default zero-value is false (existing non-Shadow vouches keep false).
var v2 types.Vouch
if v2.IsShadow {
t.Error("zero-value Vouch.IsShadow should be false (additive — existing vouches unchanged)")
}
}