docs(P04): complete Bearers skeleton I phase

P4 complete. Bearers skeleton I (D-020/D-035 pattern, zero ext deps):
- x/bridge/types (NEW): BridgeStatus enum (4), BridgeRoute with by-ID-string
  refs to satellite L2Chain + watcher quorum (G-003).
- x/exit/types (NEW): ExitStatus enum (5), ExitRoute with bridge-route-id
  by-ID-string (A-308), DEXSwap with opaque venue string.
- x/bearers/types (EXT): OYSATLink surveillance-resistant LOCKED true (A-311),
  OYQRCode MarkConsumed idempotent.
- x/partner/types (EXT): AnchorCredential, custody-provider-id empty in
  skeleton (A-304), PartnerTierCount=4 regression (A-305).
All 4 packages 100% coverage. Both firewalls green. G-003 intact.

---ci---
project: oy
phase: 4
milestone: v0.3
status: complete
tag_base: v0.2.x
phase_role: execution
requirements:
  covered: [REQ-010, REQ-022, REQ-023]
  partial: []
---/ci---
This commit is contained in:
2026-08-17 22:21:16 +00:00
parent 349453ecd9
commit ab43befdaf
10 changed files with 1475 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
package types
import "fmt"
// genesis.go holds the data-engineer's genesis schema helpers for the
// exit module (G-008 split). ValidateGenesis in types.go composes these
// helpers; the security-engineer's test assertions live in types_test.go.
//
// The Exit genesis schema has two top-level sets: Routes (exit routes) and
// Swaps (DEX swaps). The invariants enforced at genesis load are (1)
// route-id uniqueness, (2) swap-id uniqueness, and (3) status validity.
// The route's bridge-route-id is a by-ID-string ref (G-003) and is NOT
// referentially checked at genesis (the referenced x/bridge state is in a
// separate module; cross-module referential integrity is a v0.4 keeper
// concern, not a v0.3 skeleton concern per A-308).
// ValidateRoutes asserts route-ids are present and unique, and that each
// route's status is a known ExitStatus. ValidateRoutes is the
// data-engineer's schema validator, composed by ValidateGenesis in
// types.go.
func ValidateRoutes(routes []ExitRoute) error {
seen := make(map[string]bool, len(routes))
for i, r := range routes {
if r.RouteID == "" {
return fmt.Errorf("exit [%d]: empty route-id", i)
}
if seen[r.RouteID] {
return fmt.Errorf("exit: duplicate route-id %q", r.RouteID)
}
seen[r.RouteID] = true
if !knownExitStatus(r.Status) {
return fmt.Errorf("exit %q: unknown exit status %q", r.RouteID, r.Status)
}
}
return nil
}
// ValidateSwaps asserts swap-ids are present and unique, and that each
// swap's status is a known ExitStatus. The venue is an opaque string
// (A-308) and is not validated against a locked enum.
func ValidateSwaps(swaps []DEXSwap) error {
seen := make(map[string]bool, len(swaps))
for i, s := range swaps {
if s.SwapID == "" {
return fmt.Errorf("exit [%d]: empty swap-id", i)
}
if seen[s.SwapID] {
return fmt.Errorf("exit: duplicate swap-id %q", s.SwapID)
}
seen[s.SwapID] = true
if !knownExitStatus(s.Status) {
return fmt.Errorf("exit swap %q: unknown exit status %q", s.SwapID, s.Status)
}
}
return nil
}
// knownExitStatus reports whether s is one of the five ExitStatus values.
func knownExitStatus(s ExitStatus) bool {
for _, ss := range AllExitStatuses() {
if s == ss {
return true
}
}
return false
}
+122
View File
@@ -0,0 +1,122 @@
package types
import (
"encoding/json"
"fmt"
)
const (
ModuleName = "exit"
StoreKey = ModuleName
RouterKey = ModuleName
QuerierRoute = ModuleName
// ExitStatusCount is the locked count of ExitStatus enum values
// (vision §7, REQ-010, D-036). Five exit lifecycle states: Proposed,
// InProgress, Settled, Failed, Refunded. A regression firewall:
// adding/removing/renaming a status breaks this const's test.
ExitStatusCount = 5
)
// ExitStatus enumerates the lifecycle of a Layer-3 exit (vision §7,
// REQ-010, D-036). The five-state lifecycle covers both successful exits
// (Proposed → InProgress → Settled) and the failure/recovery paths
// (Failed → Refunded). Refunded is the terminal recovery state when an
// exit fails and the holder is made whole.
type ExitStatus string
const (
ExitProposed ExitStatus = "Proposed" // exit declared, not yet executing
ExitInProgress ExitStatus = "InProgress" // exit executing (swap/bridge hop)
ExitSettled ExitStatus = "Settled" // exit completed, holder paid out
ExitFailed ExitStatus = "Failed" // exit failed (slippage/timeout)
ExitRefunded ExitStatus = "Refunded" // failed exit refunded to holder
)
// AllExitStatuses returns all five ExitStatus values in vision §7 lifecycle
// order. Locked-const test asserts exactly 5 entries.
func AllExitStatuses() []ExitStatus {
return []ExitStatus{
ExitProposed,
ExitInProgress,
ExitSettled,
ExitFailed,
ExitRefunded,
}
}
// ExitRoute is a Holder-initiated exit route (REQ-010, D-036, A-308). The
// route describes a holder's intent to exit the mesh via a DEX swap and
// (optionally) a cross-chain bridge hop. All cross-module references are
// by-ID-string per G-003:
//
// - route-id is this route's unique identifier.
// - bridge-route-id references an x/bridge BridgeRoute by ID-string
// (A-308, G-003). It is optional (empty for same-chain exits) and
// present for cross-chain exits. No struct import of x/bridge.
// - status is the exit lifecycle (ExitStatus).
//
// The bridge-route-id is the P4 intra-phase dependency edge (x/bridge is
// authored first within P4; x/exit references it by ID-string only).
type ExitRoute struct {
RouteID string `json:"route_id" yaml:"route_id"`
BridgeRouteID string `json:"bridge_route_id" yaml:"bridge_route_id"`
Status ExitStatus `json:"status" yaml:"status"`
}
// DEXSwap is a single DEX swap executed as part of an exit route (REQ-010,
// D-036, A-308). The venue is an OPAQUE string (e.g. "uniswap-v3", "oy-dex")
// — NOT a locked enum. A-308: venues are operational, not protocol-locked;
// locking an enum now risks churn (uniswap-v3/v4, oy-dex, etc. change over
// time). The skeleton keeps the venue as a free-form string so the type
// shape is stable across venue additions. status reuses ExitStatus (a swap
// shares the exit lifecycle: Proposed → InProgress → Settled/Failed).
//
// - swap-id is this swap's unique identifier.
// - venue is the opaque DEX venue string (A-308 — not a locked enum).
// - status is the swap lifecycle (ExitStatus).
type DEXSwap struct {
SwapID string `json:"swap_id" yaml:"swap_id"`
Venue string `json:"venue" yaml:"venue"`
Status ExitStatus `json:"status" yaml:"status"`
}
// Params for the exit module (skeleton — no tunables in v0.3).
type Params struct{}
func DefaultParams() Params { return Params{} }
// GenesisState defines the exit module genesis state (REQ-010). Routes is
// the set of exit routes; Swaps is the set of DEX swaps. ValidateGenesis
// enforces route-id and swap-id uniqueness. The data-engineer's genesis.go
// holds the schema helpers (G-008 split).
type GenesisState struct {
Params Params `json:"params" yaml:"params"`
Routes []ExitRoute `json:"routes" yaml:"routes"`
Swaps []DEXSwap `json:"swaps" yaml:"swaps"`
}
func DefaultGenesisState() *GenesisState {
return &GenesisState{
Params: DefaultParams(),
Routes: []ExitRoute{},
Swaps: []DEXSwap{},
}
}
// ValidateGenesis performs ID-uniqueness checks (A-212 upgrade from v0.1
// no-op): rejects duplicate route-ids and swap-ids. Delegates to the
// data-engineer's genesis.go helpers (G-008).
func ValidateGenesis(bz json.RawMessage) error {
var gs GenesisState
if err := json.Unmarshal(bz, &gs); err != nil {
return fmt.Errorf("exit: invalid genesis: %w", err)
}
if err := ValidateRoutes(gs.Routes); err != nil {
return fmt.Errorf("exit: %w", err)
}
if err := ValidateSwaps(gs.Swaps); err != nil {
return fmt.Errorf("exit: %w", err)
}
return nil
}
+390
View File
@@ -0,0 +1,390 @@
package types_test
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/oy/openyield/lexicon"
etypes "github.com/oy/openyield/x/exit/types"
)
// --- ExitStatus enum (exactly 5) -----------------------------------------------
// TestExitStatusCountLockedConst asserts ExitStatusCount == 5 and
// AllExitStatuses() returns exactly 5 (vision §7, REQ-010, D-036). A
// regression firewall: adding/removing/renaming a status breaks this test.
func TestExitStatusCountLockedConst(t *testing.T) {
if etypes.ExitStatusCount != 5 {
t.Errorf("ExitStatusCount = %d, expected 5 (vision §7 LOCKED)", etypes.ExitStatusCount)
}
all := etypes.AllExitStatuses()
if len(all) != 5 {
t.Errorf("AllExitStatuses() len = %d, expected 5", len(all))
}
}
// TestAllExitStatusesNames asserts the 5 vision §7 exit-lifecycle names in
// order with no extras, no dups, no renames (Proposed, InProgress, Settled,
// Failed, Refunded).
func TestAllExitStatusesNames(t *testing.T) {
want := []string{"Proposed", "InProgress", "Settled", "Failed", "Refunded"}
all := etypes.AllExitStatuses()
if len(all) != len(want) {
t.Fatalf("len = %d, want %d", len(all), len(want))
}
seen := map[string]bool{}
for i, s := range all {
if string(s) != want[i] {
t.Errorf("AllExitStatuses()[%d] = %q, want %q", i, s, want[i])
}
if seen[string(s)] {
t.Errorf("duplicate ExitStatus %q", s)
}
seen[string(s)] = true
}
}
// TestExitStatusValues asserts each named const matches its AllExitStatuses
// entry.
func TestExitStatusValues(t *testing.T) {
if etypes.ExitProposed != "Proposed" {
t.Errorf("ExitProposed = %q", etypes.ExitProposed)
}
if etypes.ExitInProgress != "InProgress" {
t.Errorf("ExitInProgress = %q", etypes.ExitInProgress)
}
if etypes.ExitSettled != "Settled" {
t.Errorf("ExitSettled = %q", etypes.ExitSettled)
}
if etypes.ExitFailed != "Failed" {
t.Errorf("ExitFailed = %q", etypes.ExitFailed)
}
if etypes.ExitRefunded != "Refunded" {
t.Errorf("ExitRefunded = %q", etypes.ExitRefunded)
}
}
// --- ExitRoute struct (bridge-route-id by-ID-string — G-003/A-308) ----------------
// TestExitRouteStructFields asserts ExitRoute carries all required fields
// including the by-ID-string ref to x/bridge BridgeRoute (bridge-route-id)
// per A-308/G-003. No struct import of x/bridge (the G-003 import-invariant
// test enforces this).
func TestExitRouteStructFields(t *testing.T) {
r := etypes.ExitRoute{
RouteID: "route-1",
BridgeRouteID: "bridge-1", // by-ID-string ref to x/bridge (A-308/G-003)
Status: etypes.ExitProposed,
}
if r.RouteID != "route-1" {
t.Errorf("RouteID = %q", r.RouteID)
}
if r.BridgeRouteID != "bridge-1" {
t.Errorf("BridgeRouteID = %q", r.BridgeRouteID)
}
if r.Status != etypes.ExitProposed {
t.Errorf("Status = %q", r.Status)
}
}
// TestExitRouteBridgeRouteIDIsString asserts the BridgeRouteID field is an
// opaque string (by-ID-string ref — G-003), NOT a typed x/bridge.BridgeRoute
// import. This locks the by-ID-string invariant at the type level.
func TestExitRouteBridgeRouteIDIsString(t *testing.T) {
r := etypes.ExitRoute{BridgeRouteID: "bridge-9"}
// The field must be assignable from a plain string (no bridge.BridgeRoute
// type needed).
r.BridgeRouteID = "bridge-2"
if r.BridgeRouteID != "bridge-2" {
t.Errorf("BridgeRouteID = %q, want %q (must be plain string)", r.BridgeRouteID, "bridge-2")
}
}
// TestExitRouteBridgeRouteIDOptional asserts an empty bridge-route-id is
// valid (same-chain exits have no bridge hop).
func TestExitRouteBridgeRouteIDOptional(t *testing.T) {
r := etypes.ExitRoute{
RouteID: "same-chain-exit",
BridgeRouteID: "", // empty = same-chain exit (no bridge hop)
Status: etypes.ExitSettled,
}
if r.BridgeRouteID != "" {
t.Errorf("BridgeRouteID should be empty for same-chain exit; got %q", r.BridgeRouteID)
}
}
// --- DEXSwap struct (opaque venue — A-308) --------------------------------------
// TestDEXSwapStructFields asserts DEXSwap carries all required fields
// including the opaque venue string (A-308) and an ExitStatus.
func TestDEXSwapStructFields(t *testing.T) {
s := etypes.DEXSwap{
SwapID: "swap-1",
Venue: "uniswap-v3",
Status: etypes.ExitSettled,
}
if s.SwapID != "swap-1" {
t.Errorf("SwapID = %q", s.SwapID)
}
if s.Venue != "uniswap-v3" {
t.Errorf("Venue = %q", s.Venue)
}
if s.Status != etypes.ExitSettled {
t.Errorf("Status = %q", s.Status)
}
}
// TestDEXSwapVenueIsOpaqueString asserts the DEXSwap venue is an opaque
// string, NOT a locked enum (A-308 — venues are operational, locking now
// risks churn). The field must accept any free-form string.
func TestDEXSwapVenueIsOpaqueString(t *testing.T) {
// A-308: venue is an opaque string, not a locked enum. Various venue
// strings must be assignable without any enum type.
venues := []string{"uniswap-v3", "oy-dex", "1inch", "paraswap", "0x-api", "custom-venue-xyz"}
for _, v := range venues {
s := etypes.DEXSwap{SwapID: "s", Venue: v}
if s.Venue != v {
t.Errorf("Venue = %q, want %q (A-308: venue must be opaque string)", s.Venue, v)
}
}
}
// TestDEXSwapVenueTypeIsString asserts the Venue field's Go type is the
// built-in string (not a typed enum). This locks A-308 at the type level:
// the field is a plain string, so any venue string is assignable without
// conversion.
func TestDEXSwapVenueTypeIsString(t *testing.T) {
s := etypes.DEXSwap{}
// Assigning a plain string literal must compile and work — no enum
// conversion needed. If venue were a typed enum, assigning a plain
// string would require a type conversion (e.g. etypes.Venue("x")).
s.Venue = "any-string-works"
var want string = "any-string-works"
if s.Venue != want {
t.Errorf("Venue type is not plain string (A-308): got %q want %q", s.Venue, want)
}
}
// TestDEXSwapStatusReusesExitStatus asserts the DEXSwap status field reuses
// the ExitStatus enum (a swap shares the exit lifecycle).
func TestDEXSwapStatusReusesExitStatus(t *testing.T) {
statuses := etypes.AllExitStatuses()
for _, st := range statuses {
s := etypes.DEXSwap{SwapID: "s", Venue: "v", Status: st}
if s.Status != st {
t.Errorf("DEXSwap.Status = %q, want %q (must reuse ExitStatus)", s.Status, st)
}
}
}
// --- Genesis tests (A-212) ------------------------------------------------------
// TestDefaultGenesisStateEmpty asserts DefaultGenesisState returns non-nil
// empty slices for Routes and Swaps.
func TestDefaultGenesisStateEmpty(t *testing.T) {
gs := etypes.DefaultGenesisState()
if gs == nil {
t.Fatal("DefaultGenesisState returned nil")
}
if gs.Routes == nil || len(gs.Routes) != 0 {
t.Errorf("Default Routes should be non-nil empty slice; got len=%d nil=%v", len(gs.Routes), gs.Routes == nil)
}
if gs.Swaps == nil || len(gs.Swaps) != 0 {
t.Errorf("Default Swaps should be non-nil empty slice; got len=%d nil=%v", len(gs.Swaps), gs.Swaps == nil)
}
}
// TestValidateGenesisRejectsDupRouteIDs asserts A-212: duplicate route-ids
// are rejected.
func TestValidateGenesisRejectsDupRouteIDs(t *testing.T) {
gs := etypes.GenesisState{
Routes: []etypes.ExitRoute{
{RouteID: "r1", Status: etypes.ExitProposed},
{RouteID: "r1", Status: etypes.ExitSettled}, // dup
},
}
bz, _ := json.Marshal(gs)
if err := etypes.ValidateGenesis(bz); err == nil {
t.Error("ValidateGenesis should reject duplicate route-ids")
}
}
// TestValidateGenesisRejectsEmptyRouteID asserts empty route-id is rejected.
func TestValidateGenesisRejectsEmptyRouteID(t *testing.T) {
gs := etypes.GenesisState{
Routes: []etypes.ExitRoute{{RouteID: "", Status: etypes.ExitProposed}},
}
bz, _ := json.Marshal(gs)
if err := etypes.ValidateGenesis(bz); err == nil {
t.Error("ValidateGenesis should reject empty route-id")
}
}
// TestValidateGenesisRejectsUnknownRouteStatus asserts an unknown ExitStatus
// on a route is rejected.
func TestValidateGenesisRejectsUnknownRouteStatus(t *testing.T) {
gs := etypes.GenesisState{
Routes: []etypes.ExitRoute{{RouteID: "r1", Status: etypes.ExitStatus("Bogus")}},
}
bz, _ := json.Marshal(gs)
if err := etypes.ValidateGenesis(bz); err == nil {
t.Error("ValidateGenesis should reject unknown exit status on route")
}
}
// TestValidateGenesisRejectsDupSwapIDs asserts A-212: duplicate swap-ids
// are rejected.
func TestValidateGenesisRejectsDupSwapIDs(t *testing.T) {
gs := etypes.GenesisState{
Swaps: []etypes.DEXSwap{
{SwapID: "s1", Venue: "uniswap-v3", Status: etypes.ExitSettled},
{SwapID: "s1", Venue: "oy-dex", Status: etypes.ExitProposed}, // dup
},
}
bz, _ := json.Marshal(gs)
if err := etypes.ValidateGenesis(bz); err == nil {
t.Error("ValidateGenesis should reject duplicate swap-ids")
}
}
// TestValidateGenesisRejectsEmptySwapID asserts empty swap-id is rejected.
func TestValidateGenesisRejectsEmptySwapID(t *testing.T) {
gs := etypes.GenesisState{
Swaps: []etypes.DEXSwap{{SwapID: "", Venue: "oy-dex", Status: etypes.ExitProposed}},
}
bz, _ := json.Marshal(gs)
if err := etypes.ValidateGenesis(bz); err == nil {
t.Error("ValidateGenesis should reject empty swap-id")
}
}
// TestValidateGenesisRejectsUnknownSwapStatus asserts an unknown ExitStatus
// on a swap is rejected.
func TestValidateGenesisRejectsUnknownSwapStatus(t *testing.T) {
gs := etypes.GenesisState{
Swaps: []etypes.DEXSwap{{SwapID: "s1", Venue: "oy-dex", Status: etypes.ExitStatus("Bogus")}},
}
bz, _ := json.Marshal(gs)
if err := etypes.ValidateGenesis(bz); err == nil {
t.Error("ValidateGenesis should reject unknown exit status on swap")
}
}
// TestValidateGenesisRejectsBadJSON asserts malformed JSON is rejected.
func TestValidateGenesisRejectsBadJSON(t *testing.T) {
if err := etypes.ValidateGenesis(json.RawMessage(`{not json`)); err == nil {
t.Error("ValidateGenesis should reject malformed JSON")
}
}
// TestValidateGenesisAcceptsClean asserts a clean genesis validates.
func TestValidateGenesisAcceptsClean(t *testing.T) {
gs := etypes.GenesisState{
Routes: []etypes.ExitRoute{
{RouteID: "r1", BridgeRouteID: "bridge-1", Status: etypes.ExitInProgress},
{RouteID: "r2", BridgeRouteID: "", Status: etypes.ExitSettled}, // same-chain exit
},
Swaps: []etypes.DEXSwap{
{SwapID: "s1", Venue: "uniswap-v3", Status: etypes.ExitSettled},
{SwapID: "s2", Venue: "oy-dex", Status: etypes.ExitProposed},
},
}
bz, _ := json.Marshal(gs)
if err := etypes.ValidateGenesis(bz); err != nil {
t.Errorf("ValidateGenesis should accept clean genesis, got: %v", err)
}
}
// --- Module consts -------------------------------------------------------------
// TestModuleConsts asserts the four Cosmos-convention module consts.
func TestModuleConsts(t *testing.T) {
if etypes.ModuleName != "exit" {
t.Errorf("ModuleName = %q", etypes.ModuleName)
}
if etypes.StoreKey != "exit" {
t.Errorf("StoreKey = %q", etypes.StoreKey)
}
if etypes.RouterKey != "exit" {
t.Errorf("RouterKey = %q", etypes.RouterKey)
}
if etypes.QuerierRoute != "exit" {
t.Errorf("QuerierRoute = %q", etypes.QuerierRoute)
}
}
// TestDefaultParams asserts DefaultParams returns a zero-value Params.
func TestDefaultParams(t *testing.T) {
_ = etypes.DefaultParams() // no panics
}
// --- Lexicon assertion (REQ-012) -------------------------------------------------
//
// The exit module must avoid the banned financial holder terms (the
// lexicon firewall's banned list). Use "Holder"/"Reach" instead. The lexicon
// helpers are used here — no banned literals are inlined.
// TestLexiconNoBannedTermsInExitPackage scans every non-test .go file in
// the exit/types package directory for the banned terms (case-insensitive).
// Production files only — the test file references banned terms via the
// lexicon package helpers (standard lexicon-test bootstrapping pattern).
func TestLexiconNoBannedTermsInExitPackage(t *testing.T) {
pkgDir := packageDir(t, "github.com/oy/openyield/x/exit/types")
files, err := filepath.Glob(filepath.Join(pkgDir, "*.go"))
if err != nil {
t.Fatalf("glob: %v", err)
}
prodFiles := []string{}
for _, f := range files {
if strings.HasSuffix(f, "_test.go") {
continue
}
prodFiles = append(prodFiles, f)
}
if len(prodFiles) == 0 {
t.Fatal("no production .go files found in exit/types")
}
for _, f := range prodFiles {
bz, err := os.ReadFile(f)
if err != nil {
t.Fatalf("read %s: %v", f, err)
}
if found, ok := lexicon.FindBannedTerm(string(bz)); ok {
t.Errorf("%s: banned term %q (REQ-012 lexicon firewall — use Holder/Reach, not banned financial terms)", filepath.Base(f), found)
}
}
}
// TestLexiconNoBannedTermsInExitTestFile asserts this test file itself
// does not contain any banned term as a literal.
func TestLexiconNoBannedTermsInExitTestFile(t *testing.T) {
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
bz, err := os.ReadFile(thisFile)
if err != nil {
t.Fatalf("read self: %v", err)
}
if found, ok := lexicon.FindBannedTerm(string(bz)); ok {
t.Fatalf("exit test file contains banned term %q — use lexicon helpers, not literals", found)
}
}
// packageDir resolves a Go import path to its filesystem directory by
// walking up from this test file (v0.3 skeleton has zero external deps).
func packageDir(t *testing.T, importPath string) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
// file = .../oy/x/exit/types/types_test.go -> repoRoot = .../oy (4 dirs up)
repoRoot := filepath.Dir(filepath.Dir(filepath.Dir(filepath.Dir(file))))
rel := strings.TrimPrefix(importPath, "github.com/oy/openyield/")
return filepath.Join(repoRoot, rel)
}