Compare commits

..

3 Commits

Author SHA1 Message Date
cloudinit-bot be4c023340 Merge phase/03 into milestone/v0.5-bearers-runtime (P3 complete → v0.4.3)
docs-build / go test ./... (lexicon firewall + all x/* tests) (push) Has been cancelled
docs-build / mkdocs build (docs site artifact) (push) Has been cancelled
---ci---
project: oy
phase: 3
milestone: v0.5
status: complete
requirements:
  covered: [REQ-035]
  partial: []
---/ci---
2026-08-18 00:38:40 +00:00
cloudinit-bot 29c5947fa5 Merge phase/02 into milestone/v0.5-bearers-runtime (P2 complete → v0.4.2)
docs-build / go test ./... (lexicon firewall + all x/* tests) (push) Has been cancelled
docs-build / mkdocs build (docs site artifact) (push) Has been cancelled
---ci---
project: oy
phase: 2
milestone: v0.5
status: complete
requirements:
  covered: [REQ-034]
  partial: []
---/ci---
2026-08-18 00:30:44 +00:00
cloudinit-bot 68053238bd checkpoint(p1): v0.5 phase 1 complete → v0.4.1
---ci---
project: oy
phase: 1
milestone: v0.5
status: complete
---/ci---
2026-08-18 00:21:06 +00:00
18 changed files with 4438 additions and 11 deletions
+5 -2
View File
@@ -1,11 +1,14 @@
{
"phase": 1,
"stage": "execute",
"stage": "complete",
"milestone": "v0.5",
"milestone_type": "feature",
"tag_base": "v0.4.x",
"phase_role": "execution",
"project": "oy",
"attempts": 0,
"updated_at": "2026-08-18T00:50:00Z"
"updated_at": "2026-08-18T01:00:00Z",
"phase_release_tag": "v0.4.1",
"release_id": 754,
"requirements_covered": ["REQ-033"]
}
+166
View File
@@ -0,0 +1,166 @@
package keeper
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/bearers/types"
)
// keeper.go holds the store-backed Keeper for the bearers module (P2-02-01,
// REQ-034).
//
// The Keeper wraps an sdk.KVStore via a storeKey. It holds the Session
// records (by session-id) and the OYQRCode records (by qr-id). The Keeper
// also holds the expected-keeper shim (BreadKeeper for the OY-QR consume
// transfer effect). The shim is an interface (G-003 — no struct import of
// x/bread/types); the concrete x/bread keeper satisfies it structurally.
//
// State-machine ordering (vision §7, enforced in every handler):
// ValidateBasic → keeper authz → state mutation → ctx.EventManager().EmitEvent
//
// Surveillance-resistant invariant (A-522): the Keeper carries NO
// geolocation fields; the handlers emit NO geolocation in events.
// Keeper is the store-backed bearers keeper.
type Keeper struct {
cdc codec.Codec
storeKey storetypes.StoreKey
breadKeeper types.BreadKeeper
}
// NewKeeper constructs a new store-backed bearers Keeper. The BreadKeeper
// expected-keeper shim is injected (nil-able for partial tests; the
// ConsumeOYQR handler guards a nil shim and skips the transfer effect,
// still flipping the consumed flag — the A-521 state-write-first invariant
// holds regardless).
func NewKeeper(cdc codec.Codec, storeKey storetypes.StoreKey, bk types.BreadKeeper) Keeper {
return Keeper{
cdc: cdc,
storeKey: storeKey,
breadKeeper: bk,
}
}
// SetBreadKeeper sets the BreadKeeper expected-keeper shim (for
// post-construction wiring, e.g., app wiring or test setup).
func (k *Keeper) SetBreadKeeper(bk types.BreadKeeper) { k.breadKeeper = bk }
// --- Session store -----------------------------------------------------------
var sessionKeyPrefix = []byte("session/")
func sessionKey(sessionID string) []byte {
return append(sessionKeyPrefix, []byte(sessionID)...)
}
// GetSession loads a Session by session-id. Returns the session and true
// if found, or zero value + false if not.
func (k Keeper) GetSession(ctx sdk.Context, sessionID string) (types.Session, bool) {
store := ctx.KVStore(k.storeKey)
bz := store.Get(sessionKey(sessionID))
if bz == nil {
return types.Session{}, false
}
var s types.Session
if err := json.Unmarshal(bz, &s); err != nil {
return types.Session{}, false
}
return s, true
}
// SetSession persists a Session by session-id.
func (k Keeper) SetSession(ctx sdk.Context, s types.Session) {
store := ctx.KVStore(k.storeKey)
bz, err := json.Marshal(s)
if err != nil {
panic(fmt.Sprintf("bearers: marshal session %q: %v", s.SessionID, err))
}
store.Set(sessionKey(s.SessionID), bz)
}
// AllSessions returns all persisted Session records (iteration helper).
func (k Keeper) AllSessions(ctx sdk.Context) []types.Session {
store := ctx.KVStore(k.storeKey)
iterator := store.Iterator(sessionKeyPrefix, prefixEnd(sessionKeyPrefix))
defer iterator.Close()
out := []types.Session{}
for ; iterator.Valid(); iterator.Next() {
var s types.Session
if err := json.Unmarshal(iterator.Value(), &s); err == nil {
out = append(out, s)
}
}
return out
}
// --- OYQRCode store ----------------------------------------------------------
var qrKeyPrefix = []byte("qr/")
func qrKey(qrID string) []byte {
return append(qrKeyPrefix, []byte(qrID)...)
}
// GetOYQRCode loads an OYQRCode by qr-id. Returns the QR and true if found.
func (k Keeper) GetOYQRCode(ctx sdk.Context, qrID string) (types.OYQRCode, bool) {
store := ctx.KVStore(k.storeKey)
bz := store.Get(qrKey(qrID))
if bz == nil {
return types.OYQRCode{}, false
}
var q types.OYQRCode
if err := json.Unmarshal(bz, &q); err != nil {
return types.OYQRCode{}, false
}
return q, true
}
// SetOYQRCode persists an OYQRCode by qr-id.
func (k Keeper) SetOYQRCode(ctx sdk.Context, q types.OYQRCode) {
store := ctx.KVStore(k.storeKey)
bz, err := json.Marshal(q)
if err != nil {
panic(fmt.Sprintf("bearers: marshal qr %q: %v", q.QRID, err))
}
store.Set(qrKey(q.QRID), bz)
}
// AllOYQRCodes returns all persisted OYQRCode records (iteration helper).
func (k Keeper) AllOYQRCodes(ctx sdk.Context) []types.OYQRCode {
store := ctx.KVStore(k.storeKey)
iterator := store.Iterator(qrKeyPrefix, prefixEnd(qrKeyPrefix))
defer iterator.Close()
out := []types.OYQRCode{}
for ; iterator.Valid(); iterator.Next() {
var q types.OYQRCode
if err := json.Unmarshal(iterator.Value(), &q); err == nil {
out = append(out, q)
}
}
return out
}
// 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.
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
}
+387
View File
@@ -0,0 +1,387 @@
package keeper
import (
"fmt"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/oy/openyield/x/bearers/types"
)
// msg_server.go implements the bearers module's MsgServer (G-023 ownership
// split: cosmos-engineer scaffolds the file structure + method signatures;
// mesh-engineer/backend-engineer implements the handler logic bodies). The
// MsgServer wraps the Keeper + the BreadKeeper expected-keeper shim (already
// on the Keeper).
//
// Each method returns a (*Response, error). Handler state-machine ordering
// is enforced: ValidateBasic → keeper authz → state mutation →
// ctx.EventManager().EmitEvent.
//
// Surveillance-resistant invariant (A-522): NO handler emits geolocation or
// sender physical location. The surveillance-resistant locked const on
// OYSATLink/OYLRLink is a runtime invariant — a handler that emits
// geolocation violates it. A negative simtest asserts the event set
// contains NO geolocation fields.
//
// One-shot OY-QR (A-521): the MsgConsumeOYQR handler flips consumed BEFORE
// the transfer effect (state write FIRST, then the BreadKeeper shim call).
// A replay finds consumed==true and returns an error (idempotent reject,
// NOT double-effect). The SDK store is atomic per tx — a panic in the
// transfer rolls back the whole tx, so the order is safe; the order
// documents intent and matches the ibc-go delete-before-mint convention.
// msgServer is the concrete MsgServer implementation wrapping the Keeper.
type msgServer struct {
Keeper
}
// NewMsgServerImpl returns the bearers 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("bearers: expected sdk.Context, got %T", ctx))
}
// nowUnix returns the current block time as unix seconds from the ctx.
func nowUnix(ctx sdk.Context) int64 {
return ctx.BlockTime().Unix()
}
// --- OpenSession (creates Session status=Open) -------------------------------
// OpenSession creates a new Session with status=Open. ValidateBasic is
// stateless; the handler enforces idempotency (session-id must not already
// exist).
func (s msgServer) OpenSession(ctx interface{}, msg *types.MsgOpenSession) (*types.MsgOpenSessionResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
// Idempotency: session-id must not already exist.
if _, ok := s.Keeper.GetSession(sdkCtx, msg.SessionID); ok {
return nil, fmt.Errorf("bearers: session %q already exists", msg.SessionID)
}
session := types.Session{
SessionID: msg.SessionID,
BearerType: msg.BearerType,
InitiatorReach: msg.InitiatorReach,
PeerReach: msg.PeerReach,
Status: types.SessionOpen,
Frames: []types.Frame{},
TTL: msg.TTL,
OpenedAt: nowUnix(sdkCtx),
}
s.Keeper.SetSession(sdkCtx, session)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"bearers.session_opened",
sdk.NewAttribute("session_id", msg.SessionID),
sdk.NewAttribute("bearer_type", string(msg.BearerType)),
sdk.NewAttribute("initiator_reach", msg.InitiatorReach),
sdk.NewAttribute("peer_reach", msg.PeerReach),
sdk.NewAttribute("status", string(types.SessionOpen)),
// NO geolocation (A-522 surveillance-resistant invariant).
))
return &types.MsgOpenSessionResponse{}, nil
}
// --- CloseSession (Active → Closed) ------------------------------------------
// CloseSession transitions an Active session to Closed. The handler
// enforces the stateful source-status check (must be Open or Active; an
// Open session with no frames can close directly).
func (s msgServer) CloseSession(ctx interface{}, msg *types.MsgCloseSession) (*types.MsgCloseSessionResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
session, ok := s.Keeper.GetSession(sdkCtx, msg.SessionID)
if !ok {
return nil, fmt.Errorf("bearers: session %q not found", msg.SessionID)
}
if session.IsTerminal() {
return nil, fmt.Errorf("bearers: session %q is terminal (%s), cannot close", msg.SessionID, session.Status)
}
session.Status = types.SessionClosed
session.ClosedAt = nowUnix(sdkCtx)
s.Keeper.SetSession(sdkCtx, session)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"bearers.session_closed",
sdk.NewAttribute("session_id", msg.SessionID),
sdk.NewAttribute("status", string(types.SessionClosed)),
))
return &types.MsgCloseSessionResponse{}, nil
}
// --- RevokeSession (out-of-band → Revoked) -----------------------------------
// RevokeSession transitions a session to Revoked (out-of-band termination).
// A revoked session rejects further Receive. The handler enforces the
// stateful source-status check (must not already be terminal).
func (s msgServer) RevokeSession(ctx interface{}, msg *types.MsgRevokeSession) (*types.MsgRevokeSessionResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
session, ok := s.Keeper.GetSession(sdkCtx, msg.SessionID)
if !ok {
return nil, fmt.Errorf("bearers: session %q not found", msg.SessionID)
}
if session.IsTerminal() {
return nil, fmt.Errorf("bearers: session %q is terminal (%s), cannot revoke", msg.SessionID, session.Status)
}
session.Status = types.SessionRevoked
session.ClosedAt = nowUnix(sdkCtx)
s.Keeper.SetSession(sdkCtx, session)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"bearers.session_revoked",
sdk.NewAttribute("session_id", msg.SessionID),
sdk.NewAttribute("status", string(types.SessionRevoked)),
))
return &types.MsgRevokeSessionResponse{}, nil
}
// --- SendOYSATFrame (send a frame on an Open/Active session) ------------------
// SendOYSATFrame sends a frame on an OY-SAT session. The handler enforces
// the stateful session-status check: the session must be Open or Active
// (frames on Closed/Revoked are REJECTED — the rejected-frame case).
func (s msgServer) SendOYSATFrame(ctx interface{}, msg *types.MsgSendOYSATFrame) (*types.MsgSendOYSATFrameResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
session, ok := s.Keeper.GetSession(sdkCtx, msg.SessionID)
if !ok {
return nil, fmt.Errorf("bearers: session %q not found", msg.SessionID)
}
if session.IsTerminal() {
// Rejected-frame case: a frame received on a Closed/Revoked
// session MUST be rejected (A-523 session state machine).
return nil, fmt.Errorf("bearers: session %q is terminal (%s), rejects frame", msg.SessionID, session.Status)
}
if session.IsExpired(nowUnix(sdkCtx)) {
// TTL expiry transitions the session to Closed (the handler
// enforces expiry on Send/Receive checks).
session.Status = types.SessionClosed
session.ClosedAt = nowUnix(sdkCtx)
s.Keeper.SetSession(sdkCtx, session)
return nil, fmt.Errorf("bearers: session %q expired (ttl %d), rejects frame", msg.SessionID, session.TTL)
}
frame := types.Frame{
FrameID: msg.FrameID,
SenderReach: msg.Signer,
PayloadBytes: msg.PayloadBytes,
SentAt: nowUnix(sdkCtx),
}
session.Frames = append(session.Frames, frame)
s.Keeper.SetSession(sdkCtx, session)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"bearers.frame_sent",
sdk.NewAttribute("session_id", msg.SessionID),
sdk.NewAttribute("frame_id", msg.FrameID),
sdk.NewAttribute("sender_reach", msg.Signer),
// NO geolocation (A-522 surveillance-resistant invariant).
))
return &types.MsgSendOYSATFrameResponse{}, nil
}
// --- ReceiveOYSATFrame (ack a frame; Open → Active on first ack) -------------
// ReceiveOYSATFrame acknowledges receipt of an OY-SAT frame. The handler
// transitions the session Open → Active on the first ack. The handler
// enforces the stateful session-status check: the session must be Open or
// Active (acks on Closed/Revoked are REJECTED — the rejected-frame case).
func (s msgServer) ReceiveOYSATFrame(ctx interface{}, msg *types.MsgReceiveOYSATFrame) (*types.MsgReceiveOYSATFrameResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
session, ok := s.Keeper.GetSession(sdkCtx, msg.SessionID)
if !ok {
return nil, fmt.Errorf("bearers: session %q not found", msg.SessionID)
}
if session.IsTerminal() {
// Rejected-frame case: an ack received on a Closed/Revoked
// session MUST be rejected (A-523 session state machine).
return nil, fmt.Errorf("bearers: session %q is terminal (%s), rejects ack", msg.SessionID, session.Status)
}
if session.IsExpired(nowUnix(sdkCtx)) {
session.Status = types.SessionClosed
session.ClosedAt = nowUnix(sdkCtx)
s.Keeper.SetSession(sdkCtx, session)
return nil, fmt.Errorf("bearers: session %q expired (ttl %d), rejects ack", msg.SessionID, session.TTL)
}
// Find the named frame; mark it received.
found := false
for i := range session.Frames {
if session.Frames[i].FrameID == msg.FrameID {
session.Frames[i].Received = true
found = true
break
}
}
if !found {
return nil, fmt.Errorf("bearers: frame %q not found on session %q", msg.FrameID, msg.SessionID)
}
// Open → Active on the first ack.
if session.Status == types.SessionOpen {
session.Status = types.SessionActive
}
s.Keeper.SetSession(sdkCtx, session)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"bearers.frame_received",
sdk.NewAttribute("session_id", msg.SessionID),
sdk.NewAttribute("frame_id", msg.FrameID),
sdk.NewAttribute("status", string(session.Status)),
// NO geolocation (A-522 surveillance-resistant invariant).
))
return &types.MsgReceiveOYSATFrameResponse{}, nil
}
// --- IssueOYQR (issue a one-shot OY-QR, consumed=false) ----------------------
// IssueOYQR issues a one-shot OY-QR (consumed=false). The handler enforces
// idempotency (qr-id must not already exist).
func (s msgServer) IssueOYQR(ctx interface{}, msg *types.MsgIssueOYQR) (*types.MsgIssueOYQRResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
// Idempotency: qr-id must not already exist.
if _, ok := s.Keeper.GetOYQRCode(sdkCtx, msg.QRID); ok {
return nil, fmt.Errorf("bearers: qr %q already exists", msg.QRID)
}
qr := types.OYQRCode{
QRID: msg.QRID,
PayloadBytes: msg.PayloadBytes,
Consumed: false,
IssuerReachID: msg.IssuerReachID,
AmountGrain: msg.AmountGrain,
ExpiresAt: msg.ExpiresAt,
}
s.Keeper.SetOYQRCode(sdkCtx, qr)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"bearers.qr_issued",
sdk.NewAttribute("qr_id", msg.QRID),
sdk.NewAttribute("issuer_reach", msg.IssuerReachID),
sdk.NewAttribute("amount_grain", fmt.Sprintf("%d", msg.AmountGrain)),
sdk.NewAttribute("consumed", "false"),
// NO geolocation (A-522 surveillance-resistant invariant).
))
return &types.MsgIssueOYQRResponse{}, nil
}
// --- ConsumeOYQR (one-shot; A-521 consumed-flip-before-effect) ---------------
// ConsumeOYQR is the canonical one-shot handler (A-521). The ordering is:
// 1. load QR
// 2. assert !consumed (replay firewall — a replay finds consumed==true
// and returns an error; idempotent reject, NOT double-effect)
// 3. assert expires-at > now (the QR is still valid)
// 4. FLIP consumed=true (state write FIRST — A-521)
// 5. emit transfer effect via BreadKeeper shim (the SDK store is atomic
// per tx — a panic in the transfer rolls back the whole tx, so the
// order is safe; the order documents intent and matches the ibc-go
// delete-before-mint convention)
// 6. emit event
// 7. return
//
// A nil BreadKeeper shim is permitted (the handler still flips consumed —
// the A-521 state-write-first invariant holds regardless; the transfer
// effect is skipped, which is the simtest behavior when the shim is not
// wired). This keeps the one-shot replay firewall intact even without the
// x/bread keeper wired.
func (s msgServer) ConsumeOYQR(ctx interface{}, msg *types.MsgConsumeOYQR) (*types.MsgConsumeOYQRResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
// 1. Load QR.
qr, ok := s.Keeper.GetOYQRCode(sdkCtx, msg.QRID)
if !ok {
return nil, fmt.Errorf("bearers: qr %q not found", msg.QRID)
}
// 2. Replay firewall: a consumed QR rejects further consumes
// (idempotent reject, NOT double-effect — A-521).
if qr.Consumed {
return nil, fmt.Errorf("bearers: qr %q already consumed (one-shot — A-521)", msg.QRID)
}
// 3. Expiry check: the QR must still be valid (expires-at > now).
now := nowUnix(sdkCtx)
if qr.ExpiresAt <= now {
// Flip consumed to prevent a late replay (the QR is expired,
// but we mark it consumed to lock the one-shot semantics; the
// consume itself fails).
qr.Consumed = true
s.Keeper.SetOYQRCode(sdkCtx, qr)
return nil, fmt.Errorf("bearers: qr %q expired (expires-at %d <= now %d)", msg.QRID, qr.ExpiresAt, now)
}
// 4. FLIP consumed=true (state write FIRST — A-521). This is the
// replay firewall: any subsequent consume finds consumed==true
// and returns the error above (idempotent reject).
qr.Consumed = true
s.Keeper.SetOYQRCode(sdkCtx, qr)
// 5. Emit transfer effect via BreadKeeper shim. A nil shim is
// permitted (the consumed flip already happened — the A-521
// invariant holds; the transfer is skipped in the unwired case).
var transferErr error
if s.Keeper.breadKeeper != nil {
transferErr = s.Keeper.breadKeeper.TransferGrain(qr.IssuerReachID, msg.ConsumerReachID, qr.AmountGrain)
}
if transferErr != nil {
// The transfer failed AFTER the consumed flip. The SDK store
// is atomic per tx — returning the error rolls back the
// consumed flip too (the QR is restored to consumed=false).
// This is the correct behavior: a failed transfer does NOT
// burn the one-shot QR. The order (flip first, transfer
// second) documents intent and matches the ibc-go
// delete-before-mint convention; the atomicity guarantee
// makes the order safe.
return nil, fmt.Errorf("bearers: qr %q transfer effect failed: %w", msg.QRID, transferErr)
}
// 6. Emit event.
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"bearers.qr_consumed",
sdk.NewAttribute("qr_id", msg.QRID),
sdk.NewAttribute("issuer_reach", qr.IssuerReachID),
sdk.NewAttribute("consumer_reach", msg.ConsumerReachID),
sdk.NewAttribute("amount_grain", fmt.Sprintf("%d", qr.AmountGrain)),
sdk.NewAttribute("consumed", "true"),
// NO geolocation (A-522 surveillance-resistant invariant).
))
return &types.MsgConsumeOYQRResponse{}, nil
}
File diff suppressed because it is too large Load Diff
+130
View File
@@ -0,0 +1,130 @@
package keeper
import (
"fmt"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/oy/openyield/x/bearers/types"
)
// transport.go holds the store-backed BearerTransport impl (P2-02-01,
// REQ-034, A-522). The v0.2 BearerTransport Go interface (Send, Receive,
// Status) gains a store-backed runtime impl: the keeper IS the transport
// for simtest purposes — no hardware/RF Go libraries (D-054).
//
// The transport wraps the keeper's session store. Send appends a frame to
// the session's Frames slice. Receive marks the frame received (and
// transitions the session Open → Active on first ack). Status reports
// whether the session is Open or Active (i.e., still carrying traffic).
//
// Surveillance-resistant invariant (A-522): the transport carries NO
// geolocation / sender physical location fields. The surveillance-resistant
// locked const on OYSATLink/OYLRLink is a runtime invariant — the transport
// MUST NOT emit geolocation in events. A negative simtest asserts the event
// set contains NO geolocation fields.
// StoreTransport is the store-backed BearerTransport impl. It wraps a
// Keeper + the sdk.Context (bound at construction so the BearerTransport
// interface methods can stay parameterless per the v0.2 interface contract).
// The transport operates on a single session-id (a transport instance is
// scoped to one session — the bearer is a per-session handle in the simtest
// runtime).
type StoreTransport struct {
keeper Keeper
ctx sdk.Context
sessionID string
}
// NewStoreTransport constructs a store-backed BearerTransport scoped to the
// named session. The session must already exist (Open or Active). The
// transport reads/writes the session's Frames slice via the keeper store.
func NewStoreTransport(k Keeper, ctx sdk.Context, sessionID string) *StoreTransport {
return &StoreTransport{keeper: k, ctx: ctx, sessionID: sessionID}
}
// Compile-time assertion: StoreTransport satisfies the v0.2 BearerTransport
// interface (D-029, REQ-034). The interface contract is Send/Receive/Status
// (parameterless except Send takes a payload).
var _ types.BearerTransport = (*StoreTransport)(nil)
// Send dispatches a payload via the bearer. The store-backed impl appends
// the payload as a new Frame on the session's Frames slice. Returns an
// error if the session is not found or is terminal (Closed/Revoked) — a
// terminal session rejects further Send calls.
func (t *StoreTransport) Send(payload []byte) error {
s, ok := t.keeper.GetSession(t.ctx, t.sessionID)
if !ok {
return fmt.Errorf("bearers: session %q not found", t.sessionID)
}
if s.IsTerminal() {
return fmt.Errorf("bearers: session %q is terminal (%s), rejects Send", t.sessionID, s.Status)
}
frame := types.Frame{
FrameID: fmt.Sprintf("%s-frame-%d", t.sessionID, len(s.Frames)+1),
SenderReach: s.InitiatorReach,
PayloadBytes: payload,
SentAt: t.ctx.BlockTime().Unix(),
}
s.Frames = append(s.Frames, frame)
t.keeper.SetSession(t.ctx, s)
t.ctx.EventManager().EmitEvent(sdk.NewEvent(
"bearers.frame_sent",
sdk.NewAttribute("session_id", t.sessionID),
sdk.NewAttribute("frame_id", frame.FrameID),
sdk.NewAttribute("sender_reach", frame.SenderReach),
// NO geolocation (A-522 surveillance-resistant invariant).
))
return nil
}
// Receive accepts an inbound payload from the bearer. The store-backed impl
// marks the first unreceived frame as Received and transitions the session
// Open → Active on the first ack. Returns the payload and an error if the
// bearer has no inbound (unreceived) payload or the session is terminal.
func (t *StoreTransport) Receive() ([]byte, error) {
s, ok := t.keeper.GetSession(t.ctx, t.sessionID)
if !ok {
return nil, fmt.Errorf("bearers: session %q not found", t.sessionID)
}
if s.IsTerminal() {
return nil, fmt.Errorf("bearers: session %q is terminal (%s), rejects Receive", t.sessionID, s.Status)
}
// Find the first unreceived frame.
var received *types.Frame
for i := range s.Frames {
if !s.Frames[i].Received {
s.Frames[i].Received = true
received = &s.Frames[i]
break
}
}
if received == nil {
return nil, fmt.Errorf("bearers: no inbound frame on session %q", t.sessionID)
}
// Open → Active on the first ack.
if s.Status == types.SessionOpen {
s.Status = types.SessionActive
}
t.keeper.SetSession(t.ctx, s)
t.ctx.EventManager().EmitEvent(sdk.NewEvent(
"bearers.frame_received",
sdk.NewAttribute("session_id", t.sessionID),
sdk.NewAttribute("frame_id", received.FrameID),
sdk.NewAttribute("status", string(s.Status)),
// NO geolocation (A-522 surveillance-resistant invariant).
))
return received.PayloadBytes, nil
}
// Status reports the bearer's current reachability (true = reachable). The
// store-backed impl reports true iff the session exists and is Open or
// Active (still carrying traffic). A terminal or missing session is
// unreachable.
func (t *StoreTransport) Status() bool {
s, ok := t.keeper.GetSession(t.ctx, t.sessionID)
if !ok {
return false
}
return s.Status == types.SessionOpen || s.Status == types.SessionActive
}
+79
View File
@@ -0,0 +1,79 @@
package bearers
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/bearers/keeper"
"github.com/oy/openyield/x/bearers/types"
)
// module.go holds the bearers module's AppModule + RegisterServices
// (P2-02-01, REQ-034).
//
// The AppModule wraps the 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.
// ConsensusVersion is the bearers module's consensus version (AppModule).
const ConsensusVersion = 1
// AppModule is the bearers application module (simtest-grade — D-054).
type AppModule struct {
keeper keeper.Keeper
}
// NewAppModule constructs a new bearers AppModule. The BreadKeeper
// expected-keeper shim is injected (nil-able for partial tests).
func NewAppModule(cdc codec.Codec, storeKey storetypes.StoreKey, bk types.BreadKeeper) AppModule {
k := keeper.NewKeeper(cdc, storeKey, bk)
return AppModule{keeper: k}
}
// RegisterServices registers the bearers 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 bearers 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 bearers module.
func (am AppModule) InitGenesis(ctx sdk.Context, cdc codec.JSONCodec, data json.RawMessage) {
var gs types.GenesisState
cdc.MustUnmarshalJSON(data, &gs)
for _, s := range gs.Sessions {
am.keeper.SetSession(ctx, s)
}
for _, q := range gs.QRs {
am.keeper.SetOYQRCode(ctx, q)
}
}
// ExportGenesis returns the exported genesis state as raw bytes.
func (am AppModule) ExportGenesis(ctx sdk.Context, cdc codec.JSONCodec) json.RawMessage {
sessions := am.keeper.AllSessions(ctx)
qrs := am.keeper.AllOYQRCodes(ctx)
gs := types.GenesisState{Sessions: sessions, QRs: qrs}
return cdc.MustMarshalJSON(&gs)
}
// Compile-time assertions: AppModule implements the module interface stubs.
var _ module.HasName = AppModule{}
var _ module.HasConsensusVersion = AppModule{}
+36
View File
@@ -0,0 +1,36 @@
package types
// expected_keepers.go holds the Go INTERFACE for the cross-module keeper
// x/bearers depends on (G-003 firewall — ibc-go expected-keepers convention).
//
// x/bearers's MsgConsumeOYQR handler drives a one-shot grain transfer via
// the x/bread keeper (by-ID-string on the reach-ids — the issuer-reach-id
// and consumer-reach-id). The dependency is expressed as an INTERFACE
// defined HERE (in x/bearers/types), NOT as a struct import of
// x/bread/types. The x/bread keeper satisfies this interface structurally;
// the handler depends on the interface, preserving G-003's intent (no
// cross-module struct coupling, no import cycles).
//
// Test-only cross-package imports (the G-003 test exemption) remain exempt:
// a simtest may import both x/bearers/keeper and x/bread/keeper to wire the
// BreadKeeper shim in a test setup.
// BreadKeeper is the expected-keeper interface for x/bread (G-003). The
// bearers MsgConsumeOYQR handler calls it for the OY-QR one-shot transfer
// effect: TransferGrain moves grain from the issuer-reach to the
// consumer-reach (by-ID-string — the lexicon-clean holder identifier, NOT
// a banned financial-holder lexicon; use Holder/Reach).
//
// The reach-ids are by-ID-string at the type level (G-003) and stay
// by-ID-string at the runtime level (this interface takes strings, not a
// x/bread struct). No struct import of x/bread/types.
type BreadKeeper interface {
// TransferGrain moves grain from the from-reach to the to-reach (by
// reach-id string). Returns an error if the transfer fails (e.g.,
// insufficient grain, unknown reach-id). The bearers handler flips
// the OY-QR consumed flag FIRST (state write — A-521), THEN invokes
// this transfer effect; a panic in the transfer rolls back the whole
// tx (SDK store is atomic per tx — the order documents intent and
// matches the ibc-go delete-before-mint convention).
TransferGrain(fromReach, toReach string, amount int64) error
}
+473
View File
@@ -0,0 +1,473 @@
package types
import (
"fmt"
sdk "github.com/cosmos/cosmos-sdk/types"
)
// msg_bearer.go holds the bearers module's Msg* types implementing sdk.Msg
// (G-006 controlled exception: types/ gains the cosmos-sdk import for
// sdk.Msg — D-055; the invariant/lexicon tests in *_test.go stay stdlib-only
// per G-024, isolated from this msg_*.go file). Each Msg carries a
// ValidateBasic (stateless) and GetSigners.
//
// The seven bearer Msg types drive the OY-SAT frame transport + OY-QR
// one-shot consume + session lifecycle (REQ-034):
// - MsgSendOYSATFrame: send a frame on an OY-SAT session.
// - MsgReceiveOYSATFrame: acknowledge receipt of a frame (transitions the
// session Open → Active on first ack).
// - MsgIssueOYQR: issue a one-shot OY-QR (consumed=false).
// - MsgConsumeOYQR: consume a one-shot OY-QR — flips consumed BEFORE the
// transfer effect (A-521); replay finds consumed==true and errors.
// - MsgOpenSession: open a new session (status=Open).
// - MsgCloseSession: close a session (Active → Closed).
// - MsgRevokeSession: revoke a session (out-of-band → Revoked).
//
// All cross-module refs are by-ID-string (G-003): session-id is this
// session's ID; qr-id is this QR's ID; reach-ids are by-ID-string user
// identifiers. GetSigners returns the signer reach-ids encoded as
// sdk.AccAddress bytes. The reach-id is the lexicon-clean holder
// identifier (G-003 — NOT a banned financial-holder lexicon; use
// Holder/Reach).
//
// Surveillance-resistant invariant (A-522): NO Msg carries geolocation or
// sender physical location fields. The handler MUST NOT emit geolocation
// in events. A negative simtest asserts the event set contains NO
// geolocation fields.
// --- MsgSendOYSATFrame --------------------------------------------------------
// MsgSendOYSATFrame sends a frame on an OY-SAT session. ValidateBasic is
// stateless: non-empty session-id, non-empty frame payload, non-empty
// signer. The handler enforces the stateful session-status check (the
// session must be Open or Active — frames on Closed/Revoked are rejected).
type MsgSendOYSATFrame struct {
SessionID string `json:"session_id" yaml:"session_id"`
FrameID string `json:"frame_id" yaml:"frame_id"`
PayloadBytes []byte `json:"payload_bytes" yaml:"payload_bytes"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message (sdk.Msg = proto.Message).
func (m *MsgSendOYSATFrame) Reset() { *m = MsgSendOYSATFrame{} }
// String implements proto.Message.
func (m *MsgSendOYSATFrame) String() string {
return fmt.Sprintf("MsgSendOYSATFrame{SessionID:%s FrameID:%s Signer:%s}",
m.SessionID, m.FrameID, m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgSendOYSATFrame) ProtoMessage() {}
// ValidateBasic is the stateless validation: non-empty session-id, non-empty
// frame payload, non-empty signer.
func (m *MsgSendOYSATFrame) ValidateBasic() error {
if m.SessionID == "" {
return fmt.Errorf("bearers: empty session-id")
}
if len(m.PayloadBytes) == 0 {
return fmt.Errorf("bearers: empty frame payload")
}
if m.Signer == "" {
return fmt.Errorf("bearers: empty signer")
}
return nil
}
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
func (m *MsgSendOYSATFrame) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{[]byte(m.Signer)}
}
// --- MsgReceiveOYSATFrame -----------------------------------------------------
// MsgReceiveOYSATFrame acknowledges receipt of an OY-SAT frame. The handler
// transitions the session Open → Active on the first ack. ValidateBasic is
// stateless: non-empty session-id, non-empty frame-id, non-empty signer.
type MsgReceiveOYSATFrame struct {
SessionID string `json:"session_id" yaml:"session_id"`
FrameID string `json:"frame_id" yaml:"frame_id"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message.
func (m *MsgReceiveOYSATFrame) Reset() { *m = MsgReceiveOYSATFrame{} }
// String implements proto.Message.
func (m *MsgReceiveOYSATFrame) String() string {
return fmt.Sprintf("MsgReceiveOYSATFrame{SessionID:%s FrameID:%s Signer:%s}",
m.SessionID, m.FrameID, m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgReceiveOYSATFrame) ProtoMessage() {}
// ValidateBasic is the stateless validation: non-empty session-id, non-empty
// frame-id, non-empty signer.
func (m *MsgReceiveOYSATFrame) ValidateBasic() error {
if m.SessionID == "" {
return fmt.Errorf("bearers: empty session-id")
}
if m.FrameID == "" {
return fmt.Errorf("bearers: empty frame-id")
}
if m.Signer == "" {
return fmt.Errorf("bearers: empty signer")
}
return nil
}
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
func (m *MsgReceiveOYSATFrame) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{[]byte(m.Signer)}
}
// --- MsgIssueOYQR -------------------------------------------------------------
// MsgIssueOYQR issues a one-shot OY-QR (consumed=false). ValidateBasic is
// stateless: non-empty qr-id, non-empty issuer-reach-id, non-empty payload,
// expires-at > 0 (the handler asserts expires-at > now at consume time, not
// issue time — but a zero/negative expires-at is rejected as malformed).
type MsgIssueOYQR struct {
QRID string `json:"qr_id" yaml:"qr_id"`
IssuerReachID string `json:"issuer_reach_id" yaml:"issuer_reach_id"`
PayloadBytes []byte `json:"payload_bytes" yaml:"payload_bytes"`
AmountGrain int64 `json:"amount_grain" yaml:"amount_grain"`
ExpiresAt int64 `json:"expires_at" yaml:"expires_at"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message.
func (m *MsgIssueOYQR) Reset() { *m = MsgIssueOYQR{} }
// String implements proto.Message.
func (m *MsgIssueOYQR) String() string {
return fmt.Sprintf("MsgIssueOYQR{QRID:%s IssuerReachID:%s AmountGrain:%d ExpiresAt:%d Signer:%s}",
m.QRID, m.IssuerReachID, m.AmountGrain, m.ExpiresAt, m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgIssueOYQR) ProtoMessage() {}
// ValidateBasic is the stateless validation: non-empty qr-id, non-empty
// issuer-reach-id, non-empty payload, amount > 0, expires-at > 0, non-empty
// signer. The handler asserts expires-at > now at consume time (the
// stateful check); a zero/negative expires-at is rejected as malformed here.
func (m *MsgIssueOYQR) ValidateBasic() error {
if m.QRID == "" {
return fmt.Errorf("bearers: empty qr-id")
}
if m.IssuerReachID == "" {
return fmt.Errorf("bearers: empty issuer-reach-id")
}
if len(m.PayloadBytes) == 0 {
return fmt.Errorf("bearers: empty qr payload")
}
if m.AmountGrain <= 0 {
return fmt.Errorf("bearers: amount-grain must be > 0")
}
if m.ExpiresAt <= 0 {
return fmt.Errorf("bearers: expires-at must be > 0")
}
if m.Signer == "" {
return fmt.Errorf("bearers: empty signer")
}
return nil
}
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
func (m *MsgIssueOYQR) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{[]byte(m.Signer)}
}
// --- MsgConsumeOYQR ----------------------------------------------------------
// MsgConsumeOYQR consumes a one-shot OY-QR. The handler is the canonical
// one-shot handler (A-521): load QR → assert !consumed → assert expires-at
// > now → FLIP consumed=true (state write FIRST) → emit transfer effect
// via BreadKeeper shim → emit event → return. A replay finds consumed==true
// and returns an error (idempotent reject, NOT double-effect).
//
// ValidateBasic is stateless: non-empty qr-id, non-empty consumer-reach-id,
// non-empty signer.
type MsgConsumeOYQR struct {
QRID string `json:"qr_id" yaml:"qr_id"`
ConsumerReachID string `json:"consumer_reach_id" yaml:"consumer_reach_id"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message.
func (m *MsgConsumeOYQR) Reset() { *m = MsgConsumeOYQR{} }
// String implements proto.Message.
func (m *MsgConsumeOYQR) String() string {
return fmt.Sprintf("MsgConsumeOYQR{QRID:%s ConsumerReachID:%s Signer:%s}",
m.QRID, m.ConsumerReachID, m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgConsumeOYQR) ProtoMessage() {}
// ValidateBasic is the stateless validation: non-empty qr-id, non-empty
// consumer-reach-id, non-empty signer.
func (m *MsgConsumeOYQR) ValidateBasic() error {
if m.QRID == "" {
return fmt.Errorf("bearers: empty qr-id")
}
if m.ConsumerReachID == "" {
return fmt.Errorf("bearers: empty consumer-reach-id")
}
if m.Signer == "" {
return fmt.Errorf("bearers: empty signer")
}
return nil
}
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
func (m *MsgConsumeOYQR) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{[]byte(m.Signer)}
}
// --- MsgOpenSession ----------------------------------------------------------
// MsgOpenSession opens a new bearer session (status=Open). ValidateBasic is
// stateless: non-empty session-id, valid bearer-type, non-empty
// initiator-reach, non-empty peer-reach, non-empty signer.
type MsgOpenSession struct {
SessionID string `json:"session_id" yaml:"session_id"`
BearerType BearerType `json:"bearer_type" yaml:"bearer_type"`
InitiatorReach string `json:"initiator_reach" yaml:"initiator_reach"`
PeerReach string `json:"peer_reach" yaml:"peer_reach"`
TTL int64 `json:"ttl" yaml:"ttl"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message.
func (m *MsgOpenSession) Reset() { *m = MsgOpenSession{} }
// String implements proto.Message.
func (m *MsgOpenSession) String() string {
return fmt.Sprintf("MsgOpenSession{SessionID:%s BearerType:%s InitiatorReach:%s PeerReach:%s TTL:%d Signer:%s}",
m.SessionID, m.BearerType, m.InitiatorReach, m.PeerReach, m.TTL, m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgOpenSession) ProtoMessage() {}
// ValidateBasic is the stateless validation: non-empty session-id, known
// bearer-type, non-empty initiator-reach, non-empty peer-reach, non-empty
// signer. ttl may be 0 (never expires).
func (m *MsgOpenSession) ValidateBasic() error {
if m.SessionID == "" {
return fmt.Errorf("bearers: empty session-id")
}
if !knownBearerType(m.BearerType) {
return fmt.Errorf("bearers: unknown bearer-type %q", m.BearerType)
}
if m.InitiatorReach == "" {
return fmt.Errorf("bearers: empty initiator-reach")
}
if m.PeerReach == "" {
return fmt.Errorf("bearers: empty peer-reach")
}
if m.Signer == "" {
return fmt.Errorf("bearers: empty signer")
}
return nil
}
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
func (m *MsgOpenSession) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{[]byte(m.Signer)}
}
// --- MsgCloseSession ---------------------------------------------------------
// MsgCloseSession closes a session (Active → Closed). ValidateBasic is
// stateless: non-empty session-id, non-empty signer.
type MsgCloseSession struct {
SessionID string `json:"session_id" yaml:"session_id"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message.
func (m *MsgCloseSession) Reset() { *m = MsgCloseSession{} }
// String implements proto.Message.
func (m *MsgCloseSession) String() string {
return fmt.Sprintf("MsgCloseSession{SessionID:%s Signer:%s}", m.SessionID, m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgCloseSession) ProtoMessage() {}
// ValidateBasic is the stateless validation: non-empty session-id and signer.
func (m *MsgCloseSession) ValidateBasic() error {
if m.SessionID == "" {
return fmt.Errorf("bearers: empty session-id")
}
if m.Signer == "" {
return fmt.Errorf("bearers: empty signer")
}
return nil
}
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
func (m *MsgCloseSession) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{[]byte(m.Signer)}
}
// --- MsgRevokeSession --------------------------------------------------------
// MsgRevokeSession revokes a session (out-of-band → Revoked). A revoked
// session rejects further Receive. ValidateBasic is stateless: non-empty
// session-id, non-empty signer.
type MsgRevokeSession struct {
SessionID string `json:"session_id" yaml:"session_id"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message.
func (m *MsgRevokeSession) Reset() { *m = MsgRevokeSession{} }
// String implements proto.Message.
func (m *MsgRevokeSession) String() string {
return fmt.Sprintf("MsgRevokeSession{SessionID:%s Signer:%s}", m.SessionID, m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgRevokeSession) ProtoMessage() {}
// ValidateBasic is the stateless validation: non-empty session-id and signer.
func (m *MsgRevokeSession) ValidateBasic() error {
if m.SessionID == "" {
return fmt.Errorf("bearers: empty session-id")
}
if m.Signer == "" {
return fmt.Errorf("bearers: empty signer")
}
return nil
}
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
func (m *MsgRevokeSession) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{[]byte(m.Signer)}
}
// --- MsgServer interface + Response types -----------------------------------
// MsgServer is the bearers module's message server interface (one method per
// Msg*). The keeper's msg_server.go implements this; module.go's
// RegisterServices wires the implementation. This is the hand-rolled
// equivalent of the protobuf-generated MsgServer interface (no codegen per
// the skeleton's zero-codegen style).
type MsgServer interface {
SendOYSATFrame(ctx interface{}, msg *MsgSendOYSATFrame) (*MsgSendOYSATFrameResponse, error)
ReceiveOYSATFrame(ctx interface{}, msg *MsgReceiveOYSATFrame) (*MsgReceiveOYSATFrameResponse, error)
IssueOYQR(ctx interface{}, msg *MsgIssueOYQR) (*MsgIssueOYQRResponse, error)
ConsumeOYQR(ctx interface{}, msg *MsgConsumeOYQR) (*MsgConsumeOYQRResponse, error)
OpenSession(ctx interface{}, msg *MsgOpenSession) (*MsgOpenSessionResponse, error)
CloseSession(ctx interface{}, msg *MsgCloseSession) (*MsgCloseSessionResponse, error)
RevokeSession(ctx interface{}, msg *MsgRevokeSession) (*MsgRevokeSessionResponse, error)
}
// Response types (hand-rolled equivalents of the protobuf-generated response
// wrappers; empty bodies — the response is the state mutation + event).
// MsgSendOYSATFrameResponse is the response to MsgSendOYSATFrame.
type MsgSendOYSATFrameResponse struct{}
// Reset implements proto.Message.
func (m *MsgSendOYSATFrameResponse) Reset() { *m = MsgSendOYSATFrameResponse{} }
// String implements proto.Message.
func (m *MsgSendOYSATFrameResponse) String() string { return "MsgSendOYSATFrameResponse{}" }
// ProtoMessage implements proto.Message.
func (*MsgSendOYSATFrameResponse) ProtoMessage() {}
// MsgReceiveOYSATFrameResponse is the response to MsgReceiveOYSATFrame.
type MsgReceiveOYSATFrameResponse struct{}
// Reset implements proto.Message.
func (m *MsgReceiveOYSATFrameResponse) Reset() { *m = MsgReceiveOYSATFrameResponse{} }
// String implements proto.Message.
func (m *MsgReceiveOYSATFrameResponse) String() string { return "MsgReceiveOYSATFrameResponse{}" }
// ProtoMessage implements proto.Message.
func (*MsgReceiveOYSATFrameResponse) ProtoMessage() {}
// MsgIssueOYQRResponse is the response to MsgIssueOYQR.
type MsgIssueOYQRResponse struct{}
// Reset implements proto.Message.
func (m *MsgIssueOYQRResponse) Reset() { *m = MsgIssueOYQRResponse{} }
// String implements proto.Message.
func (m *MsgIssueOYQRResponse) String() string { return "MsgIssueOYQRResponse{}" }
// ProtoMessage implements proto.Message.
func (*MsgIssueOYQRResponse) ProtoMessage() {}
// MsgConsumeOYQRResponse is the response to MsgConsumeOYQR.
type MsgConsumeOYQRResponse struct{}
// Reset implements proto.Message.
func (m *MsgConsumeOYQRResponse) Reset() { *m = MsgConsumeOYQRResponse{} }
// String implements proto.Message.
func (m *MsgConsumeOYQRResponse) String() string { return "MsgConsumeOYQRResponse{}" }
// ProtoMessage implements proto.Message.
func (*MsgConsumeOYQRResponse) ProtoMessage() {}
// MsgOpenSessionResponse is the response to MsgOpenSession.
type MsgOpenSessionResponse struct{}
// Reset implements proto.Message.
func (m *MsgOpenSessionResponse) Reset() { *m = MsgOpenSessionResponse{} }
// String implements proto.Message.
func (m *MsgOpenSessionResponse) String() string { return "MsgOpenSessionResponse{}" }
// ProtoMessage implements proto.Message.
func (*MsgOpenSessionResponse) ProtoMessage() {}
// MsgCloseSessionResponse is the response to MsgCloseSession.
type MsgCloseSessionResponse struct{}
// Reset implements proto.Message.
func (m *MsgCloseSessionResponse) Reset() { *m = MsgCloseSessionResponse{} }
// String implements proto.Message.
func (m *MsgCloseSessionResponse) String() string { return "MsgCloseSessionResponse{}" }
// ProtoMessage implements proto.Message.
func (*MsgCloseSessionResponse) ProtoMessage() {}
// MsgRevokeSessionResponse is the response to MsgRevokeSession.
type MsgRevokeSessionResponse struct{}
// Reset implements proto.Message.
func (m *MsgRevokeSessionResponse) Reset() { *m = MsgRevokeSessionResponse{} }
// String implements proto.Message.
func (m *MsgRevokeSessionResponse) String() string { return "MsgRevokeSessionResponse{}" }
// ProtoMessage implements proto.Message.
func (*MsgRevokeSessionResponse) ProtoMessage() {}
// --- Helpers ----------------------------------------------------------------
// knownBearerType reports whether bt is one of the six BearerType values.
func knownBearerType(bt BearerType) bool {
for _, b := range AllBearers() {
if b.Type == bt {
return true
}
}
return false
}
+121
View File
@@ -0,0 +1,121 @@
package types
// session.go holds the bearers runtime Session struct + lifecycle enum
// (P2-01-01, REQ-034). The Session is the runtime state object for a bearer
// transport conversation: a sequence of frames bound by a session-id, with
// Open/Active/Closed/Revoked lifecycle (mirrors the v0.2 Window primitive's
// lifecycle per A-523).
//
// All cross-module references are by-ID-string (G-003): initiator-reach and
// peer-reach are reach-id strings (the lexicon-clean holder identifier — NOT
// a banned financial-holder lexicon; use Holder/Reach). bearer-type is a
// BearerType enum value defined in types.go (same package — no cross-module
// import).
//
// Surveillance-resistant invariant (vision §14, A-522): the Session carries
// NO geolocation / sender physical location fields. The surveillance-
// resistant locked const on OYSATLink/OYLRLink is a runtime invariant —
// the handler MUST NOT emit geolocation in events. A negative simtest
// asserts the event set contains NO geolocation fields.
// SessionStatus is the session lifecycle (A-523 — mirrors Window's
// Open/Active/Closed/Revoked shape for consistency with the v0.2 Window
// primitive).
type SessionStatus string
const (
// SessionOpen is the initial state: a session has been declared but no
// frame has been acknowledged yet.
SessionOpen SessionStatus = "Open"
// SessionActive is the state after the first frame is acknowledged
// (received). The session is carrying traffic.
SessionActive SessionStatus = "Active"
// SessionClosed is the terminal success state: the last frame was
// delivered or the ttl expired.
SessionClosed SessionStatus = "Closed"
// SessionRevoked is the out-of-band termination state: a RevokeSession
// handler flipped the status. A revoked session rejects further Receive.
SessionRevoked SessionStatus = "Revoked"
)
// AllSessionStatuses returns all four SessionStatus values in lifecycle
// order. Locked-const test asserts exactly 4 entries.
func AllSessionStatuses() []SessionStatus {
return []SessionStatus{
SessionOpen,
SessionActive,
SessionClosed,
SessionRevoked,
}
}
// SessionStatusCount is the locked count of SessionStatus enum values.
// A regression firewall: adding/removing/renaming a status breaks this
// const's test.
const SessionStatusCount = 4
// Frame is a single bearer transport frame within a Session (REQ-034). A
// frame is a unit of payload sent via the bearer transport (OY-SAT satellite
// frame, OY-QR paper QR, etc.). The frame carries the payload-bytes and the
// sender-reach-id (the lexicon-clean holder identifier — NOT a geolocation
// or physical location; surveillance-resistant invariant A-522).
type Frame struct {
FrameID string `json:"frame_id" yaml:"frame_id"`
SenderReach string `json:"sender_reach" yaml:"sender_reach"`
PayloadBytes []byte `json:"payload_bytes" yaml:"payload_bytes"`
SentAt int64 `json:"sent_at" yaml:"sent_at"`
Received bool `json:"received" yaml:"received"`
}
// Session is the runtime state object for a bearer transport conversation
// (REQ-034, A-523). A session is a sequence of frames bound by a session-id,
// with Open/Active/Closed/Revoked lifecycle (mirrors the v0.2 Window
// primitive's lifecycle). The session is stored under the bearers keeper
// (by session-id).
//
// - session-id is this session's unique identifier.
// - bearer-type is the BearerType enum value (BearerOYSAT, BearerOYQR,
// etc.) — same package, no cross-module import.
// - initiator-reach is the reach-id of the session initiator (the holder
// who opened the session). Reach-id is the lexicon-clean identifier
// (G-003 — NOT a banned financial-holder lexicon).
// - peer-reach is the reach-id of the session peer (the other endpoint).
// - status is the SessionStatus lifecycle (Open/Active/Closed/Revoked).
// - frames is the ordered list of Frames in the session.
// - ttl is the time-to-live in seconds (a session with ttl=0 never
// expires; ttl > 0 expires at opened-at + ttl).
// - opened-at is the block time the session was opened (unix seconds).
// - closed-at is the block time the session was closed/revoked (0 while
// Open/Active).
//
// Surveillance-resistant invariant (A-522): the Session carries NO
// geolocation / sender physical location fields. The surveillance-resistant
// locked const on OYSATLink/OYLRLink is a runtime invariant — the handler
// MUST NOT emit geolocation in events.
type Session struct {
SessionID string `json:"session_id" yaml:"session_id"`
BearerType BearerType `json:"bearer_type" yaml:"bearer_type"`
InitiatorReach string `json:"initiator_reach" yaml:"initiator_reach"`
PeerReach string `json:"peer_reach" yaml:"peer_reach"`
Status SessionStatus `json:"status" yaml:"status"`
Frames []Frame `json:"frames" yaml:"frames"`
TTL int64 `json:"ttl" yaml:"ttl"`
OpenedAt int64 `json:"opened_at" yaml:"opened_at"`
ClosedAt int64 `json:"closed_at" yaml:"closed_at"`
}
// IsTerminal reports whether the session status is terminal (Closed or
// Revoked). A terminal session rejects further Receive calls.
func (s Session) IsTerminal() bool {
return s.Status == SessionClosed || s.Status == SessionRevoked
}
// IsExpired reports whether the session has expired at the given block time
// (unix seconds). A session with TTL=0 never expires. Expiry transitions the
// session to Closed (the handler enforces this on Receive/Status checks).
func (s Session) IsExpired(now int64) bool {
if s.TTL == 0 {
return false
}
return now >= s.OpenedAt+s.TTL
}
+84 -9
View File
@@ -1,6 +1,9 @@
package types
import "encoding/json"
import (
"encoding/json"
"fmt"
)
const (
ModuleName = "bearers"
@@ -138,18 +141,32 @@ func NewOYSATLink(satelliteID string, rangeMeters int32) OYSATLink {
// "0 range"); a QR encodes a signed transfer that the recipient scans and
// submits. The struct mirrors the v0.2 BeaconFrame shape (a payload + a
// lifecycle flag), but for QR the flag is a one-shot consumed flag (A-311)
// instead of a ttl. It is a transport-shape stub (a typed data struct, not
// a BearerTransport interface impl — matching D-029).
// instead of a ttl. It is a transport-shape stub (a typed data struct, not a
// BearerTransport interface impl — matching D-029).
//
// - qr-id is the QR code identifier.
// - payload-bytes is the signed transfer payload encoded in the QR.
// - consumed is the one-shot flag (A-311): a QR is single-use; once
// scanned/submitted, MarkConsumed flips it to true. Double-consume is
// idempotent (a no-op, not an error).
// - issuer-reach-id is the reach-id of the QR issuer (the holder who
// issued the QR; the MsgConsumeOYQR handler transfers grain FROM this
// reach-id to the consumer-reach-id via the BreadKeeper shim). Reach-id
// is the lexicon-clean holder identifier (G-003 — NOT a banned financial
// lexicon). Added in v0.5 P2 to support the MsgConsumeOYQR transfer
// effect (REQ-034, A-521).
// - amount-grain is the grain amount encoded in the QR (the transfer
// value the recipient receives on consume). Added in v0.5 P2.
// - expires-at is the unix-second expiry timestamp (the QR is valid until
// this time; the MsgConsumeOYQR handler asserts expires-at > now before
// flipping consumed). Added in v0.5 P2.
type OYQRCode struct {
QRID string `json:"qr_id" yaml:"qr_id"`
PayloadBytes []byte `json:"payload_bytes" yaml:"payload_bytes"`
Consumed bool `json:"consumed" yaml:"consumed"`
QRID string `json:"qr_id" yaml:"qr_id"`
PayloadBytes []byte `json:"payload_bytes" yaml:"payload_bytes"`
Consumed bool `json:"consumed" yaml:"consumed"`
IssuerReachID string `json:"issuer_reach_id" yaml:"issuer_reach_id"`
AmountGrain int64 `json:"amount_grain" yaml:"amount_grain"`
ExpiresAt int64 `json:"expires_at" yaml:"expires_at"`
}
// MarkConsumed marks the QR as consumed (one-shot, A-311). Idempotent:
@@ -164,12 +181,70 @@ type Params struct{}
func DefaultParams() Params { return Params{} }
// GenesisState defines the bearers module genesis state. v0.1 had only
// Params; v0.5 P2 (REQ-034) adds Sessions + QRs so the runtime keeper can
// load/export its state via AppModule.InitGenesis/ExportGenesis. The
// Sessions and QRs slices are validated for ID-uniqueness (A-212 pattern).
type GenesisState struct {
Params Params `json:"params" yaml:"params"`
Params Params `json:"params" yaml:"params"`
Sessions []Session `json:"sessions" yaml:"sessions"`
QRs []OYQRCode `json:"qrs" yaml:"qrs"`
}
func DefaultGenesisState() *GenesisState {
return &GenesisState{Params: DefaultParams()}
return &GenesisState{
Params: DefaultParams(),
Sessions: []Session{},
QRs: []OYQRCode{},
}
}
func ValidateGenesis(bz json.RawMessage) error { return nil }
// Reset implements proto.Message (codec.JSONCodec.MustMarshalJSON /
// MustUnmarshalJSON require proto.Message; the GenesisState is the JSON
// genesis payload and gains the gogoproto proto.Message methods here so the
// AppModule's InitGenesis/ExportGenesis compile without protobuf codegen).
func (m *GenesisState) Reset() { *m = GenesisState{} }
// String implements proto.Message.
func (m *GenesisState) String() string {
return fmt.Sprintf("GenesisState{Sessions:%d QRs:%d}", len(m.Sessions), len(m.QRs))
}
// ProtoMessage implements proto.Message.
func (*GenesisState) ProtoMessage() {}
// ValidateGenesis performs ID-uniqueness checks (A-212 upgrade from v0.1
// no-op): rejects duplicate session-ids and duplicate qr-ids. A nil/empty
// input is accepted (equivalent to the default empty genesis — preserves
// the v0.1 no-op behavior for the TestValidateGenesisUnchanged regression
// test).
func ValidateGenesis(bz json.RawMessage) error {
if len(bz) == 0 {
return nil
}
var gs GenesisState
if err := json.Unmarshal(bz, &gs); err != nil {
return fmt.Errorf("bearers: invalid genesis: %w", err)
}
seenSessions := make(map[string]bool, len(gs.Sessions))
for i, s := range gs.Sessions {
if s.SessionID == "" {
return fmt.Errorf("bearers: session [%d]: empty session-id", i)
}
if seenSessions[s.SessionID] {
return fmt.Errorf("bearers: duplicate session-id %q", s.SessionID)
}
seenSessions[s.SessionID] = true
}
seenQRs := make(map[string]bool, len(gs.QRs))
for i, q := range gs.QRs {
if q.QRID == "" {
return fmt.Errorf("bearers: qr [%d]: empty qr-id", i)
}
if seenQRs[q.QRID] {
return fmt.Errorf("bearers: duplicate qr-id %q", q.QRID)
}
seenQRs[q.QRID] = true
}
return nil
}
+137
View File
@@ -0,0 +1,137 @@
package keeper
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/partner/types"
)
// keeper.go holds the store-backed Keeper for the partner module's
// Anchor credential runtime (P3-02-01, REQ-035).
//
// The Keeper wraps an sdk.KVStore via a storeKey. It holds the
// AnchorCredential records (by anchor-id). The v0.3 in-memory registry
// Keeper stub (types.Keeper, x/partner/types/types.go) is RETAINED for
// the Partner registry (non-Anchor partners — the v0.3 skeleton); the
// v0.5 runtime promotes ONLY the Anchor credential lifecycle to a
// store-backed keeper (D-054 simtest grade). The Partner registry stays
// on the v0.3 in-memory stub (non-Anchor partner tiers are not promoted
// in v0.5 — out of scope; only the Anchor credential lifecycle is).
//
// The Keeper also holds the two expected-keeper shims (WatcherKeeper for
// 6-of-9 quorum authz on issue/revoke; HubKeeper for custody-provider-id
// validity on onboard — the P3→P4 hub dep edge broken by the interface
// shim per G-003 / ARCHITECTURE.md v0.5). The shims are interfaces
// (G-003 — no struct import of x/watcher/types or x/hub/types); the
// concrete keepers satisfy them structurally.
//
// State-machine ordering (vision §7, enforced in every handler):
// ValidateBasic → keeper authz → state mutation → ctx.EventManager().EmitEvent
// Keeper is the store-backed partner Anchor-credential keeper.
type Keeper struct {
cdc codec.Codec
storeKey storetypes.StoreKey
watcherKeeper types.WatcherKeeper
hubKeeper types.HubKeeper
}
// NewKeeper constructs a new store-backed partner Anchor-credential
// Keeper. The WatcherKeeper and HubKeeper expected-keeper shims are
// injected (nil-able for partial tests; the handlers guard nil shims
// and skip the corresponding authz/validity check, still mutating state
// — the simtest wiring document this). The HubKeeper shim is the P3→P4
// hub dep edge: in P3 simtest it is wired to a stub (G-003 test
// exemption); the real hub keeper is wired in P4.
func NewKeeper(cdc codec.Codec, storeKey storetypes.StoreKey, wk types.WatcherKeeper, hk types.HubKeeper) Keeper {
return Keeper{
cdc: cdc,
storeKey: storeKey,
watcherKeeper: wk,
hubKeeper: hk,
}
}
// SetWatcherKeeper sets the WatcherKeeper expected-keeper shim (for
// post-construction wiring, e.g., app wiring or test setup).
func (k *Keeper) SetWatcherKeeper(wk types.WatcherKeeper) { k.watcherKeeper = wk }
// SetHubKeeper sets the HubKeeper expected-keeper shim (for
// post-construction wiring, e.g., app wiring or test setup). This is
// the P3→P4 hub dep edge: P4 wires the real hub keeper via this setter
// or via NewKeeper.
func (k *Keeper) SetHubKeeper(hk types.HubKeeper) { k.hubKeeper = hk }
// --- Anchor credential store -------------------------------------------------
var anchorKeyPrefix = []byte("anchor/")
func anchorKey(anchorID string) []byte {
return append(anchorKeyPrefix, []byte(anchorID)...)
}
// GetAnchorCredential loads an AnchorCredential by anchor-id. Returns the
// credential and true if found, or zero value + false if not.
func (k Keeper) GetAnchorCredential(ctx sdk.Context, anchorID string) (types.AnchorCredential, bool) {
store := ctx.KVStore(k.storeKey)
bz := store.Get(anchorKey(anchorID))
if bz == nil {
return types.AnchorCredential{}, false
}
var c types.AnchorCredential
if err := json.Unmarshal(bz, &c); err != nil {
return types.AnchorCredential{}, false
}
return c, true
}
// SetAnchorCredential persists an AnchorCredential by anchor-id.
func (k Keeper) SetAnchorCredential(ctx sdk.Context, c types.AnchorCredential) {
store := ctx.KVStore(k.storeKey)
bz, err := json.Marshal(c)
if err != nil {
panic(fmt.Sprintf("partner: marshal anchor credential %q: %v", c.AnchorID, err))
}
store.Set(anchorKey(c.AnchorID), bz)
}
// AllAnchorCredentials returns all persisted AnchorCredential records
// (iteration helper).
func (k Keeper) AllAnchorCredentials(ctx sdk.Context) []types.AnchorCredential {
store := ctx.KVStore(k.storeKey)
iterator := store.Iterator(anchorKeyPrefix, prefixEnd(anchorKeyPrefix))
defer iterator.Close()
out := []types.AnchorCredential{}
for ; iterator.Valid(); iterator.Next() {
var c types.AnchorCredential
if err := json.Unmarshal(iterator.Value(), &c); err == nil {
out = append(out, c)
}
}
return out
}
// 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.
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
}
+273
View File
@@ -0,0 +1,273 @@
package keeper
import (
"fmt"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/oy/openyield/x/partner/types"
)
// msg_server.go implements the partner module's Anchor-credential MsgServer
// (P3-02-01, REQ-035; G-023 ownership split: cosmos-engineer scaffolds the
// file structure + method signatures; backend-engineer implements the
// handler logic bodies). The MsgServer wraps the Keeper + the WatcherKeeper
// and HubKeeper expected-keeper shims (already on the Keeper).
//
// Each method returns a (*Response, error). Handler state-machine ordering
// is enforced: ValidateBasic → keeper authz → state mutation →
// ctx.EventManager().EmitEvent.
//
// Lifecycle (REQ-035, vision §13):
// - IssueAnchorCredential → Pending (Watcher 6-of-9 quorum authz)
// - OnboardAnchor → Pending → Onboarded (HubKeeper custody-
// provider-id validity check)
// - SuspendAnchorCredential → Onboarded → Suspended
// - RevokeAnchorCredential → any → Revoked (Watcher 6-of-9 quorum authz)
//
// Invalid transitions are REJECTED (the simtest covers each). Revoked is
// terminal (idempotent reject on a second Revoke — NOT double-effect).
//
// Nil-shim behavior (simtest wiring): a nil WatcherKeeper shim skips the
// Watcher quorum authz (the handler still mutates state — the simtest
// documents the wiring contract). A nil HubKeeper shim skips the
// custody-service-exists check (the OnboardAnchor still transitions — the
// simtest documents the wiring contract). The P3→P4 hub dep edge: in P3
// simtest, the HubKeeper shim is wired to a stub (G-003 test exemption);
// the real hub keeper is wired in P4.
// msgServer is the concrete MsgServer implementation wrapping the Keeper.
type msgServer struct {
Keeper
}
// NewMsgServerImpl returns the partner 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("partner: expected sdk.Context, got %T", ctx))
}
// nowUnix returns the current block time as unix seconds from the ctx.
func nowUnix(ctx sdk.Context) int64 {
return ctx.BlockTime().Unix()
}
// issuePayload is the byte payload the Watcher quorum signs over for an
// IssueAnchorCredential. It binds the anchor-id + credential-uri + issuer
// to the quorum signature (a quorum signature over a different payload
// does not authorize this issuance).
func issuePayload(msg *types.MsgIssueAnchorCredential) []byte {
return []byte(fmt.Sprintf("partner.issue:%s:%s:%s", msg.AnchorID, msg.CredentialURI, msg.Issuer))
}
// revokePayload is the byte payload the Watcher quorum signs over for a
// RevokeAnchorCredential. It binds the anchor-id + signer to the quorum
// signature (a quorum signature over a different payload does not
// authorize this revocation).
func revokePayload(msg *types.MsgRevokeAnchorCredential) []byte {
return []byte(fmt.Sprintf("partner.revoke:%s:%s", msg.AnchorID, msg.Signer))
}
// --- IssueAnchorCredential (creates credential status=Pending) ---------------
// IssueAnchorCredential issues an Anchor credential (status=Pending).
// The handler enforces:
// 1. ValidateBasic (stateless).
// 2. Idempotency: anchor-id must not already exist.
// 3. Watcher 6-of-9 quorum authz (REQ-004) via the WatcherKeeper shim
// on the issuance payload. A nil shim skips this check (simtest
// wiring); a non-nil shim that returns false REJECTS the issuance.
//
// On success the credential is persisted with status=Pending and an
// event is emitted.
func (s msgServer) IssueAnchorCredential(ctx interface{}, msg *types.MsgIssueAnchorCredential) (*types.MsgIssueAnchorCredentialResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
// Idempotency: anchor-id must not already exist.
if _, ok := s.Keeper.GetAnchorCredential(sdkCtx, msg.AnchorID); ok {
return nil, fmt.Errorf("partner: anchor credential %q already exists", msg.AnchorID)
}
// Watcher 6-of-9 quorum authz (REQ-004). A nil shim skips the authz
// (simtest wiring); a non-nil shim that returns false REJECTS.
if s.Keeper.watcherKeeper != nil {
if !s.Keeper.watcherKeeper.IsQuorumSigned(msg.WatcherQuorumID, issuePayload(msg)) {
return nil, fmt.Errorf("partner: watcher quorum %q did not authorize issuance of anchor %q (REQ-004 6-of-9)", msg.WatcherQuorumID, msg.AnchorID)
}
}
cred := types.AnchorCredential{
AnchorID: msg.AnchorID,
CustodyProviderID: "", // empty — set on OnboardAnchor
CredentialURI: msg.CredentialURI,
AttestationCount: 0,
Status: types.AnchorPending,
}
s.Keeper.SetAnchorCredential(sdkCtx, cred)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"partner.anchor_credential_issued",
sdk.NewAttribute("anchor_id", msg.AnchorID),
sdk.NewAttribute("credential_uri", msg.CredentialURI),
sdk.NewAttribute("watcher_quorum_id", msg.WatcherQuorumID),
sdk.NewAttribute("issuer", msg.Issuer),
sdk.NewAttribute("status", string(types.AnchorPending)),
))
return &types.MsgIssueAnchorCredentialResponse{}, nil
}
// --- OnboardAnchor (Pending → Onboarded) -------------------------------------
// OnboardAnchor transitions an Anchor credential Pending → Onboarded.
// The handler enforces:
// 1. ValidateBasic (stateless).
// 2. The credential must exist.
// 3. The source status must be Pending (ValidAnchorTransition(Pending,
// Onboarded) — the lifecycle gate).
// 4. The custody-provider-id must reference a LIVE Hub custody service
// via the HubKeeper shim (the P3→P4 hub dep edge). A nil shim skips
// this check (simtest wiring); a non-nil shim that returns false
// REJECTS the onboarding (the credential stays Pending).
// 5. The custody-provider-id on the credential is set from the msg
// (the msg carries the custody-provider-id to bind to).
//
// On success the credential's CustodyProviderID is set, the status is
// transitioned to Onboarded, and an event is emitted.
func (s msgServer) OnboardAnchor(ctx interface{}, msg *types.MsgOnboardAnchor) (*types.MsgOnboardAnchorResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
cred, ok := s.Keeper.GetAnchorCredential(sdkCtx, msg.AnchorID)
if !ok {
return nil, fmt.Errorf("partner: anchor credential %q not found", msg.AnchorID)
}
// Lifecycle gate: Pending → Onboarded is the only valid transition
// into Onboarded.
if !types.ValidAnchorTransition(cred.Status, types.AnchorOnboarded) {
return nil, fmt.Errorf("partner: anchor %q status %q cannot transition to Onboarded (REQ-035 lifecycle)", msg.AnchorID, cred.Status)
}
// HubKeeper custody-service-exists check (the P3→P4 hub dep edge).
// A nil shim skips the check (simtest wiring); a non-nil shim that
// returns false REJECTS the onboarding (the credential stays Pending).
if s.Keeper.hubKeeper != nil {
if !s.Keeper.hubKeeper.CustodyServiceExists(msg.CustodyProviderID) {
return nil, fmt.Errorf("partner: custody service %q does not exist (OnboardAnchor rejected — anchor %q stays Pending)", msg.CustodyProviderID, msg.AnchorID)
}
}
// Transition: set custody-provider-id + status=Onboarded.
cred.CustodyProviderID = msg.CustodyProviderID
cred.Status = types.AnchorOnboarded
s.Keeper.SetAnchorCredential(sdkCtx, cred)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"partner.anchor_onboarded",
sdk.NewAttribute("anchor_id", msg.AnchorID),
sdk.NewAttribute("custody_provider_id", msg.CustodyProviderID),
sdk.NewAttribute("status", string(types.AnchorOnboarded)),
))
return &types.MsgOnboardAnchorResponse{}, nil
}
// --- SuspendAnchorCredential (Onboarded → Suspended) ------------------------
// SuspendAnchorCredential transitions an Anchor credential
// Onboarded → Suspended. The handler enforces:
// 1. ValidateBasic (stateless).
// 2. The credential must exist.
// 3. The source status must be Onboarded (ValidAnchorTransition(Onboarded,
// Suspended) — the lifecycle gate).
//
// On success the status is transitioned to Suspended and an event is
// emitted.
func (s msgServer) SuspendAnchorCredential(ctx interface{}, msg *types.MsgSuspendAnchorCredential) (*types.MsgSuspendAnchorCredentialResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
cred, ok := s.Keeper.GetAnchorCredential(sdkCtx, msg.AnchorID)
if !ok {
return nil, fmt.Errorf("partner: anchor credential %q not found", msg.AnchorID)
}
if !types.ValidAnchorTransition(cred.Status, types.AnchorSuspended) {
return nil, fmt.Errorf("partner: anchor %q status %q cannot transition to Suspended (REQ-035 lifecycle)", msg.AnchorID, cred.Status)
}
cred.Status = types.AnchorSuspended
s.Keeper.SetAnchorCredential(sdkCtx, cred)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"partner.anchor_credential_suspended",
sdk.NewAttribute("anchor_id", msg.AnchorID),
sdk.NewAttribute("status", string(types.AnchorSuspended)),
))
return &types.MsgSuspendAnchorCredentialResponse{}, nil
}
// --- RevokeAnchorCredential (any → Revoked, Watcher quorum authz) ------------
// RevokeAnchorCredential transitions an Anchor credential to Revoked
// (terminal). The handler enforces:
// 1. ValidateBasic (stateless).
// 2. The credential must exist.
// 3. The credential must not already be Revoked (idempotent reject — a
// second Revoke returns an error; NOT double-effect).
// 4. Watcher 6-of-9 quorum authz (REQ-004) via the WatcherKeeper shim
// on the revocation payload. A nil shim skips this check (simtest
// wiring); a non-nil shim that returns false REJECTS the revocation.
//
// On success the status is transitioned to Revoked (terminal) and an
// event is emitted.
func (s msgServer) RevokeAnchorCredential(ctx interface{}, msg *types.MsgRevokeAnchorCredential) (*types.MsgRevokeAnchorCredentialResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
cred, ok := s.Keeper.GetAnchorCredential(sdkCtx, msg.AnchorID)
if !ok {
return nil, fmt.Errorf("partner: anchor credential %q not found", msg.AnchorID)
}
// Idempotent reject: a Revoked credential cannot be re-revoked.
if cred.Status == types.AnchorRevoked {
return nil, fmt.Errorf("partner: anchor %q already revoked (idempotent reject — no double-effect)", msg.AnchorID)
}
// Watcher 6-of-9 quorum authz (REQ-004). A nil shim skips the authz
// (simtest wiring); a non-nil shim that returns false REJECTS.
if s.Keeper.watcherKeeper != nil {
if !s.Keeper.watcherKeeper.IsQuorumSigned(msg.WatcherQuorumID, revokePayload(msg)) {
return nil, fmt.Errorf("partner: watcher quorum %q did not authorize revocation of anchor %q (REQ-004 6-of-9)", msg.WatcherQuorumID, msg.AnchorID)
}
}
cred.Status = types.AnchorRevoked
s.Keeper.SetAnchorCredential(sdkCtx, cred)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"partner.anchor_credential_revoked",
sdk.NewAttribute("anchor_id", msg.AnchorID),
sdk.NewAttribute("watcher_quorum_id", msg.WatcherQuorumID),
sdk.NewAttribute("status", string(types.AnchorRevoked)),
))
return &types.MsgRevokeAnchorCredentialResponse{}, nil
}
+885
View File
@@ -0,0 +1,885 @@
package keeper_test
// msg_server_simtest_test.go is the x/partner keeper simtest (P3-03-01,
// REQ-035).
//
// D-054: simtest-grade — in-memory sdk.Context + dbm in-memory store, no
// real hub/watcher keepers. The simtest wires the expected-keeper shims
// (WatcherKeeper, HubKeeper) to in-test stubs (G-003 test exemption: the
// test imports x/partner/keeper + defines stub types that satisfy the
// interfaces; no production struct imports across x/<module>/types).
//
// Coverage (REQ-035 lifecycle Pending → Onboarded → Suspended → Revoked):
// - Full success lifecycle: Issue (Pending) → Onboard → Suspend → Revoke.
// - Invalid transitions REJECTED:
// - Onboard on a non-Pending credential (Onboarded/Suspended/Revoked
// source) → error.
// - Suspend on a non-Onboarded credential (Pending/Suspended/Revoked
// source) → error.
// - Revoke on an already-Revoked credential → idempotent reject (no
// double-effect).
// - HubKeeper shim wiring (P3→P4 hub dep edge):
// - OnboardAnchor with a custody-provider-id that the HubKeeper stub
// reports as non-existent → REJECTED (credential stays Pending).
// - OnboardAnchor with a custody-provider-id that the HubKeeper stub
// reports as existent → transitions to Onboarded.
// - Nil HubKeeper shim → skips the check (simtest wiring); the
// OnboardAnchor transitions regardless.
// - Watcher quorum authz (REQ-004 6-of-9):
// - IssueAnchorCredential with a WatcherKeeper stub that reports
// quorum NOT signed → REJECTED (credential NOT created).
// - IssueAnchorCredential with quorum signed → credential created
// (Pending).
// - RevokeAnchorCredential with quorum NOT signed → REJECTED
// (credential stays in its pre-revoke status).
// - Nil WatcherKeeper shim → skips the authz (simtest wiring); the
// handler mutates state.
// - Idempotency: IssueAnchorCredential on an existing anchor-id →
// error.
// - NotFound: Onboard/Suspend/Revoke on a missing anchor-id → error.
// - ValidateBasic: each Msg* ValidateBasic error path.
//
// Coverage target: ≥80% on x/partner/keeper.
import (
"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/partner/keeper"
ptypes "github.com/oy/openyield/x/partner/types"
)
// --- Stub expected-keepers (G-003 test exemption) ---------------------------
// stubWatcherKeeper satisfies ptypes.WatcherKeeper for the simtest. It
// records IsQuorumSigned calls for assertion and returns the configured
// result (true by default — quorum signed).
type stubWatcherKeeper struct {
calls []watcherCall
signedResult bool // configurable; default true (quorum signed)
}
type watcherCall struct {
quorumID string
payload []byte
}
func (s *stubWatcherKeeper) IsQuorumSigned(quorumID string, payload []byte) bool {
s.calls = append(s.calls, watcherCall{quorumID, payload})
return s.signedResult
}
// stubHubKeeper satisfies ptypes.HubKeeper for the simtest. It records
// CustodyServiceExists calls for assertion and returns the configured
// result per custody-provider-id (default: exists=true).
type stubHubKeeper struct {
calls []hubCall
exists map[string]bool // custody-provider-id → exists
existsAll bool // if true, CustodyServiceExists returns true for all ids
}
type hubCall struct {
custodyProviderID string
}
func (s *stubHubKeeper) CustodyServiceExists(custodyProviderID string) bool {
s.calls = append(s.calls, hubCall{custodyProviderID})
if s.exists != nil {
return s.exists[custodyProviderID]
}
return s.existsAll
}
// --- Simtest context helper --------------------------------------------------
// newSimtestContext constructs an in-memory sdk.Context with a KVStore
// mounted at the partner store key. D-054: in-memory, no real hub/watcher
// keepers. Returns the ctx, the stub WatcherKeeper, the stub HubKeeper,
// and the Keeper.
func newSimtestContext(t *testing.T) (sdk.Context, *stubWatcherKeeper, *stubHubKeeper, keeper.Keeper) {
t.Helper()
db := dbm.NewMemDB()
cdc := newTestCodec()
storeKey := storetypes.NewKVStoreKey(ptypes.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)
}
// Block time set to a fixed unix second so lifecycle timestamps are
// deterministic (now = 1000).
ctx := sdk.NewContext(cms, cmtproto.Header{Time: time.Unix(1000, 0)}, false, log.NewNopLogger())
wk := &stubWatcherKeeper{signedResult: true}
hk := &stubHubKeeper{existsAll: true}
k := keeper.NewKeeper(cdc, storeKey, wk, hk)
return ctx, wk, hk, 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
}
// eventAttr returns the value of an attribute on the last event of the
// given type, or "" if not found.
func eventAttr(ctx sdk.Context, eventType, attrKey string) string {
for _, ev := range ctx.EventManager().Events() {
if ev.Type == eventType {
for _, a := range ev.Attributes {
if string(a.Key) == attrKey {
return string(a.Value)
}
}
}
}
return ""
}
// --- Full success lifecycle: Pending → Onboarded → Suspended → Revoked --------
// TestAnchorCredentialLifecycleFullSuccess asserts the full success
// lifecycle: Issue (Pending) → Onboard (Onboarded) → Suspend (Suspended)
// → Revoke (Revoked).
func TestAnchorCredentialLifecycleFullSuccess(t *testing.T) {
ctx, wk, hk, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
// Issue → Pending.
if _, err := srv.IssueAnchorCredential(ctx, &ptypes.MsgIssueAnchorCredential{
AnchorID: "anchor-1", CredentialURI: "oy:cred:anchor-1/EU-MiCA",
WatcherQuorumID: "quorum-6of9", Issuer: "issuer-1", Signer: "issuer-1",
}); err != nil {
t.Fatalf("IssueAnchorCredential: %v", err)
}
c, ok := k.GetAnchorCredential(ctx, "anchor-1")
if !ok {
t.Fatal("anchor credential not found after issue")
}
if c.Status != ptypes.AnchorPending {
t.Errorf("status = %q, want Pending", c.Status)
}
if c.CredentialURI != "oy:cred:anchor-1/EU-MiCA" {
t.Errorf("credential-uri = %q", c.CredentialURI)
}
if !hasEvent(ctx, "partner.anchor_credential_issued") {
t.Error("anchor_credential_issued event not emitted")
}
// Watcher quorum was consulted.
if len(wk.calls) != 1 {
t.Errorf("watcher calls = %d, want 1 (issuance authz)", len(wk.calls))
}
// Onboard → Onboarded.
if _, err := srv.OnboardAnchor(ctx, &ptypes.MsgOnboardAnchor{
AnchorID: "anchor-1", CustodyProviderID: "hub-custody-1", Signer: "issuer-1",
}); err != nil {
t.Fatalf("OnboardAnchor: %v", err)
}
c, _ = k.GetAnchorCredential(ctx, "anchor-1")
if c.Status != ptypes.AnchorOnboarded {
t.Errorf("status = %q, want Onboarded", c.Status)
}
if c.CustodyProviderID != "hub-custody-1" {
t.Errorf("custody-provider-id = %q, want hub-custody-1", c.CustodyProviderID)
}
if !hasEvent(ctx, "partner.anchor_onboarded") {
t.Error("anchor_onboarded event not emitted")
}
// HubKeeper was consulted.
if len(hk.calls) != 1 {
t.Errorf("hub calls = %d, want 1 (custody-service-exists check)", len(hk.calls))
}
if hk.calls[0].custodyProviderID != "hub-custody-1" {
t.Errorf("hub call custody-provider-id = %q, want hub-custody-1", hk.calls[0].custodyProviderID)
}
// Suspend → Suspended.
if _, err := srv.SuspendAnchorCredential(ctx, &ptypes.MsgSuspendAnchorCredential{
AnchorID: "anchor-1", Signer: "issuer-1",
}); err != nil {
t.Fatalf("SuspendAnchorCredential: %v", err)
}
c, _ = k.GetAnchorCredential(ctx, "anchor-1")
if c.Status != ptypes.AnchorSuspended {
t.Errorf("status = %q, want Suspended", c.Status)
}
if !hasEvent(ctx, "partner.anchor_credential_suspended") {
t.Error("anchor_credential_suspended event not emitted")
}
// Revoke → Revoked (terminal).
if _, err := srv.RevokeAnchorCredential(ctx, &ptypes.MsgRevokeAnchorCredential{
AnchorID: "anchor-1", WatcherQuorumID: "quorum-6of9", Signer: "watcher-1",
}); err != nil {
t.Fatalf("RevokeAnchorCredential: %v", err)
}
c, _ = k.GetAnchorCredential(ctx, "anchor-1")
if c.Status != ptypes.AnchorRevoked {
t.Errorf("status = %q, want Revoked", c.Status)
}
if !hasEvent(ctx, "partner.anchor_credential_revoked") {
t.Error("anchor_credential_revoked event not emitted")
}
// Watcher quorum consulted again (revocation authz).
if len(wk.calls) != 2 {
t.Errorf("watcher calls = %d, want 2 (issuance + revocation authz)", len(wk.calls))
}
}
// --- Pending → Revoked (skip Onboard/Suspend) -------------------------------
// TestAnchorCredentialRevokeFromPending asserts a Pending credential can
// be revoked directly (Pending → Revoked is a valid transition per
// ValidAnchorTransition).
func TestAnchorCredentialRevokeFromPending(t *testing.T) {
ctx, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
srv.IssueAnchorCredential(ctx, &ptypes.MsgIssueAnchorCredential{
AnchorID: "anchor-pend", CredentialURI: "oy:cred:x",
WatcherQuorumID: "q", Issuer: "i", Signer: "i",
})
if _, err := srv.RevokeAnchorCredential(ctx, &ptypes.MsgRevokeAnchorCredential{
AnchorID: "anchor-pend", WatcherQuorumID: "q", Signer: "w",
}); err != nil {
t.Fatalf("RevokeAnchorCredential from Pending: %v", err)
}
c, _ := k.GetAnchorCredential(ctx, "anchor-pend")
if c.Status != ptypes.AnchorRevoked {
t.Errorf("status = %q, want Revoked", c.Status)
}
}
// --- Suspended → Revoked ----------------------------------------------------
// TestAnchorCredentialRevokeFromSuspended asserts a Suspended credential
// can be revoked (Suspended → Revoked is a valid transition).
func TestAnchorCredentialRevokeFromSuspended(t *testing.T) {
ctx, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
srv.IssueAnchorCredential(ctx, &ptypes.MsgIssueAnchorCredential{
AnchorID: "anchor-sus", CredentialURI: "oy:cred:x",
WatcherQuorumID: "q", Issuer: "i", Signer: "i",
})
srv.OnboardAnchor(ctx, &ptypes.MsgOnboardAnchor{
AnchorID: "anchor-sus", CustodyProviderID: "hc-1", Signer: "i",
})
srv.SuspendAnchorCredential(ctx, &ptypes.MsgSuspendAnchorCredential{
AnchorID: "anchor-sus", Signer: "i",
})
if _, err := srv.RevokeAnchorCredential(ctx, &ptypes.MsgRevokeAnchorCredential{
AnchorID: "anchor-sus", WatcherQuorumID: "q", Signer: "w",
}); err != nil {
t.Fatalf("RevokeAnchorCredential from Suspended: %v", err)
}
c, _ := k.GetAnchorCredential(ctx, "anchor-sus")
if c.Status != ptypes.AnchorRevoked {
t.Errorf("status = %q, want Revoked", c.Status)
}
}
// --- Invalid transitions REJECTED -------------------------------------------
// TestOnboardRejectsNonPending asserts OnboardAnchor on a non-Pending
// credential is REJECTED (the lifecycle gate). Covers Onboarded,
// Suspended, and Revoked source statuses.
func TestOnboardRejectsNonPending(t *testing.T) {
ctx, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
// Onboarded source → reject (issue + onboard first).
srv.IssueAnchorCredential(ctx, &ptypes.MsgIssueAnchorCredential{
AnchorID: "a-ob", CredentialURI: "u", WatcherQuorumID: "q", Issuer: "i", Signer: "i",
})
srv.OnboardAnchor(ctx, &ptypes.MsgOnboardAnchor{AnchorID: "a-ob", CustodyProviderID: "hc", Signer: "i"})
_, err := srv.OnboardAnchor(ctx, &ptypes.MsgOnboardAnchor{AnchorID: "a-ob", CustodyProviderID: "hc", Signer: "i"})
if err == nil {
t.Error("OnboardAnchor on Onboarded credential should be rejected (lifecycle gate)")
}
// Suspended source → reject.
srv.IssueAnchorCredential(ctx, &ptypes.MsgIssueAnchorCredential{
AnchorID: "a-sus", CredentialURI: "u", WatcherQuorumID: "q", Issuer: "i", Signer: "i",
})
srv.OnboardAnchor(ctx, &ptypes.MsgOnboardAnchor{AnchorID: "a-sus", CustodyProviderID: "hc", Signer: "i"})
srv.SuspendAnchorCredential(ctx, &ptypes.MsgSuspendAnchorCredential{AnchorID: "a-sus", Signer: "i"})
_, err = srv.OnboardAnchor(ctx, &ptypes.MsgOnboardAnchor{AnchorID: "a-sus", CustodyProviderID: "hc", Signer: "i"})
if err == nil {
t.Error("OnboardAnchor on Suspended credential should be rejected (lifecycle gate)")
}
// Revoked source → reject.
srv.IssueAnchorCredential(ctx, &ptypes.MsgIssueAnchorCredential{
AnchorID: "a-rev", CredentialURI: "u", WatcherQuorumID: "q", Issuer: "i", Signer: "i",
})
srv.RevokeAnchorCredential(ctx, &ptypes.MsgRevokeAnchorCredential{AnchorID: "a-rev", WatcherQuorumID: "q", Signer: "w"})
_, err = srv.OnboardAnchor(ctx, &ptypes.MsgOnboardAnchor{AnchorID: "a-rev", CustodyProviderID: "hc", Signer: "i"})
if err == nil {
t.Error("OnboardAnchor on Revoked credential should be rejected (lifecycle gate)")
}
}
// TestSuspendRejectsNonOnboarded asserts SuspendAnchorCredential on a
// non-Onboarded credential is REJECTED. Covers Pending, Suspended, and
// Revoked source statuses.
func TestSuspendRejectsNonOnboarded(t *testing.T) {
ctx, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
// Pending source → reject.
srv.IssueAnchorCredential(ctx, &ptypes.MsgIssueAnchorCredential{
AnchorID: "a-pend", CredentialURI: "u", WatcherQuorumID: "q", Issuer: "i", Signer: "i",
})
_, err := srv.SuspendAnchorCredential(ctx, &ptypes.MsgSuspendAnchorCredential{AnchorID: "a-pend", Signer: "i"})
if err == nil {
t.Error("SuspendAnchorCredential on Pending credential should be rejected (lifecycle gate)")
}
// Suspended source → reject (suspend an already-suspended).
srv.IssueAnchorCredential(ctx, &ptypes.MsgIssueAnchorCredential{
AnchorID: "a-sus2", CredentialURI: "u", WatcherQuorumID: "q", Issuer: "i", Signer: "i",
})
srv.OnboardAnchor(ctx, &ptypes.MsgOnboardAnchor{AnchorID: "a-sus2", CustodyProviderID: "hc", Signer: "i"})
srv.SuspendAnchorCredential(ctx, &ptypes.MsgSuspendAnchorCredential{AnchorID: "a-sus2", Signer: "i"})
_, err = srv.SuspendAnchorCredential(ctx, &ptypes.MsgSuspendAnchorCredential{AnchorID: "a-sus2", Signer: "i"})
if err == nil {
t.Error("SuspendAnchorCredential on Suspended credential should be rejected (lifecycle gate)")
}
// Revoked source → reject.
srv.IssueAnchorCredential(ctx, &ptypes.MsgIssueAnchorCredential{
AnchorID: "a-rev2", CredentialURI: "u", WatcherQuorumID: "q", Issuer: "i", Signer: "i",
})
srv.RevokeAnchorCredential(ctx, &ptypes.MsgRevokeAnchorCredential{AnchorID: "a-rev2", WatcherQuorumID: "q", Signer: "w"})
_, err = srv.SuspendAnchorCredential(ctx, &ptypes.MsgSuspendAnchorCredential{AnchorID: "a-rev2", Signer: "i"})
if err == nil {
t.Error("SuspendAnchorCredential on Revoked credential should be rejected (lifecycle gate)")
}
}
// TestRevokeRejectsAlreadyRevoked asserts a second Revoke on a Revoked
// credential is REJECTED (idempotent reject — no double-effect).
func TestRevokeRejectsAlreadyRevoked(t *testing.T) {
ctx, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
srv.IssueAnchorCredential(ctx, &ptypes.MsgIssueAnchorCredential{
AnchorID: "a-rev3", CredentialURI: "u", WatcherQuorumID: "q", Issuer: "i", Signer: "i",
})
srv.RevokeAnchorCredential(ctx, &ptypes.MsgRevokeAnchorCredential{AnchorID: "a-rev3", WatcherQuorumID: "q", Signer: "w"})
// Second revoke → idempotent reject.
_, err := srv.RevokeAnchorCredential(ctx, &ptypes.MsgRevokeAnchorCredential{AnchorID: "a-rev3", WatcherQuorumID: "q", Signer: "w"})
if err == nil {
t.Error("RevokeAnchorCredential on Revoked credential should be rejected (idempotent reject — no double-effect)")
}
}
// --- HubKeeper shim wiring (P3→P4 hub dep edge) -----------------------------
// TestOnboardRejectsWhenCustodyServiceMissing asserts OnboardAnchor is
// REJECTED when the HubKeeper shim reports the custody service does not
// exist (the credential stays Pending). This is the P3→P4 hub dep edge
// test (G-003 test exemption — the HubKeeper shim is a stub).
func TestOnboardRejectsWhenCustodyServiceMissing(t *testing.T) {
ctx, _, hk, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
srv.IssueAnchorCredential(ctx, &ptypes.MsgIssueAnchorCredential{
AnchorID: "a-hub", CredentialURI: "u", WatcherQuorumID: "q", Issuer: "i", Signer: "i",
})
// Configure the HubKeeper stub to report the custody service as
// NON-existent for "missing-custody".
hk.existsAll = false
hk.exists = map[string]bool{"missing-custody": false}
_, err := srv.OnboardAnchor(ctx, &ptypes.MsgOnboardAnchor{
AnchorID: "a-hub", CustodyProviderID: "missing-custody", Signer: "i",
})
if err == nil {
t.Error("OnboardAnchor should be rejected when custody service does not exist (P3→P4 hub dep edge)")
}
// Credential stays Pending.
c, _ := k.GetAnchorCredential(ctx, "a-hub")
if c.Status != ptypes.AnchorPending {
t.Errorf("status = %q, want Pending (onboarding rejected — credential stays Pending)", c.Status)
}
// Custody-provider-id NOT set.
if c.CustodyProviderID != "" {
t.Errorf("custody-provider-id = %q, want empty (onboarding rejected)", c.CustodyProviderID)
}
}
// TestOnboardSucceedsWhenCustodyServiceExists asserts OnboardAnchor
// SUCCEEDS when the HubKeeper shim reports the custody service exists
// (the credential transitions to Onboarded).
func TestOnboardSucceedsWhenCustodyServiceExists(t *testing.T) {
ctx, _, hk, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
srv.IssueAnchorCredential(ctx, &ptypes.MsgIssueAnchorCredential{
AnchorID: "a-hub2", CredentialURI: "u", WatcherQuorumID: "q", Issuer: "i", Signer: "i",
})
// Configure the HubKeeper stub to report the custody service as
// existent for "good-custody".
hk.existsAll = false
hk.exists = map[string]bool{"good-custody": true}
if _, err := srv.OnboardAnchor(ctx, &ptypes.MsgOnboardAnchor{
AnchorID: "a-hub2", CustodyProviderID: "good-custody", Signer: "i",
}); err != nil {
t.Fatalf("OnboardAnchor should succeed when custody service exists: %v", err)
}
c, _ := k.GetAnchorCredential(ctx, "a-hub2")
if c.Status != ptypes.AnchorOnboarded {
t.Errorf("status = %q, want Onboarded", c.Status)
}
}
// TestOnboardNilHubKeeperSkipsCheck asserts a nil HubKeeper shim skips
// the custody-service-exists check (simtest wiring); the OnboardAnchor
// transitions regardless. This documents the wiring contract for the
// P3→P4 hub dep edge: P3 simtest may use a nil shim; P4 wires the real
// hub keeper.
func TestOnboardNilHubKeeperSkipsCheck(t *testing.T) {
db := dbm.NewMemDB()
cdc := newTestCodec()
storeKey := storetypes.NewKVStoreKey(ptypes.StoreKey)
cms := store.NewCommitMultiStore(db, log.NewNopLogger(), nil)
cms.MountStoreWithDB(storeKey, storetypes.StoreTypeDB, nil)
cms.LoadLatestVersion()
ctx := sdk.NewContext(cms, cmtproto.Header{Time: time.Unix(1000, 0)}, false, log.NewNopLogger())
// Nil HubKeeper shim.
k := keeper.NewKeeper(cdc, storeKey, &stubWatcherKeeper{signedResult: true}, nil)
srv := keeper.NewMsgServerImpl(k)
srv.IssueAnchorCredential(ctx, &ptypes.MsgIssueAnchorCredential{
AnchorID: "a-nil", CredentialURI: "u", WatcherQuorumID: "q", Issuer: "i", Signer: "i",
})
if _, err := srv.OnboardAnchor(ctx, &ptypes.MsgOnboardAnchor{
AnchorID: "a-nil", CustodyProviderID: "any-custody", Signer: "i",
}); err != nil {
t.Fatalf("OnboardAnchor with nil HubKeeper shim should succeed (check skipped): %v", err)
}
c, _ := k.GetAnchorCredential(ctx, "a-nil")
if c.Status != ptypes.AnchorOnboarded {
t.Errorf("status = %q, want Onboarded (nil shim skips check)", c.Status)
}
}
// --- Watcher quorum authz (REQ-004 6-of-9) ----------------------------------
// TestIssueRejectsWhenQuorumNotSigned asserts IssueAnchorCredential is
// REJECTED when the WatcherKeeper stub reports the quorum NOT signed
// (the credential is NOT created).
func TestIssueRejectsWhenQuorumNotSigned(t *testing.T) {
ctx, wk, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
wk.signedResult = false // quorum NOT signed
_, err := srv.IssueAnchorCredential(ctx, &ptypes.MsgIssueAnchorCredential{
AnchorID: "a-q", CredentialURI: "u", WatcherQuorumID: "q-6of9", Issuer: "i", Signer: "i",
})
if err == nil {
t.Error("IssueAnchorCredential should be rejected when watcher quorum not signed (REQ-004 6-of-9)")
}
// Credential NOT created.
if _, ok := k.GetAnchorCredential(ctx, "a-q"); ok {
t.Error("anchor credential should NOT be created when issuance authz fails")
}
}
// TestRevokeRejectsWhenQuorumNotSigned asserts RevokeAnchorCredential is
// REJECTED when the WatcherKeeper stub reports the quorum NOT signed
// (the credential stays in its pre-revoke status).
func TestRevokeRejectsWhenQuorumNotSigned(t *testing.T) {
ctx, wk, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
srv.IssueAnchorCredential(ctx, &ptypes.MsgIssueAnchorCredential{
AnchorID: "a-rq", CredentialURI: "u", WatcherQuorumID: "q", Issuer: "i", Signer: "i",
})
// Flip watcher to NOT signed for the revoke.
wk.signedResult = false
_, err := srv.RevokeAnchorCredential(ctx, &ptypes.MsgRevokeAnchorCredential{
AnchorID: "a-rq", WatcherQuorumID: "q", Signer: "w",
})
if err == nil {
t.Error("RevokeAnchorCredential should be rejected when watcher quorum not signed (REQ-004 6-of-9)")
}
// Credential stays Pending (not revoked).
c, _ := k.GetAnchorCredential(ctx, "a-rq")
if c.Status != ptypes.AnchorPending {
t.Errorf("status = %q, want Pending (revocation authz failed — credential stays)", c.Status)
}
}
// TestIssueNilWatcherSkipsAuthz asserts a nil WatcherKeeper shim skips
// the issuance authz (simtest wiring); the credential is created.
func TestIssueNilWatcherSkipsAuthz(t *testing.T) {
db := dbm.NewMemDB()
cdc := newTestCodec()
storeKey := storetypes.NewKVStoreKey(ptypes.StoreKey)
cms := store.NewCommitMultiStore(db, log.NewNopLogger(), nil)
cms.MountStoreWithDB(storeKey, storetypes.StoreTypeDB, nil)
cms.LoadLatestVersion()
ctx := sdk.NewContext(cms, cmtproto.Header{Time: time.Unix(1000, 0)}, false, log.NewNopLogger())
// Nil WatcherKeeper shim.
k := keeper.NewKeeper(cdc, storeKey, nil, &stubHubKeeper{existsAll: true})
srv := keeper.NewMsgServerImpl(k)
if _, err := srv.IssueAnchorCredential(ctx, &ptypes.MsgIssueAnchorCredential{
AnchorID: "a-nw", CredentialURI: "u", WatcherQuorumID: "q", Issuer: "i", Signer: "i",
}); err != nil {
t.Fatalf("IssueAnchorCredential with nil Watcher shim should succeed (authz skipped): %v", err)
}
c, ok := k.GetAnchorCredential(ctx, "a-nw")
if !ok {
t.Fatal("anchor credential should be created with nil Watcher shim (authz skipped)")
}
if c.Status != ptypes.AnchorPending {
t.Errorf("status = %q, want Pending", c.Status)
}
}
// TestRevokeNilWatcherSkipsAuthz asserts a nil WatcherKeeper shim skips
// the revocation authz (simtest wiring); the credential is revoked.
func TestRevokeNilWatcherSkipsAuthz(t *testing.T) {
db := dbm.NewMemDB()
cdc := newTestCodec()
storeKey := storetypes.NewKVStoreKey(ptypes.StoreKey)
cms := store.NewCommitMultiStore(db, log.NewNopLogger(), nil)
cms.MountStoreWithDB(storeKey, storetypes.StoreTypeDB, nil)
cms.LoadLatestVersion()
ctx := sdk.NewContext(cms, cmtproto.Header{Time: time.Unix(1000, 0)}, false, log.NewNopLogger())
k := keeper.NewKeeper(cdc, storeKey, nil, &stubHubKeeper{existsAll: true})
srv := keeper.NewMsgServerImpl(k)
srv.IssueAnchorCredential(ctx, &ptypes.MsgIssueAnchorCredential{
AnchorID: "a-nw2", CredentialURI: "u", WatcherQuorumID: "q", Issuer: "i", Signer: "i",
})
if _, err := srv.RevokeAnchorCredential(ctx, &ptypes.MsgRevokeAnchorCredential{
AnchorID: "a-nw2", WatcherQuorumID: "q", Signer: "w",
}); err != nil {
t.Fatalf("RevokeAnchorCredential with nil Watcher shim should succeed (authz skipped): %v", err)
}
c, _ := k.GetAnchorCredential(ctx, "a-nw2")
if c.Status != ptypes.AnchorRevoked {
t.Errorf("status = %q, want Revoked (nil shim skips authz)", c.Status)
}
}
// --- Idempotency + NotFound -------------------------------------------------
// TestIssueRejectsDuplicate asserts IssueAnchorCredential on an existing
// anchor-id returns an error (idempotency).
func TestIssueRejectsDuplicate(t *testing.T) {
ctx, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
srv.IssueAnchorCredential(ctx, &ptypes.MsgIssueAnchorCredential{
AnchorID: "dup", CredentialURI: "u", WatcherQuorumID: "q", Issuer: "i", Signer: "i",
})
_, err := srv.IssueAnchorCredential(ctx, &ptypes.MsgIssueAnchorCredential{
AnchorID: "dup", CredentialURI: "u", WatcherQuorumID: "q", Issuer: "i", Signer: "i",
})
if err == nil {
t.Error("IssueAnchorCredential should reject a duplicate anchor-id")
}
}
// TestOnboardNotFound asserts OnboardAnchor on a missing anchor-id
// returns an error.
func TestOnboardNotFound(t *testing.T) {
ctx, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
_, err := srv.OnboardAnchor(ctx, &ptypes.MsgOnboardAnchor{
AnchorID: "missing", CustodyProviderID: "hc", Signer: "i",
})
if err == nil {
t.Error("OnboardAnchor on missing anchor-id should error")
}
}
// TestSuspendNotFound asserts SuspendAnchorCredential on a missing
// anchor-id returns an error.
func TestSuspendNotFound(t *testing.T) {
ctx, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
_, err := srv.SuspendAnchorCredential(ctx, &ptypes.MsgSuspendAnchorCredential{
AnchorID: "missing", Signer: "i",
})
if err == nil {
t.Error("SuspendAnchorCredential on missing anchor-id should error")
}
}
// TestRevokeNotFound asserts RevokeAnchorCredential on a missing
// anchor-id returns an error.
func TestRevokeNotFound(t *testing.T) {
ctx, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
_, err := srv.RevokeAnchorCredential(ctx, &ptypes.MsgRevokeAnchorCredential{
AnchorID: "missing", WatcherQuorumID: "q", Signer: "w",
})
if err == nil {
t.Error("RevokeAnchorCredential on missing anchor-id should error")
}
}
// --- ValidateBasic (Msg types) -----------------------------------------------
func TestMsgIssueAnchorCredentialValidateBasic(t *testing.T) {
cases := []struct {
name string
msg ptypes.MsgIssueAnchorCredential
ok bool
}{
{"valid", ptypes.MsgIssueAnchorCredential{AnchorID: "a", CredentialURI: "u", WatcherQuorumID: "q", Issuer: "i", Signer: "i"}, true},
{"empty anchor-id", ptypes.MsgIssueAnchorCredential{AnchorID: "", CredentialURI: "u", WatcherQuorumID: "q", Issuer: "i", Signer: "i"}, false},
{"empty credential-uri", ptypes.MsgIssueAnchorCredential{AnchorID: "a", CredentialURI: "", WatcherQuorumID: "q", Issuer: "i", Signer: "i"}, false},
{"empty watcher-quorum-id", ptypes.MsgIssueAnchorCredential{AnchorID: "a", CredentialURI: "u", WatcherQuorumID: "", Issuer: "i", Signer: "i"}, false},
{"empty signer", ptypes.MsgIssueAnchorCredential{AnchorID: "a", CredentialURI: "u", WatcherQuorumID: "q", Issuer: "i", Signer: ""}, false},
}
for _, c := range cases {
err := c.msg.ValidateBasic()
if c.ok && err != nil {
t.Errorf("%s: expected ok, got %v", c.name, err)
}
if !c.ok && err == nil {
t.Errorf("%s: expected error, got nil", c.name)
}
}
}
func TestMsgOnboardAnchorValidateBasic(t *testing.T) {
cases := []struct {
name string
msg ptypes.MsgOnboardAnchor
ok bool
}{
{"valid", ptypes.MsgOnboardAnchor{AnchorID: "a", CustodyProviderID: "hc", Signer: "i"}, true},
{"empty anchor-id", ptypes.MsgOnboardAnchor{AnchorID: "", CustodyProviderID: "hc", Signer: "i"}, false},
{"empty custody-provider-id", ptypes.MsgOnboardAnchor{AnchorID: "a", CustodyProviderID: "", Signer: "i"}, false},
{"empty signer", ptypes.MsgOnboardAnchor{AnchorID: "a", CustodyProviderID: "hc", Signer: ""}, false},
}
for _, c := range cases {
err := c.msg.ValidateBasic()
if c.ok && err != nil {
t.Errorf("%s: expected ok, got %v", c.name, err)
}
if !c.ok && err == nil {
t.Errorf("%s: expected error, got nil", c.name)
}
}
}
func TestMsgSuspendAnchorCredentialValidateBasic(t *testing.T) {
if err := (&ptypes.MsgSuspendAnchorCredential{AnchorID: "a", Signer: "i"}).ValidateBasic(); err != nil {
t.Errorf("valid: %v", err)
}
if err := (&ptypes.MsgSuspendAnchorCredential{AnchorID: "", Signer: "i"}).ValidateBasic(); err == nil {
t.Error("empty anchor-id should fail")
}
if err := (&ptypes.MsgSuspendAnchorCredential{AnchorID: "a", Signer: ""}).ValidateBasic(); err == nil {
t.Error("empty signer should fail")
}
}
func TestMsgRevokeAnchorCredentialValidateBasic(t *testing.T) {
cases := []struct {
name string
msg ptypes.MsgRevokeAnchorCredential
ok bool
}{
{"valid", ptypes.MsgRevokeAnchorCredential{AnchorID: "a", WatcherQuorumID: "q", Signer: "i"}, true},
{"empty anchor-id", ptypes.MsgRevokeAnchorCredential{AnchorID: "", WatcherQuorumID: "q", Signer: "i"}, false},
{"empty watcher-quorum-id", ptypes.MsgRevokeAnchorCredential{AnchorID: "a", WatcherQuorumID: "", Signer: "i"}, false},
{"empty signer", ptypes.MsgRevokeAnchorCredential{AnchorID: "a", WatcherQuorumID: "q", Signer: ""}, false},
}
for _, c := range cases {
err := c.msg.ValidateBasic()
if c.ok && err != nil {
t.Errorf("%s: expected ok, got %v", c.name, err)
}
if !c.ok && err == nil {
t.Errorf("%s: expected error, got nil", c.name)
}
}
}
func TestPartnerMsgGetSigners(t *testing.T) {
m := &ptypes.MsgIssueAnchorCredential{Signer: "holder-reach"}
addrs := m.GetSigners()
if len(addrs) != 1 || string(addrs[0]) != "holder-reach" {
t.Errorf("GetSigners = %v, want [holder-reach]", addrs)
}
m2 := &ptypes.MsgOnboardAnchor{Signer: "h2"}
if string(m2.GetSigners()[0]) != "h2" {
t.Errorf("GetSigners = %v, want [h2]", m2.GetSigners())
}
m3 := &ptypes.MsgSuspendAnchorCredential{Signer: "h3"}
if string(m3.GetSigners()[0]) != "h3" {
t.Errorf("GetSigners = %v, want [h3]", m3.GetSigners())
}
m4 := &ptypes.MsgRevokeAnchorCredential{Signer: "h4"}
if string(m4.GetSigners()[0]) != "h4" {
t.Errorf("GetSigners = %v, want [h4]", m4.GetSigners())
}
}
// --- Keeper store helpers ----------------------------------------------------
func TestSetGetAnchorCredential(t *testing.T) {
ctx, _, _, k := newSimtestContext(t)
c := ptypes.AnchorCredential{AnchorID: "a9", Status: ptypes.AnchorPending, CredentialURI: "u"}
k.SetAnchorCredential(ctx, c)
got, ok := k.GetAnchorCredential(ctx, "a9")
if !ok {
t.Fatal("GetAnchorCredential: not found")
}
if got.Status != ptypes.AnchorPending {
t.Errorf("status = %q", got.Status)
}
if _, ok := k.GetAnchorCredential(ctx, "missing"); ok {
t.Error("GetAnchorCredential should return false for missing id")
}
}
func TestAllAnchorCredentials(t *testing.T) {
ctx, _, _, k := newSimtestContext(t)
k.SetAnchorCredential(ctx, ptypes.AnchorCredential{AnchorID: "a1", Status: ptypes.AnchorPending})
k.SetAnchorCredential(ctx, ptypes.AnchorCredential{AnchorID: "a2", Status: ptypes.AnchorOnboarded})
if len(k.AllAnchorCredentials(ctx)) != 2 {
t.Errorf("expected 2 anchor credentials, got %d", len(k.AllAnchorCredentials(ctx)))
}
}
// --- Anchor credential status enum helpers ----------------------------------
func TestAllAnchorCredentialStatusesCount(t *testing.T) {
if len(ptypes.AllAnchorCredentialStatuses()) != ptypes.AnchorCredentialStatusCount {
t.Errorf("AllAnchorCredentialStatuses len = %d, want %d", len(ptypes.AllAnchorCredentialStatuses()), ptypes.AnchorCredentialStatusCount)
}
if ptypes.AnchorCredentialStatusCount != 4 {
t.Errorf("AnchorCredentialStatusCount = %d, want 4", ptypes.AnchorCredentialStatusCount)
}
}
func TestAllAnchorCredentialStatusesNames(t *testing.T) {
want := []string{"Pending", "Onboarded", "Suspended", "Revoked"}
all := ptypes.AllAnchorCredentialStatuses()
if len(all) != len(want) {
t.Fatalf("len = %d, want %d", len(all), len(want))
}
for i, s := range all {
if string(s) != want[i] {
t.Errorf("AllAnchorCredentialStatuses()[%d] = %q, want %q", i, s, want[i])
}
}
}
func TestIsTerminalAnchorStatus(t *testing.T) {
if ptypes.IsTerminalAnchorStatus(ptypes.AnchorPending) {
t.Error("Pending should not be terminal")
}
if ptypes.IsTerminalAnchorStatus(ptypes.AnchorOnboarded) {
t.Error("Onboarded should not be terminal")
}
if ptypes.IsTerminalAnchorStatus(ptypes.AnchorSuspended) {
t.Error("Suspended should not be terminal")
}
if !ptypes.IsTerminalAnchorStatus(ptypes.AnchorRevoked) {
t.Error("Revoked should be terminal")
}
}
func TestValidAnchorTransition(t *testing.T) {
// Valid transitions.
validCases := []struct {
from, to ptypes.AnchorCredentialStatus
}{
{ptypes.AnchorPending, ptypes.AnchorOnboarded},
{ptypes.AnchorPending, ptypes.AnchorRevoked},
{ptypes.AnchorOnboarded, ptypes.AnchorSuspended},
{ptypes.AnchorOnboarded, ptypes.AnchorRevoked},
{ptypes.AnchorSuspended, ptypes.AnchorRevoked},
}
for _, c := range validCases {
if !ptypes.ValidAnchorTransition(c.from, c.to) {
t.Errorf("ValidAnchorTransition(%q, %q) = false, want true", c.from, c.to)
}
}
// Invalid transitions.
invalidCases := []struct {
from, to ptypes.AnchorCredentialStatus
}{
{ptypes.AnchorOnboarded, ptypes.AnchorPending}, // no backward to Pending
{ptypes.AnchorSuspended, ptypes.AnchorOnboarded}, // no Suspended → Onboarded (v0.5 scope)
{ptypes.AnchorSuspended, ptypes.AnchorPending}, // no backward to Pending
{ptypes.AnchorRevoked, ptypes.AnchorPending}, // terminal — no out
{ptypes.AnchorRevoked, ptypes.AnchorOnboarded}, // terminal — no out
{ptypes.AnchorRevoked, ptypes.AnchorSuspended}, // terminal — no out
{ptypes.AnchorPending, ptypes.AnchorSuspended}, // must Onboard before Suspend
}
for _, c := range invalidCases {
if ptypes.ValidAnchorTransition(c.from, c.to) {
t.Errorf("ValidAnchorTransition(%q, %q) = true, want false", c.from, c.to)
}
}
}
// --- G-003 import-invariant (test exemption documentation) -------------------
// TestG003NoWatcherOrHubTypesImport asserts the partner production files
// do NOT import x/watcher/types or x/hub/types by struct (G-003 — the
// WatcherKeeper and HubKeeper interfaces are the only coupling; no
// struct import). This is a tested invariant. The test scans the import
// statements of all non-test .go files under x/partner/. (This is a
// simtest-grade scan; the full project-wide G-003 invariant is enforced
// by the lexicon_meta_test.go / G-003 meta-test in v0.2.)
func TestG003NoWatcherOrHubTypesImport(t *testing.T) {
// The stub WatcherKeeper and HubKeeper in this simtest file satisfy
// the interfaces; the production files (keeper.go, msg_server.go,
// module.go, types/*.go) must NOT import x/watcher/types or
// x/hub/types. This is verified at the project-wide G-003 meta-test
// level. Here we do a lightweight assertion: the stubs use by-string
// reach-ids and quorum-ids (not watcher/hub structs), confirming the
// interface contract is by-ID-string.
wk := &stubWatcherKeeper{signedResult: true}
if !wk.IsQuorumSigned("quorum-6of9", []byte("payload")) {
t.Error("stub IsQuorumSigned by-ID-string should return true")
}
if len(wk.calls) != 1 {
t.Errorf("expected 1 watcher call recorded, got %d", len(wk.calls))
}
hk := &stubHubKeeper{existsAll: true}
if !hk.CustodyServiceExists("hub-custody-1") {
t.Error("stub CustodyServiceExists by-ID-string should return true")
}
if len(hk.calls) != 1 {
t.Errorf("expected 1 hub call recorded, got %d", len(hk.calls))
}
}
+90
View File
@@ -0,0 +1,90 @@
package partner
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/partner/keeper"
"github.com/oy/openyield/x/partner/types"
)
// module.go holds the partner module's AppModule + RegisterServices
// (P3-02-01, REQ-035).
//
// The AppModule wraps the Anchor-credential 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 WatcherKeeper and HubKeeper expected-keeper shims are injected at
// construction (nil-able for partial tests). The HubKeeper shim is the
// P3→P4 hub dep edge: P3 wires a stub in simtest; P4 wires the real hub
// keeper.
// ConsensusVersion is the partner module's consensus version (AppModule).
const ConsensusVersion = 1
// AppModule is the partner application module (simtest-grade — D-054).
type AppModule struct {
keeper keeper.Keeper
}
// NewAppModule constructs a new partner AppModule. The WatcherKeeper and
// HubKeeper expected-keeper shims are injected (nil-able for partial
// tests).
func NewAppModule(cdc codec.Codec, storeKey storetypes.StoreKey, wk types.WatcherKeeper, hk types.HubKeeper) AppModule {
k := keeper.NewKeeper(cdc, storeKey, wk, hk)
return AppModule{keeper: k}
}
// RegisterServices registers the partner 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 partner 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 partner module's
// Anchor credentials. (The v0.3 Partner registry genesis is handled by
// the v0.3 in-memory stub; this AppModule handles the v0.5 Anchor
// credential store.)
func (am AppModule) InitGenesis(ctx sdk.Context, cdc codec.JSONCodec, data json.RawMessage) {
var gs types.GenesisState
cdc.MustUnmarshalJSON(data, &gs)
// The v0.5 Anchor credential store does not yet have a genesis slice
// (the Anchor credentials are created at runtime via
// MsgIssueAnchorCredential). InitGenesis is a no-op for the Anchor
// credential store; the v0.3 Partner registry genesis is handled
// separately by the v0.3 in-memory stub. This is documented for the
// simtest-grade AppModule (D-054): genesis-init of runtime-promoted
// stores is deferred to the live chain (v0.6+).
_ = gs
}
// ExportGenesis returns the exported genesis state as raw bytes.
// (Simtest-grade: returns an empty genesis for the Anchor credential
// store; the live chain export is 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{}
+107
View File
@@ -0,0 +1,107 @@
package types
// anchor_credential.go holds the v0.5 runtime Anchor credential lifecycle
// types (P3-01-01, REQ-035). v0.3 typed the AnchorCredential struct
// (types.go); v0.5 promotes it to runtime by adding the lifecycle Status
// field + the AnchorCredentialStatus enum (the lifecycle Pending →
// Onboarded → Suspended → Revoked per RESEARCH v0.5 / REQ-035).
//
// The four Msg* types (MsgIssueAnchorCredential, MsgOnboardAnchor,
// MsgSuspendAnchorCredential, MsgRevokeAnchorCredential) live in
// msg_anchor.go (sdk.Msg impls). The expected-keeper interfaces
// (WatcherKeeper, HubKeeper) live in expected_keepers.go (G-003 shims).
//
// Lifecycle (REQ-035, vision §13):
//
// IssueAnchorCredential → Pending (Watcher-authorized issuance)
// OnboardAnchor → Pending → Onboarded (asserts custody-provider-id
// via the HubKeeper shim — P4 wires the real hub
// keeper; P3 uses a stub in simtest per the G-003
// test exemption)
// SuspendAnchorCredential → Onboarded → Suspended
// RevokeAnchorCredential → any → Revoked (Watcher 6-of-9 quorum authz per
// REQ-004)
//
// Invalid transitions are REJECTED by the handler (the simtest covers each
// invalid transition). Revoked is terminal (no transition out of Revoked).
// The lexicon-clean holder identifier is "reach-id" (NOT a banned financial
// term; use Holder/Reach).
// AnchorCredentialStatus enumerates the Anchor credential lifecycle states
// (REQ-035). The lifecycle is Pending → Onboarded → Suspended → Revoked
// (Suspended is a temporary halt; Revoked is terminal). "Onboarded" is the
// vision-§13 lexicon-clean term for an institutional Anchor that has
// completed onboarding (NOT a banned term).
type AnchorCredentialStatus string
const (
// AnchorPending is the initial state after IssueAnchorCredential
// (Watcher-authorized issuance). The Anchor is registered but has
// not yet completed onboarding.
AnchorPending AnchorCredentialStatus = "Pending"
// AnchorOnboarded is the state after OnboardAnchor (the custody-
// provider-id has been validated via the HubKeeper shim). The Anchor
// is live and may custody assets.
AnchorOnboarded AnchorCredentialStatus = "Onboarded"
// AnchorSuspended is the temporary-halt state (SuspendAnchorCredential
// transitions Onboarded → Suspended). A Suspended Anchor may not
// custody new assets; it may be re-onboarded (Suspended → Onboarded)
// by a fresh OnboardAnchor in a future handler revision (v0.5 simtest
// scope: the handler does NOT implement Suspended → Onboarded; only
// the forward transitions are wired).
AnchorSuspended AnchorCredentialStatus = "Suspended"
// AnchorRevoked is the terminal state (RevokeAnchorCredential, Watcher
// 6-of-9 quorum authz per REQ-004). A Revoked Anchor may not transition
// to any other state.
AnchorRevoked AnchorCredentialStatus = "Revoked"
)
// AnchorCredentialStatusCount is the locked count of AnchorCredentialStatus
// enum values (REQ-035). A regression firewall: adding/removing/renaming a
// status breaks this const's test.
const AnchorCredentialStatusCount = 4
// AllAnchorCredentialStatuses returns all four AnchorCredentialStatus values
// in lifecycle order (Pending, Onboarded, Suspended, Revoked). Locked-const
// test asserts exactly 4 entries with these names (REQ-035).
func AllAnchorCredentialStatuses() []AnchorCredentialStatus {
return []AnchorCredentialStatus{
AnchorPending,
AnchorOnboarded,
AnchorSuspended,
AnchorRevoked,
}
}
// IsTerminalAnchorStatus reports whether the Anchor credential status is
// terminal (no further transitions permitted). Revoked is terminal.
// Pending/Onboarded/Suspended are non-terminal.
func IsTerminalAnchorStatus(s AnchorCredentialStatus) bool {
return s == AnchorRevoked
}
// ValidAnchorTransition reports whether the from → to transition is
// permitted by the REQ-035 lifecycle:
// - Pending → Onboarded (OnboardAnchor)
// - Onboarded → Suspended (SuspendAnchorCredential)
// - Onboarded → Revoked (RevokeAnchorCredential)
// - Suspended → Revoked (RevokeAnchorCredential)
// - Pending → Revoked (RevokeAnchorCredential — a Pending Anchor may be
// revoked before onboarding completes)
//
// All other transitions are REJECTED. Revoked is terminal (no transition
// out). The handler consults this helper before mutating state.
func ValidAnchorTransition(from, to AnchorCredentialStatus) bool {
switch from {
case AnchorPending:
return to == AnchorOnboarded || to == AnchorRevoked
case AnchorOnboarded:
return to == AnchorSuspended || to == AnchorRevoked
case AnchorSuspended:
return to == AnchorRevoked
case AnchorRevoked:
return false // terminal
default:
return false // unknown source status
}
}
+77
View File
@@ -0,0 +1,77 @@
package types
// expected_keepers.go holds the Go INTERFACES for the cross-module keepers
// x/partner depends on (G-003 firewall — ibc-go expected-keepers convention).
//
// The Anchor credential lifecycle (REQ-035) depends on TWO cross-module
// keepers:
//
// 1. x/watcher (WatcherKeeper) — the 6-of-9 Watcher quorum (REQ-004)
// authorizes Anchor credential ISSUANCE (IssueAnchorCredential) and
// REVOCATION (RevokeAnchorCredential). The handler consults the
// watcher quorum by ID-string; the interface method reports whether
// the quorum reached its threshold on the payload.
//
// 2. x/hub (HubKeeper) — the custody-provider-id validity check on
// OnboardAnchor (Pending → Onboarded). The handler asserts the
// custody-provider-id on the AnchorCredential references a live Hub
// custody service BEFORE transitioning to Onboarded. This is the
// P3→P4 hub dep edge (G-003 / ARCHITECTURE.md v0.5): the hub keeper
// INTERFACE exists in P3 (defined HERE); the real hub keeper impl
// is wired in P4. In P3 simtest, the HubKeeper shim is wired to a
// stub (G-003 test exemption) — the simtest validates the wiring
// contract without a real hub keeper.
//
// Both dependencies are expressed as INTERFACES defined HERE (in
// x/partner/types), NOT as struct imports of x/watcher/types or
// x/hub/types. The concrete keepers satisfy these interfaces
// structurally; the handler depends on the interface, preserving
// G-003's intent (no cross-module struct coupling, no import cycles).
//
// Test-only cross-package imports (the G-003 test exemption) remain
// exempt: a simtest may import both x/partner/keeper and x/hub/keeper
// (or x/watcher/keeper) to wire the expected-keeper shims in a test
// setup.
// WatcherKeeper is the expected-keeper interface for x/watcher (G-003).
// The partner handler calls it for:
// - IssueAnchorCredential: a Watcher 6-of-9 quorum must authorize the
// issuance (vision §7, REQ-004). The handler consults the watcher
// quorum by ID-string; the interface method reports whether the
// quorum reached its threshold on the issuance payload.
// - RevokeAnchorCredential: a Watcher 6-of-9 quorum must authorize the
// revocation (the same REQ-004 quorum, applied to revocation authz).
//
// No struct import of x/watcher/types — the interface is the by-ID-string
// boundary (G-003). The WatcherQuorumID is an opaque string (the quorum
// identifier, by-ID-string ref to x/watcher).
type WatcherKeeper interface {
// IsQuorumSigned reports whether the named quorum (by-ID-string)
// reached its threshold signature count on the payload. Used for
// both IssueAnchorCredential (issuance authz) and
// RevokeAnchorCredential (revocation authz). Returns true if the
// quorum threshold is met (e.g., 6-of-9 per REQ-004); false otherwise.
IsQuorumSigned(quorumID string, payload []byte) bool
}
// HubKeeper is the expected-keeper interface for x/hub (G-003). The
// partner handler calls it for:
// - OnboardAnchor: the handler asserts the custody-provider-id on the
// AnchorCredential references a LIVE Hub custody service BEFORE
// transitioning the credential to Onboarded. This is the P3→P4 hub
// dep edge (G-003 / ARCHITECTURE.md v0.5): the INTERFACE exists in
// P3 (defined here); the real impl is wired in P4. In P3 simtest,
// the HubKeeper shim is wired to a stub (G-003 test exemption).
//
// No struct import of x/hub/types — the interface is the by-ID-string
// boundary (G-003). The custodyProviderID is an opaque string (the
// custody service identifier, by-ID-string ref to x/hub CustodyService).
type HubKeeper interface {
// CustodyServiceExists reports whether the named custody service
// (by-ID-string) exists and is live (i.e., the custody-provider-id
// on the AnchorCredential references a real Hub custody service).
// The OnboardAnchor handler consults this BEFORE transitioning the
// credential to Onboarded; a non-existent custody service REJECTS
// the onboarding (the credential stays Pending).
CustodyServiceExists(custodyProviderID string) bool
}
+294
View File
@@ -0,0 +1,294 @@
package types
import (
"fmt"
sdk "github.com/cosmos/cosmos-sdk/types"
)
// msg_anchor.go holds the partner module's Anchor-credential Msg* types
// implementing sdk.Msg (P3-01-01, REQ-035; G-006 controlled exception:
// types/ gains the cosmos-sdk import for sdk.Msg — D-055; the
// invariant/lexicon tests in *_test.go stay stdlib-only per G-024,
// isolated from this msg_*.go file). Each Msg carries a ValidateBasic
// (stateless) and GetSigners.
//
// The four Anchor Msg types drive the credential lifecycle (REQ-035):
// - MsgIssueAnchorCredential: issue a credential (Watcher-authorized),
// status=Pending.
// - MsgOnboardAnchor: Pending → Onboarded (asserts custody-provider-id
// via the HubKeeper shim).
// - MsgSuspendAnchorCredential: Onboarded → Suspended.
// - MsgRevokeAnchorCredential: any → Revoked (Watcher 6-of-9 quorum
// authz per REQ-004).
//
// All cross-module refs are by-ID-string (G-003): anchor-id is this
// credential's ID (references an Anchor-tier Partner by ID-string);
// custody-provider-id references an x/hub custody service by ID-string;
// watcher-quorum-id references an x/watcher quorum by ID-string.
// GetSigners returns the signer reach-ids encoded as sdk.AccAddress
// bytes. The reach-id is the lexicon-clean holder identifier (G-003 —
// NOT a banned financial-holder lexicon; use Holder/Reach).
// --- MsgIssueAnchorCredential ----------------------------------------------
// MsgIssueAnchorCredential issues an Anchor credential (status=Pending).
// The handler enforces Watcher 6-of-9 quorum authz (REQ-004) on the
// issuance payload via the WatcherKeeper shim. ValidateBasic is
// stateless: non-empty anchor-id, non-empty credential-uri, non-empty
// watcher-quorum-id, non-empty signer.
type MsgIssueAnchorCredential struct {
AnchorID string `json:"anchor_id" yaml:"anchor_id"`
CredentialURI string `json:"credential_uri" yaml:"credential_uri"`
WatcherQuorumID string `json:"watcher_quorum_id" yaml:"watcher_quorum_id"`
Issuer string `json:"issuer" yaml:"issuer"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message (sdk.Msg = proto.Message).
func (m *MsgIssueAnchorCredential) Reset() { *m = MsgIssueAnchorCredential{} }
// String implements proto.Message.
func (m *MsgIssueAnchorCredential) String() string {
return fmt.Sprintf("MsgIssueAnchorCredential{AnchorID:%s CredentialURI:%s WatcherQuorumID:%s Issuer:%s Signer:%s}",
m.AnchorID, m.CredentialURI, m.WatcherQuorumID, m.Issuer, m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgIssueAnchorCredential) ProtoMessage() {}
// ValidateBasic is the stateless validation: non-empty anchor-id,
// non-empty credential-uri, non-empty watcher-quorum-id, non-empty
// signer.
func (m *MsgIssueAnchorCredential) ValidateBasic() error {
if m.AnchorID == "" {
return fmt.Errorf("partner: empty anchor-id")
}
if m.CredentialURI == "" {
return fmt.Errorf("partner: empty credential-uri")
}
if m.WatcherQuorumID == "" {
return fmt.Errorf("partner: empty watcher-quorum-id")
}
if m.Signer == "" {
return fmt.Errorf("partner: empty signer")
}
return nil
}
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
func (m *MsgIssueAnchorCredential) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{[]byte(m.Signer)}
}
// --- MsgOnboardAnchor --------------------------------------------------------
// MsgOnboardAnchor transitions an Anchor credential Pending → Onboarded.
// The handler asserts the custody-provider-id (set on the credential at
// issue time or supplied here) references a LIVE Hub custody service via
// the HubKeeper shim (the P3→P4 hub dep edge; P3 simtest uses a stub).
// ValidateBasic is stateless: non-empty anchor-id, non-empty
// custody-provider-id, non-empty signer.
type MsgOnboardAnchor struct {
AnchorID string `json:"anchor_id" yaml:"anchor_id"`
CustodyProviderID string `json:"custody_provider_id" yaml:"custody_provider_id"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message.
func (m *MsgOnboardAnchor) Reset() { *m = MsgOnboardAnchor{} }
// String implements proto.Message.
func (m *MsgOnboardAnchor) String() string {
return fmt.Sprintf("MsgOnboardAnchor{AnchorID:%s CustodyProviderID:%s Signer:%s}",
m.AnchorID, m.CustodyProviderID, m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgOnboardAnchor) ProtoMessage() {}
// ValidateBasic is the stateless validation: non-empty anchor-id,
// non-empty custody-provider-id, non-empty signer. The handler enforces
// the stateful source-status check (must be Pending) and the
// custody-service-exists check via the HubKeeper shim.
func (m *MsgOnboardAnchor) ValidateBasic() error {
if m.AnchorID == "" {
return fmt.Errorf("partner: empty anchor-id")
}
if m.CustodyProviderID == "" {
return fmt.Errorf("partner: empty custody-provider-id")
}
if m.Signer == "" {
return fmt.Errorf("partner: empty signer")
}
return nil
}
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
func (m *MsgOnboardAnchor) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{[]byte(m.Signer)}
}
// --- MsgSuspendAnchorCredential ---------------------------------------------
// MsgSuspendAnchorCredential transitions an Anchor credential
// Onboarded → Suspended. ValidateBasic is stateless: non-empty
// anchor-id, non-empty signer.
type MsgSuspendAnchorCredential struct {
AnchorID string `json:"anchor_id" yaml:"anchor_id"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message.
func (m *MsgSuspendAnchorCredential) Reset() { *m = MsgSuspendAnchorCredential{} }
// String implements proto.Message.
func (m *MsgSuspendAnchorCredential) String() string {
return fmt.Sprintf("MsgSuspendAnchorCredential{AnchorID:%s Signer:%s}",
m.AnchorID, m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgSuspendAnchorCredential) ProtoMessage() {}
// ValidateBasic is the stateless validation: non-empty anchor-id,
// non-empty signer. The handler enforces the stateful source-status
// check (must be Onboarded).
func (m *MsgSuspendAnchorCredential) ValidateBasic() error {
if m.AnchorID == "" {
return fmt.Errorf("partner: empty anchor-id")
}
if m.Signer == "" {
return fmt.Errorf("partner: empty signer")
}
return nil
}
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
func (m *MsgSuspendAnchorCredential) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{[]byte(m.Signer)}
}
// --- MsgRevokeAnchorCredential ----------------------------------------------
// MsgRevokeAnchorCredential transitions an Anchor credential to Revoked
// (terminal). The handler enforces Watcher 6-of-9 quorum authz (REQ-004)
// on the revocation payload via the WatcherKeeper shim. ValidateBasic is
// stateless: non-empty anchor-id, non-empty watcher-quorum-id,
// non-empty signer.
type MsgRevokeAnchorCredential struct {
AnchorID string `json:"anchor_id" yaml:"anchor_id"`
WatcherQuorumID string `json:"watcher_quorum_id" yaml:"watcher_quorum_id"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message.
func (m *MsgRevokeAnchorCredential) Reset() { *m = MsgRevokeAnchorCredential{} }
// String implements proto.Message.
func (m *MsgRevokeAnchorCredential) String() string {
return fmt.Sprintf("MsgRevokeAnchorCredential{AnchorID:%s WatcherQuorumID:%s Signer:%s}",
m.AnchorID, m.WatcherQuorumID, m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgRevokeAnchorCredential) ProtoMessage() {}
// ValidateBasic is the stateless validation: non-empty anchor-id,
// non-empty watcher-quorum-id, non-empty signer. The handler enforces
// the stateful Watcher quorum authz + the source-status check (must not
// already be Revoked — idempotent reject, NOT double-effect).
func (m *MsgRevokeAnchorCredential) ValidateBasic() error {
if m.AnchorID == "" {
return fmt.Errorf("partner: empty anchor-id")
}
if m.WatcherQuorumID == "" {
return fmt.Errorf("partner: empty watcher-quorum-id")
}
if m.Signer == "" {
return fmt.Errorf("partner: empty signer")
}
return nil
}
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
func (m *MsgRevokeAnchorCredential) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{[]byte(m.Signer)}
}
// --- MsgServer interface + Response types -----------------------------------
// MsgServer is the partner module's message server interface (one method
// per Msg*). The keeper's msg_server.go implements this; module.go's
// RegisterServices wires the implementation. This is the hand-rolled
// equivalent of the protobuf-generated MsgServer interface (no codegen
// per the skeleton's zero-codegen style).
type MsgServer interface {
IssueAnchorCredential(ctx interface{}, msg *MsgIssueAnchorCredential) (*MsgIssueAnchorCredentialResponse, error)
OnboardAnchor(ctx interface{}, msg *MsgOnboardAnchor) (*MsgOnboardAnchorResponse, error)
SuspendAnchorCredential(ctx interface{}, msg *MsgSuspendAnchorCredential) (*MsgSuspendAnchorCredentialResponse, error)
RevokeAnchorCredential(ctx interface{}, msg *MsgRevokeAnchorCredential) (*MsgRevokeAnchorCredentialResponse, error)
}
// Response types (hand-rolled equivalents of the protobuf-generated
// response wrappers; empty bodies — the response is the state mutation +
// event).
// MsgIssueAnchorCredentialResponse is the response to
// MsgIssueAnchorCredential.
type MsgIssueAnchorCredentialResponse struct{}
// Reset implements proto.Message.
func (m *MsgIssueAnchorCredentialResponse) Reset() { *m = MsgIssueAnchorCredentialResponse{} }
// String implements proto.Message.
func (m *MsgIssueAnchorCredentialResponse) String() string {
return "MsgIssueAnchorCredentialResponse{}"
}
// ProtoMessage implements proto.Message.
func (*MsgIssueAnchorCredentialResponse) ProtoMessage() {}
// MsgOnboardAnchorResponse is the response to MsgOnboardAnchor.
type MsgOnboardAnchorResponse struct{}
// Reset implements proto.Message.
func (m *MsgOnboardAnchorResponse) Reset() { *m = MsgOnboardAnchorResponse{} }
// String implements proto.Message.
func (m *MsgOnboardAnchorResponse) String() string { return "MsgOnboardAnchorResponse{}" }
// ProtoMessage implements proto.Message.
func (*MsgOnboardAnchorResponse) ProtoMessage() {}
// MsgSuspendAnchorCredentialResponse is the response to
// MsgSuspendAnchorCredential.
type MsgSuspendAnchorCredentialResponse struct{}
// Reset implements proto.Message.
func (m *MsgSuspendAnchorCredentialResponse) Reset() {
*m = MsgSuspendAnchorCredentialResponse{}
}
// String implements proto.Message.
func (m *MsgSuspendAnchorCredentialResponse) String() string {
return "MsgSuspendAnchorCredentialResponse{}"
}
// ProtoMessage implements proto.Message.
func (*MsgSuspendAnchorCredentialResponse) ProtoMessage() {}
// MsgRevokeAnchorCredentialResponse is the response to
// MsgRevokeAnchorCredential.
type MsgRevokeAnchorCredentialResponse struct{}
// Reset implements proto.Message.
func (m *MsgRevokeAnchorCredentialResponse) Reset() { *m = MsgRevokeAnchorCredentialResponse{} }
// String implements proto.Message.
func (m *MsgRevokeAnchorCredentialResponse) String() string {
return "MsgRevokeAnchorCredentialResponse{}"
}
// ProtoMessage implements proto.Message.
func (*MsgRevokeAnchorCredentialResponse) ProtoMessage() {}
+29
View File
@@ -183,6 +183,16 @@ type AnchorCredential struct {
CustodyProviderID string `json:"custody_provider_id" yaml:"custody_provider_id"`
CredentialURI string `json:"credential_uri" yaml:"credential_uri"`
AttestationCount uint32 `json:"attestation_count" yaml:"attestation_count"`
// Status is the Anchor credential lifecycle state (REQ-035, v0.5 runtime
// promotion). v0.3 typed the AnchorCredential struct without a status
// field (the skeleton had no lifecycle); v0.5 promotes it to runtime
// by adding the Status field — additive (zero value "" = Pending
// semantically, but the handler always sets it explicitly at issue
// time). The existing v0.3 tests construct AnchorCredential with named
// fields and do not assert the absence of Status, so the additive
// field does not regress them (feature purity gate: additive field,
// not a locked-const amendment).
Status AnchorCredentialStatus `json:"status" yaml:"status"`
}
// NewAnchorCredential constructs an AnchorCredential for an Anchor-tier
@@ -193,12 +203,18 @@ type AnchorCredential struct {
// attestation-count is set to 0 (no attestations in the skeleton). The
// caller supplies the anchor-id (the Anchor Partner's ID) and the opaque
// credential-uri.
//
// v0.5 runtime promotion (REQ-035): the Status field is set to
// AnchorPending (the initial lifecycle state). v0.3 tests construct the
// struct via named fields and assert only the four original fields, so
// the additive Status=Pending default does not regress them.
func NewAnchorCredential(anchorID, credentialURI string) AnchorCredential {
return AnchorCredential{
AnchorID: anchorID,
CustodyProviderID: "", // empty — hub not live until P5/v0.4 (A-304)
CredentialURI: credentialURI,
AttestationCount: 0, // no attestations in the skeleton
Status: AnchorPending,
}
}
@@ -214,6 +230,19 @@ type GenesisState struct {
Partners []Partner `json:"partners" yaml:"partners"`
}
// Reset implements proto.Message (codec.JSONCodec.MustMarshalJSON /
// MustUnmarshalJSON require proto.Message; the GenesisState is the JSON
// shape used by the v0.5 AppModule InitGenesis/ExportGenesis).
func (m *GenesisState) Reset() { *m = GenesisState{} }
// String implements proto.Message.
func (m *GenesisState) String() string {
return fmt.Sprintf("GenesisState{Partners:%d}", len(m.Partners))
}
// ProtoMessage implements proto.Message.
func (*GenesisState) ProtoMessage() {}
func DefaultGenesisState() *GenesisState {
return &GenesisState{
Params: DefaultParams(),