Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fff74b2de1 | |||
| b6d7b1a9ec | |||
| 4eec2ff502 | |||
| 72cc922b3b | |||
| 907dc66d12 | |||
| a23856a9ee |
@@ -111,6 +111,15 @@ func TestLexiconMetaCoverNoBannedTerms(t *testing.T) {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
// Skip the walk-coverage fixture dir (G-013):
|
||||
// TestLexiconMetaCoverWalkCoverage creates
|
||||
// x/cover/.lexicon_fixture/ with synthetic banned-term .go
|
||||
// files. Those fixtures are test artifacts, NOT production
|
||||
// code; skip the dir to avoid a self-trip if cleanup is
|
||||
// delayed.
|
||||
if info.Name() == ".lexicon_fixture" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".go") {
|
||||
|
||||
@@ -43,6 +43,16 @@ func TestLexiconMetaNoBannedTermsInX(t *testing.T) {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
// Skip the lexicon_meta_cover walk-coverage fixture dir
|
||||
// (G-013): TestLexiconMetaCoverWalkCoverage creates
|
||||
// x/cover/.lexicon_fixture/ with synthetic banned-term .go
|
||||
// files to verify the Cover firewall's walk logic. Those
|
||||
// fixtures are test artifacts, NOT production code, and would
|
||||
// trip this project-wide firewall if scanned concurrently.
|
||||
// Skip the fixture dir to avoid the test-isolation race.
|
||||
if info.Name() == ".lexicon_fixture" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".go") {
|
||||
|
||||
@@ -43,6 +43,13 @@ type Keeper struct {
|
||||
watcherKeeper types.WatcherKeeper
|
||||
bondKeeper types.BondKeeper
|
||||
stillKeeper types.StillKeeper
|
||||
// paramsOverride is a simtest-grade Params override (nil = use
|
||||
// DefaultParams). A future P2+ will load the Params from the params
|
||||
// store; for now the handler uses DefaultParams unless an override is
|
||||
// set via SetParamsOverride (the D-086 simtest case (f) uses this to
|
||||
// restrict FactoryAllowedPhases to [Phase2, Phase3] only and reject a
|
||||
// Phase4 launch).
|
||||
paramsOverride *types.Params
|
||||
}
|
||||
|
||||
// NewKeeper constructs a new store-backed cover Keeper. The four expected-
|
||||
@@ -76,6 +83,22 @@ func (k *Keeper) SetBondKeeper(bk types.BondKeeper) { k.bondKeeper = bk }
|
||||
// SetStillKeeper sets the StillKeeper expected-keeper shim.
|
||||
func (k *Keeper) SetStillKeeper(stK types.StillKeeper) { k.stillKeeper = stK }
|
||||
|
||||
// SetParamsOverride sets a simtest-grade Params override (nil = use
|
||||
// DefaultParams). The D-086 simtest case (f) uses this to restrict
|
||||
// FactoryAllowedPhases to [Phase2, Phase3] only and reject a Phase4
|
||||
// launch. A future P2+ will replace this with a params-store load.
|
||||
func (k *Keeper) SetParamsOverride(p types.Params) { k.paramsOverride = &p }
|
||||
|
||||
// Params returns the effective Params (the override if set, else
|
||||
// DefaultParams). The handler calls this to get FactoryAllowedPhases +
|
||||
// PoolStandingGate.
|
||||
func (k Keeper) Params() types.Params {
|
||||
if k.paramsOverride != nil {
|
||||
return *k.paramsOverride
|
||||
}
|
||||
return types.DefaultParams()
|
||||
}
|
||||
|
||||
// StoreKey returns the keeper's store key (exported for simtest access to
|
||||
// the underlying KVStore, e.g. to inject corrupt bytes for marshal-error
|
||||
// coverage). Mirrors the x/hub simtest pattern (the simtest reaches the
|
||||
@@ -201,3 +224,242 @@ func prefixEnd(prefix []byte) []byte {
|
||||
// All bytes were 0xFF; return nil (iterate to end of store).
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- P2: CoverCharter / PoolCouncil / CoverCallVote / CharterAmendment stores --
|
||||
//
|
||||
// (REQ-052, REQ-062). Four new stores keyed by ID-string. The
|
||||
// CoverCharter store is keyed by CharterID; the PoolCouncil store is keyed
|
||||
// by PoolID (one council per pool); the CoverCallVote store is keyed by
|
||||
// VoteID; the CharterAmendment store is keyed by AmendmentID. All four
|
||||
// use the same JSON-marshal pattern as the P1 CoverPool / CoverCall
|
||||
// stores. The Get/Set/All helpers mirror the P1 helpers.
|
||||
|
||||
var charterKeyPrefix = []byte("charter/")
|
||||
|
||||
func charterKey(charterID string) []byte {
|
||||
return append(charterKeyPrefix, []byte(charterID)...)
|
||||
}
|
||||
|
||||
// GetCoverCharter loads a CoverCharter by charter-id. Returns the charter
|
||||
// and true if found, or zero value + false if not.
|
||||
func (k Keeper) GetCoverCharter(ctx sdk.Context, charterID string) (types.CoverCharter, bool) {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
bz := store.Get(charterKey(charterID))
|
||||
if bz == nil {
|
||||
return types.CoverCharter{}, false
|
||||
}
|
||||
var c types.CoverCharter
|
||||
if err := json.Unmarshal(bz, &c); err != nil {
|
||||
return types.CoverCharter{}, false
|
||||
}
|
||||
return c, true
|
||||
}
|
||||
|
||||
// SetCoverCharter persists a CoverCharter by charter-id.
|
||||
func (k Keeper) SetCoverCharter(ctx sdk.Context, c types.CoverCharter) {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
bz, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("cover: marshal charter %q: %v", c.CharterID, err))
|
||||
}
|
||||
store.Set(charterKey(c.CharterID), bz)
|
||||
}
|
||||
|
||||
// AllCoverCharters returns all persisted CoverCharter records (iteration
|
||||
// helper, unordered).
|
||||
func (k Keeper) AllCoverCharters(ctx sdk.Context) []types.CoverCharter {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
iterator := store.Iterator(charterKeyPrefix, prefixEnd(charterKeyPrefix))
|
||||
defer iterator.Close()
|
||||
out := []types.CoverCharter{}
|
||||
for ; iterator.Valid(); iterator.Next() {
|
||||
var c types.CoverCharter
|
||||
if err := json.Unmarshal(iterator.Value(), &c); err == nil {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var councilKeyPrefix = []byte("council/")
|
||||
|
||||
func councilKey(poolID string) []byte {
|
||||
return append(councilKeyPrefix, []byte(poolID)...)
|
||||
}
|
||||
|
||||
// GetPoolCouncil loads a PoolCouncil by pool-id. Returns the council and
|
||||
// true if found, or zero value + false if not.
|
||||
func (k Keeper) GetPoolCouncil(ctx sdk.Context, poolID string) (types.PoolCouncil, bool) {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
bz := store.Get(councilKey(poolID))
|
||||
if bz == nil {
|
||||
return types.PoolCouncil{}, false
|
||||
}
|
||||
var c types.PoolCouncil
|
||||
if err := json.Unmarshal(bz, &c); err != nil {
|
||||
return types.PoolCouncil{}, false
|
||||
}
|
||||
return c, true
|
||||
}
|
||||
|
||||
// SetPoolCouncil persists a PoolCouncil by pool-id.
|
||||
func (k Keeper) SetPoolCouncil(ctx sdk.Context, c types.PoolCouncil) {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
bz, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("cover: marshal council for pool %q: %v", c.PoolID, err))
|
||||
}
|
||||
store.Set(councilKey(c.PoolID), bz)
|
||||
}
|
||||
|
||||
// AllPoolCouncils returns all persisted PoolCouncil records (iteration
|
||||
// helper, unordered).
|
||||
func (k Keeper) AllPoolCouncils(ctx sdk.Context) []types.PoolCouncil {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
iterator := store.Iterator(councilKeyPrefix, prefixEnd(councilKeyPrefix))
|
||||
defer iterator.Close()
|
||||
out := []types.PoolCouncil{}
|
||||
for ; iterator.Valid(); iterator.Next() {
|
||||
var c types.PoolCouncil
|
||||
if err := json.Unmarshal(iterator.Value(), &c); err == nil {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var voteKeyPrefix = []byte("vote/")
|
||||
|
||||
func voteKey(voteID string) []byte {
|
||||
return append(voteKeyPrefix, []byte(voteID)...)
|
||||
}
|
||||
|
||||
// GetCoverCallVote loads a CoverCallVote by vote-id. Returns the vote and
|
||||
// true if found, or zero value + false if not.
|
||||
func (k Keeper) GetCoverCallVote(ctx sdk.Context, voteID string) (types.CoverCallVote, bool) {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
bz := store.Get(voteKey(voteID))
|
||||
if bz == nil {
|
||||
return types.CoverCallVote{}, false
|
||||
}
|
||||
var v types.CoverCallVote
|
||||
if err := json.Unmarshal(bz, &v); err != nil {
|
||||
return types.CoverCallVote{}, false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
// SetCoverCallVote persists a CoverCallVote by vote-id.
|
||||
func (k Keeper) SetCoverCallVote(ctx sdk.Context, v types.CoverCallVote) {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
bz, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("cover: marshal vote %q: %v", v.VoteID, err))
|
||||
}
|
||||
store.Set(voteKey(v.VoteID), bz)
|
||||
}
|
||||
|
||||
// AllCoverCallVotes returns all persisted CoverCallVote records (iteration
|
||||
// helper, unordered).
|
||||
func (k Keeper) AllCoverCallVotes(ctx sdk.Context) []types.CoverCallVote {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
iterator := store.Iterator(voteKeyPrefix, prefixEnd(voteKeyPrefix))
|
||||
defer iterator.Close()
|
||||
out := []types.CoverCallVote{}
|
||||
for ; iterator.Valid(); iterator.Next() {
|
||||
var v types.CoverCallVote
|
||||
if err := json.Unmarshal(iterator.Value(), &v); err == nil {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var amendmentKeyPrefix = []byte("amendment/")
|
||||
|
||||
func amendmentKey(amendmentID string) []byte {
|
||||
return append(amendmentKeyPrefix, []byte(amendmentID)...)
|
||||
}
|
||||
|
||||
// GetCharterAmendment loads a CharterAmendment by amendment-id. Returns
|
||||
// the amendment and true if found, or zero value + false if not.
|
||||
func (k Keeper) GetCharterAmendment(ctx sdk.Context, amendmentID string) (types.CharterAmendment, bool) {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
bz := store.Get(amendmentKey(amendmentID))
|
||||
if bz == nil {
|
||||
return types.CharterAmendment{}, false
|
||||
}
|
||||
var a types.CharterAmendment
|
||||
if err := json.Unmarshal(bz, &a); err != nil {
|
||||
return types.CharterAmendment{}, false
|
||||
}
|
||||
return a, true
|
||||
}
|
||||
|
||||
// SetCharterAmendment persists a CharterAmendment by amendment-id.
|
||||
func (k Keeper) SetCharterAmendment(ctx sdk.Context, a types.CharterAmendment) {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
bz, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("cover: marshal amendment %q: %v", a.AmendmentID, err))
|
||||
}
|
||||
store.Set(amendmentKey(a.AmendmentID), bz)
|
||||
}
|
||||
|
||||
// AllCharterAmendments returns all persisted CharterAmendment records
|
||||
// (iteration helper, unordered).
|
||||
func (k Keeper) AllCharterAmendments(ctx sdk.Context) []types.CharterAmendment {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
iterator := store.Iterator(amendmentKeyPrefix, prefixEnd(amendmentKeyPrefix))
|
||||
defer iterator.Close()
|
||||
out := []types.CharterAmendment{}
|
||||
for ; iterator.Valid(); iterator.Next() {
|
||||
var a types.CharterAmendment
|
||||
if err := json.Unmarshal(iterator.Value(), &a); err == nil {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CoolCharterAmendment transitions a Proposed CharterAmendment to Cooled
|
||||
// if the 7-day cooling has elapsed (REQ-052). Returns an error if the
|
||||
// amendment is not found, not in the Proposed status, or the cooling has
|
||||
// not elapsed. The handler (or simtest) calls this after the cooling
|
||||
// period; a separate RatifyCharterAmendment transitions to Ratified.
|
||||
func (k Keeper) CoolCharterAmendment(ctx sdk.Context, amendmentID string, now int64) (types.CharterAmendment, error) {
|
||||
a, ok := k.GetCharterAmendment(ctx, amendmentID)
|
||||
if !ok {
|
||||
return types.CharterAmendment{}, fmt.Errorf("cover: amendment %q not found", amendmentID)
|
||||
}
|
||||
if a.Status != types.AmendmentProposed {
|
||||
return types.CharterAmendment{}, fmt.Errorf("cover: amendment %q status %q (only Proposed can be Cooled)", amendmentID, a.Status)
|
||||
}
|
||||
if now-a.ProposedAt < types.CharterAmendmentCoolingSeconds {
|
||||
return types.CharterAmendment{}, fmt.Errorf("cover: amendment %q cooling not elapsed (now=%d ProposedAt=%d, need %d seconds)", amendmentID, now, a.ProposedAt, types.CharterAmendmentCoolingSeconds)
|
||||
}
|
||||
a.Status = types.AmendmentCooled
|
||||
a.CooledAt = now
|
||||
k.SetCharterAmendment(ctx, a)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// RatifyCharterAmendment transitions a Cooled CharterAmendment to
|
||||
// Ratified (REQ-052). Returns an error if the amendment is not found or
|
||||
// not in the Cooled status. The Pool supermajority + Watcher + Counsel
|
||||
// are checked upstream (the handler); this helper does the state
|
||||
// transition + appends the amendment to the parent charter's Amendments
|
||||
// slice.
|
||||
func (k Keeper) RatifyCharterAmendment(ctx sdk.Context, amendmentID string, now int64) (types.CharterAmendment, error) {
|
||||
a, ok := k.GetCharterAmendment(ctx, amendmentID)
|
||||
if !ok {
|
||||
return types.CharterAmendment{}, fmt.Errorf("cover: amendment %q not found", amendmentID)
|
||||
}
|
||||
if a.Status != types.AmendmentCooled {
|
||||
return types.CharterAmendment{}, fmt.Errorf("cover: amendment %q status %q (only Cooled can be Ratified)", amendmentID, a.Status)
|
||||
}
|
||||
a.Status = types.AmendmentRatified
|
||||
a.RatifiedAt = now
|
||||
k.SetCharterAmendment(ctx, a)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
+367
-15
@@ -1,15 +1,16 @@
|
||||
package keeper
|
||||
|
||||
// msg_server.go implements the cover module's MsgServer (REQ-046, REQ-047,
|
||||
// REQ-049, REQ-050, REQ-055, D-077, D-079, D-086, D-088, D-089). The
|
||||
// MsgServer wraps the Keeper + the four expected-keeper shims (already on
|
||||
// the Keeper: StandingKeeper, WatcherKeeper, BondKeeper, StillKeeper).
|
||||
// REQ-049, REQ-050, REQ-052, REQ-055, REQ-056, REQ-062, REQ-048, D-077,
|
||||
// D-079, D-086, D-088, D-089, D-090). The MsgServer wraps the Keeper + the
|
||||
// four expected-keeper shims (already on the Keeper: StandingKeeper,
|
||||
// WatcherKeeper, BondKeeper, StillKeeper).
|
||||
//
|
||||
// Each method returns a (*Response, error). Handler state-machine ordering
|
||||
// is enforced: ValidateBasic -> handler authz/gate -> state mutation ->
|
||||
// ctx.EventManager().EmitEvent.
|
||||
//
|
||||
// Handler set:
|
||||
// P1 handler set:
|
||||
// - LaunchCoverPool: D-086 category phase check + D-077 Standing gate +
|
||||
// reserve floor + Watcher attestation; persists the CoverPool.
|
||||
// - RouteCoverFee: D-079 Anti-Crowding-Out firewall + category-tag match +
|
||||
@@ -18,13 +19,28 @@ package keeper
|
||||
// - FileCoverCall: P1 scaffold — persists the CoverCall + emits an event;
|
||||
// P4 adds the Voucher adjudication + no-self-adjudication + slashing.
|
||||
//
|
||||
// P2 handler set:
|
||||
// - SignCoverCharter: D-090(1) Bill of Rights gate (ValidateBasic) +
|
||||
// idempotency + Watcher attestation; persists the CoverCharter.
|
||||
// - AmendCoverCharter: creates a CharterAmendment with Status=Proposed;
|
||||
// the 7-day cooling is enforced by CoolCharterAmendment /
|
||||
// RatifyCharterAmendment (keeper helpers).
|
||||
// - ElectPoolMason: loads/creates the PoolCouncil + adds the Mason (max
|
||||
// 3 — a 4th is REJECTED).
|
||||
// - VoteCoverCall: loads the CoverCall + Watcher-observer-present check
|
||||
// for a CallVoteYes; persists the CoverCallVote.
|
||||
// - AmendPoolStandingGate: D-090(3) dual check (ValidateBasic + handler
|
||||
// re-check) + updates the pool's PoolStandingGate.
|
||||
// - EscalateReserveCeiling: 12-month age check + Watcher attestation +
|
||||
// sets the pool's reserve target to CoverReserveCeilingAnnualContribX.
|
||||
//
|
||||
// Nil-shim behavior (simtest wiring): a nil StandingKeeper skips the D-077
|
||||
// gate (the handler still mutates state — the simtest documents the wiring
|
||||
// contract); a nil WatcherKeeper skips the launch attestation; a nil
|
||||
// StillKeeper skips the auto-Still recording (the pool's PoolPaused flag is
|
||||
// still set, just the Still event is not recorded in a still store); a nil
|
||||
// BondKeeper is the P1 default (the P4 handler will reject a nil shim as a
|
||||
// wiring error when the P4 MAB check is wired).
|
||||
// contract); a nil WatcherKeeper skips the launch/charter/escalation
|
||||
// attestation; a nil StillKeeper skips the auto-Still recording (the pool's
|
||||
// PoolPaused flag is still set, just the Still event is not recorded in a
|
||||
// still store); a nil BondKeeper is the P1 default (the P4 handler will
|
||||
// reject a nil shim as a wiring error when the P4 MAB check is wired).
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -125,11 +141,12 @@ func (s msgServer) LaunchCoverPool(ctx interface{}, msg *types.MsgLaunchCoverPoo
|
||||
return nil, fmt.Errorf("cover: pool %q already exists", msg.PoolID)
|
||||
}
|
||||
|
||||
// Load the Params (P1: DefaultParams — the live Params store is deferred;
|
||||
// the handler uses DefaultParams for the FactoryAllowedPhases + the
|
||||
// PoolStandingGate floor). A future P2 will load the Params from the
|
||||
// params store; P1 ships the default.
|
||||
params := types.DefaultParams()
|
||||
// Load the Params (the effective Params: the override if set, else
|
||||
// DefaultParams). The D-086 simtest case (f) uses the override to
|
||||
// restrict FactoryAllowedPhases to [Phase2, Phase3] only and reject a
|
||||
// Phase4 launch. A future P2+ will load the Params from the params
|
||||
// store; for now the keeper holds the override.
|
||||
params := s.Keeper.Params()
|
||||
if err := params.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("cover: params invalid: %w", err)
|
||||
}
|
||||
@@ -190,7 +207,7 @@ func (s msgServer) LaunchCoverPool(ctx interface{}, msg *types.MsgLaunchCoverPoo
|
||||
CharterHash: msg.CharterHash,
|
||||
FactoryAllowedPhases: params.FactoryAllowedPhases,
|
||||
PoolStandingGate: params.PoolStandingGate,
|
||||
CreatedAt: sdkCtx.BlockHeight(),
|
||||
CreatedAt: sdkCtx.BlockTime().Unix(),
|
||||
}
|
||||
s.Keeper.SetCoverPool(sdkCtx, pool)
|
||||
|
||||
@@ -352,3 +369,338 @@ func (s msgServer) FileCoverCall(ctx interface{}, msg *types.MsgFileCoverCall) (
|
||||
))
|
||||
return &types.MsgFileCoverCallResponse{}, nil
|
||||
}
|
||||
|
||||
// --- P2: SignCoverCharter -----------------------------------------------------
|
||||
|
||||
// SignCoverCharter signs a Cover-Charter for a Pool (REQ-052, REQ-056,
|
||||
// D-090(1)). The handler enforces:
|
||||
// 1. ValidateBasic (stateless — includes the D-090(1) Bill of Rights
|
||||
// gate: any WaivedRights element REJECTS the signing).
|
||||
// 2. Idempotency: CharterID must not already exist.
|
||||
// 3. The referenced Pool must exist (the charter binds to a pool).
|
||||
// 4. WatcherKeeper.Attest on the charter witness hash (a nil WatcherKeeper
|
||||
// skips; an empty WatcherWitnessHash skips).
|
||||
// 5. Persist the CoverCharter + link the pool's CharterRef.
|
||||
// 6. Emit cover.charter_signed.
|
||||
func (s msgServer) SignCoverCharter(ctx interface{}, msg *types.MsgSignCoverCharter) (*types.MsgSignCoverCharterResponse, error) {
|
||||
if err := msg.ValidateBasic(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sdkCtx := unwrapCtx(ctx)
|
||||
|
||||
// Idempotency: charter-id must not already exist.
|
||||
if _, ok := s.Keeper.GetCoverCharter(sdkCtx, msg.CharterID); ok {
|
||||
return nil, fmt.Errorf("cover: charter %q already exists", msg.CharterID)
|
||||
}
|
||||
|
||||
// The referenced pool must exist (the charter binds to a pool).
|
||||
pool, ok := s.Keeper.GetCoverPool(sdkCtx, msg.PoolID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("cover: pool %q not found (SignCoverCharter rejected)", msg.PoolID)
|
||||
}
|
||||
|
||||
// Watcher attestation over the witness hash (REQ-052). A nil
|
||||
// WatcherKeeper skips; an empty WatcherWitnessHash skips (the charter
|
||||
// may be signed without a witness in simtest).
|
||||
if s.Keeper.watcherKeeper != nil && len(msg.WatcherWitnessHash) > 0 {
|
||||
if _, err := s.Keeper.watcherKeeper.Attest(msg.PoolID, msg.WatcherWitnessHash); err != nil {
|
||||
return nil, fmt.Errorf("cover: Watcher attestation for charter %q: %w (REQ-052)", msg.CharterID, err)
|
||||
}
|
||||
}
|
||||
|
||||
charter := types.CoverCharter{
|
||||
CharterID: msg.CharterID,
|
||||
PoolID: msg.PoolID,
|
||||
StatementOfBeliefsHash: msg.StatementOfBeliefsHash,
|
||||
DisputePath: msg.DisputePath,
|
||||
Gate: msg.Gate,
|
||||
HoldingPeriodDays: msg.HoldingPeriodDays,
|
||||
HostReachID: msg.HostReachID,
|
||||
WatcherWitnessHash: msg.WatcherWitnessHash,
|
||||
Amendments: []types.CharterAmendment{},
|
||||
WaivedRights: msg.WaivedRights,
|
||||
}
|
||||
s.Keeper.SetCoverCharter(sdkCtx, charter)
|
||||
|
||||
// Link the pool's CharterRef.
|
||||
pool.CharterRef = msg.CharterID
|
||||
s.Keeper.SetCoverPool(sdkCtx, pool)
|
||||
|
||||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||
"cover.charter_signed",
|
||||
sdk.NewAttribute("charter_id", msg.CharterID),
|
||||
sdk.NewAttribute("pool_id", msg.PoolID),
|
||||
sdk.NewAttribute("host_reach_id", msg.HostReachID),
|
||||
))
|
||||
return &types.MsgSignCoverCharterResponse{}, nil
|
||||
}
|
||||
|
||||
// --- P2: AmendCoverCharter ----------------------------------------------------
|
||||
|
||||
// AmendCoverCharter files a Charter amendment (REQ-052). The handler
|
||||
// enforces:
|
||||
// 1. ValidateBasic (stateless).
|
||||
// 2. The referenced charter must exist.
|
||||
// 3. Create a CharterAmendment with Status=AmendmentProposed,
|
||||
// ProposedAt=now. Persist the amendment + append to the charter's
|
||||
// Amendments slice.
|
||||
// 4. Emit cover.charter_amend_proposed.
|
||||
//
|
||||
// The 7-day cooling is enforced by CoolCharterAmendment /
|
||||
// RatifyCharterAmendment (keeper helpers) — a simtest time-advance or a
|
||||
// separate handler transitions the amendment to Cooled then Ratified.
|
||||
func (s msgServer) AmendCoverCharter(ctx interface{}, msg *types.MsgAmendCoverCharter) (*types.MsgAmendCoverCharterResponse, error) {
|
||||
if err := msg.ValidateBasic(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sdkCtx := unwrapCtx(ctx)
|
||||
|
||||
charter, ok := s.Keeper.GetCoverCharter(sdkCtx, msg.CharterID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("cover: charter %q not found (AmendCoverCharter rejected)", msg.CharterID)
|
||||
}
|
||||
|
||||
// Idempotency: amendment-id must not already exist.
|
||||
if _, ok := s.Keeper.GetCharterAmendment(sdkCtx, msg.AmendmentID); ok {
|
||||
return nil, fmt.Errorf("cover: amendment %q already exists", msg.AmendmentID)
|
||||
}
|
||||
|
||||
amendment := types.CharterAmendment{
|
||||
AmendmentID: msg.AmendmentID,
|
||||
Description: msg.Description,
|
||||
Status: types.AmendmentProposed,
|
||||
ProposedAt: sdkCtx.BlockTime().Unix(),
|
||||
}
|
||||
s.Keeper.SetCharterAmendment(sdkCtx, amendment)
|
||||
|
||||
// Append the amendment to the charter's Amendments slice + persist.
|
||||
charter.Amendments = append(charter.Amendments, amendment)
|
||||
s.Keeper.SetCoverCharter(sdkCtx, charter)
|
||||
|
||||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||
"cover.charter_amend_proposed",
|
||||
sdk.NewAttribute("charter_id", msg.CharterID),
|
||||
sdk.NewAttribute("amendment_id", msg.AmendmentID),
|
||||
))
|
||||
return &types.MsgAmendCoverCharterResponse{}, nil
|
||||
}
|
||||
|
||||
// --- P2: ElectPoolMason -------------------------------------------------------
|
||||
|
||||
// ElectPoolMason elects a Mason to the Pool Council (REQ-062). The
|
||||
// handler enforces:
|
||||
// 1. ValidateBasic (stateless).
|
||||
// 2. The referenced pool must exist.
|
||||
// 3. Load or create the PoolCouncil. Add the MasonReachID to
|
||||
// ElectedMasonReachIDs (max PoolCouncilMaxMasons = 3 — a 4th is
|
||||
// REJECTED). Reject a duplicate MasonReachID (already elected).
|
||||
// 4. Persist the PoolCouncil + link the pool's CouncilRef.
|
||||
// 5. Emit cover.pool_mason_elected.
|
||||
func (s msgServer) ElectPoolMason(ctx interface{}, msg *types.MsgElectPoolMason) (*types.MsgElectPoolMasonResponse, 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 (ElectPoolMason rejected)", msg.PoolID)
|
||||
}
|
||||
|
||||
council, exists := s.Keeper.GetPoolCouncil(sdkCtx, msg.PoolID)
|
||||
if !exists {
|
||||
council = types.PoolCouncil{
|
||||
PoolID: msg.PoolID,
|
||||
HostReachID: pool.HostReachID,
|
||||
ElectedMasonReachIDs: [3]string{},
|
||||
}
|
||||
}
|
||||
|
||||
// Reject a duplicate MasonReachID (already elected).
|
||||
for _, m := range council.ElectedMasonReachIDs {
|
||||
if m == msg.MasonReachID {
|
||||
return nil, fmt.Errorf("cover: mason %q already elected to pool %q council (REQ-062)", msg.MasonReachID, msg.PoolID)
|
||||
}
|
||||
}
|
||||
|
||||
// Find the first empty slot; if all 3 are filled, REJECT (max
|
||||
// PoolCouncilMaxMasons).
|
||||
slotIdx := -1
|
||||
for i, m := range council.ElectedMasonReachIDs {
|
||||
if m == "" {
|
||||
slotIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if slotIdx == -1 {
|
||||
return nil, fmt.Errorf("cover: pool %q council already has %d masons (REQ-062 max %d)", msg.PoolID, types.PoolCouncilMaxMasons, types.PoolCouncilMaxMasons)
|
||||
}
|
||||
council.ElectedMasonReachIDs[slotIdx] = msg.MasonReachID
|
||||
s.Keeper.SetPoolCouncil(sdkCtx, council)
|
||||
|
||||
// Link the pool's CouncilRef (the council is keyed by pool-id, so the
|
||||
// ref is the pool-id itself).
|
||||
pool.CouncilRef = msg.PoolID
|
||||
s.Keeper.SetCoverPool(sdkCtx, pool)
|
||||
|
||||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||
"cover.pool_mason_elected",
|
||||
sdk.NewAttribute("pool_id", msg.PoolID),
|
||||
sdk.NewAttribute("mason_reach_id", msg.MasonReachID),
|
||||
sdk.NewAttribute("slot", fmt.Sprintf("%d", slotIdx)),
|
||||
))
|
||||
return &types.MsgElectPoolMasonResponse{}, nil
|
||||
}
|
||||
|
||||
// --- P2: VoteCoverCall --------------------------------------------------------
|
||||
|
||||
// VoteCoverCall votes on a Cover Call (REQ-062). The handler enforces:
|
||||
// 1. ValidateBasic (stateless — includes the valid VoteOption check).
|
||||
// 2. The referenced CoverCall must exist.
|
||||
// 3. The Watcher-observer-present check: if VoteOption == CallVoteYes and
|
||||
// WatcherObserverPresent == false, REJECT (majority requires observer
|
||||
// present — REQ-062).
|
||||
// 4. Idempotency: VoteID must not already exist.
|
||||
// 5. Persist the CoverCallVote. Emit cover.cover_call_voted.
|
||||
func (s msgServer) VoteCoverCall(ctx interface{}, msg *types.MsgVoteCoverCall) (*types.MsgVoteCoverCallResponse, error) {
|
||||
if err := msg.ValidateBasic(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sdkCtx := unwrapCtx(ctx)
|
||||
|
||||
// The referenced CoverCall must exist.
|
||||
if _, ok := s.Keeper.GetCoverCall(sdkCtx, msg.CallID); !ok {
|
||||
return nil, fmt.Errorf("cover: call %q not found (VoteCoverCall rejected)", msg.CallID)
|
||||
}
|
||||
|
||||
// The Watcher-observer-present check (REQ-062): a CallVoteYes requires
|
||||
// the Watcher observer to be present. A CallVoteNo / CallVoteAbstain
|
||||
// does NOT require the observer (only an affirmative vote demands the
|
||||
// witness).
|
||||
if msg.VoteOption == types.CallVoteYes && !msg.WatcherObserverPresent {
|
||||
return nil, fmt.Errorf("cover: CallVoteYes on call %q requires Watcher observer present (REQ-062)", msg.CallID)
|
||||
}
|
||||
|
||||
// Idempotency: vote-id must not already exist.
|
||||
if _, ok := s.Keeper.GetCoverCallVote(sdkCtx, msg.VoteID); ok {
|
||||
return nil, fmt.Errorf("cover: vote %q already exists", msg.VoteID)
|
||||
}
|
||||
|
||||
vote := types.CoverCallVote{
|
||||
VoteID: msg.VoteID,
|
||||
CallID: msg.CallID,
|
||||
PoolID: msg.PoolID,
|
||||
VoterReachID: msg.VoterReachID,
|
||||
VoteOption: msg.VoteOption,
|
||||
WatcherObserverPresent: msg.WatcherObserverPresent,
|
||||
VotedAt: sdkCtx.BlockTime().Unix(),
|
||||
}
|
||||
s.Keeper.SetCoverCallVote(sdkCtx, vote)
|
||||
|
||||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||
"cover.cover_call_voted",
|
||||
sdk.NewAttribute("vote_id", msg.VoteID),
|
||||
sdk.NewAttribute("call_id", msg.CallID),
|
||||
sdk.NewAttribute("pool_id", msg.PoolID),
|
||||
sdk.NewAttribute("voter_reach_id", msg.VoterReachID),
|
||||
sdk.NewAttribute("vote_option", string(msg.VoteOption)),
|
||||
))
|
||||
return &types.MsgVoteCoverCallResponse{}, nil
|
||||
}
|
||||
|
||||
// --- P2: AmendPoolStandingGate ------------------------------------------------
|
||||
|
||||
// AmendPoolStandingGate amends a Pool's Standing gate (D-090(3)). The
|
||||
// handler enforces:
|
||||
// 1. ValidateBasic (stateless — includes the D-090(3) dual check:
|
||||
// NewGate >= CoverStandingGateTrusted).
|
||||
// 2. The referenced pool must exist.
|
||||
// 3. D-090(3) handler re-check (defense in depth): NewGate >=
|
||||
// CoverStandingGateTrusted. ValidateBasic already checked, but the
|
||||
// handler re-checks in case of a future Params-bypass.
|
||||
// 4. Update the pool's PoolStandingGate. Persist.
|
||||
// 5. Emit cover.pool_standing_gate_amended.
|
||||
func (s msgServer) AmendPoolStandingGate(ctx interface{}, msg *types.MsgAmendPoolStandingGate) (*types.MsgAmendPoolStandingGateResponse, 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 (AmendPoolStandingGate rejected)", msg.PoolID)
|
||||
}
|
||||
|
||||
// D-090(3) handler re-check (defense in depth — ValidateBasic already
|
||||
// checked, but the handler re-checks in case of a future Params-bypass).
|
||||
if msg.NewGate < types.CoverStandingGateTrusted {
|
||||
return nil, fmt.Errorf("cover: NewGate %.2f < CoverStandingGateTrusted %.2f (D-090(3) handler re-check: a pool may tighten the gate but never lower it)", msg.NewGate, types.CoverStandingGateTrusted)
|
||||
}
|
||||
|
||||
pool.PoolStandingGate = msg.NewGate
|
||||
s.Keeper.SetCoverPool(sdkCtx, pool)
|
||||
|
||||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||
"cover.pool_standing_gate_amended",
|
||||
sdk.NewAttribute("pool_id", msg.PoolID),
|
||||
sdk.NewAttribute("new_gate", fmt.Sprintf("%.2f", msg.NewGate)),
|
||||
))
|
||||
return &types.MsgAmendPoolStandingGateResponse{}, nil
|
||||
}
|
||||
|
||||
// --- P2: EscalateReserveCeiling -----------------------------------------------
|
||||
|
||||
// EscalateReserveCeiling escalates a Pool's reserve target to the
|
||||
// CoverReserveCeilingAnnualContribX (REQ-048). The handler enforces:
|
||||
// 1. ValidateBasic (stateless).
|
||||
// 2. The referenced pool must exist.
|
||||
// 3. 12-month age check: now - pool.CreatedAt >= ReserveCeilingAgeSeconds
|
||||
// (365 days). A fresh pool is REJECTED. NOTE: pool.CreatedAt is set to
|
||||
// sdkCtx.BlockHeight() at launch in P1; for the age check we use
|
||||
// BlockTime().Unix() - pool.CreatedAt where pool.CreatedAt is
|
||||
// interpreted as a unix timestamp (the simtest sets CreatedAt to a
|
||||
// unix timestamp to satisfy this check).
|
||||
// 4. Set the pool's ReserveAnnualContribRatio to
|
||||
// CoverReserveCeilingAnnualContribX (2.5).
|
||||
// 5. WatcherKeeper.Attest (a nil WatcherKeeper skips).
|
||||
// 6. Persist the updated pool. Emit cover.reserve_ceiling_escalated.
|
||||
func (s msgServer) EscalateReserveCeiling(ctx interface{}, msg *types.MsgEscalateReserveCeiling) (*types.MsgEscalateReserveCeilingResponse, 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 (EscalateReserveCeiling rejected)", msg.PoolID)
|
||||
}
|
||||
|
||||
// 12-month age check (REQ-048): the pool must have >= 365 days of
|
||||
// operating history before the reserve target can be escalated to the
|
||||
// ceiling. pool.CreatedAt is interpreted as a unix timestamp (the
|
||||
// simtest sets it accordingly).
|
||||
now := sdkCtx.BlockTime().Unix()
|
||||
if now-pool.CreatedAt < types.ReserveCeilingAgeSeconds {
|
||||
return nil, fmt.Errorf("cover: pool %q age %d seconds < %d seconds (REQ-048: 12-month operating history required for reserve ceiling escalation)", msg.PoolID, now-pool.CreatedAt, types.ReserveCeilingAgeSeconds)
|
||||
}
|
||||
|
||||
// Set the pool's reserve target to the ceiling.
|
||||
pool.ReserveAnnualContribRatio = types.CoverReserveCeilingAnnualContribX
|
||||
|
||||
// Watcher attestation (REQ-048). A nil WatcherKeeper skips.
|
||||
if s.Keeper.watcherKeeper != nil {
|
||||
payload := []byte(fmt.Sprintf("cover.escalate:%s:%.2f", msg.PoolID, types.CoverReserveCeilingAnnualContribX))
|
||||
if _, err := s.Keeper.watcherKeeper.Attest(msg.PoolID, payload); err != nil {
|
||||
return nil, fmt.Errorf("cover: Watcher attestation for reserve ceiling escalation on pool %q: %w (REQ-048)", msg.PoolID, err)
|
||||
}
|
||||
}
|
||||
|
||||
s.Keeper.SetCoverPool(sdkCtx, pool)
|
||||
|
||||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||
"cover.reserve_ceiling_escalated",
|
||||
sdk.NewAttribute("pool_id", msg.PoolID),
|
||||
sdk.NewAttribute("reserve_annual_contrib_ratio", fmt.Sprintf("%.2f", types.CoverReserveCeilingAnnualContribX)),
|
||||
))
|
||||
return &types.MsgEscalateReserveCeilingResponse{}, nil
|
||||
}
|
||||
|
||||
@@ -227,8 +227,9 @@ func TestLaunchCoverPoolSuccess(t *testing.T) {
|
||||
if p.PoolStandingGate != types.CoverStandingGateTrusted {
|
||||
t.Errorf("PoolStandingGate = %.2f, want %.2f", p.PoolStandingGate, types.CoverStandingGateTrusted)
|
||||
}
|
||||
if len(p.FactoryAllowedPhases) != 1 || p.FactoryAllowedPhases[0] != types.Phase2 {
|
||||
t.Errorf("FactoryAllowedPhases = %v, want [Phase2] (D-086)", p.FactoryAllowedPhases)
|
||||
// D-086 P2: DefaultParams FactoryAllowedPhases = [Phase2, Phase3, Phase4].
|
||||
if len(p.FactoryAllowedPhases) != 3 {
|
||||
t.Errorf("FactoryAllowedPhases = %v, want [Phase2 Phase3 Phase4] (D-086 P2)", p.FactoryAllowedPhases)
|
||||
}
|
||||
if !hasEvent(ctx, "cover.pool_launched") {
|
||||
t.Error("cover.pool_launched event not emitted")
|
||||
@@ -282,12 +283,48 @@ func TestLaunchCoverPoolRejectedBelowReserveFloor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLaunchCoverPoolRejectedOutOfPhase (case g, D-086) asserts a launch with
|
||||
// a Phase3 category (EquipmentLoss) is REJECTED when FactoryAllowedPhases =
|
||||
// [Phase2] only (the P1 default).
|
||||
// TestLaunchCoverPoolRejectedOutOfPhase (case f, D-086 P2) asserts a launch
|
||||
// with a Phase4 category (CyberSkimming) is REJECTED when the Params
|
||||
// override restricts FactoryAllowedPhases to [Phase2, Phase3] only. The P2
|
||||
// DefaultParams allows all three phases; this test overrides to [Phase2,
|
||||
// Phase3] to exercise the D-086 phase-check rejection path.
|
||||
func TestLaunchCoverPoolRejectedOutOfPhase(t *testing.T) {
|
||||
ctx, sk, _, _, _, _, k := newSimtestContext(t)
|
||||
// Override Params to [Phase2, Phase3] only (the D-086 P2 simtest case
|
||||
// (f) — DefaultParams now allows all three phases; this test restricts
|
||||
// to [Phase2, Phase3] to reject a Phase4 launch).
|
||||
k.SetParamsOverride(types.Params{
|
||||
FactoryAllowedPhases: []types.CoverCategoryPhase{types.Phase2, types.Phase3},
|
||||
PoolStandingGate: types.CoverStandingGateTrusted,
|
||||
})
|
||||
// Even with a passing Standing gate, the phase check rejects first.
|
||||
sk.buckets = map[string]struct {
|
||||
bucket string
|
||||
score float64
|
||||
}{
|
||||
"host-1/CyberSkimming": {"Trusted", 4.0},
|
||||
}
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
|
||||
_, err := srv.LaunchCoverPool(ctx, &types.MsgLaunchCoverPool{
|
||||
PoolID: "pool-phase4", HostReachID: "host-1", Categories: []types.CoverCategory{types.CatCyberSkimming},
|
||||
ReserveAnnualContribRatio: 1.5, ReserveAccount: "acc-1", Signer: "host-1",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("LaunchCoverPool with Phase4 category when only Phase2/Phase3 allowed should be rejected (D-086)")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "D-086") {
|
||||
t.Errorf("error = %q, want 'D-086'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestLaunchCoverPoolPhase3AllowedByDefaultP2 (D-086 P2) asserts a Phase3
|
||||
// category (EquipmentLoss) launch SUCCEEDS with the P2 DefaultParams (all
|
||||
// three phases allowed). This is the positive counterpart to the
|
||||
// TestLaunchCoverPoolRejectedOutOfPhase case (the P2 default unblocks
|
||||
// Phase3 launches).
|
||||
func TestLaunchCoverPoolPhase3AllowedByDefaultP2(t *testing.T) {
|
||||
ctx, sk, _, _, _, _, k := newSimtestContext(t)
|
||||
sk.buckets = map[string]struct {
|
||||
bucket string
|
||||
score float64
|
||||
@@ -297,14 +334,11 @@ func TestLaunchCoverPoolRejectedOutOfPhase(t *testing.T) {
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
|
||||
_, err := srv.LaunchCoverPool(ctx, &types.MsgLaunchCoverPool{
|
||||
PoolID: "pool-phase3", HostReachID: "host-1", Categories: []types.CoverCategory{types.CatEquipmentLoss},
|
||||
PoolID: "pool-phase3-ok", HostReachID: "host-1", Categories: []types.CoverCategory{types.CatEquipmentLoss},
|
||||
ReserveAnnualContribRatio: 1.5, ReserveAccount: "acc-1", Signer: "host-1",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("LaunchCoverPool with Phase3 category in P1 should be rejected (D-086)")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "D-086") {
|
||||
t.Errorf("error = %q, want 'D-086'", err.Error())
|
||||
if err != nil {
|
||||
t.Fatalf("LaunchCoverPool with Phase3 category should succeed under P2 DefaultParams (D-086): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -996,3 +1030,877 @@ func TestStubWatcherAndBond(t *testing.T) {
|
||||
t.Error("stubBondKeeper GetBond(bond-1) should return true after populate")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// P2 simtest cases (REQ-052, REQ-062, REQ-056, REQ-048, D-086, D-090(1),
|
||||
// D-090(3)). The P2 simtest exercises:
|
||||
// (a) successful Charter signing + Watcher witness (WaivedRights empty).
|
||||
// (b) D-090(1) Charter with WaivedRights non-empty -> REJECTED at
|
||||
// ValidateBasic.
|
||||
// (c) Charter amendment with 7-day cooling (Proposed -> rejected-ratify-
|
||||
// before-7d -> Cooled -> Ratified).
|
||||
// (d) Pool Council election (3 Masons elected; 4th rejected).
|
||||
// (e) Cover Call vote with Watcher observer present (CallVoteYes ->
|
||||
// succeeds) + absent (CallVoteYes -> REJECTED).
|
||||
// (f) D-086 Factory rejects out-of-phase category launch (Phase 4 when
|
||||
// FactoryAllowedPhases overridden to [Phase2, Phase3] only) — covered
|
||||
// above in TestLaunchCoverPoolRejectedOutOfPhase.
|
||||
// (g) reserve ceiling escalation after 12-month age check (pool with old
|
||||
// CreatedAt -> succeeds; pool with new CreatedAt -> REJECTED).
|
||||
// (h) D-090(3) Pool Standing gate amendment below floor -> REJECTED at
|
||||
// ValidateBasic (covered in msg_charter_test.go; the handler
|
||||
// re-check is covered here).
|
||||
// ============================================================================
|
||||
|
||||
// launchPoolForP2 is a helper that launches a Cover Pool for the P2
|
||||
// simtest cases (the Charter/Council/Vote/Escalate handlers all require a
|
||||
// pre-existing pool). Uses a passing StandingKeeper stub.
|
||||
func launchPoolForP2(t *testing.T, ctx sdk.Context, srv types.MsgServer, poolID, hostReachID string) {
|
||||
t.Helper()
|
||||
_, err := srv.LaunchCoverPool(ctx, &types.MsgLaunchCoverPool{
|
||||
PoolID: poolID, HostReachID: hostReachID, Categories: []types.CoverCategory{types.CatTravel},
|
||||
ReserveAnnualContribRatio: 1.5, ReserveAccount: "acc-" + poolID, Signer: hostReachID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("launchPoolForP2 %q: %v", poolID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// launchPoolForP2NilShims is launchPoolForP2 but with a nil-shim keeper
|
||||
// (skips the Standing gate).
|
||||
func launchPoolForP2NilShims(t *testing.T, ctx sdk.Context, srv types.MsgServer, poolID, hostReachID string) {
|
||||
t.Helper()
|
||||
_, err := srv.LaunchCoverPool(ctx, &types.MsgLaunchCoverPool{
|
||||
PoolID: poolID, HostReachID: hostReachID, Categories: []types.CoverCategory{types.CatTravel},
|
||||
ReserveAnnualContribRatio: 1.5, ReserveAccount: "acc-" + poolID, Signer: hostReachID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("launchPoolForP2NilShims %q: %v", poolID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- (a) + (b) SignCoverCharter ----------------------------------------------
|
||||
|
||||
// TestSignCoverCharterSuccess (case a) asserts a successful Cover-Charter
|
||||
// signing with an empty WaivedRights (the D-090(1) gate passes) + a Watcher
|
||||
// witness hash (the WatcherKeeper.Attest is called).
|
||||
func TestSignCoverCharterSuccess(t *testing.T) {
|
||||
ctx, sk, wk, _, _, _, k := newSimtestContext(t)
|
||||
sk.buckets = map[string]struct {
|
||||
bucket string
|
||||
score float64
|
||||
}{
|
||||
"host-1/Travel": {"Trusted", 4.0},
|
||||
}
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
launchPoolForP2(t, ctx, srv, "pool-ch", "host-1")
|
||||
|
||||
_, err := srv.SignCoverCharter(ctx, &types.MsgSignCoverCharter{
|
||||
CharterID: "charter-1", PoolID: "pool-ch", HostReachID: "host-1",
|
||||
DisputePath: "counsel", Gate: "Trusted", HoldingPeriodDays: 30,
|
||||
StatementOfBeliefsHash: []byte{1, 2, 3},
|
||||
WatcherWitnessHash: []byte{4, 5, 6},
|
||||
WaivedRights: []types.RightID{},
|
||||
Signer: "host-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SignCoverCharter: %v", err)
|
||||
}
|
||||
c, ok := k.GetCoverCharter(ctx, "charter-1")
|
||||
if !ok {
|
||||
t.Fatal("CoverCharter not persisted")
|
||||
}
|
||||
if c.HostReachID != "host-1" {
|
||||
t.Errorf("CoverCharter HostReachID = %q, want host-1", c.HostReachID)
|
||||
}
|
||||
if len(c.WaivedRights) != 0 {
|
||||
t.Errorf("CoverCharter WaivedRights = %v, want empty", c.WaivedRights)
|
||||
}
|
||||
// The pool's CharterRef is linked.
|
||||
p, _ := k.GetCoverPool(ctx, "pool-ch")
|
||||
if p.CharterRef != "charter-1" {
|
||||
t.Errorf("pool CharterRef = %q, want charter-1", p.CharterRef)
|
||||
}
|
||||
// The Watcher attested on the witness hash.
|
||||
if wk.lastPoolID != "pool-ch" {
|
||||
t.Errorf("stubWatcher lastPoolID = %q, want pool-ch", wk.lastPoolID)
|
||||
}
|
||||
if !hasEvent(ctx, "cover.charter_signed") {
|
||||
t.Error("cover.charter_signed event not emitted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSignCoverCharterWaivedRightsRejected (case b, D-090(1)) asserts a
|
||||
// Cover-Charter signing with a non-empty WaivedRights is REJECTED at
|
||||
// ValidateBasic (the dual-firewall runtime gate). The Charter is NOT
|
||||
// persisted.
|
||||
func TestSignCoverCharterWaivedRightsRejected(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)
|
||||
launchPoolForP2(t, ctx, srv, "pool-ch-bad", "host-1")
|
||||
|
||||
_, err := srv.SignCoverCharter(ctx, &types.MsgSignCoverCharter{
|
||||
CharterID: "charter-bad", PoolID: "pool-ch-bad", HostReachID: "host-1",
|
||||
DisputePath: "counsel", Gate: "Trusted", HoldingPeriodDays: 30,
|
||||
WaivedRights: []types.RightID{types.RightOneTapExit, types.RightCooling},
|
||||
Signer: "host-1",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("SignCoverCharter with non-empty WaivedRights should be rejected (D-090(1))")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "REQ-056") {
|
||||
t.Errorf("error = %q, want 'REQ-056'", err.Error())
|
||||
}
|
||||
// The Charter was NOT persisted.
|
||||
if _, ok := k.GetCoverCharter(ctx, "charter-bad"); ok {
|
||||
t.Error("CoverCharter should NOT be persisted on D-090(1) reject")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSignCoverCharterIdempotentReject asserts a second SignCoverCharter on
|
||||
// the same charter-id is REJECTED.
|
||||
func TestSignCoverCharterIdempotentReject(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)
|
||||
launchPoolForP2(t, ctx, srv, "pool-ch-dup", "host-1")
|
||||
|
||||
first := &types.MsgSignCoverCharter{
|
||||
CharterID: "charter-dup", PoolID: "pool-ch-dup", HostReachID: "host-1",
|
||||
DisputePath: "counsel", Gate: "Trusted", HoldingPeriodDays: 30, Signer: "host-1",
|
||||
}
|
||||
if _, err := srv.SignCoverCharter(ctx, first); err != nil {
|
||||
t.Fatalf("first SignCoverCharter: %v", err)
|
||||
}
|
||||
_, err := srv.SignCoverCharter(ctx, first)
|
||||
if err == nil {
|
||||
t.Error("second SignCoverCharter on same charter-id should be rejected (idempotent)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSignCoverCharterNonExistentPool asserts SignCoverCharter on a non-
|
||||
// existent pool is REJECTED.
|
||||
func TestSignCoverCharterNonExistentPool(t *testing.T) {
|
||||
ctx, _, _, _, _, _, k := newSimtestContext(t)
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
|
||||
_, err := srv.SignCoverCharter(ctx, &types.MsgSignCoverCharter{
|
||||
CharterID: "charter-nopool", PoolID: "no-such-pool", HostReachID: "host-1",
|
||||
DisputePath: "counsel", Gate: "Trusted", HoldingPeriodDays: 30, Signer: "host-1",
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("SignCoverCharter on non-existent pool should be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not found") {
|
||||
t.Errorf("error = %q, want 'not found'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSignCoverCharterNilWatcherSkip asserts a nil WatcherKeeper skips the
|
||||
// attestation (simtest wiring) and the Charter is still persisted.
|
||||
func TestSignCoverCharterNilWatcherSkip(t *testing.T) {
|
||||
ctx, _, k := newSimtestContextNilShims(t)
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
launchPoolForP2NilShims(t, ctx, srv, "pool-ch-nil", "host-1")
|
||||
|
||||
_, err := srv.SignCoverCharter(ctx, &types.MsgSignCoverCharter{
|
||||
CharterID: "charter-nil", PoolID: "pool-ch-nil", HostReachID: "host-1",
|
||||
DisputePath: "counsel", Gate: "Trusted", HoldingPeriodDays: 30,
|
||||
WatcherWitnessHash: []byte{1, 2},
|
||||
Signer: "host-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SignCoverCharter with nil WatcherKeeper should skip attestation: %v", err)
|
||||
}
|
||||
if _, ok := k.GetCoverCharter(ctx, "charter-nil"); !ok {
|
||||
t.Error("CoverCharter should be persisted even with nil WatcherKeeper")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSignCoverCharterWatcherAttestError asserts a WatcherKeeper.Attest
|
||||
// error REJECTS the signing.
|
||||
func TestSignCoverCharterWatcherAttestError(t *testing.T) {
|
||||
ctx, sk, wk, _, _, _, k := newSimtestContext(t)
|
||||
sk.buckets = map[string]struct {
|
||||
bucket string
|
||||
score float64
|
||||
}{
|
||||
"host-1/Travel": {"Trusted", 4.0},
|
||||
}
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
launchPoolForP2(t, ctx, srv, "pool-ch-attest-err", "host-1")
|
||||
// Set the Watcher attest error AFTER the pool launch (the launch also
|
||||
// calls Attest; we want the SignCoverCharter Attest to fail, not the
|
||||
// launch's).
|
||||
wk.attestErr = errAttestFailed
|
||||
|
||||
_, err := srv.SignCoverCharter(ctx, &types.MsgSignCoverCharter{
|
||||
CharterID: "charter-attest-err", PoolID: "pool-ch-attest-err", HostReachID: "host-1",
|
||||
DisputePath: "counsel", Gate: "Trusted", HoldingPeriodDays: 30,
|
||||
WatcherWitnessHash: []byte{1, 2},
|
||||
Signer: "host-1",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("SignCoverCharter with Watcher attest error should be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "attestation") {
|
||||
t.Errorf("error = %q, want 'attestation'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// --- (c) AmendCoverCharter + 7-day cooling -----------------------------------
|
||||
|
||||
// TestAmendCoverCharterCooling (case c) asserts the Charter amendment
|
||||
// lifecycle: Proposed -> rejected-ratify-before-7d -> Cooled -> Ratified.
|
||||
// The 7-day cooling is enforced by CoolCharterAmendment (the handler
|
||||
// records ProposedAt; the simtest advances time + calls CoolCharterAmendment
|
||||
// + RatifyCharterAmendment).
|
||||
func TestAmendCoverCharterCooling(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)
|
||||
launchPoolForP2(t, ctx, srv, "pool-amend", "host-1")
|
||||
|
||||
if _, err := srv.SignCoverCharter(ctx, &types.MsgSignCoverCharter{
|
||||
CharterID: "charter-amend", PoolID: "pool-amend", HostReachID: "host-1",
|
||||
DisputePath: "counsel", Gate: "Trusted", HoldingPeriodDays: 30, Signer: "host-1",
|
||||
}); err != nil {
|
||||
t.Fatalf("SignCoverCharter: %v", err)
|
||||
}
|
||||
|
||||
// File the amendment (Proposed).
|
||||
if _, err := srv.AmendCoverCharter(ctx, &types.MsgAmendCoverCharter{
|
||||
CharterID: "charter-amend", AmendmentID: "amend-1", Description: "tighten gate", Signer: "host-1",
|
||||
}); err != nil {
|
||||
t.Fatalf("AmendCoverCharter: %v", err)
|
||||
}
|
||||
a, ok := k.GetCharterAmendment(ctx, "amend-1")
|
||||
if !ok {
|
||||
t.Fatal("CharterAmendment not persisted")
|
||||
}
|
||||
if a.Status != types.AmendmentProposed {
|
||||
t.Errorf("amendment Status = %q, want Proposed", a.Status)
|
||||
}
|
||||
if !hasEvent(ctx, "cover.charter_amend_proposed") {
|
||||
t.Error("cover.charter_amend_proposed event not emitted")
|
||||
}
|
||||
|
||||
// Attempt to Cool BEFORE the 7-day cooling elapses -> REJECTED.
|
||||
proposedAt := a.ProposedAt
|
||||
if _, err := k.CoolCharterAmendment(ctx, "amend-1", proposedAt); err == nil {
|
||||
t.Fatal("CoolCharterAmendment before 7-day cooling should be rejected")
|
||||
}
|
||||
|
||||
// Advance time by 7 days + 1 second + Cool -> Cooled.
|
||||
coolTime := proposedAt + types.CharterAmendmentCoolingSeconds + 1
|
||||
a, err := k.CoolCharterAmendment(ctx, "amend-1", coolTime)
|
||||
if err != nil {
|
||||
t.Fatalf("CoolCharterAmendment after 7-day cooling: %v", err)
|
||||
}
|
||||
if a.Status != types.AmendmentCooled {
|
||||
t.Errorf("amendment Status = %q, want Cooled", a.Status)
|
||||
}
|
||||
|
||||
// Ratify -> Ratified.
|
||||
a, err = k.RatifyCharterAmendment(ctx, "amend-1", coolTime+1)
|
||||
if err != nil {
|
||||
t.Fatalf("RatifyCharterAmendment: %v", err)
|
||||
}
|
||||
if a.Status != types.AmendmentRatified {
|
||||
t.Errorf("amendment Status = %q, want Ratified", a.Status)
|
||||
}
|
||||
|
||||
// The charter's Amendments slice contains the amendment.
|
||||
c, _ := k.GetCoverCharter(ctx, "charter-amend")
|
||||
if len(c.Amendments) != 1 || c.Amendments[0].AmendmentID != "amend-1" {
|
||||
t.Errorf("charter Amendments = %v, want one amend-1", c.Amendments)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAmendCoverCharterNonExistentCharter asserts AmendCoverCharter on a
|
||||
// non-existent charter is REJECTED.
|
||||
func TestAmendCoverCharterNonExistentCharter(t *testing.T) {
|
||||
ctx, _, _, _, _, _, k := newSimtestContext(t)
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
|
||||
_, err := srv.AmendCoverCharter(ctx, &types.MsgAmendCoverCharter{
|
||||
CharterID: "no-such-charter", AmendmentID: "a", Description: "d", Signer: "s",
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("AmendCoverCharter on non-existent charter should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAmendCoverCharterDuplicateAmendment asserts a duplicate amendment-id
|
||||
// is REJECTED.
|
||||
func TestAmendCoverCharterDuplicateAmendment(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)
|
||||
launchPoolForP2(t, ctx, srv, "pool-amend-dup", "host-1")
|
||||
if _, err := srv.SignCoverCharter(ctx, &types.MsgSignCoverCharter{
|
||||
CharterID: "charter-dup-amend", PoolID: "pool-amend-dup", HostReachID: "host-1",
|
||||
DisputePath: "counsel", Gate: "Trusted", HoldingPeriodDays: 30, Signer: "host-1",
|
||||
}); err != nil {
|
||||
t.Fatalf("SignCoverCharter: %v", err)
|
||||
}
|
||||
first := &types.MsgAmendCoverCharter{
|
||||
CharterID: "charter-dup-amend", AmendmentID: "amend-dup", Description: "d", Signer: "host-1",
|
||||
}
|
||||
if _, err := srv.AmendCoverCharter(ctx, first); err != nil {
|
||||
t.Fatalf("first AmendCoverCharter: %v", err)
|
||||
}
|
||||
_, err := srv.AmendCoverCharter(ctx, first)
|
||||
if err == nil {
|
||||
t.Error("second AmendCoverCharter on same amendment-id should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCoolCharterAmendmentErrors asserts the CoolCharterAmendment helper
|
||||
// error paths (not found, wrong status, cooling not elapsed — the last is
|
||||
// covered above; this covers not-found + wrong-status).
|
||||
func TestCoolCharterAmendmentErrors(t *testing.T) {
|
||||
ctx, _, _, _, _, _, k := newSimtestContext(t)
|
||||
// Not found.
|
||||
if _, err := k.CoolCharterAmendment(ctx, "no-such-amendment", 1000); err == nil {
|
||||
t.Error("CoolCharterAmendment on non-existent amendment should fail")
|
||||
}
|
||||
// Wrong status: directly persist a Ratified amendment, then attempt to
|
||||
// Cool it -> REJECTED.
|
||||
k.SetCharterAmendment(ctx, types.CharterAmendment{AmendmentID: "amend-rat", Status: types.AmendmentRatified, ProposedAt: 0})
|
||||
if _, err := k.CoolCharterAmendment(ctx, "amend-rat", 1000000); err == nil {
|
||||
t.Error("CoolCharterAmendment on a Ratified amendment should fail")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRatifyCharterAmendmentErrors asserts the RatifyCharterAmendment helper
|
||||
// error paths (not found, wrong status — a Proposed amendment cannot be
|
||||
// Ratified directly).
|
||||
func TestRatifyCharterAmendmentErrors(t *testing.T) {
|
||||
ctx, _, _, _, _, _, k := newSimtestContext(t)
|
||||
// Not found.
|
||||
if _, err := k.RatifyCharterAmendment(ctx, "no-such-amendment", 1000); err == nil {
|
||||
t.Error("RatifyCharterAmendment on non-existent amendment should fail")
|
||||
}
|
||||
// Wrong status: a Proposed amendment cannot be Ratified directly (must
|
||||
// be Cooled first).
|
||||
k.SetCharterAmendment(ctx, types.CharterAmendment{AmendmentID: "amend-prop", Status: types.AmendmentProposed, ProposedAt: 0})
|
||||
if _, err := k.RatifyCharterAmendment(ctx, "amend-prop", 1000000); err == nil {
|
||||
t.Error("RatifyCharterAmendment on a Proposed amendment should fail (must be Cooled first)")
|
||||
}
|
||||
}
|
||||
|
||||
// --- (d) ElectPoolMason -------------------------------------------------------
|
||||
|
||||
// TestElectPoolMason (case d) asserts the Pool Council election: 3 Masons
|
||||
// are elected; a 4th is REJECTED. The pool's CouncilRef is linked.
|
||||
func TestElectPoolMason(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)
|
||||
launchPoolForP2(t, ctx, srv, "pool-council", "host-1")
|
||||
|
||||
// Elect 3 Masons.
|
||||
for i, m := range []string{"mason-1", "mason-2", "mason-3"} {
|
||||
_, err := srv.ElectPoolMason(ctx, &types.MsgElectPoolMason{
|
||||
PoolID: "pool-council", MasonReachID: m, Signer: "host-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ElectPoolMason %d (%s): %v", i, m, err)
|
||||
}
|
||||
if !hasEvent(ctx, "cover.pool_mason_elected") {
|
||||
t.Error("cover.pool_mason_elected event not emitted")
|
||||
}
|
||||
}
|
||||
c, ok := k.GetPoolCouncil(ctx, "pool-council")
|
||||
if !ok {
|
||||
t.Fatal("PoolCouncil not persisted")
|
||||
}
|
||||
if c.ElectedMasonReachIDs != [3]string{"mason-1", "mason-2", "mason-3"} {
|
||||
t.Errorf("ElectedMasonReachIDs = %v, want [mason-1 mason-2 mason-3]", c.ElectedMasonReachIDs)
|
||||
}
|
||||
// The pool's CouncilRef is linked.
|
||||
p, _ := k.GetCoverPool(ctx, "pool-council")
|
||||
if p.CouncilRef != "pool-council" {
|
||||
t.Errorf("pool CouncilRef = %q, want pool-council", p.CouncilRef)
|
||||
}
|
||||
|
||||
// 4th Mason is REJECTED (max 3).
|
||||
_, err := srv.ElectPoolMason(ctx, &types.MsgElectPoolMason{
|
||||
PoolID: "pool-council", MasonReachID: "mason-4", Signer: "host-1",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("ElectPoolMason 4th mason should be rejected (max 3)")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "max 3") && !strings.Contains(err.Error(), "already has 3") {
|
||||
t.Errorf("error = %q, want 'max 3' or 'already has 3'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestElectPoolMasonDuplicate asserts a duplicate MasonReachID is REJECTED.
|
||||
func TestElectPoolMasonDuplicate(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)
|
||||
launchPoolForP2(t, ctx, srv, "pool-council-dup", "host-1")
|
||||
|
||||
if _, err := srv.ElectPoolMason(ctx, &types.MsgElectPoolMason{
|
||||
PoolID: "pool-council-dup", MasonReachID: "mason-dup", Signer: "host-1",
|
||||
}); err != nil {
|
||||
t.Fatalf("first ElectPoolMason: %v", err)
|
||||
}
|
||||
_, err := srv.ElectPoolMason(ctx, &types.MsgElectPoolMason{
|
||||
PoolID: "pool-council-dup", MasonReachID: "mason-dup", Signer: "host-1",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("ElectPoolMason with duplicate mason-reach-id should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestElectPoolMasonNonExistentPool asserts ElectPoolMason on a non-existent
|
||||
// pool is REJECTED.
|
||||
func TestElectPoolMasonNonExistentPool(t *testing.T) {
|
||||
ctx, _, _, _, _, _, k := newSimtestContext(t)
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
|
||||
_, err := srv.ElectPoolMason(ctx, &types.MsgElectPoolMason{
|
||||
PoolID: "no-such-pool", MasonReachID: "mason-1", Signer: "host-1",
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("ElectPoolMason on non-existent pool should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// --- (e) VoteCoverCall -------------------------------------------------------
|
||||
|
||||
// TestVoteCoverCall (case e) asserts the Cover Call vote: a CallVoteYes
|
||||
// with Watcher observer present SUCCEEDS; a CallVoteYes with Watcher
|
||||
// observer absent is REJECTED (REQ-062).
|
||||
func TestVoteCoverCall(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)
|
||||
launchPoolForP2(t, ctx, srv, "pool-vote", "host-1")
|
||||
// File a Cover Call to vote on.
|
||||
if _, err := srv.FileCoverCall(ctx, &types.MsgFileCoverCall{
|
||||
CallID: "call-vote", PoolID: "pool-vote", ClaimantReachID: "user-1",
|
||||
Category: types.CatTravel, AmountGrain: 500, Signer: "user-1",
|
||||
}); err != nil {
|
||||
t.Fatalf("FileCoverCall: %v", err)
|
||||
}
|
||||
|
||||
// CallVoteYes with Watcher observer present -> SUCCEEDS.
|
||||
_, err := srv.VoteCoverCall(ctx, &types.MsgVoteCoverCall{
|
||||
VoteID: "vote-yes", CallID: "call-vote", PoolID: "pool-vote", VoterReachID: "voter-1",
|
||||
VoteOption: types.CallVoteYes, WatcherObserverPresent: true, Signer: "voter-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VoteCoverCall CallVoteYes with observer present: %v", err)
|
||||
}
|
||||
v, ok := k.GetCoverCallVote(ctx, "vote-yes")
|
||||
if !ok {
|
||||
t.Fatal("CoverCallVote not persisted")
|
||||
}
|
||||
if v.VoteOption != types.CallVoteYes {
|
||||
t.Errorf("vote VoteOption = %q, want Yes", v.VoteOption)
|
||||
}
|
||||
if !hasEvent(ctx, "cover.cover_call_voted") {
|
||||
t.Error("cover.cover_call_voted event not emitted")
|
||||
}
|
||||
|
||||
// CallVoteYes with Watcher observer ABSENT -> REJECTED (REQ-062).
|
||||
_, err = srv.VoteCoverCall(ctx, &types.MsgVoteCoverCall{
|
||||
VoteID: "vote-yes-no-obs", CallID: "call-vote", PoolID: "pool-vote", VoterReachID: "voter-2",
|
||||
VoteOption: types.CallVoteYes, WatcherObserverPresent: false, Signer: "voter-2",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("VoteCoverCall CallVoteYes without observer should be rejected (REQ-062)")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "observer") {
|
||||
t.Errorf("error = %q, want 'observer'", err.Error())
|
||||
}
|
||||
|
||||
// CallVoteNo without observer -> SUCCEEDS (only an affirmative vote
|
||||
// demands the witness).
|
||||
_, err = srv.VoteCoverCall(ctx, &types.MsgVoteCoverCall{
|
||||
VoteID: "vote-no", CallID: "call-vote", PoolID: "pool-vote", VoterReachID: "voter-3",
|
||||
VoteOption: types.CallVoteNo, WatcherObserverPresent: false, Signer: "voter-3",
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("VoteCoverCall CallVoteNo without observer should succeed: %v", err)
|
||||
}
|
||||
|
||||
// CallVoteAbstain without observer -> SUCCEEDS.
|
||||
_, err = srv.VoteCoverCall(ctx, &types.MsgVoteCoverCall{
|
||||
VoteID: "vote-abstain", CallID: "call-vote", PoolID: "pool-vote", VoterReachID: "voter-4",
|
||||
VoteOption: types.CallVoteAbstain, WatcherObserverPresent: false, Signer: "voter-4",
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("VoteCoverCall CallVoteAbstain without observer should succeed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoteCoverCallNonExistentCall asserts VoteCoverCall on a non-existent
|
||||
// call is REJECTED.
|
||||
func TestVoteCoverCallNonExistentCall(t *testing.T) {
|
||||
ctx, _, _, _, _, _, k := newSimtestContext(t)
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
|
||||
_, err := srv.VoteCoverCall(ctx, &types.MsgVoteCoverCall{
|
||||
VoteID: "vote-x", CallID: "no-such-call", PoolID: "p", VoterReachID: "v",
|
||||
VoteOption: types.CallVoteYes, WatcherObserverPresent: true, Signer: "s",
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("VoteCoverCall on non-existent call should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoteCoverCallDuplicate asserts a duplicate vote-id is REJECTED.
|
||||
func TestVoteCoverCallDuplicate(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)
|
||||
launchPoolForP2(t, ctx, srv, "pool-vote-dup", "host-1")
|
||||
if _, err := srv.FileCoverCall(ctx, &types.MsgFileCoverCall{
|
||||
CallID: "call-dup", PoolID: "pool-vote-dup", ClaimantReachID: "user-1",
|
||||
Category: types.CatTravel, AmountGrain: 500, Signer: "user-1",
|
||||
}); err != nil {
|
||||
t.Fatalf("FileCoverCall: %v", err)
|
||||
}
|
||||
first := &types.MsgVoteCoverCall{
|
||||
VoteID: "vote-dup", CallID: "call-dup", PoolID: "pool-vote-dup", VoterReachID: "voter-1",
|
||||
VoteOption: types.CallVoteYes, WatcherObserverPresent: true, Signer: "voter-1",
|
||||
}
|
||||
if _, err := srv.VoteCoverCall(ctx, first); err != nil {
|
||||
t.Fatalf("first VoteCoverCall: %v", err)
|
||||
}
|
||||
_, err := srv.VoteCoverCall(ctx, first)
|
||||
if err == nil {
|
||||
t.Error("second VoteCoverCall on same vote-id should be rejected (idempotent)")
|
||||
}
|
||||
}
|
||||
|
||||
// --- (g) EscalateReserveCeiling -----------------------------------------------
|
||||
|
||||
// TestEscalateReserveCeiling (case g) asserts the reserve ceiling escalation
|
||||
// after the 12-month age check: a pool with an OLD CreatedAt (>= 365 days)
|
||||
// SUCCEEDS; a pool with a NEW CreatedAt is REJECTED.
|
||||
func TestEscalateReserveCeiling(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)
|
||||
launchPoolForP2(t, ctx, srv, "pool-esc-old", "host-1")
|
||||
// Mutate the pool's CreatedAt to an OLD timestamp (the ctx BlockTime is
|
||||
// time.Unix(1000, 0); set CreatedAt to a negative value so
|
||||
// now - CreatedAt >= 365 days. -ReserveCeilingAgeSeconds puts the age
|
||||
// at exactly 31537000 seconds = 365d + 1000s, which satisfies the
|
||||
// >= ReserveCeilingAgeSeconds check).
|
||||
p, _ := k.GetCoverPool(ctx, "pool-esc-old")
|
||||
p.CreatedAt = -types.ReserveCeilingAgeSeconds
|
||||
k.SetCoverPool(ctx, p)
|
||||
|
||||
_, err := srv.EscalateReserveCeiling(ctx, &types.MsgEscalateReserveCeiling{
|
||||
PoolID: "pool-esc-old", Signer: "host-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("EscalateReserveCeiling on old pool: %v", err)
|
||||
}
|
||||
p, _ = k.GetCoverPool(ctx, "pool-esc-old")
|
||||
if p.ReserveAnnualContribRatio != types.CoverReserveCeilingAnnualContribX {
|
||||
t.Errorf("reserve ratio = %.2f, want %.2f (ceiling)", p.ReserveAnnualContribRatio, types.CoverReserveCeilingAnnualContribX)
|
||||
}
|
||||
if !hasEvent(ctx, "cover.reserve_ceiling_escalated") {
|
||||
t.Error("cover.reserve_ceiling_escalated event not emitted")
|
||||
}
|
||||
|
||||
// A fresh pool (new CreatedAt) -> REJECTED.
|
||||
launchPoolForP2(t, ctx, srv, "pool-esc-new", "host-1")
|
||||
// pool-esc-new CreatedAt = ctx.BlockTime().Unix() = 1000; the age check
|
||||
// (now - CreatedAt >= 365 days) fails (0 < 31536000).
|
||||
_, err = srv.EscalateReserveCeiling(ctx, &types.MsgEscalateReserveCeiling{
|
||||
PoolID: "pool-esc-new", Signer: "host-1",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("EscalateReserveCeiling on fresh pool should be rejected (12-month age check)")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "12-month") {
|
||||
t.Errorf("error = %q, want '12-month'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestEscalateReserveCeilingNonExistentPool asserts EscalateReserveCeiling
|
||||
// on a non-existent pool is REJECTED.
|
||||
func TestEscalateReserveCeilingNonExistentPool(t *testing.T) {
|
||||
ctx, _, _, _, _, _, k := newSimtestContext(t)
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
|
||||
_, err := srv.EscalateReserveCeiling(ctx, &types.MsgEscalateReserveCeiling{
|
||||
PoolID: "no-such-pool", Signer: "host-1",
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("EscalateReserveCeiling on non-existent pool should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEscalateReserveCeilingWatcherAttestError asserts a WatcherKeeper.Attest
|
||||
// error REJECTS the escalation.
|
||||
func TestEscalateReserveCeilingWatcherAttestError(t *testing.T) {
|
||||
ctx, sk, wk, _, _, _, k := newSimtestContext(t)
|
||||
sk.buckets = map[string]struct {
|
||||
bucket string
|
||||
score float64
|
||||
}{
|
||||
"host-1/Travel": {"Trusted", 4.0},
|
||||
}
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
launchPoolForP2(t, ctx, srv, "pool-esc-attest-err", "host-1")
|
||||
// Set the Watcher attest error AFTER the pool launch + set CreatedAt
|
||||
// to an OLD timestamp so the age check passes (the escalation's Attest
|
||||
// fails, not the launch's).
|
||||
p, _ := k.GetCoverPool(ctx, "pool-esc-attest-err")
|
||||
p.CreatedAt = -types.ReserveCeilingAgeSeconds
|
||||
k.SetCoverPool(ctx, p)
|
||||
wk.attestErr = errAttestFailed
|
||||
|
||||
_, err := srv.EscalateReserveCeiling(ctx, &types.MsgEscalateReserveCeiling{
|
||||
PoolID: "pool-esc-attest-err", Signer: "host-1",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("EscalateReserveCeiling with Watcher attest error should be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "attestation") {
|
||||
t.Errorf("error = %q, want 'attestation'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// --- (h) AmendPoolStandingGate D-090(3) handler re-check ----------------------
|
||||
|
||||
// TestAmendPoolStandingGateSuccess asserts a successful gate amendment
|
||||
// (NewGate >= CoverStandingGateTrusted) updates the pool's PoolStandingGate.
|
||||
func TestAmendPoolStandingGateSuccess(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)
|
||||
launchPoolForP2(t, ctx, srv, "pool-gate", "host-1")
|
||||
|
||||
_, err := srv.AmendPoolStandingGate(ctx, &types.MsgAmendPoolStandingGate{
|
||||
PoolID: "pool-gate", NewGate: 4.5, Signer: "host-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AmendPoolStandingGate: %v", err)
|
||||
}
|
||||
p, _ := k.GetCoverPool(ctx, "pool-gate")
|
||||
if p.PoolStandingGate != 4.5 {
|
||||
t.Errorf("PoolStandingGate = %.2f, want 4.5", p.PoolStandingGate)
|
||||
}
|
||||
if !hasEvent(ctx, "cover.pool_standing_gate_amended") {
|
||||
t.Error("cover.pool_standing_gate_amended event not emitted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAmendPoolStandingGateBelowFloorHandler (case h, D-090(3)) asserts a
|
||||
// below-floor amendment (NewGate = 3.0 < 4.0) is REJECTED at the handler
|
||||
// (the handler re-checks in defense in depth — ValidateBasic already
|
||||
// rejected, but this test confirms the handler re-check also fires when
|
||||
// the message reaches the handler via a non-ValidateBasic path).
|
||||
func TestAmendPoolStandingGateBelowFloorHandler(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)
|
||||
launchPoolForP2(t, ctx, srv, "pool-gate-bad", "host-1")
|
||||
|
||||
// NewGate = 3.0 < 4.0 -> REJECTED at ValidateBasic (the handler never
|
||||
// reaches the state mutation).
|
||||
_, err := srv.AmendPoolStandingGate(ctx, &types.MsgAmendPoolStandingGate{
|
||||
PoolID: "pool-gate-bad", NewGate: 3.0, Signer: "host-1",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("AmendPoolStandingGate with NewGate 3.0 < 4.0 should be rejected (D-090(3))")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "D-090(3)") {
|
||||
t.Errorf("error = %q, want 'D-090(3)'", err.Error())
|
||||
}
|
||||
// The pool's gate was NOT mutated.
|
||||
p, _ := k.GetCoverPool(ctx, "pool-gate-bad")
|
||||
if p.PoolStandingGate != types.CoverStandingGateTrusted {
|
||||
t.Errorf("PoolStandingGate = %.2f, want %.2f (unchanged)", p.PoolStandingGate, types.CoverStandingGateTrusted)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAmendPoolStandingGateNonExistentPool asserts AmendPoolStandingGate on
|
||||
// a non-existent pool is REJECTED.
|
||||
func TestAmendPoolStandingGateNonExistentPool(t *testing.T) {
|
||||
ctx, _, _, _, _, _, k := newSimtestContext(t)
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
|
||||
_, err := srv.AmendPoolStandingGate(ctx, &types.MsgAmendPoolStandingGate{
|
||||
PoolID: "no-such-pool", NewGate: 4.5, Signer: "host-1",
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("AmendPoolStandingGate on non-existent pool should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// --- P2 keeper accessors (coverage) -----------------------------------------
|
||||
|
||||
// TestP2KeeperAccessors exercises the P2 keeper accessors (AllCoverCharters,
|
||||
// AllPoolCouncils, AllCoverCallVotes, AllCharterAmendments, the Get/Set
|
||||
// helpers, the marshal-error paths) to push coverage >=80%.
|
||||
func TestP2KeeperAccessors(t *testing.T) {
|
||||
ctx, _, _, _, _, _, k := newSimtestContext(t)
|
||||
|
||||
// Empty-store accessors return empty (not nil) slices.
|
||||
if got := k.AllCoverCharters(ctx); len(got) != 0 {
|
||||
t.Errorf("AllCoverCharters empty = %d, want 0", len(got))
|
||||
}
|
||||
if got := k.AllPoolCouncils(ctx); len(got) != 0 {
|
||||
t.Errorf("AllPoolCouncils empty = %d, want 0", len(got))
|
||||
}
|
||||
if got := k.AllCoverCallVotes(ctx); len(got) != 0 {
|
||||
t.Errorf("AllCoverCallVotes empty = %d, want 0", len(got))
|
||||
}
|
||||
if got := k.AllCharterAmendments(ctx); len(got) != 0 {
|
||||
t.Errorf("AllCharterAmendments empty = %d, want 0", len(got))
|
||||
}
|
||||
if _, ok := k.GetCoverCharter(ctx, "nobody"); ok {
|
||||
t.Error("GetCoverCharter on empty store should return false")
|
||||
}
|
||||
if _, ok := k.GetPoolCouncil(ctx, "nobody"); ok {
|
||||
t.Error("GetPoolCouncil on empty store should return false")
|
||||
}
|
||||
if _, ok := k.GetCoverCallVote(ctx, "nobody"); ok {
|
||||
t.Error("GetCoverCallVote on empty store should return false")
|
||||
}
|
||||
if _, ok := k.GetCharterAmendment(ctx, "nobody"); ok {
|
||||
t.Error("GetCharterAmendment on empty store should return false")
|
||||
}
|
||||
|
||||
// Populate + read back via accessors.
|
||||
k.SetCoverCharter(ctx, types.CoverCharter{CharterID: "c1", PoolID: "p1", HostReachID: "h1"})
|
||||
if c, ok := k.GetCoverCharter(ctx, "c1"); !ok || c.HostReachID != "h1" {
|
||||
t.Errorf("GetCoverCharter = %+v ok=%v", c, ok)
|
||||
}
|
||||
if got := k.AllCoverCharters(ctx); len(got) != 1 {
|
||||
t.Errorf("AllCoverCharters = %d, want 1", len(got))
|
||||
}
|
||||
|
||||
k.SetPoolCouncil(ctx, types.PoolCouncil{PoolID: "p1", HostReachID: "h1"})
|
||||
if c, ok := k.GetPoolCouncil(ctx, "p1"); !ok || c.HostReachID != "h1" {
|
||||
t.Errorf("GetPoolCouncil = %+v ok=%v", c, ok)
|
||||
}
|
||||
if got := k.AllPoolCouncils(ctx); len(got) != 1 {
|
||||
t.Errorf("AllPoolCouncils = %d, want 1", len(got))
|
||||
}
|
||||
|
||||
k.SetCoverCallVote(ctx, types.CoverCallVote{VoteID: "v1", CallID: "c1", PoolID: "p1", VoterReachID: "v1"})
|
||||
if v, ok := k.GetCoverCallVote(ctx, "v1"); !ok || v.VoterReachID != "v1" {
|
||||
t.Errorf("GetCoverCallVote = %+v ok=%v", v, ok)
|
||||
}
|
||||
if got := k.AllCoverCallVotes(ctx); len(got) != 1 {
|
||||
t.Errorf("AllCoverCallVotes = %d, want 1", len(got))
|
||||
}
|
||||
|
||||
k.SetCharterAmendment(ctx, types.CharterAmendment{AmendmentID: "a1", Description: "d", Status: types.AmendmentProposed})
|
||||
if a, ok := k.GetCharterAmendment(ctx, "a1"); !ok || a.Description != "d" {
|
||||
t.Errorf("GetCharterAmendment = %+v ok=%v", a, ok)
|
||||
}
|
||||
if got := k.AllCharterAmendments(ctx); len(got) != 1 {
|
||||
t.Errorf("AllCharterAmendments = %d, want 1", len(got))
|
||||
}
|
||||
|
||||
// Marshal-error paths (corrupt bytes in store).
|
||||
store := ctx.KVStore(k.StoreKey())
|
||||
store.Set([]byte("charter/corrupt"), []byte("not-json"))
|
||||
if _, ok := k.GetCoverCharter(ctx, "corrupt"); ok {
|
||||
t.Error("GetCoverCharter on corrupt bytes should return false")
|
||||
}
|
||||
store.Set([]byte("council/corrupt"), []byte("not-json"))
|
||||
if _, ok := k.GetPoolCouncil(ctx, "corrupt"); ok {
|
||||
t.Error("GetPoolCouncil on corrupt bytes should return false")
|
||||
}
|
||||
store.Set([]byte("vote/corrupt"), []byte("not-json"))
|
||||
if _, ok := k.GetCoverCallVote(ctx, "corrupt"); ok {
|
||||
t.Error("GetCoverCallVote on corrupt bytes should return false")
|
||||
}
|
||||
store.Set([]byte("amendment/corrupt"), []byte("not-json"))
|
||||
if _, ok := k.GetCharterAmendment(ctx, "corrupt"); ok {
|
||||
t.Error("GetCharterAmendment on corrupt bytes should return false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParamsOverrideAndAccessors exercises the SetParamsOverride + Params
|
||||
// accessors (coverage).
|
||||
func TestParamsOverrideAndAccessors(t *testing.T) {
|
||||
_, _, _, _, _, _, k := newSimtestContext(t)
|
||||
// Default Params (no override).
|
||||
p := k.Params()
|
||||
if len(p.FactoryAllowedPhases) != 3 {
|
||||
t.Errorf("default Params FactoryAllowedPhases len = %d, want 3", len(p.FactoryAllowedPhases))
|
||||
}
|
||||
// Override.
|
||||
override := types.Params{
|
||||
FactoryAllowedPhases: []types.CoverCategoryPhase{types.Phase2},
|
||||
PoolStandingGate: types.CoverStandingGateTrusted,
|
||||
}
|
||||
k.SetParamsOverride(override)
|
||||
p = k.Params()
|
||||
if len(p.FactoryAllowedPhases) != 1 || p.FactoryAllowedPhases[0] != types.Phase2 {
|
||||
t.Errorf("overridden Params FactoryAllowedPhases = %v, want [Phase2]", p.FactoryAllowedPhases)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
package types
|
||||
|
||||
// msg_charter.go holds the P2 Cover-Charter + Pool-Council + Cover-Call-Vote
|
||||
// Msg* types (REQ-052, REQ-062, REQ-056, REQ-048, D-090(1), D-090(3)). The
|
||||
// P1 Msg* types live in msg_cover.go; this file is the P2 extension
|
||||
// (separated for file-hygiene — the P1 file is already at ~280 lines).
|
||||
//
|
||||
// G-006 controlled exception: this file gains the cosmos-sdk import for
|
||||
// sdk.Msg (mirrors msg_cover.go — D-055; the invariant/lexicon tests in
|
||||
// *_test.go stay stdlib-only per G-024, isolated from this msg_*.go file).
|
||||
//
|
||||
// The six P2 Msg types drive the Cover-Charter + Pool Council + Cover Call
|
||||
// Vote runtime:
|
||||
// - MsgSignCoverCharter: sign a Cover-Charter (the handler enforces the
|
||||
// D-090(1) Bill of Rights gate at ValidateBasic: any WaivedRights
|
||||
// element REJECTS the signing; persists the CoverCharter + Watcher
|
||||
// attests the witness hash).
|
||||
// - MsgAmendCoverCharter: file a Charter amendment (the handler creates a
|
||||
// CharterAmendment with Status=AmendmentProposed; a separate ratify
|
||||
// handler / simtest time-advance transitions it to Cooled then
|
||||
// Ratified after the 7-day cooling).
|
||||
// - MsgElectPoolMason: elect a Mason to the Pool Council (the handler
|
||||
// adds the MasonReachID to ElectedMasonReachIDs, max 3 — a 4th is
|
||||
// REJECTED).
|
||||
// - MsgVoteCoverCall: vote on a Cover Call (the handler enforces the
|
||||
// Watcher-observer-present check for a CallVoteYes — REQ-062).
|
||||
// - MsgAmendPoolStandingGate: amend a Pool's Standing gate (D-090(3) dual
|
||||
// check: ValidateBasic rejects NewGate < CoverStandingGateTrusted; the
|
||||
// handler re-checks in defense in depth).
|
||||
// - MsgEscalateReserveCeiling: escalate a Pool's reserve target to the
|
||||
// CoverReserveCeilingAnnualContribX (REQ-048 — the handler enforces
|
||||
// the 12-month age check: now - pool.CreatedAt >= 365 days).
|
||||
//
|
||||
// All cross-module refs are by-ID-string (G-003). The WaivedRights field
|
||||
// on MsgSignCoverCharter is []RightID (the RightID type from rights.go) so
|
||||
// the D-090(1) gate can type-check it.
|
||||
//
|
||||
// Lexicon note (REQ-012, D-088): the message names + field names use the
|
||||
// safe Cover vocabulary EXCLUSIVELY. "Cover-Charter", "Pool Council",
|
||||
// "Cover Call Vote", "Charter Amendment" are the clean names; the four
|
||||
// Cover-specific banned terms NEVER appear (enforced by lexicon_meta_cover).
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
sdk "github.com/cosmos/cosmos-sdk/types"
|
||||
)
|
||||
|
||||
// --- MsgSignCoverCharter ------------------------------------------------------
|
||||
|
||||
// MsgSignCoverCharter signs a Cover-Charter for a Pool (REQ-052, REQ-056,
|
||||
// D-090(1)). The handler enforces:
|
||||
// - D-090(1) Bill of Rights gate at ValidateBasic: len(WaivedRights) > 0
|
||||
// -> REJECT with "REQ-056: rights non-amendable, non-waivable by any
|
||||
// Charter". This is the dual-firewall runtime gate (mirrors
|
||||
// MissionLockAmendmentRejected at ValidateBasic in x/council).
|
||||
// - Idempotency: CharterID must not already exist.
|
||||
// - WatcherKeeper.Attest on the charter witness hash (a nil WatcherKeeper
|
||||
// skips).
|
||||
// - Persist the CoverCharter + emit cover.charter_signed.
|
||||
//
|
||||
// ValidateBasic is stateless: non-empty fields + the D-090(1) WaivedRights
|
||||
// gate. The WaivedRights field is []RightID (the RightID type from
|
||||
// rights.go) so the gate can type-check it; the gate rejects any non-empty
|
||||
// slice (the 13 rights are non-waivable by any Charter).
|
||||
type MsgSignCoverCharter struct {
|
||||
CharterID string `json:"charter_id" yaml:"charter_id"`
|
||||
PoolID string `json:"pool_id" yaml:"pool_id"`
|
||||
StatementOfBeliefsHash []byte `json:"statement_of_beliefs_hash" yaml:"statement_of_beliefs_hash"`
|
||||
DisputePath string `json:"dispute_path" yaml:"dispute_path"`
|
||||
Gate string `json:"gate" yaml:"gate"`
|
||||
HoldingPeriodDays uint32 `json:"holding_period_days" yaml:"holding_period_days"`
|
||||
HostReachID string `json:"host_reach_id" yaml:"host_reach_id"`
|
||||
WatcherWitnessHash []byte `json:"watcher_witness_hash" yaml:"watcher_witness_hash"`
|
||||
WaivedRights []RightID `json:"waived_rights" yaml:"waived_rights"`
|
||||
Signer string `json:"signer" yaml:"signer"`
|
||||
}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgSignCoverCharter) Reset() { *m = MsgSignCoverCharter{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgSignCoverCharter) String() string {
|
||||
return fmt.Sprintf("MsgSignCoverCharter{CharterID:%s PoolID:%s HostReachID:%s Gate:%s HoldingPeriodDays:%d WaivedRights:%v Signer:%s}",
|
||||
m.CharterID, m.PoolID, m.HostReachID, m.Gate, m.HoldingPeriodDays, m.WaivedRights, m.Signer)
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgSignCoverCharter) ProtoMessage() {}
|
||||
|
||||
// ValidateBasic is the stateless validation: non-empty fields + the
|
||||
// D-090(1) Bill of Rights gate. The gate rejects any non-empty WaivedRights
|
||||
// slice (the 13 rights are non-amendable, non-waivable by any Charter —
|
||||
// REQ-056, vision §8.2). This is the dual-firewall runtime gate (mirrors
|
||||
// MissionLockAmendmentRejected at ValidateBasic in x/council — D-064).
|
||||
func (m *MsgSignCoverCharter) ValidateBasic() error {
|
||||
if m.CharterID == "" {
|
||||
return fmt.Errorf("cover: empty charter-id")
|
||||
}
|
||||
if m.PoolID == "" {
|
||||
return fmt.Errorf("cover: empty pool-id")
|
||||
}
|
||||
if m.HostReachID == "" {
|
||||
return fmt.Errorf("cover: empty host-reach-id")
|
||||
}
|
||||
if m.DisputePath == "" {
|
||||
return fmt.Errorf("cover: empty dispute-path")
|
||||
}
|
||||
if m.Gate == "" {
|
||||
return fmt.Errorf("cover: empty gate")
|
||||
}
|
||||
if m.HoldingPeriodDays == 0 {
|
||||
return fmt.Errorf("cover: empty holding-period-days")
|
||||
}
|
||||
if m.Signer == "" {
|
||||
return fmt.Errorf("cover: empty signer")
|
||||
}
|
||||
// D-090(1) Bill of Rights gate: the 13 rights are non-amendable,
|
||||
// non-waivable by any Charter (REQ-056, vision §8.2). Any WaivedRights
|
||||
// element REJECTS the signing. This is the dual-firewall runtime gate
|
||||
// (the const firewall is the 13 Waivable* consts all false +
|
||||
// RightIsWaivable() always false; this gate is the runtime rejection).
|
||||
// Mirrors MissionLockAmendmentRejected at ValidateBasic in x/council
|
||||
// (D-064).
|
||||
if len(m.WaivedRights) > 0 {
|
||||
return fmt.Errorf("cover: REQ-056: rights non-amendable, non-waivable by any Charter (WaivedRights=%v)", m.WaivedRights)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||
func (m *MsgSignCoverCharter) GetSigners() []sdk.AccAddress {
|
||||
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||
}
|
||||
|
||||
// --- MsgAmendCoverCharter -----------------------------------------------------
|
||||
|
||||
// MsgAmendCoverCharter files a Charter amendment (REQ-052). The handler
|
||||
// creates a CharterAmendment with Status=AmendmentProposed, ProposedAt=now.
|
||||
// After the 7-day cooling (CharterAmendmentCoolingSeconds), a separate
|
||||
// ratify handler (or simtest time-advance) transitions it to Cooled then
|
||||
// Ratified. The cooling is the Anti-Capture Bill of Rights RightCooling
|
||||
// enforcement.
|
||||
//
|
||||
// ValidateBasic is stateless: non-empty fields.
|
||||
type MsgAmendCoverCharter struct {
|
||||
CharterID string `json:"charter_id" yaml:"charter_id"`
|
||||
AmendmentID string `json:"amendment_id" yaml:"amendment_id"`
|
||||
Description string `json:"description" yaml:"description"`
|
||||
Signer string `json:"signer" yaml:"signer"`
|
||||
}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgAmendCoverCharter) Reset() { *m = MsgAmendCoverCharter{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgAmendCoverCharter) String() string {
|
||||
return fmt.Sprintf("MsgAmendCoverCharter{CharterID:%s AmendmentID:%s Signer:%s}",
|
||||
m.CharterID, m.AmendmentID, m.Signer)
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgAmendCoverCharter) ProtoMessage() {}
|
||||
|
||||
// ValidateBasic is the stateless validation: non-empty fields.
|
||||
func (m *MsgAmendCoverCharter) ValidateBasic() error {
|
||||
if m.CharterID == "" {
|
||||
return fmt.Errorf("cover: empty charter-id")
|
||||
}
|
||||
if m.AmendmentID == "" {
|
||||
return fmt.Errorf("cover: empty amendment-id")
|
||||
}
|
||||
if m.Description == "" {
|
||||
return fmt.Errorf("cover: empty description")
|
||||
}
|
||||
if m.Signer == "" {
|
||||
return fmt.Errorf("cover: empty signer")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||
func (m *MsgAmendCoverCharter) GetSigners() []sdk.AccAddress {
|
||||
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||
}
|
||||
|
||||
// --- MsgElectPoolMason --------------------------------------------------------
|
||||
|
||||
// MsgElectPoolMason elects a Mason to the Pool Council (REQ-062). The
|
||||
// handler loads or creates the PoolCouncil, adds the MasonReachID to
|
||||
// ElectedMasonReachIDs (max 3 — a 4th is REJECTED), and persists.
|
||||
//
|
||||
// ValidateBasic is stateless: non-empty fields.
|
||||
type MsgElectPoolMason struct {
|
||||
PoolID string `json:"pool_id" yaml:"pool_id"`
|
||||
MasonReachID string `json:"mason_reach_id" yaml:"mason_reach_id"`
|
||||
Signer string `json:"signer" yaml:"signer"`
|
||||
}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgElectPoolMason) Reset() { *m = MsgElectPoolMason{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgElectPoolMason) String() string {
|
||||
return fmt.Sprintf("MsgElectPoolMason{PoolID:%s MasonReachID:%s Signer:%s}",
|
||||
m.PoolID, m.MasonReachID, m.Signer)
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgElectPoolMason) ProtoMessage() {}
|
||||
|
||||
// ValidateBasic is the stateless validation: non-empty fields.
|
||||
func (m *MsgElectPoolMason) ValidateBasic() error {
|
||||
if m.PoolID == "" {
|
||||
return fmt.Errorf("cover: empty pool-id")
|
||||
}
|
||||
if m.MasonReachID == "" {
|
||||
return fmt.Errorf("cover: empty mason-reach-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 *MsgElectPoolMason) GetSigners() []sdk.AccAddress {
|
||||
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||
}
|
||||
|
||||
// --- MsgVoteCoverCall ---------------------------------------------------------
|
||||
|
||||
// MsgVoteCoverCall votes on a Cover Call (REQ-062). The handler enforces:
|
||||
// - the CoverCall exists.
|
||||
// - the Watcher-observer-present check: if VoteOption == CallVoteYes and
|
||||
// WatcherObserverPresent == false, REJECT (majority requires observer
|
||||
// present — REQ-062).
|
||||
// - persist the CoverCallVote + emit cover.cover_call_voted.
|
||||
//
|
||||
// ValidateBasic is stateless: non-empty fields + valid VoteOption.
|
||||
type MsgVoteCoverCall struct {
|
||||
VoteID string `json:"vote_id" yaml:"vote_id"`
|
||||
CallID string `json:"call_id" yaml:"call_id"`
|
||||
PoolID string `json:"pool_id" yaml:"pool_id"`
|
||||
VoterReachID string `json:"voter_reach_id" yaml:"voter_reach_id"`
|
||||
VoteOption CallVoteOption `json:"vote_option" yaml:"vote_option"`
|
||||
WatcherObserverPresent bool `json:"watcher_observer_present" yaml:"watcher_observer_present"`
|
||||
Signer string `json:"signer" yaml:"signer"`
|
||||
}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgVoteCoverCall) Reset() { *m = MsgVoteCoverCall{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgVoteCoverCall) String() string {
|
||||
return fmt.Sprintf("MsgVoteCoverCall{VoteID:%s CallID:%s PoolID:%s VoterReachID:%s VoteOption:%s WatcherObserverPresent:%v Signer:%s}",
|
||||
m.VoteID, m.CallID, m.PoolID, m.VoterReachID, m.VoteOption, m.WatcherObserverPresent, m.Signer)
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgVoteCoverCall) ProtoMessage() {}
|
||||
|
||||
// ValidateBasic is the stateless validation: non-empty fields + valid
|
||||
// VoteOption.
|
||||
func (m *MsgVoteCoverCall) ValidateBasic() error {
|
||||
if m.VoteID == "" {
|
||||
return fmt.Errorf("cover: empty vote-id")
|
||||
}
|
||||
if m.CallID == "" {
|
||||
return fmt.Errorf("cover: empty call-id")
|
||||
}
|
||||
if m.PoolID == "" {
|
||||
return fmt.Errorf("cover: empty pool-id")
|
||||
}
|
||||
if m.VoterReachID == "" {
|
||||
return fmt.Errorf("cover: empty voter-reach-id")
|
||||
}
|
||||
if m.Signer == "" {
|
||||
return fmt.Errorf("cover: empty signer")
|
||||
}
|
||||
if !knownCallVoteOption(m.VoteOption) {
|
||||
return fmt.Errorf("cover: unknown vote-option %q", m.VoteOption)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||
func (m *MsgVoteCoverCall) GetSigners() []sdk.AccAddress {
|
||||
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||
}
|
||||
|
||||
// --- MsgAmendPoolStandingGate -------------------------------------------------
|
||||
|
||||
// MsgAmendPoolStandingGate amends a Pool's Standing gate (D-090(3)). The
|
||||
// handler re-checks NewGate >= CoverStandingGateTrusted in defense in
|
||||
// depth (ValidateBasic already checked — but the handler re-checks in
|
||||
// case of a future Params-bypass). The gate may be TIGHTENED above the
|
||||
// protocol minimum but NEVER lowered below it.
|
||||
//
|
||||
// ValidateBasic is the D-090(3) dual check: NewGate >=
|
||||
// CoverStandingGateTrusted (a below-floor amendment is REJECTED at
|
||||
// ValidateBasic, NOT just at the handler).
|
||||
type MsgAmendPoolStandingGate struct {
|
||||
PoolID string `json:"pool_id" yaml:"pool_id"`
|
||||
NewGate float64 `json:"new_gate" yaml:"new_gate"`
|
||||
Signer string `json:"signer" yaml:"signer"`
|
||||
}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgAmendPoolStandingGate) Reset() { *m = MsgAmendPoolStandingGate{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgAmendPoolStandingGate) String() string {
|
||||
return fmt.Sprintf("MsgAmendPoolStandingGate{PoolID:%s NewGate:%.2f Signer:%s}",
|
||||
m.PoolID, m.NewGate, m.Signer)
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgAmendPoolStandingGate) ProtoMessage() {}
|
||||
|
||||
// ValidateBasic is the D-090(3) dual check: non-empty fields + NewGate >=
|
||||
// CoverStandingGateTrusted (a below-floor amendment is REJECTED at
|
||||
// ValidateBasic, NOT just at the handler — the dual firewall).
|
||||
func (m *MsgAmendPoolStandingGate) ValidateBasic() error {
|
||||
if m.PoolID == "" {
|
||||
return fmt.Errorf("cover: empty pool-id")
|
||||
}
|
||||
if m.Signer == "" {
|
||||
return fmt.Errorf("cover: empty signer")
|
||||
}
|
||||
if m.NewGate < CoverStandingGateTrusted {
|
||||
return fmt.Errorf("cover: NewGate %.2f < CoverStandingGateTrusted %.2f (D-090(3): a pool may tighten the gate but never lower it)", m.NewGate, CoverStandingGateTrusted)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||
func (m *MsgAmendPoolStandingGate) GetSigners() []sdk.AccAddress {
|
||||
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||
}
|
||||
|
||||
// --- MsgEscalateReserveCeiling ------------------------------------------------
|
||||
|
||||
// MsgEscalateReserveCeiling escalates a Pool's reserve target to the
|
||||
// CoverReserveCeilingAnnualContribX (REQ-048). The handler enforces the
|
||||
// 12-month age check: now - pool.CreatedAt >= ReserveCeilingAgeSeconds
|
||||
// (365 days). A fresh pool is REJECTED. The handler calls
|
||||
// WatcherKeeper.Attest (a nil WatcherKeeper skips).
|
||||
//
|
||||
// ValidateBasic is stateless: non-empty fields.
|
||||
type MsgEscalateReserveCeiling struct {
|
||||
PoolID string `json:"pool_id" yaml:"pool_id"`
|
||||
Signer string `json:"signer" yaml:"signer"`
|
||||
}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgEscalateReserveCeiling) Reset() { *m = MsgEscalateReserveCeiling{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgEscalateReserveCeiling) String() string {
|
||||
return fmt.Sprintf("MsgEscalateReserveCeiling{PoolID:%s Signer:%s}", m.PoolID, m.Signer)
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgEscalateReserveCeiling) ProtoMessage() {}
|
||||
|
||||
// ValidateBasic is the stateless validation: non-empty fields.
|
||||
func (m *MsgEscalateReserveCeiling) 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 *MsgEscalateReserveCeiling) GetSigners() []sdk.AccAddress {
|
||||
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||
}
|
||||
|
||||
// --- P2 Response types --------------------------------------------------------
|
||||
//
|
||||
// Hand-rolled (no protobuf codegen); empty bodies — the response is the
|
||||
// state mutation + event. Mirrors the P1 Response types in msg_cover.go.
|
||||
|
||||
// MsgSignCoverCharterResponse is the response to MsgSignCoverCharter.
|
||||
type MsgSignCoverCharterResponse struct{}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgSignCoverCharterResponse) Reset() { *m = MsgSignCoverCharterResponse{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgSignCoverCharterResponse) String() string { return "MsgSignCoverCharterResponse{}" }
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgSignCoverCharterResponse) ProtoMessage() {}
|
||||
|
||||
// MsgAmendCoverCharterResponse is the response to MsgAmendCoverCharter.
|
||||
type MsgAmendCoverCharterResponse struct{}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgAmendCoverCharterResponse) Reset() { *m = MsgAmendCoverCharterResponse{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgAmendCoverCharterResponse) String() string { return "MsgAmendCoverCharterResponse{}" }
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgAmendCoverCharterResponse) ProtoMessage() {}
|
||||
|
||||
// MsgElectPoolMasonResponse is the response to MsgElectPoolMason.
|
||||
type MsgElectPoolMasonResponse struct{}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgElectPoolMasonResponse) Reset() { *m = MsgElectPoolMasonResponse{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgElectPoolMasonResponse) String() string { return "MsgElectPoolMasonResponse{}" }
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgElectPoolMasonResponse) ProtoMessage() {}
|
||||
|
||||
// MsgVoteCoverCallResponse is the response to MsgVoteCoverCall.
|
||||
type MsgVoteCoverCallResponse struct{}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgVoteCoverCallResponse) Reset() { *m = MsgVoteCoverCallResponse{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgVoteCoverCallResponse) String() string { return "MsgVoteCoverCallResponse{}" }
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgVoteCoverCallResponse) ProtoMessage() {}
|
||||
|
||||
// MsgAmendPoolStandingGateResponse is the response to MsgAmendPoolStandingGate.
|
||||
type MsgAmendPoolStandingGateResponse struct{}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgAmendPoolStandingGateResponse) Reset() { *m = MsgAmendPoolStandingGateResponse{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgAmendPoolStandingGateResponse) String() string {
|
||||
return "MsgAmendPoolStandingGateResponse{}"
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgAmendPoolStandingGateResponse) ProtoMessage() {}
|
||||
|
||||
// MsgEscalateReserveCeilingResponse is the response to MsgEscalateReserveCeiling.
|
||||
type MsgEscalateReserveCeilingResponse struct{}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgEscalateReserveCeilingResponse) Reset() { *m = MsgEscalateReserveCeilingResponse{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgEscalateReserveCeilingResponse) String() string {
|
||||
return "MsgEscalateReserveCeilingResponse{}"
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgEscalateReserveCeilingResponse) ProtoMessage() {}
|
||||
@@ -0,0 +1,325 @@
|
||||
package types
|
||||
|
||||
// msg_charter_test.go holds the P2 Msg* method coverage tests for
|
||||
// x/cover/types (REQ-052, REQ-062, REQ-056, REQ-048, D-090(1), D-090(3)).
|
||||
// The P2 Msg* Reset/String/ProtoMessage/ValidateBasic/GetSigners methods
|
||||
// are exercised here so the types package coverage is >=80%.
|
||||
//
|
||||
// G-024 controlled exception (mirrors msg_cover_test.go): this file imports
|
||||
// cosmos-sdk for GetSigners (sdk.AccAddress) — this is a Msg-method test,
|
||||
// NOT an invariant/lexicon test, so the G-024 stdlib-only constraint does
|
||||
// not apply.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
sdk "github.com/cosmos/cosmos-sdk/types"
|
||||
)
|
||||
|
||||
// --- MsgSignCoverCharter methods ---------------------------------------------
|
||||
|
||||
func TestMsgSignCoverCharterMethods(t *testing.T) {
|
||||
m := &MsgSignCoverCharter{
|
||||
CharterID: "c1", PoolID: "p1", HostReachID: "h1", DisputePath: "dp",
|
||||
Gate: "Trusted", HoldingPeriodDays: 30, Signer: "h1",
|
||||
StatementOfBeliefsHash: []byte{1, 2},
|
||||
WatcherWitnessHash: []byte{3, 4},
|
||||
WaivedRights: []RightID{},
|
||||
}
|
||||
if err := m.ValidateBasic(); err != nil {
|
||||
t.Errorf("valid MsgSignCoverCharter ValidateBasic: %v", err)
|
||||
}
|
||||
if !strings.Contains(m.String(), "c1") {
|
||||
t.Errorf("MsgSignCoverCharter String = %q, want c1", m.String())
|
||||
}
|
||||
m.Reset()
|
||||
if m.CharterID != "" {
|
||||
t.Errorf("MsgSignCoverCharter Reset did not zero: %+v", m)
|
||||
}
|
||||
m.ProtoMessage()
|
||||
m2 := &MsgSignCoverCharter{Signer: "host-1"}
|
||||
if got := m2.GetSigners(); len(got) != 1 || string(got[0]) != "host-1" {
|
||||
t.Errorf("MsgSignCoverCharter GetSigners = %v, want [host-1]", got)
|
||||
}
|
||||
var _ []sdk.AccAddress = m2.GetSigners()
|
||||
}
|
||||
|
||||
// TestMsgSignCoverCharterValidateBasicErrors asserts each error path,
|
||||
// including the D-090(1) Bill of Rights gate (any WaivedRights element
|
||||
// REJECTS the signing).
|
||||
func TestMsgSignCoverCharterValidateBasicErrors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
msg MsgSignCoverCharter
|
||||
}{
|
||||
{"empty charter-id", MsgSignCoverCharter{PoolID: "p", HostReachID: "h", DisputePath: "dp", Gate: "g", HoldingPeriodDays: 30, Signer: "s"}},
|
||||
{"empty pool-id", MsgSignCoverCharter{CharterID: "c", HostReachID: "h", DisputePath: "dp", Gate: "g", HoldingPeriodDays: 30, Signer: "s"}},
|
||||
{"empty host-reach-id", MsgSignCoverCharter{CharterID: "c", PoolID: "p", DisputePath: "dp", Gate: "g", HoldingPeriodDays: 30, Signer: "s"}},
|
||||
{"empty dispute-path", MsgSignCoverCharter{CharterID: "c", PoolID: "p", HostReachID: "h", Gate: "g", HoldingPeriodDays: 30, Signer: "s"}},
|
||||
{"empty gate", MsgSignCoverCharter{CharterID: "c", PoolID: "p", HostReachID: "h", DisputePath: "dp", HoldingPeriodDays: 30, Signer: "s"}},
|
||||
{"zero holding-period-days", MsgSignCoverCharter{CharterID: "c", PoolID: "p", HostReachID: "h", DisputePath: "dp", Gate: "g", Signer: "s"}},
|
||||
{"empty signer", MsgSignCoverCharter{CharterID: "c", PoolID: "p", HostReachID: "h", DisputePath: "dp", Gate: "g", HoldingPeriodDays: 30}},
|
||||
{"waived-rights non-empty (D-090(1))", MsgSignCoverCharter{CharterID: "c", PoolID: "p", HostReachID: "h", DisputePath: "dp", Gate: "g", HoldingPeriodDays: 30, Signer: "s", WaivedRights: []RightID{RightOneTapExit}}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
err := c.msg.ValidateBasic()
|
||||
if err == nil {
|
||||
t.Errorf("case %q: ValidateBasic should fail", c.name)
|
||||
continue
|
||||
}
|
||||
// The D-090(1) case must mention REQ-056.
|
||||
if c.name == "waived-rights non-empty (D-090(1))" && !strings.Contains(err.Error(), "REQ-056") {
|
||||
t.Errorf("case %q: error = %q, want 'REQ-056'", c.name, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- MsgAmendCoverCharter methods --------------------------------------------
|
||||
|
||||
func TestMsgAmendCoverCharterMethods(t *testing.T) {
|
||||
m := &MsgAmendCoverCharter{CharterID: "c1", AmendmentID: "a1", Description: "d", Signer: "h1"}
|
||||
if err := m.ValidateBasic(); err != nil {
|
||||
t.Errorf("valid MsgAmendCoverCharter ValidateBasic: %v", err)
|
||||
}
|
||||
if !strings.Contains(m.String(), "a1") {
|
||||
t.Errorf("MsgAmendCoverCharter String = %q, want a1", m.String())
|
||||
}
|
||||
m.Reset()
|
||||
if m.CharterID != "" {
|
||||
t.Errorf("MsgAmendCoverCharter Reset did not zero: %+v", m)
|
||||
}
|
||||
m.ProtoMessage()
|
||||
m2 := &MsgAmendCoverCharter{Signer: "host-1"}
|
||||
if got := m2.GetSigners(); len(got) != 1 || string(got[0]) != "host-1" {
|
||||
t.Errorf("MsgAmendCoverCharter GetSigners = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgAmendCoverCharterValidateBasicErrors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
msg MsgAmendCoverCharter
|
||||
}{
|
||||
{"empty charter-id", MsgAmendCoverCharter{AmendmentID: "a", Description: "d", Signer: "s"}},
|
||||
{"empty amendment-id", MsgAmendCoverCharter{CharterID: "c", Description: "d", Signer: "s"}},
|
||||
{"empty description", MsgAmendCoverCharter{CharterID: "c", AmendmentID: "a", Signer: "s"}},
|
||||
{"empty signer", MsgAmendCoverCharter{CharterID: "c", AmendmentID: "a", Description: "d"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if err := c.msg.ValidateBasic(); err == nil {
|
||||
t.Errorf("case %q: ValidateBasic should fail", c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- MsgElectPoolMason methods -----------------------------------------------
|
||||
|
||||
func TestMsgElectPoolMasonMethods(t *testing.T) {
|
||||
m := &MsgElectPoolMason{PoolID: "p1", MasonReachID: "m1", Signer: "h1"}
|
||||
if err := m.ValidateBasic(); err != nil {
|
||||
t.Errorf("valid MsgElectPoolMason ValidateBasic: %v", err)
|
||||
}
|
||||
if !strings.Contains(m.String(), "m1") {
|
||||
t.Errorf("MsgElectPoolMason String = %q, want m1", m.String())
|
||||
}
|
||||
m.Reset()
|
||||
if m.PoolID != "" {
|
||||
t.Errorf("MsgElectPoolMason Reset did not zero: %+v", m)
|
||||
}
|
||||
m.ProtoMessage()
|
||||
m2 := &MsgElectPoolMason{Signer: "host-1"}
|
||||
if got := m2.GetSigners(); len(got) != 1 || string(got[0]) != "host-1" {
|
||||
t.Errorf("MsgElectPoolMason GetSigners = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgElectPoolMasonValidateBasicErrors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
msg MsgElectPoolMason
|
||||
}{
|
||||
{"empty pool-id", MsgElectPoolMason{MasonReachID: "m", Signer: "s"}},
|
||||
{"empty mason-reach-id", MsgElectPoolMason{PoolID: "p", Signer: "s"}},
|
||||
{"empty signer", MsgElectPoolMason{PoolID: "p", MasonReachID: "m"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if err := c.msg.ValidateBasic(); err == nil {
|
||||
t.Errorf("case %q: ValidateBasic should fail", c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- MsgVoteCoverCall methods ------------------------------------------------
|
||||
|
||||
func TestMsgVoteCoverCallMethods(t *testing.T) {
|
||||
m := &MsgVoteCoverCall{VoteID: "v1", CallID: "c1", PoolID: "p1", VoterReachID: "v1", VoteOption: CallVoteYes, WatcherObserverPresent: true, Signer: "h1"}
|
||||
if err := m.ValidateBasic(); err != nil {
|
||||
t.Errorf("valid MsgVoteCoverCall ValidateBasic: %v", err)
|
||||
}
|
||||
if !strings.Contains(m.String(), "v1") {
|
||||
t.Errorf("MsgVoteCoverCall String = %q, want v1", m.String())
|
||||
}
|
||||
m.Reset()
|
||||
if m.VoteID != "" {
|
||||
t.Errorf("MsgVoteCoverCall Reset did not zero: %+v", m)
|
||||
}
|
||||
m.ProtoMessage()
|
||||
m2 := &MsgVoteCoverCall{Signer: "host-1"}
|
||||
if got := m2.GetSigners(); len(got) != 1 || string(got[0]) != "host-1" {
|
||||
t.Errorf("MsgVoteCoverCall GetSigners = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgVoteCoverCallValidateBasicErrors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
msg MsgVoteCoverCall
|
||||
}{
|
||||
{"empty vote-id", MsgVoteCoverCall{CallID: "c", PoolID: "p", VoterReachID: "v", VoteOption: CallVoteYes, Signer: "s"}},
|
||||
{"empty call-id", MsgVoteCoverCall{VoteID: "v", PoolID: "p", VoterReachID: "v", VoteOption: CallVoteYes, Signer: "s"}},
|
||||
{"empty pool-id", MsgVoteCoverCall{VoteID: "v", CallID: "c", VoterReachID: "v", VoteOption: CallVoteYes, Signer: "s"}},
|
||||
{"empty voter-reach-id", MsgVoteCoverCall{VoteID: "v", CallID: "c", PoolID: "p", VoteOption: CallVoteYes, Signer: "s"}},
|
||||
{"empty signer", MsgVoteCoverCall{VoteID: "v", CallID: "c", PoolID: "p", VoterReachID: "v", VoteOption: CallVoteYes}},
|
||||
{"unknown vote-option", MsgVoteCoverCall{VoteID: "v", CallID: "c", PoolID: "p", VoterReachID: "v", VoteOption: CallVoteOption("Maybe"), Signer: "s"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if err := c.msg.ValidateBasic(); err == nil {
|
||||
t.Errorf("case %q: ValidateBasic should fail", c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- MsgAmendPoolStandingGate methods ----------------------------------------
|
||||
|
||||
func TestMsgAmendPoolStandingGateMethods(t *testing.T) {
|
||||
m := &MsgAmendPoolStandingGate{PoolID: "p1", NewGate: 4.5, Signer: "h1"}
|
||||
if err := m.ValidateBasic(); err != nil {
|
||||
t.Errorf("valid MsgAmendPoolStandingGate ValidateBasic: %v", err)
|
||||
}
|
||||
if !strings.Contains(m.String(), "p1") {
|
||||
t.Errorf("MsgAmendPoolStandingGate String = %q, want p1", m.String())
|
||||
}
|
||||
m.Reset()
|
||||
if m.PoolID != "" {
|
||||
t.Errorf("MsgAmendPoolStandingGate Reset did not zero: %+v", m)
|
||||
}
|
||||
m.ProtoMessage()
|
||||
m2 := &MsgAmendPoolStandingGate{Signer: "host-1"}
|
||||
if got := m2.GetSigners(); len(got) != 1 || string(got[0]) != "host-1" {
|
||||
t.Errorf("MsgAmendPoolStandingGate GetSigners = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMsgAmendPoolStandingGateD0903BelowFloor asserts the D-090(3) dual
|
||||
// check: a NewGate below CoverStandingGateTrusted (4.0) is REJECTED at
|
||||
// ValidateBasic (NOT just at the handler). NewGate = 3.0 < 4.0 -> REJECT.
|
||||
func TestMsgAmendPoolStandingGateD0903BelowFloor(t *testing.T) {
|
||||
m := &MsgAmendPoolStandingGate{PoolID: "p", NewGate: 3.0, Signer: "s"}
|
||||
err := m.ValidateBasic()
|
||||
if err == nil {
|
||||
t.Fatal("MsgAmendPoolStandingGate with NewGate 3.0 < 4.0 should fail ValidateBasic (D-090(3))")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "D-090(3)") {
|
||||
t.Errorf("error = %q, want 'D-090(3)'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgAmendPoolStandingGateValidateBasicErrors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
msg MsgAmendPoolStandingGate
|
||||
}{
|
||||
{"empty pool-id", MsgAmendPoolStandingGate{NewGate: 4.5, Signer: "s"}},
|
||||
{"empty signer", MsgAmendPoolStandingGate{PoolID: "p", NewGate: 4.5}},
|
||||
{"below floor", MsgAmendPoolStandingGate{PoolID: "p", NewGate: 3.0, Signer: "s"}},
|
||||
{"below floor zero", MsgAmendPoolStandingGate{PoolID: "p", NewGate: 0, Signer: "s"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if err := c.msg.ValidateBasic(); err == nil {
|
||||
t.Errorf("case %q: ValidateBasic should fail", c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- MsgEscalateReserveCeiling methods ---------------------------------------
|
||||
|
||||
func TestMsgEscalateReserveCeilingMethods(t *testing.T) {
|
||||
m := &MsgEscalateReserveCeiling{PoolID: "p1", Signer: "h1"}
|
||||
if err := m.ValidateBasic(); err != nil {
|
||||
t.Errorf("valid MsgEscalateReserveCeiling ValidateBasic: %v", err)
|
||||
}
|
||||
if !strings.Contains(m.String(), "p1") {
|
||||
t.Errorf("MsgEscalateReserveCeiling String = %q, want p1", m.String())
|
||||
}
|
||||
m.Reset()
|
||||
if m.PoolID != "" {
|
||||
t.Errorf("MsgEscalateReserveCeiling Reset did not zero: %+v", m)
|
||||
}
|
||||
m.ProtoMessage()
|
||||
m2 := &MsgEscalateReserveCeiling{Signer: "host-1"}
|
||||
if got := m2.GetSigners(); len(got) != 1 || string(got[0]) != "host-1" {
|
||||
t.Errorf("MsgEscalateReserveCeiling GetSigners = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgEscalateReserveCeilingValidateBasicErrors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
msg MsgEscalateReserveCeiling
|
||||
}{
|
||||
{"empty pool-id", MsgEscalateReserveCeiling{Signer: "s"}},
|
||||
{"empty signer", MsgEscalateReserveCeiling{PoolID: "p"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if err := c.msg.ValidateBasic(); err == nil {
|
||||
t.Errorf("case %q: ValidateBasic should fail", c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- P2 Response types methods -----------------------------------------------
|
||||
|
||||
func TestP2ResponseTypesMethods(t *testing.T) {
|
||||
r1 := &MsgSignCoverCharterResponse{}
|
||||
r1.Reset()
|
||||
if !strings.Contains(r1.String(), "MsgSignCoverCharterResponse") {
|
||||
t.Errorf("MsgSignCoverCharterResponse String = %q", r1.String())
|
||||
}
|
||||
r1.ProtoMessage()
|
||||
|
||||
r2 := &MsgAmendCoverCharterResponse{}
|
||||
r2.Reset()
|
||||
if !strings.Contains(r2.String(), "MsgAmendCoverCharterResponse") {
|
||||
t.Errorf("MsgAmendCoverCharterResponse String = %q", r2.String())
|
||||
}
|
||||
r2.ProtoMessage()
|
||||
|
||||
r3 := &MsgElectPoolMasonResponse{}
|
||||
r3.Reset()
|
||||
if !strings.Contains(r3.String(), "MsgElectPoolMasonResponse") {
|
||||
t.Errorf("MsgElectPoolMasonResponse String = %q", r3.String())
|
||||
}
|
||||
r3.ProtoMessage()
|
||||
|
||||
r4 := &MsgVoteCoverCallResponse{}
|
||||
r4.Reset()
|
||||
if !strings.Contains(r4.String(), "MsgVoteCoverCallResponse") {
|
||||
t.Errorf("MsgVoteCoverCallResponse String = %q", r4.String())
|
||||
}
|
||||
r4.ProtoMessage()
|
||||
|
||||
r5 := &MsgAmendPoolStandingGateResponse{}
|
||||
r5.Reset()
|
||||
if !strings.Contains(r5.String(), "MsgAmendPoolStandingGateResponse") {
|
||||
t.Errorf("MsgAmendPoolStandingGateResponse String = %q", r5.String())
|
||||
}
|
||||
r5.ProtoMessage()
|
||||
|
||||
r6 := &MsgEscalateReserveCeilingResponse{}
|
||||
r6.Reset()
|
||||
if !strings.Contains(r6.String(), "MsgEscalateReserveCeilingResponse") {
|
||||
t.Errorf("MsgEscalateReserveCeilingResponse String = %q", r6.String())
|
||||
}
|
||||
r6.ProtoMessage()
|
||||
}
|
||||
@@ -229,10 +229,21 @@ func (m *MsgFileCoverCall) GetSigners() []sdk.AccAddress {
|
||||
// Msg*). The keeper's msg_server.go implements this; module.go's
|
||||
// RegisterServices wires the implementation. Hand-rolled (no protobuf
|
||||
// codegen per the skeleton's zero-codegen style).
|
||||
//
|
||||
// P2 extension (REQ-052, REQ-062, REQ-056, REQ-048): the six new methods
|
||||
// (SignCoverCharter, AmendCoverCharter, ElectPoolMason, VoteCoverCall,
|
||||
// AmendPoolStandingGate, EscalateReserveCeiling) are defined in
|
||||
// msg_charter.go; their Response types are defined below the interface.
|
||||
type MsgServer interface {
|
||||
LaunchCoverPool(ctx interface{}, msg *MsgLaunchCoverPool) (*MsgLaunchCoverPoolResponse, error)
|
||||
RouteCoverFee(ctx interface{}, msg *MsgRouteCoverFee) (*MsgRouteCoverFeeResponse, error)
|
||||
FileCoverCall(ctx interface{}, msg *MsgFileCoverCall) (*MsgFileCoverCallResponse, error)
|
||||
SignCoverCharter(ctx interface{}, msg *MsgSignCoverCharter) (*MsgSignCoverCharterResponse, error)
|
||||
AmendCoverCharter(ctx interface{}, msg *MsgAmendCoverCharter) (*MsgAmendCoverCharterResponse, error)
|
||||
ElectPoolMason(ctx interface{}, msg *MsgElectPoolMason) (*MsgElectPoolMasonResponse, error)
|
||||
VoteCoverCall(ctx interface{}, msg *MsgVoteCoverCall) (*MsgVoteCoverCallResponse, error)
|
||||
AmendPoolStandingGate(ctx interface{}, msg *MsgAmendPoolStandingGate) (*MsgAmendPoolStandingGateResponse, error)
|
||||
EscalateReserveCeiling(ctx interface{}, msg *MsgEscalateReserveCeiling) (*MsgEscalateReserveCeilingResponse, error)
|
||||
}
|
||||
|
||||
// Response types (hand-rolled; empty bodies — the response is the state
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
package types
|
||||
|
||||
// rights.go holds the Anti-Capture Bill of Rights types (REQ-056, vision §8.2,
|
||||
// D-090(1) temporal-gap fix). This file lands in P2 (NOT P5) so the dual
|
||||
// firewall is in place BEFORE any Cover-Charter can be signed: the P2
|
||||
// MsgSignCoverCharter handler rejects any WaivedRights element at
|
||||
// ValidateBasic, and P5 then layers the Counsel review ceremony on top of
|
||||
// these already-locked types.
|
||||
//
|
||||
// The 13 rights are non-amendable, non-waivable by any Charter (REQ-056,
|
||||
// vision §8.2). The dual firewall mirrors the Mission-Lock firewall in
|
||||
// x/council (D-064): there the firewall is MissionLockAmendable=false (the
|
||||
// const) + MissionLockAmendmentRejected rejected at ValidateBasic (the gate);
|
||||
// here the firewall is the 13 Waivable* consts (all false) +
|
||||
// RightIsWaivable() always returns false + MsgSignCoverCharter.ValidateBasic
|
||||
// rejects any WaivedRights element. A future agent flipping any const OR
|
||||
// removing the ValidateBasic gate breaks the regression tests in
|
||||
// rights_test.go.
|
||||
//
|
||||
// The Bill of Rights is the INVARIANT declaration; the Anti-Crowding-Out
|
||||
// firewall (x/cover/firewall, P1) is the ENFORCEMENT mechanism for
|
||||
// RightNoTaxOnPersonalStash (the firewall rejects a Cover-Fee routing
|
||||
// destination that is a Root-Pool operating-expenses holder, which would
|
||||
// crowd out the contributor-pool reserve — exactly what
|
||||
// RightNoTaxOnPersonalStash forbids). The two layers together close the
|
||||
// Anti-Capture failure mode: the right declares the invariant; the firewall
|
||||
// rejects the code path that would violate it; the ValidateBasic gate
|
||||
// rejects a Charter that would waive it.
|
||||
//
|
||||
// D-085 13th-right candidate (RightNonParticipationNoDenial, confidence
|
||||
// 0.55): logged as an assumption per the P2 plan — the lead-developer
|
||||
// surfaces D-085 to the PO before P2; the fallback (log the 13th right and
|
||||
// proceed) is exercised here. The const AntiCaptureBillOfRightsCount = 13
|
||||
// is the locked regression firewall for the count; removing or adding a
|
||||
// right breaks the test.
|
||||
//
|
||||
// Lexicon note (REQ-012, D-088): "Right", "Charter", "Waived", "Counsel",
|
||||
// "Watcher", "Freeholder", "Wayfarer", "Secession" are all lexicon-clean.
|
||||
// The right identifiers use the safe Cover vocabulary EXCLUSIVELY; the four
|
||||
// Cover-specific banned terms (enumerated by lexicon.CoverBannedTerms — not
|
||||
// inlined here so this source stays lexicon-clean) NEVER appear in this
|
||||
// file (enforced by lexicon_meta_cover).
|
||||
|
||||
// RightID is the identifier type for an Anti-Capture Bill of Rights right
|
||||
// (REQ-056, vision §8.2). A RightID is a string enum: one of the 13 locked
|
||||
// Right* consts below. The type is a string (not a uint8) so the value is
|
||||
// self-documenting at the call site + in serialized state (a WaivedRights
|
||||
// slice in a CoverCharter serializes the right names, not opaque integers).
|
||||
type RightID string
|
||||
|
||||
const (
|
||||
// RightOneTapExit is the right to one-tap exit a Stand (vision §8.2).
|
||||
// A Stand holder may dissolve their Stand + return assets to their
|
||||
// Stash with no Council vote required (the Household one-tap-exit
|
||||
// handler in P3 is the enforcement). Non-waivable.
|
||||
RightOneTapExit RightID = "OneTapExit"
|
||||
|
||||
// RightNoTaxOnPersonalStash is the right that the personal Stash is
|
||||
// not taxed to fund Cover-Fee routing (vision §8.2 — the Anti-
|
||||
// Crowding-Out firewall enforces this: a Cover-Fee may NEVER route
|
||||
// into a Root-Pool operating-expenses holder, only into a Cover
|
||||
// Pool's ReserveAccount). Non-waivable.
|
||||
RightNoTaxOnPersonalStash RightID = "NoTaxOnPersonalStash"
|
||||
|
||||
// RightAuditableVoice is the right that Voice is auditable (vision
|
||||
// §8.2 — the Voice tally is recorded + replayable; the council
|
||||
// module's TallyResult is the audit record). Non-waivable.
|
||||
RightAuditableVoice RightID = "AuditableVoice"
|
||||
|
||||
// RightCooling is the right to a cooling period before a Charter
|
||||
// amendment is ratified (vision §8.2 — the 7-day Charter amendment
|
||||
// cooling in P2 is the enforcement). Non-waivable.
|
||||
RightCooling RightID = "Cooling"
|
||||
|
||||
// RightWatcherInspection is the right that a Watcher may inspect any
|
||||
// Cover Pool (vision §8.2 — the Watcher attestation pipeline is the
|
||||
// inspection surface). Non-waivable.
|
||||
RightWatcherInspection RightID = "WatcherInspection"
|
||||
|
||||
// RightFreeholderVoucher is the right that a Freeholder's Vouch is
|
||||
// counted (vision §8.2 — the Standing module's Vouch weight is the
|
||||
// counting). Non-waivable.
|
||||
RightFreeholderVoucher RightID = "FreeholderVoucher"
|
||||
|
||||
// RightCounselEscalation is the right to escalate to Counsel
|
||||
// (vision §8.2 — the Counsel review ceremony in P5 is the escalation
|
||||
// surface). Non-waivable.
|
||||
RightCounselEscalation RightID = "CounselEscalation"
|
||||
|
||||
// RightAnchoredBreadConversion is the right that Bread conversion is
|
||||
// anchored to the mission (vision §8.2 — the Bread/Grain conversion
|
||||
// is mission-locked, not freely tunable). Non-waivable.
|
||||
RightAnchoredBreadConversion RightID = "AnchoredBreadConversion"
|
||||
|
||||
// RightWayfarersRecord is the right that the Wayfarer's record is
|
||||
// preserved (vision §8.2 — the Wayfarer's journey is recorded
|
||||
// immutably). Non-waivable.
|
||||
RightWayfarersRecord RightID = "WayfarersRecord"
|
||||
|
||||
// RightSecessionFoundingTerms is the right that secession terms are
|
||||
// coded at founding (vision §8.2 — the SecessionTerms hash-pinned at
|
||||
// Guild/Chapter creation in P3 is the enforcement; the terms are
|
||||
// immutable after founding). Non-waivable.
|
||||
RightSecessionFoundingTerms RightID = "SecessionFoundingTerms"
|
||||
|
||||
// RightNonCoverAccess is the right that non-Cover access is preserved
|
||||
// (vision §8.2 — a holder's access to the mesh is not gated on Cover
|
||||
// Pool participation). Non-waivable.
|
||||
RightNonCoverAccess RightID = "NonCoverAccess"
|
||||
|
||||
// RightCategoryMismatchRefusal is the right to refuse a category
|
||||
// mismatch (vision §8.2 — a Cover Call filed against a category the
|
||||
// Pool does not cover is REJECTED at the handler; the holder is not
|
||||
// forced to accept a mismatched Call). Non-waivable.
|
||||
RightCategoryMismatchRefusal RightID = "CategoryMismatchRefusal"
|
||||
|
||||
// RightNonParticipationNoDenial is the D-085 13th-right candidate
|
||||
// (confidence 0.55, logged as an assumption per the P2 plan): the
|
||||
// right that non-participation in a Cover Pool does NOT deny mesh
|
||||
// access (vision §8.2 — a holder who does not join a Cover Pool is
|
||||
// not denied the mesh-level rights). Non-waivable.
|
||||
RightNonParticipationNoDenial RightID = "NonParticipationNoDenial"
|
||||
)
|
||||
|
||||
// AntiCaptureBillOfRightsCount is the LOCKED count of Anti-Capture Bill of
|
||||
// Rights rights (REQ-056, vision §8.2). The 13 rights are non-amendable,
|
||||
// non-waivable by any Charter. A regression here is a mission-lock breach:
|
||||
// adding or removing a right breaks the locked-const test in rights_test.go.
|
||||
// The count is the dual-firewall anchor: the 13 Waivable* consts below +
|
||||
// RightIsWaivable() + the ValidateBasic gate all key off this count.
|
||||
const AntiCaptureBillOfRightsCount = 13
|
||||
|
||||
// The 13 Waivable* bool consts (all false) are the first layer of the dual
|
||||
// firewall: each right has a matching Waivable* const that is LOCKED false
|
||||
// (a right can NEVER be waivable). The RightIsWaivable() function below is
|
||||
// the second layer (it consults these consts + always returns false); the
|
||||
// MsgSignCoverCharter.ValidateBasic gate is the third layer (it rejects any
|
||||
// WaivedRights element). A future agent flipping any const to true breaks
|
||||
// the regression test. Mirrors MissionLockAmendable=false (D-064).
|
||||
const (
|
||||
WaivableOneTapExit = false
|
||||
WaivableNoTaxOnPersonalStash = false
|
||||
WaivableAuditableVoice = false
|
||||
WaivableCooling = false
|
||||
WaivableWatcherInspection = false
|
||||
WaivableFreeholderVoucher = false
|
||||
WaivableCounselEscalation = false
|
||||
WaivableAnchoredBreadConversion = false
|
||||
WaivableWayfarersRecord = false
|
||||
WaivableSecessionFoundingTerms = false
|
||||
WaivableNonCoverAccess = false
|
||||
WaivableCategoryMismatchRefusal = false
|
||||
WaivableNonParticipationNoDenial = false
|
||||
)
|
||||
|
||||
// AllRights returns all 13 Anti-Capture Bill of Rights RightID values in
|
||||
// canonical order (REQ-056, vision §8.2). The canonical order is the
|
||||
// declaration order above (OneTapExit first, NonParticipationNoDenial last).
|
||||
// The locked-const test in rights_test.go asserts exactly 13 entries with
|
||||
// these names. A future agent reordering, adding, or removing a right
|
||||
// breaks the test.
|
||||
func AllRights() []RightID {
|
||||
return []RightID{
|
||||
RightOneTapExit,
|
||||
RightNoTaxOnPersonalStash,
|
||||
RightAuditableVoice,
|
||||
RightCooling,
|
||||
RightWatcherInspection,
|
||||
RightFreeholderVoucher,
|
||||
RightCounselEscalation,
|
||||
RightAnchoredBreadConversion,
|
||||
RightWayfarersRecord,
|
||||
RightSecessionFoundingTerms,
|
||||
RightNonCoverAccess,
|
||||
RightCategoryMismatchRefusal,
|
||||
RightNonParticipationNoDenial,
|
||||
}
|
||||
}
|
||||
|
||||
// AllWaivableFlags returns the 13 Waivable* bool flags keyed by RightID
|
||||
// (all false — the dual-firewall regression surface). Used by the
|
||||
// rights_test.go regression test to assert every flag is false. A future
|
||||
// agent flipping any flag breaks the test. Mirrors the
|
||||
// MissionLockAmendable=false const firewall in x/council (D-064) but
|
||||
// applied per-right (13 flags instead of one).
|
||||
func AllWaivableFlags() map[RightID]bool {
|
||||
return map[RightID]bool{
|
||||
RightOneTapExit: WaivableOneTapExit,
|
||||
RightNoTaxOnPersonalStash: WaivableNoTaxOnPersonalStash,
|
||||
RightAuditableVoice: WaivableAuditableVoice,
|
||||
RightCooling: WaivableCooling,
|
||||
RightWatcherInspection: WaivableWatcherInspection,
|
||||
RightFreeholderVoucher: WaivableFreeholderVoucher,
|
||||
RightCounselEscalation: WaivableCounselEscalation,
|
||||
RightAnchoredBreadConversion: WaivableAnchoredBreadConversion,
|
||||
RightWayfarersRecord: WaivableWayfarersRecord,
|
||||
RightSecessionFoundingTerms: WaivableSecessionFoundingTerms,
|
||||
RightNonCoverAccess: WaivableNonCoverAccess,
|
||||
RightCategoryMismatchRefusal: WaivableCategoryMismatchRefusal,
|
||||
RightNonParticipationNoDenial: WaivableNonParticipationNoDenial,
|
||||
}
|
||||
}
|
||||
|
||||
// RightIsWaivable reports whether the named right is waivable by a Charter
|
||||
// (REQ-056, vision §8.2). ALWAYS returns false — the 13 rights are non-
|
||||
// waivable by any Charter. This is the firewall function: the
|
||||
// MsgSignCoverCharter.ValidateBasic gate calls this (defense in depth —
|
||||
// the gate also checks len(WaivedRights) > 0 directly, but this function
|
||||
// is the canonical query for any future call site that asks "is this right
|
||||
// waivable?"). A future agent changing the return to true breaks the
|
||||
// regression test. Mirrors the MissionLockAmendable=false const firewall
|
||||
// in x/council (D-064): there the const is the firewall; here the function
|
||||
// is the firewall (consulting the 13 Waivable* consts, all false).
|
||||
func RightIsWaivable(id RightID) bool {
|
||||
flags := AllWaivableFlags()
|
||||
if waivable, ok := flags[id]; ok {
|
||||
return waivable
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package types
|
||||
|
||||
// rights_test.go holds the Anti-Capture Bill of Rights regression tests
|
||||
// (REQ-056, vision §8.2, D-090(1) temporal-gap fix).
|
||||
//
|
||||
// G-024: this test file stays STDLIB-ONLY (no cosmos-sdk import) — it does
|
||||
// invariant + lexicon assertions, not handler logic. The handler simtest
|
||||
// (x/cover/keeper/msg_server_simtest_test.go) MAY import cosmos-sdk.
|
||||
//
|
||||
// The regression surface:
|
||||
// - AntiCaptureBillOfRightsCount == 13 (the locked count firewall).
|
||||
// - All 13 Waivable* consts are false (the dual-firewall const layer).
|
||||
// - RightIsWaivable returns false for all 13 rights (the firewall
|
||||
// function layer).
|
||||
// - AllRights returns 13 RightID values in canonical order.
|
||||
// - AllWaivableFlags returns a 13-entry map, all values false.
|
||||
// - RightIsWaivable returns false for an unknown RightID (defense in
|
||||
// depth — an unknown right is NOT waivable by default).
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAntiCaptureBillOfRightsCount asserts the locked count of rights is
|
||||
// 13 (REQ-056, vision §8.2). A regression here is a mission-lock breach:
|
||||
// adding or removing a right breaks the dual-firewall anchor.
|
||||
func TestAntiCaptureBillOfRightsCount(t *testing.T) {
|
||||
if AntiCaptureBillOfRightsCount != 13 {
|
||||
t.Errorf("AntiCaptureBillOfRightsCount = %d, want 13 (REQ-056 locked count, vision §8.2)", AntiCaptureBillOfRightsCount)
|
||||
}
|
||||
if len(AllRights()) != 13 {
|
||||
t.Errorf("len(AllRights()) = %d, want 13 (REQ-056)", len(AllRights()))
|
||||
}
|
||||
if len(AllWaivableFlags()) != 13 {
|
||||
t.Errorf("len(AllWaivableFlags()) = %d, want 13 (REQ-056)", len(AllWaivableFlags()))
|
||||
}
|
||||
}
|
||||
|
||||
// TestWaivableConstsAllFalse asserts all 13 Waivable* consts are false
|
||||
// (the dual-firewall const layer — mirrors MissionLockAmendable=false in
|
||||
// x/council, D-064). A future agent flipping any const to true breaks
|
||||
// this test.
|
||||
func TestWaivableConstsAllFalse(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
waivable bool
|
||||
}{
|
||||
{"WaivableOneTapExit", WaivableOneTapExit},
|
||||
{"WaivableNoTaxOnPersonalStash", WaivableNoTaxOnPersonalStash},
|
||||
{"WaivableAuditableVoice", WaivableAuditableVoice},
|
||||
{"WaivableCooling", WaivableCooling},
|
||||
{"WaivableWatcherInspection", WaivableWatcherInspection},
|
||||
{"WaivableFreeholderVoucher", WaivableFreeholderVoucher},
|
||||
{"WaivableCounselEscalation", WaivableCounselEscalation},
|
||||
{"WaivableAnchoredBreadConversion", WaivableAnchoredBreadConversion},
|
||||
{"WaivableWayfarersRecord", WaivableWayfarersRecord},
|
||||
{"WaivableSecessionFoundingTerms", WaivableSecessionFoundingTerms},
|
||||
{"WaivableNonCoverAccess", WaivableNonCoverAccess},
|
||||
{"WaivableCategoryMismatchRefusal", WaivableCategoryMismatchRefusal},
|
||||
{"WaivableNonParticipationNoDenial", WaivableNonParticipationNoDenial},
|
||||
}
|
||||
if len(cases) != AntiCaptureBillOfRightsCount {
|
||||
t.Fatalf("test cases len = %d, want AntiCaptureBillOfRightsCount %d (a Waivable* const is missing from the test)", len(cases), AntiCaptureBillOfRightsCount)
|
||||
}
|
||||
for _, c := range cases {
|
||||
if c.waivable {
|
||||
t.Errorf("%s = true, want false (REQ-056: rights non-amendable, non-waivable by any Charter)", c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRightIsWaivableAlwaysFalse asserts RightIsWaivable returns false for
|
||||
// all 13 rights + for an unknown RightID (the firewall function layer).
|
||||
// A future agent changing the return to true breaks this test.
|
||||
func TestRightIsWaivableAlwaysFalse(t *testing.T) {
|
||||
for _, id := range AllRights() {
|
||||
if RightIsWaivable(id) {
|
||||
t.Errorf("RightIsWaivable(%q) = true, want false (REQ-056: rights non-waivable by any Charter)", id)
|
||||
}
|
||||
}
|
||||
// An unknown RightID returns false (defense in depth — an unknown
|
||||
// right is NOT waivable by default).
|
||||
if RightIsWaivable(RightID("UnknownRight")) {
|
||||
t.Error("RightIsWaivable(UnknownRight) = true, want false (unknown rights are NOT waivable)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllRightsCanonicalOrder asserts AllRights returns the 13 rights in
|
||||
// the canonical declaration order (OneTapExit first,
|
||||
// NonParticipationNoDenial last). A reordering breaks the test.
|
||||
func TestAllRightsCanonicalOrder(t *testing.T) {
|
||||
want := []RightID{
|
||||
RightOneTapExit,
|
||||
RightNoTaxOnPersonalStash,
|
||||
RightAuditableVoice,
|
||||
RightCooling,
|
||||
RightWatcherInspection,
|
||||
RightFreeholderVoucher,
|
||||
RightCounselEscalation,
|
||||
RightAnchoredBreadConversion,
|
||||
RightWayfarersRecord,
|
||||
RightSecessionFoundingTerms,
|
||||
RightNonCoverAccess,
|
||||
RightCategoryMismatchRefusal,
|
||||
RightNonParticipationNoDenial,
|
||||
}
|
||||
got := AllRights()
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("len(AllRights()) = %d, want %d", len(got), len(want))
|
||||
}
|
||||
for i, id := range got {
|
||||
if id != want[i] {
|
||||
t.Errorf("AllRights()[%d] = %q, want %q (canonical order)", i, id, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllWaivableFlagsAllFalse asserts AllWaivableFlags returns a 13-entry
|
||||
// map with all values false. A future agent flipping a flag breaks this
|
||||
// test.
|
||||
func TestAllWaivableFlagsAllFalse(t *testing.T) {
|
||||
flags := AllWaivableFlags()
|
||||
if len(flags) != AntiCaptureBillOfRightsCount {
|
||||
t.Fatalf("len(AllWaivableFlags()) = %d, want %d", len(flags), AntiCaptureBillOfRightsCount)
|
||||
}
|
||||
for id, waivable := range flags {
|
||||
if waivable {
|
||||
t.Errorf("AllWaivableFlags()[%q] = true, want false (REQ-056)", id)
|
||||
}
|
||||
}
|
||||
// Cross-check: every right in AllRights() has an entry in
|
||||
// AllWaivableFlags().
|
||||
for _, id := range AllRights() {
|
||||
if _, ok := flags[id]; !ok {
|
||||
t.Errorf("AllWaivableFlags() missing entry for right %q", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRightIDValues asserts the 13 RightID string values are the expected
|
||||
// canonical strings (a regression on the string value would break
|
||||
// serialized state compatibility).
|
||||
func TestRightIDValues(t *testing.T) {
|
||||
cases := []struct {
|
||||
id RightID
|
||||
want string
|
||||
}{
|
||||
{RightOneTapExit, "OneTapExit"},
|
||||
{RightNoTaxOnPersonalStash, "NoTaxOnPersonalStash"},
|
||||
{RightAuditableVoice, "AuditableVoice"},
|
||||
{RightCooling, "Cooling"},
|
||||
{RightWatcherInspection, "WatcherInspection"},
|
||||
{RightFreeholderVoucher, "FreeholderVoucher"},
|
||||
{RightCounselEscalation, "CounselEscalation"},
|
||||
{RightAnchoredBreadConversion, "AnchoredBreadConversion"},
|
||||
{RightWayfarersRecord, "WayfarersRecord"},
|
||||
{RightSecessionFoundingTerms, "SecessionFoundingTerms"},
|
||||
{RightNonCoverAccess, "NonCoverAccess"},
|
||||
{RightCategoryMismatchRefusal, "CategoryMismatchRefusal"},
|
||||
{RightNonParticipationNoDenial, "NonParticipationNoDenial"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if string(c.id) != c.want {
|
||||
t.Errorf("RightID(%q) value = %q, want %q", c.id, c.id, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+190
-4
@@ -147,6 +147,13 @@ func CoverCategoryPhaseFor(cat CoverCategory) CoverCategoryPhase {
|
||||
// (>= CoverStandingGateTrusted; the pool can demand a higher gate than the
|
||||
// protocol minimum but never lower). FactoryAllowedPhases is the pool's
|
||||
// allowed phases (P1 default = [Phase2] only per D-086).
|
||||
//
|
||||
// P2 extensions (REQ-052, REQ-062): CharterRef is the by-ID-string ref to
|
||||
// the CoverCharter signed for this pool (empty until a Charter is signed);
|
||||
// CouncilRef is the by-ID-string ref to the PoolCouncil elected for this
|
||||
// pool (empty until a Council is seated). Both are by-ID-string per G-003
|
||||
// (no struct import of the charter/council records — the keeper loads them
|
||||
// by ID from their own stores).
|
||||
type CoverPool struct {
|
||||
PoolID string `json:"pool_id" yaml:"pool_id"`
|
||||
HostReachID string `json:"host_reach_id" yaml:"host_reach_id"`
|
||||
@@ -158,6 +165,8 @@ type CoverPool struct {
|
||||
FactoryAllowedPhases []CoverCategoryPhase `json:"factory_allowed_phases" yaml:"factory_allowed_phases"`
|
||||
PoolStandingGate float64 `json:"pool_standing_gate" yaml:"pool_standing_gate"`
|
||||
CreatedAt int64 `json:"created_at" yaml:"created_at"`
|
||||
CharterRef string `json:"charter_ref" yaml:"charter_ref"`
|
||||
CouncilRef string `json:"council_ref" yaml:"council_ref"`
|
||||
}
|
||||
|
||||
// CoverFeeTag is the category tag on a Cover-Fee routing event (REQ-050,
|
||||
@@ -202,12 +211,17 @@ type Params struct {
|
||||
PoolStandingGate float64 `json:"pool_standing_gate" yaml:"pool_standing_gate"`
|
||||
}
|
||||
|
||||
// DefaultParams returns the P1 default Params (D-086): FactoryAllowedPhases
|
||||
// = [Phase2] ONLY (Phase3/Phase4 categories are REJECTED at launch in P1),
|
||||
// PoolStandingGate = CoverStandingGateTrusted (the locked protocol minimum).
|
||||
// DefaultParams returns the P2 default Params (D-086 P2 completion):
|
||||
// FactoryAllowedPhases = [Phase2, Phase3, Phase4] (the P1 default was
|
||||
// [Phase2] only; P2 extends the factory to all three phases so Phase3
|
||||
// categories (EquipmentLoss/LifeBurial/RoadSide) and Phase4 categories
|
||||
// (CyberSkimming/GuildInternalMutualAid) can be launched), PoolStandingGate
|
||||
// = CoverStandingGateTrusted (the locked protocol minimum). A test that
|
||||
// needs the P1 behavior (Phase2 only) overrides FactoryAllowedPhases
|
||||
// explicitly (the D-086 simtest case f does this).
|
||||
func DefaultParams() Params {
|
||||
return Params{
|
||||
FactoryAllowedPhases: []CoverCategoryPhase{Phase2},
|
||||
FactoryAllowedPhases: []CoverCategoryPhase{Phase2, Phase3, Phase4},
|
||||
PoolStandingGate: CoverStandingGateTrusted,
|
||||
}
|
||||
}
|
||||
@@ -309,3 +323,175 @@ func validateCalls(calls []CoverCall) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- P2: Cover-Charter + CharterAmendment + PoolCouncil + CoverCallVote -------
|
||||
//
|
||||
// (REQ-052, REQ-062, REQ-056; vision §15, §8.2.) The four structs below are
|
||||
// the P2 governance surface. CoverCharter is the mission-locked charter a
|
||||
// Pool Host signs (with the Anti-Capture Bill of Rights gate at
|
||||
// MsgSignCoverCharter.ValidateBasic — D-090(1)). CharterAmendment is the
|
||||
// amendment record with a 7-day cooling (the amendment stays Proposed for
|
||||
// 7 days, then Cooled, then Ratified). PoolCouncil is the Pool's elected
|
||||
// governance council (3 Masons + 1 Watcher observer; NO Anchor seat; NO
|
||||
// MAB-holder seat — REQ-062, REQ-063). CoverCallVote is a single vote on
|
||||
// a Cover Call (the majority requires a Watcher observer present for a
|
||||
// CallVoteYes — REQ-062).
|
||||
//
|
||||
// Lexicon note (REQ-012, D-088): "Cover-Charter", "Pool Council", "Cover
|
||||
// Call Vote", "Charter Amendment" are lexicon-clean. The four Cover-
|
||||
// specific banned terms NEVER appear (enforced by lexicon_meta_cover).
|
||||
|
||||
// CharterAmendmentStatus is the lifecycle status of a CharterAmendment
|
||||
// (REQ-052). The amendment transitions Proposed -> Cooled (after the 7-day
|
||||
// cooling) -> Ratified (after the Pool supermajority + Watcher + Counsel).
|
||||
// The cooling is enforced at the handler: a ratify attempt before 7 days
|
||||
// is REJECTED.
|
||||
type CharterAmendmentStatus string
|
||||
|
||||
const (
|
||||
// AmendmentProposed is the initial status (the amendment is filed; the
|
||||
// 7-day cooling clock starts at ProposedAt).
|
||||
AmendmentProposed CharterAmendmentStatus = "Proposed"
|
||||
// AmendmentCooled is the post-cooling status (>= 7 days after
|
||||
// ProposedAt; the amendment is eligible for ratification).
|
||||
AmendmentCooled CharterAmendmentStatus = "Cooled"
|
||||
// AmendmentRatified is the terminal status (the Pool supermajority +
|
||||
// Watcher + Counsel have ratified the amendment).
|
||||
AmendmentRatified CharterAmendmentStatus = "Ratified"
|
||||
)
|
||||
|
||||
// CharterAmendmentCoolingSeconds is the LOCKED 7-day cooling period for a
|
||||
// Charter amendment (REQ-052). The amendment stays Proposed for this many
|
||||
// seconds before it can be Cooled + Ratified. A regression here is a
|
||||
// mission-lock breach (the cooling is the Anti-Capture Bill of Rights
|
||||
// RightCooling enforcement). The handler checks `now - ProposedAt >=
|
||||
// CharterAmendmentCoolingSeconds` before transitioning to Cooled.
|
||||
const CharterAmendmentCoolingSeconds int64 = 7 * 24 * 60 * 60
|
||||
|
||||
// ReserveCeilingAgeSeconds is the LOCKED 12-month operating-history age
|
||||
// required before a Watcher can escalate a pool's reserve target to the
|
||||
// CoverReserveCeilingAnnualContribX (REQ-048). The handler checks
|
||||
// `now - pool.CreatedAt >= ReserveCeilingAgeSeconds` before the escalation
|
||||
// is permitted. A regression here is a mission-lock breach (the 12-month
|
||||
// age check prevents a fresh pool from jumping to the ceiling).
|
||||
const ReserveCeilingAgeSeconds int64 = 365 * 24 * 60 * 60
|
||||
|
||||
// CharterAmendment is a single amendment to a Cover-Charter (REQ-052).
|
||||
// The amendment is filed via MsgAmendCoverCharter (Status = AmendmentProposed,
|
||||
// ProposedAt = now). After the 7-day cooling (CharterAmendmentCoolingSeconds),
|
||||
// a separate handler (or simtest time-advance) transitions it to
|
||||
// AmendmentCooled. After the Pool supermajority + Watcher + Counsel, it
|
||||
// transitions to AmendmentRatified. The cooling is the Anti-Capture Bill
|
||||
// of Rights RightCooling enforcement.
|
||||
type CharterAmendment struct {
|
||||
AmendmentID string `json:"amendment_id" yaml:"amendment_id"`
|
||||
Description string `json:"description" yaml:"description"`
|
||||
Status CharterAmendmentStatus `json:"status" yaml:"status"`
|
||||
ProposedAt int64 `json:"proposed_at" yaml:"proposed_at"`
|
||||
CooledAt int64 `json:"cooled_at" yaml:"cooled_at"`
|
||||
RatifiedAt int64 `json:"ratified_at" yaml:"ratified_at"`
|
||||
}
|
||||
|
||||
// CoverCharter is the mission-locked charter a Pool Host signs (REQ-052,
|
||||
// REQ-056). The charter is signed via MsgSignCoverCharter (the handler
|
||||
// enforces the D-090(1) Bill of Rights gate at ValidateBasic: any
|
||||
// WaivedRights element REJECTS the signing). The charter's
|
||||
// StatementOfBeliefsHash is the hash of the charter's statement of beliefs
|
||||
// (the protocol does NOT enforce the content — FR-CHTR-5). DisputePath is
|
||||
// the dispute-resolution path. Gate is the pool's tightened Standing gate
|
||||
// (>= CoverStandingGateTrusted). HoldingPeriodDays is the minimum holding
|
||||
// period. HostReachID is the host's reach-id. WatcherWitnessHash is the
|
||||
// Watcher's witness hash (the handler calls WatcherKeeper.Attest; a nil
|
||||
// WatcherKeeper skips). Amendments is the amendment history. WaivedRights
|
||||
// is the (ALWAYS EMPTY in a valid charter) slice of waived rights — the
|
||||
// ValidateBasic gate rejects any non-empty slice.
|
||||
type CoverCharter struct {
|
||||
CharterID string `json:"charter_id" yaml:"charter_id"`
|
||||
PoolID string `json:"pool_id" yaml:"pool_id"`
|
||||
StatementOfBeliefsHash []byte `json:"statement_of_beliefs_hash" yaml:"statement_of_beliefs_hash"`
|
||||
DisputePath string `json:"dispute_path" yaml:"dispute_path"`
|
||||
Gate string `json:"gate" yaml:"gate"`
|
||||
HoldingPeriodDays uint32 `json:"holding_period_days" yaml:"holding_period_days"`
|
||||
HostReachID string `json:"host_reach_id" yaml:"host_reach_id"`
|
||||
WatcherWitnessHash []byte `json:"watcher_witness_hash" yaml:"watcher_witness_hash"`
|
||||
Amendments []CharterAmendment `json:"amendments" yaml:"amendments"`
|
||||
WaivedRights []RightID `json:"waived_rights" yaml:"waived_rights"`
|
||||
}
|
||||
|
||||
// PoolCouncil is the Pool's elected governance council (REQ-062). The
|
||||
// council is seated via MsgElectPoolMason (the handler adds MasonReachIDs
|
||||
// to the ElectedMasonReachIDs array, max 3 — a 4th is REJECTED). The
|
||||
// ElectedMasonReachIDs is a fixed-size [3]string array (the three elected
|
||||
// Masons; empty strings until elected). WatcherObserverReachID is the
|
||||
// Watcher observer (the majority-required-with-observer check in
|
||||
// VoteCoverCall: a CallVoteYes requires WatcherObserverPresent == true).
|
||||
// NO Anchor seat (vision §5 — the Anchor does not sit on the Pool
|
||||
// Council). NO MAB-holder seat (REQ-063 — the MAB holder is excluded from
|
||||
// the Pool Council voice set; the MAB governance lands in P4 but the
|
||||
// struct excludes them now).
|
||||
type PoolCouncil struct {
|
||||
PoolID string `json:"pool_id" yaml:"pool_id"`
|
||||
HostReachID string `json:"host_reach_id" yaml:"host_reach_id"`
|
||||
ElectedMasonReachIDs [3]string `json:"elected_mason_reach_ids" yaml:"elected_mason_reach_ids"`
|
||||
WatcherObserverReachID string `json:"watcher_observer_reach_id" yaml:"watcher_observer_reach_id"`
|
||||
}
|
||||
|
||||
// PoolCouncilMaxMasons is the LOCKED max number of elected Masons on a
|
||||
// Pool Council (REQ-062). A 4th election is REJECTED at the handler. A
|
||||
// regression here is a mission-lock breach.
|
||||
const PoolCouncilMaxMasons = 3
|
||||
|
||||
// CallVoteOption is the vote option on a Cover Call (REQ-062). The three
|
||||
// options: CallVoteYes, CallVoteNo, CallVoteAbstain. A CallVoteYes
|
||||
// requires the Watcher observer to be present (WatcherObserverPresent ==
|
||||
// true) at the handler — a CallVoteYes without the observer is REJECTED.
|
||||
type CallVoteOption string
|
||||
|
||||
const (
|
||||
CallVoteYes CallVoteOption = "Yes"
|
||||
CallVoteNo CallVoteOption = "No"
|
||||
CallVoteAbstain CallVoteOption = "Abstain"
|
||||
)
|
||||
|
||||
// CallVoteOptionCount is the LOCKED count of CallVoteOption enum values
|
||||
// (REQ-062). A regression firewall: adding/removing/renaming a
|
||||
// CallVoteOption breaks this const's test.
|
||||
const CallVoteOptionCount = 3
|
||||
|
||||
// AllCallVoteOptions returns all three CallVoteOption values in REQ-062
|
||||
// order. The locked-const test asserts exactly 3 entries.
|
||||
func AllCallVoteOptions() []CallVoteOption {
|
||||
return []CallVoteOption{
|
||||
CallVoteYes,
|
||||
CallVoteNo,
|
||||
CallVoteAbstain,
|
||||
}
|
||||
}
|
||||
|
||||
// knownCallVoteOption reports whether o is one of the three CallVoteOption
|
||||
// values (used by MsgVoteCoverCall.ValidateBasic).
|
||||
func knownCallVoteOption(o CallVoteOption) bool {
|
||||
for _, oo := range AllCallVoteOptions() {
|
||||
if o == oo {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CoverCallVote is a single vote on a Cover Call (REQ-062). The vote is
|
||||
// cast via MsgVoteCoverCall (the handler enforces the CoverCall exists +
|
||||
// the Watcher-observer-present check for a CallVoteYes). VoterReachID is
|
||||
// the voter's reach-id. VoteOption is the CallVoteOption. WatcherObserverPresent
|
||||
// records whether the Watcher observer was present at the time of the vote
|
||||
// (the handler rejects a CallVoteYes with WatcherObserverPresent == false).
|
||||
// VotedAt is the vote timestamp (unix seconds).
|
||||
type CoverCallVote struct {
|
||||
VoteID string `json:"vote_id" yaml:"vote_id"`
|
||||
CallID string `json:"call_id" yaml:"call_id"`
|
||||
PoolID string `json:"pool_id" yaml:"pool_id"`
|
||||
VoterReachID string `json:"voter_reach_id" yaml:"voter_reach_id"`
|
||||
VoteOption CallVoteOption `json:"vote_option" yaml:"vote_option"`
|
||||
WatcherObserverPresent bool `json:"watcher_observer_present" yaml:"watcher_observer_present"`
|
||||
VotedAt int64 `json:"voted_at" yaml:"voted_at"`
|
||||
}
|
||||
|
||||
+117
-6
@@ -81,16 +81,23 @@ func TestCoverCategoryPhaseFor(t *testing.T) {
|
||||
// --- DefaultParams (D-086) --------------------------------------------------
|
||||
|
||||
// TestDefaultParamsFactoryAllowedPhases asserts DefaultParams ships
|
||||
// FactoryAllowedPhases = [Phase2] ONLY (D-086 — P1 allows Phase2 only;
|
||||
// Phase3/Phase4 categories are REJECTED at launch in P1) and
|
||||
// FactoryAllowedPhases = [Phase2, Phase3, Phase4] (D-086 P2 completion —
|
||||
// P1 allowed Phase2 only; P2 extends the factory to all three phases so
|
||||
// Phase3 categories (EquipmentLoss/LifeBurial/RoadSide) and Phase4
|
||||
// categories (CyberSkimming/GuildInternalMutualAid) can be launched) and
|
||||
// PoolStandingGate = CoverStandingGateTrusted (the locked protocol minimum).
|
||||
// A test that needs the P1 behavior (Phase2 only) overrides
|
||||
// FactoryAllowedPhases explicitly.
|
||||
func TestDefaultParamsFactoryAllowedPhases(t *testing.T) {
|
||||
p := DefaultParams()
|
||||
if len(p.FactoryAllowedPhases) != 1 {
|
||||
t.Fatalf("DefaultParams FactoryAllowedPhases len = %d, want 1 (D-086: P1 allows Phase2 only)", len(p.FactoryAllowedPhases))
|
||||
if len(p.FactoryAllowedPhases) != 3 {
|
||||
t.Fatalf("DefaultParams FactoryAllowedPhases len = %d, want 3 (D-086 P2: [Phase2, Phase3, Phase4])", len(p.FactoryAllowedPhases))
|
||||
}
|
||||
if p.FactoryAllowedPhases[0] != Phase2 {
|
||||
t.Errorf("DefaultParams FactoryAllowedPhases[0] = %q, want Phase2 (D-086)", p.FactoryAllowedPhases[0])
|
||||
want := []CoverCategoryPhase{Phase2, Phase3, Phase4}
|
||||
for i, ph := range p.FactoryAllowedPhases {
|
||||
if ph != want[i] {
|
||||
t.Errorf("DefaultParams FactoryAllowedPhases[%d] = %q, want %q (D-086 P2)", i, ph, want[i])
|
||||
}
|
||||
}
|
||||
if p.PoolStandingGate != CoverStandingGateTrusted {
|
||||
t.Errorf("DefaultParams PoolStandingGate = %.2f, want %.2f (CoverStandingGateTrusted)", p.PoolStandingGate, CoverStandingGateTrusted)
|
||||
@@ -214,6 +221,17 @@ func TestLexiconNoBannedTermsInCover(t *testing.T) {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
// Skip the lexicon_meta_cover walk-coverage fixture dir
|
||||
// (G-013): TestLexiconMetaCoverWalkCoverage creates
|
||||
// x/cover/.lexicon_fixture/ with synthetic banned-term .go
|
||||
// files. Those fixtures are test artifacts, NOT production
|
||||
// code; skip the dir to avoid a cross-package test-isolation
|
||||
// race (the fixture is created + cleaned up by the
|
||||
// lexicon_meta_cover package, which runs concurrently with
|
||||
// this package).
|
||||
if info.Name() == ".lexicon_fixture" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".go") {
|
||||
@@ -266,3 +284,96 @@ func TestGenesisStateProtoMessage(t *testing.T) {
|
||||
}
|
||||
m.ProtoMessage() // no-op, just cover
|
||||
}
|
||||
|
||||
// --- P2 consts (REQ-052, REQ-062, REQ-048, D-086) ----------------------------
|
||||
|
||||
// TestP2LockedConsts asserts the P2 locked consts hold their locked values
|
||||
// (REQ-052 cooling, REQ-062 council max + vote options, REQ-048 reserve
|
||||
// ceiling age). A regression here is a mission-lock breach.
|
||||
func TestP2LockedConsts(t *testing.T) {
|
||||
// REQ-052: 7-day Charter amendment cooling.
|
||||
if CharterAmendmentCoolingSeconds != 7*24*60*60 {
|
||||
t.Errorf("CharterAmendmentCoolingSeconds = %d, want %d (REQ-052 7-day cooling)", CharterAmendmentCoolingSeconds, 7*24*60*60)
|
||||
}
|
||||
// REQ-048: 12-month operating history for reserve ceiling escalation.
|
||||
if ReserveCeilingAgeSeconds != 365*24*60*60 {
|
||||
t.Errorf("ReserveCeilingAgeSeconds = %d, want %d (REQ-048 12-month age check)", ReserveCeilingAgeSeconds, 365*24*60*60)
|
||||
}
|
||||
// REQ-062: Pool Council max 3 Masons.
|
||||
if PoolCouncilMaxMasons != 3 {
|
||||
t.Errorf("PoolCouncilMaxMasons = %d, want 3 (REQ-062)", PoolCouncilMaxMasons)
|
||||
}
|
||||
// REQ-062: CallVoteOption enum count = 3.
|
||||
if CallVoteOptionCount != 3 {
|
||||
t.Errorf("CallVoteOptionCount = %d, want 3 (REQ-062)", CallVoteOptionCount)
|
||||
}
|
||||
if len(AllCallVoteOptions()) != 3 {
|
||||
t.Errorf("len(AllCallVoteOptions()) = %d, want 3 (REQ-062)", len(AllCallVoteOptions()))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCallVoteOptionValues asserts the three CallVoteOption string values
|
||||
// (a regression on the string value would break serialized state).
|
||||
func TestCallVoteOptionValues(t *testing.T) {
|
||||
cases := []struct {
|
||||
opt CallVoteOption
|
||||
want string
|
||||
}{
|
||||
{CallVoteYes, "Yes"},
|
||||
{CallVoteNo, "No"},
|
||||
{CallVoteAbstain, "Abstain"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if string(c.opt) != c.want {
|
||||
t.Errorf("CallVoteOption(%q) value = %q, want %q", c.opt, c.opt, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCharterAmendmentStatusValues asserts the three CharterAmendmentStatus
|
||||
// string values (Proposed/Cooled/Ratified).
|
||||
func TestCharterAmendmentStatusValues(t *testing.T) {
|
||||
if string(AmendmentProposed) != "Proposed" {
|
||||
t.Errorf("AmendmentProposed = %q, want Proposed", AmendmentProposed)
|
||||
}
|
||||
if string(AmendmentCooled) != "Cooled" {
|
||||
t.Errorf("AmendmentCooled = %q, want Cooled", AmendmentCooled)
|
||||
}
|
||||
if string(AmendmentRatified) != "Ratified" {
|
||||
t.Errorf("AmendmentRatified = %q, want Ratified", AmendmentRatified)
|
||||
}
|
||||
}
|
||||
|
||||
// TestP2StructConstruction exercises the P2 struct construction (CoverCharter,
|
||||
// CharterAmendment, PoolCouncil, CoverCallVote) for coverage on the
|
||||
// zero-method paths.
|
||||
func TestP2StructConstruction(t *testing.T) {
|
||||
c := CoverCharter{
|
||||
CharterID: "c1", PoolID: "p1", HostReachID: "h1", DisputePath: "dp",
|
||||
Gate: "Trusted", HoldingPeriodDays: 30,
|
||||
StatementOfBeliefsHash: []byte{1, 2, 3},
|
||||
WatcherWitnessHash: []byte{4, 5, 6},
|
||||
Amendments: []CharterAmendment{{AmendmentID: "a1", Status: AmendmentProposed}},
|
||||
WaivedRights: []RightID{},
|
||||
}
|
||||
if c.CharterID != "c1" {
|
||||
t.Errorf("CoverCharter CharterID = %q", c.CharterID)
|
||||
}
|
||||
a := CharterAmendment{AmendmentID: "a1", Description: "d", Status: AmendmentProposed, ProposedAt: 1000}
|
||||
if a.AmendmentID != "a1" {
|
||||
t.Errorf("CharterAmendment AmendmentID = %q", a.AmendmentID)
|
||||
}
|
||||
pc := PoolCouncil{PoolID: "p1", HostReachID: "h1", ElectedMasonReachIDs: [3]string{"m1", "m2", "m3"}, WatcherObserverReachID: "w1"}
|
||||
if pc.ElectedMasonReachIDs[0] != "m1" {
|
||||
t.Errorf("PoolCouncil ElectedMasonReachIDs[0] = %q", pc.ElectedMasonReachIDs[0])
|
||||
}
|
||||
v := CoverCallVote{VoteID: "v1", CallID: "c1", PoolID: "p1", VoterReachID: "v1", VoteOption: CallVoteYes, WatcherObserverPresent: true, VotedAt: 1000}
|
||||
if v.VoteID != "v1" {
|
||||
t.Errorf("CoverCallVote VoteID = %q", v.VoteID)
|
||||
}
|
||||
// CoverPool P2 fields.
|
||||
p := CoverPool{PoolID: "p1", CharterRef: "c1", CouncilRef: "p1"}
|
||||
if p.CharterRef != "c1" || p.CouncilRef != "p1" {
|
||||
t.Errorf("CoverPool P2 refs = %q/%q", p.CharterRef, p.CouncilRef)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
package keeper
|
||||
|
||||
// keeper.go holds the store-backed Keeper for the guild module's Guild
|
||||
// Charter + Chapter Federation + Household + Confederation runtime (P3,
|
||||
// REQ-051, REQ-053, REQ-057, REQ-058).
|
||||
//
|
||||
// The Keeper wraps an sdk.KVStore via a storeKey. It holds:
|
||||
// - the Guild records (guild-id -> Guild; both Parent Guilds and Chapters
|
||||
// are stored here — a Chapter is a Guild with IsChapter=true);
|
||||
// - the Lien records (guild-id + lien-idx -> Lien; the AddLien handler
|
||||
// appends here with SecuredAtFounding=false; founding-locked liens
|
||||
// (SecuredAtFounding=true) are stored on the Guild itself at creation);
|
||||
// - the Confederation Voice delegation records
|
||||
// (confederation-stand-id + member-stand-id -> ConfederationVoice).
|
||||
//
|
||||
// The Keeper also holds the two expected-keeper shims (StandKeeper for the
|
||||
// Household/Confederation type check; StashKeeper for the asset return on
|
||||
// Household one-tap exit). The shims are interfaces (G-003 — no struct
|
||||
// import of x/stand/types or x/stash/types); the concrete keepers (or
|
||||
// simtest stubs) satisfy them structurally.
|
||||
//
|
||||
// State-machine ordering (vision §7, enforced in every handler):
|
||||
// ValidateBasic -> handler authz/gate -> state mutation -> ctx.EventManager().EmitEvent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
storetypes "cosmossdk.io/store/types"
|
||||
"github.com/cosmos/cosmos-sdk/codec"
|
||||
sdk "github.com/cosmos/cosmos-sdk/types"
|
||||
|
||||
"github.com/oy/openyield/x/guild/types"
|
||||
)
|
||||
|
||||
// Keeper is the store-backed guild Keeper.
|
||||
type Keeper struct {
|
||||
cdc codec.Codec
|
||||
storeKey storetypes.StoreKey
|
||||
standKeeper types.StandKeeper
|
||||
stashKeeper types.StashKeeper
|
||||
paramsHolder types.Params
|
||||
}
|
||||
|
||||
// NewKeeper constructs a new store-backed guild Keeper. The StandKeeper +
|
||||
// StashKeeper expected-keeper shims are injected (StandKeeper is nil-able
|
||||
// for partial wiring — the OneTapExitStand + DelegateConfederationVoice
|
||||
// handlers REJECT on a nil StandKeeper (the type check is load-bearing);
|
||||
// StashKeeper is nil-able — a nil StashKeeper skips the asset return on
|
||||
// one-tap exit (simtest wiring)).
|
||||
func NewKeeper(cdc codec.Codec, storeKey storetypes.StoreKey, sk types.StandKeeper, stashK types.StashKeeper) Keeper {
|
||||
return Keeper{
|
||||
cdc: cdc,
|
||||
storeKey: storeKey,
|
||||
standKeeper: sk,
|
||||
stashKeeper: stashK,
|
||||
paramsHolder: types.DefaultParams(),
|
||||
}
|
||||
}
|
||||
|
||||
// SetStandKeeper sets the StandKeeper expected-keeper shim (for
|
||||
// post-construction wiring, e.g., app wiring or test setup).
|
||||
func (k *Keeper) SetStandKeeper(sk types.StandKeeper) { k.standKeeper = sk }
|
||||
|
||||
// SetStashKeeper sets the StashKeeper expected-keeper shim.
|
||||
func (k *Keeper) SetStashKeeper(stashK types.StashKeeper) { k.stashKeeper = stashK }
|
||||
|
||||
// SetParams sets the Params (simtest-grade override; a future version will
|
||||
// load from the params store).
|
||||
func (k *Keeper) SetParams(p types.Params) { k.paramsHolder = p }
|
||||
|
||||
// Params returns the effective Params.
|
||||
func (k Keeper) Params() types.Params { return k.paramsHolder }
|
||||
|
||||
// StoreKey returns the keeper's store key (exported for simtest access to
|
||||
// the underlying KVStore, e.g., to inject corrupt bytes for marshal-error
|
||||
// coverage). Mirrors the x/cover simtest pattern.
|
||||
func (k Keeper) StoreKey() storetypes.StoreKey { return k.storeKey }
|
||||
|
||||
// --- Guild store --------------------------------------------------------------
|
||||
|
||||
var guildKeyPrefix = []byte("guild/")
|
||||
|
||||
func guildKey(guildID string) []byte {
|
||||
return append(guildKeyPrefix, []byte(guildID)...)
|
||||
}
|
||||
|
||||
// GetGuild loads a Guild by guild-id. Returns the Guild and true if found,
|
||||
// or zero value + false if not. Both Parent Guilds and Chapters are stored
|
||||
// here (a Chapter is a Guild with IsChapter=true).
|
||||
func (k Keeper) GetGuild(ctx sdk.Context, guildID string) (types.Guild, bool) {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
bz := store.Get(guildKey(guildID))
|
||||
if bz == nil {
|
||||
return types.Guild{}, false
|
||||
}
|
||||
var g types.Guild
|
||||
if err := json.Unmarshal(bz, &g); err != nil {
|
||||
return types.Guild{}, false
|
||||
}
|
||||
return g, true
|
||||
}
|
||||
|
||||
// SetGuild persists a Guild by guild-id.
|
||||
func (k Keeper) SetGuild(ctx sdk.Context, g types.Guild) {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
bz, err := json.Marshal(g)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("guild: marshal guild %q: %v", g.GuildID, err))
|
||||
}
|
||||
store.Set(guildKey(g.GuildID), bz)
|
||||
}
|
||||
|
||||
// AllGuilds returns all persisted Guild records (iteration helper,
|
||||
// unordered). Both Parent Guilds and Chapters are returned.
|
||||
func (k Keeper) AllGuilds(ctx sdk.Context) []types.Guild {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
iterator := store.Iterator(guildKeyPrefix, prefixEnd(guildKeyPrefix))
|
||||
defer iterator.Close()
|
||||
out := []types.Guild{}
|
||||
for ; iterator.Valid(); iterator.Next() {
|
||||
var g types.Guild
|
||||
if err := json.Unmarshal(iterator.Value(), &g); err == nil {
|
||||
out = append(out, g)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- Lien store ---------------------------------------------------------------
|
||||
//
|
||||
// The Lien store is keyed by guild-id + lien-idx. The AddLien handler
|
||||
// appends here with SecuredAtFounding=false. Founding-locked liens
|
||||
// (SecuredAtFounding=true) are stored on the Guild itself at creation
|
||||
// (GoodStandingLiens slice); the AddLien handler rejects any new
|
||||
// SecuredAtFounding=true lien (founding is a one-time event — REQ-053).
|
||||
|
||||
var lienKeyPrefix = []byte("lien/")
|
||||
|
||||
func lienKey(guildID string, idx uint32) []byte {
|
||||
return append(lienKeyPrefix, []byte(fmt.Sprintf("%s/%d", guildID, idx))...)
|
||||
}
|
||||
|
||||
// GetLien loads a Lien by guild-id + lien-idx. Returns the Lien and true if
|
||||
// found, or zero value + false if not.
|
||||
func (k Keeper) GetLien(ctx sdk.Context, guildID string, idx uint32) (types.Lien, bool) {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
bz := store.Get(lienKey(guildID, idx))
|
||||
if bz == nil {
|
||||
return types.Lien{}, false
|
||||
}
|
||||
var l types.Lien
|
||||
if err := json.Unmarshal(bz, &l); err != nil {
|
||||
return types.Lien{}, false
|
||||
}
|
||||
return l, true
|
||||
}
|
||||
|
||||
// SetLien persists a Lien by guild-id + lien-idx.
|
||||
func (k Keeper) SetLien(ctx sdk.Context, guildID string, idx uint32, l types.Lien) {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
bz, err := json.Marshal(l)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("guild: marshal lien %s/%d: %v", guildID, idx, err))
|
||||
}
|
||||
store.Set(lienKey(guildID, idx), bz)
|
||||
}
|
||||
|
||||
// AllLiens returns all persisted Lien records for a guild (iteration helper,
|
||||
// unordered — the idx ordering is NOT preserved across iterations; the
|
||||
// simtest asserts count + content, not order).
|
||||
func (k Keeper) AllLiens(ctx sdk.Context, guildID string) []types.Lien {
|
||||
prefix := append(lienKeyPrefix, []byte(guildID+"/")...)
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
iterator := store.Iterator(prefix, prefixEnd(prefix))
|
||||
defer iterator.Close()
|
||||
out := []types.Lien{}
|
||||
for ; iterator.Valid(); iterator.Next() {
|
||||
var l types.Lien
|
||||
if err := json.Unmarshal(iterator.Value(), &l); err == nil {
|
||||
out = append(out, l)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// NextLienIdx returns the next lien-idx for a guild (the count of existing
|
||||
// liens — the AddLien handler uses this to assign the new lien's idx). The
|
||||
// founding-locked liens on the Guild's GoodStandingLiens slice do NOT
|
||||
// consume an idx in this store (they are stored on the Guild itself); only
|
||||
// post-founding liens (SecuredAtFounding=false) added via AddLien consume an
|
||||
// idx here.
|
||||
func (k Keeper) NextLienIdx(ctx sdk.Context, guildID string) uint32 {
|
||||
return uint32(len(k.AllLiens(ctx, guildID)))
|
||||
}
|
||||
|
||||
// --- Confederation Voice delegation store --------------------------------------
|
||||
//
|
||||
// The delegation store is keyed by confederation-stand-id + member-stand-id.
|
||||
// The DelegateConfederationVoice handler records one delegation per member
|
||||
// Stand (a duplicate delegation from the same MemberStandID is REJECTED).
|
||||
// One-Stand-one-Vote: each member Stand gets exactly 1 Voice in the
|
||||
// Confederation's aggregate, regardless of size.
|
||||
|
||||
var delegationKeyPrefix = []byte("delegation/")
|
||||
|
||||
func delegationKey(confederationStandID, memberStandID string) []byte {
|
||||
return append(delegationKeyPrefix, []byte(fmt.Sprintf("%s/%s", confederationStandID, memberStandID))...)
|
||||
}
|
||||
|
||||
// GetDelegation loads a ConfederationVoice delegation by confederation-stand-id
|
||||
// + member-stand-id. Returns the ConfederationVoice (from x/guild/types) and
|
||||
// true if found, or zero value + false if not.
|
||||
func (k Keeper) GetDelegation(ctx sdk.Context, confederationStandID, memberStandID string) (types.ConfederationVoice, bool) {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
bz := store.Get(delegationKey(confederationStandID, memberStandID))
|
||||
if bz == nil {
|
||||
return types.ConfederationVoice{}, false
|
||||
}
|
||||
var v types.ConfederationVoice
|
||||
if err := json.Unmarshal(bz, &v); err != nil {
|
||||
return types.ConfederationVoice{}, false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
// SetDelegation persists a ConfederationVoice delegation by confederation-
|
||||
// stand-id + member-stand-id.
|
||||
func (k Keeper) SetDelegation(ctx sdk.Context, v types.ConfederationVoice) {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
bz, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("guild: marshal delegation %s/%s: %v", v.ConfederationStandID, v.MemberStandID, err))
|
||||
}
|
||||
store.Set(delegationKey(v.ConfederationStandID, v.MemberStandID), bz)
|
||||
}
|
||||
|
||||
// AllDelegations returns all persisted ConfederationVoice delegations for a
|
||||
// Confederation Stand (iteration helper, unordered).
|
||||
func (k Keeper) AllDelegations(ctx sdk.Context, confederationStandID string) []types.ConfederationVoice {
|
||||
prefix := append(delegationKeyPrefix, []byte(confederationStandID+"/")...)
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
iterator := store.Iterator(prefix, prefixEnd(prefix))
|
||||
defer iterator.Close()
|
||||
out := []types.ConfederationVoice{}
|
||||
for ; iterator.Valid(); iterator.Next() {
|
||||
var v types.ConfederationVoice
|
||||
if err := json.Unmarshal(iterator.Value(), &v); err == nil {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- prefixEnd helper ---------------------------------------------------------
|
||||
|
||||
// prefixEnd returns the key that sorts immediately after all keys sharing
|
||||
// the given prefix (the standard prefix-iteration end key: increment the
|
||||
// last byte, drop overflow). Used for store.Iterator(start, prefixEnd(start))
|
||||
// prefix scans. Mirrors x/hub/keeper/keeper.go + x/cover/keeper/keeper.go.
|
||||
func prefixEnd(prefix []byte) []byte {
|
||||
if len(prefix) == 0 {
|
||||
return nil
|
||||
}
|
||||
end := make([]byte, len(prefix))
|
||||
copy(end, prefix)
|
||||
for i := len(end) - 1; i >= 0; i-- {
|
||||
end[i]++
|
||||
if end[i] != 0 {
|
||||
return end
|
||||
}
|
||||
}
|
||||
// All bytes were 0xFF; return nil (iterate to end of store).
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
package keeper
|
||||
|
||||
// msg_server.go implements the guild module's MsgServer (P3, REQ-051,
|
||||
// REQ-053, REQ-057, REQ-058, REQ-061). The MsgServer wraps the Keeper + the
|
||||
// StandKeeper + StashKeeper expected-keeper shims (already on the Keeper).
|
||||
//
|
||||
// Each method returns a (*Response, error). Handler state-machine ordering
|
||||
// is enforced: ValidateBasic -> handler authz/gate -> state mutation ->
|
||||
// ctx.EventManager().EmitEvent.
|
||||
//
|
||||
// Handler set:
|
||||
// - CreateGuild (REQ-051): validate, idempotency, persist Guild with
|
||||
// CommonBondHash + PublicProfile, surface a jurisdictional disclaimer
|
||||
// (REQ-061).
|
||||
// - CreateChapter (REQ-053): validate, idempotency, load Parent Guild,
|
||||
// pin SecessionTermsHash, set IsChapter=true + ParentGuildID, record
|
||||
// GoodStandingLiens (SecuredAtFounding=true), reject cooling below the
|
||||
// protocol minimum, persist, surface a disclaimer (REQ-061).
|
||||
// - OneTapExitStand (REQ-057): validate, assert Stand type is Household
|
||||
// via StandKeeper shim (nil REJECTS), dissolve the Stand + return assets
|
||||
// to the Holder's Stash via StashKeeper shim (nil skips the return,
|
||||
// still emits the dissolution event), emit event.
|
||||
// - DelegateConfederationVoice (REQ-058): validate, assert Confederation
|
||||
// Stand type via StandKeeper shim, record one delegation per member
|
||||
// Stand (duplicate REJECTED), emit event.
|
||||
// - AddLien (REQ-053): validate, load Guild, REJECT any new
|
||||
// SecuredAtFounding=true lien (founding is one-time — REQ-053/REQ-081),
|
||||
// persist the lien, emit event.
|
||||
//
|
||||
// Nil-shim behavior (simtest wiring): a nil StandKeeper REJECTS the
|
||||
// OneTapExitStand + DelegateConfederationVoice handlers (the Household /
|
||||
// Confederation type check is load-bearing — it cannot be skipped). A nil
|
||||
// StashKeeper skips the asset return on one-tap exit (the handler still
|
||||
// emits the dissolution event — the asset return is a side-effect the
|
||||
// simtest stub records).
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
sdk "github.com/cosmos/cosmos-sdk/types"
|
||||
|
||||
"github.com/oy/openyield/x/guild/types"
|
||||
)
|
||||
|
||||
// DisclaimerJurisdictional is the jurisdictional disclaimer surfaced at
|
||||
// every charter signing (REQ-061). NOT session-bounded — surfaced at every
|
||||
// CreateGuild + CreateChapter. The disclaimer is a fixed string (the live
|
||||
// jurisdictional overlay lands in a later phase; the simtest asserts the
|
||||
// Disclaimer field is non-empty).
|
||||
const DisclaimerJurisdictional = "OpenYield Guilds are self-governed mesh collectives; the protocol does not provide legal, tax, or fiduciary advice. Signers affirm they have reviewed the Common Bond + jurisdictional obligations before signing."
|
||||
|
||||
// msgServer is the concrete MsgServer implementation wrapping the Keeper.
|
||||
type msgServer struct {
|
||||
Keeper
|
||||
}
|
||||
|
||||
// NewMsgServerImpl returns the guild MsgServer for the provided Keeper.
|
||||
func NewMsgServerImpl(k Keeper) types.MsgServer {
|
||||
return &msgServer{Keeper: k}
|
||||
}
|
||||
|
||||
var _ types.MsgServer = msgServer{}
|
||||
|
||||
// unwrapCtx extracts the sdk.Context from the interface-typed ctx.
|
||||
func unwrapCtx(ctx interface{}) sdk.Context {
|
||||
if c, ok := ctx.(sdk.Context); ok {
|
||||
return c
|
||||
}
|
||||
panic(fmt.Sprintf("guild: expected sdk.Context, got %T", ctx))
|
||||
}
|
||||
|
||||
// --- CreateGuild (REQ-051, REQ-061) -------------------------------------------
|
||||
|
||||
// CreateGuild creates a Guild with a Common Bond hash + Public Profile
|
||||
// (REQ-051). The handler enforces:
|
||||
// 1. ValidateBasic (stateless — non-empty fields + non-empty
|
||||
// CommonBondHash).
|
||||
// 2. Idempotency: guild-id must not already exist.
|
||||
// 3. Persist the Guild with CommonBondHash + PublicProfile (the Common
|
||||
// Bond is hash-pinned at creation — immutable; the handler does NOT
|
||||
// store the bond text, only the hash).
|
||||
// 4. Surface a jurisdictional disclaimer (REQ-061) in the response.
|
||||
//
|
||||
// On success the Guild is persisted and an event is emitted.
|
||||
func (s msgServer) CreateGuild(ctx interface{}, msg *types.MsgCreateGuild) (*types.MsgCreateGuildResponse, error) {
|
||||
if err := msg.ValidateBasic(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sdkCtx := unwrapCtx(ctx)
|
||||
|
||||
// Idempotency: guild-id must not already exist.
|
||||
if _, ok := s.Keeper.GetGuild(sdkCtx, msg.GuildID); ok {
|
||||
return nil, fmt.Errorf("guild: guild %q already exists", msg.GuildID)
|
||||
}
|
||||
|
||||
g := types.Guild{
|
||||
GuildID: msg.GuildID,
|
||||
Name: msg.Name,
|
||||
FounderReach: msg.FounderReach,
|
||||
CreatedAt: sdkCtx.BlockTime().Unix(),
|
||||
StandAffiliationID: msg.StandAffiliationID,
|
||||
CommonBondHash: msg.CommonBondHash,
|
||||
PublicProfile: msg.PublicProfile,
|
||||
IsChapter: false,
|
||||
ParentGuildID: "",
|
||||
}
|
||||
s.Keeper.SetGuild(sdkCtx, g)
|
||||
|
||||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||
"guild.guild_created",
|
||||
sdk.NewAttribute("guild_id", msg.GuildID),
|
||||
sdk.NewAttribute("founder_reach", msg.FounderReach),
|
||||
))
|
||||
return &types.MsgCreateGuildResponse{Disclaimer: DisclaimerJurisdictional}, nil
|
||||
}
|
||||
|
||||
// --- CreateChapter (REQ-053, REQ-061) -----------------------------------------
|
||||
|
||||
// CreateChapter creates a Chapter under a Parent Guild (REQ-053). The
|
||||
// handler enforces:
|
||||
// 1. ValidateBasic (stateless — non-empty fields, SecessionTerms valid +
|
||||
// protocol-minimum-bounded, each GoodStandingLien is SecuredAtFounding).
|
||||
// 2. Idempotency: chapter guild-id must not already exist.
|
||||
// 3. Load the Parent Guild (must exist; must NOT itself be a Chapter — a
|
||||
// Chapter cannot have a Chapter parent).
|
||||
// 4. Pin the SecessionTerms hash (HashSecessionTerms — immutable; no
|
||||
// handler to amend it).
|
||||
// 5. Set IsChapter=true + ParentGuildID + GoodStandingLiens (each with
|
||||
// SecuredAtFounding=true — ValidateBasic already enforced this).
|
||||
// 6. Persist the Chapter.
|
||||
// 7. Surface a jurisdictional disclaimer (REQ-061) in the response.
|
||||
//
|
||||
// On success the Chapter is persisted and an event is emitted.
|
||||
func (s msgServer) CreateChapter(ctx interface{}, msg *types.MsgCreateChapter) (*types.MsgCreateChapterResponse, error) {
|
||||
if err := msg.ValidateBasic(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sdkCtx := unwrapCtx(ctx)
|
||||
|
||||
// Idempotency: chapter guild-id must not already exist.
|
||||
if _, ok := s.Keeper.GetGuild(sdkCtx, msg.GuildID); ok {
|
||||
return nil, fmt.Errorf("guild: chapter %q already exists", msg.GuildID)
|
||||
}
|
||||
|
||||
// Load the Parent Guild (must exist; must NOT itself be a Chapter).
|
||||
parent, ok := s.Keeper.GetGuild(sdkCtx, msg.ParentGuildID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("guild: parent guild %q not found (REQ-053)", msg.ParentGuildID)
|
||||
}
|
||||
if parent.IsChapter {
|
||||
return nil, fmt.Errorf("guild: parent %q is itself a Chapter (a Chapter cannot have a Chapter parent — REQ-053)", msg.ParentGuildID)
|
||||
}
|
||||
|
||||
// Pin the SecessionTerms hash (immutable — no handler to amend it).
|
||||
termsHash := types.HashSecessionTerms(msg.SecessionTerms)
|
||||
|
||||
// GoodStandingLiens are recorded with SecuredAtFounding=true
|
||||
// (ValidateBasic already enforced this — founding-locked liens).
|
||||
liens := make([]types.Lien, len(msg.GoodStandingLiens))
|
||||
copy(liens, msg.GoodStandingLiens)
|
||||
|
||||
chapter := types.Guild{
|
||||
GuildID: msg.GuildID,
|
||||
Name: msg.Name,
|
||||
FounderReach: msg.FounderReach,
|
||||
CreatedAt: sdkCtx.BlockTime().Unix(),
|
||||
CommonBondHash: parent.CommonBondHash, // a Chapter inherits the Parent's Common Bond hash
|
||||
PublicProfile: parent.PublicProfile, // a Chapter inherits the Parent's Public Profile
|
||||
IsChapter: true,
|
||||
ParentGuildID: msg.ParentGuildID,
|
||||
SecessionTermsHash: termsHash,
|
||||
GoodStandingLiens: liens,
|
||||
}
|
||||
s.Keeper.SetGuild(sdkCtx, chapter)
|
||||
|
||||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||
"guild.chapter_created",
|
||||
sdk.NewAttribute("guild_id", msg.GuildID),
|
||||
sdk.NewAttribute("parent_guild_id", msg.ParentGuildID),
|
||||
))
|
||||
return &types.MsgCreateChapterResponse{Disclaimer: DisclaimerJurisdictional}, nil
|
||||
}
|
||||
|
||||
// --- OneTapExitStand (REQ-057) ------------------------------------------------
|
||||
|
||||
// OneTapExitStand one-tap exits a Household Stand (REQ-057). The handler
|
||||
// enforces:
|
||||
// 1. ValidateBasic (stateless).
|
||||
// 2. StandKeeper shim must be non-nil (the Household type check is
|
||||
// load-bearing — a nil shim is a wiring error, REJECTED).
|
||||
// 3. The Stand must exist + its type must be "Household" (one-tap exit is
|
||||
// Household-only — a Crew / Entity / etc. Stand is REJECTED).
|
||||
// 4. StashKeeper shim: if non-nil, call ReturnAssetsToHolder to return the
|
||||
// dissolved Stand's assets to the Holder's Stash (a nil shim skips the
|
||||
// return — simtest wiring; the dissolution event is still emitted). A
|
||||
// non-nil error from ReturnAssetsToHolder REJECTS the dissolution (the
|
||||
// asset return is load-bearing — a failed return leaves the Stand
|
||||
// intact).
|
||||
// 5. Emit the dissolution event.
|
||||
//
|
||||
// The signer is treated as the Holder (the Reach the assets are returned
|
||||
// to). The live authz (signer must be the Stand's admin-reach) is deferred
|
||||
// (simtest grade).
|
||||
func (s msgServer) OneTapExitStand(ctx interface{}, msg *types.MsgOneTapExitStand) (*types.MsgOneTapExitStandResponse, error) {
|
||||
if err := msg.ValidateBasic(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sdkCtx := unwrapCtx(ctx)
|
||||
|
||||
// StandKeeper shim must be non-nil (the type check is load-bearing).
|
||||
if s.Keeper.standKeeper == nil {
|
||||
return nil, fmt.Errorf("guild: StandKeeper not wired (OneTapExitStand rejected — Household type check is load-bearing)")
|
||||
}
|
||||
|
||||
// The Stand must exist + be a Household (one-tap exit is Household-only).
|
||||
standType, exists := s.Keeper.standKeeper.GetStand(msg.StandID)
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("guild: stand %q not found (OneTapExitStand rejected)", msg.StandID)
|
||||
}
|
||||
if standType != "Household" {
|
||||
return nil, fmt.Errorf("guild: stand %q type %q is not a Household (one-tap exit is Household-only — REQ-057)", msg.StandID, standType)
|
||||
}
|
||||
|
||||
// StashKeeper: return the dissolved Stand's assets to the Holder's Stash.
|
||||
// A nil shim skips the return (simtest wiring); a non-nil error REJECTS
|
||||
// (the asset return is load-bearing).
|
||||
if s.Keeper.stashKeeper != nil {
|
||||
if err := s.Keeper.stashKeeper.ReturnAssetsToHolder(msg.Signer, msg.StandID); err != nil {
|
||||
return nil, fmt.Errorf("guild: return assets to holder %q for stand %q: %w", msg.Signer, msg.StandID, err)
|
||||
}
|
||||
}
|
||||
|
||||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||
"guild.one_tap_exit",
|
||||
sdk.NewAttribute("stand_id", msg.StandID),
|
||||
sdk.NewAttribute("holder_reach", msg.Signer),
|
||||
))
|
||||
return &types.MsgOneTapExitStandResponse{}, nil
|
||||
}
|
||||
|
||||
// --- DelegateConfederationVoice (REQ-058) -------------------------------------
|
||||
|
||||
// DelegateConfederationVoice delegates a member Stand's Voice in a
|
||||
// Confederation (REQ-058). The handler enforces:
|
||||
// 1. ValidateBasic (stateless).
|
||||
// 2. StandKeeper shim must be non-nil (the Confederation type check is
|
||||
// load-bearing — a nil shim is a wiring error, REJECTED).
|
||||
// 3. The Confederation Stand must exist + its type must be "Confederation".
|
||||
// 4. One delegation per member Stand: a duplicate delegation from the same
|
||||
// MemberStandID is REJECTED (one-Stand-one-Vote — each member Stand gets
|
||||
// exactly 1 Voice in the Confederation's aggregate, regardless of size).
|
||||
// 5. Persist the delegation + emit the event.
|
||||
func (s msgServer) DelegateConfederationVoice(ctx interface{}, msg *types.MsgDelegateConfederationVoice) (*types.MsgDelegateConfederationVoiceResponse, error) {
|
||||
if err := msg.ValidateBasic(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sdkCtx := unwrapCtx(ctx)
|
||||
|
||||
// StandKeeper shim must be non-nil (the type check is load-bearing).
|
||||
if s.Keeper.standKeeper == nil {
|
||||
return nil, fmt.Errorf("guild: StandKeeper not wired (DelegateConfederationVoice rejected — Confederation type check is load-bearing)")
|
||||
}
|
||||
|
||||
// The Confederation Stand must exist + be a Confederation.
|
||||
standType, exists := s.Keeper.standKeeper.GetStand(msg.ConfederationStandID)
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("guild: confederation stand %q not found", msg.ConfederationStandID)
|
||||
}
|
||||
if standType != "Confederation" {
|
||||
return nil, fmt.Errorf("guild: stand %q type %q is not a Confederation (REQ-058)", msg.ConfederationStandID, standType)
|
||||
}
|
||||
|
||||
// One delegation per member Stand: a duplicate is REJECTED.
|
||||
if _, ok := s.Keeper.GetDelegation(sdkCtx, msg.ConfederationStandID, msg.MemberStandID); ok {
|
||||
return nil, fmt.Errorf("guild: member stand %q already delegates in confederation %q (one-Stand-one-Vote — duplicate REJECTED — REQ-058)", msg.MemberStandID, msg.ConfederationStandID)
|
||||
}
|
||||
|
||||
v := types.ConfederationVoice{
|
||||
ConfederationStandID: msg.ConfederationStandID,
|
||||
MemberStandID: msg.MemberStandID,
|
||||
DelegateReachID: msg.DelegateReachID,
|
||||
DelegatedAt: sdkCtx.BlockTime().Unix(),
|
||||
}
|
||||
s.Keeper.SetDelegation(sdkCtx, v)
|
||||
|
||||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||
"guild.confederation_voice_delegated",
|
||||
sdk.NewAttribute("confederation_stand_id", msg.ConfederationStandID),
|
||||
sdk.NewAttribute("member_stand_id", msg.MemberStandID),
|
||||
sdk.NewAttribute("delegate_reach_id", msg.DelegateReachID),
|
||||
))
|
||||
return &types.MsgDelegateConfederationVoiceResponse{}, nil
|
||||
}
|
||||
|
||||
// --- AddLien (REQ-053) --------------------------------------------------------
|
||||
|
||||
// AddLien adds a Good-Standing Lien to a Guild (REQ-053). The handler
|
||||
// enforces:
|
||||
// 1. ValidateBasic (stateless — non-empty fields, Lien Amount > 0).
|
||||
// 2. The Guild must exist.
|
||||
// 3. REJECT any new SecuredAtFounding=true lien (founding is a one-time
|
||||
// event — REQ-053/REQ-081; post-founding liens added via AddLien MUST
|
||||
// be SecuredAtFounding=false).
|
||||
// 4. Persist the lien (assigned the next lien-idx) + emit the event.
|
||||
func (s msgServer) AddLien(ctx interface{}, msg *types.MsgAddLien) (*types.MsgAddLienResponse, error) {
|
||||
if err := msg.ValidateBasic(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sdkCtx := unwrapCtx(ctx)
|
||||
|
||||
// The Guild must exist.
|
||||
if _, ok := s.Keeper.GetGuild(sdkCtx, msg.GuildID); !ok {
|
||||
return nil, fmt.Errorf("guild: guild %q not found (AddLien rejected)", msg.GuildID)
|
||||
}
|
||||
|
||||
// REJECT any new SecuredAtFounding=true lien (founding is one-time —
|
||||
// REQ-053/REQ-081).
|
||||
if msg.Lien.SecuredAtFounding {
|
||||
return nil, fmt.Errorf("guild: AddLien rejects SecuredAtFounding=true liens (founding is a one-time event — REQ-053/REQ-081; post-founding liens must be SecuredAtFounding=false)")
|
||||
}
|
||||
|
||||
idx := s.Keeper.NextLienIdx(sdkCtx, msg.GuildID)
|
||||
s.Keeper.SetLien(sdkCtx, msg.GuildID, idx, msg.Lien)
|
||||
|
||||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||
"guild.lien_added",
|
||||
sdk.NewAttribute("guild_id", msg.GuildID),
|
||||
sdk.NewAttribute("lien_idx", fmt.Sprintf("%d", idx)),
|
||||
sdk.NewAttribute("creditor_reach_id", msg.Lien.CreditorReachID),
|
||||
sdk.NewAttribute("amount", fmt.Sprintf("%d", msg.Lien.Amount)),
|
||||
))
|
||||
return &types.MsgAddLienResponse{}, nil
|
||||
}
|
||||
@@ -0,0 +1,978 @@
|
||||
package keeper_test
|
||||
|
||||
// msg_server_simtest_test.go is the x/guild keeper simtest (P3, REQ-051,
|
||||
// REQ-053, REQ-057, REQ-058, REQ-061).
|
||||
//
|
||||
// D-054: simtest-grade — in-memory sdk.Context + dbm in-memory store, no
|
||||
// real Stand keeper (the StandKeeper shim is a stub; G-003 test exemption),
|
||||
// no real Stash keeper (the StashKeeper shim is a simtest-local stub that
|
||||
// records ReturnAssetsToHolder calls for assertion). The simtest exercises:
|
||||
//
|
||||
// CreateGuild (REQ-051 + REQ-061 disclaimer):
|
||||
// - (a) successful Guild creation with Common Bond hash + Public Profile
|
||||
// (MasonCount disclosed).
|
||||
// - (b) successful Guild creation with MasonCountPrivate=true (count not
|
||||
// disclosed — MasonCount is 0).
|
||||
// - idempotency: a second CreateGuild on the same guild-id is REJECTED.
|
||||
// - (g) Disclaimer surfaced at every signing (the response Disclaimer is
|
||||
// non-empty).
|
||||
//
|
||||
// CreateChapter (REQ-053 + REQ-061 disclaimer):
|
||||
// - (c) successful Chapter creation with secession terms hash-pinned +
|
||||
// good-standing liens (SecuredAtFounding=true).
|
||||
// - (d) Chapter inherits Parent policy + tightens (longer cooling allowed)
|
||||
// + loosens (shorter cooling REJECTED at ValidateBasic).
|
||||
// - rejected on non-existent Parent Guild.
|
||||
// - rejected when Parent is itself a Chapter.
|
||||
// - (g) Disclaimer surfaced at every signing.
|
||||
//
|
||||
// OneTapExitStand (REQ-057):
|
||||
// - (e) Household one-tap exit succeeds (Stand type Household + StandKeeper
|
||||
// stub returns "Household" + StashKeeper stub records the call).
|
||||
// - (e) Crew one-tap exit REJECTED (one-tap is Household-only).
|
||||
// - rejected on non-existent Stand.
|
||||
// - rejected on nil StandKeeper (the type check is load-bearing).
|
||||
//
|
||||
// DelegateConfederationVoice (REQ-058):
|
||||
// - (f) Confederation Voice delegation succeeds (one-per-Stand).
|
||||
// - (f) duplicate delegation REJECTED (one-Stand-one-Vote).
|
||||
// - rejected on non-Confederation Stand type.
|
||||
// - rejected on nil StandKeeper.
|
||||
//
|
||||
// AddLien (REQ-053):
|
||||
// - (h) post-founding lien with SecuredAtFounding=false succeeds.
|
||||
// - (h) post-founding lien with SecuredAtFounding=true REJECTED (founding
|
||||
// is one-time — REQ-053/REQ-081).
|
||||
// - rejected on non-existent Guild.
|
||||
//
|
||||
// Coverage target: >=80% on x/guild/keeper.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cosmossdk.io/log"
|
||||
"cosmossdk.io/store"
|
||||
storetypes "cosmossdk.io/store/types"
|
||||
cmtproto "github.com/cometbft/cometbft/proto/tendermint/types"
|
||||
dbm "github.com/cosmos/cosmos-db"
|
||||
"github.com/cosmos/cosmos-sdk/codec"
|
||||
codectypes "github.com/cosmos/cosmos-sdk/codec/types"
|
||||
sdk "github.com/cosmos/cosmos-sdk/types"
|
||||
|
||||
"github.com/oy/openyield/x/guild/keeper"
|
||||
"github.com/oy/openyield/x/guild/types"
|
||||
)
|
||||
|
||||
// --- Stub expected-keepers (G-003 test exemption) ---------------------------
|
||||
|
||||
// stubStandKeeper satisfies types.StandKeeper for the simtest. It returns a
|
||||
// configurable stand-type per stand-id (a missing key returns ("", false) —
|
||||
// the non-existent Stand case).
|
||||
type stubStandKeeper struct {
|
||||
stands map[string]string // stand-id -> stand-type
|
||||
}
|
||||
|
||||
func (s *stubStandKeeper) GetStand(standID string) (string, bool) {
|
||||
if s.stands == nil {
|
||||
return "", false
|
||||
}
|
||||
t, ok := s.stands[standID]
|
||||
return t, ok
|
||||
}
|
||||
|
||||
// stubStashKeeper satisfies types.StashKeeper for the simtest. It records
|
||||
// every ReturnAssetsToHolder call for assertion (the one-tap exit simtest
|
||||
// asserts the call was made with the right holder + stand-id).
|
||||
type stubStashKeeper struct {
|
||||
calls []struct {
|
||||
holderReachID string
|
||||
standID string
|
||||
}
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *stubStashKeeper) ReturnAssetsToHolder(holderReachID string, standID string) error {
|
||||
if s.err != nil {
|
||||
return s.err
|
||||
}
|
||||
s.calls = append(s.calls, struct {
|
||||
holderReachID string
|
||||
standID string
|
||||
}{holderReachID, standID})
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Simtest context helper --------------------------------------------------
|
||||
|
||||
// newSimtestContext constructs an in-memory sdk.Context with a KVStore
|
||||
// mounted at the guild store key. Returns the ctx, the two stub keepers,
|
||||
// the store key, and the Keeper.
|
||||
func newSimtestContext(t *testing.T) (sdk.Context, *stubStandKeeper, *stubStashKeeper, storetypes.StoreKey, keeper.Keeper) {
|
||||
t.Helper()
|
||||
db := dbm.NewMemDB()
|
||||
cdc := newTestCodec()
|
||||
storeKey := storetypes.NewKVStoreKey(types.StoreKey)
|
||||
cms := store.NewCommitMultiStore(db, log.NewNopLogger(), nil)
|
||||
cms.MountStoreWithDB(storeKey, storetypes.StoreTypeDB, nil)
|
||||
if err := cms.LoadLatestVersion(); err != nil {
|
||||
t.Fatalf("load latest version: %v", err)
|
||||
}
|
||||
ctx := sdk.NewContext(cms, cmtproto.Header{Time: time.Unix(1000, 0)}, false, log.NewNopLogger())
|
||||
|
||||
sk := &stubStandKeeper{}
|
||||
stashK := &stubStashKeeper{}
|
||||
k := keeper.NewKeeper(cdc, storeKey, sk, stashK)
|
||||
return ctx, sk, stashK, storeKey, k
|
||||
}
|
||||
|
||||
// newSimtestContextNilStand constructs an in-memory ctx with a nil
|
||||
// StandKeeper (for the nil-shim reject-path coverage).
|
||||
func newSimtestContextNilStand(t *testing.T) (sdk.Context, storetypes.StoreKey, keeper.Keeper) {
|
||||
t.Helper()
|
||||
db := dbm.NewMemDB()
|
||||
cdc := newTestCodec()
|
||||
storeKey := storetypes.NewKVStoreKey(types.StoreKey)
|
||||
cms := store.NewCommitMultiStore(db, log.NewNopLogger(), nil)
|
||||
cms.MountStoreWithDB(storeKey, storetypes.StoreTypeDB, nil)
|
||||
if err := cms.LoadLatestVersion(); err != nil {
|
||||
t.Fatalf("load latest version: %v", err)
|
||||
}
|
||||
ctx := sdk.NewContext(cms, cmtproto.Header{Time: time.Unix(1000, 0)}, false, log.NewNopLogger())
|
||||
k := keeper.NewKeeper(cdc, storeKey, nil, nil)
|
||||
return ctx, storeKey, k
|
||||
}
|
||||
|
||||
// newTestCodec constructs a minimal codec for the simtest.
|
||||
func newTestCodec() codec.Codec {
|
||||
registry := codectypes.NewInterfaceRegistry()
|
||||
return codec.NewProtoCodec(registry)
|
||||
}
|
||||
|
||||
// hasEvent reports whether ctx emitted an event of the given type.
|
||||
func hasEvent(ctx sdk.Context, eventType string) bool {
|
||||
for _, ev := range ctx.EventManager().Events() {
|
||||
if ev.Type == eventType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// validTerms returns SecessionTerms at the protocol minimums.
|
||||
func validTerms() types.SecessionTerms {
|
||||
return types.SecessionTerms{
|
||||
CoolingCoverActiveDays: types.CoolingSecessionCoverActiveDays,
|
||||
CoolingNonCoverDays: types.CoolingSecessionNonCoverDays,
|
||||
LienAuditRequired: true,
|
||||
CovenantClearanceRequired: true,
|
||||
}
|
||||
}
|
||||
|
||||
// createParentGuild is a helper that creates a Parent Guild for the Chapter
|
||||
// simtest cases.
|
||||
func createParentGuild(t *testing.T, srv types.MsgServer, ctx sdk.Context, guildID string) {
|
||||
t.Helper()
|
||||
_, err := srv.CreateGuild(ctx, &types.MsgCreateGuild{
|
||||
GuildID: guildID,
|
||||
Name: "Parent",
|
||||
FounderReach: "reach:founder",
|
||||
CommonBondHash: []byte{0xAA, 0xBB, 0xCC},
|
||||
PublicProfile: types.GuildPublicProfile{
|
||||
BondSummary: "bond-summary",
|
||||
MasonCount: 10,
|
||||
},
|
||||
Signer: "reach:founder",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("createParentGuild %q: %v", guildID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- CreateGuild (REQ-051, REQ-061) ------------------------------------------
|
||||
|
||||
// TestCreateGuildSuccess (case a) asserts a successful Guild creation with
|
||||
// Common Bond hash + Public Profile (MasonCount disclosed).
|
||||
func TestCreateGuildSuccess(t *testing.T) {
|
||||
ctx, _, _, _, k := newSimtestContext(t)
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
|
||||
resp, err := srv.CreateGuild(ctx, &types.MsgCreateGuild{
|
||||
GuildID: "g-1",
|
||||
Name: "Task Guild",
|
||||
FounderReach: "reach:founder",
|
||||
CommonBondHash: []byte{1, 2, 3},
|
||||
PublicProfile: types.GuildPublicProfile{
|
||||
BondSummary: "a bond summary",
|
||||
Disclaimers: []string{"d1"},
|
||||
MasonCount: 42,
|
||||
},
|
||||
Signer: "reach:founder",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateGuild: %v", err)
|
||||
}
|
||||
g, ok := k.GetGuild(ctx, "g-1")
|
||||
if !ok {
|
||||
t.Fatal("Guild not persisted")
|
||||
}
|
||||
if g.IsChapter {
|
||||
t.Error("IsChapter should be false for a Parent Guild")
|
||||
}
|
||||
if g.ParentGuildID != "" {
|
||||
t.Errorf("ParentGuildID = %q, want empty for a Parent Guild", g.ParentGuildID)
|
||||
}
|
||||
if len(g.CommonBondHash) != 3 {
|
||||
t.Errorf("CommonBondHash = %v, want 3 bytes", g.CommonBondHash)
|
||||
}
|
||||
if g.PublicProfile.MasonCount != 42 {
|
||||
t.Errorf("MasonCount = %d, want 42", g.PublicProfile.MasonCount)
|
||||
}
|
||||
if g.PublicProfile.MasonCountPrivate {
|
||||
t.Error("MasonCountPrivate should be false when count is disclosed")
|
||||
}
|
||||
if !hasEvent(ctx, "guild.guild_created") {
|
||||
t.Error("guild.guild_created event not emitted")
|
||||
}
|
||||
// (g) Disclaimer surfaced.
|
||||
if resp.Disclaimer == "" {
|
||||
t.Error("CreateGuild response Disclaimer is empty (REQ-061)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateGuildMasonCountPrivate (case b) asserts a Guild creation with
|
||||
// MasonCountPrivate=true (count not disclosed — MasonCount is 0).
|
||||
func TestCreateGuildMasonCountPrivate(t *testing.T) {
|
||||
ctx, _, _, _, k := newSimtestContext(t)
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
|
||||
_, err := srv.CreateGuild(ctx, &types.MsgCreateGuild{
|
||||
GuildID: "g-priv",
|
||||
Name: "Private Count Guild",
|
||||
FounderReach: "reach:f",
|
||||
CommonBondHash: []byte{1},
|
||||
PublicProfile: types.GuildPublicProfile{
|
||||
BondSummary: "private count",
|
||||
MasonCount: 0,
|
||||
MasonCountPrivate: true,
|
||||
},
|
||||
Signer: "reach:f",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateGuild: %v", err)
|
||||
}
|
||||
g, _ := k.GetGuild(ctx, "g-priv")
|
||||
if !g.PublicProfile.MasonCountPrivate {
|
||||
t.Error("MasonCountPrivate should be true")
|
||||
}
|
||||
if g.PublicProfile.MasonCount != 0 {
|
||||
t.Errorf("MasonCount = %d, want 0 (not disclosed)", g.PublicProfile.MasonCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateGuildIdempotentReject asserts a second CreateGuild on the same
|
||||
// guild-id is REJECTED.
|
||||
func TestCreateGuildIdempotentReject(t *testing.T) {
|
||||
ctx, _, _, _, k := newSimtestContext(t)
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
|
||||
first := &types.MsgCreateGuild{
|
||||
GuildID: "g-dup", Name: "n", FounderReach: "reach:f",
|
||||
CommonBondHash: []byte{1}, Signer: "reach:f",
|
||||
}
|
||||
if _, err := srv.CreateGuild(ctx, first); err != nil {
|
||||
t.Fatalf("first CreateGuild: %v", err)
|
||||
}
|
||||
_, err := srv.CreateGuild(ctx, first)
|
||||
if err == nil {
|
||||
t.Error("second CreateGuild on same guild-id should be rejected (idempotent)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateGuildValidateBasicReject asserts a CreateGuild with empty
|
||||
// CommonBondHash is REJECTED at ValidateBasic.
|
||||
func TestCreateGuildValidateBasicReject(t *testing.T) {
|
||||
ctx, _, _, _, k := newSimtestContext(t)
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
|
||||
_, err := srv.CreateGuild(ctx, &types.MsgCreateGuild{
|
||||
GuildID: "g-bad", Name: "n", FounderReach: "reach:f",
|
||||
CommonBondHash: nil, Signer: "reach:f",
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("CreateGuild with empty CommonBondHash should be rejected at ValidateBasic")
|
||||
}
|
||||
}
|
||||
|
||||
// --- CreateChapter (REQ-053, REQ-061) ----------------------------------------
|
||||
|
||||
// TestCreateChapterSuccess (case c) asserts a successful Chapter creation
|
||||
// with secession terms hash-pinned + good-standing liens
|
||||
// (SecuredAtFounding=true).
|
||||
func TestCreateChapterSuccess(t *testing.T) {
|
||||
ctx, _, _, _, k := newSimtestContext(t)
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
createParentGuild(t, srv, ctx, "g-parent")
|
||||
|
||||
resp, err := srv.CreateChapter(ctx, &types.MsgCreateChapter{
|
||||
GuildID: "g-chapter",
|
||||
Name: "Chapter",
|
||||
ParentGuildID: "g-parent",
|
||||
FounderReach: "reach:founder",
|
||||
SecessionTerms: types.SecessionTerms{
|
||||
CoolingCoverActiveDays: types.CoolingSecessionCoverActiveDays,
|
||||
CoolingNonCoverDays: types.CoolingSecessionNonCoverDays,
|
||||
LienAuditRequired: true,
|
||||
CovenantClearanceRequired: true,
|
||||
},
|
||||
GoodStandingLiens: []types.Lien{
|
||||
{Amount: 1000, CreditorReachID: "reach:cred", SecuredAtFounding: true, CoverPoolCovenantRef: "covenant-1"},
|
||||
},
|
||||
Signer: "reach:founder",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChapter: %v", err)
|
||||
}
|
||||
c, ok := k.GetGuild(ctx, "g-chapter")
|
||||
if !ok {
|
||||
t.Fatal("Chapter not persisted")
|
||||
}
|
||||
if !c.IsChapter {
|
||||
t.Error("IsChapter should be true for a Chapter")
|
||||
}
|
||||
if c.ParentGuildID != "g-parent" {
|
||||
t.Errorf("ParentGuildID = %q, want g-parent", c.ParentGuildID)
|
||||
}
|
||||
// SecessionTermsHash is pinned (non-empty).
|
||||
if len(c.SecessionTermsHash) == 0 {
|
||||
t.Error("SecessionTermsHash should be pinned (non-empty)")
|
||||
}
|
||||
// The pinned hash matches HashSecessionTerms.
|
||||
expected := types.HashSecessionTerms(types.SecessionTerms{
|
||||
CoolingCoverActiveDays: types.CoolingSecessionCoverActiveDays,
|
||||
CoolingNonCoverDays: types.CoolingSecessionNonCoverDays,
|
||||
LienAuditRequired: true,
|
||||
CovenantClearanceRequired: true,
|
||||
})
|
||||
if string(c.SecessionTermsHash) != string(expected) {
|
||||
t.Errorf("SecessionTermsHash mismatch: got %x, want %x", c.SecessionTermsHash, expected)
|
||||
}
|
||||
// Good-standing liens recorded with SecuredAtFounding=true.
|
||||
if len(c.GoodStandingLiens) != 1 || !c.GoodStandingLiens[0].SecuredAtFounding {
|
||||
t.Errorf("GoodStandingLiens = %v", c.GoodStandingLiens)
|
||||
}
|
||||
if c.GoodStandingLiens[0].CoverPoolCovenantRef != "covenant-1" {
|
||||
t.Errorf("CoverPoolCovenantRef = %q", c.GoodStandingLiens[0].CoverPoolCovenantRef)
|
||||
}
|
||||
// Chapter inherits Parent's Common Bond hash + Public Profile.
|
||||
parent, _ := k.GetGuild(ctx, "g-parent")
|
||||
if string(c.CommonBondHash) != string(parent.CommonBondHash) {
|
||||
t.Errorf("Chapter CommonBondHash = %x, want parent's %x", c.CommonBondHash, parent.CommonBondHash)
|
||||
}
|
||||
if !hasEvent(ctx, "guild.chapter_created") {
|
||||
t.Error("guild.chapter_created event not emitted")
|
||||
}
|
||||
// (g) Disclaimer surfaced.
|
||||
if resp.Disclaimer == "" {
|
||||
t.Error("CreateChapter response Disclaimer is empty (REQ-061)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateChapterTightenCoolingAllowed (case d) asserts a Chapter MAY
|
||||
// tighten the cooling (longer than the protocol minimum is allowed).
|
||||
func TestCreateChapterTightenCoolingAllowed(t *testing.T) {
|
||||
ctx, _, _, _, k := newSimtestContext(t)
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
createParentGuild(t, srv, ctx, "g-p-tight")
|
||||
|
||||
_, err := srv.CreateChapter(ctx, &types.MsgCreateChapter{
|
||||
GuildID: "g-c-tight",
|
||||
Name: "Tight Chapter",
|
||||
ParentGuildID: "g-p-tight",
|
||||
FounderReach: "reach:f",
|
||||
SecessionTerms: types.SecessionTerms{
|
||||
CoolingCoverActiveDays: types.CoolingSecessionCoverActiveDays + 10, // tighter (longer)
|
||||
CoolingNonCoverDays: types.CoolingSecessionNonCoverDays + 5, // tighter (longer)
|
||||
},
|
||||
GoodStandingLiens: []types.Lien{
|
||||
{Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: true},
|
||||
},
|
||||
Signer: "reach:f",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChapter with tighter cooling should succeed: %v", err)
|
||||
}
|
||||
if _, ok := k.GetGuild(ctx, "g-c-tight"); !ok {
|
||||
t.Error("tighter Chapter not persisted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateChapterLoosenCoolingRejected (case d) asserts a Chapter MAY NOT
|
||||
// loosen the cooling (shorter than the protocol minimum is REJECTED at
|
||||
// ValidateBasic).
|
||||
func TestCreateChapterLoosenCoolingRejected(t *testing.T) {
|
||||
ctx, _, _, _, k := newSimtestContext(t)
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
createParentGuild(t, srv, ctx, "g-p-loose")
|
||||
|
||||
_, err := srv.CreateChapter(ctx, &types.MsgCreateChapter{
|
||||
GuildID: "g-c-loose",
|
||||
Name: "Loose Chapter",
|
||||
ParentGuildID: "g-p-loose",
|
||||
FounderReach: "reach:f",
|
||||
SecessionTerms: types.SecessionTerms{
|
||||
CoolingCoverActiveDays: types.CoolingSecessionCoverActiveDays - 1, // looser (shorter) — REJECT
|
||||
CoolingNonCoverDays: types.CoolingSecessionNonCoverDays,
|
||||
},
|
||||
GoodStandingLiens: []types.Lien{
|
||||
{Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: true},
|
||||
},
|
||||
Signer: "reach:f",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("CreateChapter with looser cooling (shorter) should be rejected (Chapter may tighten but not loosen — REQ-053/REQ-064)")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "minimum") {
|
||||
t.Errorf("error = %q, want 'minimum'", err.Error())
|
||||
}
|
||||
// The Chapter was NOT persisted.
|
||||
if _, ok := k.GetGuild(ctx, "g-c-loose"); ok {
|
||||
t.Error("loose Chapter should NOT be persisted on reject")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateChapterNonExistentParent asserts a CreateChapter with a non-
|
||||
// existent Parent Guild is REJECTED.
|
||||
func TestCreateChapterNonExistentParent(t *testing.T) {
|
||||
ctx, _, _, _, k := newSimtestContext(t)
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
|
||||
_, err := srv.CreateChapter(ctx, &types.MsgCreateChapter{
|
||||
GuildID: "g-c-noparent",
|
||||
Name: "n",
|
||||
ParentGuildID: "no-such-parent",
|
||||
FounderReach: "reach:f",
|
||||
SecessionTerms: validTerms(),
|
||||
GoodStandingLiens: []types.Lien{
|
||||
{Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: true},
|
||||
},
|
||||
Signer: "reach:f",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("CreateChapter with non-existent parent should be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not found") {
|
||||
t.Errorf("error = %q, want 'not found'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateChapterParentIsChapter asserts a CreateChapter whose Parent is
|
||||
// itself a Chapter is REJECTED (a Chapter cannot have a Chapter parent).
|
||||
func TestCreateChapterParentIsChapter(t *testing.T) {
|
||||
ctx, _, _, _, k := newSimtestContext(t)
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
createParentGuild(t, srv, ctx, "g-real-parent")
|
||||
// Create a first Chapter.
|
||||
_, err := srv.CreateChapter(ctx, &types.MsgCreateChapter{
|
||||
GuildID: "g-chapter-1",
|
||||
Name: "Chapter1",
|
||||
ParentGuildID: "g-real-parent",
|
||||
FounderReach: "reach:f",
|
||||
SecessionTerms: validTerms(),
|
||||
GoodStandingLiens: []types.Lien{
|
||||
{Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: true},
|
||||
},
|
||||
Signer: "reach:f",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("first CreateChapter: %v", err)
|
||||
}
|
||||
// Attempt to create a second Chapter under the first Chapter (a Chapter
|
||||
// parent) — REJECTED.
|
||||
_, err = srv.CreateChapter(ctx, &types.MsgCreateChapter{
|
||||
GuildID: "g-chapter-2",
|
||||
Name: "Chapter2",
|
||||
ParentGuildID: "g-chapter-1",
|
||||
FounderReach: "reach:f",
|
||||
SecessionTerms: validTerms(),
|
||||
GoodStandingLiens: []types.Lien{
|
||||
{Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: true},
|
||||
},
|
||||
Signer: "reach:f",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("CreateChapter with a Chapter parent should be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Chapter") {
|
||||
t.Errorf("error = %q, want 'Chapter'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateChapterIdempotentReject asserts a second CreateChapter on the
|
||||
// same chapter guild-id is REJECTED.
|
||||
func TestCreateChapterIdempotentReject(t *testing.T) {
|
||||
ctx, _, _, _, k := newSimtestContext(t)
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
createParentGuild(t, srv, ctx, "g-p-dup")
|
||||
|
||||
first := &types.MsgCreateChapter{
|
||||
GuildID: "g-c-dup",
|
||||
Name: "n",
|
||||
ParentGuildID: "g-p-dup",
|
||||
FounderReach: "reach:f",
|
||||
SecessionTerms: validTerms(),
|
||||
GoodStandingLiens: []types.Lien{
|
||||
{Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: true},
|
||||
},
|
||||
Signer: "reach:f",
|
||||
}
|
||||
if _, err := srv.CreateChapter(ctx, first); err != nil {
|
||||
t.Fatalf("first CreateChapter: %v", err)
|
||||
}
|
||||
_, err := srv.CreateChapter(ctx, first)
|
||||
if err == nil {
|
||||
t.Error("second CreateChapter on same guild-id should be rejected (idempotent)")
|
||||
}
|
||||
}
|
||||
|
||||
// --- OneTapExitStand (REQ-057) -----------------------------------------------
|
||||
|
||||
// TestOneTapExitStandHouseholdSuccess (case e) asserts a Household one-tap
|
||||
// exit succeeds (Stand type Household + StashKeeper stub records the call).
|
||||
func TestOneTapExitStandHouseholdSuccess(t *testing.T) {
|
||||
ctx, sk, stashK, _, k := newSimtestContext(t)
|
||||
sk.stands = map[string]string{"stand-hh": "Household"}
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
|
||||
_, err := srv.OneTapExitStand(ctx, &types.MsgOneTapExitStand{
|
||||
StandID: "stand-hh",
|
||||
Signer: "reach:holder",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("OneTapExitStand: %v", err)
|
||||
}
|
||||
if !hasEvent(ctx, "guild.one_tap_exit") {
|
||||
t.Error("guild.one_tap_exit event not emitted")
|
||||
}
|
||||
// StashKeeper recorded the asset return.
|
||||
if len(stashK.calls) != 1 {
|
||||
t.Fatalf("StashKeeper calls = %d, want 1", len(stashK.calls))
|
||||
}
|
||||
if stashK.calls[0].holderReachID != "reach:holder" || stashK.calls[0].standID != "stand-hh" {
|
||||
t.Errorf("StashKeeper call = %+v", stashK.calls[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestOneTapExitStandCrewRejected (case e) asserts a Crew Stand one-tap exit
|
||||
// is REJECTED (one-tap is Household-only).
|
||||
func TestOneTapExitStandCrewRejected(t *testing.T) {
|
||||
ctx, sk, _, _, k := newSimtestContext(t)
|
||||
sk.stands = map[string]string{"stand-crew": "Crew"}
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
|
||||
_, err := srv.OneTapExitStand(ctx, &types.MsgOneTapExitStand{
|
||||
StandID: "stand-crew",
|
||||
Signer: "reach:holder",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("OneTapExitStand on a Crew Stand should be rejected (one-tap is Household-only — REQ-057)")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Household") {
|
||||
t.Errorf("error = %q, want 'Household'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestOneTapExitStandNonExistent asserts a one-tap exit on a non-existent
|
||||
// Stand is REJECTED.
|
||||
func TestOneTapExitStandNonExistent(t *testing.T) {
|
||||
ctx, sk, _, _, k := newSimtestContext(t)
|
||||
sk.stands = map[string]string{}
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
|
||||
_, err := srv.OneTapExitStand(ctx, &types.MsgOneTapExitStand{
|
||||
StandID: "no-such-stand",
|
||||
Signer: "reach:holder",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("OneTapExitStand on non-existent Stand should be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not found") {
|
||||
t.Errorf("error = %q, want 'not found'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestOneTapExitStandNilStandKeeperReject asserts a nil StandKeeper REJECTS
|
||||
// the one-tap exit (the type check is load-bearing).
|
||||
func TestOneTapExitStandNilStandKeeperReject(t *testing.T) {
|
||||
ctx, _, k := newSimtestContextNilStand(t)
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
|
||||
_, err := srv.OneTapExitStand(ctx, &types.MsgOneTapExitStand{
|
||||
StandID: "any-stand",
|
||||
Signer: "reach:holder",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("OneTapExitStand with nil StandKeeper should be rejected (type check is load-bearing)")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "StandKeeper") {
|
||||
t.Errorf("error = %q, want 'StandKeeper'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestOneTapExitStandNilStashKeeperSkip asserts a nil StashKeeper skips the
|
||||
// asset return (the dissolution event is still emitted).
|
||||
func TestOneTapExitStandNilStashKeeperSkip(t *testing.T) {
|
||||
ctx, sk, _, _, k := newSimtestContext(t)
|
||||
sk.stands = map[string]string{"stand-hh2": "Household"}
|
||||
// Wire a nil StashKeeper via the setter (the keeper was constructed with
|
||||
// a non-nil stub; override to nil for this case).
|
||||
k.SetStashKeeper(nil)
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
|
||||
_, err := srv.OneTapExitStand(ctx, &types.MsgOneTapExitStand{
|
||||
StandID: "stand-hh2",
|
||||
Signer: "reach:holder",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("OneTapExitStand with nil StashKeeper should skip asset return: %v", err)
|
||||
}
|
||||
if !hasEvent(ctx, "guild.one_tap_exit") {
|
||||
t.Error("guild.one_tap_exit event should still be emitted with nil StashKeeper")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOneTapExitStandStashErrorReject asserts a StashKeeper error REJECTS
|
||||
// the one-tap exit (the asset return is load-bearing).
|
||||
func TestOneTapExitStandStashErrorReject(t *testing.T) {
|
||||
ctx, sk, stashK, _, k := newSimtestContext(t)
|
||||
sk.stands = map[string]string{"stand-hh-err": "Household"}
|
||||
stashK.err = sentinelErr("stash return failed (simtest)")
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
|
||||
_, err := srv.OneTapExitStand(ctx, &types.MsgOneTapExitStand{
|
||||
StandID: "stand-hh-err",
|
||||
Signer: "reach:holder",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("OneTapExitStand with StashKeeper error should be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "return assets") {
|
||||
t.Errorf("error = %q, want 'return assets'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// --- DelegateConfederationVoice (REQ-058) ------------------------------------
|
||||
|
||||
// TestDelegateConfederationVoiceSuccess (case f) asserts a Confederation
|
||||
// Voice delegation succeeds (one-per-Stand).
|
||||
func TestDelegateConfederationVoiceSuccess(t *testing.T) {
|
||||
ctx, sk, _, _, k := newSimtestContext(t)
|
||||
sk.stands = map[string]string{"conf-1": "Confederation"}
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
|
||||
_, err := srv.DelegateConfederationVoice(ctx, &types.MsgDelegateConfederationVoice{
|
||||
ConfederationStandID: "conf-1",
|
||||
MemberStandID: "mem-1",
|
||||
DelegateReachID: "reach:delegate",
|
||||
Signer: "reach:s",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("DelegateConfederationVoice: %v", err)
|
||||
}
|
||||
v, ok := k.GetDelegation(ctx, "conf-1", "mem-1")
|
||||
if !ok {
|
||||
t.Fatal("delegation not persisted")
|
||||
}
|
||||
if v.DelegateReachID != "reach:delegate" {
|
||||
t.Errorf("DelegateReachID = %q, want reach:delegate", v.DelegateReachID)
|
||||
}
|
||||
if !hasEvent(ctx, "guild.confederation_voice_delegated") {
|
||||
t.Error("guild.confederation_voice_delegated event not emitted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDelegateConfederationVoiceDuplicateRejected (case f) asserts a
|
||||
// duplicate delegation from the same MemberStandID is REJECTED (one-Stand-
|
||||
// one-Vote).
|
||||
func TestDelegateConfederationVoiceDuplicateRejected(t *testing.T) {
|
||||
ctx, sk, _, _, k := newSimtestContext(t)
|
||||
sk.stands = map[string]string{"conf-dup": "Confederation"}
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
|
||||
first := &types.MsgDelegateConfederationVoice{
|
||||
ConfederationStandID: "conf-dup",
|
||||
MemberStandID: "mem-dup",
|
||||
DelegateReachID: "reach:d1",
|
||||
Signer: "reach:s",
|
||||
}
|
||||
if _, err := srv.DelegateConfederationVoice(ctx, first); err != nil {
|
||||
t.Fatalf("first delegation: %v", err)
|
||||
}
|
||||
// A second delegation from the same MemberStandID (even to a different
|
||||
// delegate) is REJECTED.
|
||||
_, err := srv.DelegateConfederationVoice(ctx, &types.MsgDelegateConfederationVoice{
|
||||
ConfederationStandID: "conf-dup",
|
||||
MemberStandID: "mem-dup",
|
||||
DelegateReachID: "reach:d2",
|
||||
Signer: "reach:s",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("duplicate delegation from the same MemberStandID should be rejected (one-Stand-one-Vote — REQ-058)")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "duplicate") {
|
||||
t.Errorf("error = %q, want 'duplicate'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDelegateConfederationVoiceNonConfederationRejected asserts a
|
||||
// delegation where the named Confederation Stand is NOT a Confederation type
|
||||
// is REJECTED.
|
||||
func TestDelegateConfederationVoiceNonConfederationRejected(t *testing.T) {
|
||||
ctx, sk, _, _, k := newSimtestContext(t)
|
||||
sk.stands = map[string]string{"not-conf": "Crew"}
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
|
||||
_, err := srv.DelegateConfederationVoice(ctx, &types.MsgDelegateConfederationVoice{
|
||||
ConfederationStandID: "not-conf",
|
||||
MemberStandID: "mem-1",
|
||||
DelegateReachID: "reach:d",
|
||||
Signer: "reach:s",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("DelegateConfederationVoice on a non-Confederation Stand should be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Confederation") {
|
||||
t.Errorf("error = %q, want 'Confederation'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDelegateConfederationVoiceNonExistent asserts a delegation on a non-
|
||||
// existent Stand is REJECTED.
|
||||
func TestDelegateConfederationVoiceNonExistent(t *testing.T) {
|
||||
ctx, sk, _, _, k := newSimtestContext(t)
|
||||
sk.stands = map[string]string{}
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
|
||||
_, err := srv.DelegateConfederationVoice(ctx, &types.MsgDelegateConfederationVoice{
|
||||
ConfederationStandID: "no-such-conf",
|
||||
MemberStandID: "mem-1",
|
||||
DelegateReachID: "reach:d",
|
||||
Signer: "reach:s",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("DelegateConfederationVoice on non-existent Stand should be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not found") {
|
||||
t.Errorf("error = %q, want 'not found'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDelegateConfederationVoiceNilStandKeeperReject asserts a nil
|
||||
// StandKeeper REJECTS the delegation (the type check is load-bearing).
|
||||
func TestDelegateConfederationVoiceNilStandKeeperReject(t *testing.T) {
|
||||
ctx, _, k := newSimtestContextNilStand(t)
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
|
||||
_, err := srv.DelegateConfederationVoice(ctx, &types.MsgDelegateConfederationVoice{
|
||||
ConfederationStandID: "any",
|
||||
MemberStandID: "mem",
|
||||
DelegateReachID: "reach:d",
|
||||
Signer: "reach:s",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("DelegateConfederationVoice with nil StandKeeper should be rejected (type check is load-bearing)")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "StandKeeper") {
|
||||
t.Errorf("error = %q, want 'StandKeeper'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// --- AddLien (REQ-053) -------------------------------------------------------
|
||||
|
||||
// TestAddLienPostFoundingSuccess (case h) asserts a post-founding lien with
|
||||
// SecuredAtFounding=false succeeds.
|
||||
func TestAddLienPostFoundingSuccess(t *testing.T) {
|
||||
ctx, _, _, _, k := newSimtestContext(t)
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
createParentGuild(t, srv, ctx, "g-lien")
|
||||
|
||||
_, err := srv.AddLien(ctx, &types.MsgAddLien{
|
||||
GuildID: "g-lien",
|
||||
Lien: types.Lien{
|
||||
Amount: 500,
|
||||
CreditorReachID: "reach:cred",
|
||||
SecuredAtFounding: false,
|
||||
CoverPoolCovenantRef: "covenant-2",
|
||||
},
|
||||
Signer: "reach:s",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddLien: %v", err)
|
||||
}
|
||||
// The lien is persisted at idx 0.
|
||||
l, ok := k.GetLien(ctx, "g-lien", 0)
|
||||
if !ok {
|
||||
t.Fatal("lien not persisted")
|
||||
}
|
||||
if l.Amount != 500 || l.SecuredAtFounding {
|
||||
t.Errorf("lien = %+v", l)
|
||||
}
|
||||
if !hasEvent(ctx, "guild.lien_added") {
|
||||
t.Error("guild.lien_added event not emitted")
|
||||
}
|
||||
if got := k.AllLiens(ctx, "g-lien"); len(got) != 1 {
|
||||
t.Errorf("AllLiens = %d, want 1", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddLienSecuredAtFoundingRejected (case h) asserts a post-founding lien
|
||||
// with SecuredAtFounding=true is REJECTED (founding is a one-time event —
|
||||
// REQ-053/REQ-081).
|
||||
func TestAddLienSecuredAtFoundingRejected(t *testing.T) {
|
||||
ctx, _, _, _, k := newSimtestContext(t)
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
createParentGuild(t, srv, ctx, "g-lien-reject")
|
||||
|
||||
_, err := srv.AddLien(ctx, &types.MsgAddLien{
|
||||
GuildID: "g-lien-reject",
|
||||
Lien: types.Lien{
|
||||
Amount: 500,
|
||||
CreditorReachID: "reach:cred",
|
||||
SecuredAtFounding: true, // REJECTED — founding is one-time
|
||||
},
|
||||
Signer: "reach:s",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("AddLien with SecuredAtFounding=true post-founding should be rejected (founding is one-time — REQ-053/REQ-081)")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "SecuredAtFounding") {
|
||||
t.Errorf("error = %q, want 'SecuredAtFounding'", err.Error())
|
||||
}
|
||||
// The lien was NOT persisted.
|
||||
if got := k.AllLiens(ctx, "g-lien-reject"); len(got) != 0 {
|
||||
t.Errorf("AllLiens = %d, want 0 (rejected lien not persisted)", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddLienNonExistentGuild asserts an AddLien on a non-existent Guild is
|
||||
// REJECTED.
|
||||
func TestAddLienNonExistentGuild(t *testing.T) {
|
||||
ctx, _, _, _, k := newSimtestContext(t)
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
|
||||
_, err := srv.AddLien(ctx, &types.MsgAddLien{
|
||||
GuildID: "no-such-guild",
|
||||
Lien: types.Lien{
|
||||
Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: false,
|
||||
},
|
||||
Signer: "reach:s",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("AddLien on non-existent Guild should be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not found") {
|
||||
t.Errorf("error = %q, want 'not found'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// --- unwrapCtx panic --------------------------------------------------------
|
||||
|
||||
// TestUnwrapCtxPanic asserts unwrapCtx panics on a non-sdk.Context value.
|
||||
func TestUnwrapCtxPanic(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Error("unwrapCtx on non-sdk.Context should panic")
|
||||
}
|
||||
}()
|
||||
_, _ = keeper.NewMsgServerImpl(keeper.Keeper{}).AddLien("not-a-ctx",
|
||||
&types.MsgAddLien{GuildID: "g", Lien: types.Lien{Amount: 1, CreditorReachID: "c"}, Signer: "s"})
|
||||
}
|
||||
|
||||
// --- Keeper accessors (coverage) --------------------------------------------
|
||||
|
||||
// TestKeeperAccessors exercises the exported Keeper accessors that the
|
||||
// simtest above does not directly hit (AllGuilds, GetLien on empty,
|
||||
// AllDelegations, the marshal-error paths, the setters) to push coverage
|
||||
// >=80%.
|
||||
func TestKeeperAccessors(t *testing.T) {
|
||||
ctx, sk, _, storeKey, k := newSimtestContext(t)
|
||||
_ = sk
|
||||
|
||||
// Empty-store accessors return empty (not nil) slices.
|
||||
if got := k.AllGuilds(ctx); len(got) != 0 {
|
||||
t.Errorf("AllGuilds empty = %d, want 0", len(got))
|
||||
}
|
||||
if got := k.AllLiens(ctx, "nobody"); len(got) != 0 {
|
||||
t.Errorf("AllLiens empty = %d, want 0", len(got))
|
||||
}
|
||||
if got := k.AllDelegations(ctx, "nobody"); len(got) != 0 {
|
||||
t.Errorf("AllDelegations empty = %d, want 0", len(got))
|
||||
}
|
||||
if _, ok := k.GetLien(ctx, "nobody", 0); ok {
|
||||
t.Error("GetLien on empty store should return false")
|
||||
}
|
||||
if _, ok := k.GetDelegation(ctx, "nobody", "nobody"); ok {
|
||||
t.Error("GetDelegation on empty store should return false")
|
||||
}
|
||||
|
||||
// Populate + read back.
|
||||
k.SetGuild(ctx, types.Guild{GuildID: "g-a", Name: "n", FounderReach: "reach:f"})
|
||||
if g, ok := k.GetGuild(ctx, "g-a"); !ok || g.Name != "n" {
|
||||
t.Errorf("GetGuild = %+v ok=%v", g, ok)
|
||||
}
|
||||
if got := k.AllGuilds(ctx); len(got) != 1 {
|
||||
t.Errorf("AllGuilds = %d, want 1", len(got))
|
||||
}
|
||||
|
||||
k.SetLien(ctx, "g-a", 0, types.Lien{Amount: 1, CreditorReachID: "reach:c"})
|
||||
if l, ok := k.GetLien(ctx, "g-a", 0); !ok || l.Amount != 1 {
|
||||
t.Errorf("GetLien = %+v ok=%v", l, ok)
|
||||
}
|
||||
if got := k.AllLiens(ctx, "g-a"); len(got) != 1 {
|
||||
t.Errorf("AllLiens = %d, want 1", len(got))
|
||||
}
|
||||
if idx := k.NextLienIdx(ctx, "g-a"); idx != 1 {
|
||||
t.Errorf("NextLienIdx = %d, want 1", idx)
|
||||
}
|
||||
|
||||
k.SetDelegation(ctx, types.ConfederationVoice{
|
||||
ConfederationStandID: "conf-a", MemberStandID: "mem-a",
|
||||
DelegateReachID: "reach:d", DelegatedAt: 1,
|
||||
})
|
||||
if v, ok := k.GetDelegation(ctx, "conf-a", "mem-a"); !ok || v.DelegateReachID != "reach:d" {
|
||||
t.Errorf("GetDelegation = %+v ok=%v", v, ok)
|
||||
}
|
||||
if got := k.AllDelegations(ctx, "conf-a"); len(got) != 1 {
|
||||
t.Errorf("AllDelegations = %d, want 1", len(got))
|
||||
}
|
||||
|
||||
// Marshal-error paths (corrupt bytes in store).
|
||||
store := ctx.KVStore(storeKey)
|
||||
store.Set([]byte("guild/corrupt"), []byte("not-json"))
|
||||
if _, ok := k.GetGuild(ctx, "corrupt"); ok {
|
||||
t.Error("GetGuild on corrupt bytes should return false")
|
||||
}
|
||||
store.Set([]byte("lien/corrupt/0"), []byte("not-json"))
|
||||
if _, ok := k.GetLien(ctx, "corrupt", 0); ok {
|
||||
t.Error("GetLien on corrupt bytes should return false")
|
||||
}
|
||||
store.Set([]byte("delegation/corrupt/m"), []byte("not-json"))
|
||||
if _, ok := k.GetDelegation(ctx, "corrupt", "m"); ok {
|
||||
t.Error("GetDelegation on corrupt bytes should return false")
|
||||
}
|
||||
|
||||
// Post-construction setters (coverage).
|
||||
k.SetStandKeeper(&stubStandKeeper{stands: map[string]string{"s": "Household"}})
|
||||
k.SetStashKeeper(&stubStashKeeper{})
|
||||
k.SetParams(types.DefaultParams())
|
||||
if k.Params().DefaultCoolingCoverActiveDays != types.CoolingSecessionCoverActiveDays {
|
||||
t.Errorf("Params DefaultCoolingCoverActiveDays = %d", k.Params().DefaultCoolingCoverActiveDays)
|
||||
}
|
||||
}
|
||||
|
||||
// --- sentinel error helper ---------------------------------------------------
|
||||
|
||||
type sentinelErr string
|
||||
|
||||
func (e sentinelErr) Error() string { return string(e) }
|
||||
@@ -0,0 +1,89 @@
|
||||
package guild
|
||||
|
||||
// module.go holds the guild module's AppModule + RegisterServices (P3,
|
||||
// REQ-051, REQ-053, REQ-057, REQ-058).
|
||||
//
|
||||
// The AppModule wraps the guild Keeper and registers the MsgServer via
|
||||
// RegisterServices. This is the simtest-grade AppModule (D-054): the
|
||||
// RegisterServices wires the hand-rolled MsgServer (no protobuf codegen
|
||||
// per the skeleton's zero-codegen style). The MsgServer is constructed
|
||||
// directly and exposed via the module for test wiring.
|
||||
//
|
||||
// The StandKeeper + StashKeeper expected-keeper shims are injected at
|
||||
// construction (StandKeeper nil-able — the OneTapExitStand +
|
||||
// DelegateConfederationVoice handlers REJECT on a nil StandKeeper; the type
|
||||
// check is load-bearing. StashKeeper nil-able — a nil StashKeeper skips the
|
||||
// asset return on one-tap exit; the dissolution event is still emitted).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
storetypes "cosmossdk.io/store/types"
|
||||
"github.com/cosmos/cosmos-sdk/codec"
|
||||
sdk "github.com/cosmos/cosmos-sdk/types"
|
||||
"github.com/cosmos/cosmos-sdk/types/module"
|
||||
|
||||
"github.com/oy/openyield/x/guild/keeper"
|
||||
"github.com/oy/openyield/x/guild/types"
|
||||
)
|
||||
|
||||
// ConsensusVersion is the guild module's consensus version (AppModule).
|
||||
const ConsensusVersion = 1
|
||||
|
||||
// AppModule is the guild application module (simtest-grade — D-054).
|
||||
type AppModule struct {
|
||||
keeper keeper.Keeper
|
||||
}
|
||||
|
||||
// NewAppModule constructs a new guild AppModule. The StandKeeper + StashKeeper
|
||||
// expected-keeper shims are injected (StandKeeper nil-able — the
|
||||
// OneTapExitStand + DelegateConfederationVoice handlers REJECT on a nil
|
||||
// StandKeeper; StashKeeper nil-able — a nil StashKeeper skips the asset
|
||||
// return on one-tap exit).
|
||||
func NewAppModule(cdc codec.Codec, storeKey storetypes.StoreKey, sk types.StandKeeper, stashK types.StashKeeper) AppModule {
|
||||
k := keeper.NewKeeper(cdc, storeKey, sk, stashK)
|
||||
return AppModule{keeper: k}
|
||||
}
|
||||
|
||||
// RegisterServices registers the guild MsgServer. Simtest-grade wiring: the
|
||||
// MsgServer is constructed from the keeper and exposed via the module's
|
||||
// MsgServer method (tests use NewMsgServerImpl directly).
|
||||
func (am AppModule) RegisterServices(cfg module.Configurator) {
|
||||
_ = cfg
|
||||
}
|
||||
|
||||
// MsgServer returns the guild MsgServer for this module's keeper.
|
||||
func (am AppModule) MsgServer() types.MsgServer {
|
||||
return keeper.NewMsgServerImpl(am.keeper)
|
||||
}
|
||||
|
||||
// Name returns the module name.
|
||||
func (AppModule) Name() string { return types.ModuleName }
|
||||
|
||||
// ConsensusVersion implements AppModule.ConsensusVersion.
|
||||
func (AppModule) ConsensusVersion() uint64 { return ConsensusVersion }
|
||||
|
||||
// InitGenesis performs genesis initialization for the guild module (simtest-
|
||||
// grade no-op — the runtime stores are created at handler time; genesis
|
||||
// init of runtime-promoted stores is deferred to the live chain v0.6+).
|
||||
func (am AppModule) InitGenesis(ctx sdk.Context, cdc codec.JSONCodec, data json.RawMessage) {
|
||||
var gs types.GenesisState
|
||||
cdc.MustUnmarshalJSON(data, &gs)
|
||||
for _, g := range gs.Guilds {
|
||||
am.keeper.SetGuild(ctx, g)
|
||||
}
|
||||
for _, c := range gs.Chapters {
|
||||
am.keeper.SetGuild(ctx, c)
|
||||
}
|
||||
}
|
||||
|
||||
// ExportGenesis returns the exported genesis state as raw bytes (simtest-
|
||||
// grade: returns an empty genesis; live chain export deferred to v0.6+).
|
||||
func (am AppModule) ExportGenesis(ctx sdk.Context, cdc codec.JSONCodec) json.RawMessage {
|
||||
gs := types.DefaultGenesisState()
|
||||
return cdc.MustMarshalJSON(gs)
|
||||
}
|
||||
|
||||
// Compile-time assertions: AppModule implements the module interface stubs.
|
||||
var _ module.HasName = AppModule{}
|
||||
var _ module.HasConsensusVersion = AppModule{}
|
||||
@@ -0,0 +1,104 @@
|
||||
package guild_test
|
||||
|
||||
// module_test.go exercises the x/guild AppModule (D-054 simtest-grade).
|
||||
// The AppModule wraps the Keeper + exposes the MsgServer; this test
|
||||
// constructs an AppModule with nil shims + asserts Name, ConsensusVersion,
|
||||
// MsgServer, InitGenesis, ExportGenesis. Coverage target: the module.go
|
||||
// surface.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"cosmossdk.io/log"
|
||||
"cosmossdk.io/store"
|
||||
storetypes "cosmossdk.io/store/types"
|
||||
cmtproto "github.com/cometbft/cometbft/proto/tendermint/types"
|
||||
dbm "github.com/cosmos/cosmos-db"
|
||||
"github.com/cosmos/cosmos-sdk/codec"
|
||||
codectypes "github.com/cosmos/cosmos-sdk/codec/types"
|
||||
sdk "github.com/cosmos/cosmos-sdk/types"
|
||||
|
||||
"github.com/oy/openyield/x/guild"
|
||||
"github.com/oy/openyield/x/guild/types"
|
||||
)
|
||||
|
||||
func newModuleTestContext(t *testing.T) (sdk.Context, guild.AppModule, codec.Codec) {
|
||||
t.Helper()
|
||||
db := dbm.NewMemDB()
|
||||
cdc := newModuleTestCodec()
|
||||
storeKey := storetypes.NewKVStoreKey(types.StoreKey)
|
||||
cms := store.NewCommitMultiStore(db, log.NewNopLogger(), nil)
|
||||
cms.MountStoreWithDB(storeKey, storetypes.StoreTypeDB, nil)
|
||||
if err := cms.LoadLatestVersion(); err != nil {
|
||||
t.Fatalf("load latest version: %v", err)
|
||||
}
|
||||
ctx := sdk.NewContext(cms, cmtproto.Header{}, false, log.NewNopLogger())
|
||||
am := guild.NewAppModule(cdc, storeKey, nil, nil)
|
||||
return ctx, am, cdc
|
||||
}
|
||||
|
||||
func newModuleTestCodec() codec.Codec {
|
||||
registry := codectypes.NewInterfaceRegistry()
|
||||
return codec.NewProtoCodec(registry)
|
||||
}
|
||||
|
||||
// TestAppModuleName asserts the module name.
|
||||
func TestAppModuleName(t *testing.T) {
|
||||
_, am, _ := newModuleTestContext(t)
|
||||
if am.Name() != types.ModuleName {
|
||||
t.Errorf("Name = %q, want %q", am.Name(), types.ModuleName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppModuleConsensusVersion asserts ConsensusVersion == 1.
|
||||
func TestAppModuleConsensusVersion(t *testing.T) {
|
||||
_, am, _ := newModuleTestContext(t)
|
||||
if am.ConsensusVersion() != guild.ConsensusVersion {
|
||||
t.Errorf("ConsensusVersion = %d, want %d", am.ConsensusVersion(), guild.ConsensusVersion)
|
||||
}
|
||||
if guild.ConsensusVersion != 1 {
|
||||
t.Errorf("ConsensusVersion const = %d, want 1", guild.ConsensusVersion)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppModuleMsgServer asserts MsgServer returns a non-nil MsgServer.
|
||||
func TestAppModuleMsgServer(t *testing.T) {
|
||||
_, am, _ := newModuleTestContext(t)
|
||||
srv := am.MsgServer()
|
||||
if srv == nil {
|
||||
t.Fatal("MsgServer() returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppModuleInitExportGenesis asserts InitGenesis + ExportGenesis round-
|
||||
// trip an empty genesis.
|
||||
func TestAppModuleInitExportGenesis(t *testing.T) {
|
||||
ctx, am, cdc := newModuleTestContext(t)
|
||||
|
||||
empty := types.DefaultGenesisState()
|
||||
data := cdc.MustMarshalJSON(empty)
|
||||
am.InitGenesis(ctx, cdc, data)
|
||||
|
||||
exported := am.ExportGenesis(ctx, cdc)
|
||||
if len(exported) == 0 {
|
||||
t.Fatal("ExportGenesis returned empty bytes")
|
||||
}
|
||||
var gs types.GenesisState
|
||||
if err := json.Unmarshal(exported, &gs); err != nil {
|
||||
t.Fatalf("ExportGenesis bytes not valid JSON: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppModuleRegisterServicesNoPanic asserts RegisterServices does not
|
||||
// panic with a nil configurator (simtest-grade — the method is a no-op stub
|
||||
// for the hand-rolled MsgServer wiring).
|
||||
func TestAppModuleRegisterServicesNoPanic(t *testing.T) {
|
||||
_, am, _ := newModuleTestContext(t)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("RegisterServices panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
am.RegisterServices(nil)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package types
|
||||
|
||||
// expected_keepers.go holds the Go INTERFACES for the cross-module keepers
|
||||
// x/guild depends on (G-003 firewall — ibc-go expected-keepers convention).
|
||||
//
|
||||
// The guild runtime (REQ-051, REQ-053, REQ-057, REQ-058) depends on TWO
|
||||
// cross-module keepers:
|
||||
//
|
||||
// 1. x/stand (StandKeeper) — the OneTapExitStand handler asserts the named
|
||||
// Stand is a Household (REQ-057) before dissolving it; the
|
||||
// DelegateConfederationVoice handler asserts the named Stand is a
|
||||
// Confederation (REQ-058) before recording the delegation. The handler
|
||||
// queries GetStand for the Stand type (an opaque string — "Household" or
|
||||
// "Confederation") and compares. This is the v0.7 P3 household-edge: the
|
||||
// Guild module references a Stand by ID-string (G-003 — no struct import
|
||||
// of x/stand/types).
|
||||
//
|
||||
// 2. x/stash (StashKeeper) — the OneTapExitStand handler returns the
|
||||
// dissolved Household Stand's assets to the Holder's Stash (REQ-057).
|
||||
// The handler calls ReturnAssetsToHolder; the simtest stub records the
|
||||
// call for assertion (no actual asset transfer in simtest).
|
||||
//
|
||||
// Both dependencies are expressed as INTERFACES defined HERE (in
|
||||
// x/guild/types), NOT as struct imports of any x/<module>/types. The
|
||||
// concrete keepers (or simtest stubs) satisfy these interfaces structurally
|
||||
// (the P3 simtest wires stubs per G-003 test exemption); the handler depends
|
||||
// on the interface, preserving G-003's intent (no cross-module struct
|
||||
// coupling, no import cycles).
|
||||
//
|
||||
// Lexicon note (REQ-012): "Guild", "Chapter", "Stand", "Household",
|
||||
// "Confederation", "Stash", "Holder", "Reach", "Voice" are all lexicon-clean.
|
||||
// The project-wide 10 banned terms NEVER appear (enforced by lexicon_meta +
|
||||
// the per-package lexicon assertion in types_test.go).
|
||||
|
||||
// StandKeeper is the expected-keeper interface for x/stand (G-003). The
|
||||
// OneTapExitStand handler calls GetStand to assert the Stand type is
|
||||
// "Household" (REQ-057 — one-tap exit is Household-only). The
|
||||
// DelegateConfederationVoice handler calls GetStand to assert the Stand type
|
||||
// is "Confederation" (REQ-058). The standType string is the opaque Stand
|
||||
// type name (cross-doc to x/stand.StandType — "Household", "Confederation",
|
||||
// etc.); the handler compares the string.
|
||||
//
|
||||
// No struct import of x/stand/types — the interface is the by-ID-string
|
||||
// boundary (G-003). The standID is an opaque string. A nil StandKeeper
|
||||
// REJECTS the OneTapExitStand + DelegateConfederationVoice handlers (the
|
||||
// type check is load-bearing — a nil shim is a wiring error, NOT a simtest
|
||||
// skip path; the household/confederation type check cannot be skipped).
|
||||
type StandKeeper interface {
|
||||
// GetStand returns the Stand type string + exists flag for the named
|
||||
// Stand (by-ID-string). The OneTapExitStand handler compares the
|
||||
// returned type against "Household"; the
|
||||
// DelegateConfederationVoice handler compares against "Confederation".
|
||||
// A non-existent Stand returns ("", false) — the handler REJECTS.
|
||||
GetStand(standID string) (standType string, exists bool)
|
||||
}
|
||||
|
||||
// StashKeeper is the expected-keeper interface for x/stash (G-003). The
|
||||
// OneTapExitStand handler calls ReturnAssetsToHolder to return the dissolved
|
||||
// Household Stand's assets to the Holder's Stash (REQ-057). The simtest stub
|
||||
// records the call for assertion (no actual asset transfer in simtest — the
|
||||
// simtest documents the wiring contract).
|
||||
//
|
||||
// No struct import of x/stash/types — the interface is the by-ID-string
|
||||
// boundary (G-003). The holderReachID + standID are opaque strings. A nil
|
||||
// StashKeeper skips the asset return (simtest wiring — the handler still
|
||||
// emits the dissolution event; the asset return is a side-effect the simtest
|
||||
// stub records).
|
||||
type StashKeeper interface {
|
||||
// ReturnAssetsToHolder returns the named Stand's assets to the named
|
||||
// Holder's Stash. The OneTapExitStand handler calls this on a Household
|
||||
// dissolution (REQ-057). A non-nil error REJECTS the dissolution (the
|
||||
// asset return is load-bearing — a failed return leaves the Stand
|
||||
// intact).
|
||||
ReturnAssetsToHolder(holderReachID string, standID string) error
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
package types
|
||||
|
||||
// msg_guild.go holds the x/guild Msg* types implementing sdk.Msg (REQ-051,
|
||||
// REQ-053, REQ-057, REQ-058). G-006 controlled exception: types/ gains the
|
||||
// cosmos-sdk import for sdk.Msg (mirrors x/cover/types/msg_cover.go — D-055;
|
||||
// the invariant/lexicon tests in *_test.go stay stdlib-only per G-024,
|
||||
// isolated from this msg_*.go file).
|
||||
//
|
||||
// The five P3 Guild Msg types drive the Guild Charter + Chapter Federation +
|
||||
// Household + Confederation runtime:
|
||||
// - MsgCreateGuild: create a Guild with a Common Bond hash + Public Profile
|
||||
// (REQ-051). The handler persists the Guild + surfaces a jurisdictional
|
||||
// disclaimer (REQ-061).
|
||||
// - MsgCreateChapter: create a Chapter under a Parent Guild (REQ-053). The
|
||||
// handler pins the SecessionTerms hash + records the Good-Standing Liens
|
||||
// (SecuredAtFounding=true) + rejects cooling below the protocol minimum
|
||||
// + surfaces a jurisdictional disclaimer (REQ-061).
|
||||
// - MsgOneTapExitStand: one-tap exit a Household Stand (REQ-057). The
|
||||
// handler asserts the Stand type is Household via the StandKeeper shim +
|
||||
// dissolves the Stand + returns assets to the Holder's Stash.
|
||||
// - MsgDelegateConfederationVoice: delegate a member Stand's Voice in a
|
||||
// Confederation (REQ-058). The handler asserts the Stand type is
|
||||
// Confederation via the StandKeeper shim + records the delegation (one
|
||||
// delegation per member Stand — duplicate REJECTED).
|
||||
// - MsgAddLien: add a Good-Standing Lien to a Guild (REQ-053). The handler
|
||||
// rejects any new SecuredAtFounding=true lien (founding is a one-time
|
||||
// event — REQ-053/REQ-081).
|
||||
//
|
||||
// All cross-module refs are by-ID-string (G-003): founder-reach refs an
|
||||
// x/identity Reach; stand-id refs an x/stand Stand; parent-guild-id refs a
|
||||
// Guild; cover-pool-covenant-ref refs a Cover Pool covenant. No struct
|
||||
// imports of x/stand/types or x/stash/types (the shims are interfaces
|
||||
// defined in expected_keepers.go — G-003 preserved).
|
||||
//
|
||||
// Lexicon note (REQ-012): the message names + field names use the safe Guild
|
||||
// vocabulary EXCLUSIVELY. "Guild", "Chapter", "Parent Guild", "Common Bond",
|
||||
// "Public Profile", "Good-Standing Lien", "Secession Terms", "Household",
|
||||
// "Confederation", "Hand-Pass" are the clean names; the project-wide 10
|
||||
// banned terms NEVER appear (enforced by lexicon_meta + the per-package
|
||||
// lexicon assertion in types_test.go).
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
sdk "github.com/cosmos/cosmos-sdk/types"
|
||||
)
|
||||
|
||||
// --- MsgCreateGuild -----------------------------------------------------------
|
||||
|
||||
// MsgCreateGuild creates a Guild with a Common Bond hash + Public Profile
|
||||
// (REQ-051). The handler persists the Guild + surfaces a jurisdictional
|
||||
// disclaimer (REQ-061 — the Disclaimer string is in the response).
|
||||
//
|
||||
// ValidateBasic is stateless: non-empty fields + non-empty CommonBondHash.
|
||||
type MsgCreateGuild struct {
|
||||
GuildID string `json:"guild_id" yaml:"guild_id"`
|
||||
Name string `json:"name" yaml:"name"`
|
||||
FounderReach string `json:"founder_reach" yaml:"founder_reach"`
|
||||
StandAffiliationID string `json:"stand_affiliation_id,omitempty" yaml:"stand_affiliation_id,omitempty"`
|
||||
CommonBondHash []byte `json:"common_bond_hash" yaml:"common_bond_hash"`
|
||||
PublicProfile GuildPublicProfile `json:"public_profile" yaml:"public_profile"`
|
||||
Signer string `json:"signer" yaml:"signer"`
|
||||
}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgCreateGuild) Reset() { *m = MsgCreateGuild{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgCreateGuild) String() string {
|
||||
return fmt.Sprintf("MsgCreateGuild{GuildID:%s Name:%s FounderReach:%s StandAffiliationID:%s Signer:%s}",
|
||||
m.GuildID, m.Name, m.FounderReach, m.StandAffiliationID, m.Signer)
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgCreateGuild) ProtoMessage() {}
|
||||
|
||||
// ValidateBasic is the stateless validation: non-empty guild-id, name,
|
||||
// founder-reach, signer, non-empty CommonBondHash.
|
||||
func (m *MsgCreateGuild) ValidateBasic() error {
|
||||
if m.GuildID == "" {
|
||||
return fmt.Errorf("guild: empty guild-id")
|
||||
}
|
||||
if m.Name == "" {
|
||||
return fmt.Errorf("guild: empty name")
|
||||
}
|
||||
if m.FounderReach == "" {
|
||||
return fmt.Errorf("guild: empty founder-reach")
|
||||
}
|
||||
if m.Signer == "" {
|
||||
return fmt.Errorf("guild: empty signer")
|
||||
}
|
||||
if len(m.CommonBondHash) == 0 {
|
||||
return fmt.Errorf("guild: empty common-bond-hash (REQ-051 — hash-pinned at creation)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||
func (m *MsgCreateGuild) GetSigners() []sdk.AccAddress {
|
||||
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||
}
|
||||
|
||||
// --- MsgCreateChapter ---------------------------------------------------------
|
||||
|
||||
// MsgCreateChapter creates a Chapter under a Parent Guild (REQ-053). The
|
||||
// handler pins the SecessionTerms hash (HashSecessionTerms) + records the
|
||||
// Good-Standing Liens (SecuredAtFounding=true) + rejects cooling below the
|
||||
// protocol minimum (CoolingSecessionCoverActiveDays / NonCoverDays) +
|
||||
// surfaces a jurisdictional disclaimer (REQ-061).
|
||||
//
|
||||
// ValidateBasic is stateless: non-empty fields, non-empty ParentGuildID,
|
||||
// SecessionTerms valid (non-zero + protocol-minimum-bounded via
|
||||
// SecessionTerms.Validate), each GoodStandingLien has SecuredAtFounding=true
|
||||
// + non-empty CreditorReachID + Amount > 0.
|
||||
type MsgCreateChapter struct {
|
||||
GuildID string `json:"guild_id" yaml:"guild_id"`
|
||||
Name string `json:"name" yaml:"name"`
|
||||
ParentGuildID string `json:"parent_guild_id" yaml:"parent_guild_id"`
|
||||
FounderReach string `json:"founder_reach" yaml:"founder_reach"`
|
||||
SecessionTerms SecessionTerms `json:"secession_terms" yaml:"secession_terms"`
|
||||
GoodStandingLiens []Lien `json:"good_standing_liens" yaml:"good_standing_liens"`
|
||||
Signer string `json:"signer" yaml:"signer"`
|
||||
}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgCreateChapter) Reset() { *m = MsgCreateChapter{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgCreateChapter) String() string {
|
||||
return fmt.Sprintf("MsgCreateChapter{GuildID:%s Name:%s ParentGuildID:%s FounderReach:%s SecessionTerms:%+v Liens:%d Signer:%s}",
|
||||
m.GuildID, m.Name, m.ParentGuildID, m.FounderReach, m.SecessionTerms, len(m.GoodStandingLiens), m.Signer)
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgCreateChapter) ProtoMessage() {}
|
||||
|
||||
// ValidateBasic is the stateless validation: non-empty fields, non-empty
|
||||
// ParentGuildID, SecessionTerms valid, each GoodStandingLien is
|
||||
// SecuredAtFounding=true with non-empty CreditorReachID + Amount > 0
|
||||
// (founding-locked liens are recorded ONCE at founding — REQ-053).
|
||||
func (m *MsgCreateChapter) ValidateBasic() error {
|
||||
if m.GuildID == "" {
|
||||
return fmt.Errorf("guild: empty chapter guild-id")
|
||||
}
|
||||
if m.Name == "" {
|
||||
return fmt.Errorf("guild: empty chapter name")
|
||||
}
|
||||
if m.ParentGuildID == "" {
|
||||
return fmt.Errorf("guild: empty parent-guild-id (REQ-053 — Chapter requires a Parent)")
|
||||
}
|
||||
if m.ParentGuildID == m.GuildID {
|
||||
return fmt.Errorf("guild: Chapter %q cannot be its own parent", m.GuildID)
|
||||
}
|
||||
if m.FounderReach == "" {
|
||||
return fmt.Errorf("guild: empty founder-reach")
|
||||
}
|
||||
if m.Signer == "" {
|
||||
return fmt.Errorf("guild: empty signer")
|
||||
}
|
||||
if err := m.SecessionTerms.Validate(); err != nil {
|
||||
return fmt.Errorf("guild: secession terms: %w", err)
|
||||
}
|
||||
for i, l := range m.GoodStandingLiens {
|
||||
if !l.SecuredAtFounding {
|
||||
return fmt.Errorf("guild: GoodStandingLien[%d] has SecuredAtFounding=false (founding liens must be secured at founding — REQ-053)", i)
|
||||
}
|
||||
if l.CreditorReachID == "" {
|
||||
return fmt.Errorf("guild: GoodStandingLien[%d] has empty CreditorReachID", i)
|
||||
}
|
||||
if l.Amount <= 0 {
|
||||
return fmt.Errorf("guild: GoodStandingLien[%d] Amount %d <= 0", i, l.Amount)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||
func (m *MsgCreateChapter) GetSigners() []sdk.AccAddress {
|
||||
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||
}
|
||||
|
||||
// --- MsgOneTapExitStand -------------------------------------------------------
|
||||
|
||||
// MsgOneTapExitStand one-tap exits a Household Stand (REQ-057). The handler
|
||||
// asserts the Stand type is Household via the StandKeeper shim + dissolves
|
||||
// the Stand + returns assets to the Holder's Stash via the StashKeeper shim.
|
||||
// One-tap exit is the Household dispute path (no Council vote required —
|
||||
// Household skips the formal-Council requirement).
|
||||
//
|
||||
// ValidateBasic is stateless: non-empty stand-id + signer.
|
||||
type MsgOneTapExitStand struct {
|
||||
StandID string `json:"stand_id" yaml:"stand_id"`
|
||||
Signer string `json:"signer" yaml:"signer"`
|
||||
}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgOneTapExitStand) Reset() { *m = MsgOneTapExitStand{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgOneTapExitStand) String() string {
|
||||
return fmt.Sprintf("MsgOneTapExitStand{StandID:%s Signer:%s}", m.StandID, m.Signer)
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgOneTapExitStand) ProtoMessage() {}
|
||||
|
||||
// ValidateBasic is the stateless validation: non-empty stand-id + signer.
|
||||
func (m *MsgOneTapExitStand) ValidateBasic() error {
|
||||
if m.StandID == "" {
|
||||
return fmt.Errorf("guild: empty stand-id")
|
||||
}
|
||||
if m.Signer == "" {
|
||||
return fmt.Errorf("guild: empty signer")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||
func (m *MsgOneTapExitStand) GetSigners() []sdk.AccAddress {
|
||||
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||
}
|
||||
|
||||
// --- MsgDelegateConfederationVoice --------------------------------------------
|
||||
|
||||
// MsgDelegateConfederationVoice delegates a member Stand's Voice in a
|
||||
// Confederation (REQ-058). The handler asserts the ConfederationStandID
|
||||
// references a Confederation Stand via the StandKeeper shim + records the
|
||||
// delegation (one delegation per member Stand — a duplicate delegation from
|
||||
// the same MemberStandID is REJECTED). One-Stand-one-Vote: each member Stand
|
||||
// gets exactly 1 Voice in the Confederation's aggregate, regardless of size.
|
||||
//
|
||||
// ValidateBasic is stateless: non-empty fields.
|
||||
type MsgDelegateConfederationVoice struct {
|
||||
ConfederationStandID string `json:"confederation_stand_id" yaml:"confederation_stand_id"`
|
||||
MemberStandID string `json:"member_stand_id" yaml:"member_stand_id"`
|
||||
DelegateReachID string `json:"delegate_reach_id" yaml:"delegate_reach_id"`
|
||||
Signer string `json:"signer" yaml:"signer"`
|
||||
}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgDelegateConfederationVoice) Reset() { *m = MsgDelegateConfederationVoice{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgDelegateConfederationVoice) String() string {
|
||||
return fmt.Sprintf("MsgDelegateConfederationVoice{ConfederationStandID:%s MemberStandID:%s DelegateReachID:%s Signer:%s}",
|
||||
m.ConfederationStandID, m.MemberStandID, m.DelegateReachID, m.Signer)
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgDelegateConfederationVoice) ProtoMessage() {}
|
||||
|
||||
// ValidateBasic is the stateless validation: non-empty fields.
|
||||
func (m *MsgDelegateConfederationVoice) ValidateBasic() error {
|
||||
if m.ConfederationStandID == "" {
|
||||
return fmt.Errorf("guild: empty confederation-stand-id")
|
||||
}
|
||||
if m.MemberStandID == "" {
|
||||
return fmt.Errorf("guild: empty member-stand-id")
|
||||
}
|
||||
if m.DelegateReachID == "" {
|
||||
return fmt.Errorf("guild: empty delegate-reach-id")
|
||||
}
|
||||
if m.Signer == "" {
|
||||
return fmt.Errorf("guild: empty signer")
|
||||
}
|
||||
if m.ConfederationStandID == m.MemberStandID {
|
||||
return fmt.Errorf("guild: ConfederationStandID %q cannot delegate to itself", m.ConfederationStandID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||
func (m *MsgDelegateConfederationVoice) GetSigners() []sdk.AccAddress {
|
||||
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||
}
|
||||
|
||||
// --- MsgAddLien ---------------------------------------------------------------
|
||||
|
||||
// MsgAddLien adds a Good-Standing Lien to a Guild (REQ-053). The handler
|
||||
// rejects any new SecuredAtFounding=true lien (founding is a one-time event —
|
||||
// REQ-053/REQ-081; post-founding liens are SecuredAtFounding=false). The
|
||||
// handler loads the Guild + persists the lien.
|
||||
//
|
||||
// ValidateBasic is stateless: non-empty guild-id, non-empty signer, Lien
|
||||
// Amount > 0, non-empty CreditorReachID.
|
||||
type MsgAddLien struct {
|
||||
GuildID string `json:"guild_id" yaml:"guild_id"`
|
||||
Lien Lien `json:"lien" yaml:"lien"`
|
||||
Signer string `json:"signer" yaml:"signer"`
|
||||
}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgAddLien) Reset() { *m = MsgAddLien{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgAddLien) String() string {
|
||||
return fmt.Sprintf("MsgAddLien{GuildID:%s Lien:{Amount:%d CreditorReachID:%s SecuredAtFounding:%v} Signer:%s}",
|
||||
m.GuildID, m.Lien.Amount, m.Lien.CreditorReachID, m.Lien.SecuredAtFounding, m.Signer)
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgAddLien) ProtoMessage() {}
|
||||
|
||||
// ValidateBasic is the stateless validation: non-empty guild-id + signer,
|
||||
// Lien Amount > 0, non-empty CreditorReachID.
|
||||
func (m *MsgAddLien) ValidateBasic() error {
|
||||
if m.GuildID == "" {
|
||||
return fmt.Errorf("guild: empty guild-id")
|
||||
}
|
||||
if m.Signer == "" {
|
||||
return fmt.Errorf("guild: empty signer")
|
||||
}
|
||||
if m.Lien.Amount <= 0 {
|
||||
return fmt.Errorf("guild: lien Amount %d <= 0", m.Lien.Amount)
|
||||
}
|
||||
if m.Lien.CreditorReachID == "" {
|
||||
return fmt.Errorf("guild: empty lien CreditorReachID")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||
func (m *MsgAddLien) GetSigners() []sdk.AccAddress {
|
||||
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||
}
|
||||
|
||||
// --- MsgServer interface + Response types -------------------------------------
|
||||
|
||||
// MsgServer is the guild module's message server interface (one method per
|
||||
// Msg*). The keeper's msg_server.go implements this; module.go's
|
||||
// RegisterServices wires the implementation. Hand-rolled (no protobuf
|
||||
// codegen per the skeleton's zero-codegen style).
|
||||
type MsgServer interface {
|
||||
CreateGuild(ctx interface{}, msg *MsgCreateGuild) (*MsgCreateGuildResponse, error)
|
||||
CreateChapter(ctx interface{}, msg *MsgCreateChapter) (*MsgCreateChapterResponse, error)
|
||||
OneTapExitStand(ctx interface{}, msg *MsgOneTapExitStand) (*MsgOneTapExitStandResponse, error)
|
||||
DelegateConfederationVoice(ctx interface{}, msg *MsgDelegateConfederationVoice) (*MsgDelegateConfederationVoiceResponse, error)
|
||||
AddLien(ctx interface{}, msg *MsgAddLien) (*MsgAddLienResponse, error)
|
||||
}
|
||||
|
||||
// --- Response types -----------------------------------------------------------
|
||||
//
|
||||
// Hand-rolled (no protobuf codegen). The CreateGuild + CreateChapter
|
||||
// responses carry a Disclaimer string (REQ-061 — the jurisdictional
|
||||
// disclaimer surfaced at every charter signing). The other responses are
|
||||
// empty bodies (the response is the state mutation + event).
|
||||
|
||||
// MsgCreateGuildResponse is the response to MsgCreateGuild. Disclaimer is
|
||||
// the jurisdictional disclaimer surfaced at signing (REQ-061).
|
||||
type MsgCreateGuildResponse struct {
|
||||
Disclaimer string `json:"disclaimer" yaml:"disclaimer"`
|
||||
}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgCreateGuildResponse) Reset() { *m = MsgCreateGuildResponse{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgCreateGuildResponse) String() string {
|
||||
return fmt.Sprintf("MsgCreateGuildResponse{Disclaimer:%s}", m.Disclaimer)
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgCreateGuildResponse) ProtoMessage() {}
|
||||
|
||||
// MsgCreateChapterResponse is the response to MsgCreateChapter. Disclaimer
|
||||
// is the jurisdictional disclaimer surfaced at signing (REQ-061).
|
||||
type MsgCreateChapterResponse struct {
|
||||
Disclaimer string `json:"disclaimer" yaml:"disclaimer"`
|
||||
}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgCreateChapterResponse) Reset() { *m = MsgCreateChapterResponse{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgCreateChapterResponse) String() string {
|
||||
return fmt.Sprintf("MsgCreateChapterResponse{Disclaimer:%s}", m.Disclaimer)
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgCreateChapterResponse) ProtoMessage() {}
|
||||
|
||||
// MsgOneTapExitStandResponse is the response to MsgOneTapExitStand.
|
||||
type MsgOneTapExitStandResponse struct{}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgOneTapExitStandResponse) Reset() { *m = MsgOneTapExitStandResponse{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgOneTapExitStandResponse) String() string { return "MsgOneTapExitStandResponse{}" }
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgOneTapExitStandResponse) ProtoMessage() {}
|
||||
|
||||
// MsgDelegateConfederationVoiceResponse is the response to
|
||||
// MsgDelegateConfederationVoice.
|
||||
type MsgDelegateConfederationVoiceResponse struct{}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgDelegateConfederationVoiceResponse) Reset() {
|
||||
*m = MsgDelegateConfederationVoiceResponse{}
|
||||
}
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgDelegateConfederationVoiceResponse) String() string {
|
||||
return "MsgDelegateConfederationVoiceResponse{}"
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgDelegateConfederationVoiceResponse) ProtoMessage() {}
|
||||
|
||||
// MsgAddLienResponse is the response to MsgAddLien.
|
||||
type MsgAddLienResponse struct{}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgAddLienResponse) Reset() { *m = MsgAddLienResponse{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgAddLienResponse) String() string { return "MsgAddLienResponse{}" }
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgAddLienResponse) ProtoMessage() {}
|
||||
@@ -0,0 +1,305 @@
|
||||
package types
|
||||
|
||||
// msg_guild_test.go holds the Msg* method coverage tests for x/guild/types
|
||||
// (REQ-051, REQ-053, REQ-057, REQ-058). The Msg* Reset/String/ProtoMessage/
|
||||
// ValidateBasic/GetSigners methods are exercised here so the types package
|
||||
// coverage is >=80% (the keeper simtest exercises the handlers but its
|
||||
// coverage counts toward the keeper package, not types).
|
||||
//
|
||||
// G-024: this file imports cosmos-sdk for GetSigners (sdk.AccAddress) —
|
||||
// this is a Msg-method test, NOT an invariant/lexicon test, so the G-024
|
||||
// stdlib-only constraint does not apply (the invariant + lexicon assertions
|
||||
// live in types_test.go, which stays stdlib + lexicon-only).
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
sdk "github.com/cosmos/cosmos-sdk/types"
|
||||
)
|
||||
|
||||
// --- MsgCreateGuild methods ---------------------------------------------------
|
||||
|
||||
func TestMsgCreateGuildMethods(t *testing.T) {
|
||||
m := &MsgCreateGuild{
|
||||
GuildID: "g1", Name: "Guild", FounderReach: "reach:f",
|
||||
CommonBondHash: []byte{1, 2, 3},
|
||||
PublicProfile: GuildPublicProfile{BondSummary: "s", MasonCount: 7},
|
||||
Signer: "reach:f",
|
||||
}
|
||||
if err := m.ValidateBasic(); err != nil {
|
||||
t.Errorf("valid MsgCreateGuild ValidateBasic: %v", err)
|
||||
}
|
||||
if !strings.Contains(m.String(), "g1") {
|
||||
t.Errorf("MsgCreateGuild String = %q, want to contain g1", m.String())
|
||||
}
|
||||
m.Reset()
|
||||
if m.GuildID != "" || len(m.CommonBondHash) != 0 {
|
||||
t.Errorf("MsgCreateGuild Reset did not zero: %+v", m)
|
||||
}
|
||||
m.ProtoMessage() // no-op coverage
|
||||
m2 := &MsgCreateGuild{Signer: "reach:s"}
|
||||
if got := m2.GetSigners(); len(got) != 1 || string(got[0]) != "reach:s" {
|
||||
t.Errorf("MsgCreateGuild GetSigners = %v, want [reach:s]", got)
|
||||
}
|
||||
var _ []sdk.AccAddress = m2.GetSigners()
|
||||
}
|
||||
|
||||
func TestMsgCreateGuildValidateBasicErrors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mut func(*MsgCreateGuild)
|
||||
}{
|
||||
{"empty guild-id", func(m *MsgCreateGuild) { m.GuildID = "" }},
|
||||
{"empty name", func(m *MsgCreateGuild) { m.Name = "" }},
|
||||
{"empty founder-reach", func(m *MsgCreateGuild) { m.FounderReach = "" }},
|
||||
{"empty signer", func(m *MsgCreateGuild) { m.Signer = "" }},
|
||||
{"empty common-bond-hash", func(m *MsgCreateGuild) { m.CommonBondHash = nil }},
|
||||
}
|
||||
for _, c := range cases {
|
||||
m := &MsgCreateGuild{GuildID: "g", Name: "n", FounderReach: "r", CommonBondHash: []byte{1}, Signer: "s"}
|
||||
c.mut(m)
|
||||
if err := m.ValidateBasic(); err == nil {
|
||||
t.Errorf("MsgCreateGuild %s: expected error, got nil", c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- MsgCreateChapter methods -------------------------------------------------
|
||||
|
||||
func TestMsgCreateChapterMethods(t *testing.T) {
|
||||
m := &MsgCreateChapter{
|
||||
GuildID: "c1", Name: "Chapter", ParentGuildID: "g1", FounderReach: "reach:f",
|
||||
SecessionTerms: SecessionTerms{
|
||||
CoolingCoverActiveDays: CoolingSecessionCoverActiveDays,
|
||||
CoolingNonCoverDays: CoolingSecessionNonCoverDays,
|
||||
},
|
||||
GoodStandingLiens: []Lien{{Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: true}},
|
||||
Signer: "reach:f",
|
||||
}
|
||||
if err := m.ValidateBasic(); err != nil {
|
||||
t.Errorf("valid MsgCreateChapter ValidateBasic: %v", err)
|
||||
}
|
||||
if !strings.Contains(m.String(), "c1") || !strings.Contains(m.String(), "g1") {
|
||||
t.Errorf("MsgCreateChapter String = %q", m.String())
|
||||
}
|
||||
m.Reset()
|
||||
if m.GuildID != "" || m.ParentGuildID != "" {
|
||||
t.Errorf("MsgCreateChapter Reset did not zero: %+v", m)
|
||||
}
|
||||
m.ProtoMessage()
|
||||
m2 := &MsgCreateChapter{Signer: "reach:s"}
|
||||
if got := m2.GetSigners(); len(got) != 1 || string(got[0]) != "reach:s" {
|
||||
t.Errorf("MsgCreateChapter GetSigners = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgCreateChapterValidateBasicErrors(t *testing.T) {
|
||||
validTerms := SecessionTerms{
|
||||
CoolingCoverActiveDays: CoolingSecessionCoverActiveDays,
|
||||
CoolingNonCoverDays: CoolingSecessionNonCoverDays,
|
||||
}
|
||||
validLiens := []Lien{{Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: true}}
|
||||
cases := []struct {
|
||||
name string
|
||||
mut func(*MsgCreateChapter)
|
||||
}{
|
||||
{"empty guild-id", func(m *MsgCreateChapter) { m.GuildID = "" }},
|
||||
{"empty name", func(m *MsgCreateChapter) { m.Name = "" }},
|
||||
{"empty parent-guild-id", func(m *MsgCreateChapter) { m.ParentGuildID = "" }},
|
||||
{"self parent", func(m *MsgCreateChapter) { m.ParentGuildID = m.GuildID }},
|
||||
{"empty founder-reach", func(m *MsgCreateChapter) { m.FounderReach = "" }},
|
||||
{"empty signer", func(m *MsgCreateChapter) { m.Signer = "" }},
|
||||
{"loose cooling (cover)", func(m *MsgCreateChapter) {
|
||||
m.SecessionTerms.CoolingCoverActiveDays = CoolingSecessionCoverActiveDays - 1
|
||||
}},
|
||||
{"loose cooling (non-cover)", func(m *MsgCreateChapter) {
|
||||
m.SecessionTerms.CoolingNonCoverDays = CoolingSecessionNonCoverDays - 1
|
||||
}},
|
||||
{"zero cooling (cover)", func(m *MsgCreateChapter) { m.SecessionTerms.CoolingCoverActiveDays = 0 }},
|
||||
{"lien not secured at founding", func(m *MsgCreateChapter) {
|
||||
m.GoodStandingLiens = []Lien{{Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: false}}
|
||||
}},
|
||||
{"lien empty creditor", func(m *MsgCreateChapter) {
|
||||
m.GoodStandingLiens = []Lien{{Amount: 100, CreditorReachID: "", SecuredAtFounding: true}}
|
||||
}},
|
||||
{"lien zero amount", func(m *MsgCreateChapter) {
|
||||
m.GoodStandingLiens = []Lien{{Amount: 0, CreditorReachID: "reach:c", SecuredAtFounding: true}}
|
||||
}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
m := &MsgCreateChapter{
|
||||
GuildID: "c", Name: "n", ParentGuildID: "g", FounderReach: "r",
|
||||
SecessionTerms: validTerms, GoodStandingLiens: validLiens, Signer: "s",
|
||||
}
|
||||
c.mut(m)
|
||||
if err := m.ValidateBasic(); err == nil {
|
||||
t.Errorf("MsgCreateChapter %s: expected error, got nil", c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- MsgOneTapExitStand methods -----------------------------------------------
|
||||
|
||||
func TestMsgOneTapExitStandMethods(t *testing.T) {
|
||||
m := &MsgOneTapExitStand{StandID: "s1", Signer: "reach:h"}
|
||||
if err := m.ValidateBasic(); err != nil {
|
||||
t.Errorf("valid MsgOneTapExitStand ValidateBasic: %v", err)
|
||||
}
|
||||
if !strings.Contains(m.String(), "s1") {
|
||||
t.Errorf("MsgOneTapExitStand String = %q", m.String())
|
||||
}
|
||||
m.Reset()
|
||||
if m.StandID != "" {
|
||||
t.Errorf("MsgOneTapExitStand Reset did not zero: %+v", m)
|
||||
}
|
||||
m.ProtoMessage()
|
||||
m2 := &MsgOneTapExitStand{Signer: "reach:s"}
|
||||
if got := m2.GetSigners(); len(got) != 1 || string(got[0]) != "reach:s" {
|
||||
t.Errorf("MsgOneTapExitStand GetSigners = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgOneTapExitStandValidateBasicErrors(t *testing.T) {
|
||||
if err := (&MsgOneTapExitStand{}).ValidateBasic(); err == nil {
|
||||
t.Error("empty MsgOneTapExitStand should fail ValidateBasic")
|
||||
}
|
||||
if err := (&MsgOneTapExitStand{StandID: "s"}).ValidateBasic(); err == nil {
|
||||
t.Error("MsgOneTapExitStand with empty signer should fail ValidateBasic")
|
||||
}
|
||||
if err := (&MsgOneTapExitStand{Signer: "s"}).ValidateBasic(); err == nil {
|
||||
t.Error("MsgOneTapExitStand with empty stand-id should fail ValidateBasic")
|
||||
}
|
||||
}
|
||||
|
||||
// --- MsgDelegateConfederationVoice methods ------------------------------------
|
||||
|
||||
func TestMsgDelegateConfederationVoiceMethods(t *testing.T) {
|
||||
m := &MsgDelegateConfederationVoice{
|
||||
ConfederationStandID: "conf-1", MemberStandID: "mem-1",
|
||||
DelegateReachID: "reach:d", Signer: "reach:s",
|
||||
}
|
||||
if err := m.ValidateBasic(); err != nil {
|
||||
t.Errorf("valid MsgDelegateConfederationVoice ValidateBasic: %v", err)
|
||||
}
|
||||
if !strings.Contains(m.String(), "conf-1") {
|
||||
t.Errorf("MsgDelegateConfederationVoice String = %q", m.String())
|
||||
}
|
||||
m.Reset()
|
||||
if m.ConfederationStandID != "" {
|
||||
t.Errorf("MsgDelegateConfederationVoice Reset did not zero: %+v", m)
|
||||
}
|
||||
m.ProtoMessage()
|
||||
m2 := &MsgDelegateConfederationVoice{Signer: "reach:s"}
|
||||
if got := m2.GetSigners(); len(got) != 1 || string(got[0]) != "reach:s" {
|
||||
t.Errorf("MsgDelegateConfederationVoice GetSigners = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgDelegateConfederationVoiceValidateBasicErrors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mut func(*MsgDelegateConfederationVoice)
|
||||
}{
|
||||
{"empty confederation", func(m *MsgDelegateConfederationVoice) { m.ConfederationStandID = "" }},
|
||||
{"empty member", func(m *MsgDelegateConfederationVoice) { m.MemberStandID = "" }},
|
||||
{"empty delegate", func(m *MsgDelegateConfederationVoice) { m.DelegateReachID = "" }},
|
||||
{"empty signer", func(m *MsgDelegateConfederationVoice) { m.Signer = "" }},
|
||||
{"self-delegate", func(m *MsgDelegateConfederationVoice) { m.MemberStandID = m.ConfederationStandID }},
|
||||
}
|
||||
for _, c := range cases {
|
||||
m := &MsgDelegateConfederationVoice{
|
||||
ConfederationStandID: "c", MemberStandID: "m",
|
||||
DelegateReachID: "d", Signer: "s",
|
||||
}
|
||||
c.mut(m)
|
||||
if err := m.ValidateBasic(); err == nil {
|
||||
t.Errorf("MsgDelegateConfederationVoice %s: expected error", c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- MsgAddLien methods -------------------------------------------------------
|
||||
|
||||
func TestMsgAddLienMethods(t *testing.T) {
|
||||
m := &MsgAddLien{
|
||||
GuildID: "g1",
|
||||
Lien: Lien{Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: false},
|
||||
Signer: "reach:s",
|
||||
}
|
||||
if err := m.ValidateBasic(); err != nil {
|
||||
t.Errorf("valid MsgAddLien ValidateBasic: %v", err)
|
||||
}
|
||||
if !strings.Contains(m.String(), "g1") {
|
||||
t.Errorf("MsgAddLien String = %q", m.String())
|
||||
}
|
||||
m.Reset()
|
||||
if m.GuildID != "" {
|
||||
t.Errorf("MsgAddLien Reset did not zero: %+v", m)
|
||||
}
|
||||
m.ProtoMessage()
|
||||
m2 := &MsgAddLien{Signer: "reach:s"}
|
||||
if got := m2.GetSigners(); len(got) != 1 || string(got[0]) != "reach:s" {
|
||||
t.Errorf("MsgAddLien GetSigners = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgAddLienValidateBasicErrors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mut func(*MsgAddLien)
|
||||
}{
|
||||
{"empty guild-id", func(m *MsgAddLien) { m.GuildID = "" }},
|
||||
{"empty signer", func(m *MsgAddLien) { m.Signer = "" }},
|
||||
{"zero amount", func(m *MsgAddLien) { m.Lien.Amount = 0 }},
|
||||
{"negative amount", func(m *MsgAddLien) { m.Lien.Amount = -1 }},
|
||||
{"empty creditor", func(m *MsgAddLien) { m.Lien.CreditorReachID = "" }},
|
||||
}
|
||||
for _, c := range cases {
|
||||
m := &MsgAddLien{
|
||||
GuildID: "g", Lien: Lien{Amount: 100, CreditorReachID: "reach:c"}, Signer: "s",
|
||||
}
|
||||
c.mut(m)
|
||||
if err := m.ValidateBasic(); err == nil {
|
||||
t.Errorf("MsgAddLien %s: expected error", c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Response type methods ----------------------------------------------------
|
||||
|
||||
func TestResponseMethods(t *testing.T) {
|
||||
r1 := &MsgCreateGuildResponse{Disclaimer: "d"}
|
||||
if !strings.Contains(r1.String(), "d") {
|
||||
t.Errorf("MsgCreateGuildResponse String = %q", r1.String())
|
||||
}
|
||||
r1.Reset()
|
||||
if r1.Disclaimer != "" {
|
||||
t.Errorf("MsgCreateGuildResponse Reset did not zero: %+v", r1)
|
||||
}
|
||||
r1.ProtoMessage()
|
||||
|
||||
r2 := &MsgCreateChapterResponse{Disclaimer: "d"}
|
||||
if !strings.Contains(r2.String(), "d") {
|
||||
t.Errorf("MsgCreateChapterResponse String = %q", r2.String())
|
||||
}
|
||||
r2.Reset()
|
||||
if r2.Disclaimer != "" {
|
||||
t.Errorf("MsgCreateChapterResponse Reset did not zero: %+v", r2)
|
||||
}
|
||||
r2.ProtoMessage()
|
||||
|
||||
for _, r := range []interface {
|
||||
Reset()
|
||||
String() string
|
||||
ProtoMessage()
|
||||
}{
|
||||
&MsgOneTapExitStandResponse{},
|
||||
&MsgDelegateConfederationVoiceResponse{},
|
||||
&MsgAddLienResponse{},
|
||||
} {
|
||||
r.ProtoMessage()
|
||||
_ = r.String()
|
||||
r.Reset()
|
||||
}
|
||||
}
|
||||
+226
-13
@@ -1,6 +1,7 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
@@ -17,18 +18,162 @@ const (
|
||||
// (v0.1 already encodes HandPassGuild as a 0-fee waiver reason). v0.2's Guild
|
||||
// module references that waiver, doesn't redefine the fee.
|
||||
HandPassFeeBps = 0
|
||||
|
||||
// PierCarriesVoice is the 12th locked const (GRILL D-087, FR-VOICE-6):
|
||||
// the Pier wrapper does NOT carry Voice, regardless of fiduciary role.
|
||||
// This is a mission-locked invariant: a Chapter retains mesh-level Voice
|
||||
// (the const enforces that the optional Pier-Routed Legal Wrapper does
|
||||
// NOT carry Voice). Locked-const regression in types_test.go.
|
||||
PierCarriesVoice = false
|
||||
|
||||
// CoolingSecessionCoverActiveDays is the LOCKED protocol minimum (REQ-064)
|
||||
// for a Cover-active Chapter's secession cooling period: 21 Mesh-days. A
|
||||
// Chapter's SecessionTerms MAY specify a longer cooling but NOT shorter
|
||||
// (the CreateChapter handler rejects shorter). Locked-const regression in
|
||||
// types_test.go.
|
||||
CoolingSecessionCoverActiveDays = uint32(21)
|
||||
|
||||
// CoolingSecessionNonCoverDays is the LOCKED protocol minimum (REQ-064)
|
||||
// for a non-Cover-active Chapter's secession cooling period: 14 Mesh-days.
|
||||
// A Chapter's SecessionTerms MAY specify a longer cooling but NOT shorter
|
||||
// (the CreateChapter handler rejects shorter). Locked-const regression in
|
||||
// types_test.go.
|
||||
CoolingSecessionNonCoverDays = uint32(14)
|
||||
)
|
||||
|
||||
// Guild is a task-oriented collective (vision §16, REQ-017). A Guild may
|
||||
// optionally affiliate with a Stand (stand-affiliation-id references x/stand
|
||||
// by ID string — G-003 by-ID-string invariant). founder-reach references
|
||||
// x/identity Reach by string.
|
||||
//
|
||||
// P3 extension (REQ-051, REQ-053): the Guild carries a Common Bond
|
||||
// (hash-pinned at creation — CommonBondHash) + a Public Profile
|
||||
// (GuildPublicProfile). A Parent Guild (IsChapter=false, ParentGuildID="")
|
||||
// may have Chapters (IsChapter=true, ParentGuildID by-ID-string). A Chapter
|
||||
// pins its SecessionTerms at creation (SecessionTermsHash — the hash of the
|
||||
// JSON-encoded SecessionTerms; immutable — no handler to amend it). A
|
||||
// Chapter's Good-Standing Liens (GoodStandingLiens) are recorded at founding
|
||||
// with SecuredAtFounding=true; post-founding liens are SecuredAtFounding=false
|
||||
// (the AddLien handler rejects any new SecuredAtFounding=true lien — founding
|
||||
// is a one-time event).
|
||||
type Guild struct {
|
||||
GuildID string `json:"guild_id" yaml:"guild_id"`
|
||||
Name string `json:"name" yaml:"name"`
|
||||
FounderReach string `json:"founder_reach" yaml:"founder_reach"`
|
||||
CreatedAt int64 `json:"created_at" yaml:"created_at"`
|
||||
StandAffiliationID string `json:"stand_affiliation_id,omitempty" yaml:"stand_affiliation_id,omitempty"`
|
||||
GuildID string `json:"guild_id" yaml:"guild_id"`
|
||||
Name string `json:"name" yaml:"name"`
|
||||
FounderReach string `json:"founder_reach" yaml:"founder_reach"`
|
||||
CreatedAt int64 `json:"created_at" yaml:"created_at"`
|
||||
StandAffiliationID string `json:"stand_affiliation_id,omitempty" yaml:"stand_affiliation_id,omitempty"`
|
||||
CommonBondHash []byte `json:"common_bond_hash,omitempty" yaml:"common_bond_hash,omitempty"`
|
||||
PublicProfile GuildPublicProfile `json:"public_profile,omitempty" yaml:"public_profile,omitempty"`
|
||||
ParentGuildID string `json:"parent_guild_id,omitempty" yaml:"parent_guild_id,omitempty"`
|
||||
IsChapter bool `json:"is_chapter,omitempty" yaml:"is_chapter,omitempty"`
|
||||
SecessionTermsHash []byte `json:"secession_terms_hash,omitempty" yaml:"secession_terms_hash,omitempty"`
|
||||
GoodStandingLiens []Lien `json:"good_standing_liens,omitempty" yaml:"good_standing_liens,omitempty"`
|
||||
}
|
||||
|
||||
// GuildPublicProfile is a Guild's published profile (REQ-051). BondSummary is
|
||||
// a short, human-readable summary of the Common Bond (the protocol does NOT
|
||||
// parse it — FR-CHTR-5). Disclaimers is the list of jurisdictional
|
||||
// disclaimers the Guild publishes. MasonCount is the member count when
|
||||
// disclosed; MasonCountPrivate=true means the count is NOT disclosed
|
||||
// (MasonCount is 0; consumers check the bool). PierWrapperID references a
|
||||
// Pier wrapper by-ID-string (G-003); empty means no Pier wrapper (the §5
|
||||
// default-no-wrapper — D-087: the Pier wrapper does NOT carry Voice).
|
||||
type GuildPublicProfile struct {
|
||||
BondSummary string `json:"bond_summary" yaml:"bond_summary"`
|
||||
Disclaimers []string `json:"disclaimers" yaml:"disclaimers"`
|
||||
MasonCount uint32 `json:"mason_count" yaml:"mason_count"`
|
||||
MasonCountPrivate bool `json:"mason_count_private" yaml:"mason_count_private"`
|
||||
PierWrapperID string `json:"pier_wrapper_id,omitempty" yaml:"pier_wrapper_id,omitempty"`
|
||||
}
|
||||
|
||||
// Lien is a Good-Standing Lien on a Guild (REQ-053). Amount is the lien
|
||||
// amount in Grain. CreditorReachID references the creditor's Reach by string
|
||||
// (G-003). SecuredAtFounding=true marks a founding-locked lien (recorded at
|
||||
// Guild/Chapter creation; NOT freely increasable post-founding — the AddLien
|
||||
// handler rejects any new SecuredAtFounding=true lien). CoverPoolCovenantRef
|
||||
// references a Cover Pool covenant by-ID-string (G-003); empty for a lien
|
||||
// with no Cover Pool covenant backing.
|
||||
type Lien struct {
|
||||
Amount int64 `json:"amount" yaml:"amount"`
|
||||
CreditorReachID string `json:"creditor_reach_id" yaml:"creditor_reach_id"`
|
||||
SecuredAtFounding bool `json:"secured_at_founding" yaml:"secured_at_founding"`
|
||||
CoverPoolCovenantRef string `json:"cover_pool_covenant_ref,omitempty" yaml:"cover_pool_covenant_ref,omitempty"`
|
||||
}
|
||||
|
||||
// SecessionTerms is a Chapter's secession cooling terms (REQ-053, REQ-064).
|
||||
// Hash-pinned at Guild creation (the SecessionTermsHash on the Guild is the
|
||||
// SHA-256 of this struct's JSON; immutable — no handler to amend it). The
|
||||
// cooling periods are protocol-minimum-bounded: the Chapter MAY specify
|
||||
// longer but NOT shorter than CoolingSecessionCoverActiveDays /
|
||||
// CoolingSecessionNonCoverDays (the CreateChapter handler rejects shorter).
|
||||
// LienAuditRequired marks whether a lien audit must pass before secession
|
||||
// completes. CovenantClearanceRequired marks whether Cover Call / Bond
|
||||
// covenant clearance must pass before secession completes.
|
||||
type SecessionTerms struct {
|
||||
CoolingCoverActiveDays uint32 `json:"cooling_cover_active_days" yaml:"cooling_cover_active_days"`
|
||||
CoolingNonCoverDays uint32 `json:"cooling_non_cover_days" yaml:"cooling_non_cover_days"`
|
||||
LienAuditRequired bool `json:"lien_audit_required" yaml:"lien_audit_required"`
|
||||
CovenantClearanceRequired bool `json:"covenant_clearance_required" yaml:"covenant_clearance_required"`
|
||||
}
|
||||
|
||||
// HashSecessionTerms returns the SHA-256 hash of the JSON-encoded
|
||||
// SecessionTerms. This is the value stored on Guild.SecessionTermsHash at
|
||||
// Chapter creation (immutable). The handler pins the hash, NOT the terms
|
||||
// themselves (the terms are recoverable from genesis; the hash pins them
|
||||
// against amendment — REQ-053 immutability).
|
||||
func HashSecessionTerms(t SecessionTerms) []byte {
|
||||
bz, err := json.Marshal(t)
|
||||
if err != nil {
|
||||
// SecessionTerms is a plain struct with only uint32/bool fields;
|
||||
// json.Marshal never errors here. Panic is the defensive path.
|
||||
panic(fmt.Sprintf("guild: marshal secession terms: %v", err))
|
||||
}
|
||||
sum := sha256.Sum256(bz)
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
// ConfederationVoice is a Confederation Voice delegation record (REQ-058).
|
||||
// One-Stand-one-Vote: each member Stand gets exactly 1 Voice in the
|
||||
// Confederation's aggregate, regardless of size. ConfederationStandID +
|
||||
// MemberStandID reference x/stand Stands by-ID-string (G-003).
|
||||
// DelegateReachID references the Reach the member Stand's Voice is delegated
|
||||
// to. DelegatedAt is the delegation timestamp (block time). The guild
|
||||
// keeper persists this (the DelegateConfederationVoice handler records one
|
||||
// delegation per member Stand — a duplicate is REJECTED).
|
||||
//
|
||||
// NOTE: x/stand/types defines a type-level ConfederationVoice struct too
|
||||
// (the type-level addition); this guild-side struct is the persisted record
|
||||
// (the guild keeper owns the delegation store). The two structs share the
|
||||
// same JSON field names so a value of one round-trips through the other
|
||||
// (the simtest asserts against this struct; the x/stand/types struct is the
|
||||
// type-level scaffold for the aggregation logic landing in a later phase).
|
||||
type ConfederationVoice struct {
|
||||
ConfederationStandID string `json:"confederation_stand_id" yaml:"confederation_stand_id"`
|
||||
MemberStandID string `json:"member_stand_id" yaml:"member_stand_id"`
|
||||
DelegateReachID string `json:"delegate_reach_id" yaml:"delegate_reach_id"`
|
||||
DelegatedAt int64 `json:"delegated_at" yaml:"delegated_at"`
|
||||
}
|
||||
|
||||
// Validate asserts a SecessionTerms is non-zero + protocol-minimum-bounded
|
||||
// (the Chapter MAY tighten the cooling but NOT loosen it below
|
||||
// CoolingSecessionCoverActiveDays / CoolingSecessionNonCoverDays). The
|
||||
// CreateChapter handler calls this BEFORE pinning the hash.
|
||||
func (t SecessionTerms) Validate() error {
|
||||
if t.CoolingCoverActiveDays == 0 {
|
||||
return fmt.Errorf("guild: CoolingCoverActiveDays must be non-zero")
|
||||
}
|
||||
if t.CoolingNonCoverDays == 0 {
|
||||
return fmt.Errorf("guild: CoolingNonCoverDays must be non-zero")
|
||||
}
|
||||
if t.CoolingCoverActiveDays < CoolingSecessionCoverActiveDays {
|
||||
return fmt.Errorf("guild: CoolingCoverActiveDays %d < protocol minimum %d (Chapter may tighten but not loosen — REQ-053/REQ-064)",
|
||||
t.CoolingCoverActiveDays, CoolingSecessionCoverActiveDays)
|
||||
}
|
||||
if t.CoolingNonCoverDays < CoolingSecessionNonCoverDays {
|
||||
return fmt.Errorf("guild: CoolingNonCoverDays %d < protocol minimum %d (Chapter may tighten but not loosen — REQ-053/REQ-064)",
|
||||
t.CoolingNonCoverDays, CoolingSecessionNonCoverDays)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandPass is a free (0% protocol fee) Pass-Act issued by a Guild (REQ-017).
|
||||
@@ -60,17 +205,38 @@ func IssueHandPass(passID, guildID, issuerReach, recipientReach string, amountGr
|
||||
}
|
||||
}
|
||||
|
||||
// Params for the guild module (skeleton — no tunables in v0.2).
|
||||
type Params struct{}
|
||||
// Params for the guild module (P3 extension — REQ-064 cooling defaults).
|
||||
// DefaultCoolingCoverActiveDays + DefaultCoolingNonCoverDays are the
|
||||
// protocol-default cooling periods for a Chapter with no SecessionTerms
|
||||
// override (the Chapter's own SecessionTerms MAY specify longer but NOT
|
||||
// shorter than the protocol minimums CoolingSecessionCoverActiveDays /
|
||||
// CoolingSecessionNonCoverDays).
|
||||
type Params struct {
|
||||
DefaultCoolingCoverActiveDays uint32 `json:"default_cooling_cover_active_days" yaml:"default_cooling_cover_active_days"`
|
||||
DefaultCoolingNonCoverDays uint32 `json:"default_cooling_non_cover_days" yaml:"default_cooling_non_cover_days"`
|
||||
}
|
||||
|
||||
func DefaultParams() Params { return Params{} }
|
||||
// DefaultParams returns the Params with the protocol-minimum cooling defaults
|
||||
// (CoolingSecessionCoverActiveDays / CoolingSecessionNonCoverDays — the
|
||||
// Chapter MAY tighten but NOT loosen).
|
||||
func DefaultParams() Params {
|
||||
return Params{
|
||||
DefaultCoolingCoverActiveDays: CoolingSecessionCoverActiveDays,
|
||||
DefaultCoolingNonCoverDays: CoolingSecessionNonCoverDays,
|
||||
}
|
||||
}
|
||||
|
||||
// GenesisState defines the guild module genesis state (REQ-017).
|
||||
// Guilds + HandPasses are the two top-level sets; ValidateGenesis enforces
|
||||
// guild-id uniqueness and pass-id uniqueness.
|
||||
// GenesisState defines the guild module genesis state (REQ-017, REQ-053).
|
||||
// Guilds + HandPasses + Chapters are the three top-level sets; Chapters is a
|
||||
// separate slice for genesis validation clarity (a Chapter is a Guild with
|
||||
// IsChapter=true — the separate slice makes the Chapter→ParentGuildID
|
||||
// reference check unambiguous). ValidateGenesis enforces guild-id + pass-id
|
||||
// uniqueness + the Chapter→ParentGuildID reference check (a Chapter's
|
||||
// ParentGuildID must reference an existing Guild in the genesis — REQ-053).
|
||||
type GenesisState struct {
|
||||
Params Params `json:"params" yaml:"params"`
|
||||
Guilds []Guild `json:"guilds" yaml:"guilds"`
|
||||
Chapters []Guild `json:"chapters,omitempty" yaml:"chapters,omitempty"`
|
||||
HandPasses []HandPass `json:"hand_passes" yaml:"hand_passes"`
|
||||
}
|
||||
|
||||
@@ -78,19 +244,40 @@ func DefaultGenesisState() *GenesisState {
|
||||
return &GenesisState{
|
||||
Params: DefaultParams(),
|
||||
Guilds: []Guild{},
|
||||
Chapters: []Guild{},
|
||||
HandPasses: []HandPass{},
|
||||
}
|
||||
}
|
||||
|
||||
// Reset implements proto.Message (required by codec.JSONCodec for
|
||||
// InitGenesis/ExportGenesis).
|
||||
func (m *GenesisState) Reset() { *m = GenesisState{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *GenesisState) String() string {
|
||||
return fmt.Sprintf("GenesisState{Guilds:%d Chapters:%d HandPasses:%d}",
|
||||
len(m.Guilds), len(m.Chapters), len(m.HandPasses))
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*GenesisState) ProtoMessage() {}
|
||||
|
||||
// ValidateGenesis performs ID-uniqueness checks (A-212 upgrade from v0.1
|
||||
// no-op): rejects duplicate guild-ids and duplicate pass-ids. Also enforces
|
||||
// the 0-fee covenant on genesis HandPasses (FeeGrain must be 0).
|
||||
// the 0-fee covenant on genesis HandPasses (FeeGrain must be 0). P3
|
||||
// extension (REQ-053): a Chapter (Guild with IsChapter=true, in either the
|
||||
// Guilds or Chapters slice) must have a non-empty ParentGuildID referencing
|
||||
// an existing Guild in the genesis (the parent must be a non-Chapter Guild).
|
||||
func ValidateGenesis(bz json.RawMessage) error {
|
||||
var gs GenesisState
|
||||
if err := json.Unmarshal(bz, &gs); err != nil {
|
||||
return fmt.Errorf("guild: invalid genesis: %w", err)
|
||||
}
|
||||
seenGuild := make(map[string]bool, len(gs.Guilds))
|
||||
// Index all guild-ids across the Guilds + Chapters slices for the
|
||||
// Chapter→ParentGuildID reference check. Reject duplicate guild-ids
|
||||
// across BOTH slices (a Chapter may not share a guild-id with a Parent
|
||||
// Guild).
|
||||
seenGuild := make(map[string]bool, len(gs.Guilds)+len(gs.Chapters))
|
||||
for _, g := range gs.Guilds {
|
||||
if g.GuildID == "" {
|
||||
return fmt.Errorf("guild: empty guild-id")
|
||||
@@ -99,6 +286,32 @@ func ValidateGenesis(bz json.RawMessage) error {
|
||||
return fmt.Errorf("guild: duplicate guild-id %q", g.GuildID)
|
||||
}
|
||||
seenGuild[g.GuildID] = true
|
||||
// A Guild in the Guilds slice with IsChapter=true is rejected (a
|
||||
// Chapter must live in the Chapters slice — the split is for genesis
|
||||
// validation clarity).
|
||||
if g.IsChapter {
|
||||
return fmt.Errorf("guild: Guild %q has IsChapter=true but is in the Guilds slice (move to Chapters)", g.GuildID)
|
||||
}
|
||||
}
|
||||
for _, c := range gs.Chapters {
|
||||
if c.GuildID == "" {
|
||||
return fmt.Errorf("guild: empty chapter guild-id")
|
||||
}
|
||||
if seenGuild[c.GuildID] {
|
||||
return fmt.Errorf("guild: duplicate guild-id %q (Chapter)", c.GuildID)
|
||||
}
|
||||
seenGuild[c.GuildID] = true
|
||||
// REQ-053: a Chapter must have IsChapter=true + a non-empty
|
||||
// ParentGuildID referencing an existing Guild.
|
||||
if !c.IsChapter {
|
||||
return fmt.Errorf("guild: Chapter %q has IsChapter=false (Chapters slice requires IsChapter=true)", c.GuildID)
|
||||
}
|
||||
if c.ParentGuildID == "" {
|
||||
return fmt.Errorf("guild: Chapter %q has empty ParentGuildID (REQ-053)", c.GuildID)
|
||||
}
|
||||
if !seenGuild[c.ParentGuildID] {
|
||||
return fmt.Errorf("guild: Chapter %q ParentGuildID %q not found in genesis (REQ-053)", c.GuildID, c.ParentGuildID)
|
||||
}
|
||||
}
|
||||
seenPass := make(map[string]bool, len(gs.HandPasses))
|
||||
for _, p := range gs.HandPasses {
|
||||
|
||||
+324
-7
@@ -221,14 +221,16 @@ func TestDefaultParams(t *testing.T) {
|
||||
// --- Lexicon assertion (REQ-012) -------------------------------------------------
|
||||
|
||||
// TestLexiconNoBannedTermsInGuildPackage scans every non-test .go file in
|
||||
// the guild/types package directory for the 9 banned terms (case-insensitive).
|
||||
// Production files only — the test file contains the banned terms as the list
|
||||
// of things to forbid (standard lexicon-test bootstrapping pattern).
|
||||
// the x/guild module tree (types + keeper + module.go) for the 10 banned
|
||||
// terms (case-insensitive). Production files only — the test file contains
|
||||
// the banned terms as the list of things to forbid (standard lexicon-test
|
||||
// bootstrapping pattern). The scan walks x/guild/**/*.go (the spec's
|
||||
// `x/guild/**/*.go` lexicon assertion for P3).
|
||||
func TestLexiconNoBannedTermsInGuildPackage(t *testing.T) {
|
||||
pkgDir := packageDir(t, "github.com/oy/openyield/x/guild/types")
|
||||
files, err := filepath.Glob(filepath.Join(pkgDir, "*.go"))
|
||||
guildDir := packageDir(t, "github.com/oy/openyield/x/guild")
|
||||
files, err := walkGoFiles(guildDir)
|
||||
if err != nil {
|
||||
t.Fatalf("glob: %v", err)
|
||||
t.Fatalf("walk: %v", err)
|
||||
}
|
||||
prodFiles := []string{}
|
||||
for _, f := range files {
|
||||
@@ -238,7 +240,7 @@ func TestLexiconNoBannedTermsInGuildPackage(t *testing.T) {
|
||||
prodFiles = append(prodFiles, f)
|
||||
}
|
||||
if len(prodFiles) == 0 {
|
||||
t.Fatal("no production .go files found in guild/types")
|
||||
t.Fatal("no production .go files found in x/guild")
|
||||
}
|
||||
for _, f := range prodFiles {
|
||||
bz, err := os.ReadFile(f)
|
||||
@@ -251,6 +253,321 @@ func TestLexiconNoBannedTermsInGuildPackage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// walkGoFiles returns all .go files under dir (recursively).
|
||||
func walkGoFiles(dir string) ([]string, error) {
|
||||
var out []string
|
||||
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if strings.HasSuffix(path, ".go") {
|
||||
out = append(out, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// --- P3 locked-const regression (REQ-064, D-087) -------------------------------
|
||||
|
||||
// TestPierCarriesVoiceLockedConst asserts D-087: PierCarriesVoice == false
|
||||
// (FR-VOICE-6: the Pier wrapper does NOT carry Voice, regardless of
|
||||
// fiduciary role — mission-locked invariant). A regression firewall:
|
||||
// changing PierCarriesVoice to true breaks this test.
|
||||
func TestPierCarriesVoiceLockedConst(t *testing.T) {
|
||||
if types.PierCarriesVoice {
|
||||
t.Errorf("PierCarriesVoice = true, expected false (D-087 FR-VOICE-6: Pier does NOT carry Voice)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCoolingSecessionLockedConsts asserts REQ-064: the protocol-minimum
|
||||
// cooling periods for Chapter secession (21d Cover-active, 14d non-Cover).
|
||||
// A Chapter's SecessionTerms MAY specify longer but NOT shorter (the
|
||||
// CreateChapter handler rejects shorter). Regression firewall: changing
|
||||
// these consts breaks this test.
|
||||
func TestCoolingSecessionLockedConsts(t *testing.T) {
|
||||
if types.CoolingSecessionCoverActiveDays != 21 {
|
||||
t.Errorf("CoolingSecessionCoverActiveDays = %d, expected 21 (REQ-064 LOCKED)",
|
||||
types.CoolingSecessionCoverActiveDays)
|
||||
}
|
||||
if types.CoolingSecessionNonCoverDays != 14 {
|
||||
t.Errorf("CoolingSecessionNonCoverDays = %d, expected 14 (REQ-064 LOCKED)",
|
||||
types.CoolingSecessionNonCoverDays)
|
||||
}
|
||||
}
|
||||
|
||||
// --- P3 Guild struct extension (REQ-051, REQ-053) ------------------------------
|
||||
|
||||
// TestGuildP3Fields asserts the Guild struct carries the P3 extension fields
|
||||
// (CommonBondHash, PublicProfile, ParentGuildID, IsChapter,
|
||||
// SecessionTermsHash, GoodStandingLiens) — a compile-time + runtime
|
||||
// regression firewall (removing any field breaks this test).
|
||||
func TestGuildP3Fields(t *testing.T) {
|
||||
g := types.Guild{
|
||||
GuildID: "g1",
|
||||
Name: "Parent",
|
||||
FounderReach: "reach:f",
|
||||
CommonBondHash: []byte{1, 2, 3},
|
||||
PublicProfile: types.GuildPublicProfile{BondSummary: "sum", MasonCount: 7},
|
||||
ParentGuildID: "",
|
||||
IsChapter: false,
|
||||
SecessionTermsHash: nil,
|
||||
GoodStandingLiens: []types.Lien{{Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: true}},
|
||||
}
|
||||
if g.CommonBondHash == nil || len(g.CommonBondHash) != 3 {
|
||||
t.Errorf("CommonBondHash = %v, want 3 bytes", g.CommonBondHash)
|
||||
}
|
||||
if g.PublicProfile.BondSummary != "sum" || g.PublicProfile.MasonCount != 7 {
|
||||
t.Errorf("PublicProfile = %+v", g.PublicProfile)
|
||||
}
|
||||
if g.IsChapter {
|
||||
t.Errorf("IsChapter = true, want false for a Parent Guild")
|
||||
}
|
||||
if g.ParentGuildID != "" {
|
||||
t.Errorf("ParentGuildID = %q, want empty for a Parent Guild", g.ParentGuildID)
|
||||
}
|
||||
if len(g.GoodStandingLiens) != 1 || !g.GoodStandingLiens[0].SecuredAtFounding {
|
||||
t.Errorf("GoodStandingLiens = %v", g.GoodStandingLiens)
|
||||
}
|
||||
|
||||
// Chapter variant.
|
||||
c := types.Guild{
|
||||
GuildID: "c1",
|
||||
Name: "Chapter",
|
||||
FounderReach: "reach:f",
|
||||
ParentGuildID: "g1",
|
||||
IsChapter: true,
|
||||
SecessionTermsHash: []byte{9, 9, 9},
|
||||
GoodStandingLiens: []types.Lien{{Amount: 50, CreditorReachID: "reach:c2", SecuredAtFounding: true}},
|
||||
}
|
||||
if !c.IsChapter || c.ParentGuildID != "g1" {
|
||||
t.Errorf("Chapter fields: IsChapter=%v ParentGuildID=%q", c.IsChapter, c.ParentGuildID)
|
||||
}
|
||||
if len(c.SecessionTermsHash) != 3 {
|
||||
t.Errorf("SecessionTermsHash = %v, want 3 bytes", c.SecessionTermsHash)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuildPublicProfileMasonCountPrivate asserts the MasonCountPrivate bool:
|
||||
// when true, the MasonCount is NOT disclosed (the field is 0; consumers
|
||||
// check the bool).
|
||||
func TestGuildPublicProfileMasonCountPrivate(t *testing.T) {
|
||||
disclosed := types.GuildPublicProfile{BondSummary: "s", MasonCount: 42, MasonCountPrivate: false}
|
||||
if disclosed.MasonCountPrivate || disclosed.MasonCount != 42 {
|
||||
t.Errorf("disclosed profile: %+v", disclosed)
|
||||
}
|
||||
private := types.GuildPublicProfile{BondSummary: "s", MasonCount: 0, MasonCountPrivate: true}
|
||||
if !private.MasonCountPrivate {
|
||||
t.Errorf("private profile: MasonCountPrivate = false, want true")
|
||||
}
|
||||
if private.MasonCount != 0 {
|
||||
t.Errorf("private profile: MasonCount = %d, want 0 (not disclosed)", private.MasonCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLienStruct asserts the Lien struct carries the four required fields
|
||||
// (Amount, CreditorReachID, SecuredAtFounding, CoverPoolCovenantRef).
|
||||
func TestLienStruct(t *testing.T) {
|
||||
l := types.Lien{
|
||||
Amount: 1000,
|
||||
CreditorReachID: "reach:cred",
|
||||
SecuredAtFounding: true,
|
||||
CoverPoolCovenantRef: "covenant-1",
|
||||
}
|
||||
if l.Amount != 1000 || l.CreditorReachID != "reach:cred" ||
|
||||
!l.SecuredAtFounding || l.CoverPoolCovenantRef != "covenant-1" {
|
||||
t.Errorf("Lien fields: %+v", l)
|
||||
}
|
||||
// A lien with no Cover Pool covenant backing (empty ref) is valid.
|
||||
l2 := types.Lien{Amount: 500, CreditorReachID: "reach:c", SecuredAtFounding: false}
|
||||
if l2.CoverPoolCovenantRef != "" {
|
||||
t.Errorf("Lien2 CoverPoolCovenantRef = %q, want empty", l2.CoverPoolCovenantRef)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecessionTermsStruct asserts the SecessionTerms struct + its Validate
|
||||
// method (non-zero + protocol-minimum-bounded).
|
||||
func TestSecessionTermsStruct(t *testing.T) {
|
||||
// Valid: exactly the protocol minimums.
|
||||
valid := types.SecessionTerms{
|
||||
CoolingCoverActiveDays: types.CoolingSecessionCoverActiveDays,
|
||||
CoolingNonCoverDays: types.CoolingSecessionNonCoverDays,
|
||||
LienAuditRequired: true,
|
||||
CovenantClearanceRequired: true,
|
||||
}
|
||||
if err := valid.Validate(); err != nil {
|
||||
t.Errorf("valid SecessionTerms Validate: %v", err)
|
||||
}
|
||||
// Valid: tighter than the protocol minimum (longer cooling allowed).
|
||||
tighter := types.SecessionTerms{
|
||||
CoolingCoverActiveDays: types.CoolingSecessionCoverActiveDays + 10,
|
||||
CoolingNonCoverDays: types.CoolingSecessionNonCoverDays + 5,
|
||||
}
|
||||
if err := tighter.Validate(); err != nil {
|
||||
t.Errorf("tighter SecessionTerms Validate: %v", err)
|
||||
}
|
||||
// Invalid: zero CoolingCoverActiveDays.
|
||||
if err := (types.SecessionTerms{CoolingNonCoverDays: 14}).Validate(); err == nil {
|
||||
t.Error("SecessionTerms with zero CoolingCoverActiveDays should fail Validate")
|
||||
}
|
||||
// Invalid: zero CoolingNonCoverDays.
|
||||
if err := (types.SecessionTerms{CoolingCoverActiveDays: 21}).Validate(); err == nil {
|
||||
t.Error("SecessionTerms with zero CoolingNonCoverDays should fail Validate")
|
||||
}
|
||||
// Invalid: CoolingCoverActiveDays below protocol minimum (looser).
|
||||
loose := types.SecessionTerms{
|
||||
CoolingCoverActiveDays: types.CoolingSecessionCoverActiveDays - 1,
|
||||
CoolingNonCoverDays: types.CoolingSecessionNonCoverDays,
|
||||
}
|
||||
if err := loose.Validate(); err == nil {
|
||||
t.Error("SecessionTerms with CoolingCoverActiveDays below minimum should fail Validate (Chapter may tighten but not loosen)")
|
||||
}
|
||||
// Invalid: CoolingNonCoverDays below protocol minimum (looser).
|
||||
loose2 := types.SecessionTerms{
|
||||
CoolingCoverActiveDays: types.CoolingSecessionCoverActiveDays,
|
||||
CoolingNonCoverDays: types.CoolingSecessionNonCoverDays - 1,
|
||||
}
|
||||
if err := loose2.Validate(); err == nil {
|
||||
t.Error("SecessionTerms with CoolingNonCoverDays below minimum should fail Validate (Chapter may tighten but not loosen)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHashSecessionTermsDeterministic asserts HashSecessionTerms is
|
||||
// deterministic (the same terms produce the same hash; different terms
|
||||
// produce a different hash). This is the immutability pin: the
|
||||
// SecessionTermsHash on a Chapter is the hash of its SecessionTerms JSON.
|
||||
func TestHashSecessionTermsDeterministic(t *testing.T) {
|
||||
t1 := types.SecessionTerms{CoolingCoverActiveDays: 21, CoolingNonCoverDays: 14}
|
||||
t2 := types.SecessionTerms{CoolingCoverActiveDays: 21, CoolingNonCoverDays: 14}
|
||||
if !bytesEqual(types.HashSecessionTerms(t1), types.HashSecessionTerms(t2)) {
|
||||
t.Error("HashSecessionTerms not deterministic for equal terms")
|
||||
}
|
||||
t3 := types.SecessionTerms{CoolingCoverActiveDays: 31, CoolingNonCoverDays: 14}
|
||||
if bytesEqual(types.HashSecessionTerms(t1), types.HashSecessionTerms(t3)) {
|
||||
t.Error("HashSecessionTerms collided for different terms")
|
||||
}
|
||||
}
|
||||
|
||||
// bytesEqual is a stdlib-only byte-slice equality helper (the types_test.go
|
||||
// stays stdlib-only per G-024 — no bytes import needed for this trivial
|
||||
// comparison).
|
||||
func bytesEqual(a, b []byte) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// --- P3 Params + GenesisState extension ---------------------------------------
|
||||
|
||||
// TestDefaultParamsCooling asserts DefaultParams returns the protocol-minimum
|
||||
// cooling defaults (CoolingSecessionCoverActiveDays / NonCoverDays).
|
||||
func TestDefaultParamsCooling(t *testing.T) {
|
||||
p := types.DefaultParams()
|
||||
if p.DefaultCoolingCoverActiveDays != types.CoolingSecessionCoverActiveDays {
|
||||
t.Errorf("DefaultCoolingCoverActiveDays = %d, want %d",
|
||||
p.DefaultCoolingCoverActiveDays, types.CoolingSecessionCoverActiveDays)
|
||||
}
|
||||
if p.DefaultCoolingNonCoverDays != types.CoolingSecessionNonCoverDays {
|
||||
t.Errorf("DefaultCoolingNonCoverDays = %d, want %d",
|
||||
p.DefaultCoolingNonCoverDays, types.CoolingSecessionNonCoverDays)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultGenesisStateChapters asserts DefaultGenesisState returns a
|
||||
// non-nil empty Chapters slice.
|
||||
func TestDefaultGenesisStateChapters(t *testing.T) {
|
||||
gs := types.DefaultGenesisState()
|
||||
if gs.Chapters == nil || len(gs.Chapters) != 0 {
|
||||
t.Errorf("Default Chapters should be non-nil empty slice, got %v", gs.Chapters)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsChapterMissingParent asserts REQ-053: a Chapter
|
||||
// (in the Chapters slice) with an empty ParentGuildID is REJECTED.
|
||||
func TestValidateGenesisRejectsChapterMissingParent(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Guilds: []types.Guild{{GuildID: "g1"}},
|
||||
Chapters: []types.Guild{{GuildID: "c1", IsChapter: true, ParentGuildID: ""}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject Chapter with empty ParentGuildID (REQ-053)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsChapterParentNotFound asserts REQ-053: a Chapter
|
||||
// whose ParentGuildID does not reference an existing Guild is REJECTED.
|
||||
func TestValidateGenesisRejectsChapterParentNotFound(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Chapters: []types.Guild{{GuildID: "c1", IsChapter: true, ParentGuildID: "no-such-parent"}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject Chapter with ParentGuildID not in genesis (REQ-053)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisAcceptsChapterWithParent asserts a Chapter with a
|
||||
// valid ParentGuildID (referencing an existing Guild) is accepted.
|
||||
func TestValidateGenesisAcceptsChapterWithParent(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Guilds: []types.Guild{{GuildID: "g1"}},
|
||||
Chapters: []types.Guild{{GuildID: "c1", IsChapter: true, ParentGuildID: "g1"}},
|
||||
HandPasses: []types.HandPass{{PassID: "p1", GuildID: "g1", FeeGrain: 0}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err != nil {
|
||||
t.Errorf("ValidateGenesis should accept Chapter with valid parent, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsChapterInGuildsSlice asserts a Guild in the
|
||||
// Guilds slice with IsChapter=true is REJECTED (a Chapter must live in the
|
||||
// Chapters slice — the split is for genesis validation clarity).
|
||||
func TestValidateGenesisRejectsChapterInGuildsSlice(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Guilds: []types.Guild{{GuildID: "g1", IsChapter: true}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject a Chapter in the Guilds slice (move to Chapters)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsNonChapterInChaptersSlice asserts a Guild in
|
||||
// the Chapters slice with IsChapter=false is REJECTED.
|
||||
func TestValidateGenesisRejectsNonChapterInChaptersSlice(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Chapters: []types.Guild{{GuildID: "c1", IsChapter: false, ParentGuildID: "g1"}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject a non-Chapter in the Chapters slice")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsDupChapterID asserts a duplicate guild-id across
|
||||
// the Guilds + Chapters slices is REJECTED.
|
||||
func TestValidateGenesisRejectsDupChapterID(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Guilds: []types.Guild{{GuildID: "g1"}},
|
||||
Chapters: []types.Guild{{GuildID: "g1", IsChapter: true, ParentGuildID: "g1"}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject duplicate guild-id across Guilds + Chapters")
|
||||
}
|
||||
}
|
||||
|
||||
// packageDir resolves a Go import path to its filesystem directory.
|
||||
func packageDir(t *testing.T, importPath string) string {
|
||||
t.Helper()
|
||||
|
||||
@@ -50,6 +50,40 @@ func AllStandTypes() []StandType {
|
||||
}
|
||||
}
|
||||
|
||||
// IsHousehold reports whether a StandType is a Household (REQ-057). The
|
||||
// x/guild OneTapExitStand handler (via the StandKeeper shim) consults this
|
||||
// to assert one-tap exit is Household-only. By-ID-string boundary (G-003):
|
||||
// the handler compares the stand-type string against "Household"; this
|
||||
// helper is the type-level scaffold.
|
||||
func IsHousehold(t StandType) bool { return t == StandHousehold }
|
||||
|
||||
// IsConfederation reports whether a StandType is a Confederation (REQ-058).
|
||||
// The x/guild DelegateConfederationVoice handler (via the StandKeeper shim)
|
||||
// consults this to assert the named Stand is a Confederation before
|
||||
// recording a delegation. By-ID-string boundary (G-003): the handler
|
||||
// compares the stand-type string against "Confederation"; this helper is the
|
||||
// type-level scaffold.
|
||||
func IsConfederation(t StandType) bool { return t == StandConfederation }
|
||||
|
||||
// ConfederationVoice is a Confederation Voice delegation record (REQ-058).
|
||||
// One-Stand-one-Vote: each member Stand gets exactly 1 Voice in the
|
||||
// Confederation's aggregate, regardless of size. ConfederationStandID +
|
||||
// MemberStandID reference Stands by-ID-string (G-003). DelegateReachID
|
||||
// references the Reach the member Stand's Voice is delegated to.
|
||||
// DelegatedAt is the delegation timestamp (block time).
|
||||
//
|
||||
// NOTE: the x/guild keeper owns the persisted delegation record (the
|
||||
// x/guild/types.ConfederationVoice struct is the persisted shape — same JSON
|
||||
// field names so a value of one round-trips through the other). This
|
||||
// x/stand/types struct is the type-level scaffold for the Confederation
|
||||
// Voice aggregation logic landing in a later phase.
|
||||
type ConfederationVoice struct {
|
||||
ConfederationStandID string `json:"confederation_stand_id" yaml:"confederation_stand_id"`
|
||||
MemberStandID string `json:"member_stand_id" yaml:"member_stand_id"`
|
||||
DelegateReachID string `json:"delegate_reach_id" yaml:"delegate_reach_id"`
|
||||
DelegatedAt int64 `json:"delegated_at" yaml:"delegated_at"`
|
||||
}
|
||||
|
||||
// Stand is a governed group holding a Vault (vision §11, REQ-016).
|
||||
// Modeled on Cosmos SDK x/group (a group of members with a decision policy
|
||||
// governing a Vault). admin-reach references a Reach ID (by-ID-string, G-003);
|
||||
|
||||
@@ -249,6 +249,55 @@ func TestDefaultParams(t *testing.T) {
|
||||
_ = types.DefaultParams() // no panics
|
||||
}
|
||||
|
||||
// --- P3 Household / Confederation helpers (REQ-057, REQ-058) ------------------
|
||||
|
||||
// TestIsHousehold asserts IsHousehold returns true only for StandHousehold.
|
||||
func TestIsHousehold(t *testing.T) {
|
||||
if !types.IsHousehold(types.StandHousehold) {
|
||||
t.Error("IsHousehold(Household) should be true")
|
||||
}
|
||||
for _, s := range types.AllStandTypes() {
|
||||
if s == types.StandHousehold {
|
||||
continue
|
||||
}
|
||||
if types.IsHousehold(s) {
|
||||
t.Errorf("IsHousehold(%q) should be false", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsConfederation asserts IsConfederation returns true only for
|
||||
// StandConfederation.
|
||||
func TestIsConfederation(t *testing.T) {
|
||||
if !types.IsConfederation(types.StandConfederation) {
|
||||
t.Error("IsConfederation(Confederation) should be true")
|
||||
}
|
||||
for _, s := range types.AllStandTypes() {
|
||||
if s == types.StandConfederation {
|
||||
continue
|
||||
}
|
||||
if types.IsConfederation(s) {
|
||||
t.Errorf("IsConfederation(%q) should be false", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfederationVoiceStruct asserts the ConfederationVoice struct carries
|
||||
// the four required fields (ConfederationStandID, MemberStandID,
|
||||
// DelegateReachID, DelegatedAt — REQ-058).
|
||||
func TestConfederationVoiceStruct(t *testing.T) {
|
||||
v := types.ConfederationVoice{
|
||||
ConfederationStandID: "conf-1",
|
||||
MemberStandID: "mem-1",
|
||||
DelegateReachID: "reach:delegate",
|
||||
DelegatedAt: 12345,
|
||||
}
|
||||
if v.ConfederationStandID != "conf-1" || v.MemberStandID != "mem-1" ||
|
||||
v.DelegateReachID != "reach:delegate" || v.DelegatedAt != 12345 {
|
||||
t.Errorf("ConfederationVoice fields: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Lexicon assertion (REQ-012) -------------------------------------------------
|
||||
|
||||
// TestLexiconNoBannedTermsInStandPackage scans every non-test .go file in
|
||||
|
||||
Reference in New Issue
Block a user