From 9e7fc403f5fa574537939c3ca6069cf1c516ddc1 Mon Sep 17 00:00:00 2001 From: cloudinit-bot Date: Wed, 19 Aug 2026 02:31:52 +0000 Subject: [PATCH] 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--- --- web/store/store.go | 2 +- x/bond/keeper/keeper.go | 160 +++++- x/bond/keeper/msg_server.go | 238 +++++++++ x/bond/keeper/msg_server_simtest_test.go | 541 +++++++++++++++++++++ x/bond/types/expected_keepers.go | 71 ++- x/bond/types/genesis.go | 38 ++ x/bond/types/msg_bond.go | 6 + x/bond/types/msg_mab.go | 330 +++++++++++++ x/bond/types/types.go | 153 ++++++ x/bond/types/types_test.go | 172 +++++++ x/cover/keeper/keeper.go | 78 +++ x/cover/keeper/msg_server.go | 228 +++++++++ x/cover/keeper/msg_server_simtest_test.go | 568 +++++++++++++++++++++- x/cover/types/expected_keepers.go | 38 ++ x/cover/types/msg_cover.go | 6 + x/cover/types/msg_voucher.go | 316 ++++++++++++ x/cover/types/types.go | 112 ++++- x/cover/types/types_test.go | 123 +++++ x/standing/types/types.go | 73 ++- x/standing/types/types_test.go | 77 ++- 20 files changed, 3294 insertions(+), 36 deletions(-) create mode 100644 x/bond/types/msg_mab.go create mode 100644 x/cover/types/msg_voucher.go diff --git a/web/store/store.go b/web/store/store.go index 45bbd6a..4464e4e 100644 --- a/web/store/store.go +++ b/web/store/store.go @@ -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 } diff --git a/x/bond/keeper/keeper.go b/x/bond/keeper/keeper.go index 7d0bb76..e1dba09 100644 --- a/x/bond/keeper/keeper.go +++ b/x/bond/keeper/keeper.go @@ -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// -> 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/; 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// -> 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 diff --git a/x/bond/keeper/msg_server.go b/x/bond/keeper/msg_server.go index ba355e4..51e54b8 100644 --- a/x/bond/keeper/msg_server.go +++ b/x/bond/keeper/msg_server.go @@ -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// +// -> 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 +} diff --git a/x/bond/keeper/msg_server_simtest_test.go b/x/bond/keeper/msg_server_simtest_test.go index 3f93197..d8227d5 100644 --- a/x/bond/keeper/msg_server_simtest_test.go +++ b/x/bond/keeper/msg_server_simtest_test.go @@ -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") + } +} diff --git a/x/bond/types/expected_keepers.go b/x/bond/types/expected_keepers.go index 1f2d3f9..0222a52 100644 --- a/x/bond/types/expected_keepers.go +++ b/x/bond/types/expected_keepers.go @@ -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 +} diff --git a/x/bond/types/genesis.go b/x/bond/types/genesis.go index c26a559..c8eba86 100644 --- a/x/bond/types/genesis.go +++ b/x/bond/types/genesis.go @@ -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 +} diff --git a/x/bond/types/msg_bond.go b/x/bond/types/msg_bond.go index 789d5db..ae33cfc 100644 --- a/x/bond/types/msg_bond.go +++ b/x/bond/types/msg_bond.go @@ -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). diff --git a/x/bond/types/msg_mab.go b/x/bond/types/msg_mab.go new file mode 100644 index 0000000..934e121 --- /dev/null +++ b/x/bond/types/msg_mab.go @@ -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// +// -> 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() {} diff --git a/x/bond/types/types.go b/x/bond/types/types.go index e215ae8..ba21bd4 100644 --- a/x/bond/types/types.go +++ b/x/bond/types/types.go @@ -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 diff --git a/x/bond/types/types_test.go b/x/bond/types/types_test.go index 200915a..55b7e21 100644 --- a/x/bond/types/types_test.go +++ b/x/bond/types/types_test.go @@ -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) + } +} diff --git a/x/cover/keeper/keeper.go b/x/cover/keeper/keeper.go index baa6f1f..ed8a34d 100644 --- a/x/cover/keeper/keeper.go +++ b/x/cover/keeper/keeper.go @@ -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 diff --git a/x/cover/keeper/msg_server.go b/x/cover/keeper/msg_server.go index f4ddb5d..6ddfbce 100644 --- a/x/cover/keeper/msg_server.go +++ b/x/cover/keeper/msg_server.go @@ -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 +} diff --git a/x/cover/keeper/msg_server_simtest_test.go b/x/cover/keeper/msg_server_simtest_test.go index 855ffb1..a350fed 100644 --- a/x/cover/keeper/msg_server_simtest_test.go +++ b/x/cover/keeper/msg_server_simtest_test.go @@ -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") + } +} diff --git a/x/cover/types/expected_keepers.go b/x/cover/types/expected_keepers.go index 00482d9..e881ed4 100644 --- a/x/cover/types/expected_keepers.go +++ b/x/cover/types/expected_keepers.go @@ -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 diff --git a/x/cover/types/msg_cover.go b/x/cover/types/msg_cover.go index 8b35096..b5af787 100644 --- a/x/cover/types/msg_cover.go +++ b/x/cover/types/msg_cover.go @@ -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 diff --git a/x/cover/types/msg_voucher.go b/x/cover/types/msg_voucher.go new file mode 100644 index 0000000..ef14184 --- /dev/null +++ b/x/cover/types/msg_voucher.go @@ -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() {} diff --git a/x/cover/types/types.go b/x/cover/types/types.go index 397b5b6..4a512c0 100644 --- a/x/cover/types/types.go +++ b/x/cover/types/types.go @@ -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" +) diff --git a/x/cover/types/types_test.go b/x/cover/types/types_test.go index aec4f0b..399fc7b 100644 --- a/x/cover/types/types_test.go +++ b/x/cover/types/types_test.go @@ -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) + } +} diff --git a/x/standing/types/types.go b/x/standing/types/types.go index 8d8bf47..397a3ad 100644 --- a/x/standing/types/types.go +++ b/x/standing/types/types.go @@ -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) diff --git a/x/standing/types/types_test.go b/x/standing/types/types_test.go index ea02a66..0da7f45 100644 --- a/x/standing/types/types_test.go +++ b/x/standing/types/types_test.go @@ -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)") + } +}