Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c1aa274ab1 | |||
| 5dc253d174 | |||
| ab43befdaf | |||
| 349453ecd9 | |||
| 2ef3f2e39f | |||
| e493216b8a | |||
| d09c6132b1 | |||
| fa4ee47bde | |||
| a780884379 | |||
| cb394cb516 |
@@ -1,12 +1,14 @@
|
||||
{
|
||||
"phase": 0,
|
||||
"stage": "grill",
|
||||
"phase": 4,
|
||||
"stage": "complete",
|
||||
"milestone": "v0.3",
|
||||
"milestone_type": "feature",
|
||||
"tag_base": "v0.2.x",
|
||||
"phase_role": "pre_execution",
|
||||
"phase_role": "execution",
|
||||
"project": "oy",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-08-17T23:30:00Z",
|
||||
"milestone_complete": false
|
||||
"updated_at": "2026-08-18T00:35:00Z",
|
||||
"milestone_complete": false,
|
||||
"phase_release_tag": "v0.2.4",
|
||||
"release_id": 737
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
# OpenYield
|
||||
|
||||
OpenYield is a jurisdiction-light, public-good mesh for **real production** — a
|
||||
protocol organized around Holders, Stands, and the Six Principles, designed to
|
||||
hold real value without the words or the shapes that invite capture. The mesh
|
||||
runs on OY Chain (Layer 1), a canonical state layer for the Bread unit, the
|
||||
Storage Pools (Stash, Vault, Root-Pool), Standing, Watcher attestations, and
|
||||
the Pact / Council / Partner surface. It is anti-greed by construction: Mission
|
||||
Lock fixes the Six Principles and fee covenant so no council can amend them,
|
||||
and the 8% coupon cap on bonds is a mission-locked ceiling, not a parameter.
|
||||
|
||||
## The Six Principles
|
||||
|
||||
1. **Real value** — the mesh holds real production, not speculation.
|
||||
2. **Sustainability** — fees are floored and capped; the protocol cannot drain its users.
|
||||
3. **Mission-lock** — the Six Principles and fee covenant are immutable; no council can amend them.
|
||||
4. **Openness** — anyone may join; the mesh is a public good.
|
||||
5. **Ownership** — Holders own their Stash and their Reach; custody is theirs.
|
||||
6. **Self-service** — a Holder can act without a custodian; the mesh is jurisdiction-light.
|
||||
|
||||
## Bread unit & scale
|
||||
|
||||
The unit of value is **Bread**, scaled in 11 tiers: **Grain → Crumb → Bread →
|
||||
Loaf → Batch → Cake → Bakery → Granary → Mill → Harvest → Earth.**
|
||||
|
||||
## Status
|
||||
|
||||
**v0.3 (Bearers & Documentation) — in progress.** The codebase is a skeleton +
|
||||
tests layer (Go types + keeper stubs + invariant tests, zero external Go deps)
|
||||
matching the v0.1/v0.2 pre-MVP pattern. See `.ciagent/oy/ROADMAP.md` for the
|
||||
phase plan and `.ciagent/oy/PROJECT.md` for governance.
|
||||
|
||||
## Build & test
|
||||
|
||||
OpenYield is pure Go with **zero external dependencies** (`go.mod` has no
|
||||
`require` lines; `go 1.22`). From the repo root:
|
||||
|
||||
```sh
|
||||
go build ./...
|
||||
go test ./...
|
||||
```
|
||||
|
||||
## Docs
|
||||
|
||||
The docs site is [MkDocs Material](https://squidfunk.github.io/mkdocs-material/)
|
||||
(a build-only Python dep; **not** a Go dep — `go.mod` is unchanged). To
|
||||
preview locally:
|
||||
|
||||
```sh
|
||||
mkdocs serve
|
||||
# or build to a static site/ dir:
|
||||
mkdocs build
|
||||
```
|
||||
|
||||
The site lives under `docs/` (see `mkdocs.yml` for the nav). Publishing CI is
|
||||
deferred to v0.4 (D-046); v0.3 ships the source.
|
||||
|
||||
## Lexicon firewall
|
||||
|
||||
OpenYield bans 10 financial terms as standalone words (REQ-012) across all Go
|
||||
source (`x/**/*.go`) and all docs (`README.md` + `docs/**/*.md`). The banned
|
||||
terms are the words you would expect a legacy financial institution to use;
|
||||
this README and the docs describe them only by their **safe replacements**, so
|
||||
the firewall itself never trips. The firewall is enforced in code by two
|
||||
sibling Go tests:
|
||||
|
||||
- `lexicon_meta_test.go` (v0.2) — scans `x/**/*.go`.
|
||||
- `lexicon_meta_docs/lexicon_meta_docs_test.go` (v0.3) — scans `README.md` +
|
||||
`docs/**/*.md`.
|
||||
|
||||
Both use `lexicon.FindBannedTerm` (word-boundary, case-insensitive), so
|
||||
"OpenYield" is safe (word-boundary does not match the banned term inside an
|
||||
identifier) but the standalone banned term is not — docs say **"real
|
||||
production"** / **"real return"**, and a Holder's identity is **Holder** /
|
||||
**Reach**, never the banned word for a custodial position. See
|
||||
`docs/shared/lexicon.md` for the glossary of safe replacements.
|
||||
|
||||
## Governance
|
||||
|
||||
- `.ciagent/oy/PROJECT.md` — full vision, decisions (D-0xx), assumptions.
|
||||
- `.ciagent/oy/PLANS.md` — phase plans (v0.1, v0.2, v0.3).
|
||||
- `.ciagent/oy/REQUIREMENTS.md` — REQ coverage matrix.
|
||||
- `.ciagent/oy/ROADMAP.md` — release roadmap.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Anchor Preview
|
||||
|
||||
An **Anchor** (REQ-023) is the fourth and highest tier of the
|
||||
[Partner Spectrum](partner-spectrum.md) (REQ-018) — the first **institutional**
|
||||
Partner tier. Anchors are coming in v0.3 P4. This page previews what an Anchor
|
||||
is and what the v0.3 skeleton will deliver; the runtime behavior is deferred
|
||||
to v0.4+.
|
||||
|
||||
## What an Anchor is
|
||||
|
||||
An Anchor is a Partner that carries an **AnchorCredential**: a jurisdiction
|
||||
(e.g., "EU-MiCA"), a custody provider, and a set of attestation references.
|
||||
The Anchor tier is how the jurisdiction-light mesh interfaces with
|
||||
jurisdiction-bound institutional actors without becoming them. An Anchor
|
||||
holds a credential; the [Holder](../nomads/reach.md) still holds their
|
||||
[Stash](../nomads/stash.md). The mesh says **custody**, **compliance**, and
|
||||
**jurisdiction** — never the legacy institutional words banned by the
|
||||
[lexicon](../shared/lexicon.md).
|
||||
|
||||
## What is coming in v0.3 P4
|
||||
|
||||
v0.3 P4 (REQ-023) extends `x/partner` with the `AnchorCredential` struct and
|
||||
a `Partner.AnchorCredential()` accessor (returns nil for non-Anchor tiers).
|
||||
The four-tier `PartnerTier` enum (Op, Master Op, Pier, Anchor) is **unchanged**
|
||||
— v0.3 adds Anchor-specific fields, not a new tier. The custody-provider-id
|
||||
field is a by-ID-string reference to `x/hub` (the Hub API, coming in v0.3 P5),
|
||||
empty in the v0.3 skeleton because the Hub is not live until P5/v0.4. This is
|
||||
the P4→P5 ordering edge: `x/hub` in P5 references Anchor partner-ids from P4.
|
||||
|
||||
## Why Anchors matter to a Freeholder
|
||||
|
||||
A Freeholder engaging an Anchor gets a Partner with a verifiable credential
|
||||
and a custody/compliance relationship — useful for cross-jurisdiction routes
|
||||
and institutional [bonds](bonds.md). The Anchor's [Standing](standing.md) and
|
||||
attestations are visible so the Freeholder can verify the Anchor is real
|
||||
before opening a [Window](../nomads/window.md). See
|
||||
[Partner Spectrum](partner-spectrum.md) for the other three tiers, and
|
||||
[Councils & Voice](councils-voice.md) for how the Mesh Council can suspend or
|
||||
revoke an Anchor.
|
||||
|
||||
## What v0.3 does not deliver
|
||||
|
||||
The v0.3 skeleton is types + tests only (D-035): the `AnchorCredential`
|
||||
struct, the accessor, and the `ListAnchors()` keeper alias. Live custody
|
||||
routing, attestation verification, and the Hub API integration are v0.4+
|
||||
runtime work. See [Components](../reference/components.md) for the full
|
||||
module map.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Bonds
|
||||
|
||||
A **Mesh Bond** (REQ-021, vision §17) is a [Stand](stands-guilds.md)-issued
|
||||
instrument that pays a **coupon** to its holder over a term and returns the
|
||||
principal at maturity. The coupon is bounded by a **mission-locked cap and
|
||||
floor**: 8% upper cap, 0% floor (locked `CouponCapBps = 800` and
|
||||
`CouponFloorBps = 0` in `x/bond`). The cap exists so the mesh cannot become a
|
||||
speculative market; the floor exists so the coupon cannot go negative.
|
||||
|
||||
## The coupon clamp
|
||||
|
||||
The coupon is clamped to `[floor, cap]` by the `Clamp` helper in `x/bond`
|
||||
(same shape as the [Fee Covenant](../shared/six-principles.md) clamp): a
|
||||
coupon above 8% is reduced to 8%; a coupon below 0% is raised to 0%; a coupon
|
||||
in range is unchanged. The clamp is a tested invariant: below floor → floor,
|
||||
above cap → cap, in range → unchanged. This is the Mission Lock's expression
|
||||
in the capital layer.
|
||||
|
||||
## Why a cap
|
||||
|
||||
OpenYield is a public-good mesh for **real production**, not a speculation
|
||||
engine. An uncapped coupon market would let a Stand offer arbitrarily high
|
||||
coupons to attract Bread, turning the mesh into a speculative race. The 8%
|
||||
cap bounds the coupon at a level consistent with real production returns, and
|
||||
the [Mission Lock](councils-voice.md) makes the cap non-amendable — no Council
|
||||
vote can raise it. The mesh says **coupon** and **real return**, never the
|
||||
passive-value or standalone-metric words banned by the
|
||||
[lexicon](../shared/lexicon.md).
|
||||
|
||||
## The bond lifecycle
|
||||
|
||||
A Bond moves through five states (locked `BondStatus` enum in `x/bond`):
|
||||
Issued → Active → Matured, with Defaulted and Repaid as terminal paths. The
|
||||
issuer is a Stand (referenced by stand-id); the principal is denominated in
|
||||
[Grain](../shared/bread-scale.md). The bond market is governed by the
|
||||
[Stand Council](councils-voice.md) for the issuing Stand.
|
||||
|
||||
## Coming in v0.3 P5
|
||||
|
||||
v0.3 P5 (REQ-026) extends the bond market with **Growth Bonds** (a coupon that
|
||||
grows over the term, still clamped to the 8% cap) and a **secondary market**
|
||||
(Buy/Sell orders on issued bonds). The 8% / 0% consts are unchanged — the
|
||||
D-028 regression firewall guarantees v0.3 cannot alter the v0.2 mission-locked
|
||||
ceiling. See [Partner Spectrum](partner-spectrum.md) for how Partners relate
|
||||
to the bond market, and [Anchor Preview](anchor-preview.md) for the
|
||||
institutional tier.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Councils & Voice
|
||||
|
||||
OpenYield governs itself through three **Councils** (REQ-011, vision §19):
|
||||
the Mesh Council, the Guild Council, and the Stand Council. Each Freeholder
|
||||
participates through the Councils, weighted by **Voice** — a multi-source
|
||||
weight that combines [Stash](../nomads/stash.md), [Standing](standing.md),
|
||||
Vouch, Freeholder status, and Guild membership. The **Mission Lock** makes
|
||||
the covenant non-amendable: no Council can vote to change the
|
||||
[Six Principles](../shared/six-principles.md) or the fee covenant.
|
||||
|
||||
## The three Councils
|
||||
|
||||
- **Mesh Council** — the mesh-wide Council. Handles protocol-level proposals
|
||||
that affect every Holder and every [Stand](stands-guilds.md).
|
||||
- **Guild Council** — the Council for [Guilds](stands-guilds.md). Handles
|
||||
Guild-scope proposals, referenced by guild-id.
|
||||
- **Stand Council** — the Council for a single Stand, referenced by stand-id.
|
||||
Handles Stand-scope proposals (e.g., Vault use, [Bond](bonds.md) issuance).
|
||||
|
||||
The three-tier shape mirrors the three [Storage Pools](../shared/storage-pools.md):
|
||||
a Council exists at each layer where custody is held.
|
||||
|
||||
## Multi-source Voice
|
||||
|
||||
Voice is not one number. It is a weighted tally from five sources (locked as
|
||||
the `VoiceSource` enum in `x/council`): Stash, Standing, Vouch, Freeholder,
|
||||
and Guild. A Freeholder with high [Standing](standing.md) and a long-held
|
||||
Stash carries more Voice than a freshly-minted one. The
|
||||
[TallyResult](../reference/components.md) mirrors the Cosmos SDK `x/gov`
|
||||
shape so the governance layer can wire to standard tooling. The VoteOption
|
||||
enum is **Yes / No / Abstain** — there is no "no-with-veto", an anti-greed
|
||||
design choice.
|
||||
|
||||
## Mission Lock
|
||||
|
||||
The Mission Lock is a locked `const bool` in `x/council`
|
||||
(`MissionLockAmendable = false`). The Six Principles, the fee covenant
|
||||
(ceiling 0.1% / floor 0.01% / 1-Grain minimum), and the bond coupon cap
|
||||
([8% / 0%](bonds.md)) cannot be amended by any Council vote. This is the
|
||||
firewall that keeps the mesh a public good: governance can act *within* the
|
||||
covenant, never *on* the covenant.
|
||||
|
||||
## How a Freeholder participates
|
||||
|
||||
A Freeholder submits or votes on proposals in the Councils they belong to.
|
||||
Each vote is weighted by multi-source Voice; the tally follows `x/gov`
|
||||
semantics. See [Bonds](bonds.md) for the coupon cap the Mission Lock protects,
|
||||
and [Standing](standing.md) for the metric that weights a Freeholder's Voice.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Freeholders
|
||||
|
||||
A **Freeholder** is a Holder who has earned all four Freeholder signals (REQ-005):
|
||||
a 90-day [Stash](../nomads/stash.md), a [Standing](standing.md) threshold of
|
||||
4.5★ or higher in 3 categories, the Capital signal, and the Vouch signal. A
|
||||
Freeholder is the active participant in the OpenYield mesh — they sit in
|
||||
[Stands & Guilds](stands-guilds.md), vote in the three
|
||||
[Councils & Voice](councils-voice.md), issue [Bonds](bonds.md), and relate to
|
||||
the four-tier [Partner Spectrum](partner-spectrum.md).
|
||||
|
||||
## The four signals
|
||||
|
||||
The signals are the gate to Freeholder participation. They are deliberately
|
||||
heterogeneous — no single input can be pumped — so the path resists gaming:
|
||||
|
||||
- [Signals](signals.md) — the four Freeholder signals (REQ-005): 90-day Stash,
|
||||
4.5★+ in 3 categories, Capital, Vouch.
|
||||
- [Standing](standing.md) — the Bayesian anti-gaming formula (REQ-006):
|
||||
Bayesian prior + time-decay + diversity + voucher-weighting − slashes.
|
||||
- [Stands & Guilds](stands-guilds.md) — the nine Stand types (REQ-016) and
|
||||
Guilds with free Hand-Passes (REQ-017).
|
||||
- [Councils & Voice](councils-voice.md) — the three Councils and the
|
||||
non-amendable Mission Lock (REQ-011).
|
||||
- [Bonds](bonds.md) — the Mesh Bond Market, the 8% coupon cap / 0% floor
|
||||
(REQ-021).
|
||||
- [Partner Spectrum](partner-spectrum.md) — the four Partner tiers (REQ-018):
|
||||
Op, Master Op, Pier, Anchor.
|
||||
- [Anchor Preview](anchor-preview.md) — the first institutional Partner tier
|
||||
(REQ-023), coming in v0.3 P4.
|
||||
|
||||
## What a Freeholder does
|
||||
|
||||
A Freeholder is a Holder who has crossed the signal gate. From there the mesh
|
||||
opens: a Freeholder joins a [Stand](stands-guilds.md) (or forms a Guild), votes
|
||||
in the [Councils](councils-voice.md) with multi-source Voice, issues or holds
|
||||
[Bonds](bonds.md) under the mission-locked coupon cap, and engages the
|
||||
[Partner Spectrum](partner-spectrum.md) — including the Anchor tier coming in
|
||||
v0.3. The covenant is the same for every audience; the Freeholder pages
|
||||
describe how it shows up in governance and capital.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Partner Spectrum
|
||||
|
||||
OpenYield defines a four-tier **Partner Spectrum** (REQ-018, vision §13):
|
||||
**Op**, **Master Op**, **Pier**, and **Anchor**. Partners are the external
|
||||
actors a [Freeholder](index.md) interacts with through the mesh — service
|
||||
operators, route providers, and institutional bridges. The four tiers are
|
||||
locked as the `PartnerTier` enum in `x/partner` (exactly 4, regression-tested).
|
||||
|
||||
## The four tiers
|
||||
|
||||
- **Op** — a service operator. Runs a service a Holder uses through a
|
||||
[Window](../nomads/window.md) (e.g., a Maps provider). The lightest tier.
|
||||
- **Master Op** — a senior operator. Coordinates multiple Ops or runs a
|
||||
higher-trust service. "Op" is the safe short form; the full word is not
|
||||
used as a standalone term.
|
||||
- **Pier** — a routing Partner. Connects the mesh to external venues (e.g.,
|
||||
a DEX or an off-mesh service) and sources [Forex](../reference/components.md)
|
||||
rates. Piers route; they do not custody Holder value.
|
||||
- **Anchor** — the first institutional Partner tier. Carries a credential
|
||||
(jurisdiction, custody provider, attestations). See
|
||||
[Anchor Preview](anchor-preview.md) for what is coming in v0.3 P4.
|
||||
|
||||
## How Freeholders relate to Partners
|
||||
|
||||
A Freeholder authorizes a Partner to act on their behalf through a scoped,
|
||||
time-limited, revocable [Window](../nomads/window.md) — never by handing over
|
||||
custody. The Partner holds a credential, not the Holder's [Stash](../nomads/stash.md).
|
||||
A Partner's [Standing](standing.md) is visible so a Freeholder can choose an
|
||||
operator with a real history over a freshly-spun-up alternative (see
|
||||
[Maps & Pay](../nomads/maps-pay.md)).
|
||||
|
||||
## Partner status
|
||||
|
||||
Each Partner has a status (locked `PartnerStatus` enum in `x/partner`):
|
||||
Pending → Active, with Suspended and Revoked as the governance paths. The
|
||||
[Mesh Council](councils-voice.md) can suspend or revoke a Partner. The four
|
||||
tiers and the status enum are unchanged by v0.3 — v0.3 only *extends*
|
||||
`x/partner` with the Anchor credential shape (REQ-023), not a new tier.
|
||||
|
||||
See [Storage Pools](../shared/storage-pools.md) for why the mesh says
|
||||
"Holder" and "Reach" rather than the legacy custodial words, and
|
||||
[Bonds](bonds.md) for the coupon market a Partner may route to.
|
||||
@@ -0,0 +1,45 @@
|
||||
# The Four Freeholder Signals
|
||||
|
||||
The four **Freeholder signals** (REQ-005) are the gate to Freeholder
|
||||
participation. A [Holder](../nomads/reach.md) who earns all four becomes a
|
||||
[Freeholder](index.md) — eligible to join [Stands & Guilds](stands-guilds.md),
|
||||
vote in the [Councils](councils-voice.md), and issue [Bonds](bonds.md). The
|
||||
signals are deliberately heterogeneous: no single input can be pumped, so the
|
||||
path resists gaming.
|
||||
|
||||
## 1. The 90-day Stash
|
||||
|
||||
A Holder must hold a [Stash](../nomads/stash.md) continuously for 90 days
|
||||
(REQ-014). The signal is about **continuity, not size** — a small Stash held
|
||||
steadily counts. This filters out transient actors who spin up a position to
|
||||
game a vote and then leave. See [Storage Pools](../shared/storage-pools.md)
|
||||
for the three-pool model.
|
||||
|
||||
## 2. Standing of 4.5★ or higher in 3 categories
|
||||
|
||||
A Holder must earn a [Standing](standing.md) of 4.5★ or higher in **three
|
||||
distinct categories** (REQ-006). The diversity requirement is the anti-gaming
|
||||
core: a Holder cannot reach Freeholder by repeating the same action with the
|
||||
same counterparty. Three categories force breadth.
|
||||
|
||||
## 3. Capital
|
||||
|
||||
The Capital signal requires a Holder to hold a meaningful amount of
|
||||
[Bread](../shared/bread-scale.md) in their Stash. The threshold is set by the
|
||||
mesh [Councils](councils-voice.md) and is a stake, not a fee: the Holder keeps
|
||||
the Bread. Capital aligns the Freeholder's stake with the mesh.
|
||||
|
||||
## 4. Vouch
|
||||
|
||||
The Vouch signal requires another Freeholder to vouch for the Holder. A
|
||||
vouch from a high-[Standing](standing.md) Freeholder carries more weight
|
||||
(voucher-weighting), so a single colluding vouch cannot carry a Holder over
|
||||
the gate. Vouch is the social signal that ties the other three together.
|
||||
|
||||
## Why four, not one
|
||||
|
||||
Each signal covers a different attack surface: continuity (90-day Stash),
|
||||
breadth (3-category Standing), stake (Capital), and social trust (Vouch).
|
||||
Earning all four is the proof a Holder is a participant, not a transient
|
||||
gamer. See [Standing](standing.md) for the anti-gaming math, and
|
||||
[Bonds](bonds.md) for what a Freeholder can do once the signals are earned.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Bayesian Standing
|
||||
|
||||
**Standing** (REQ-006) is a Holder's measured history on the mesh — the
|
||||
anti-gaming metric that gates [Freeholder](index.md) participation and weighs
|
||||
[Voice](councils-voice.md) in the [Councils](councils-voice.md). Standing is
|
||||
not a count of transactions and not a reputation score you can farm. It is a
|
||||
Bayesian score that resists the obvious attacks: volume spam, self-dealing,
|
||||
fake vouches.
|
||||
|
||||
## The formula, at conceptual depth
|
||||
|
||||
Standing combines four signals and a penalty:
|
||||
|
||||
- **Bayesian prior + updates.** The mesh starts with a prior for each Holder
|
||||
and updates it from each observed action. A burst of activity cannot
|
||||
inflate Standing because the prior anchors it.
|
||||
- **Time-decay.** Old evidence decays, so a Holder cannot rest on a burst
|
||||
from years ago. Standing reflects *recent, sustained* real production.
|
||||
- **Diversity weighting.** A Holder who acts across many services, many
|
||||
[Stands](stands-guilds.md), and many [bearers](../nomads/bearers.md) accrues
|
||||
more Standing than one who repeats the same action with the same
|
||||
counterparty. Diversity is the anti-collusion lever.
|
||||
- **Voucher-weighting.** A vouch from a high-Standing Freeholder counts for
|
||||
more than a vouch from a low-Standing one. This makes fake vouches expensive:
|
||||
the voucher must themselves have Standing to lose.
|
||||
- **Minus slashes.** Bad behavior (failed attestations, broken Pacts) removes
|
||||
Standing. Slashes are the penalty that bounds the upside of gaming.
|
||||
|
||||
> The full sub-tables (priors, decay rates, diversity categories, slash
|
||||
> conditions) are deferred per PROJECT.md Q2. This page gives the conceptual
|
||||
> depth; the [nomads Standing page](../nomads/standing.md) gives the plain-
|
||||
> language version.
|
||||
|
||||
## Why it cannot be gamed
|
||||
|
||||
There is no single input a Holder can pump. Volume is bounded by the Bayesian
|
||||
prior; recency is bounded by time-decay; breadth is bounded by diversity;
|
||||
social trust is bounded by voucher-weighting; and any attempt that misfires
|
||||
costs Standing via slashes. The four signals (the [90-day Stash](signals.md),
|
||||
3-category threshold, Capital, Vouch) sit on top of this metric, so the
|
||||
Freeholder gate inherits the same anti-gaming property.
|
||||
|
||||
## What Standing is not
|
||||
|
||||
Standing is not a custodial position, a tier you buy, or legacy history. It
|
||||
is a measured, decayed, diversified Bayesian score. See
|
||||
[Storage Pools](../shared/storage-pools.md) for why the mesh says "Stash"
|
||||
rather than the legacy custodial words, and [Councils & Voice](councils-voice.md)
|
||||
for how Standing weights a Freeholder's vote.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Stands & Guilds
|
||||
|
||||
A **Stand** is a governed group of Holders that holds a [Vault](../shared/storage-pools.md)
|
||||
in common (REQ-016). A **Guild** is a looser association of Holders that can
|
||||
pass value among its members for free (REQ-017). Both are the organizational
|
||||
layer a [Freeholder](index.md) joins after earning the four
|
||||
[signals](signals.md).
|
||||
|
||||
## The nine Stand types
|
||||
|
||||
OpenYield defines exactly nine Stand types (REQ-016, vision §11), locked as a
|
||||
const in `x/stand`:
|
||||
|
||||
1. **Household** — a family-scale group.
|
||||
2. **Crew** — a working team.
|
||||
3. **Entity** — a single legal actor.
|
||||
4. **Co-op** — a cooperative.
|
||||
5. **Circle** — an affinity group.
|
||||
6. **Trust** — a trust arrangement.
|
||||
7. **Foundation** — a purpose-bound entity.
|
||||
8. **Confederation** — a federation of Stands.
|
||||
9. **Shadow** — a privacy-preserving Stand.
|
||||
|
||||
A Stand's decision policy (threshold or weighted, mirroring the Cosmos SDK
|
||||
`x/group` shape) governs how its Vault is used. A Stand can also issue
|
||||
[Bonds](bonds.md) — the bond issuer is a Stand, referenced by stand-id.
|
||||
|
||||
## Guilds and Hand-Passes
|
||||
|
||||
A **Guild** is a looser association: it may affiliate with a Stand or stand
|
||||
alone. Inside a Guild, a **Hand-Pass** moves [Bread](../shared/bread-scale.md)
|
||||
between members at a **0% protocol fee** (REQ-017, locked `HandPassFeeBps = 0`
|
||||
in `x/guild`). The 0% fee is mission-locked: the mesh does not tax the social
|
||||
transfer of value among a self-organized group. See the
|
||||
[Fee Covenant](../shared/six-principles.md) for the broader fee shape.
|
||||
|
||||
## How a Freeholder joins
|
||||
|
||||
A Freeholder joins a Stand by becoming a member (the Stand's policy admits
|
||||
them) or forms a Guild as a founder. Membership is recorded in `x/stand`
|
||||
and `x/guild` respectively, by stand-id / guild-id and the member's
|
||||
[Reach](../nomads/reach.md). From a Stand a Freeholder gains Vault access and
|
||||
the ability to issue [Bonds](bonds.md); from a Guild a Freeholder gains free
|
||||
Hand-Passes with other members.
|
||||
|
||||
See [Councils & Voice](councils-voice.md) for how Stands and Guilds each get a
|
||||
Council, and [Storage Pools](../shared/storage-pools.md) for the Vault layer.
|
||||
@@ -0,0 +1,30 @@
|
||||
# OpenYield
|
||||
|
||||
OpenYield is a jurisdiction-light, public-good mesh for **real production**. It
|
||||
runs on OY Chain (Layer 1), a canonical state layer for the Bread unit, the
|
||||
Storage Pools, Standing, Watcher attestations, and the Pact / Council /
|
||||
Partner surface. The mesh is anti-greed by construction: Mission Lock fixes
|
||||
the Six Principles and fee covenant so no council can amend them, and the
|
||||
coupon cap on bonds is a mission-locked ceiling, not a parameter.
|
||||
|
||||
## Audiences
|
||||
|
||||
The docs are organized by audience:
|
||||
|
||||
- **Nomads** — the everyday Holder: your Reach, your Stash, your bearers, how
|
||||
you pay (Maps-Pay), the Pacts you join, and the Window you open. See
|
||||
[Nomads](nomads/index.md).
|
||||
- **Freeholders** — the active participant: the four signals, Bayesian
|
||||
Standing, Stands & Guilds, the three Councils and Voice, the bond market,
|
||||
and the four-tier Partner Spectrum. See [Freeholders](freeholders/index.md).
|
||||
- **Shared** — concepts common to every audience: the Six Principles, the
|
||||
Bread scale, the three Storage Pools, the Watchers & Mirror, the lexicon
|
||||
glossary, and the vision overview. See [Shared](shared/index.md).
|
||||
- **Reference** — the architecture and component map. See
|
||||
[Reference](reference/architecture.md).
|
||||
|
||||
## Build the docs
|
||||
|
||||
This site is [MkDocs Material](https://squidfunk.github.io/mkdocs-material/),
|
||||
a build-only Python dep (not a Go dep). To preview locally, see the
|
||||
[README](../README.md) for build instructions.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Bearers
|
||||
|
||||
The **bearers** (REQ-019) are how a Nomad reaches the mesh. OpenYield ships
|
||||
six bearers through a single **Unified Bearer Layer**: the mesh does not
|
||||
care which bearer a Holder uses — first-to-deliver-wins, and a Nomad can
|
||||
switch bearers without switching identity. The [Mirror](../shared/watchers-mirror.md)
|
||||
mirrors the canonical state to every bearer so a Nomad can read the mesh's
|
||||
real return on any of them.
|
||||
|
||||
## The six bearers
|
||||
|
||||
| Bearer | Live in v0.2 | What it is |
|
||||
|---|---|---|
|
||||
| **Internet** | yes | the default bearer; OY Chain over the open internet. |
|
||||
| **OY-BLE** | yes | Bluetooth Low Energy; short-range, peer-to-peer, no phone plan. |
|
||||
| **OY-WiFi-Direct** | yes | WiFi Direct; local mesh without an access point. |
|
||||
| **OY-LR** | yes | Long Range radio (LoRa-class); long-distance, low-bandwidth, surveillance-resistant. |
|
||||
| **OY-SAT** | coming (v0.3 P4) | satellite; offline coverage via a satellite constellation. |
|
||||
| **OY-QR** | coming (v0.3 P4) | signed QR code; one-shot offline transfer scanned by a peer. |
|
||||
|
||||
## What this means for a Nomad
|
||||
|
||||
A Nomad does not pick "the right bearer". The four already-live bearers
|
||||
(Internet, OY-BLE, OY-WiFi-Direct, OY-LR) cover the everyday situations:
|
||||
on the open internet, in a room with another Holder, in a local group with
|
||||
no router, or kilometers away with no infrastructure. OY-SAT and OY-QR
|
||||
extend that to true-offline paths and are coming in the next phase.
|
||||
|
||||
## Surveillance resistance
|
||||
|
||||
OY-LR, OY-BLE, OY-WiFi-Direct, OY-SAT, and OY-QR are designed to be
|
||||
surveillance-resistant: a Nomad can send or receive value without a
|
||||
phone plan, a SIM, or a custodial on-ramp. The bearer is the transport; the
|
||||
[Reach](reach.md) is the identity; the [Stash](stash.md) is the storage. None
|
||||
of them depends on a custodial position.
|
||||
|
||||
## First-to-deliver-wins
|
||||
|
||||
The Unified Bearer Layer is first-to-deliver-wins: if a Nomad sends a
|
||||
transfer over two bearers at once, the mesh accepts the first one that
|
||||
arrives and drops the duplicate. This is why a Nomad can switch bearers
|
||||
mid-transfer without double-spending.
|
||||
|
||||
See [Watchers & Mirror](../shared/watchers-mirror.md) for how the canonical
|
||||
state is mirrored to every bearer, and [Maps & Pay](maps-pay.md) for how a
|
||||
Nomad uses a bearer to find and pay for services.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Nomads
|
||||
|
||||
A **Nomad** is a person using the OpenYield mesh through a **Reach** — the
|
||||
protocol-level identity a Holder uses to act on the mesh without a
|
||||
custodian, a gatekeeper, or a legacy financial position. The Nomad path
|
||||
is the entry path: a Nomad is a Holder who has a Reach and a [Stash](stash.md)
|
||||
and is on the way to earning the four Freeholder signals, but has not yet
|
||||
earned all four.
|
||||
|
||||
## The Nomad path
|
||||
|
||||
The pages here cover what a Nomad does day-to-day on the mesh:
|
||||
|
||||
- [Reach](reach.md) — the identity; the first Freeholder signal (REQ-005).
|
||||
- [Stash](stash.md) — the personal [Storage Pool](../shared/storage-pools.md)
|
||||
where a Nomad holds Bread (REQ-014).
|
||||
- [Bearers](bearers.md) — how a Nomad reaches the mesh (REQ-019): Internet,
|
||||
OY-BLE, OY-WiFi-Direct, OY-LR live now; OY-SAT and OY-QR coming.
|
||||
- [Maps & Pay](maps-pay.md) — finding services and paying for them.
|
||||
- [Pacts](pacts.md) — the six contract shapes a Nomad encounters
|
||||
(REQ-020): Pause, Ground, Stance, Cover, Stand Registry, Hub API.
|
||||
- [Standing](standing.md) — the Bayesian anti-gaming metric (REQ-006),
|
||||
and why the mesh cannot be gamed.
|
||||
- [Window](window.md) — the delegation primitive (REQ-015): scope,
|
||||
duration, rate-limit, audit-log, revoke.
|
||||
|
||||
## Where a Nomad starts
|
||||
|
||||
A Nomad starts with a Reach and a Stash — that is enough to begin. From
|
||||
there the bearers carry value to the Stash, Maps finds services, Pay and
|
||||
the Window let a Nomad use them without giving up custody, and Standing
|
||||
accrues as the Nomad acts. A Nomad who earns the 90-day Stash signal, the
|
||||
Standing threshold, the Capital signal, and the Vouch signal becomes a
|
||||
Freeholder (see the Freeholders section).
|
||||
|
||||
## Shared concepts
|
||||
|
||||
The Nomad path rests on the [shared concepts](../shared/index.md): the
|
||||
[Six Principles](../shared/six-principles.md), the [Bread scale](../shared/bread-scale.md),
|
||||
the [Storage Pools](../shared/storage-pools.md), the [Watchers & Mirror](../shared/watchers-mirror.md),
|
||||
and the [Lexicon](../shared/lexicon.md). The covenant is the same for
|
||||
every audience; the Nomad pages describe how it shows up in everyday use.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Maps & Pay
|
||||
|
||||
**Maps** and **Pay** are the day-to-day Mesh Experience a Nomad uses on the
|
||||
mesh. Maps finds services; Pay settles them. Both run over the
|
||||
[bearers](bearers.md) and read the [Mirror](../shared/watchers-mirror.md) so a
|
||||
Nomad can find and pay for a service on a surveillance-resistant bearer
|
||||
without an internet connection to OY Chain.
|
||||
|
||||
## Maps
|
||||
|
||||
Maps is the directory of services a Nomad can reach. A service is anything
|
||||
a Partner or a Stand exposes to the mesh: a Care service, a SIM, a Vault,
|
||||
a Mailbox (preview of v0.3 P5 — see [Pacts](pacts.md) for the Hub API). Maps
|
||||
is sorted by geographic proximity (REQ-007): a Nomad physically closer to a
|
||||
service or its operator is shown that service first. There is no paid
|
||||
ranking; the order is proximity, not promotion.
|
||||
|
||||
## Pay
|
||||
|
||||
Pay is how a Nomad settles a service. A payment is a transfer of Bread
|
||||
from the Nomad's [Stash](stash.md) to the service operator's Stash, signed
|
||||
by the Nomad's [Reach](reach.md). The fee covenant floors and caps the fee;
|
||||
inside a Guild, a Hand-Pass is free at the protocol level (REQ-017). Pay
|
||||
runs over any bearer, first-to-deliver-wins.
|
||||
|
||||
## Authorize, don't hand over
|
||||
|
||||
For recurring services a Nomad does not re-sign every payment. Instead
|
||||
the Nomad opens a [Window](window.md) to the service: a scoped,
|
||||
time-limited, rate-limited, revocable capability that lets the service pull
|
||||
value from the Stash within bounds the Nomad set. The Window is audited;
|
||||
the Nomad can revoke it at any time. This is the self-service principle in
|
||||
practice: the Nomad delegates a capability, not custody.
|
||||
|
||||
## Find, pay, verify
|
||||
|
||||
A Nomad's loop is:
|
||||
|
||||
1. **Find** a service on Maps.
|
||||
2. **Pay** once, or **authorize** a [Window](window.md) for recurring use.
|
||||
3. **Verify** the service against the Watcher attestations on the Mirror
|
||||
(see [Watchers & Mirror](../shared/watchers-mirror.md)).
|
||||
|
||||
See [Stash](stash.md) for where the Bread comes from, [Window](window.md)
|
||||
for the delegation primitive, and [Standing](standing.md) for how a
|
||||
service operator's history is measured.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Pacts
|
||||
|
||||
The **six Pacts** (REQ-020) are the contract shapes a Nomad encounters on
|
||||
the mesh. A Pact is a typed, mission-locked agreement between parties; the
|
||||
core terms of the Pause, Ground, and Stance Pacts are **non-amendable** —
|
||||
no Council can rewrite them after the fact. A Nomad mostly interacts with
|
||||
Pacts through [Maps & Pay](maps-pay.md) and the [Window](window.md)
|
||||
primitive, but it helps to know what each one is.
|
||||
|
||||
## The six Pacts
|
||||
|
||||
| Pact | What it does for a Nomad |
|
||||
|---|---|
|
||||
| **Pause** | A temporary hold. A Nomad can pause a recurring payment or a Window without voiding it; the Pause core terms are non-amendable. |
|
||||
| **Ground** | A baseline obligation the mesh enforces by default — the "ground rules" between a Nomad and a service operator. Non-amendable. |
|
||||
| **Stance** | A stated position a party commits to (e.g., a service operator's Stance on jurisdiction-light operation). Non-amendable. |
|
||||
| **Cover** | A flat commitment a Stand or a Partner offers to cover a Nomad against a defined failure; a Nomad reads Cover when choosing a service. |
|
||||
| **Stand Registry** | The registry of the nine [Stand](../shared/storage-pools.md) types (Household, Crew, Entity, Co-op, Circle, Trust, Foundation, Confederation, Shadow) a Nomad can join. |
|
||||
| **Hub API** | The B2B backbone (preview of v0.3 P5) — the Hub Pact exposes custody, a lending primitive, and compliance to service operators. A Nomad sees the Hub through Maps, not directly. |
|
||||
|
||||
## What a Nomad actually does with Pacts
|
||||
|
||||
A Nomad does not draft Pacts by hand. The flow is:
|
||||
|
||||
1. **Find** a service on [Maps & Pay](maps-pay.md).
|
||||
2. The service's terms are backed by one or more Pacts (e.g., a recurring
|
||||
payment is a Pause-able Window; a Stand's service is registered in the
|
||||
Stand Registry).
|
||||
3. The Nomad **authorizes** a [Window](window.md) scoped to those terms.
|
||||
|
||||
## Mission Lock
|
||||
|
||||
The Pause, Ground, and Stance core terms are mission-locked: a `const`
|
||||
flag in the Pact module marks them non-amendable, and an invariant test
|
||||
asserts that flag can never flip. A Nomad can rely on the ground rules
|
||||
not changing. See [Six Principles](../shared/six-principles.md) for the
|
||||
mission-lock covenant.
|
||||
|
||||
See [Window](window.md) for the delegation primitive the Pacts are
|
||||
delivered through, and [Standing](standing.md) for how a service
|
||||
operator's history is measured.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Reach
|
||||
|
||||
A **Nomad** is a person using the OpenYield mesh through a **Reach** — the
|
||||
protocol-level identity that lets a Holder act on the mesh without a
|
||||
custodian, a gatekeeper, or a legacy financial position. The Reach is the
|
||||
first of the four Freeholder signals (REQ-005), and it is the baseline every
|
||||
Nomad starts from: a Nomad is a Holder who has a Reach and a Stash but has not
|
||||
yet earned all four Freeholder signals.
|
||||
|
||||
## What a Reach is
|
||||
|
||||
A Reach is an identity, not a custodial position. It is the by-ID-string a
|
||||
Holder uses to receive value, open a [Window](window.md), join a Stand, or
|
||||
pay for a service. The protocol does not require KYC at the protocol layer;
|
||||
the Reach is the unit of self-service (see [Six
|
||||
Principles](../shared/six-principles.md)).
|
||||
|
||||
## How a Nomad starts
|
||||
|
||||
A Nomad starts with two things:
|
||||
|
||||
1. **A Reach** — the identity.
|
||||
2. **A [Stash](stash.md)** — the personal [Storage Pool](../shared/storage-pools.md)
|
||||
where the Holder holds Bread.
|
||||
|
||||
That pair is enough to begin. From there a Nomad can use the [bearers](bearers.md)
|
||||
to reach the mesh, find services on [Maps & Pay](maps-pay.md), authorize a
|
||||
[Window](window.md) to a partner, and accrue [Standing](standing.md).
|
||||
|
||||
## Geographic proximity
|
||||
|
||||
The mesh processes actions first-come, first-served with a
|
||||
geographic-proximity preference (REQ-007) — a Nomad physically closer to a
|
||||
service or a Stand's region is served first. The Reach is how the mesh
|
||||
identifies the Nomad for that ordering; there is no separate tier to buy into.
|
||||
|
||||
## The four Freeholder signals
|
||||
|
||||
The Reach is the first Freeholder signal. The four signals (REQ-005) are
|
||||
earned over time: the 90-day [Stash](stash.md) signal, the Standing
|
||||
threshold, the Capital signal, and the Vouch signal. A Nomad who earns all
|
||||
four becomes a Freeholder (see the Freeholders section). The pages here cover
|
||||
the Nomad path — everything up to that point.
|
||||
|
||||
See [Stash](stash.md) for the Storage Pool a Reach holds Bread in,
|
||||
[Bearers](bearers.md) for how to reach the mesh, and
|
||||
[Standing](standing.md) for the anti-gaming metric that accrues as a Nomad
|
||||
acts on the mesh.
|
||||
@@ -0,0 +1,44 @@
|
||||
# Standing
|
||||
|
||||
**Standing** (REQ-006) is a Holder's measured history on the mesh. It is
|
||||
the anti-gaming metric: a Bayesian score with time-decay, diversity
|
||||
weighting, and voucher-weighting, minus slashes for bad behavior. For a
|
||||
Nomad, the headline is that the mesh **cannot be gamed** — Standing
|
||||
rewards real production and resists the obvious attacks (volume spam,
|
||||
self-dealing, fake vouches).
|
||||
|
||||
## What Standing is, in plain language
|
||||
|
||||
Standing is not a count of transactions. It is a Bayesian score: the mesh
|
||||
starts with a prior, updates it from each observed action, and decays
|
||||
old evidence so a Holder cannot rest on a burst of activity from years
|
||||
ago. Diversity weighting means a Nomad who acts across many services,
|
||||
many Stands, and many bearers accrues more Standing than a Nomad who
|
||||
repeats the same action with the same counterparty. Voucher-weighting
|
||||
means a vouch from a Holder with high Standing counts for more.
|
||||
|
||||
## Why it matters to a Nomad
|
||||
|
||||
A Nomad mostly reads Standing, not computes it. Two places it shows up:
|
||||
|
||||
- **Choosing a service.** Maps shows a service operator's Standing so a
|
||||
Nomad can pick an operator with a real history over a freshly-spun-up
|
||||
alternative (see [Maps & Pay](maps-pay.md)).
|
||||
- **The Freeholder path.** Earning a Standing threshold in 3 categories
|
||||
is one of the four Freeholder signals (REQ-005). A Nomad who accrues
|
||||
Standing over time is on the path to becoming a Freeholder.
|
||||
|
||||
## What Standing is not
|
||||
|
||||
Standing is not a custodial position, a tier you buy, or a reputation
|
||||
score you can farm. It is not legacy custodial history. The
|
||||
Bayesian + time-decay + diversity design is exactly what makes it hard to
|
||||
game: there is no single input a Holder can pump.
|
||||
|
||||
## The math, deferred
|
||||
|
||||
The full Bayesian formula (priors, decay rates, diversity sub-tables,
|
||||
slash conditions) is documented in the Freeholders section — a Nomad does
|
||||
not need the math to use the mesh. See
|
||||
[Six Principles](../shared/six-principles.md) for the covenant Standing
|
||||
enforces, and [Reach](reach.md) for the identity a Standing accrues to.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Stash
|
||||
|
||||
A **Stash** is a Holder's personal [Storage Pool](../shared/storage-pools.md)
|
||||
(REQ-014). It is the place a Nomad holds Bread, and it is the second thing a
|
||||
Nomad needs after a [Reach](reach.md) to begin. The Stash is the unit of
|
||||
self-service: the Holder owns it, controls it, and can delegate a scoped,
|
||||
time-limited, revocable [Window](window.md) to a partner or a service
|
||||
without giving up custody.
|
||||
|
||||
## What a Stash is
|
||||
|
||||
The Stash is the Holder-level layer of the three Storage Pools (Stash,
|
||||
Vault, Root-Pool). It is a storage layer, not a custodial position: the
|
||||
protocol holds the canonical state that records who owns what; the Holder
|
||||
holds the value. There is no custodian between a Nomad and their Stash.
|
||||
|
||||
## How a Nomad uses a Stash
|
||||
|
||||
A Nomad moves Bread into a Stash through the [bearers](bearers.md) — a
|
||||
Holder on a surveillance-resistant bearer can receive value without an
|
||||
internet connection to OY Chain. From the Stash a Nomad can:
|
||||
|
||||
- **Hold** Bread (the unit of value — see [Bread scale](../shared/bread-scale.md)).
|
||||
- **Pass** value to another Reach (the Hand-Pass, free at the protocol
|
||||
level inside a Guild).
|
||||
- **Pay** for a service via [Maps & Pay](maps-pay.md).
|
||||
- **Authorize** a [Window](window.md) so a partner or service can read the
|
||||
Stash within bounds the Holder set.
|
||||
|
||||
## The 90-day Freeholder signal
|
||||
|
||||
Holding a Stash continuously for 90 days is the first of the four
|
||||
Freeholder signals (REQ-005). The Stash does not need to hold a large
|
||||
amount — the signal is about continuity, not size. A Nomad who keeps a
|
||||
Stash for 90 days and earns the other three signals (Standing, Capital,
|
||||
Vouch) becomes a Freeholder.
|
||||
|
||||
## Delegation, not custody
|
||||
|
||||
The Stash stays the Holder's. When a Nomad opens a Window to a service,
|
||||
the service gets a scoped capability (e.g., "read Stash balance for the
|
||||
next hour", "spend up to N Grain on this service this week") — it does not
|
||||
get custody. The Window is revocable, rate-limited, and audited. See
|
||||
[Window](window.md) for the primitive.
|
||||
|
||||
See [Storage Pools](../shared/storage-pools.md) for the full three-pool
|
||||
model, and [Bearers](bearers.md) for how value reaches a Stash over a
|
||||
surveillance-resistant bearer.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Window
|
||||
|
||||
A **Window** (REQ-015) is the primitive a Nomad uses to delegate a
|
||||
capability without delegating custody. It is scoped, time-limited,
|
||||
rate-limited, audited, and revocable. A Nomad opens a Window so a partner
|
||||
or a service can act on the Nomad's [Stash](stash.md) within bounds the
|
||||
Nomad set — the partner never gets custody, and the Nomad can close the
|
||||
Window at any time.
|
||||
|
||||
## The five parts of a Window
|
||||
|
||||
| Part | What it bounds |
|
||||
|---|---|
|
||||
| **Scope** | what the grantee can do (e.g., read Stash balance, spend up to N Grain on a specific service). |
|
||||
| **Duration** | when the Window starts and ends (a start time and an end time). |
|
||||
| **Rate limit** | how many actions per duration window (e.g., at most 10 reads per hour). |
|
||||
| **Audit log** | an append-only log of every action the grantee took under the Window. |
|
||||
| **Revoke** | the Nomad can revoke the Window at any time; revoke after expiry is a no-op. |
|
||||
|
||||
## Why a Nomad opens one
|
||||
|
||||
A Nomad opens a Window for the same reason a Nomad uses [Maps & Pay](maps-pay.md):
|
||||
to let a service do something on the Nomad's behalf without handing over
|
||||
the Stash. Common examples:
|
||||
|
||||
- A recurring service (e.g., a Care service) pulls a capped amount of
|
||||
Bread from the Stash each week, within a rate limit the Nomad set.
|
||||
- A partner reads the Stash balance for a compliance check, scoped to
|
||||
read-only, time-limited to one hour.
|
||||
- A Stand operator processes a Pass-Act on the Nomad's behalf inside a
|
||||
scoped, audited Window.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
A Window moves through a fixed lifecycle: **Open → Active → Revoked** or
|
||||
**Expired**. A Nomad can revoke at any point; revoking after expiry is a
|
||||
no-op (idempotent). The lifecycle is mission-locked: a partner cannot
|
||||
extend a Window past its end time — the Nomad must open a new one.
|
||||
|
||||
## Self-service, by design
|
||||
|
||||
The Window is the self-service principle in code. The protocol records
|
||||
the Window on OY Chain; the partner holds only the capability, never the
|
||||
value. See [Six Principles](../shared/six-principles.md) for the covenant,
|
||||
and [Pacts](pacts.md) for the contract shapes delivered through Windows.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Architecture
|
||||
|
||||
This is the architecture index for OpenYield. The mesh is built from 14
|
||||
modular components and 6 cross-component interfaces, with a critical blocker
|
||||
chain that fixes the build order. The full governance source lives in
|
||||
`.ciagent/oy/ARCHITECTURE.md`; this page is the user-facing rewrite, kept
|
||||
lexicon-clean by the [docs firewall](../shared/lexicon.md).
|
||||
|
||||
## The 14 modular components
|
||||
|
||||
| # | Component | Vision § | Phase |
|
||||
|---|---|---|---|
|
||||
| 1 | OY Chain & Mirror | §7 | P1 |
|
||||
| 2 | Cross-Chain & Exit | §7 | P3 |
|
||||
| 3 | Bread Unit & Root Basket | §6, §16 | P1 |
|
||||
| 4 | Bloom Engine | §6 | P1 |
|
||||
| 5 | Storage Substrate | §5 | P1 |
|
||||
| 6 | Identity, Standing & Citizenship | §8, §9 | P1 |
|
||||
| 7 | Window Primitive | §10 | P2 |
|
||||
| 8 | Pacts Suite (Pause, Ground, Stance, Cover, Stand Registry, Hub API, Bonds) | §16, §17 | P2 |
|
||||
| 9 | Mesh Experience (Maps, Pay) | §8 | P1 |
|
||||
| 10 | Organizational Primitives (Stands, Guilds) | §11, §12 | P2 |
|
||||
| 11 | Partner Spectrum & Forex | §13 | P2 |
|
||||
| 12 | Bearers & Processing Mesh | §14, §15 | P1 |
|
||||
| 13 | Fee Covenant | §18 | P1 |
|
||||
| 14 | Governance (Mesh/Guild/Stand Councils) | §19 | P2 |
|
||||
|
||||
## The 6 cross-component interfaces
|
||||
|
||||
1. **Standing API** — consumed by Identity, Window, Pacts, Orgs, Partners,
|
||||
and Governance. See [Standing](../freeholders/standing.md).
|
||||
2. **Forge / Fold Interface** — mints [Bread](../shared/bread-scale.md)
|
||||
against Root Basket assets only. See the Bloom Engine.
|
||||
3. **Watcher Attestation Interface (the Mirror)** — 9 Watchers, 6-of-9
|
||||
quorum. See [Watchers & Mirror](../shared/watchers-mirror.md).
|
||||
4. **Window Lifecycle Interface** — Holder-authorized, scope-bounded,
|
||||
revocable. See [Window](../nomads/window.md).
|
||||
5. **Fee Covenant Interface** — auto-decline, ceiling/floor enforced.
|
||||
See [Six Principles](../shared/six-principles.md).
|
||||
6. **Voice / Council Interface** — multi-source Voice, Mission Lock enforced.
|
||||
See [Councils & Voice](../freeholders/councils-voice.md).
|
||||
|
||||
## The critical blocker chain
|
||||
|
||||
The components build in a fixed order: OY Chain (1) → Bread/Root Basket (3)
|
||||
→ Storage (5) → Identity/Standing (6), which then unblocks {Window (7),
|
||||
Pacts (8), Orgs (10), Partners (11), Governance (14)}. The Fee Covenant (13)
|
||||
blocks Pacts, Orgs, Partners, and Bearers — the fee shape must exist before
|
||||
any of those can ship. v0.3 adds the Cross-Chain & Exit layer (component 2)
|
||||
and the Bearers/Partner/Bond extensions; see [Components](components.md) for
|
||||
the `x/` module map and the v0.3 phase status.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Component Map
|
||||
|
||||
This is the `x/` module map for OpenYield. Each module is a Cosmos-SDK-style
|
||||
`x/<name>/types/` package, zero external Go deps (G-006), referenced by
|
||||
ID-string across modules (G-003 — no struct imports). The map covers v0.1,
|
||||
v0.2, and v0.3 (skeleton + tests depth, D-020/D-035).
|
||||
|
||||
## v0.1 baseline (pre-MVP skeleton)
|
||||
|
||||
| Module | Vision § | REQ | Purpose |
|
||||
|---|---|---|---|
|
||||
| `x/mesh` | §7 | REQ-008 | OY Chain (Layer 1) shell |
|
||||
| `x/mirror` | §7 | REQ-004 | Mirror of canonical state to bearers |
|
||||
| `x/bread` | §4, §6 | REQ-013 | Bread unit + 11-tier scale |
|
||||
| `x/bloom` | §6 | REQ-003 | Bloom Engine (real production only) |
|
||||
| `x/forge` | §4.2 | REQ-003 | Forge/Fold minting against Root Basket |
|
||||
| `x/rootpool` | §5 | REQ-014 | Root-Pool (mesh treasury) |
|
||||
| `x/stash` | §5 | REQ-014 | Stash (Holder-level storage pool) |
|
||||
| `x/vault` | §5 | REQ-014 | Vault (Stand-level storage pool) |
|
||||
| `x/identity` | §8 | REQ-005 | Reach identity (Holder, no KYC) |
|
||||
| `x/standing` | §9.2 | REQ-006 | Bayesian Standing |
|
||||
| `x/processing` | §15 | REQ-007 | FCFS processing mesh |
|
||||
| `x/watcher` | §7 | REQ-004 | 9 Watchers, 6-of-9 quorum |
|
||||
| `x/feecovenant` | §18 | REQ-002 | Fee ceiling/floor/minimum |
|
||||
| `x/still` | §3 | — | Still/Stir pause/resume state |
|
||||
| `x/bearers` | §14 | REQ-019 | Unified Bearer Layer (6 bearers) |
|
||||
|
||||
## v0.2 (The Mesh — skeleton + tests)
|
||||
|
||||
| Module | Vision § | REQ | Purpose |
|
||||
|---|---|---|---|
|
||||
| `x/window` | §10 | REQ-015 | Window primitive (scope, rate-limit, revoke) |
|
||||
| `x/stand` | §11 | REQ-016 | Nine Stand types |
|
||||
| `x/guild` | §12 | REQ-017 | Guilds + Hand-Passes at 0% protocol fee |
|
||||
| `x/pact` | §16 | REQ-020 | Six Pacts (Pause, Ground, Stance, Cover, Stand Registry, Hub API) |
|
||||
| `x/partner` | §13 | REQ-018 | Four-tier Partner Spectrum (Op, Master Op, Pier, Anchor) |
|
||||
| `x/council` | §19 | REQ-011 | Three Councils + Mission Lock (non-amendable) |
|
||||
| `x/forex` | §13 | Forex v1 | Forex Engine v1 (pair type + oracle interface) |
|
||||
| `x/bond` | §17 | REQ-021 | Mesh Bond Market (8% cap / 0% floor clamp) |
|
||||
| `x/satellite` | §7 | REQ-009 | L2 IBC Satellite (Polygon active + 4 stubs) |
|
||||
|
||||
## v0.3 (Bearers & Documentation — in progress)
|
||||
|
||||
| Module | Vision § | REQ | Status | Purpose |
|
||||
|---|---|---|---|---|
|
||||
| `x/bridge` | §7 | REQ-010 | P4 (pending) | L2↔L1 bridge routes |
|
||||
| `x/exit` | §7 | REQ-010 | P4 (pending) | Exit routes + DEX swaps |
|
||||
| `x/bearers` (ext) | §14 | REQ-022 | P4 (pending) | OY-SAT + OY-QR transport stubs |
|
||||
| `x/partner` (ext) | §13 | REQ-023 | P4 (pending) | AnchorCredential (Anchor tier) |
|
||||
| `x/hub` | §13, §16 | REQ-024 | P5 (pending) | Hub API (Custody, Lending, Compliance) |
|
||||
| `x/services` | §13 | REQ-025 | P5 (pending) | Services (Care, SIM, Vault, Mail) |
|
||||
| `x/bond` (ext) | §17 | REQ-026 | P5 (pending) | Growth Bonds + secondary market |
|
||||
|
||||
## Notes
|
||||
|
||||
- Every module follows the same pattern: `types/types.go` + `types/types_test.go`
|
||||
(package `types`), zero external deps, by-ID-string inter-module refs (G-003).
|
||||
- Each new/extended test file includes a lexicon assertion (REQ-012); the
|
||||
project-wide meta-test (`lexicon_meta_test.go`) scans all `x/**/*.go`.
|
||||
- The docs firewall (`lexicon_meta_docs_test.go`) scans `README.md` + all
|
||||
`docs/**/*.md`. See the [architecture index](architecture.md) for the
|
||||
14-component view and the 6 cross-component interfaces.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Bread scale
|
||||
|
||||
The unit of value in OpenYield is **Bread** (REQ-013). Bread is scaled in 11
|
||||
tiers, each 1,000× the previous, so a Holder can reason about a Crumb and a
|
||||
Granary in the same mental model:
|
||||
|
||||
| Tier | Name | Multiple |
|
||||
|---|---|---|
|
||||
| 1 | **Grain** | 1 |
|
||||
| 2 | **Crumb** | 1,000 Grain |
|
||||
| 3 | **Bread** | 1,000 Crumb |
|
||||
| 4 | **Loaf** | 1,000 Bread |
|
||||
| 5 | **Batch** | 1,000 Loaf |
|
||||
| 6 | **Cake** | 1,000 Batch |
|
||||
| 7 | **Bakery** | 1,000 Cake |
|
||||
| 8 | **Granary** | 1,000 Bakery |
|
||||
| 9 | **Mill** | 1,000 Granary |
|
||||
| 10 | **Harvest** | 1,000 Mill |
|
||||
| 11 | **Earth** | 1,000 Harvest |
|
||||
|
||||
## Why 11 tiers
|
||||
|
||||
The 11-tier scale gives the mesh a single unit for everything from a
|
||||
1-Grain internal minimum (the Fee Covenant floor) to the Earth-tier totals
|
||||
held in the Root-Pool. There is no separate "small unit" and "large unit":
|
||||
the Bread scale is the unit. The 1-Grain minimum prevents dust games; the
|
||||
tier names keep human-readable values at every scale.
|
||||
|
||||
## Where Bread lives
|
||||
|
||||
Bread is held in the three [Storage Pools](storage-pools.md): the Stash
|
||||
(Holder-level), the Vault (Stand-level), and the Root-Pool (treasury). The
|
||||
Watchers attest to the state of the pools daily; the Mirror mirrors the
|
||||
canonical state to the bearers. See [Watchers & Mirror](watchers-mirror.md)
|
||||
for the attestation layer.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Shared concepts
|
||||
|
||||
The Shared section holds the concepts common to every OpenYield audience —
|
||||
Nomads and Freeholders alike. These are the covenant-level ideas that make
|
||||
OpenYield a public-good mesh rather than a custodial platform.
|
||||
|
||||
- [Six Principles](six-principles.md) — the immutable covenant (REQ-001).
|
||||
- [Bread Scale](bread-scale.md) — the unit of value and its 11 tiers (REQ-013).
|
||||
- [Storage Pools](storage-pools.md) — the three pools (Stash, Vault, Root-Pool) (REQ-014).
|
||||
- [Watchers & Mirror](watchers-mirror.md) — the 9 Watchers, 6-of-9 quorum, the Mirror (REQ-004).
|
||||
- [Lexicon](lexicon.md) — why 10 terms are banned, and what to say instead (REQ-012).
|
||||
- [Vision](vision.md) — the OpenYield covenant in brief.
|
||||
|
||||
See the [README](../index.md) for build instructions, or the
|
||||
[Nomads](../nomads/index.md) and [Freeholders](../freeholders/index.md)
|
||||
sections for audience-specific docs.
|
||||
@@ -0,0 +1,148 @@
|
||||
# Lexicon
|
||||
|
||||
OpenYield bans 10 financial terms as standalone words (REQ-012). The firewall
|
||||
scans every Go file under `x/` and every Markdown file under `README.md` +
|
||||
`docs/`, and fails the build on any standalone occurrence. This page documents
|
||||
**why** the terms are banned and **what to say instead** — the replacements,
|
||||
not the banned literals.
|
||||
|
||||
## Why a lexicon
|
||||
|
||||
The words a legacy financial institution uses carry the shapes of that
|
||||
institution: custodial positions, jurisdiction-bound units, and
|
||||
speculation-language. OpenYield is a jurisdiction-light, public-good mesh for
|
||||
real production; using the old words would import the old shapes. The
|
||||
lexicon firewall keeps the mesh's language aligned with its covenant. The
|
||||
firewall is enforced in code by two sibling Go tests
|
||||
(`lexicon_meta_test.go` for `x/**/*.go`;
|
||||
`lexicon_meta_docs/lexicon_meta_docs_test.go` for `README.md` +
|
||||
`docs/**/*.md`), both using `lexicon.FindBannedTerm` (word-boundary,
|
||||
case-insensitive). Word-boundary matching means "OpenYield" is safe — the
|
||||
firewall bans standalone words, not substrings.
|
||||
|
||||
## The 10 banned terms and their safe replacements
|
||||
|
||||
The firewall bans 10 standalone words. This page does not write the banned
|
||||
words as literals (the firewall scans this page); it describes them by the
|
||||
concept each belongs to, and gives the safe replacement.
|
||||
|
||||
### 1. The custodial-position word
|
||||
|
||||
A legacy institution holds your value in a custodial position. OpenYield
|
||||
does not: a Holder owns their **Stash**, a Stand owns its **Vault**, the mesh
|
||||
owns the **Root-Pool**. The Holder's identity is a **Reach**, and the Holder
|
||||
themselves is a **Holder** — never the banned custodial-position word.
|
||||
|
||||
- Banned: the word for a custodial position.
|
||||
- Safe: **Holder**, **Reach**, **Stash**, **Vault**, **Root-Pool**.
|
||||
|
||||
### 2. The legacy-institution word
|
||||
|
||||
The legacy financial institution itself is banned as a concept. OpenYield is
|
||||
a **mesh**, a **public good**, a **protocol** — not that word.
|
||||
|
||||
- Banned: the word for a legacy financial institution.
|
||||
- Safe: **mesh**, **protocol**, **public good**.
|
||||
|
||||
### 3. The place-value word
|
||||
|
||||
The word for a place to hold value under custody is banned. Use the
|
||||
**Stash** (Holder-level), the **Vault** (Stand-level), or the **Root-Pool**
|
||||
(treasury).
|
||||
|
||||
- Banned: the word for a place value is held.
|
||||
- Safe: **Stash**, **Vault**, **Root-Pool**, **Storage Pools**.
|
||||
|
||||
### 4. The put-in word
|
||||
|
||||
The verb for putting value into a custodial position is banned. Use **hold**,
|
||||
**store**, **move**, or **transfer**.
|
||||
|
||||
- Banned: the verb for placing value under custody.
|
||||
- Safe: **hold**, **store**, **move**, **transfer**, **Pass-Act**.
|
||||
|
||||
### 5. The passive-value word
|
||||
|
||||
The word for value earned passively on a custodial position is banned. For
|
||||
bonds, use **coupon**. For the mesh's metric, use **real production** or
|
||||
**real return**.
|
||||
|
||||
- Banned: the word for passive value on a custodial position.
|
||||
- Safe: **coupon**, **real production**, **real return**.
|
||||
|
||||
### 6. The standalone metric word
|
||||
|
||||
The standalone word for a return metric is banned (it is the same concept as
|
||||
#5 in verb form). Use **real production**, **real return**, or **coupon**
|
||||
(for bonds). "OpenYield" is safe — word-boundary matching does not flag the
|
||||
banned term inside an identifier.
|
||||
|
||||
- Banned: the standalone return-metric word.
|
||||
- Safe: **real production**, **real return**, **coupon**. **OpenYield** is safe.
|
||||
|
||||
### 7. The medium-of-exchange word
|
||||
|
||||
The word for a national medium of exchange is banned. The mesh's unit is
|
||||
**Bread** (see [Bread scale](bread-scale.md)). For a foreign-exchange pair,
|
||||
use **Forex** (allowed) with **base-asset** / **quote-asset** labels, or
|
||||
**Bread / Asset**.
|
||||
|
||||
- Banned: the word for a national medium of exchange.
|
||||
- Safe: **Bread**, **asset**, **Forex**, **base-asset**, **quote-asset**.
|
||||
|
||||
### 8. The first national-unit word
|
||||
|
||||
The word for the first major national unit is banned. Use **Bread** or
|
||||
opaque chain names (e.g., "Polygon", "OY-Chain").
|
||||
|
||||
- Banned: the first national-unit word.
|
||||
- Safe: **Bread**, **asset**, chain names.
|
||||
|
||||
### 9. The second national-unit word
|
||||
|
||||
The word for the second major national unit is banned (the firewall bans it
|
||||
as a standalone word; "european" is safe by word-boundary). Use **Bread** or
|
||||
opaque chain names.
|
||||
|
||||
- Banned: the second national-unit word.
|
||||
- Safe: **Bread**, **asset**, chain names. **European** is safe (word-boundary).
|
||||
|
||||
### 10. The set-aside word
|
||||
|
||||
The word for value set aside under custody is banned. Use **Stash**,
|
||||
**Vault**, or **Root-Pool**.
|
||||
|
||||
- Banned: the word for value set aside.
|
||||
- Safe: **Stash**, **Vault**, **Root-Pool**.
|
||||
|
||||
### 11. The holder-of-value word
|
||||
|
||||
The word for the person who holds value under custody at a legacy
|
||||
institution is banned. Use **Holder**, **Freeholder**, or **Nomad**.
|
||||
|
||||
- Banned: the word for a custodial-position holder.
|
||||
- Safe: **Holder**, **Freeholder**, **Nomad**, **Reach**.
|
||||
|
||||
> **Note**: the firewall bans 10 standalone words; this page lists 11
|
||||
> replacements because two of the banned words (the passive-value word and
|
||||
> the standalone metric word) share a concept and get the same replacement
|
||||
> family (**coupon** / **real production** / **real return**).
|
||||
|
||||
## How the firewall works
|
||||
|
||||
The firewall uses `lexicon.FindBannedTerm` — a word-boundary, case-insensitive
|
||||
regex match — so:
|
||||
|
||||
- "OpenYield" is **safe**: the standalone banned term inside an identifier
|
||||
does not match (word-boundary).
|
||||
- "european" is **safe**: the standalone national-unit word inside a larger
|
||||
word does not match.
|
||||
- The standalone banned word in prose **is** matched and fails the build.
|
||||
|
||||
The firewall's own source (`lexicon/lexicon.go`) assembles the banned terms
|
||||
at runtime from two-character fragments, so the firewall's own code does not
|
||||
contain any banned term as a literal substring. The two sibling meta-tests
|
||||
(`lexicon_meta_test.go` and `lexicon_meta_docs/lexicon_meta_docs_test.go`)
|
||||
each include a self-test table that verifies detection of all 10 banned
|
||||
terms from the single source `lexicon.BannedTerms()` (G-014 drift
|
||||
prevention).
|
||||
@@ -0,0 +1,54 @@
|
||||
# Six Principles
|
||||
|
||||
The Six Principles are the immutable covenant of OpenYield (REQ-001). They
|
||||
are **Mission-locked**: no Council can amend them, and the fee covenant is
|
||||
locked alongside them. The mesh exists to hold real production, not
|
||||
speculation; everything else follows from that.
|
||||
|
||||
## 1. Real value
|
||||
|
||||
The mesh holds **real production**. The Bread unit is the unit of real value
|
||||
held in the Storage Pools; the bond market caps coupons so the mesh cannot
|
||||
become a speculation engine. "Real return" is the metric, not a nominal rate.
|
||||
|
||||
## 2. Sustainability
|
||||
|
||||
Fees are floored and capped. The fee covenant fixes a ceiling and a floor
|
||||
(see the Fee Covenant module), and the 1-Grain internal minimum prevents dust
|
||||
games. The protocol cannot drain its users, and it cannot starve its
|
||||
Watchers.
|
||||
|
||||
## 3. Mission-lock
|
||||
|
||||
The Six Principles and the fee covenant are immutable. No Council — Mesh,
|
||||
Guild, or Stand — can amend them. Mission Lock is a `const` in the council
|
||||
module, and an invariant test asserts it can never be set to amendable. The
|
||||
coupon cap on bonds is a mission-locked ceiling, not a parameter a Council
|
||||
can tune.
|
||||
|
||||
## 4. Openness
|
||||
|
||||
Anyone may join. The mesh is a public good. A Holder needs only a Reach (an
|
||||
identity) and a Stash (a storage pool) to begin; there is no gatekeeper and
|
||||
no custodian.
|
||||
|
||||
## 5. Ownership
|
||||
|
||||
Holders own their Stash and their Reach. Custody is theirs: the Stash is the
|
||||
Holder-level storage pool, the Vault is the Stand-level pool, and the
|
||||
Root-Pool is the treasury. The protocol does not custody user value; it
|
||||
holds the canonical state that records who owns what.
|
||||
|
||||
## 6. Self-service
|
||||
|
||||
A Holder can act without a custodian. The Window primitive lets a Holder
|
||||
delegate a scope-bounded, time-limited, revocable capability to a partner or
|
||||
a service; the bearers (OY-LR, OY-BLE, OY-WiFi-Direct, OY-SAT, OY-QR) let a
|
||||
Holder reach the mesh without a phone plan or a custodial on-ramp. The mesh
|
||||
is jurisdiction-light by design.
|
||||
|
||||
---
|
||||
|
||||
See the [Vision](vision.md) for the covenant in brief, or the
|
||||
[Lexicon](lexicon.md) for why the docs say "real production" and "Holder"
|
||||
rather than the words a legacy financial institution would use.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Storage Pools
|
||||
|
||||
OpenYield has three Storage Pools (REQ-014). Each is a layer of custody
|
||||
responsibility, and none of them is a custodial position — the protocol holds
|
||||
the canonical state that records who owns what; the Holder, the Stand, and
|
||||
the mesh treasury each hold their own pool.
|
||||
|
||||
| Pool | Level | Held by | Purpose |
|
||||
|---|---|---|---|
|
||||
| **Stash** | Holder | a single Holder | the personal storage pool; the unit of self-service |
|
||||
| **Vault** | Stand | a Stand (a governed group) | the Stand-level pool; the unit of shared ownership |
|
||||
| **Root-Pool** | Mesh | the mesh treasury | the canonical treasury; the unit of the public good |
|
||||
|
||||
## The Stash
|
||||
|
||||
The Stash is the Holder-level storage pool. A Holder needs only a Reach (an
|
||||
identity) and a Stash to begin. The Stash is the unit of self-service: the
|
||||
Holder owns it, controls it, and can delegate a scoped, time-limited,
|
||||
revocable Window to a partner or a service without giving up custody. See
|
||||
[Watchers & Mirror](watchers-mirror.md) for the attestation layer that
|
||||
records Stash state.
|
||||
|
||||
## The Vault
|
||||
|
||||
The Vault is the Stand-level storage pool. A Stand is a governed group
|
||||
(one of the nine Stand types: Household, Crew, Entity, Co-op, Circle,
|
||||
Trust, Foundation, Confederation, Shadow) that holds a Vault in common. The
|
||||
Stand's decision policy (threshold or weighted, mirroring the Cosmos SDK
|
||||
`x/group` shape) governs how the Vault is used. See the Freeholders section
|
||||
for Stands & Guilds.
|
||||
|
||||
## The Root-Pool
|
||||
|
||||
The Root-Pool is the mesh treasury. It holds the canonical state of the
|
||||
Bread unit, the Watcher bonds, and the Root Basket. The Root-Pool is the
|
||||
unit of the public good: the Watchers attest to its state daily, and the
|
||||
Mirror mirrors it to the bearers so a Holder can verify the mesh's real
|
||||
return without trusting a single custodian.
|
||||
|
||||
## Custody, not custody
|
||||
|
||||
The three pools are storage layers, not custodial positions. The protocol
|
||||
does not custody user value; it holds the canonical state that records who
|
||||
owns what. A Holder's Stash is theirs; a Stand's Vault is the Stand's; the
|
||||
Root-Pool is the mesh's. The Window primitive lets a Holder delegate a
|
||||
capability without delegating custody. See the [Lexicon](lexicon.md) for
|
||||
why the docs say "Stash", "Vault", and "Root-Pool" rather than the words a
|
||||
legacy financial institution would use.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Vision
|
||||
|
||||
OpenYield is a jurisdiction-light, public-good mesh for **real production**.
|
||||
The vision is a covenant, not a product: the mesh holds real value, the Six
|
||||
Principles are immutable, and the protocol cannot become a custodial
|
||||
platform. This page is the brief overview; the full vision source lives in
|
||||
`.ciagent/oy/PROJECT.md`.
|
||||
|
||||
## The covenant
|
||||
|
||||
OpenYield exists to hold **real production** — the real return of real work,
|
||||
held in the Bread unit, in the three Storage Pools, attested by the Watchers,
|
||||
mirrored by the Mirror. The covenant is anti-greed by construction:
|
||||
|
||||
- **Mission Lock** fixes the Six Principles and the fee covenant. No Council
|
||||
— Mesh, Guild, or Stand — can amend them. The coupon cap on bonds is a
|
||||
mission-locked ceiling, not a parameter.
|
||||
- **Jurisdiction-light** — the bearers (OY-LR, OY-BLE, OY-WiFi-Direct, OY-SAT,
|
||||
OY-QR) let a Holder reach the mesh without a phone plan or a custodial
|
||||
on-ramp. A Holder needs only a Reach and a Stash to begin.
|
||||
- **Public good** — the mesh is open to all. The Watchers attest daily; the
|
||||
Mirror mirrors the state; anyone can verify the mesh's real return without
|
||||
trusting a single custodian.
|
||||
|
||||
## The layers
|
||||
|
||||
1. **OY Chain** (Layer 1) — the canonical state: the Bread unit, the
|
||||
Storage Pools, Standing, Watcher attestations, the Pact / Council /
|
||||
Partner surface.
|
||||
2. **Satellites** (Layer 2) — wrapped Bread propagates to satellite chains
|
||||
(Polygon active; Base, Arbitrum, Optimism, Solana as enum placeholders)
|
||||
via IBC.
|
||||
3. **Bearers** — the surveillance-resistant transport layer: OY-LR (LoRa,
|
||||
long-range), OY-BLE (Bluetooth), OY-WiFi-Direct, OY-SAT (satellite),
|
||||
OY-QR (paper / QR code). The Mirror mirrors canonical state to them.
|
||||
4. **Exits** — the Layer 3 exit layer: Holder-initiated DEX swaps and
|
||||
off-mesh service exits, with bridge routes for cross-chain exits.
|
||||
|
||||
## The actors
|
||||
|
||||
- **Holders** (Nomads) — the everyday participants, each with a Reach and a
|
||||
Stash.
|
||||
- **Freeholders** — the active participants who run Stands, Guilds, and
|
||||
Councils.
|
||||
- **Partners** — the four-tier spectrum (Op, MasterOp, Pier, Anchor) that
|
||||
processes Pass-Acts and provides credentials and institutional backing.
|
||||
- **Watchers** — the 9 attesters with 6-of-9 quorum and 100,000 Bread bonds.
|
||||
|
||||
## The units
|
||||
|
||||
- **Bread** — the unit of real value (see [Bread scale](bread-scale.md)).
|
||||
- **Standing** — the reputation layer (the four signals, Bayesian Standing).
|
||||
- **Voice** — the governance input (multi-source: Stash, Standing, Vouch,
|
||||
Freeholder, Guild).
|
||||
- **Coupon** — the bond-market term (capped at 8% / floored at 0%, mission-locked).
|
||||
|
||||
## Where to go next
|
||||
|
||||
- [Six Principles](six-principles.md) — the immutable covenant.
|
||||
- [Storage Pools](storage-pools.md) — the three pools.
|
||||
- [Watchers & Mirror](watchers-mirror.md) — the attestation layer.
|
||||
- [Lexicon](lexicon.md) — why the docs say "real production" and "Holder".
|
||||
- [README](../../README.md) — build & test instructions.
|
||||
- `.ciagent/oy/PROJECT.md` — the full vision source.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Watchers & Mirror
|
||||
|
||||
OpenYield is attested by **9 Watchers** with a **6-of-9 quorum** (REQ-004).
|
||||
The Watchers make daily attestations to the canonical state, and each posts
|
||||
a 100,000 Bread bond. The **Mirror** mirrors the canonical state to the
|
||||
bearers so a Holder can verify the mesh's state without trusting a single
|
||||
Watcher.
|
||||
|
||||
## The 9 Watchers
|
||||
|
||||
The Watchers are the attestation layer of the mesh. There are exactly 9, and
|
||||
the quorum is 6-of-9: any 6 Watchers can attest to a state transition, but no
|
||||
5 can. Each Watcher posts a 100,000 Bread bond, which is at risk if the
|
||||
Watcher attests to a false state. The 9/6 split is a mission-locked
|
||||
parameter — no Council can lower the quorum or the bond.
|
||||
|
||||
## Daily attestations
|
||||
|
||||
The Watchers attest to the state of the three [Storage Pools](storage-pools.md)
|
||||
daily: the Stash totals, the Vault totals, and the Root-Pool. The
|
||||
attestation is a signed statement that the canonical state recorded by OY
|
||||
Chain matches the state the Watcher observed. A Holder who wants to verify
|
||||
the mesh's real return can read the attestations and check that the
|
||||
Watchers agree.
|
||||
|
||||
## The Mirror
|
||||
|
||||
The Mirror mirrors the canonical state to the bearers (OY-LR, OY-BLE,
|
||||
OY-WiFi-Direct, OY-SAT, OY-QR). A Holder on a surveillance-resistant bearer
|
||||
can read the mirrored state without an internet connection to OY Chain; the
|
||||
Mirror is the read-side of the bearer layer. The Mirror is read-only: it
|
||||
mirrors state, it does not author it. Authoritative state lives on OY Chain
|
||||
and is attested by the Watchers.
|
||||
|
||||
## Why 6-of-9
|
||||
|
||||
The 9/6 split is a balance: 9 is large enough that no single adversary can
|
||||
easily capture a quorum, and 6 is large enough that no small cabal can
|
||||
attest to a false state. The 100,000 Bread bond per Watcher makes
|
||||
capturing a quorum expensive. The split is locked by Mission Lock — no
|
||||
Council can change it. See [Six Principles](six-principles.md) for the
|
||||
mission-lock covenant.
|
||||
@@ -0,0 +1,316 @@
|
||||
// Package lexicon_meta_docs holds the docs lexicon firewall (REQ-028, D-043).
|
||||
//
|
||||
// It is a NEW sibling meta-test created in v0.3 P1 Wave 1 that MIRRORS the v0.2
|
||||
// project-wide firewall (lexicon_meta_test.go, package lexicon_meta) but scans
|
||||
// the docs surface (README.md + docs/**/*.md) instead of x/**/*.go. It uses
|
||||
// the SAME lexicon.FindBannedTerm (word-boundary, case-insensitive) — NO
|
||||
// detection reimplementation — so the two firewalls share a single source of
|
||||
// truth for the 10 banned terms (bank, deposit, interest, yield, currency,
|
||||
// dollar, euro, account, savings, depositor).
|
||||
//
|
||||
// Placement: this file lives in lexicon_meta_docs/ (a subdirectory of the
|
||||
// repo root) because Go does not permit two distinct packages in the same
|
||||
// directory; the v0.2 firewall is package lexicon_meta at the repo root.
|
||||
// The invocation `go test ./lexicon_meta_docs/...` (PLANS P1-03-01) resolves
|
||||
// to this package. Run via `go test ./...` from the repo root as well.
|
||||
//
|
||||
// G-013 walk-coverage: TestLexiconMetaDocsWalkCoverage injects a synthetic
|
||||
// banned-term .md into a temp docs/ subtree and asserts the walk FINDS it.
|
||||
// This closes the "silently scans nothing and reports green" failure mode
|
||||
// that the G-009 self-test table (detection) alone does not cover.
|
||||
//
|
||||
// G-014 self-test drift: the self-test table and banned-term count assertion
|
||||
// reuse lexicon.BannedTerms() (the single source). A cross-reference comment
|
||||
// keeps this file's table in lockstep with lexicon_meta_test.go's table; if
|
||||
// a banned term is added, both firewalls update from one place.
|
||||
package lexicon_meta_docs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/oy/openyield/lexicon"
|
||||
)
|
||||
|
||||
// repoRoot returns the absolute path to the repo root by walking up from
|
||||
// this test file (the test lives at <repoRoot>/lexicon_meta_docs/).
|
||||
func repoRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller failed")
|
||||
}
|
||||
// file = .../oy/lexicon_meta_docs/lexicon_meta_docs_test.go
|
||||
// repo root = filepath.Dir(filepath.Dir(file))
|
||||
return filepath.Dir(filepath.Dir(file))
|
||||
}
|
||||
|
||||
// thisFile returns the absolute path of this meta-test file (to exclude it
|
||||
// from its own scan — it references banned terms via the lexicon package,
|
||||
// whose source assembles terms from fragments, so no banned-term literal
|
||||
// appears in the firewall's own code).
|
||||
func thisFile(t *testing.T) string {
|
||||
t.Helper()
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller failed")
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
// TestLexiconMetaDocsNoBannedTermsInDocs is the docs firewall (D-043). It
|
||||
// walks README.md (repo root) + every *.md under docs/ (recursive), reads each
|
||||
// file's source, and asserts no banned term is present (word-boundary,
|
||||
// case-insensitive). Excludes .ciagent/ (firewall meta-files discuss banned
|
||||
// terms by name for governance; not user-facing), .git/ (VCS), and this test
|
||||
// file itself (self-exclusion via runtime.Caller(0)).
|
||||
//
|
||||
// Passes at P1 Wave 1 with zero docs (a walk that scans nothing reports green
|
||||
// on zero hits — closed by TestLexiconMetaDocsWalkCoverage below). With the
|
||||
// Wave 2 docs present (README + index + 6 shared pages), all are lexicon-clean
|
||||
// by construction.
|
||||
func TestLexiconMetaDocsNoBannedTermsInDocs(t *testing.T) {
|
||||
root := repoRoot(t)
|
||||
this := thisFile(t)
|
||||
hits := []string{}
|
||||
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
base := filepath.Base(path)
|
||||
if base == ".ciagent" || base == ".git" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Self-exclusion: skip this meta-test file.
|
||||
if path == this {
|
||||
return nil
|
||||
}
|
||||
// Only scan .md files.
|
||||
if !strings.HasSuffix(path, ".md") {
|
||||
return nil
|
||||
}
|
||||
// Only scan README.md (repo root) + docs/**/*.md.
|
||||
rel, rerr := filepath.Rel(root, path)
|
||||
if rerr != nil {
|
||||
return rerr
|
||||
}
|
||||
if rel != "README.md" && !strings.HasPrefix(rel, "docs"+string(filepath.Separator)) && rel != "docs" {
|
||||
return nil
|
||||
}
|
||||
bz, rerr := os.ReadFile(path)
|
||||
if rerr != nil {
|
||||
return rerr
|
||||
}
|
||||
if found, ok := lexicon.FindBannedTerm(string(bz)); ok {
|
||||
hits = append(hits, rel+" contains banned term "+found)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk: %v", err)
|
||||
}
|
||||
if len(hits) > 0 {
|
||||
t.Errorf("REQ-028 docs lexicon firewall violations:\n %s",
|
||||
strings.Join(hits, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// TestLexiconMetaDocsSelfTestTable (G-009 for docs) is the firewall's own
|
||||
// detection-coverage guard. Each synthetic string embeds exactly one banned
|
||||
// term in a plausible sentence context and is asserted to trigger detection,
|
||||
// so the firewall's detection logic is durably verified — if detection ever
|
||||
// breaks, this test fails before the firewall silently passes a real
|
||||
// violation in a docs page.
|
||||
//
|
||||
// G-014 self-test drift: this table is the docs mirror of the
|
||||
// TestLexiconMetaSelfTestTable in lexicon_meta_test.go (package lexicon_meta).
|
||||
// Both reuse lexicon.BannedTerms() as the single source for the 10 terms, so
|
||||
// a future addition updates both firewalls from one place. The synthetic
|
||||
// strings are assembled from lexicon.BannedTerms() fragments so this file
|
||||
// does not contain any banned term as a literal substring (it would otherwise
|
||||
// trip its own scan; the meta-test file is also excluded from its own scan,
|
||||
// but the self-test keeps the source clean for readability/searchability).
|
||||
//
|
||||
// CROSS-REFERENCE: keep this table aligned with
|
||||
//
|
||||
// lexicon_meta_test.go :: TestLexiconMetaSelfTestTable
|
||||
//
|
||||
// Any change to the synthetic-string construction must be mirrored in both
|
||||
// files (or, preferably, add a shared helper in the lexicon package — see
|
||||
// G-014 minimum-viable: cross-reference comment + shared BannedTerms()).
|
||||
func TestLexiconMetaDocsSelfTestTable(t *testing.T) {
|
||||
terms := lexicon.BannedTerms()
|
||||
// The spec lists 10 banned terms (plan docs say "9", counting dollar/euro
|
||||
// as a pair): bank, deposit, interest, yield, currency, dollar, euro,
|
||||
// account, savings, depositor.
|
||||
if len(terms) != 10 {
|
||||
t.Fatalf("BannedTerms() len = %d, want 10", len(terms))
|
||||
}
|
||||
// Each synthetic string embeds exactly one banned term in a plausible
|
||||
// sentence context. Each must be detected.
|
||||
synthetic := []string{
|
||||
"open a " + terms[0] + " here", // bank
|
||||
"make a " + terms[1] + " now", // deposit
|
||||
"compounding " + terms[2] + " rate", // interest
|
||||
"the " + terms[3] + " is 5pct", // yield
|
||||
"foreign " + terms[4] + " pair", // currency
|
||||
"price in " + terms[5], // dollar
|
||||
"price in " + terms[6], // euro
|
||||
"freeze the " + terms[7], // account
|
||||
"move to " + terms[8] + " now", // savings
|
||||
"the " + terms[9] + " lost money", // depositor
|
||||
}
|
||||
if len(synthetic) != len(terms) {
|
||||
t.Fatalf("synthetic table len = %d, want %d", len(synthetic), len(terms))
|
||||
}
|
||||
for i, s := range synthetic {
|
||||
found, ok := lexicon.FindBannedTerm(s)
|
||||
if !ok {
|
||||
t.Errorf("G-009 docs self-test [%d]: synthetic string did not trigger detection: %q", i, s)
|
||||
continue
|
||||
}
|
||||
if found != terms[i] {
|
||||
t.Errorf("G-009 docs self-test [%d]: detected %q, want %q (in %q)", i, found, terms[i], s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLexiconMetaDocsBannedTermsCount asserts exactly 10 banned terms are
|
||||
// configured (locked-const for the firewall's scope; spec lists 10, plan docs
|
||||
// say "9" counting dollar/euro as a pair). Derived from lexicon.BannedTerms()
|
||||
// — the single source — so a count change breaks both this firewall and the
|
||||
// v0.2 x/*.go firewall (G-014 drift prevention).
|
||||
func TestLexiconMetaDocsBannedTermsCount(t *testing.T) {
|
||||
terms := lexicon.BannedTerms()
|
||||
if len(terms) != 10 {
|
||||
t.Errorf("BannedTerms() len = %d, want 10 (REQ-012/REQ-028)", len(terms))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, tr := range terms {
|
||||
if seen[tr] {
|
||||
t.Errorf("duplicate banned term %q", tr)
|
||||
}
|
||||
seen[tr] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestLexiconMetaDocsNoFalsePositiveOnOpenYield asserts the module name
|
||||
// "openyield" does NOT trigger the "yield" banned term and "european" does
|
||||
// NOT trigger the "euro" banned term (word-boundary matching must not match
|
||||
// substrings of identifiers). This is the regression firewall for the
|
||||
// word-boundary detection design — mirrors the v0.2
|
||||
// TestLexiconMetaNoFalsePositiveOnOpenYield.
|
||||
func TestLexiconMetaDocsNoFalsePositiveOnOpenYield(t *testing.T) {
|
||||
cases := []string{
|
||||
"github.com/oy/openyield/x/window/types",
|
||||
"package openyield",
|
||||
"openyield is the module",
|
||||
"european resident",
|
||||
"# OpenYield docs",
|
||||
"the OpenYield mesh",
|
||||
}
|
||||
for _, s := range cases {
|
||||
if _, ok := lexicon.FindBannedTerm(s); ok {
|
||||
t.Errorf("false positive: %q triggered a banned term (word-boundary must avoid this)", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLexiconMetaDocsWalkCoverage (G-013) is the walk-coverage firewall. The
|
||||
// G-009 self-test table (above) verifies DETECTION (FindBannedTerm on
|
||||
// synthetic strings) but NOT the WALK (which files are scanned). A walk bug
|
||||
// — e.g. wrong path prefix, missing docs/ recursion, a typo in the .md
|
||||
// suffix check — would silently scan nothing and report green on zero
|
||||
// files. This test closes that gap by injecting a synthetic banned-term .md
|
||||
// into a fixture dir under the real docs/ path the walk scans and asserting
|
||||
// the walk FINDS it.
|
||||
//
|
||||
// The fixture is created under docs/.lexicon_fixture/ (a real docs/ subtree
|
||||
// the walk reaches) and removed via defer so it never leaks into the repo.
|
||||
// If the walk logic misses the fixture, this test fails loudly instead of
|
||||
// letting a broken walk pass the firewall green on zero files scanned.
|
||||
func TestLexiconMetaDocsWalkCoverage(t *testing.T) {
|
||||
root := repoRoot(t)
|
||||
this := thisFile(t)
|
||||
|
||||
// Build a synthetic banned term from fragments so THIS file does not
|
||||
// contain a banned-term literal (it is excluded from its own scan, but
|
||||
// the synthetic stays clean for readability/searchability).
|
||||
terms := lexicon.BannedTerms()
|
||||
if len(terms) == 0 {
|
||||
t.Fatal("BannedTerms() returned no terms — cannot run walk-coverage")
|
||||
}
|
||||
// Use the first banned term ("bank") assembled from two halves.
|
||||
syntheticTerm := terms[0][:2] + terms[0][2:] // reassemble (no literal in source)
|
||||
badContent := []byte("# fixture\nthis file contains a banned term: " + syntheticTerm + "\n")
|
||||
|
||||
fixtureDir := filepath.Join(root, "docs", ".lexicon_fixture")
|
||||
fixtureFile := filepath.Join(fixtureDir, "bad_fixture.md")
|
||||
if err := os.MkdirAll(fixtureDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir fixture: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(fixtureDir)
|
||||
if err := os.WriteFile(fixtureFile, badContent, 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
|
||||
// Run the SAME walk logic as TestLexiconMetaDocsNoBannedTermsInDocs and
|
||||
// assert it FINDS the fixture's banned term. A walk that returns zero
|
||||
// hits here proves the walk logic is broken (the fixture is a known-bad
|
||||
// file inside docs/ that MUST be detected).
|
||||
hits := []string{}
|
||||
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
base := filepath.Base(path)
|
||||
if base == ".ciagent" || base == ".git" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if path == this {
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".md") {
|
||||
return nil
|
||||
}
|
||||
rel, rerr := filepath.Rel(root, path)
|
||||
if rerr != nil {
|
||||
return rerr
|
||||
}
|
||||
if rel != "README.md" && !strings.HasPrefix(rel, "docs"+string(filepath.Separator)) {
|
||||
return nil
|
||||
}
|
||||
bz, rerr := os.ReadFile(path)
|
||||
if rerr != nil {
|
||||
return rerr
|
||||
}
|
||||
if found, ok := lexicon.FindBannedTerm(string(bz)); ok {
|
||||
hits = append(hits, rel+" contains banned term "+found)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk: %v", err)
|
||||
}
|
||||
// Assert the fixture was found. The rel path uses OS-specific separator;
|
||||
// match on the suffix so the test is portable.
|
||||
foundFixture := false
|
||||
for _, h := range hits {
|
||||
if strings.Contains(h, "bad_fixture.md") && strings.Contains(h, syntheticTerm) {
|
||||
foundFixture = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundFixture {
|
||||
t.Errorf("G-013 walk-coverage: the walk did NOT find the synthetic banned-term fixture at %s — the docs firewall walk logic is broken (it would silently scan nothing and report green). hits=%v", fixtureFile, hits)
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
# OpenYield docs site (MkDocs Material, D-042).
|
||||
#
|
||||
# Build-only Python dep (mkdocs + mkdocs-material); NOT a Go dep (G-006 —
|
||||
# go.mod stays zero-require). Invoke locally with `mkdocs serve` or
|
||||
# `mkdocs build` (see README). No publishing CI in v0.3 (D-046 — publishing
|
||||
# to GitHub/Gitea Pages deferred to v0.4).
|
||||
#
|
||||
# Nav completeness (G-011): the nav lists ALL 26 pages that will exist by end
|
||||
# of P3. P1 creates the shared/ pages + index (8 files); P2 adds nomads/
|
||||
# (8 files); P3 adds freeholders/ (8 files) + reference/ (2 files). Only the
|
||||
# files that exist at P1 ship today; the nav references the not-yet-created
|
||||
# P2/P3 pages by path so the structure is complete and P2/P3 just add files.
|
||||
# mkdocs.yml is a config file, NOT validated by Go tests; the docs firewall
|
||||
# (lexicon_meta_docs_test.go) validates .md content, not nav.
|
||||
|
||||
site_name: OpenYield
|
||||
site_description: OpenYield — a jurisdiction-light, public-good mesh for real production, organized around Holders, Stands, and the Six Principles.
|
||||
|
||||
theme:
|
||||
name: material
|
||||
features:
|
||||
- navigation.sections
|
||||
- navigation.expand
|
||||
- toc.integrate
|
||||
|
||||
markdown_extensions:
|
||||
- admonition
|
||||
- toc:
|
||||
permalink: true
|
||||
- codehilite
|
||||
- pymdownx.superfences
|
||||
|
||||
nav:
|
||||
- Home: index.md
|
||||
- Nomads:
|
||||
- Overview: nomads/index.md
|
||||
- Reach: nomads/reach.md
|
||||
- Stash: nomads/stash.md
|
||||
- Bearers: nomads/bearers.md
|
||||
- Maps-Pay: nomads/maps-pay.md
|
||||
- Pacts: nomads/pacts.md
|
||||
- Standing: nomads/standing.md
|
||||
- Window: nomads/window.md
|
||||
- Freeholders:
|
||||
- Overview: freeholders/index.md
|
||||
- Signals: freeholders/signals.md
|
||||
- Standing: freeholders/standing.md
|
||||
- Stands & Guilds: freeholders/stands-guilds.md
|
||||
- Councils & Voice: freeholders/councils-voice.md
|
||||
- Bonds: freeholders/bonds.md
|
||||
- Partner Spectrum: freeholders/partner-spectrum.md
|
||||
- Anchor Preview: freeholders/anchor-preview.md
|
||||
- Shared:
|
||||
- Overview: shared/index.md
|
||||
- Six Principles: shared/six-principles.md
|
||||
- Bread Scale: shared/bread-scale.md
|
||||
- Storage Pools: shared/storage-pools.md
|
||||
- Watchers & Mirror: shared/watchers-mirror.md
|
||||
- Lexicon: shared/lexicon.md
|
||||
- Vision: shared/vision.md
|
||||
- Reference:
|
||||
- Architecture: reference/architecture.md
|
||||
- Components: reference/components.md
|
||||
@@ -93,6 +93,73 @@ type BeaconFrame struct {
|
||||
TTL int64 `json:"ttl" yaml:"ttl"`
|
||||
}
|
||||
|
||||
// OYSATLink is the OY-SAT (satellite bearer) transport link stub (D-037,
|
||||
// vision §14). OY-SAT is global, surveillance-resistant (vision §14: the
|
||||
// bearer is designed to resist surveillance, matching OY-LR). The struct
|
||||
// mirrors the v0.2 OYLRLink shape (gateway-id, range, frequency, surveillance-
|
||||
// resistant flag). It is a transport-shape stub (a typed data struct, not a
|
||||
// BearerTransport interface impl — matching the v0.2 OYLRLink/BeaconFrame
|
||||
// approach per D-029).
|
||||
//
|
||||
// - satellite-id is the satellite gateway/constellation identifier.
|
||||
// - surveillance-resistant is LOCKED true for OY-SAT (A-311: OY-SAT is
|
||||
// designed to resist surveillance, matching OY-LR from v0.2). The
|
||||
// NewOYSATLink constructor enforces this invariant; the field is
|
||||
// exported for JSON marshalling but the LOCKED-true invariant is
|
||||
// asserted by the constructor and the regression test.
|
||||
// - range-meters is the link range (0 for global satellite coverage).
|
||||
type OYSATLink struct {
|
||||
SatelliteID string `json:"satellite_id" yaml:"satellite_id"`
|
||||
SurveillanceResistant bool `json:"surveillance_resistant" yaml:"surveillance_resistant"`
|
||||
RangeMeters int32 `json:"range_meters" yaml:"range_meters"`
|
||||
}
|
||||
|
||||
// OYSATSurveillanceResistant is the LOCKED invariant for OY-SAT (A-311):
|
||||
// OY-SAT is surveillance-resistant by design (vision §14). The const is
|
||||
// the authoritative value; the NewOYSATLink constructor sets the struct
|
||||
// field from this const so the invariant is enforced at construction time.
|
||||
// A regression test asserts this const is true.
|
||||
const OYSATSurveillanceResistant = true
|
||||
|
||||
// NewOYSATLink constructs an OYSATLink with the surveillance-resistant
|
||||
// flag LOCKED true (A-311). The caller cannot clear the flag via the
|
||||
// constructor; the invariant is enforced at construction time. range-meters
|
||||
// defaults to 0 (global satellite coverage) if not specified.
|
||||
func NewOYSATLink(satelliteID string, rangeMeters int32) OYSATLink {
|
||||
return OYSATLink{
|
||||
SatelliteID: satelliteID,
|
||||
SurveillanceResistant: OYSATSurveillanceResistant, // LOCKED true (A-311)
|
||||
RangeMeters: rangeMeters,
|
||||
}
|
||||
}
|
||||
|
||||
// OYQRCode is the OY-QR (paper/QR-code bearer) transport stub (D-037,
|
||||
// vision §14). OY-QR is 0-range (vision §14: the bearer list has OY-QR at
|
||||
// "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).
|
||||
//
|
||||
// - 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).
|
||||
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"`
|
||||
}
|
||||
|
||||
// MarkConsumed marks the QR as consumed (one-shot, A-311). Idempotent:
|
||||
// calling MarkConsumed on an already-consumed QR is a no-op (no error, no
|
||||
// state change beyond setting consumed=true which is already true). This
|
||||
// locks the one-shot semantics: a QR cannot be unconsumed.
|
||||
func (q *OYQRCode) MarkConsumed() {
|
||||
q.Consumed = true
|
||||
}
|
||||
|
||||
type Params struct{}
|
||||
|
||||
func DefaultParams() Params { return Params{} }
|
||||
|
||||
@@ -261,6 +261,214 @@ func TestLexiconNoBannedTermsInBearersTestFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- v0.3 Bearers extension (P4-03, D-037, A-311) — OYSATLink + OYQRCode -------
|
||||
//
|
||||
// The following tests extend the v0.2 bearers tests with the v0.3 OY-SAT
|
||||
// and OY-QR transport stubs (D-037). The existing v0.1/v0.2 tests above
|
||||
// MUST remain green — no regression. The BearerType enum (6 bearers,
|
||||
// including BearerOYSAT + BearerOYQR) is locked since v0.1; v0.3 adds the
|
||||
// transport STRUCTS only (no enum change).
|
||||
|
||||
// TestOYSATLinkStructFields asserts the OYSATLink struct carries all
|
||||
// required fields (satellite-id, surveillance-resistant, range-meters).
|
||||
func TestOYSATLinkStructFields(t *testing.T) {
|
||||
link := btypes.OYSATLink{
|
||||
SatelliteID: "sat-1",
|
||||
SurveillanceResistant: true,
|
||||
RangeMeters: 0, // 0 for global satellite coverage
|
||||
}
|
||||
if link.SatelliteID != "sat-1" {
|
||||
t.Errorf("SatelliteID = %q", link.SatelliteID)
|
||||
}
|
||||
if !link.SurveillanceResistant {
|
||||
t.Error("SurveillanceResistant must be true for OY-SAT (vision §14)")
|
||||
}
|
||||
if link.RangeMeters != 0 {
|
||||
t.Errorf("RangeMeters = %d, want 0 (global)", link.RangeMeters)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOYSATLinkSurveillanceResistantLockedTrue asserts the OY-SAT
|
||||
// surveillance-resistant invariant is LOCKED true (A-311: OY-SAT is
|
||||
// surveillance-resistant by design, matching OY-LR). The
|
||||
// NewOYSATLink constructor sets the field from the locked const; this
|
||||
// test asserts the constructor always produces a link with
|
||||
// surveillance-resistant == true regardless of inputs.
|
||||
func TestOYSATLinkSurveillanceResistantLockedTrue(t *testing.T) {
|
||||
// The LOCKED const must be true (A-311).
|
||||
if !btypes.OYSATSurveillanceResistant {
|
||||
t.Fatal("OYSATSurveillanceResistant const must be true (A-311 LOCKED)")
|
||||
}
|
||||
// The constructor must set surveillance-resistant true regardless of
|
||||
// the other inputs.
|
||||
cases := []struct {
|
||||
satID string
|
||||
rng int32
|
||||
}{
|
||||
{"sat-1", 0},
|
||||
{"sat-2", 5000},
|
||||
{"", 0},
|
||||
{"global-constellation", 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
link := btypes.NewOYSATLink(c.satID, c.rng)
|
||||
if !link.SurveillanceResistant {
|
||||
t.Errorf("NewOYSATLink(%q,%d): SurveillanceResistant = false, want true (A-311 LOCKED)", c.satID, c.rng)
|
||||
}
|
||||
if link.SurveillanceResistant != btypes.OYSATSurveillanceResistant {
|
||||
t.Errorf("NewOYSATLink(%q,%d): field != locked const (A-311)", c.satID, c.rng)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOYSATLinkConstructorSetsFields asserts NewOYSATLink sets the
|
||||
// satellite-id and range-meters fields from the constructor args.
|
||||
func TestOYSATLinkConstructorSetsFields(t *testing.T) {
|
||||
link := btypes.NewOYSATLink("iridium-1", 0)
|
||||
if link.SatelliteID != "iridium-1" {
|
||||
t.Errorf("SatelliteID = %q, want %q", link.SatelliteID, "iridium-1")
|
||||
}
|
||||
if link.RangeMeters != 0 {
|
||||
t.Errorf("RangeMeters = %d, want 0", link.RangeMeters)
|
||||
}
|
||||
link2 := btypes.NewOYSATLink("starlink-2", 5000)
|
||||
if link2.SatelliteID != "starlink-2" {
|
||||
t.Errorf("SatelliteID = %q, want %q", link2.SatelliteID, "starlink-2")
|
||||
}
|
||||
if link2.RangeMeters != 5000 {
|
||||
t.Errorf("RangeMeters = %d, want 5000", link2.RangeMeters)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOYSATStillInAllBearers is the v0.3 REGRESSION test: OY-SAT must
|
||||
// still be in AllBearers() (the 6-bearer count is unchanged by the v0.3
|
||||
// extension — the BearerType enum is locked since v0.1).
|
||||
func TestOYSATStillInAllBearers(t *testing.T) {
|
||||
bearers := btypes.AllBearers()
|
||||
if len(bearers) != 6 {
|
||||
t.Errorf("AllBearers() len = %d, expected 6 (no regression — D-037)", len(bearers))
|
||||
}
|
||||
found := false
|
||||
for _, b := range bearers {
|
||||
if b.Type == btypes.BearerOYSAT {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("OY-SAT must be in AllBearers() (no regression — D-037)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOYQRStillInAllBearers is the v0.3 REGRESSION test: OY-QR must still
|
||||
// be in AllBearers() (the 6-bearer count is unchanged).
|
||||
func TestOYQRStillInAllBearers(t *testing.T) {
|
||||
bearers := btypes.AllBearers()
|
||||
if len(bearers) != 6 {
|
||||
t.Errorf("AllBearers() len = %d, expected 6 (no regression — D-037)", len(bearers))
|
||||
}
|
||||
found := false
|
||||
for _, b := range bearers {
|
||||
if b.Type == btypes.BearerOYQR {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("OY-QR must be in AllBearers() (no regression — D-037)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOYQRCodeStructFields asserts the OYQRCode struct carries all required
|
||||
// fields (qr-id, payload-bytes, consumed).
|
||||
func TestOYQRCodeStructFields(t *testing.T) {
|
||||
q := btypes.OYQRCode{
|
||||
QRID: "qr-1",
|
||||
PayloadBytes: []byte{0x01, 0x02, 0x03},
|
||||
Consumed: false,
|
||||
}
|
||||
if q.QRID != "qr-1" {
|
||||
t.Errorf("QRID = %q", q.QRID)
|
||||
}
|
||||
if len(q.PayloadBytes) != 3 {
|
||||
t.Errorf("PayloadBytes len = %d, want 3", len(q.PayloadBytes))
|
||||
}
|
||||
if q.Consumed {
|
||||
t.Error("Consumed should be false for a fresh QR")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOYQRCodeMarkConsumedFlipsFlag asserts MarkConsumed sets the consumed
|
||||
// flag to true (A-311: OY-QR is one-shot).
|
||||
func TestOYQRCodeMarkConsumedFlipsFlag(t *testing.T) {
|
||||
q := btypes.OYQRCode{QRID: "qr-1", PayloadBytes: []byte{0x01}, Consumed: false}
|
||||
if q.Consumed {
|
||||
t.Fatal("fresh QR should have Consumed == false")
|
||||
}
|
||||
q.MarkConsumed()
|
||||
if !q.Consumed {
|
||||
t.Error("MarkConsumed should set Consumed = true (A-311 one-shot)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOYQRCodeMarkConsumedIdempotent asserts double-consume is idempotent
|
||||
// (A-311: calling MarkConsumed on an already-consumed QR is a no-op, not an
|
||||
// error). This locks the one-shot semantics: a QR cannot be unconsumed, and
|
||||
// double-marking is safe.
|
||||
func TestOYQRCodeMarkConsumedIdempotent(t *testing.T) {
|
||||
q := btypes.OYQRCode{QRID: "qr-1", PayloadBytes: []byte{0x01}, Consumed: false}
|
||||
// First consume: false -> true.
|
||||
q.MarkConsumed()
|
||||
if !q.Consumed {
|
||||
t.Fatal("first MarkConsumed failed: Consumed still false")
|
||||
}
|
||||
// Second consume: idempotent no-op (stays true, no error, no panic).
|
||||
q.MarkConsumed()
|
||||
if !q.Consumed {
|
||||
t.Error("second MarkConsumed should be idempotent; Consumed must stay true (A-311)")
|
||||
}
|
||||
// Third consume: still idempotent.
|
||||
q.MarkConsumed()
|
||||
if !q.Consumed {
|
||||
t.Error("third MarkConsumed should be idempotent; Consumed must stay true (A-311)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOYQRCodeConsumedCannotBeCleared asserts the one-shot semantics: once
|
||||
// consumed is true, there is no method to clear it (the struct field can be
|
||||
// set directly, but the API provides no Unmark/Reset — A-311 locks the
|
||||
// one-shot invariant). This test verifies no Unmark/Reset method exists by
|
||||
// confirming MarkConsumed is the only state-mutating method (the struct is
|
||||
// a plain data type; the invariant is enforced by the API surface, not a
|
||||
// private field — matching the v0.2 OYLRLink/BeaconFrame shape approach).
|
||||
func TestOYQRCodeConsumedCannotBeCleared(t *testing.T) {
|
||||
q := btypes.OYQRCode{QRID: "qr-1", Consumed: false}
|
||||
q.MarkConsumed()
|
||||
if !q.Consumed {
|
||||
t.Fatal("MarkConsumed failed")
|
||||
}
|
||||
// The one-shot invariant: there is no UnmarkConsumed/Reset method on
|
||||
// OYQRCode. The struct is a plain data type; the API surface (only
|
||||
// MarkConsumed) enforces the one-way transition. We assert the method
|
||||
// set by confirming MarkConsumed does not flip back to false.
|
||||
q.MarkConsumed() // idempotent
|
||||
if !q.Consumed {
|
||||
t.Error("Consumed flipped back to false — one-shot invariant broken (A-311)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOYQRCodeZeroValue asserts the zero-value OYQRCode has Consumed ==
|
||||
// false (a fresh QR is unconsumed).
|
||||
func TestOYQRCodeZeroValue(t *testing.T) {
|
||||
var q btypes.OYQRCode
|
||||
if q.Consumed {
|
||||
t.Error("zero-value OYQRCode should have Consumed == false")
|
||||
}
|
||||
if q.QRID != "" {
|
||||
t.Errorf("zero-value QRID = %q, want empty", q.QRID)
|
||||
}
|
||||
}
|
||||
|
||||
// packageDir resolves a Go import path to its filesystem directory by
|
||||
// walking up from this test file (v0.2 skeleton has zero external deps).
|
||||
func packageDir(t *testing.T, importPath string) string {
|
||||
|
||||
@@ -52,3 +52,93 @@ func knownBondStatus(s BondStatus) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// --- v0.3 extension: GrowthBond + Order genesis helpers (REQ-026, G-008) --------
|
||||
//
|
||||
// genesis.go also holds the data-engineer's genesis schema helpers for the
|
||||
// v0.3 GrowthBond + SecondaryOrder sets (G-008). ValidateGenesis in types.go
|
||||
// composes ValidateGrowthBonds + ValidateOrders; the security-engineer's test
|
||||
// assertions live in types_test.go / genesis_test.go.
|
||||
|
||||
// ValidateGrowthBonds asserts growth-bond-ids are present and unique, that
|
||||
// each embedded Bond's coupon-bps is within the LOCKED [floor, cap] bounds
|
||||
// (D-028), and that each growth-bond's growth-rate-bps would not push the
|
||||
// coupon above the cap (ClampGrowth(currentBps=coupon, growth) == growth —
|
||||
// i.e. the post-growth coupon stays <= cap). The genesis-side clamp is the
|
||||
// authoritative check (a genesis growth-bond with an out-of-bounds coupon or
|
||||
// growth rate is rejected rather than silently clamped).
|
||||
func ValidateGrowthBonds(gbs []GrowthBond) error {
|
||||
seen := make(map[string]bool, len(gbs))
|
||||
for i, gb := range gbs {
|
||||
if gb.BondID == "" {
|
||||
return fmt.Errorf("growth bond [%d]: empty bond-id", i)
|
||||
}
|
||||
if seen[gb.BondID] {
|
||||
return fmt.Errorf("growth bond: duplicate bond-id %q", gb.BondID)
|
||||
}
|
||||
seen[gb.BondID] = true
|
||||
if !knownBondStatus(gb.Status) {
|
||||
return fmt.Errorf("growth bond %q: unknown bond status %q", gb.BondID, gb.Status)
|
||||
}
|
||||
// D-028 clamp on the embedded Bond's coupon.
|
||||
if gb.CouponBps < CouponFloorBps || gb.CouponBps > CouponCapBps {
|
||||
return fmt.Errorf("growth bond %q: coupon-bps %d outside [%d, %d] (D-028 clamp at genesis load)",
|
||||
gb.BondID, gb.CouponBps, CouponFloorBps, CouponCapBps)
|
||||
}
|
||||
// G-012 / A-306: the growth-rate must not push the coupon above the
|
||||
// cap. ClampGrowth(coupon, growth) must equal growth (i.e. the
|
||||
// requested growth fits within the room-to-cap); otherwise the
|
||||
// genesis growth-bond is rejected as out-of-bounds.
|
||||
if ClampGrowth(gb.CouponBps, gb.GrowthRateBps) != gb.GrowthRateBps {
|
||||
return fmt.Errorf("growth bond %q: growth-rate-bps %d would push coupon-bps %d above cap %d (G-012/A-306 clamp at genesis load)",
|
||||
gb.BondID, gb.GrowthRateBps, gb.CouponBps, CouponCapBps)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateOrders asserts order-ids are present and unique, that each order's
|
||||
// bond-id is present, that the side is a known OrderSide, and that the status
|
||||
// is a known OrderStatus (A-212, A-313).
|
||||
func ValidateOrders(orders []SecondaryOrder) error {
|
||||
seen := make(map[string]bool, len(orders))
|
||||
for i, o := range orders {
|
||||
if o.OrderID == "" {
|
||||
return fmt.Errorf("order [%d]: empty order-id", i)
|
||||
}
|
||||
if seen[o.OrderID] {
|
||||
return fmt.Errorf("order: duplicate order-id %q", o.OrderID)
|
||||
}
|
||||
seen[o.OrderID] = true
|
||||
if o.BondID == "" {
|
||||
return fmt.Errorf("order %q: empty bond-id", o.OrderID)
|
||||
}
|
||||
if !knownOrderSide(o.Side) {
|
||||
return fmt.Errorf("order %q: unknown order side %q", o.OrderID, o.Side)
|
||||
}
|
||||
if !knownOrderStatus(o.Status) {
|
||||
return fmt.Errorf("order %q: unknown order status %q", o.OrderID, o.Status)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// knownOrderSide reports whether s is one of the two OrderSide values.
|
||||
func knownOrderSide(s OrderSide) bool {
|
||||
for _, ss := range AllOrderSides() {
|
||||
if s == ss {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// knownOrderStatus reports whether s is one of the three OrderStatus values.
|
||||
func knownOrderStatus(s OrderStatus) bool {
|
||||
for _, ss := range AllOrderStatuses() {
|
||||
if s == ss {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
+167
-11
@@ -113,26 +113,33 @@ type Params struct{}
|
||||
|
||||
func DefaultParams() Params { return Params{} }
|
||||
|
||||
// GenesisState defines the bond module genesis state (REQ-021). Bonds is the
|
||||
// top-level set of issued bonds. ValidateGenesis enforces bond-id uniqueness
|
||||
// and the coupon clamp at genesis load (the data-engineer's genesis.go holds
|
||||
// the schema helpers per G-008).
|
||||
// GenesisState defines the bond module genesis state (REQ-021, REQ-026).
|
||||
// Bonds is the top-level set of issued bonds (v0.2). GrowthBonds (v0.3) and
|
||||
// Orders (v0.3) extend the genesis with growth bonds and secondary-market
|
||||
// orders. ValidateGenesis enforces bond-id / growth-bond-id / order-id
|
||||
// uniqueness and the coupon clamp at genesis load (the data-engineer's
|
||||
// genesis.go holds the schema helpers per G-008).
|
||||
type GenesisState struct {
|
||||
Params Params `json:"params" yaml:"params"`
|
||||
Bonds []Bond `json:"bonds" yaml:"bonds"`
|
||||
Params Params `json:"params" yaml:"params"`
|
||||
Bonds []Bond `json:"bonds" yaml:"bonds"`
|
||||
GrowthBonds []GrowthBond `json:"growth_bonds" yaml:"growth_bonds"`
|
||||
Orders []SecondaryOrder `json:"orders" yaml:"orders"`
|
||||
}
|
||||
|
||||
func DefaultGenesisState() *GenesisState {
|
||||
return &GenesisState{
|
||||
Params: DefaultParams(),
|
||||
Bonds: []Bond{},
|
||||
Params: DefaultParams(),
|
||||
Bonds: []Bond{},
|
||||
GrowthBonds: []GrowthBond{},
|
||||
Orders: []SecondaryOrder{},
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateGenesis performs ID-uniqueness checks (A-212 upgrade from v0.1
|
||||
// no-op): rejects duplicate bond-ids, and runs the coupon clamp at genesis
|
||||
// load (each genesis bond's coupon-bps must be within [floor, cap]). Delegates
|
||||
// to the data-engineer's genesis.go helpers (G-008).
|
||||
// no-op): rejects duplicate bond-ids / growth-bond-ids / order-ids, and runs
|
||||
// the coupon clamp at genesis load (each genesis bond's coupon-bps must be
|
||||
// within [floor, cap]). 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 {
|
||||
@@ -141,5 +148,154 @@ func ValidateGenesis(bz json.RawMessage) error {
|
||||
if err := ValidateBonds(gs.Bonds); err != nil {
|
||||
return fmt.Errorf("bond: %w", err)
|
||||
}
|
||||
if err := ValidateGrowthBonds(gs.GrowthBonds); err != nil {
|
||||
return fmt.Errorf("bond: %w", err)
|
||||
}
|
||||
if err := ValidateOrders(gs.Orders); err != nil {
|
||||
return fmt.Errorf("bond: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- v0.3 extension: GrowthBond + secondary market (REQ-026, D-041, G-012) -------
|
||||
//
|
||||
// The v0.3 bond extension adds GrowthBond (a bond whose coupon grows with
|
||||
// protocol health, vision §17) and secondary-market order types. The 8%/0%
|
||||
// consts (D-028) are UNCHANGED — the regression firewall in types_test.go
|
||||
// asserts CouponCapBps==800 and CouponFloorBps==0 are still the v0.2 values.
|
||||
// Full secondary-market matching is deferred to v0.4.
|
||||
|
||||
// OrderSideCount is the locked count of OrderSide enum values (vision §17
|
||||
// secondary market, A-313). A regression firewall: adding/removing/renaming
|
||||
// an order side breaks this const's test.
|
||||
const OrderSideCount = 2
|
||||
|
||||
// OrderStatusCount is the locked count of OrderStatus enum values (A-313).
|
||||
const OrderStatusCount = 3
|
||||
|
||||
// OrderSide enumerates the two sides of a secondary-market order (vision §17,
|
||||
// REQ-026, A-313): Buy (a bid for a bond), Sell (an ask for a bond).
|
||||
type OrderSide string
|
||||
|
||||
const (
|
||||
OrderBuy OrderSide = "Buy" // bid
|
||||
OrderSell OrderSide = "Sell" // ask
|
||||
)
|
||||
|
||||
// AllOrderSides returns both OrderSide values in vision-§17 order. Locked-
|
||||
// const test asserts exactly 2 entries with these names (A-313).
|
||||
func AllOrderSides() []OrderSide {
|
||||
return []OrderSide{
|
||||
OrderBuy,
|
||||
OrderSell,
|
||||
}
|
||||
}
|
||||
|
||||
// OrderStatus enumerates the three lifecycle states of a secondary-market
|
||||
// order (vision §17, REQ-026, A-313): Open (resting on the book), Filled
|
||||
// (matched and settled), Cancelled (removed by the holder or expired). The
|
||||
// matching engine is v0.4; v0.3 types the order shape only.
|
||||
type OrderStatus string
|
||||
|
||||
const (
|
||||
OrderOpen OrderStatus = "Open" // resting on the book
|
||||
OrderFilled OrderStatus = "Filled" // matched and settled
|
||||
OrderCancelled OrderStatus = "Cancelled" // removed by the holder or expired
|
||||
)
|
||||
|
||||
// AllOrderStatuses returns all three OrderStatus values in A-313 order.
|
||||
// Locked-const test asserts exactly 3 entries with these names.
|
||||
func AllOrderStatuses() []OrderStatus {
|
||||
return []OrderStatus{
|
||||
OrderOpen,
|
||||
OrderFilled,
|
||||
OrderCancelled,
|
||||
}
|
||||
}
|
||||
|
||||
// ClampGrowth returns the additional bps a GrowthBond's coupon can grow so
|
||||
// that the post-growth coupon (currentBps + additional) never exceeds
|
||||
// CouponCapBps (D-028, A-306, G-012). The "post-growth coupon <= cap"
|
||||
// invariant holds UNCONDITIONALLY.
|
||||
//
|
||||
// G-012 BINDING: ClampGrowth MUST guard currentBps > CouponCapBps BEFORE
|
||||
// computing cap - current. The naive `min(cap - current, growth)` underflows
|
||||
// uint32 when current > cap (cap - current wraps to a huge value, then min
|
||||
// picks growthBps — the invariant is violated). This implementation guards
|
||||
// explicitly:
|
||||
// - If currentBps >= CouponCapBps: return 0 (no room to grow; the cap is
|
||||
// already reached or exceeded — the post-growth coupon cannot grow
|
||||
// without breaching the cap).
|
||||
// - Otherwise: return min(CouponCapBps - currentBps, growthBps) (the room-
|
||||
// to-cap, clamped by the requested growth).
|
||||
//
|
||||
// The two G-012-mandated test cases are: currentBps == CouponCapBps (return 0,
|
||||
// the at-cap boundary) and currentBps > CouponCapBps (return 0, the guard
|
||||
// against uint32 underflow — NOT a wrapped huge value).
|
||||
func ClampGrowth(currentBps, growthBps uint32) uint32 {
|
||||
// G-012 guard: at-or-above cap means no room to grow. This MUST be checked
|
||||
// before the cap - current subtraction to avoid uint32 underflow when
|
||||
// currentBps > cap.
|
||||
if currentBps >= CouponCapBps {
|
||||
return 0
|
||||
}
|
||||
// currentBps < cap is guaranteed here; cap - current does not underflow.
|
||||
room := CouponCapBps - currentBps
|
||||
if growthBps < room {
|
||||
return growthBps
|
||||
}
|
||||
return room
|
||||
}
|
||||
|
||||
// GrowthBond is a bond whose coupon grows with protocol health (vision §17,
|
||||
// REQ-026, D-041, A-306). It embeds the v0.2 Bond (anonymous field) so it
|
||||
// carries all Bond fields (bond-id, issuer-stand-id, principal-grain,
|
||||
// coupon-bps, term-days, issued-at, maturity, status) PLUS a GrowthRateBps
|
||||
// field (the per-period growth rate of the coupon, in bps). The growth rate
|
||||
// is clamped at issuance so that the post-growth coupon never exceeds
|
||||
// CouponCapBps (800 bps) — see IssueGrowth, which clamps couponBps via Clamp
|
||||
// and growthRateBps via ClampGrowth (with currentBps=couponBps).
|
||||
//
|
||||
// The 8%/0% consts (D-028) apply to GrowthBonds too: the growth coupon is
|
||||
// clamped to [0, 800] bps at any point. GrowthBond is in the same package as
|
||||
// Bond (no G-003 concern for the Clamp/ClampGrowth reuse).
|
||||
type GrowthBond struct {
|
||||
Bond // anonymous embed — carries all v0.2 Bond fields
|
||||
GrowthRateBps uint32 `json:"growth_rate_bps" yaml:"growth_rate_bps"`
|
||||
}
|
||||
|
||||
// IssueGrowth is the GrowthBond issuance stub (REQ-026, D-041). It constructs a
|
||||
// GrowthBond with the coupon clamped to [CouponFloorBps, CouponCapBps] via
|
||||
// Clamp, and the growth-rate clamped so that coupon + growth never exceeds
|
||||
// CouponCapBps via ClampGrowth (with currentBps=couponBps). The returned
|
||||
// GrowthBond has status BondIssued (inherited from Issue's Bond construction).
|
||||
// The stub does not persist or enforce referential integrity of issuer-stand-
|
||||
// id (a v0.4 keeper concern); it only enforces the coupon + growth clamp
|
||||
// invariants at construction time.
|
||||
func IssueGrowth(bondID, issuerStandID string, principalGrain int64, couponBps, growthRateBps uint32, termDays uint32, issuedAt, maturity int64) GrowthBond {
|
||||
clampedCoupon := Clamp(couponBps)
|
||||
clampedGrowth := ClampGrowth(clampedCoupon, growthRateBps)
|
||||
return GrowthBond{
|
||||
Bond: Issue(bondID, issuerStandID, principalGrain, clampedCoupon, termDays, issuedAt, maturity),
|
||||
GrowthRateBps: clampedGrowth,
|
||||
}
|
||||
}
|
||||
|
||||
// SecondaryOrder is a secondary-market order on an issued bond (vision §17,
|
||||
// REQ-026, D-041, A-313). order-id is the unique identifier. bond-id references
|
||||
// a Bond (by-ID-string ref to a Bond — same package, so this is an in-package
|
||||
// ID-string ref, not a cross-module G-003 concern). side picks OrderSide
|
||||
// (Buy/Sell). price-grain is the order price in Grain (fraction of principal,
|
||||
// expressed in Grain for fixed-point precision). holder-reach-id references
|
||||
// an x/identity Reach by ID-string (G-003 — use "holder-reach-id" not the
|
||||
// banned Holder-identity term). status is the OrderStatus. created-at is the
|
||||
// unix timestamp.
|
||||
type SecondaryOrder struct {
|
||||
OrderID string `json:"order_id" yaml:"order_id"`
|
||||
BondID string `json:"bond_id" yaml:"bond_id"`
|
||||
Side OrderSide `json:"side" yaml:"side"`
|
||||
PriceGrain int64 `json:"price_grain" yaml:"price_grain"`
|
||||
HolderReachID string `json:"holder_reach_id" yaml:"holder_reach_id"`
|
||||
Status OrderStatus `json:"status" yaml:"status"`
|
||||
CreatedAt int64 `json:"created_at" yaml:"created_at"`
|
||||
}
|
||||
|
||||
@@ -416,6 +416,539 @@ func TestLexiconNoBannedTermsInBondTestFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- v0.3 extension: ClampGrowth (G-012 BINDING) ---------------------------------
|
||||
// ClampGrowth is the G-012 binding decision: it MUST guard currentBps >
|
||||
// CouponCapBps before computing cap - current, otherwise the uint32
|
||||
// subtraction underflows (cap - current wraps to a huge value, then min picks
|
||||
// growthBps — the post-growth coupon invariant is violated). These tests are
|
||||
// written FIRST (TDD) to confirm the guard works before the function existed;
|
||||
// they are the highest-severity v0.3 bond firewall.
|
||||
//
|
||||
// The five G-012-mandated test cases:
|
||||
// 1. currentBps == 0 (full growth room)
|
||||
// 2. currentBps == CouponCapBps (no room, return 0 — the at-cap boundary)
|
||||
// 3. currentBps > CouponCapBps (the underflow GUARD — return 0, NOT a wrapped
|
||||
// huge value)
|
||||
// 4. growthBps larger than room (clamp to room)
|
||||
// 5. growthBps smaller than room (return growthBps)
|
||||
|
||||
// TestClampGrowthCurrentZeroFullRoom asserts case 1: currentBps == 0 leaves
|
||||
// the full room to the cap; the growth is clamped to min(cap, growth).
|
||||
func TestClampGrowthCurrentZeroFullRoom(t *testing.T) {
|
||||
// growth < cap (room) -> return growth
|
||||
if got := btypes.ClampGrowth(0, 500); got != 500 {
|
||||
t.Errorf("ClampGrowth(0, 500) = %d, expected 500 (full room, growth < cap)", got)
|
||||
}
|
||||
// growth == cap (room) -> return cap (room)
|
||||
if got := btypes.ClampGrowth(0, btypes.CouponCapBps); got != btypes.CouponCapBps {
|
||||
t.Errorf("ClampGrowth(0, cap) = %d, expected cap %d (full room, growth == cap)", got, btypes.CouponCapBps)
|
||||
}
|
||||
// growth > cap (room) -> return cap (room)
|
||||
if got := btypes.ClampGrowth(0, 1000); got != btypes.CouponCapBps {
|
||||
t.Errorf("ClampGrowth(0, 1000) = %d, expected cap %d (full room, growth > cap clamps to cap)", got, btypes.CouponCapBps)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClampGrowthCurrentAtCapReturnsZero asserts case 2: currentBps ==
|
||||
// CouponCapBps (the at-cap boundary). There is no room to grow; return 0.
|
||||
// This is the G-012-mandated at-cap test.
|
||||
func TestClampGrowthCurrentAtCapReturnsZero(t *testing.T) {
|
||||
got := btypes.ClampGrowth(btypes.CouponCapBps, 100)
|
||||
if got != 0 {
|
||||
t.Errorf("ClampGrowth(cap, 100) = %d, expected 0 (at-cap boundary — no room to grow, G-012)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClampGrowthCurrentAboveCapReturnsZero asserts case 3: currentBps >
|
||||
// CouponCapBps (the uint32 underflow GUARD). The naive min(cap-current,
|
||||
// growth) would underflow uint32 (cap-current wraps to a huge value, then min
|
||||
// picks growth — invariant violated). ClampGrowth MUST return 0, NOT a
|
||||
// wrapped huge value. This is the G-012-mandated above-cap test.
|
||||
func TestClampGrowthCurrentAboveCapReturnsZero(t *testing.T) {
|
||||
cases := []struct {
|
||||
current uint32
|
||||
growth uint32
|
||||
}{
|
||||
{uint32(btypes.CouponCapBps) + 1, 100},
|
||||
{uint32(btypes.CouponCapBps) + 100, 500},
|
||||
{uint32(btypes.CouponCapBps) + 1000, 50},
|
||||
{5000, 100},
|
||||
{100_000, 1},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := btypes.ClampGrowth(c.current, c.growth)
|
||||
if got != 0 {
|
||||
t.Errorf("ClampGrowth(%d, %d) = %d, expected 0 (above-cap GUARD — uint32 underflow must NOT happen, G-012)",
|
||||
c.current, c.growth, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestClampGrowthGrowthLargerThanRoomClampsToRoom asserts case 4: growthBps
|
||||
// larger than the room-to-cap is clamped to the room.
|
||||
func TestClampGrowthGrowthLargerThanRoomClampsToRoom(t *testing.T) {
|
||||
// current=500, cap=800, room=300. growth=400 > room -> return 300.
|
||||
got := btypes.ClampGrowth(500, 400)
|
||||
if got != 300 {
|
||||
t.Errorf("ClampGrowth(500, 400) = %d, expected 300 (growth larger than room clamps to room)", got)
|
||||
}
|
||||
// current=799, cap=800, room=1. growth=50 > room -> return 1.
|
||||
got = btypes.ClampGrowth(799, 50)
|
||||
if got != 1 {
|
||||
t.Errorf("ClampGrowth(799, 50) = %d, expected 1 (room=1, growth clamps to room)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClampGrowthGrowthSmallerThanRoomReturnsGrowth asserts case 5: growthBps
|
||||
// smaller than the room-to-cap is returned unchanged.
|
||||
func TestClampGrowthGrowthSmallerThanRoomReturnsGrowth(t *testing.T) {
|
||||
// current=500, cap=800, room=300. growth=200 < room -> return 200.
|
||||
got := btypes.ClampGrowth(500, 200)
|
||||
if got != 200 {
|
||||
t.Errorf("ClampGrowth(500, 200) = %d, expected 200 (growth < room, unchanged)", got)
|
||||
}
|
||||
// current=0, cap=800, room=800. growth=100 < room -> return 100.
|
||||
got = btypes.ClampGrowth(0, 100)
|
||||
if got != 100 {
|
||||
t.Errorf("ClampGrowth(0, 100) = %d, expected 100 (growth < room, unchanged)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClampGrowthInvariantPostGrowthLeCap is the meta-assert: ClampGrowth
|
||||
// never ADDS growth that would push the post-growth coupon past the cap. The
|
||||
// invariant is: current + ClampGrowth(current, growth) <= max(current, cap).
|
||||
// When current <= cap, this means post-growth <= cap (no growth past the
|
||||
// cap). When current > cap (the G-012 misuse/guard case), ClampGrowth returns
|
||||
// 0 (no additional growth), so post == current (the already-broken state is
|
||||
// not made worse; the guard prevents the uint32 underflow from adding a
|
||||
// wrapped-huge value as growth).
|
||||
func TestClampGrowthInvariantPostGrowthLeCap(t *testing.T) {
|
||||
cases := []struct {
|
||||
current uint32
|
||||
growth uint32
|
||||
}{
|
||||
{0, 0},
|
||||
{0, 800},
|
||||
{0, 1000},
|
||||
{400, 400},
|
||||
{400, 500},
|
||||
{799, 1},
|
||||
{799, 100},
|
||||
{800, 100}, // at-cap
|
||||
{801, 100}, // above-cap (guard)
|
||||
{5000, 1000}, // way above-cap (guard)
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := btypes.ClampGrowth(c.current, c.growth)
|
||||
post := c.current + got
|
||||
// The bound: post <= max(current, cap). When current <= cap, this is
|
||||
// post <= cap (no growth past the cap). When current > cap, this is
|
||||
// post <= current (no additional growth — the guard returned 0).
|
||||
upper := c.current
|
||||
if uint32(btypes.CouponCapBps) > upper {
|
||||
upper = btypes.CouponCapBps
|
||||
}
|
||||
if post > upper {
|
||||
t.Errorf("ClampGrowth(%d, %d) = %d; post-growth coupon %d > %d (G-012 invariant violated)",
|
||||
c.current, c.growth, got, post, upper)
|
||||
}
|
||||
// Stronger assert for the in-bounds case: when current <= cap, post
|
||||
// must be <= cap exactly (no growth past the cap).
|
||||
if c.current <= btypes.CouponCapBps && post > btypes.CouponCapBps {
|
||||
t.Errorf("ClampGrowth(%d, %d) = %d; post-growth coupon %d > cap %d (in-bounds invariant violated)",
|
||||
c.current, c.growth, got, post, btypes.CouponCapBps)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- D-028 regression: 8%/0% consts unchanged (v0.3 must not change v0.2) -------
|
||||
// These tests are re-declared here in the v0.3 block to make the regression
|
||||
// firewall explicit in the extension context. The v0.2 tests above
|
||||
// (TestCouponCapBpsLockedConst / TestCouponFloorBpsLockedConst) are the
|
||||
// primary firewall; this block re-asserts in the v0.3 extension context.
|
||||
|
||||
// TestD028RegressionCouponCapUnchanged asserts CouponCapBps is still 800
|
||||
// after the v0.3 GrowthBond extension (D-028 regression firewall).
|
||||
func TestD028RegressionCouponCapUnchanged(t *testing.T) {
|
||||
if btypes.CouponCapBps != 800 {
|
||||
t.Errorf("D-028 regression: CouponCapBps = %d, expected 800 (v0.3 must not change v0.2 const)", btypes.CouponCapBps)
|
||||
}
|
||||
}
|
||||
|
||||
// TestD028RegressionCouponFloorUnchanged asserts CouponFloorBps is still 0.
|
||||
func TestD028RegressionCouponFloorUnchanged(t *testing.T) {
|
||||
if btypes.CouponFloorBps != 0 {
|
||||
t.Errorf("D-028 regression: CouponFloorBps = %d, expected 0 (v0.3 must not change v0.2 const)", btypes.CouponFloorBps)
|
||||
}
|
||||
}
|
||||
|
||||
// TestD028RegressionBondStatusCountUnchanged asserts BondStatusCount is still
|
||||
// 5 (the v0.2 enum is unchanged by the v0.3 extension).
|
||||
func TestD028RegressionBondStatusCountUnchanged(t *testing.T) {
|
||||
if btypes.BondStatusCount != 5 {
|
||||
t.Errorf("D-028 regression: BondStatusCount = %d, expected 5 (v0.2 enum unchanged)", btypes.BondStatusCount)
|
||||
}
|
||||
}
|
||||
|
||||
// --- OrderSide enum coverage (2) ----------------------------------------------
|
||||
|
||||
// TestOrderSideCountLockedConst asserts OrderSideCount == 2 and AllOrderSides()
|
||||
// returns exactly 2 (A-313). A regression firewall.
|
||||
func TestOrderSideCountLockedConst(t *testing.T) {
|
||||
if btypes.OrderSideCount != 2 {
|
||||
t.Errorf("OrderSideCount = %d, expected 2 (A-313 LOCKED)", btypes.OrderSideCount)
|
||||
}
|
||||
all := btypes.AllOrderSides()
|
||||
if len(all) != 2 {
|
||||
t.Errorf("AllOrderSides() len = %d, expected 2", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllOrderSidesNames asserts the 2 A-313 names in order with no extras, no
|
||||
// dups, no renames.
|
||||
func TestAllOrderSidesNames(t *testing.T) {
|
||||
want := []string{"Buy", "Sell"}
|
||||
all := btypes.AllOrderSides()
|
||||
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("AllOrderSides()[%d] = %q, want %q", i, s, want[i])
|
||||
}
|
||||
if seen[string(s)] {
|
||||
t.Errorf("duplicate OrderSide %q", s)
|
||||
}
|
||||
seen[string(s)] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestOrderSideValues asserts each named const matches its AllOrderSides entry.
|
||||
func TestOrderSideValues(t *testing.T) {
|
||||
if btypes.OrderBuy != "Buy" {
|
||||
t.Errorf("OrderBuy = %q", btypes.OrderBuy)
|
||||
}
|
||||
if btypes.OrderSell != "Sell" {
|
||||
t.Errorf("OrderSell = %q", btypes.OrderSell)
|
||||
}
|
||||
}
|
||||
|
||||
// --- OrderStatus enum coverage (3) -------------------------------------------
|
||||
|
||||
// TestOrderStatusCountLockedConst asserts OrderStatusCount == 3 and
|
||||
// AllOrderStatuses() returns exactly 3 (A-313). A regression firewall.
|
||||
func TestOrderStatusCountLockedConst(t *testing.T) {
|
||||
if btypes.OrderStatusCount != 3 {
|
||||
t.Errorf("OrderStatusCount = %d, expected 3 (A-313 LOCKED)", btypes.OrderStatusCount)
|
||||
}
|
||||
all := btypes.AllOrderStatuses()
|
||||
if len(all) != 3 {
|
||||
t.Errorf("AllOrderStatuses() len = %d, expected 3", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllOrderStatusesNames asserts the 3 A-313 names in order with no extras,
|
||||
// no dups, no renames.
|
||||
func TestAllOrderStatusesNames(t *testing.T) {
|
||||
want := []string{"Open", "Filled", "Cancelled"}
|
||||
all := btypes.AllOrderStatuses()
|
||||
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("AllOrderStatuses()[%d] = %q, want %q", i, s, want[i])
|
||||
}
|
||||
if seen[string(s)] {
|
||||
t.Errorf("duplicate OrderStatus %q", s)
|
||||
}
|
||||
seen[string(s)] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestOrderStatusValues asserts each named const matches its AllOrderStatuses
|
||||
// entry.
|
||||
func TestOrderStatusValues(t *testing.T) {
|
||||
if btypes.OrderOpen != "Open" {
|
||||
t.Errorf("OrderOpen = %q", btypes.OrderOpen)
|
||||
}
|
||||
if btypes.OrderFilled != "Filled" {
|
||||
t.Errorf("OrderFilled = %q", btypes.OrderFilled)
|
||||
}
|
||||
if btypes.OrderCancelled != "Cancelled" {
|
||||
t.Errorf("OrderCancelled = %q", btypes.OrderCancelled)
|
||||
}
|
||||
}
|
||||
|
||||
// --- GrowthBond + IssueGrowth --------------------------------------------------
|
||||
|
||||
// TestGrowthBondStructFields asserts GrowthBond embeds Bond and adds
|
||||
// GrowthRateBps.
|
||||
func TestGrowthBondStructFields(t *testing.T) {
|
||||
gb := btypes.GrowthBond{
|
||||
Bond: btypes.Bond{BondID: "gb-1", IssuerStandID: "stand-1", PrincipalGrain: 1_000_000, CouponBps: 500, TermDays: 365, IssuedAt: 1000, Maturity: 1365, Status: btypes.BondIssued},
|
||||
GrowthRateBps: 200,
|
||||
}
|
||||
if gb.BondID != "gb-1" || gb.IssuerStandID != "stand-1" || gb.PrincipalGrain != 1_000_000 ||
|
||||
gb.CouponBps != 500 || gb.TermDays != 365 || gb.IssuedAt != 1000 || gb.Maturity != 1365 ||
|
||||
gb.Status != btypes.BondIssued || gb.GrowthRateBps != 200 {
|
||||
t.Error("GrowthBond fields not set correctly")
|
||||
}
|
||||
// The embedded Bond is accessible via the anonymous field.
|
||||
if gb.Bond.BondID != "gb-1" {
|
||||
t.Errorf("embedded Bond.BondID = %q", gb.Bond.BondID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssueGrowthConstruction asserts IssueGrowth clamps the coupon via Clamp
|
||||
// and the growth-rate via ClampGrowth, and returns status BondIssued.
|
||||
func TestIssueGrowthConstruction(t *testing.T) {
|
||||
// In-range coupon and growth: both unchanged.
|
||||
gb := btypes.IssueGrowth("gb-2", "stand-1", 1_000_000, 500, 200, 365, 1000, 1365)
|
||||
if gb.BondID != "gb-2" {
|
||||
t.Errorf("BondID = %q", gb.BondID)
|
||||
}
|
||||
if gb.CouponBps != 500 {
|
||||
t.Errorf("CouponBps = %d, expected 500 (in-range, unchanged)", gb.CouponBps)
|
||||
}
|
||||
if gb.GrowthRateBps != 200 {
|
||||
t.Errorf("GrowthRateBps = %d, expected 200 (in-range, growth < room)", gb.GrowthRateBps)
|
||||
}
|
||||
if gb.Status != btypes.BondIssued {
|
||||
t.Errorf("Status = %q, expected BondIssued", gb.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssueGrowthClampsAboveCapCoupon asserts IssueGrowth clamps an above-cap
|
||||
// coupon down to the cap (via Clamp), and the growth-rate is then clamped
|
||||
// against the clamped coupon (currentBps=cap -> growth returns 0, G-012).
|
||||
func TestIssueGrowthClampsAboveCapCoupon(t *testing.T) {
|
||||
gb := btypes.IssueGrowth("gb-3", "stand-1", 1_000_000, 1200, 100, 365, 1000, 1365)
|
||||
if gb.CouponBps != btypes.CouponCapBps {
|
||||
t.Errorf("CouponBps = %d, expected cap %d (IssueGrowth must clamp above-cap coupon)", gb.CouponBps, btypes.CouponCapBps)
|
||||
}
|
||||
// coupon clamped to cap -> ClampGrowth(cap, 100) == 0 (no room, G-012).
|
||||
if gb.GrowthRateBps != 0 {
|
||||
t.Errorf("GrowthRateBps = %d, expected 0 (coupon at cap -> no room, G-012)", gb.GrowthRateBps)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssueGrowthClampsGrowthToRoom asserts IssueGrowth clamps a growth-rate
|
||||
// that would push the coupon above the cap down to the room-to-cap.
|
||||
func TestIssueGrowthClampsGrowthToRoom(t *testing.T) {
|
||||
// coupon=500, cap=800, room=300. growth=400 -> clamped to 300.
|
||||
gb := btypes.IssueGrowth("gb-4", "stand-1", 1_000_000, 500, 400, 365, 1000, 1365)
|
||||
if gb.CouponBps != 500 {
|
||||
t.Errorf("CouponBps = %d, expected 500", gb.CouponBps)
|
||||
}
|
||||
if gb.GrowthRateBps != 300 {
|
||||
t.Errorf("GrowthRateBps = %d, expected 300 (growth clamped to room, G-012)", gb.GrowthRateBps)
|
||||
}
|
||||
// post-growth coupon: 500 + 300 = 800 == cap (invariant holds).
|
||||
if gb.CouponBps+gb.GrowthRateBps > btypes.CouponCapBps {
|
||||
t.Errorf("post-growth coupon %d > cap %d (G-012 invariant)", gb.CouponBps+gb.GrowthRateBps, btypes.CouponCapBps)
|
||||
}
|
||||
}
|
||||
|
||||
// --- SecondaryOrder struct ----------------------------------------------------
|
||||
|
||||
// TestSecondaryOrderStructFields asserts SecondaryOrder carries order-id,
|
||||
// bond-id (by-ID-string ref to a Bond — in-package), side, price-grain,
|
||||
// holder-reach-id (by-ID-string ref to x/identity — G-003), status, created-at.
|
||||
func TestSecondaryOrderStructFields(t *testing.T) {
|
||||
o := btypes.SecondaryOrder{
|
||||
OrderID: "order-1",
|
||||
BondID: "bond-1",
|
||||
Side: btypes.OrderBuy,
|
||||
PriceGrain: 950_000,
|
||||
HolderReachID: "reach-holder-1",
|
||||
Status: btypes.OrderOpen,
|
||||
CreatedAt: 5000,
|
||||
}
|
||||
if o.OrderID != "order-1" || o.BondID != "bond-1" || o.Side != btypes.OrderBuy ||
|
||||
o.PriceGrain != 950_000 || o.HolderReachID != "reach-holder-1" ||
|
||||
o.Status != btypes.OrderOpen || o.CreatedAt != 5000 {
|
||||
t.Error("SecondaryOrder fields not set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecondaryOrderBondIDIsString asserts bond-id is string-typed (in-package
|
||||
// by-ID-string ref to a Bond — same package, not a G-003 cross-module import).
|
||||
func TestSecondaryOrderBondIDIsString(t *testing.T) {
|
||||
o := btypes.SecondaryOrder{BondID: "bond-xyz"}
|
||||
if o.BondID != "bond-xyz" {
|
||||
t.Errorf("BondID = %q", o.BondID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecondaryOrderHolderReachIDIsString asserts holder-reach-id is
|
||||
// string-typed (G-003 by-ID-string ref to x/identity Reach — no struct import).
|
||||
func TestSecondaryOrderHolderReachIDIsString(t *testing.T) {
|
||||
o := btypes.SecondaryOrder{HolderReachID: "reach-abc"}
|
||||
if o.HolderReachID != "reach-abc" {
|
||||
t.Errorf("HolderReachID = %q", o.HolderReachID)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Genesis v0.3 extension: GrowthBonds + Orders -----------------------------
|
||||
|
||||
// TestDefaultGenesisStateV3Empty asserts DefaultGenesisState returns non-nil
|
||||
// empty slices for the v0.3 GrowthBonds and Orders sets.
|
||||
func TestDefaultGenesisStateV3Empty(t *testing.T) {
|
||||
gs := btypes.DefaultGenesisState()
|
||||
if gs.GrowthBonds == nil || len(gs.GrowthBonds) != 0 {
|
||||
t.Errorf("Default GrowthBonds should be non-nil empty slice; got len=%d nil=%v", len(gs.GrowthBonds), gs.GrowthBonds == nil)
|
||||
}
|
||||
if gs.Orders == nil || len(gs.Orders) != 0 {
|
||||
t.Errorf("Default Orders should be non-nil empty slice; got len=%d nil=%v", len(gs.Orders), gs.Orders == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsDupGrowthBondIDs asserts A-212: duplicate
|
||||
// growth-bond-ids are rejected.
|
||||
func TestValidateGenesisRejectsDupGrowthBondIDs(t *testing.T) {
|
||||
gs := btypes.GenesisState{
|
||||
GrowthBonds: []btypes.GrowthBond{
|
||||
{Bond: btypes.Bond{BondID: "gb1", IssuerStandID: "s1", CouponBps: 500, Status: btypes.BondIssued}, GrowthRateBps: 100},
|
||||
{Bond: btypes.Bond{BondID: "gb1", IssuerStandID: "s2", CouponBps: 200, Status: btypes.BondActive}, GrowthRateBps: 50}, // dup
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := btypes.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject duplicate growth-bond-ids")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsGrowthBondCouponAboveCap asserts a genesis
|
||||
// GrowthBond with coupon-bps above the cap is rejected (D-028 at genesis).
|
||||
func TestValidateGenesisRejectsGrowthBondCouponAboveCap(t *testing.T) {
|
||||
gs := btypes.GenesisState{
|
||||
GrowthBonds: []btypes.GrowthBond{
|
||||
{Bond: btypes.Bond{BondID: "gb1", IssuerStandID: "s1", CouponBps: 900, Status: btypes.BondIssued}, GrowthRateBps: 0},
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := btypes.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject growth-bond coupon above cap (D-028)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsGrowthBondGrowthAboveRoom asserts a genesis
|
||||
// GrowthBond whose growth-rate would push the coupon above the cap is
|
||||
// rejected (G-012 / A-306 at genesis).
|
||||
func TestValidateGenesisRejectsGrowthBondGrowthAboveRoom(t *testing.T) {
|
||||
gs := btypes.GenesisState{
|
||||
GrowthBonds: []btypes.GrowthBond{
|
||||
// coupon=500, cap=800, room=300. growth=400 -> would push to 900 > cap.
|
||||
{Bond: btypes.Bond{BondID: "gb1", IssuerStandID: "s1", CouponBps: 500, Status: btypes.BondIssued}, GrowthRateBps: 400},
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := btypes.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject growth-bond growth-rate above room (G-012/A-306)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsDupOrderIDs asserts A-212: duplicate order-ids are
|
||||
// rejected.
|
||||
func TestValidateGenesisRejectsDupOrderIDs(t *testing.T) {
|
||||
gs := btypes.GenesisState{
|
||||
Orders: []btypes.SecondaryOrder{
|
||||
{OrderID: "o1", BondID: "b1", Side: btypes.OrderBuy, Status: btypes.OrderOpen},
|
||||
{OrderID: "o1", BondID: "b2", Side: btypes.OrderSell, Status: btypes.OrderOpen}, // dup
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := btypes.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject duplicate order-ids")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsEmptyOrderBondID asserts an order with an empty
|
||||
// bond-id is rejected.
|
||||
func TestValidateGenesisRejectsEmptyOrderBondID(t *testing.T) {
|
||||
gs := btypes.GenesisState{
|
||||
Orders: []btypes.SecondaryOrder{{OrderID: "o1", BondID: "", Side: btypes.OrderBuy, Status: btypes.OrderOpen}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := btypes.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject empty order bond-id")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsUnknownOrderSide asserts an unknown OrderSide is
|
||||
// rejected.
|
||||
func TestValidateGenesisRejectsUnknownOrderSide(t *testing.T) {
|
||||
gs := btypes.GenesisState{
|
||||
Orders: []btypes.SecondaryOrder{{OrderID: "o1", BondID: "b1", Side: btypes.OrderSide("Bogus"), Status: btypes.OrderOpen}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := btypes.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject unknown order side")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsUnknownOrderStatus asserts an unknown OrderStatus
|
||||
// is rejected.
|
||||
func TestValidateGenesisRejectsUnknownOrderStatus(t *testing.T) {
|
||||
gs := btypes.GenesisState{
|
||||
Orders: []btypes.SecondaryOrder{{OrderID: "o1", BondID: "b1", Side: btypes.OrderBuy, Status: btypes.OrderStatus("Bogus")}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := btypes.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject unknown order status")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisAcceptsCleanV3 asserts a clean v0.3 genesis (bonds +
|
||||
// growth bonds + orders) validates.
|
||||
func TestValidateGenesisAcceptsCleanV3(t *testing.T) {
|
||||
gs := btypes.GenesisState{
|
||||
Bonds: []btypes.Bond{
|
||||
{BondID: "b1", IssuerStandID: "s1", CouponBps: 100, Status: btypes.BondIssued},
|
||||
},
|
||||
GrowthBonds: []btypes.GrowthBond{
|
||||
{Bond: btypes.Bond{BondID: "gb1", IssuerStandID: "s1", CouponBps: 500, Status: btypes.BondIssued}, GrowthRateBps: 200},
|
||||
{Bond: btypes.Bond{BondID: "gb2", IssuerStandID: "s1", CouponBps: 800, Status: btypes.BondActive}, GrowthRateBps: 0},
|
||||
},
|
||||
Orders: []btypes.SecondaryOrder{
|
||||
{OrderID: "o1", BondID: "b1", Side: btypes.OrderBuy, PriceGrain: 950_000, HolderReachID: "r1", Status: btypes.OrderOpen, CreatedAt: 1000},
|
||||
{OrderID: "o2", BondID: "gb1", Side: btypes.OrderSell, PriceGrain: 1_050_000, HolderReachID: "r2", Status: btypes.OrderFilled, CreatedAt: 2000},
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := btypes.ValidateGenesis(bz); err != nil {
|
||||
t.Errorf("ValidateGenesis should accept clean v0.3 genesis, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGrowthBondsAcceptsClean asserts the data-engineer's
|
||||
// ValidateGrowthBonds helper accepts a clean set.
|
||||
func TestValidateGrowthBondsAcceptsClean(t *testing.T) {
|
||||
gbs := []btypes.GrowthBond{
|
||||
{Bond: btypes.Bond{BondID: "gb1", CouponBps: 0, Status: btypes.BondIssued}, GrowthRateBps: 800},
|
||||
{Bond: btypes.Bond{BondID: "gb2", CouponBps: 500, Status: btypes.BondActive}, GrowthRateBps: 300},
|
||||
{Bond: btypes.Bond{BondID: "gb3", CouponBps: 800, Status: btypes.BondMatured}, GrowthRateBps: 0},
|
||||
}
|
||||
if err := btypes.ValidateGrowthBonds(gbs); err != nil {
|
||||
t.Errorf("ValidateGrowthBonds should accept clean set; got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateOrdersAcceptsClean asserts ValidateOrders accepts a clean set.
|
||||
func TestValidateOrdersAcceptsClean(t *testing.T) {
|
||||
orders := []btypes.SecondaryOrder{
|
||||
{OrderID: "o1", BondID: "b1", Side: btypes.OrderBuy, Status: btypes.OrderOpen},
|
||||
{OrderID: "o2", BondID: "b1", Side: btypes.OrderSell, Status: btypes.OrderFilled},
|
||||
{OrderID: "o3", BondID: "b2", Side: btypes.OrderBuy, Status: btypes.OrderCancelled},
|
||||
}
|
||||
if err := btypes.ValidateOrders(orders); err != nil {
|
||||
t.Errorf("ValidateOrders should accept clean set; got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// packageDir resolves a Go import path to its filesystem directory by
|
||||
// walking up from this test file (v0.2 skeleton has zero external deps).
|
||||
func packageDir(t *testing.T, importPath string) string {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package types
|
||||
|
||||
import "fmt"
|
||||
|
||||
// genesis.go holds the data-engineer's genesis schema helpers for the
|
||||
// bridge module (G-008 split). ValidateGenesis in types.go composes these
|
||||
// helpers; the security-engineer's test assertions live in types_test.go.
|
||||
//
|
||||
// The Bridge genesis schema has one top-level set: Routes (the bridge
|
||||
// routes). The invariants enforced at genesis load are (1) bridge-id
|
||||
// uniqueness, (2) bridge-id non-empty, and (3) status is a known
|
||||
// BridgeStatus. The route's l2-chain and watcher-quorum-id are by-ID-string
|
||||
// refs (G-003) and are NOT referentially checked at genesis (the referenced
|
||||
// x/satellite and x/watcher state is in separate modules; cross-module
|
||||
// referential integrity is a v0.4 keeper concern, not a v0.3 skeleton
|
||||
// concern per A-304).
|
||||
|
||||
// ValidateRoutes asserts bridge-ids are present and unique, and that each
|
||||
// route's status is a known BridgeStatus. ValidateRoutes is the
|
||||
// data-engineer's schema validator, composed by ValidateGenesis in
|
||||
// types.go.
|
||||
func ValidateRoutes(routes []BridgeRoute) error {
|
||||
seen := make(map[string]bool, len(routes))
|
||||
for i, r := range routes {
|
||||
if r.BridgeID == "" {
|
||||
return fmt.Errorf("bridge [%d]: empty bridge-id", i)
|
||||
}
|
||||
if seen[r.BridgeID] {
|
||||
return fmt.Errorf("bridge: duplicate bridge-id %q", r.BridgeID)
|
||||
}
|
||||
seen[r.BridgeID] = true
|
||||
if !knownBridgeStatus(r.Status) {
|
||||
return fmt.Errorf("bridge %q: unknown bridge status %q", r.BridgeID, r.Status)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// knownBridgeStatus reports whether s is one of the four BridgeStatus
|
||||
// values.
|
||||
func knownBridgeStatus(s BridgeStatus) bool {
|
||||
for _, ss := range AllBridgeStatuses() {
|
||||
if s == ss {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const (
|
||||
ModuleName = "bridge"
|
||||
StoreKey = ModuleName
|
||||
RouterKey = ModuleName
|
||||
QuerierRoute = ModuleName
|
||||
|
||||
// BridgeStatusCount is the locked count of BridgeStatus enum values
|
||||
// (vision §7, REQ-010, D-036). Four route-level lifecycle states:
|
||||
// Pending, Attested, Active, Closed. A regression firewall:
|
||||
// adding/removing/renaming a status breaks this const's test.
|
||||
BridgeStatusCount = 4
|
||||
)
|
||||
|
||||
// BridgeStatus enumerates the route-level lifecycle of an L2↔L1 bridge
|
||||
// (vision §7, REQ-010, D-036). The four-state lifecycle sits above the
|
||||
// ICS-20 channel handshake (x/satellite ChannelStatus): a bridge route is
|
||||
// Pending until Watcher attestation confirms it (Attested), then it
|
||||
// becomes Active for transfers, and is Closed when the route is retired.
|
||||
// The Attested state references a Watcher quorum by ID-string (the
|
||||
// attestation is a by-ID-string field, not a struct import — G-003).
|
||||
type BridgeStatus string
|
||||
|
||||
const (
|
||||
BridgePending BridgeStatus = "Pending" // route declared, awaiting attestation
|
||||
BridgeAttested BridgeStatus = "Attested" // Watcher quorum confirmed the route
|
||||
BridgeActive BridgeStatus = "Active" // route open for transfers
|
||||
BridgeClosed BridgeStatus = "Closed" // route retired
|
||||
)
|
||||
|
||||
// AllBridgeStatuses returns all four BridgeStatus values in vision §7
|
||||
// route-lifecycle order. Locked-const test asserts exactly 4 entries.
|
||||
func AllBridgeStatuses() []BridgeStatus {
|
||||
return []BridgeStatus{
|
||||
BridgePending,
|
||||
BridgeAttested,
|
||||
BridgeActive,
|
||||
BridgeClosed,
|
||||
}
|
||||
}
|
||||
|
||||
// BridgeRoute is a single L2↔L1 bridge route (REQ-010, D-036). The route
|
||||
// is the higher-level abstraction over the v0.2 satellite IBC transfer
|
||||
// channel: it carries the route-level status lifecycle and the Watcher
|
||||
// attestation ref, while the underlying channel handshake lives in
|
||||
// x/satellite. All cross-module references are by-ID-string per G-003:
|
||||
//
|
||||
// - bridge-id is this route's unique identifier.
|
||||
// - l2-chain references an x/satellite L2Chain by ID-string (the L2
|
||||
// satellite chain this route bridges to/from). No struct import of
|
||||
// x/satellite (G-003).
|
||||
// - watcher-quorum-id references an x/watcher quorum by ID-string; it is
|
||||
// set when status transitions to Attested (the Watcher 6-of-9 quorum
|
||||
// attests the route per vision §7). No struct import of x/watcher.
|
||||
//
|
||||
// status is the route-level lifecycle (BridgeStatus), distinct from the
|
||||
// channel-level handshake (x/satellite ChannelStatus).
|
||||
type BridgeRoute struct {
|
||||
BridgeID string `json:"bridge_id" yaml:"bridge_id"`
|
||||
L2Chain string `json:"l2_chain" yaml:"l2_chain"`
|
||||
WatcherQuorumID string `json:"watcher_quorum_id" yaml:"watcher_quorum_id"`
|
||||
Status BridgeStatus `json:"status" yaml:"status"`
|
||||
}
|
||||
|
||||
// Params for the bridge module (skeleton — no tunables in v0.3).
|
||||
type Params struct{}
|
||||
|
||||
func DefaultParams() Params { return Params{} }
|
||||
|
||||
// GenesisState defines the bridge module genesis state (REQ-010). Routes
|
||||
// is the set of bridge routes. ValidateGenesis enforces bridge-id
|
||||
// uniqueness and status validity. The data-engineer's genesis.go holds
|
||||
// the schema helpers (G-008 split).
|
||||
type GenesisState struct {
|
||||
Params Params `json:"params" yaml:"params"`
|
||||
Routes []BridgeRoute `json:"routes" yaml:"routes"`
|
||||
}
|
||||
|
||||
func DefaultGenesisState() *GenesisState {
|
||||
return &GenesisState{
|
||||
Params: DefaultParams(),
|
||||
Routes: []BridgeRoute{},
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateGenesis performs ID-uniqueness checks (A-212 upgrade from v0.1
|
||||
// no-op): rejects duplicate bridge-ids and unknown statuses. 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("bridge: invalid genesis: %w", err)
|
||||
}
|
||||
if err := ValidateRoutes(gs.Routes); err != nil {
|
||||
return fmt.Errorf("bridge: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package types_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/oy/openyield/lexicon"
|
||||
btypes "github.com/oy/openyield/x/bridge/types"
|
||||
)
|
||||
|
||||
// --- BridgeStatus enum (exactly 4) ---------------------------------------------
|
||||
|
||||
// TestBridgeStatusCountLockedConst asserts BridgeStatusCount == 4 and
|
||||
// AllBridgeStatuses() returns exactly 4 (vision §7, REQ-010, D-036). A
|
||||
// regression firewall: adding/removing/renaming a status breaks this test.
|
||||
func TestBridgeStatusCountLockedConst(t *testing.T) {
|
||||
if btypes.BridgeStatusCount != 4 {
|
||||
t.Errorf("BridgeStatusCount = %d, expected 4 (vision §7 LOCKED)", btypes.BridgeStatusCount)
|
||||
}
|
||||
all := btypes.AllBridgeStatuses()
|
||||
if len(all) != 4 {
|
||||
t.Errorf("AllBridgeStatuses() len = %d, expected 4", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllBridgeStatusesNames asserts the 4 vision §7 route-lifecycle names
|
||||
// in order with no extras, no dups, no renames (Pending, Attested, Active,
|
||||
// Closed).
|
||||
func TestAllBridgeStatusesNames(t *testing.T) {
|
||||
want := []string{"Pending", "Attested", "Active", "Closed"}
|
||||
all := btypes.AllBridgeStatuses()
|
||||
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("AllBridgeStatuses()[%d] = %q, want %q", i, s, want[i])
|
||||
}
|
||||
if seen[string(s)] {
|
||||
t.Errorf("duplicate BridgeStatus %q", s)
|
||||
}
|
||||
seen[string(s)] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestBridgeStatusValues asserts each named const matches its AllBridgeStatuses
|
||||
// entry.
|
||||
func TestBridgeStatusValues(t *testing.T) {
|
||||
if btypes.BridgePending != "Pending" {
|
||||
t.Errorf("BridgePending = %q", btypes.BridgePending)
|
||||
}
|
||||
if btypes.BridgeAttested != "Attested" {
|
||||
t.Errorf("BridgeAttested = %q", btypes.BridgeAttested)
|
||||
}
|
||||
if btypes.BridgeActive != "Active" {
|
||||
t.Errorf("BridgeActive = %q", btypes.BridgeActive)
|
||||
}
|
||||
if btypes.BridgeClosed != "Closed" {
|
||||
t.Errorf("BridgeClosed = %q", btypes.BridgeClosed)
|
||||
}
|
||||
}
|
||||
|
||||
// --- BridgeRoute struct (by-ID-string refs — G-003) -----------------------------
|
||||
|
||||
// TestBridgeRouteStructFields asserts BridgeRoute carries all required
|
||||
// fields including the by-ID-string refs to x/satellite (l2-chain) and
|
||||
// x/watcher (watcher-quorum-id) per G-003. No struct imports of either
|
||||
// referenced module (the G-003 import-invariant test enforces this).
|
||||
func TestBridgeRouteStructFields(t *testing.T) {
|
||||
r := btypes.BridgeRoute{
|
||||
BridgeID: "bridge-1",
|
||||
L2Chain: "Polygon", // by-ID-string ref to x/satellite L2Chain (G-003)
|
||||
WatcherQuorumID: "quorum-1",
|
||||
Status: btypes.BridgeActive,
|
||||
}
|
||||
if r.BridgeID != "bridge-1" {
|
||||
t.Errorf("BridgeID = %q", r.BridgeID)
|
||||
}
|
||||
if r.L2Chain != "Polygon" {
|
||||
t.Errorf("L2Chain = %q", r.L2Chain)
|
||||
}
|
||||
if r.WatcherQuorumID != "quorum-1" {
|
||||
t.Errorf("WatcherQuorumID = %q", r.WatcherQuorumID)
|
||||
}
|
||||
if r.Status != btypes.BridgeActive {
|
||||
t.Errorf("Status = %q", r.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBridgeRouteL2ChainIsString asserts the L2Chain field is an opaque
|
||||
// string (by-ID-string ref — G-003), NOT a typed enum import from
|
||||
// x/satellite. This locks the by-ID-string invariant at the type level.
|
||||
func TestBridgeRouteL2ChainIsString(t *testing.T) {
|
||||
r := btypes.BridgeRoute{L2Chain: "Polygon"}
|
||||
// The field must be assignable from a plain string (no satellite.L2Chain
|
||||
// type needed).
|
||||
r.L2Chain = "Base"
|
||||
if r.L2Chain != "Base" {
|
||||
t.Errorf("L2Chain = %q, want %q (must be plain string)", r.L2Chain, "Base")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBridgeRouteWatcherQuorumIDIsString asserts the WatcherQuorumID field
|
||||
// is an opaque string (by-ID-string ref to x/watcher — G-003).
|
||||
func TestBridgeRouteWatcherQuorumIDIsString(t *testing.T) {
|
||||
r := btypes.BridgeRoute{WatcherQuorumID: "quorum-9"}
|
||||
if r.WatcherQuorumID != "quorum-9" {
|
||||
t.Errorf("WatcherQuorumID = %q", r.WatcherQuorumID)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Genesis tests (A-212) ------------------------------------------------------
|
||||
|
||||
// TestDefaultGenesisStateEmpty asserts DefaultGenesisState returns a non-nil
|
||||
// empty slice for Routes.
|
||||
func TestDefaultGenesisStateEmpty(t *testing.T) {
|
||||
gs := btypes.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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsDupBridgeIDs asserts A-212: duplicate bridge-ids
|
||||
// are rejected.
|
||||
func TestValidateGenesisRejectsDupBridgeIDs(t *testing.T) {
|
||||
gs := btypes.GenesisState{
|
||||
Routes: []btypes.BridgeRoute{
|
||||
{BridgeID: "b1", L2Chain: "Polygon", Status: btypes.BridgePending},
|
||||
{BridgeID: "b1", L2Chain: "Base", Status: btypes.BridgeActive}, // dup
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := btypes.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject duplicate bridge-ids")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsEmptyBridgeID asserts empty bridge-id is rejected.
|
||||
func TestValidateGenesisRejectsEmptyBridgeID(t *testing.T) {
|
||||
gs := btypes.GenesisState{
|
||||
Routes: []btypes.BridgeRoute{{BridgeID: "", L2Chain: "Polygon", Status: btypes.BridgePending}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := btypes.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject empty bridge-id")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsUnknownStatus asserts an unknown BridgeStatus
|
||||
// is rejected.
|
||||
func TestValidateGenesisRejectsUnknownStatus(t *testing.T) {
|
||||
gs := btypes.GenesisState{
|
||||
Routes: []btypes.BridgeRoute{{BridgeID: "b1", L2Chain: "Polygon", Status: btypes.BridgeStatus("Bogus")}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := btypes.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject unknown bridge status")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsBadJSON asserts malformed JSON is rejected.
|
||||
func TestValidateGenesisRejectsBadJSON(t *testing.T) {
|
||||
if err := btypes.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 := btypes.GenesisState{
|
||||
Routes: []btypes.BridgeRoute{
|
||||
{BridgeID: "b1", L2Chain: "Polygon", WatcherQuorumID: "q1", Status: btypes.BridgeActive},
|
||||
{BridgeID: "b2", L2Chain: "Base", Status: btypes.BridgePending},
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := btypes.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 btypes.ModuleName != "bridge" {
|
||||
t.Errorf("ModuleName = %q", btypes.ModuleName)
|
||||
}
|
||||
if btypes.StoreKey != "bridge" {
|
||||
t.Errorf("StoreKey = %q", btypes.StoreKey)
|
||||
}
|
||||
if btypes.RouterKey != "bridge" {
|
||||
t.Errorf("RouterKey = %q", btypes.RouterKey)
|
||||
}
|
||||
if btypes.QuerierRoute != "bridge" {
|
||||
t.Errorf("QuerierRoute = %q", btypes.QuerierRoute)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultParams asserts DefaultParams returns a zero-value Params.
|
||||
func TestDefaultParams(t *testing.T) {
|
||||
_ = btypes.DefaultParams() // no panics
|
||||
}
|
||||
|
||||
// --- Lexicon assertion (REQ-012) -------------------------------------------------
|
||||
//
|
||||
// The bridge 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.
|
||||
|
||||
// TestLexiconNoBannedTermsInBridgePackage scans every non-test .go file in
|
||||
// the bridge/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 TestLexiconNoBannedTermsInBridgePackage(t *testing.T) {
|
||||
pkgDir := packageDir(t, "github.com/oy/openyield/x/bridge/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 bridge/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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLexiconNoBannedTermsInBridgeTestFile asserts this test file itself
|
||||
// does not contain any banned term as a literal.
|
||||
func TestLexiconNoBannedTermsInBridgeTestFile(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("bridge 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/bridge/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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
// Package types defines the Hub API module types (vision §13, REQ-024, D-039).
|
||||
//
|
||||
// The Hub is the B2B backbone: a registry of Hub-brokered services an Anchor
|
||||
// partner operates. v0.3 ships the skeleton (enum + per-service struct stubs
|
||||
// + genesis); the live B2B runtime is deferred to v0.4 (D-039).
|
||||
//
|
||||
// Lexicon note (REQ-012, A-210): the Hub is HIGH lexicon-risk because the
|
||||
// lending primitive is a natural fit for the banned financial terms. The
|
||||
// coupon vocabulary is used EXCLUSIVELY here — "lending"/"coupon"/"custody"/
|
||||
// "compliance"/"jurisdiction" are the safe vision-§13 phrasings; the banned
|
||||
// synonyms for these concepts NEVER appear in this package. "lending" is NOT
|
||||
// a banned term (the banned list has the compounding term and the storage
|
||||
// terms, not "lending" or "loan"); "coupon" is the bond vocabulary (vision
|
||||
// §17). The per-package lexicon assertion in types_test.go is the gate.
|
||||
//
|
||||
// Cross-module references are by-ID-string per G-003 (no struct imports):
|
||||
// - operator-partner-id references an x/partner Anchor Partner by ID-string
|
||||
// (A-304, G-003). The Anchor extension lands in P4; x/hub in P5. The
|
||||
// reference is a string, validated by the keeper against the partner
|
||||
// registry at runtime, not by the type system.
|
||||
// - LendingCouponCapBps is a LOCAL const cross-documented to D-028 /
|
||||
// x/bond CouponCapBps (A-304). x/hub does NOT import x/bond; the cap is
|
||||
// redefined locally so the lending-primitive coupon clamp is enforced
|
||||
// without a cross-module struct import (mirrors how x/guild cross-docs
|
||||
// x/feecovenant WaiverHandPassGuild).
|
||||
package types
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const (
|
||||
ModuleName = "hub"
|
||||
StoreKey = ModuleName
|
||||
RouterKey = ModuleName
|
||||
QuerierRoute = ModuleName
|
||||
|
||||
// HubServiceCount is the locked count of HubService enum values (vision
|
||||
// §13, REQ-024, A-312). A regression firewall: adding/removing/renaming a
|
||||
// Hub service breaks this const's test.
|
||||
HubServiceCount = 3
|
||||
|
||||
// LendingCouponCapBps is the LOCAL upper bound on a lending-primitive
|
||||
// coupon in basis points (A-304). It is cross-documented to D-028 and
|
||||
// x/bond.CouponCapBps (also 800, the mission-locked 8pct bond coupon cap).
|
||||
// This const is LOCAL to x/hub to avoid importing x/bond (G-003 — no
|
||||
// cross-module struct imports). The two consts MUST stay in sync; a
|
||||
// change to x/bond.CouponCapBps requires a matching change here. The
|
||||
// ClampLendingCoupon helper uses this local const, NOT x/bond.Clamp.
|
||||
LendingCouponCapBps = uint32(800) // 8pct (cross-doc D-028 / x/bond CouponCapBps — A-304)
|
||||
|
||||
// LendingCouponFloorBps is the LOCAL lower bound on a lending-primitive
|
||||
// coupon (A-304, cross-doc to D-028 / x/bond.CouponFloorBps = 0). Local
|
||||
// const for the same G-003 reason as LendingCouponCapBps.
|
||||
LendingCouponFloorBps = uint32(0) // 0pct (cross-doc D-028 / x/bond CouponFloorBps — A-304)
|
||||
)
|
||||
|
||||
// HubService enumerates the three Hub-brokered B2B service categories (vision
|
||||
// §13, REQ-024, A-312): Custody (asset safekeeping), LendingPrimitive (the
|
||||
// protocol-level lending primitive, NOT a live market), Compliance (on-chain
|
||||
// compliance attestations). The full B2B suite is deferred to v0.4 (D-039).
|
||||
type HubService string
|
||||
|
||||
const (
|
||||
ServiceCustody HubService = "Custody" // asset safekeeping
|
||||
ServiceLendingPrimitive HubService = "LendingPrimitive" // protocol-level lending primitive
|
||||
ServiceCompliance HubService = "Compliance" // on-chain compliance attestations
|
||||
)
|
||||
|
||||
// AllHubServices returns all three HubService values in vision §13 order.
|
||||
// Locked-const test asserts exactly 3 entries with these names (REQ-024).
|
||||
func AllHubServices() []HubService {
|
||||
return []HubService{
|
||||
ServiceCustody,
|
||||
ServiceLendingPrimitive,
|
||||
ServiceCompliance,
|
||||
}
|
||||
}
|
||||
|
||||
// CustodyService is the per-service struct stub for a Hub custody service
|
||||
// (vision §13, REQ-024). custody-id is the service identifier. operator-
|
||||
// partner-id references an x/partner Anchor Partner by ID-string (A-304,
|
||||
// G-003 — no struct import of x/partner). asset-ref is an opaque reference to
|
||||
// the custodied asset (the asset identifier is opaque so the Hub does not
|
||||
// import any asset-denom module).
|
||||
type CustodyService struct {
|
||||
CustodyID string `json:"custody_id" yaml:"custody_id"`
|
||||
OperatorPartnerID string `json:"operator_partner_id" yaml:"operator_partner_id"`
|
||||
AssetRef string `json:"asset_ref" yaml:"asset_ref"`
|
||||
}
|
||||
|
||||
// LendingPrimitive is the per-service struct stub for a Hub lending-primitive
|
||||
// service (vision §13, REQ-024). loan-id is the primitive identifier.
|
||||
// principal-grain is the principal in Grain (the OY internal unit, cross-ref
|
||||
// x/bread by name only — no struct import). coupon-bps is the coupon rate in
|
||||
// basis points, clamped to [LendingCouponFloorBps, LendingCouponCapBps] by
|
||||
// ClampLendingCoupon at construction (NewLendingPrimitive). term-days is the
|
||||
// primitive term length. The coupon vocabulary is used EXCLUSIVELY here
|
||||
// (A-210); the banned compounding term and storage terms NEVER appear.
|
||||
type LendingPrimitive struct {
|
||||
LoanID string `json:"loan_id" yaml:"loan_id"`
|
||||
PrincipalGrain int64 `json:"principal_grain" yaml:"principal_grain"`
|
||||
CouponBps uint32 `json:"coupon_bps" yaml:"coupon_bps"`
|
||||
TermDays uint32 `json:"term_days" yaml:"term_days"`
|
||||
}
|
||||
|
||||
// ComplianceService is the per-service struct stub for a Hub compliance
|
||||
// service (vision §13, REQ-024). compliance-id is the service identifier.
|
||||
// jurisdiction is an opaque jurisdiction tag (e.g. "EU-MiCA"). attestation-
|
||||
// uri is an opaque URI to the compliance attestation (kept opaque in the
|
||||
// skeleton, like the v0.2 Pier CredentialRef).
|
||||
type ComplianceService struct {
|
||||
ComplianceID string `json:"compliance_id" yaml:"compliance_id"`
|
||||
Jurisdiction string `json:"jurisdiction" yaml:"jurisdiction"`
|
||||
AttestationURI string `json:"attestation_uri" yaml:"attestation_uri"`
|
||||
}
|
||||
|
||||
// ClampLendingCoupon ensures a lending-primitive coupon is within the LOCKED
|
||||
// LOCAL bounds (A-304: never above the local cap, never below the local floor).
|
||||
// This mirrors x/bond.Clamp's shape (min(cap, max(floor, coupon))) but uses the
|
||||
// LOCAL LendingCouponCapBps / LendingCouponFloorBps consts — it does NOT import
|
||||
// x/bond.Clamp (G-003). The clamp is automatic and authoritative; the live
|
||||
// keeper enforces it at construction and at genesis load.
|
||||
func ClampLendingCoupon(couponBps uint32) uint32 {
|
||||
if couponBps > LendingCouponCapBps {
|
||||
return LendingCouponCapBps
|
||||
}
|
||||
if couponBps < LendingCouponFloorBps {
|
||||
return LendingCouponFloorBps
|
||||
}
|
||||
return couponBps
|
||||
}
|
||||
|
||||
// NewLendingPrimitive constructs a LendingPrimitive with the coupon clamped to
|
||||
// the LOCAL [floor, cap] bounds via ClampLendingCoupon (A-304). The stub does
|
||||
// not persist or enforce referential integrity of operator-partner-id; it only
|
||||
// enforces the coupon clamp invariant at construction time.
|
||||
func NewLendingPrimitive(loanID string, principalGrain int64, couponBps uint32, termDays uint32) LendingPrimitive {
|
||||
return LendingPrimitive{
|
||||
LoanID: loanID,
|
||||
PrincipalGrain: principalGrain,
|
||||
CouponBps: ClampLendingCoupon(couponBps),
|
||||
TermDays: termDays,
|
||||
}
|
||||
}
|
||||
|
||||
// Params for the hub module (skeleton — no tunables in v0.3; the lending
|
||||
// coupon cap/floor are LOCKED LOCAL consts, not Params fields).
|
||||
type Params struct{}
|
||||
|
||||
// DefaultParams returns the zero-value Params (skeleton — no tunables).
|
||||
func DefaultParams() Params { return Params{} }
|
||||
|
||||
// GenesisState defines the hub module genesis state (REQ-024). The three
|
||||
// slices hold the per-service stubs. ValidateGenesis enforces per-set ID
|
||||
// uniqueness (A-212) and the lending-primitive coupon clamp at genesis load
|
||||
// (each LendingPrimitive's coupon-bps must be within the LOCAL bounds).
|
||||
type GenesisState struct {
|
||||
Params Params `json:"params" yaml:"params"`
|
||||
CustodyServices []CustodyService `json:"custody_services" yaml:"custody_services"`
|
||||
LendingPrimitives []LendingPrimitive `json:"lending_primitives" yaml:"lending_primitives"`
|
||||
ComplianceServices []ComplianceService `json:"compliance_services" yaml:"compliance_services"`
|
||||
}
|
||||
|
||||
// DefaultGenesisState returns an empty genesis state with non-nil slices.
|
||||
func DefaultGenesisState() *GenesisState {
|
||||
return &GenesisState{
|
||||
Params: DefaultParams(),
|
||||
CustodyServices: []CustodyService{},
|
||||
LendingPrimitives: []LendingPrimitive{},
|
||||
ComplianceServices: []ComplianceService{},
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateGenesis performs ID-uniqueness checks (A-212 upgrade from v0.1
|
||||
// no-op) and the lending-primitive coupon clamp at genesis load (A-304):
|
||||
// rejects duplicate custody-ids, loan-ids, compliance-ids, and any
|
||||
// LendingPrimitive whose coupon-bps is outside the LOCAL [floor, cap] bounds.
|
||||
func ValidateGenesis(bz json.RawMessage) error {
|
||||
var gs GenesisState
|
||||
if err := json.Unmarshal(bz, &gs); err != nil {
|
||||
return fmt.Errorf("hub: invalid genesis: %w", err)
|
||||
}
|
||||
if err := validateCustodyServices(gs.CustodyServices); err != nil {
|
||||
return fmt.Errorf("hub: %w", err)
|
||||
}
|
||||
if err := validateLendingPrimitives(gs.LendingPrimitives); err != nil {
|
||||
return fmt.Errorf("hub: %w", err)
|
||||
}
|
||||
if err := validateComplianceServices(gs.ComplianceServices); err != nil {
|
||||
return fmt.Errorf("hub: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateCustodyServices enforces custody-id presence and uniqueness.
|
||||
func validateCustodyServices(svcs []CustodyService) error {
|
||||
seen := make(map[string]bool, len(svcs))
|
||||
for i, c := range svcs {
|
||||
if c.CustodyID == "" {
|
||||
return fmt.Errorf("custody service [%d]: empty custody-id", i)
|
||||
}
|
||||
if seen[c.CustodyID] {
|
||||
return fmt.Errorf("custody service: duplicate custody-id %q", c.CustodyID)
|
||||
}
|
||||
seen[c.CustodyID] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateLendingPrimitives enforces loan-id presence/uniqueness and the
|
||||
// LOCAL coupon clamp at genesis load (A-304).
|
||||
func validateLendingPrimitives(svcs []LendingPrimitive) error {
|
||||
seen := make(map[string]bool, len(svcs))
|
||||
for i, l := range svcs {
|
||||
if l.LoanID == "" {
|
||||
return fmt.Errorf("lending primitive [%d]: empty loan-id", i)
|
||||
}
|
||||
if seen[l.LoanID] {
|
||||
return fmt.Errorf("lending primitive: duplicate loan-id %q", l.LoanID)
|
||||
}
|
||||
seen[l.LoanID] = true
|
||||
if l.CouponBps < LendingCouponFloorBps || l.CouponBps > LendingCouponCapBps {
|
||||
return fmt.Errorf("lending primitive %q: coupon-bps %d outside [%d, %d] (A-304 clamp at genesis load)",
|
||||
l.LoanID, l.CouponBps, LendingCouponFloorBps, LendingCouponCapBps)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateComplianceServices enforces compliance-id presence and uniqueness.
|
||||
func validateComplianceServices(svcs []ComplianceService) error {
|
||||
seen := make(map[string]bool, len(svcs))
|
||||
for i, c := range svcs {
|
||||
if c.ComplianceID == "" {
|
||||
return fmt.Errorf("compliance service [%d]: empty compliance-id", i)
|
||||
}
|
||||
if seen[c.ComplianceID] {
|
||||
return fmt.Errorf("compliance service: duplicate compliance-id %q", c.ComplianceID)
|
||||
}
|
||||
seen[c.ComplianceID] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
package types_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/oy/openyield/lexicon"
|
||||
htypes "github.com/oy/openyield/x/hub/types"
|
||||
)
|
||||
|
||||
// --- HubService enum coverage (3) ----------------------------------------------
|
||||
|
||||
// TestHubServiceCountLockedConst asserts HubServiceCount == 3 and
|
||||
// AllHubServices() returns exactly 3 (REQ-024, A-312). A regression firewall.
|
||||
func TestHubServiceCountLockedConst(t *testing.T) {
|
||||
if htypes.HubServiceCount != 3 {
|
||||
t.Errorf("HubServiceCount = %d, expected 3 (REQ-024 LOCKED)", htypes.HubServiceCount)
|
||||
}
|
||||
all := htypes.AllHubServices()
|
||||
if len(all) != 3 {
|
||||
t.Errorf("AllHubServices() len = %d, expected 3", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllHubServicesNames asserts the 3 REQ-024 names in order with no
|
||||
// extras, no dups, no renames.
|
||||
func TestAllHubServicesNames(t *testing.T) {
|
||||
want := []string{"Custody", "LendingPrimitive", "Compliance"}
|
||||
all := htypes.AllHubServices()
|
||||
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("AllHubServices()[%d] = %q, want %q", i, s, want[i])
|
||||
}
|
||||
if seen[string(s)] {
|
||||
t.Errorf("duplicate HubService %q", s)
|
||||
}
|
||||
seen[string(s)] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestHubServiceValues asserts each named const matches its AllHubServices
|
||||
// entry.
|
||||
func TestHubServiceValues(t *testing.T) {
|
||||
if htypes.ServiceCustody != "Custody" {
|
||||
t.Errorf("ServiceCustody = %q", htypes.ServiceCustody)
|
||||
}
|
||||
if htypes.ServiceLendingPrimitive != "LendingPrimitive" {
|
||||
t.Errorf("ServiceLendingPrimitive = %q", htypes.ServiceLendingPrimitive)
|
||||
}
|
||||
if htypes.ServiceCompliance != "Compliance" {
|
||||
t.Errorf("ServiceCompliance = %q", htypes.ServiceCompliance)
|
||||
}
|
||||
}
|
||||
|
||||
// --- LendingCouponCapBps LOCAL const (A-304) -----------------------------------
|
||||
|
||||
// TestLendingCouponCapBpsLockedConst asserts the LOCAL LendingCouponCapBps ==
|
||||
// 800 (A-304 cross-doc to D-028 / x/bond.CouponCapBps). The const is LOCAL to
|
||||
// x/hub to avoid importing x/bond (G-003); the test asserts the value matches
|
||||
// the bond cap so the two consts stay in sync.
|
||||
func TestLendingCouponCapBpsLockedConst(t *testing.T) {
|
||||
if htypes.LendingCouponCapBps != 800 {
|
||||
t.Errorf("LendingCouponCapBps = %d, expected 800 (A-304 cross-doc D-028)", htypes.LendingCouponCapBps)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLendingCouponFloorBpsLockedConst asserts the LOCAL
|
||||
// LendingCouponFloorBps == 0 (A-304 cross-doc to D-028 / x/bond.CouponFloorBps).
|
||||
func TestLendingCouponFloorBpsLockedConst(t *testing.T) {
|
||||
if htypes.LendingCouponFloorBps != 0 {
|
||||
t.Errorf("LendingCouponFloorBps = %d, expected 0 (A-304 cross-doc D-028)", htypes.LendingCouponFloorBps)
|
||||
}
|
||||
}
|
||||
|
||||
// --- ClampLendingCoupon invariants (A-304) -------------------------------------
|
||||
// The ClampLendingCoupon invariant is the hub module's firewall (A-304): a
|
||||
// lending-primitive coupon can never exceed the local cap (8pct) and can
|
||||
// never fall below the local floor (0pct). These tests are the regression
|
||||
// firewall — a change to LendingCouponCapBps or LendingCouponFloorBps breaks
|
||||
// them.
|
||||
|
||||
// TestClampLendingCouponBelowFloorReturnsFloor asserts a coupon below the
|
||||
// floor is clamped up to the floor. The floor is 0 and uint32 cannot be
|
||||
// negative, so the below-floor case is type-prevented; the test asserts the
|
||||
// floor boundary passes through.
|
||||
func TestClampLendingCouponBelowFloorReturnsFloor(t *testing.T) {
|
||||
got := htypes.ClampLendingCoupon(htypes.LendingCouponFloorBps)
|
||||
if got != htypes.LendingCouponFloorBps {
|
||||
t.Errorf("ClampLendingCoupon(floor) = %d, expected floor %d", got, htypes.LendingCouponFloorBps)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClampLendingCouponAboveCapReturnsCap asserts a coupon above the cap is
|
||||
// clamped down to the cap.
|
||||
func TestClampLendingCouponAboveCapReturnsCap(t *testing.T) {
|
||||
cases := []uint32{
|
||||
uint32(htypes.LendingCouponCapBps) + 1,
|
||||
uint32(htypes.LendingCouponCapBps) + 100,
|
||||
uint32(htypes.LendingCouponCapBps) + 1000,
|
||||
900,
|
||||
1000,
|
||||
5000,
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := htypes.ClampLendingCoupon(c)
|
||||
if got != htypes.LendingCouponCapBps {
|
||||
t.Errorf("ClampLendingCoupon(%d) = %d, expected cap %d (above-cap must clamp to cap)", c, got, htypes.LendingCouponCapBps)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestClampLendingCouponInRangeUnchanged asserts a coupon within [floor, cap]
|
||||
// is unchanged.
|
||||
func TestClampLendingCouponInRangeUnchanged(t *testing.T) {
|
||||
cases := []uint32{
|
||||
0,
|
||||
1,
|
||||
100,
|
||||
400,
|
||||
500,
|
||||
799,
|
||||
uint32(htypes.LendingCouponCapBps),
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := htypes.ClampLendingCoupon(c)
|
||||
if got != c {
|
||||
t.Errorf("ClampLendingCoupon(%d) = %d, expected %d (in-range must be unchanged)", c, got, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestClampLendingCouponShape asserts the min(cap, max(floor, coupon)) shape
|
||||
// at the boundaries.
|
||||
func TestClampLendingCouponShape(t *testing.T) {
|
||||
if htypes.ClampLendingCoupon(0) != 0 {
|
||||
t.Error("ClampLendingCoupon(0) should be 0 (floor boundary)")
|
||||
}
|
||||
if htypes.ClampLendingCoupon(800) != 800 {
|
||||
t.Error("ClampLendingCoupon(800) should be 800 (cap boundary)")
|
||||
}
|
||||
if htypes.ClampLendingCoupon(801) != 800 {
|
||||
t.Error("ClampLendingCoupon(801) should be 800 (above-cap clamps to cap)")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Per-service struct stubs --------------------------------------------------
|
||||
|
||||
// TestCustodyServiceStructFields asserts CustodyService carries custody-id,
|
||||
// operator-partner-id (by-ID-string ref to x/partner Anchor — G-003), asset-ref.
|
||||
func TestCustodyServiceStructFields(t *testing.T) {
|
||||
c := htypes.CustodyService{
|
||||
CustodyID: "cust-1",
|
||||
OperatorPartnerID: "anchor-partner-1",
|
||||
AssetRef: "bread-grain",
|
||||
}
|
||||
if c.CustodyID != "cust-1" || c.OperatorPartnerID != "anchor-partner-1" || c.AssetRef != "bread-grain" {
|
||||
t.Error("CustodyService fields not set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCustodyServiceOperatorPartnerIDIsString asserts operator-partner-id is
|
||||
// string-typed (G-003 by-ID-string ref to x/partner Anchor; no struct import).
|
||||
func TestCustodyServiceOperatorPartnerIDIsString(t *testing.T) {
|
||||
c := htypes.CustodyService{OperatorPartnerID: "anchor-1"}
|
||||
if c.OperatorPartnerID != "anchor-1" {
|
||||
t.Errorf("OperatorPartnerID = %q", c.OperatorPartnerID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLendingPrimitiveStructFields asserts LendingPrimitive carries loan-id,
|
||||
// principal-grain, coupon-bps, term-days.
|
||||
func TestLendingPrimitiveStructFields(t *testing.T) {
|
||||
l := htypes.LendingPrimitive{
|
||||
LoanID: "loan-1",
|
||||
PrincipalGrain: 1_000_000,
|
||||
CouponBps: 500,
|
||||
TermDays: 365,
|
||||
}
|
||||
if l.LoanID != "loan-1" || l.PrincipalGrain != 1_000_000 || l.CouponBps != 500 || l.TermDays != 365 {
|
||||
t.Error("LendingPrimitive fields not set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewLendingPrimitiveClampsCoupon asserts NewLendingPrimitive clamps an
|
||||
// above-cap coupon down to the cap and leaves an in-range coupon unchanged.
|
||||
func TestNewLendingPrimitiveClampsCoupon(t *testing.T) {
|
||||
l := htypes.NewLendingPrimitive("loan-2", 500_000, 1200, 180)
|
||||
if l.CouponBps != htypes.LendingCouponCapBps {
|
||||
t.Errorf("CouponBps = %d, expected cap %d (NewLendingPrimitive must clamp above-cap coupon)", l.CouponBps, htypes.LendingCouponCapBps)
|
||||
}
|
||||
l2 := htypes.NewLendingPrimitive("loan-3", 500_000, 300, 180)
|
||||
if l2.CouponBps != 300 {
|
||||
t.Errorf("CouponBps = %d, expected 300 (in-range, unchanged)", l2.CouponBps)
|
||||
}
|
||||
}
|
||||
|
||||
// TestComplianceServiceStructFields asserts ComplianceService carries
|
||||
// compliance-id, jurisdiction, attestation-uri.
|
||||
func TestComplianceServiceStructFields(t *testing.T) {
|
||||
c := htypes.ComplianceService{
|
||||
ComplianceID: "comp-1",
|
||||
Jurisdiction: "EU-MiCA",
|
||||
AttestationURI: "ipfs://attestation/abc",
|
||||
}
|
||||
if c.ComplianceID != "comp-1" || c.Jurisdiction != "EU-MiCA" || c.AttestationURI != "ipfs://attestation/abc" {
|
||||
t.Error("ComplianceService fields not set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Module consts + Params ----------------------------------------------------
|
||||
|
||||
// TestModuleConsts asserts the four Cosmos-convention module consts.
|
||||
func TestModuleConsts(t *testing.T) {
|
||||
if htypes.ModuleName != "hub" {
|
||||
t.Errorf("ModuleName = %q", htypes.ModuleName)
|
||||
}
|
||||
if htypes.StoreKey != "hub" {
|
||||
t.Errorf("StoreKey = %q", htypes.StoreKey)
|
||||
}
|
||||
if htypes.RouterKey != "hub" {
|
||||
t.Errorf("RouterKey = %q", htypes.RouterKey)
|
||||
}
|
||||
if htypes.QuerierRoute != "hub" {
|
||||
t.Errorf("QuerierRoute = %q", htypes.QuerierRoute)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultParams asserts DefaultParams returns a zero-value Params.
|
||||
func TestDefaultParams(t *testing.T) {
|
||||
_ = htypes.DefaultParams() // no panics
|
||||
}
|
||||
|
||||
// --- Genesis -------------------------------------------------------------------
|
||||
|
||||
// TestDefaultGenesisStateEmpty asserts DefaultGenesisState returns non-nil
|
||||
// empty slices for all three service sets.
|
||||
func TestDefaultGenesisStateEmpty(t *testing.T) {
|
||||
gs := htypes.DefaultGenesisState()
|
||||
if gs == nil {
|
||||
t.Fatal("DefaultGenesisState returned nil")
|
||||
}
|
||||
if gs.CustodyServices == nil || len(gs.CustodyServices) != 0 {
|
||||
t.Errorf("Default CustodyServices should be non-nil empty slice; got len=%d nil=%v", len(gs.CustodyServices), gs.CustodyServices == nil)
|
||||
}
|
||||
if gs.LendingPrimitives == nil || len(gs.LendingPrimitives) != 0 {
|
||||
t.Errorf("Default LendingPrimitives should be non-nil empty slice; got len=%d nil=%v", len(gs.LendingPrimitives), gs.LendingPrimitives == nil)
|
||||
}
|
||||
if gs.ComplianceServices == nil || len(gs.ComplianceServices) != 0 {
|
||||
t.Errorf("Default ComplianceServices should be non-nil empty slice; got len=%d nil=%v", len(gs.ComplianceServices), gs.ComplianceServices == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsDupCustodyIDs asserts A-212: duplicate custody-ids
|
||||
// are rejected.
|
||||
func TestValidateGenesisRejectsDupCustodyIDs(t *testing.T) {
|
||||
gs := htypes.GenesisState{
|
||||
CustodyServices: []htypes.CustodyService{
|
||||
{CustodyID: "c1", OperatorPartnerID: "a1"},
|
||||
{CustodyID: "c1", OperatorPartnerID: "a2"}, // dup
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := htypes.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject duplicate custody-ids")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsEmptyCustodyID asserts empty custody-id is rejected.
|
||||
func TestValidateGenesisRejectsEmptyCustodyID(t *testing.T) {
|
||||
gs := htypes.GenesisState{
|
||||
CustodyServices: []htypes.CustodyService{{CustodyID: "", OperatorPartnerID: "a1"}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := htypes.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject empty custody-id")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsDupLoanIDs asserts duplicate loan-ids are rejected.
|
||||
func TestValidateGenesisRejectsDupLoanIDs(t *testing.T) {
|
||||
gs := htypes.GenesisState{
|
||||
LendingPrimitives: []htypes.LendingPrimitive{
|
||||
{LoanID: "l1", CouponBps: 100},
|
||||
{LoanID: "l1", CouponBps: 200}, // dup
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := htypes.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject duplicate loan-ids")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsCouponAboveCap asserts the genesis-side clamp: a
|
||||
// LendingPrimitive with coupon-bps above the local cap is rejected (A-304).
|
||||
func TestValidateGenesisRejectsCouponAboveCap(t *testing.T) {
|
||||
gs := htypes.GenesisState{
|
||||
LendingPrimitives: []htypes.LendingPrimitive{
|
||||
{LoanID: "l1", CouponBps: uint32(htypes.LendingCouponCapBps) + 1},
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := htypes.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject lending-primitive coupon-bps above local cap (A-304)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsDupComplianceIDs asserts duplicate compliance-ids
|
||||
// are rejected.
|
||||
func TestValidateGenesisRejectsDupComplianceIDs(t *testing.T) {
|
||||
gs := htypes.GenesisState{
|
||||
ComplianceServices: []htypes.ComplianceService{
|
||||
{ComplianceID: "comp-1"},
|
||||
{ComplianceID: "comp-1"}, // dup
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := htypes.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject duplicate compliance-ids")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsBadJSON asserts malformed JSON is rejected.
|
||||
func TestValidateGenesisRejectsBadJSON(t *testing.T) {
|
||||
if err := htypes.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 := htypes.GenesisState{
|
||||
CustodyServices: []htypes.CustodyService{
|
||||
{CustodyID: "c1", OperatorPartnerID: "a1", AssetRef: "bread"},
|
||||
},
|
||||
LendingPrimitives: []htypes.LendingPrimitive{
|
||||
{LoanID: "l1", PrincipalGrain: 1_000_000, CouponBps: 500, TermDays: 365},
|
||||
{LoanID: "l2", PrincipalGrain: 500_000, CouponBps: 800, TermDays: 180},
|
||||
},
|
||||
ComplianceServices: []htypes.ComplianceService{
|
||||
{ComplianceID: "comp-1", Jurisdiction: "EU-MiCA", AttestationURI: "ipfs://x"},
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := htypes.ValidateGenesis(bz); err != nil {
|
||||
t.Errorf("ValidateGenesis should accept clean genesis, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Lexicon assertion (REQ-012) -------------------------------------------------
|
||||
// The hub module is HIGH lexicon-risk (lending primitive): the banned terms
|
||||
// that are natural fit-words for a lending primitive (the compounding term,
|
||||
// the storage terms, the tradable-unit terms) must NEVER appear. The coupon
|
||||
// + lending vocabulary is used EXCLUSIVELY. The lexicon helpers are used
|
||||
// here — no banned literals are inlined in this test file.
|
||||
|
||||
// TestLexiconNoBannedTermsInHubPackage scans every non-test .go file in the
|
||||
// hub/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 TestLexiconNoBannedTermsInHubPackage(t *testing.T) {
|
||||
pkgDir := packageDir(t, "github.com/oy/openyield/x/hub/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 hub/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 — coupon+lending vocabulary only)", filepath.Base(f), found)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLexiconNoBannedTermsInHubTestFile asserts this test file itself does not
|
||||
// contain any banned term as a literal (the firewall scans test files too;
|
||||
// the lexicon helpers must be used rather than inlining banned terms).
|
||||
func TestLexiconNoBannedTermsInHubTestFile(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("hub 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/hub/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)
|
||||
}
|
||||
@@ -155,6 +155,53 @@ func (k *Keeper) ListByTier(tier PartnerTier) []Partner {
|
||||
return out
|
||||
}
|
||||
|
||||
// AnchorCredential is the institutional onboarding metadata for an Anchor
|
||||
// tier Partner (REQ-023, D-038, A-305). The Anchor tier (the 4th of the
|
||||
// 4-tier Partner Spectrum, REQ-018) gets institution-specific credential
|
||||
// fields in v0.3. v0.2 defined the 4-tier enum + Partner struct +
|
||||
// CredentialRef; v0.3 adds this AnchorCredential struct carrying the
|
||||
// institutional onboarding metadata. No live institutional onboarding in
|
||||
// v0.3 (the skeleton defines the type shape only).
|
||||
//
|
||||
// All cross-module references are by-ID-string per G-003:
|
||||
//
|
||||
// - anchor-id references a Partner with Tier=Anchor by ID-string
|
||||
// (G-003). No struct import; the reference is validated against the
|
||||
// Partner registry by the keeper, not the type system.
|
||||
// - custody-provider-id references an x/hub custody service by ID-string
|
||||
// (A-304/G-003). The hub is NOT live until P5/v0.4, so this field is
|
||||
// EMPTY in the v0.3 skeleton (NewAnchorCredential sets it to "").
|
||||
// The field exists so the shape is stable when the hub comes online.
|
||||
// No struct import of x/hub.
|
||||
// - credential-uri is an opaque URI to the institutional credential
|
||||
// (regulatory jurisdiction, attestation refs, etc.) — like the v0.2
|
||||
// Pier CredentialRef, kept opaque in the skeleton.
|
||||
// - attestation-count is the number of Watcher/auditor attestations on
|
||||
// the credential (starts at 0 in the skeleton).
|
||||
type AnchorCredential struct {
|
||||
AnchorID string `json:"anchor_id" yaml:"anchor_id"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// NewAnchorCredential constructs an AnchorCredential for an Anchor-tier
|
||||
// Partner (D-038, A-305). The custody-provider-id is set to "" (empty)
|
||||
// because the x/hub custody service is NOT live until P5/v0.4 (A-304:
|
||||
// the field is typed-but-empty in the v0.3 skeleton; the hub is live in
|
||||
// P5, so the field exists but is not validated against hub yet). The
|
||||
// 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.
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Params for the partner module (skeleton — no tunables in v0.2).
|
||||
type Params struct{}
|
||||
|
||||
|
||||
@@ -411,6 +411,151 @@ func TestLexiconNoBannedTermsInPartnerTestFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- v0.3 Partner extension (P4-04, D-038, A-305) — AnchorCredential -------------
|
||||
//
|
||||
// The following tests extend the v0.2 partner tests with the v0.3
|
||||
// AnchorCredential struct (D-038). The existing v0.1/v0.2 tests above
|
||||
// MUST remain green — no regression. The PartnerTier enum (4 tiers) is
|
||||
// locked since v0.2; v0.3 adds the AnchorCredential STRUCT only (no new
|
||||
// tier — A-305).
|
||||
|
||||
// TestAnchorCredentialStructFields asserts the AnchorCredential struct
|
||||
// carries all required fields (anchor-id, custody-provider-id,
|
||||
// credential-uri, attestation-count) per D-038/A-305.
|
||||
func TestAnchorCredentialStructFields(t *testing.T) {
|
||||
c := types.AnchorCredential{
|
||||
AnchorID: "anchor-1",
|
||||
CustodyProviderID: "hub-custody-1",
|
||||
CredentialURI: "oy:cred:anchor-1/jurisdiction/EU-MiCA",
|
||||
AttestationCount: 3,
|
||||
}
|
||||
if c.AnchorID != "anchor-1" {
|
||||
t.Errorf("AnchorID = %q", c.AnchorID)
|
||||
}
|
||||
if c.CustodyProviderID != "hub-custody-1" {
|
||||
t.Errorf("CustodyProviderID = %q", c.CustodyProviderID)
|
||||
}
|
||||
if c.CredentialURI != "oy:cred:anchor-1/jurisdiction/EU-MiCA" {
|
||||
t.Errorf("CredentialURI = %q", c.CredentialURI)
|
||||
}
|
||||
if c.AttestationCount != 3 {
|
||||
t.Errorf("AttestationCount = %d, want 3", c.AttestationCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnchorCredentialAnchorIDIsString asserts the AnchorID field is an
|
||||
// opaque string (by-ID-string ref to a Partner with Tier=Anchor — G-003),
|
||||
// NOT a typed Partner import. This locks the by-ID-string invariant at
|
||||
// the type level.
|
||||
func TestAnchorCredentialAnchorIDIsString(t *testing.T) {
|
||||
c := types.AnchorCredential{AnchorID: "partner-9"}
|
||||
c.AnchorID = "partner-2"
|
||||
if c.AnchorID != "partner-2" {
|
||||
t.Errorf("AnchorID = %q, want %q (must be plain string — G-003)", c.AnchorID, "partner-2")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnchorCredentialCustodyProviderIDIsString asserts the
|
||||
// CustodyProviderID field is an opaque string (by-ID-string ref to an
|
||||
// x/hub custody service — A-304/G-003), NOT a typed x/hub import.
|
||||
func TestAnchorCredentialCustodyProviderIDIsString(t *testing.T) {
|
||||
c := types.AnchorCredential{CustodyProviderID: "hub-custody-9"}
|
||||
c.CustodyProviderID = "hub-custody-2"
|
||||
if c.CustodyProviderID != "hub-custody-2" {
|
||||
t.Errorf("CustodyProviderID = %q, want %q (must be plain string — A-304/G-003)", c.CustodyProviderID, "hub-custody-2")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewAnchorCredentialConstruction asserts NewAnchorCredential sets
|
||||
// the anchor-id and credential-uri from the constructor args, AND sets
|
||||
// custody-provider-id to "" (empty — hub not live until P5/v0.4 per
|
||||
// A-304), AND attestation-count to 0 (no attestations in the skeleton).
|
||||
func TestNewAnchorCredentialConstruction(t *testing.T) {
|
||||
c := types.NewAnchorCredential("anchor-1", "oy:cred:anchor-1/EU-MiCA")
|
||||
if c.AnchorID != "anchor-1" {
|
||||
t.Errorf("AnchorID = %q, want %q", c.AnchorID, "anchor-1")
|
||||
}
|
||||
if c.CredentialURI != "oy:cred:anchor-1/EU-MiCA" {
|
||||
t.Errorf("CredentialURI = %q, want %q", c.CredentialURI, "oy:cred:anchor-1/EU-MiCA")
|
||||
}
|
||||
// custody-provider-id must be EMPTY in the skeleton (A-304: hub not
|
||||
// live until P5/v0.4).
|
||||
if c.CustodyProviderID != "" {
|
||||
t.Errorf("CustodyProviderID = %q, want empty (A-304: hub not live until P5)", c.CustodyProviderID)
|
||||
}
|
||||
// attestation-count must be 0 in the skeleton.
|
||||
if c.AttestationCount != 0 {
|
||||
t.Errorf("AttestationCount = %d, want 0 (skeleton)", c.AttestationCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewAnchorCredentialCustodyProviderIDEmptyInvariant asserts the
|
||||
// A-304 invariant: NewAnchorCredential ALWAYS sets custody-provider-id to
|
||||
// "" regardless of inputs (the hub is not live until P5/v0.4; the field
|
||||
// is typed-but-empty in the v0.3 skeleton). This is the dependency edge
|
||||
// that forces P4 before P5 (D-044): x/partner Anchor lands in P4, x/hub
|
||||
// in P5.
|
||||
func TestNewAnchorCredentialCustodyProviderIDEmptyInvariant(t *testing.T) {
|
||||
cases := []struct {
|
||||
anchorID string
|
||||
credURI string
|
||||
}{
|
||||
{"anchor-1", "oy:cred:a/EU-MiCA"},
|
||||
{"anchor-2", "oy:cred:a/US-SOC2"},
|
||||
{"", ""},
|
||||
{"anchor-3", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := types.NewAnchorCredential(c.anchorID, c.credURI)
|
||||
if got.CustodyProviderID != "" {
|
||||
t.Errorf("NewAnchorCredential(%q,%q): CustodyProviderID = %q, want empty (A-304 LOCKED)", c.anchorID, c.credURI, got.CustodyProviderID)
|
||||
}
|
||||
if got.AttestationCount != 0 {
|
||||
t.Errorf("NewAnchorCredential(%q,%q): AttestationCount = %d, want 0 (skeleton)", c.anchorID, c.credURI, got.AttestationCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewAnchorCredentialAttestationCountZero asserts the constructor sets
|
||||
// attestation-count to 0 (no attestations in the skeleton; attestations
|
||||
// are a v0.4 keeper concern).
|
||||
func TestNewAnchorCredentialAttestationCountZero(t *testing.T) {
|
||||
c := types.NewAnchorCredential("anchor-1", "oy:cred:anchor-1/x")
|
||||
if c.AttestationCount != 0 {
|
||||
t.Errorf("AttestationCount = %d, want 0 (skeleton — attestations are v0.4)", c.AttestationCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnchorCredentialZeroValue asserts the zero-value AnchorCredential
|
||||
// has empty strings and a 0 attestation-count.
|
||||
func TestAnchorCredentialZeroValue(t *testing.T) {
|
||||
var c types.AnchorCredential
|
||||
if c.AnchorID != "" || c.CustodyProviderID != "" || c.CredentialURI != "" {
|
||||
t.Error("zero-value AnchorCredential should have empty string fields")
|
||||
}
|
||||
if c.AttestationCount != 0 {
|
||||
t.Errorf("zero-value AttestationCount = %d, want 0", c.AttestationCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPartnerTierCountStillFour is the v0.3 REGRESSION test (A-305): the
|
||||
// PartnerTier enum is LOCKED at 4 tiers since v0.2; v0.3 adds the
|
||||
// AnchorCredential STRUCT, NOT a new tier. This test asserts the count
|
||||
// is still 4 (no new tier added by the v0.3 extension).
|
||||
func TestPartnerTierCountStillFour(t *testing.T) {
|
||||
if types.PartnerTierCount != 4 {
|
||||
t.Errorf("PartnerTierCount = %d, expected 4 (A-305: v0.3 adds AnchorCredential struct, not a tier)", types.PartnerTierCount)
|
||||
}
|
||||
all := types.AllPartnerTiers()
|
||||
if len(all) != 4 {
|
||||
t.Errorf("AllPartnerTiers() len = %d, expected 4 (A-305 regression)", len(all))
|
||||
}
|
||||
// Anchor must still be the 4th tier (no new tier added before/after it).
|
||||
if all[3] != types.TierAnchor {
|
||||
t.Errorf("AllPartnerTiers()[3] = %q, want %q (Anchor must remain 4th tier)", all[3], types.TierAnchor)
|
||||
}
|
||||
}
|
||||
|
||||
// packageDir resolves a Go import path to its filesystem directory by
|
||||
// walking up from this test file (v0.2 skeleton has zero external deps).
|
||||
func packageDir(t *testing.T, importPath string) string {
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
// Package types defines the Services module types (vision §13, REQ-025,
|
||||
// D-040, A-307).
|
||||
//
|
||||
// OY-protocol services beyond the financial layer: Care (community care),
|
||||
// SIM (connectivity), Vault (storage service), Mail (messaging). v0.3 ships
|
||||
// the skeleton (enum + per-service struct stubs + genesis); no live services.
|
||||
//
|
||||
// Lexicon note (REQ-012): "Mail"/"SIM"/"Care"/"Vault" are not banned terms.
|
||||
// Avoid the banned Holder-identity term (use "operator-reach-id" not the
|
||||
// banned term). The per-package lexicon assertion in types_test.go is the gate.
|
||||
//
|
||||
// Cross-module references are by-ID-string per G-003 (no struct imports):
|
||||
// - operator-reach-id references an x/identity Reach by ID-string (G-003).
|
||||
// - window-id references an x/window Window by ID-string (A-307, G-003).
|
||||
// A service-grant opens a Window on the holder's behalf (the Window
|
||||
// Lifecycle interface hook, typed in v0.3, invoked at runtime in v0.4).
|
||||
// - mailbox-id (MailService) and storage-quota-grain (VaultService) are
|
||||
// opaque / in-package values; VaultService references x/vault by name only
|
||||
// (the ServiceKind "Vault" is a service kind, NOT a struct import of
|
||||
// x/vault — the naming collision is concept-level, not package-level).
|
||||
package types
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const (
|
||||
ModuleName = "services"
|
||||
StoreKey = ModuleName
|
||||
RouterKey = ModuleName
|
||||
QuerierRoute = ModuleName
|
||||
|
||||
// ServiceKindCount is the locked count of ServiceKind enum values (vision
|
||||
// §13, REQ-025, A-307). A regression firewall: adding/removing/renaming a
|
||||
// service kind breaks this const's test.
|
||||
ServiceKindCount = 4
|
||||
)
|
||||
|
||||
// ServiceKind enumerates the four OY-protocol service kinds (vision §13,
|
||||
// REQ-025, A-307): Care (community care), SIM (subscriber identity module /
|
||||
// connectivity), Vault (storage service), Mail (messaging). The full services
|
||||
// suite (the real-return token, Travel, +11 more) is Phase 4, out of v0.3
|
||||
// scope (D-040). The real-return token's name in vision §13 uses a banned
|
||||
// standalone term; this comment uses the lexicon-safe "real-return" phrasing.
|
||||
type ServiceKind string
|
||||
|
||||
const (
|
||||
KindCare ServiceKind = "Care" // community care
|
||||
KindSIM ServiceKind = "SIM" // connectivity
|
||||
KindVault ServiceKind = "Vault" // storage service
|
||||
KindMail ServiceKind = "Mail" // messaging
|
||||
)
|
||||
|
||||
// AllServiceKinds returns all four ServiceKind values in vision §13 order.
|
||||
// Locked-const test asserts exactly 4 entries with these names (REQ-025).
|
||||
func AllServiceKinds() []ServiceKind {
|
||||
return []ServiceKind{
|
||||
KindCare,
|
||||
KindSIM,
|
||||
KindVault,
|
||||
KindMail,
|
||||
}
|
||||
}
|
||||
|
||||
// ServiceStatus enumerates the lifecycle states of a service (REQ-025). This
|
||||
// is a LOCAL redefinition of the 4-state shape (mirrors the v0.2 PartnerStatus
|
||||
// shape); no struct import of x/partner (G-003).
|
||||
type ServiceStatus string
|
||||
|
||||
const (
|
||||
ServicePending ServiceStatus = "Pending" // registered, not yet active
|
||||
ServiceActive ServiceStatus = "Active" // live
|
||||
ServiceSuspended ServiceStatus = "Suspended" // temporarily halted
|
||||
ServiceRevoked ServiceStatus = "Revoked" // permanently revoked
|
||||
)
|
||||
|
||||
// ServiceStatusCount is the locked count of ServiceStatus enum values.
|
||||
const ServiceStatusCount = 4
|
||||
|
||||
// ServiceInfo is the registry record for a service (REQ-025, A-307).
|
||||
// service-id is the unique identifier. kind picks the ServiceKind.
|
||||
// operator-reach-id references an x/identity Reach by ID-string (G-003 — use
|
||||
// "operator-reach-id" not the banned Holder-identity term). name is a human-
|
||||
// readable label. status is the lifecycle state. window-id references an
|
||||
// x/window Window by ID-string (A-307, G-003 — a service-grant opens a Window
|
||||
// on the holder's behalf; the Window Lifecycle interface hook, typed in v0.3,
|
||||
// invoked at runtime in v0.4). The window-id field is the by-ID-string ref
|
||||
// that ties a service-grant to a Window scope.
|
||||
type ServiceInfo struct {
|
||||
ServiceID string `json:"service_id" yaml:"service_id"`
|
||||
Kind ServiceKind `json:"kind" yaml:"kind"`
|
||||
OperatorReachID string `json:"operator_reach_id" yaml:"operator_reach_id"`
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Status ServiceStatus `json:"status" yaml:"status"`
|
||||
WindowID string `json:"window_id" yaml:"window_id"`
|
||||
}
|
||||
|
||||
// CareService is the per-service struct stub for a Care service (vision §13,
|
||||
// REQ-025). care-id is the service identifier. care-kind is an opaque string
|
||||
// (the kind of community care, e.g. "mutual-aid" — opaque so the enum is not
|
||||
// locked in v0.3; care kinds are operational, not protocol-locked).
|
||||
type CareService struct {
|
||||
CareID string `json:"care_id" yaml:"care_id"`
|
||||
CareKind string `json:"care_kind" yaml:"care_kind"`
|
||||
}
|
||||
|
||||
// SIMService is the per-service struct stub for a SIM (connectivity) service
|
||||
// (vision §13, REQ-025). sim-id is the service identifier. carrier is an
|
||||
// opaque string (the connectivity carrier — opaque so the enum is not locked
|
||||
// in v0.3 per A-308 venue pattern; carriers are operational).
|
||||
type SIMService struct {
|
||||
SIMID string `json:"sim_id" yaml:"sim_id"`
|
||||
Carrier string `json:"carrier" yaml:"carrier"`
|
||||
}
|
||||
|
||||
// VaultService is the per-service struct stub for a Vault (storage) service
|
||||
// (vision §13, REQ-025). vault-id is the service identifier. holder-reach-id
|
||||
// references an x/identity Reach by ID-string (G-003 — use "holder-reach-id"
|
||||
// not the banned Holder-identity term). storage-quota-grain is the storage
|
||||
// quota in Grain (the OY internal unit, by name only — no x/bread import).
|
||||
// "Vault" here is a service kind, NOT a struct import of x/vault (the naming
|
||||
// collision is concept-level; VaultService references x/vault by ID-string at
|
||||
// runtime, not by Go import).
|
||||
type VaultService struct {
|
||||
VaultID string `json:"vault_id" yaml:"vault_id"`
|
||||
HolderReachID string `json:"holder_reach_id" yaml:"holder_reach_id"`
|
||||
StorageQuotaGrain int64 `json:"storage_quota_grain" yaml:"storage_quota_grain"`
|
||||
}
|
||||
|
||||
// MailService is the per-service struct stub for a Mail (messaging) service
|
||||
// (vision §13, REQ-025). mail-id is the service identifier. holder-reach-id
|
||||
// references an x/identity Reach by ID-string (G-003). mailbox-id is the
|
||||
// opaque mailbox identifier.
|
||||
type MailService struct {
|
||||
MailID string `json:"mail_id" yaml:"mail_id"`
|
||||
HolderReachID string `json:"holder_reach_id" yaml:"holder_reach_id"`
|
||||
MailboxID string `json:"mailbox_id" yaml:"mailbox_id"`
|
||||
}
|
||||
|
||||
// Params for the services module (skeleton — no tunables in v0.3).
|
||||
type Params struct{}
|
||||
|
||||
// DefaultParams returns the zero-value Params (skeleton — no tunables).
|
||||
func DefaultParams() Params { return Params{} }
|
||||
|
||||
// GenesisState defines the services module genesis state (REQ-025). The
|
||||
// ServiceInfos slice holds the registry records. The per-service stub slices
|
||||
// hold the service-specific metadata. ValidateGenesis enforces service-id
|
||||
// uniqueness across the registry (A-212).
|
||||
type GenesisState struct {
|
||||
Params Params `json:"params" yaml:"params"`
|
||||
ServiceInfos []ServiceInfo `json:"service_infos" yaml:"service_infos"`
|
||||
CareServices []CareService `json:"care_services" yaml:"care_services"`
|
||||
SIMServices []SIMService `json:"sim_services" yaml:"sim_services"`
|
||||
VaultServices []VaultService `json:"vault_services" yaml:"vault_services"`
|
||||
MailServices []MailService `json:"mail_services" yaml:"mail_services"`
|
||||
}
|
||||
|
||||
// DefaultGenesisState returns an empty genesis state with non-nil slices.
|
||||
func DefaultGenesisState() *GenesisState {
|
||||
return &GenesisState{
|
||||
Params: DefaultParams(),
|
||||
ServiceInfos: []ServiceInfo{},
|
||||
CareServices: []CareService{},
|
||||
SIMServices: []SIMService{},
|
||||
VaultServices: []VaultService{},
|
||||
MailServices: []MailService{},
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateGenesis performs ID-uniqueness checks (A-212 upgrade from v0.1
|
||||
// no-op): rejects duplicate or empty service-ids in the registry, and unknown
|
||||
// ServiceKind / ServiceStatus values.
|
||||
func ValidateGenesis(bz json.RawMessage) error {
|
||||
var gs GenesisState
|
||||
if err := json.Unmarshal(bz, &gs); err != nil {
|
||||
return fmt.Errorf("services: invalid genesis: %w", err)
|
||||
}
|
||||
if err := validateServiceInfos(gs.ServiceInfos); err != nil {
|
||||
return fmt.Errorf("services: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateServiceInfos enforces service-id presence and uniqueness, and that
|
||||
// each Kind/Status is a known enum value.
|
||||
func validateServiceInfos(infos []ServiceInfo) error {
|
||||
seen := make(map[string]bool, len(infos))
|
||||
for i, s := range infos {
|
||||
if s.ServiceID == "" {
|
||||
return fmt.Errorf("service info [%d]: empty service-id", i)
|
||||
}
|
||||
if seen[s.ServiceID] {
|
||||
return fmt.Errorf("service info: duplicate service-id %q", s.ServiceID)
|
||||
}
|
||||
seen[s.ServiceID] = true
|
||||
if !knownServiceKind(s.Kind) {
|
||||
return fmt.Errorf("service %q: unknown service kind %q", s.ServiceID, s.Kind)
|
||||
}
|
||||
if !knownServiceStatus(s.Status) {
|
||||
return fmt.Errorf("service %q: unknown service status %q", s.ServiceID, s.Status)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// knownServiceKind reports whether k is one of the four ServiceKind values.
|
||||
func knownServiceKind(k ServiceKind) bool {
|
||||
for _, kk := range AllServiceKinds() {
|
||||
if k == kk {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// knownServiceStatus reports whether s is one of the four ServiceStatus values.
|
||||
func knownServiceStatus(s ServiceStatus) bool {
|
||||
switch s {
|
||||
case ServicePending, ServiceActive, ServiceSuspended, ServiceRevoked:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
package types_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/oy/openyield/lexicon"
|
||||
stypes "github.com/oy/openyield/x/services/types"
|
||||
)
|
||||
|
||||
// --- ServiceKind enum coverage (4) --------------------------------------------
|
||||
|
||||
// TestServiceKindCountLockedConst asserts ServiceKindCount == 4 and
|
||||
// AllServiceKinds() returns exactly 4 (REQ-025, A-307). A regression firewall.
|
||||
func TestServiceKindCountLockedConst(t *testing.T) {
|
||||
if stypes.ServiceKindCount != 4 {
|
||||
t.Errorf("ServiceKindCount = %d, expected 4 (REQ-025 LOCKED)", stypes.ServiceKindCount)
|
||||
}
|
||||
all := stypes.AllServiceKinds()
|
||||
if len(all) != 4 {
|
||||
t.Errorf("AllServiceKinds() len = %d, expected 4", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllServiceKindsNames asserts the 4 REQ-025 names in order with no extras,
|
||||
// no dups, no renames.
|
||||
func TestAllServiceKindsNames(t *testing.T) {
|
||||
want := []string{"Care", "SIM", "Vault", "Mail"}
|
||||
all := stypes.AllServiceKinds()
|
||||
if len(all) != len(want) {
|
||||
t.Fatalf("len = %d, want %d", len(all), len(want))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for i, k := range all {
|
||||
if string(k) != want[i] {
|
||||
t.Errorf("AllServiceKinds()[%d] = %q, want %q", i, k, want[i])
|
||||
}
|
||||
if seen[string(k)] {
|
||||
t.Errorf("duplicate ServiceKind %q", k)
|
||||
}
|
||||
seen[string(k)] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceKindValues asserts each named const matches its AllServiceKinds
|
||||
// entry.
|
||||
func TestServiceKindValues(t *testing.T) {
|
||||
if stypes.KindCare != "Care" {
|
||||
t.Errorf("KindCare = %q", stypes.KindCare)
|
||||
}
|
||||
if stypes.KindSIM != "SIM" {
|
||||
t.Errorf("KindSIM = %q", stypes.KindSIM)
|
||||
}
|
||||
if stypes.KindVault != "Vault" {
|
||||
t.Errorf("KindVault = %q", stypes.KindVault)
|
||||
}
|
||||
if stypes.KindMail != "Mail" {
|
||||
t.Errorf("KindMail = %q", stypes.KindMail)
|
||||
}
|
||||
}
|
||||
|
||||
// --- ServiceStatus enum coverage (4) ------------------------------------------
|
||||
|
||||
// TestServiceStatusCountLockedConst asserts ServiceStatusCount == 4.
|
||||
func TestServiceStatusCountLockedConst(t *testing.T) {
|
||||
if stypes.ServiceStatusCount != 4 {
|
||||
t.Errorf("ServiceStatusCount = %d, expected 4", stypes.ServiceStatusCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceStatusValues asserts the four ServiceStatus named consts.
|
||||
func TestServiceStatusValues(t *testing.T) {
|
||||
if stypes.ServicePending != "Pending" {
|
||||
t.Errorf("ServicePending = %q", stypes.ServicePending)
|
||||
}
|
||||
if stypes.ServiceActive != "Active" {
|
||||
t.Errorf("ServiceActive = %q", stypes.ServiceActive)
|
||||
}
|
||||
if stypes.ServiceSuspended != "Suspended" {
|
||||
t.Errorf("ServiceSuspended = %q", stypes.ServiceSuspended)
|
||||
}
|
||||
if stypes.ServiceRevoked != "Revoked" {
|
||||
t.Errorf("ServiceRevoked = %q", stypes.ServiceRevoked)
|
||||
}
|
||||
}
|
||||
|
||||
// --- ServiceInfo struct + by-ID-string refs (G-003, A-307) --------------------
|
||||
|
||||
// TestServiceInfoStructFields asserts ServiceInfo carries service-id, kind,
|
||||
// operator-reach-id, name, status, window-id.
|
||||
func TestServiceInfoStructFields(t *testing.T) {
|
||||
s := stypes.ServiceInfo{
|
||||
ServiceID: "svc-1",
|
||||
Kind: stypes.KindCare,
|
||||
OperatorReachID: "reach-holder-1",
|
||||
Name: "Care Service",
|
||||
Status: stypes.ServiceActive,
|
||||
WindowID: "window-1",
|
||||
}
|
||||
if s.ServiceID != "svc-1" || s.Kind != stypes.KindCare || s.OperatorReachID != "reach-holder-1" ||
|
||||
s.Name != "Care Service" || s.Status != stypes.ServiceActive || s.WindowID != "window-1" {
|
||||
t.Error("ServiceInfo fields not set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceInfoWindowIDIsString asserts window-id is string-typed (A-307
|
||||
// by-ID-string ref to x/window — G-003, no struct import). This is the
|
||||
// window-id by-ID-string ref presence test mandated by the P5 task spec.
|
||||
func TestServiceInfoWindowIDIsString(t *testing.T) {
|
||||
s := stypes.ServiceInfo{WindowID: "window-abc"}
|
||||
if s.WindowID != "window-abc" {
|
||||
t.Errorf("WindowID = %q", s.WindowID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceInfoOperatorReachIDIsString asserts operator-reach-id is
|
||||
// string-typed (G-003 by-ID-string ref to x/identity Reach — no struct import).
|
||||
func TestServiceInfoOperatorReachIDIsString(t *testing.T) {
|
||||
s := stypes.ServiceInfo{OperatorReachID: "reach-xyz"}
|
||||
if s.OperatorReachID != "reach-xyz" {
|
||||
t.Errorf("OperatorReachID = %q", s.OperatorReachID)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Per-service struct stubs --------------------------------------------------
|
||||
|
||||
// TestCareServiceStructFields asserts CareService carries care-id, care-kind.
|
||||
func TestCareServiceStructFields(t *testing.T) {
|
||||
c := stypes.CareService{CareID: "care-1", CareKind: "mutual-aid"}
|
||||
if c.CareID != "care-1" || c.CareKind != "mutual-aid" {
|
||||
t.Error("CareService fields not set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSIMServiceStructFields asserts SIMService carries sim-id, carrier.
|
||||
func TestSIMServiceStructFields(t *testing.T) {
|
||||
s := stypes.SIMService{SIMID: "sim-1", Carrier: "oy-mobile"}
|
||||
if s.SIMID != "sim-1" || s.Carrier != "oy-mobile" {
|
||||
t.Error("SIMService fields not set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVaultServiceStructFields asserts VaultService carries vault-id,
|
||||
// holder-reach-id (by-ID-string ref to x/identity — G-003), storage-quota-grain.
|
||||
func TestVaultServiceStructFields(t *testing.T) {
|
||||
v := stypes.VaultService{
|
||||
VaultID: "vault-1",
|
||||
HolderReachID: "reach-holder-1",
|
||||
StorageQuotaGrain: 1_000_000,
|
||||
}
|
||||
if v.VaultID != "vault-1" || v.HolderReachID != "reach-holder-1" || v.StorageQuotaGrain != 1_000_000 {
|
||||
t.Error("VaultService fields not set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVaultServiceHolderReachIDIsString asserts holder-reach-id is string-typed
|
||||
// (G-003 by-ID-string ref to x/identity Reach — no struct import).
|
||||
func TestVaultServiceHolderReachIDIsString(t *testing.T) {
|
||||
v := stypes.VaultService{HolderReachID: "reach-abc"}
|
||||
if v.HolderReachID != "reach-abc" {
|
||||
t.Errorf("HolderReachID = %q", v.HolderReachID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailServiceStructFields asserts MailService carries mail-id,
|
||||
// holder-reach-id (by-ID-string ref to x/identity — G-003), mailbox-id.
|
||||
func TestMailServiceStructFields(t *testing.T) {
|
||||
m := stypes.MailService{
|
||||
MailID: "mail-1",
|
||||
HolderReachID: "reach-holder-1",
|
||||
MailboxID: "mbox-1",
|
||||
}
|
||||
if m.MailID != "mail-1" || m.HolderReachID != "reach-holder-1" || m.MailboxID != "mbox-1" {
|
||||
t.Error("MailService fields not set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Module consts + Params ----------------------------------------------------
|
||||
|
||||
// TestModuleConsts asserts the four Cosmos-convention module consts.
|
||||
func TestModuleConsts(t *testing.T) {
|
||||
if stypes.ModuleName != "services" {
|
||||
t.Errorf("ModuleName = %q", stypes.ModuleName)
|
||||
}
|
||||
if stypes.StoreKey != "services" {
|
||||
t.Errorf("StoreKey = %q", stypes.StoreKey)
|
||||
}
|
||||
if stypes.RouterKey != "services" {
|
||||
t.Errorf("RouterKey = %q", stypes.RouterKey)
|
||||
}
|
||||
if stypes.QuerierRoute != "services" {
|
||||
t.Errorf("QuerierRoute = %q", stypes.QuerierRoute)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultParams asserts DefaultParams returns a zero-value Params.
|
||||
func TestDefaultParams(t *testing.T) {
|
||||
_ = stypes.DefaultParams() // no panics
|
||||
}
|
||||
|
||||
// --- Genesis -------------------------------------------------------------------
|
||||
|
||||
// TestDefaultGenesisStateEmpty asserts DefaultGenesisState returns non-nil
|
||||
// empty slices for all five sets.
|
||||
func TestDefaultGenesisStateEmpty(t *testing.T) {
|
||||
gs := stypes.DefaultGenesisState()
|
||||
if gs == nil {
|
||||
t.Fatal("DefaultGenesisState returned nil")
|
||||
}
|
||||
if gs.ServiceInfos == nil || len(gs.ServiceInfos) != 0 {
|
||||
t.Errorf("Default ServiceInfos should be non-nil empty slice; got len=%d nil=%v", len(gs.ServiceInfos), gs.ServiceInfos == nil)
|
||||
}
|
||||
if gs.CareServices == nil || len(gs.CareServices) != 0 {
|
||||
t.Errorf("Default CareServices should be non-nil empty slice; got len=%d nil=%v", len(gs.CareServices), gs.CareServices == nil)
|
||||
}
|
||||
if gs.SIMServices == nil || len(gs.SIMServices) != 0 {
|
||||
t.Errorf("Default SIMServices should be non-nil empty slice; got len=%d nil=%v", len(gs.SIMServices), gs.SIMServices == nil)
|
||||
}
|
||||
if gs.VaultServices == nil || len(gs.VaultServices) != 0 {
|
||||
t.Errorf("Default VaultServices should be non-nil empty slice; got len=%d nil=%v", len(gs.VaultServices), gs.VaultServices == nil)
|
||||
}
|
||||
if gs.MailServices == nil || len(gs.MailServices) != 0 {
|
||||
t.Errorf("Default MailServices should be non-nil empty slice; got len=%d nil=%v", len(gs.MailServices), gs.MailServices == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsDupServiceIDs asserts A-212: duplicate service-ids
|
||||
// are rejected.
|
||||
func TestValidateGenesisRejectsDupServiceIDs(t *testing.T) {
|
||||
gs := stypes.GenesisState{
|
||||
ServiceInfos: []stypes.ServiceInfo{
|
||||
{ServiceID: "s1", Kind: stypes.KindCare, Status: stypes.ServiceActive},
|
||||
{ServiceID: "s1", Kind: stypes.KindSIM, Status: stypes.ServiceActive}, // dup
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := stypes.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject duplicate service-ids")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsEmptyServiceID asserts empty service-id is rejected.
|
||||
func TestValidateGenesisRejectsEmptyServiceID(t *testing.T) {
|
||||
gs := stypes.GenesisState{
|
||||
ServiceInfos: []stypes.ServiceInfo{{ServiceID: "", Kind: stypes.KindCare, Status: stypes.ServiceActive}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := stypes.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject empty service-id")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsUnknownServiceKind asserts an unknown ServiceKind
|
||||
// is rejected.
|
||||
func TestValidateGenesisRejectsUnknownServiceKind(t *testing.T) {
|
||||
gs := stypes.GenesisState{
|
||||
ServiceInfos: []stypes.ServiceInfo{{ServiceID: "s1", Kind: stypes.ServiceKind("Bogus"), Status: stypes.ServiceActive}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := stypes.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject unknown service kind")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsUnknownServiceStatus asserts an unknown
|
||||
// ServiceStatus is rejected.
|
||||
func TestValidateGenesisRejectsUnknownServiceStatus(t *testing.T) {
|
||||
gs := stypes.GenesisState{
|
||||
ServiceInfos: []stypes.ServiceInfo{{ServiceID: "s1", Kind: stypes.KindCare, Status: stypes.ServiceStatus("Bogus")}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := stypes.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject unknown service status")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsBadJSON asserts malformed JSON is rejected.
|
||||
func TestValidateGenesisRejectsBadJSON(t *testing.T) {
|
||||
if err := stypes.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 := stypes.GenesisState{
|
||||
ServiceInfos: []stypes.ServiceInfo{
|
||||
{ServiceID: "s1", Kind: stypes.KindCare, OperatorReachID: "r1", Name: "Care", Status: stypes.ServiceActive, WindowID: "w1"},
|
||||
{ServiceID: "s2", Kind: stypes.KindMail, OperatorReachID: "r2", Name: "Mail", Status: stypes.ServicePending, WindowID: "w2"},
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := stypes.ValidateGenesis(bz); err != nil {
|
||||
t.Errorf("ValidateGenesis should accept clean genesis, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Lexicon assertion (REQ-012) -------------------------------------------------
|
||||
// The services module must avoid the banned Holder-identity term (use
|
||||
// "operator-reach-id"/"holder-reach-id" not the banned term). "Mail"/"SIM"/
|
||||
// "Care"/"Vault" are not banned. The lexicon helpers are used here — no
|
||||
// banned literals are inlined in this test file.
|
||||
|
||||
// TestLexiconNoBannedTermsInServicesPackage scans every non-test .go file in
|
||||
// the services/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 TestLexiconNoBannedTermsInServicesPackage(t *testing.T) {
|
||||
pkgDir := packageDir(t, "github.com/oy/openyield/x/services/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 services/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 operator-reach-id not the banned Holder-identity term)", filepath.Base(f), found)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLexiconNoBannedTermsInServicesTestFile asserts this test file itself does
|
||||
// not contain any banned term as a literal (the firewall scans test files too;
|
||||
// the lexicon helpers must be used rather than inlining banned terms).
|
||||
func TestLexiconNoBannedTermsInServicesTestFile(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("services 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/services/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)
|
||||
}
|
||||
Reference in New Issue
Block a user