Merge milestone/v0.5-bearers-runtime into main (v0.5 Bearers Runtime feature milestone release)
v0.5 Bearers Runtime — 7 runtime REQs (REQ-033..039) shipped as feature. 8 modules promoted to runtime (MsgServer + simtest). cosmos-sdk v0.50.8 + ibc-go v8.2.1 added (G-006 controlled exception). G-003 + locked-const firewalls intact. 8 keeper packages ≥80% coverage. 5 GRILL decisions ratified; 8 binding fixes landed; 5 P1+ flagged for v0.6+. ---ci--- project: oy phase: 8 milestone: v0.5 status: complete requirements: covered: [REQ-033, REQ-034, REQ-035, REQ-036, REQ-037, REQ-038, REQ-039] partial: [] ---/ci---
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
package keeper
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
sdk "github.com/cosmos/cosmos-sdk/types"
|
||||
|
||||
capabilitytypes "github.com/cosmos/ibc-go/modules/capability/types"
|
||||
channeltypes "github.com/cosmos/ibc-go/v8/modules/core/04-channel/types"
|
||||
porttypes "github.com/cosmos/ibc-go/v8/modules/core/05-port/types"
|
||||
ibcexported "github.com/cosmos/ibc-go/v8/modules/core/exported"
|
||||
)
|
||||
|
||||
// ibc_module.go implements the IBCModule contract for the bridge module
|
||||
// (P1-03-01). The IBCModule interface (ibc-go porttypes.IBCModule, ICS-26)
|
||||
// requires the full channel-handshake lifecycle + the three packet handlers.
|
||||
// For the v0.5 simtest-grade runtime (D-054), the channel-handshake callbacks
|
||||
// are no-ops (the simtest exercises only OnRecvPacket/OnAcknowledgementPacket/
|
||||
// OnTimeoutPacket); the packet handlers are the load-bearing surface.
|
||||
//
|
||||
// Packet handler contract (REQ-033, D-059, A-513, G-021):
|
||||
//
|
||||
// - OnRecvPacket: parse the ICS-20 v1 payload (denom, amount, sender,
|
||||
// receiver). Validate the denom trace against the v0.2 WrappedBreadDenom
|
||||
// shape `transfer/channel-N/<denom>`. Mint wrapped Bread via the
|
||||
// BreadKeeper shim. The 4 EVM chains (Polygon/Base/Arbitrum/Optimism)
|
||||
// use timestamp-only timeouts; the Solana branch verifies the wormhole
|
||||
// guardian sig set (2-of-N) from state before minting. Write the
|
||||
// in-flight record (replay protection — A-513).
|
||||
//
|
||||
// - OnAcknowledgementPacket: delete the in-flight record on the first ack
|
||||
// (replay protection mirroring ibc-go). A second ack finds no record and
|
||||
// returns ERROR (G-021 — NOT a silent no-op; the CVE-class ibc-go pitfall
|
||||
// A-513 is closed by failing loudly on the replay).
|
||||
//
|
||||
// - OnTimeoutPacket: refund the source-chain escrow via the BreadKeeper
|
||||
// shim exactly once (the `Refunded` flag on the in-flight record guards
|
||||
// a second refund). A second timeout is a no-op (the record is already
|
||||
// refunded).
|
||||
|
||||
// IBCModule is the bridge module's IBC module (implements porttypes.IBCModule).
|
||||
type IBCModule struct {
|
||||
keeper Keeper
|
||||
}
|
||||
|
||||
// NewIBCModule constructs a new IBCModule wrapping the bridge Keeper.
|
||||
func NewIBCModule(k Keeper) IBCModule {
|
||||
return IBCModule{keeper: k}
|
||||
}
|
||||
|
||||
// Compile-time assertion: IBCModule implements porttypes.IBCModule.
|
||||
var _ porttypes.IBCModule = IBCModule{}
|
||||
|
||||
// --- ICS-20 v1 packet data ---------------------------------------------------
|
||||
//
|
||||
// The bridge handler parses the ICS-20 v1 payload directly (a JSON object
|
||||
// with denom, amount, sender, receiver, memo). This mirrors the ibc-go
|
||||
// transfer FungibleTokenPacketData but is hand-rolled here (no struct import
|
||||
// of the transfer types — the bridge handler is self-contained per the
|
||||
// skeleton's zero-codegen style).
|
||||
|
||||
// ICS20PacketData is the ICS-20 v1 fungible token transfer packet payload.
|
||||
type ICS20PacketData struct {
|
||||
Denom string `json:"denom"`
|
||||
Amount string `json:"amount"`
|
||||
Sender string `json:"sender"`
|
||||
Receiver string `json:"receiver"`
|
||||
Memo string `json:"memo,omitempty"`
|
||||
}
|
||||
|
||||
// ValidateBasic is the stateless ICS-20 v1 validation: non-empty denom,
|
||||
// non-empty amount (positive integer string), non-empty sender/receiver.
|
||||
func (d ICS20PacketData) ValidateBasic() error {
|
||||
if d.Denom == "" {
|
||||
return fmt.Errorf("bridge: empty denom")
|
||||
}
|
||||
if d.Amount == "" {
|
||||
return fmt.Errorf("bridge: empty amount")
|
||||
}
|
||||
if d.Sender == "" {
|
||||
return fmt.Errorf("bridge: empty sender")
|
||||
}
|
||||
if d.Receiver == "" {
|
||||
return fmt.Errorf("bridge: empty receiver")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseICS20 parses the ICS-20 v1 packet data from raw bytes (JSON).
|
||||
func parseICS20(data []byte) (ICS20PacketData, error) {
|
||||
var d ICS20PacketData
|
||||
if err := json.Unmarshal(data, &d); err != nil {
|
||||
return ICS20PacketData{}, fmt.Errorf("bridge: cannot unmarshal ICS-20 packet data: %w", err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// ValidateDenomTrace validates the ICS-20 v1 denom trace shape
|
||||
// `transfer/channel-N/<denom>` (the v0.2 WrappedBreadDenom shape). The denom
|
||||
// trace is the prefix chain; the base denom is the trailing segment. A
|
||||
// valid trace has at least one `transfer/channel-N/` hop.
|
||||
func ValidateDenomTrace(denom string) error {
|
||||
if denom == "" {
|
||||
return fmt.Errorf("bridge: empty denom trace")
|
||||
}
|
||||
// The ICS-20 v1 denom trace is a `/`-separated path of hop prefixes
|
||||
// `transfer/channel-N` followed by the base denom. A wrapped denom
|
||||
// arriving on the receiving chain has at least one hop prefix.
|
||||
if !strings.Contains(denom, "transfer/channel-") {
|
||||
return fmt.Errorf("bridge: denom %q missing transfer/channel-N/ hop prefix", denom)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseDenomTrace parses the ICS-20 v1 denom trace into the hop prefix
|
||||
// (e.g. `transfer/channel-0`) and the base denom. Returns the prefix and
|
||||
// base denom. A denom with no hop prefix is the base denom (prefix="").
|
||||
func ParseDenomTrace(denom string) (prefix, base string) {
|
||||
if denom == "" {
|
||||
return "", ""
|
||||
}
|
||||
// The trace shape is `transfer/channel-N/.../base`. Find the last `/`
|
||||
// and split there; everything before is the prefix, after is the base.
|
||||
idx := strings.LastIndex(denom, "/")
|
||||
if idx < 0 {
|
||||
return "", denom
|
||||
}
|
||||
return denom[:idx], denom[idx+1:]
|
||||
}
|
||||
|
||||
// --- Channel handshake (no-ops for simtest — D-054) --------------------------
|
||||
|
||||
// OnChanOpenInit implements porttypes.IBCModule (no-op for simtest).
|
||||
func (IBCModule) OnChanOpenInit(
|
||||
ctx sdk.Context,
|
||||
order channeltypes.Order,
|
||||
connectionHops []string,
|
||||
portID string,
|
||||
channelID string,
|
||||
channelCap *capabilitytypes.Capability,
|
||||
counterparty channeltypes.Counterparty,
|
||||
version string,
|
||||
) (string, error) {
|
||||
return version, nil
|
||||
}
|
||||
|
||||
// OnChanOpenTry implements porttypes.IBCModule (no-op for simtest).
|
||||
func (IBCModule) OnChanOpenTry(
|
||||
ctx sdk.Context,
|
||||
order channeltypes.Order,
|
||||
connectionHops []string,
|
||||
portID,
|
||||
channelID string,
|
||||
channelCap *capabilitytypes.Capability,
|
||||
counterparty channeltypes.Counterparty,
|
||||
counterpartyVersion string,
|
||||
) (string, error) {
|
||||
return counterpartyVersion, nil
|
||||
}
|
||||
|
||||
// OnChanOpenAck implements porttypes.IBCModule (no-op for simtest).
|
||||
func (IBCModule) OnChanOpenAck(
|
||||
ctx sdk.Context,
|
||||
portID,
|
||||
channelID string,
|
||||
counterpartyChannelID string,
|
||||
counterpartyVersion string,
|
||||
) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// OnChanOpenConfirm implements porttypes.IBCModule (no-op for simtest).
|
||||
func (IBCModule) OnChanOpenConfirm(
|
||||
ctx sdk.Context,
|
||||
portID,
|
||||
channelID string,
|
||||
) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// OnChanCloseInit implements porttypes.IBCModule (no-op for simtest).
|
||||
func (IBCModule) OnChanCloseInit(
|
||||
ctx sdk.Context,
|
||||
portID,
|
||||
channelID string,
|
||||
) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// OnChanCloseConfirm implements porttypes.IBCModule (no-op for simtest).
|
||||
func (IBCModule) OnChanCloseConfirm(
|
||||
ctx sdk.Context,
|
||||
portID,
|
||||
channelID string,
|
||||
) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Packet handlers (load-bearing — REQ-033, A-513, G-021) ------------------
|
||||
|
||||
// OnRecvPacket implements porttypes.IBCModule. Parses the ICS-20 v1 payload,
|
||||
// validates the denom trace, mints wrapped Bread via the BreadKeeper shim,
|
||||
// and writes the in-flight record (replay protection — A-513). The Solana
|
||||
// branch verifies the wormhole guardian sig set (2-of-N) from state before
|
||||
// minting.
|
||||
func (im IBCModule) OnRecvPacket(
|
||||
ctx sdk.Context,
|
||||
packet channeltypes.Packet,
|
||||
relayer sdk.AccAddress,
|
||||
) ibcexported.Acknowledgement {
|
||||
// Parse ICS-20 v1 payload.
|
||||
data, err := parseICS20(packet.GetData())
|
||||
if err != nil {
|
||||
return channeltypes.NewErrorAcknowledgement(err)
|
||||
}
|
||||
if err := data.ValidateBasic(); err != nil {
|
||||
return channeltypes.NewErrorAcknowledgement(err)
|
||||
}
|
||||
|
||||
// Validate the denom trace (ICS-20 v1 `transfer/channel-N/<denom>`).
|
||||
if err := ValidateDenomTrace(data.Denom); err != nil {
|
||||
return channeltypes.NewErrorAcknowledgement(err)
|
||||
}
|
||||
|
||||
// Determine the L2 chain from the source channel (simtest passes the
|
||||
// L2 chain via the packet source-port; the real wiring uses the
|
||||
// channel→route lookup). For the simtest, the source-port encodes the
|
||||
// L2 chain name (e.g. "transfer.Polygon").
|
||||
l2Chain := chainFromPort(packet.SourcePort)
|
||||
|
||||
// Solana branch: verify the wormhole guardian sig set (2-of-N) from
|
||||
// state before minting. The sig set is read from state (not hardcoded —
|
||||
// D-054 uses a frozen stub set in simtest).
|
||||
if l2Chain == "Solana" {
|
||||
gs, ok := im.keeper.GetGuardianSet(ctx)
|
||||
if !ok {
|
||||
return channeltypes.NewErrorAcknowledgement(fmt.Errorf("bridge: solana guardian set not configured"))
|
||||
}
|
||||
// The guardian sig verification: the simtest stubs this via the
|
||||
// WatcherKeeper shim (IsQuorumSigned on the guardian-set quorum
|
||||
// id). A real wormhole adapter verifies the VAA signatures; the
|
||||
// simtest uses the same IsQuorumSigned interface.
|
||||
if im.keeper.watcherKeeper == nil {
|
||||
return channeltypes.NewErrorAcknowledgement(fmt.Errorf("bridge: watcher keeper shim not wired"))
|
||||
}
|
||||
// The guardian-set threshold (2-of-N) is the quorum; the payload
|
||||
// is the packet data hash (simtest stubs the payload).
|
||||
if !im.keeper.watcherKeeper.IsQuorumSigned("solana-guardians", packet.GetData()) {
|
||||
return channeltypes.NewErrorAcknowledgement(fmt.Errorf("bridge: solana guardian sig set did not reach 2-of-N quorum"))
|
||||
}
|
||||
_ = gs // guardian set read from state (D-054 — frozen stub in simtest)
|
||||
}
|
||||
|
||||
// Mint wrapped Bread via the BreadKeeper shim.
|
||||
if im.keeper.breadKeeper == nil {
|
||||
return channeltypes.NewErrorAcknowledgement(fmt.Errorf("bridge: bread keeper shim not wired"))
|
||||
}
|
||||
// Parse the amount string to int64 grains.
|
||||
var amount int64
|
||||
if _, err := fmt.Sscanf(data.Amount, "%d", &amount); err != nil {
|
||||
return channeltypes.NewErrorAcknowledgement(fmt.Errorf("bridge: cannot parse amount %q: %w", data.Amount, err))
|
||||
}
|
||||
if amount <= 0 {
|
||||
return channeltypes.NewErrorAcknowledgement(fmt.Errorf("bridge: amount must be > 0"))
|
||||
}
|
||||
if err := im.keeper.breadKeeper.MintWrappedBread(ctx, data.Denom, amount, data.Receiver); err != nil {
|
||||
return channeltypes.NewErrorAcknowledgement(fmt.Errorf("bridge: mint wrapped bread: %w", err))
|
||||
}
|
||||
|
||||
// Write the in-flight record (replay protection — A-513).
|
||||
im.keeper.SetInflight(ctx, InflightPacket{
|
||||
SourcePort: packet.SourcePort,
|
||||
SourceChannel: packet.SourceChannel,
|
||||
Sequence: packet.Sequence,
|
||||
Denom: data.Denom,
|
||||
Amount: amount,
|
||||
Sender: data.Sender,
|
||||
Receiver: data.Receiver,
|
||||
L2Chain: l2Chain,
|
||||
Refunded: false,
|
||||
})
|
||||
|
||||
// Emit event.
|
||||
ctx.EventManager().EmitEvent(sdk.NewEvent(
|
||||
"bridge.recv_packet",
|
||||
sdk.NewAttribute("source_port", packet.SourcePort),
|
||||
sdk.NewAttribute("source_channel", packet.SourceChannel),
|
||||
sdk.NewAttribute("sequence", fmt.Sprintf("%d", packet.Sequence)),
|
||||
sdk.NewAttribute("denom", data.Denom),
|
||||
sdk.NewAttribute("amount", data.Amount),
|
||||
sdk.NewAttribute("l2_chain", l2Chain),
|
||||
))
|
||||
|
||||
return channeltypes.NewResultAcknowledgement([]byte{byte(1)})
|
||||
}
|
||||
|
||||
// OnAcknowledgementPacket implements porttypes.IBCModule. Deletes the
|
||||
// in-flight record on the first ack (replay protection mirroring ibc-go).
|
||||
// A second ack finds no record and returns ERROR (G-021 — the CVE-class
|
||||
// ibc-go pitfall A-513 is closed by failing loudly on the replay, NOT a
|
||||
// silent no-op).
|
||||
func (im IBCModule) OnAcknowledgementPacket(
|
||||
ctx sdk.Context,
|
||||
packet channeltypes.Packet,
|
||||
acknowledgement []byte,
|
||||
relayer sdk.AccAddress,
|
||||
) error {
|
||||
// Load the in-flight record. Absence = replay (G-021).
|
||||
_, ok := im.keeper.GetInflight(ctx, packet.SourcePort, packet.SourceChannel, packet.Sequence)
|
||||
if !ok {
|
||||
// G-021: the second OnAcknowledgementPacket returns ERROR (not a
|
||||
// silent no-op). This is the replay-protection firewall.
|
||||
return fmt.Errorf("bridge: replay detected — no in-flight record for %s/%s/%d (already acknowledged)",
|
||||
packet.SourcePort, packet.SourceChannel, packet.Sequence)
|
||||
}
|
||||
|
||||
// Delete the in-flight record (first ack — the deletion is the replay
|
||||
// signal for a future second ack).
|
||||
im.keeper.DeleteInflight(ctx, packet.SourcePort, packet.SourceChannel, packet.Sequence)
|
||||
|
||||
ctx.EventManager().EmitEvent(sdk.NewEvent(
|
||||
"bridge.ack_packet",
|
||||
sdk.NewAttribute("source_port", packet.SourcePort),
|
||||
sdk.NewAttribute("source_channel", packet.SourceChannel),
|
||||
sdk.NewAttribute("sequence", fmt.Sprintf("%d", packet.Sequence)),
|
||||
))
|
||||
return nil
|
||||
}
|
||||
|
||||
// OnTimeoutPacket implements porttypes.IBCModule. Refunds the source-chain
|
||||
// escrow via the BreadKeeper shim exactly once (the `Refunded` flag on the
|
||||
// in-flight record guards a second refund). A second timeout is a no-op.
|
||||
func (im IBCModule) OnTimeoutPacket(
|
||||
ctx sdk.Context,
|
||||
packet channeltypes.Packet,
|
||||
relayer sdk.AccAddress,
|
||||
) error {
|
||||
// Load the in-flight record.
|
||||
p, ok := im.keeper.GetInflight(ctx, packet.SourcePort, packet.SourceChannel, packet.Sequence)
|
||||
if !ok {
|
||||
// No in-flight record: nothing to refund (either never sent, or
|
||||
// already acked-and-deleted). No-op — a timeout on an already-acked
|
||||
// packet is benign (the ack path already finalized).
|
||||
return nil
|
||||
}
|
||||
if p.Refunded {
|
||||
// Already refunded: exactly-once guard. No-op (not an error — the
|
||||
// refund already happened; a duplicate timeout is benign).
|
||||
return nil
|
||||
}
|
||||
|
||||
// Refund the source-chain escrow via the BreadKeeper shim.
|
||||
if im.keeper.breadKeeper != nil {
|
||||
if err := im.keeper.breadKeeper.ReleaseWrappedBread(ctx, p.Denom, p.Amount, p.Sender); err != nil {
|
||||
return fmt.Errorf("bridge: timeout refund: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Flip the refunded flag (state write FIRST — A-521 idempotency).
|
||||
p.Refunded = true
|
||||
im.keeper.SetInflight(ctx, p)
|
||||
|
||||
ctx.EventManager().EmitEvent(sdk.NewEvent(
|
||||
"bridge.timeout_packet",
|
||||
sdk.NewAttribute("source_port", packet.SourcePort),
|
||||
sdk.NewAttribute("source_channel", packet.SourceChannel),
|
||||
sdk.NewAttribute("sequence", fmt.Sprintf("%d", packet.Sequence)),
|
||||
sdk.NewAttribute("denom", p.Denom),
|
||||
sdk.NewAttribute("amount", fmt.Sprintf("%d", p.Amount)),
|
||||
))
|
||||
return nil
|
||||
}
|
||||
|
||||
// chainFromPort extracts the L2 chain name from the source port. The simtest
|
||||
// encodes the L2 chain in the source port (e.g. "transfer.Polygon"). Returns
|
||||
// the chain name, or "" if not encoded.
|
||||
func chainFromPort(sourcePort string) string {
|
||||
// The simtest convention: source port = "transfer.<L2Chain>". A real
|
||||
// wiring uses the channel→route lookup; the simtest uses the port
|
||||
// encoding for simplicity (D-054).
|
||||
if idx := strings.Index(sourcePort, "."); idx >= 0 {
|
||||
return sourcePort[idx+1:]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Ensure the context import is used (the IBCModule handlers use sdk.Context
|
||||
// directly; this no-op reference keeps the import stable if handlers are
|
||||
// later refactored to use context.Context).
|
||||
var _ = context.Background
|
||||
@@ -0,0 +1,225 @@
|
||||
package keeper
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
storetypes "cosmossdk.io/store/types"
|
||||
"github.com/cosmos/cosmos-sdk/codec"
|
||||
sdk "github.com/cosmos/cosmos-sdk/types"
|
||||
|
||||
"github.com/oy/openyield/x/bridge/types"
|
||||
)
|
||||
|
||||
// keeper.go holds the store-backed Keeper for the bridge module (P1-03-01).
|
||||
//
|
||||
// The Keeper wraps an sdk.KVStore via a storeKey. It replaces the v0.3
|
||||
// in-memory stub (the stub may stay as a test helper). The Keeper holds the
|
||||
// BridgeRoute records (by bridge-id) and the IBC in-flight packet records
|
||||
// (by source-port/source-channel/sequence) used for replay protection (A-513).
|
||||
//
|
||||
// The Keeper also holds the expected-keeper shims (WatcherKeeper for the
|
||||
// Attested transition + Solana guardian sig set; BreadKeeper for mint/release
|
||||
// wrapped Bread on recv/timeout). The shims are interfaces (G-003 — no
|
||||
// struct imports of x/watcher/types or x/bread/types); the concrete keepers
|
||||
// satisfy them structurally.
|
||||
//
|
||||
// State-machine ordering (vision §7, enforced in every handler):
|
||||
// ValidateBasic → keeper authz → state mutation → ctx.EventManager().EmitEvent
|
||||
|
||||
// Keeper is the store-backed bridge keeper.
|
||||
type Keeper struct {
|
||||
cdc codec.Codec
|
||||
storeKey storetypes.StoreKey
|
||||
|
||||
watcherKeeper types.WatcherKeeper
|
||||
breadKeeper types.BreadKeeper
|
||||
}
|
||||
|
||||
// NewKeeper constructs a new store-backed bridge Keeper. The expected-keeper
|
||||
// shims are injected (nil-able for partial tests; the handler guards nil
|
||||
// shims where appropriate).
|
||||
func NewKeeper(cdc codec.Codec, storeKey storetypes.StoreKey, wk types.WatcherKeeper, bk types.BreadKeeper) Keeper {
|
||||
return Keeper{
|
||||
cdc: cdc,
|
||||
storeKey: storeKey,
|
||||
watcherKeeper: wk,
|
||||
breadKeeper: bk,
|
||||
}
|
||||
}
|
||||
|
||||
// SetWatcherKeeper sets the WatcherKeeper expected-keeper shim (for
|
||||
// post-construction wiring, e.g., app wiring or test setup).
|
||||
func (k *Keeper) SetWatcherKeeper(wk types.WatcherKeeper) { k.watcherKeeper = wk }
|
||||
|
||||
// SetBreadKeeper sets the BreadKeeper expected-keeper shim.
|
||||
func (k *Keeper) SetBreadKeeper(bk types.BreadKeeper) { k.breadKeeper = bk }
|
||||
|
||||
// --- BridgeRoute store --------------------------------------------------------
|
||||
|
||||
// routeKey is the store key prefix for a BridgeRoute record (by bridge-id).
|
||||
var routeKeyPrefix = []byte("route/")
|
||||
|
||||
func routeKey(bridgeID string) []byte {
|
||||
return append(routeKeyPrefix, []byte(bridgeID)...)
|
||||
}
|
||||
|
||||
// GetBridgeRoute loads a BridgeRoute by bridge-id. Returns the route and
|
||||
// true if found, or zero value + false if not. This is the store-backed
|
||||
// implementation that satisfies x/exit/types.BridgeKeeper (GetBridgeRoute
|
||||
// returns status + bridgeType; the status is the BridgeStatus string).
|
||||
func (k Keeper) GetBridgeRoute(ctx sdk.Context, bridgeID string) (types.BridgeRoute, bool) {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
bz := store.Get(routeKey(bridgeID))
|
||||
if bz == nil {
|
||||
return types.BridgeRoute{}, false
|
||||
}
|
||||
var r types.BridgeRoute
|
||||
if err := json.Unmarshal(bz, &r); err != nil {
|
||||
return types.BridgeRoute{}, false
|
||||
}
|
||||
return r, true
|
||||
}
|
||||
|
||||
// SetBridgeRoute persists a BridgeRoute by bridge-id.
|
||||
func (k Keeper) SetBridgeRoute(ctx sdk.Context, r types.BridgeRoute) {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
bz, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("bridge: marshal route %q: %v", r.BridgeID, err))
|
||||
}
|
||||
store.Set(routeKey(r.BridgeID), bz)
|
||||
}
|
||||
|
||||
// AllBridgeRoutes returns all persisted BridgeRoute records (iteration
|
||||
// helper for tests/queries).
|
||||
func (k Keeper) AllBridgeRoutes(ctx sdk.Context) []types.BridgeRoute {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
iterator := store.Iterator(routeKeyPrefix, prefixEnd(routeKeyPrefix))
|
||||
defer iterator.Close()
|
||||
out := []types.BridgeRoute{}
|
||||
for ; iterator.Valid(); iterator.Next() {
|
||||
var r types.BridgeRoute
|
||||
if err := json.Unmarshal(iterator.Value(), &r); err == nil {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// prefixEnd returns the key that sorts immediately after all keys sharing the
|
||||
// given prefix (the standard prefix-iteration end key).
|
||||
func prefixEnd(prefix []byte) []byte {
|
||||
if len(prefix) == 0 {
|
||||
return nil
|
||||
}
|
||||
end := make([]byte, len(prefix))
|
||||
copy(end, prefix)
|
||||
for i := len(end) - 1; i >= 0; i-- {
|
||||
end[i]++
|
||||
if end[i] != 0 {
|
||||
return end
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- IBC in-flight packet store (replay protection — A-513) -------------------
|
||||
//
|
||||
// The in-flight record tracks a packet that has been received but not yet
|
||||
// acknowledged. OnRecvPacket writes the record; OnAcknowledgementPacket
|
||||
// deletes it (first ack). A second OnAcknowledgementPacket finds no record
|
||||
// and returns ERROR (G-021 — replay protection, not a silent no-op). This
|
||||
// mirrors ibc-go's delete-on-ack pattern.
|
||||
|
||||
var inflightPrefix = []byte("inflight/")
|
||||
|
||||
func inflightKey(sourcePort, sourceChannel string, sequence uint64) []byte {
|
||||
return append(inflightPrefix, []byte(fmt.Sprintf("%s/%s/%d", sourcePort, sourceChannel, sequence))...)
|
||||
}
|
||||
|
||||
// InflightPacket is the in-flight packet record (replay protection — A-513).
|
||||
type InflightPacket struct {
|
||||
SourcePort string `json:"source_port" yaml:"source_port"`
|
||||
SourceChannel string `json:"source_channel" yaml:"source_channel"`
|
||||
Sequence uint64 `json:"sequence" yaml:"sequence"`
|
||||
Denom string `json:"denom" yaml:"denom"`
|
||||
Amount int64 `json:"amount" yaml:"amount"`
|
||||
Sender string `json:"sender" yaml:"sender"` // source-chain sender reach-id
|
||||
Receiver string `json:"receiver" yaml:"receiver"` // dest-chain receiver reach-id
|
||||
L2Chain string `json:"l2_chain" yaml:"l2_chain"` // the L2 chain (EVM or Solana)
|
||||
Refunded bool `json:"refunded" yaml:"refunded"` // timeout-refund exactly-once guard
|
||||
}
|
||||
|
||||
// SetInflight writes the in-flight packet record (OnRecvPacket).
|
||||
func (k Keeper) SetInflight(ctx sdk.Context, p InflightPacket) {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
bz, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("bridge: marshal inflight %s/%s/%d: %v", p.SourcePort, p.SourceChannel, p.Sequence, err))
|
||||
}
|
||||
store.Set(inflightKey(p.SourcePort, p.SourceChannel, p.Sequence), bz)
|
||||
}
|
||||
|
||||
// GetInflight loads the in-flight packet record. Returns the record and
|
||||
// true if found, or zero value + false if not. The absence of a record on
|
||||
// OnAcknowledgementPacket is the replay signal (G-021).
|
||||
func (k Keeper) GetInflight(ctx sdk.Context, sourcePort, sourceChannel string, sequence uint64) (InflightPacket, bool) {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
bz := store.Get(inflightKey(sourcePort, sourceChannel, sequence))
|
||||
if bz == nil {
|
||||
return InflightPacket{}, false
|
||||
}
|
||||
var p InflightPacket
|
||||
if err := json.Unmarshal(bz, &p); err != nil {
|
||||
return InflightPacket{}, false
|
||||
}
|
||||
return p, true
|
||||
}
|
||||
|
||||
// DeleteInflight deletes the in-flight packet record (OnAcknowledgementPacket
|
||||
// — first ack; the deletion is the replay-protection signal).
|
||||
func (k Keeper) DeleteInflight(ctx sdk.Context, sourcePort, sourceChannel string, sequence uint64) {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
store.Delete(inflightKey(sourcePort, sourceChannel, sequence))
|
||||
}
|
||||
|
||||
// --- Solana guardian sig set (wormhole-adapter — D-059) -----------------------
|
||||
//
|
||||
// The Solana branch verifies a wormhole guardian sig set (a 2-of-N quorum,
|
||||
// N = the wormhole guardian set). The set is read from state (not
|
||||
// hardcoded — D-054 uses a frozen stub set in simtest; live rotation is
|
||||
// deferred). The set is stored as a JSON array of guardian reach-ids.
|
||||
|
||||
var guardianSetKey = []byte("solana/guardian-set")
|
||||
|
||||
// GuardianSet is the wormhole guardian sig set for the Solana branch.
|
||||
type GuardianSet struct {
|
||||
Guardians []string `json:"guardians" yaml:"guardians"` // guardian reach-ids
|
||||
Threshold int `json:"threshold" yaml:"threshold"` // 2-of-N quorum
|
||||
}
|
||||
|
||||
// GetGuardianSet loads the current Solana guardian sig set from state.
|
||||
func (k Keeper) GetGuardianSet(ctx sdk.Context) (GuardianSet, bool) {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
bz := store.Get(guardianSetKey)
|
||||
if bz == nil {
|
||||
return GuardianSet{}, false
|
||||
}
|
||||
var gs GuardianSet
|
||||
if err := json.Unmarshal(bz, &gs); err != nil {
|
||||
return GuardianSet{}, false
|
||||
}
|
||||
return gs, true
|
||||
}
|
||||
|
||||
// SetGuardianSet persists the Solana guardian sig set (simtest uses a frozen
|
||||
// stub set; live rotation deferred per D-054).
|
||||
func (k Keeper) SetGuardianSet(ctx sdk.Context, gs GuardianSet) {
|
||||
store := ctx.KVStore(k.storeKey)
|
||||
bz, err := json.Marshal(gs)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("bridge: marshal guardian set: %v", err))
|
||||
}
|
||||
store.Set(guardianSetKey, bz)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package keeper
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
sdk "github.com/cosmos/cosmos-sdk/types"
|
||||
|
||||
"github.com/oy/openyield/x/bridge/types"
|
||||
)
|
||||
|
||||
// msg_server.go implements the bridge module's MsgServer (G-023 ownership
|
||||
// split: cosmos-engineer scaffolds the file structure; backend-engineer
|
||||
// implements the handler logic bodies). The MsgServer wraps the Keeper +
|
||||
// the expected-keeper shims (already on the Keeper).
|
||||
//
|
||||
// Each method returns a (*Response, error). Handler state-machine ordering
|
||||
// is enforced: ValidateBasic → keeper authz → state mutation →
|
||||
// ctx.EventManager().EmitEvent.
|
||||
|
||||
// msgServer is the concrete MsgServer implementation wrapping the Keeper.
|
||||
type msgServer struct {
|
||||
Keeper
|
||||
}
|
||||
|
||||
// NewMsgServerImpl returns the bridge MsgServer for the provided Keeper.
|
||||
func NewMsgServerImpl(k Keeper) types.MsgServer {
|
||||
return &msgServer{Keeper: k}
|
||||
}
|
||||
|
||||
var _ types.MsgServer = msgServer{}
|
||||
|
||||
// unwrapCtx extracts the sdk.Context from the interface-typed ctx (the
|
||||
// MsgServer interface takes interface{} to avoid coupling types/ to
|
||||
// sdk.Context; the keeper layer unwraps it).
|
||||
func unwrapCtx(ctx interface{}) sdk.Context {
|
||||
if c, ok := ctx.(sdk.Context); ok {
|
||||
return c
|
||||
}
|
||||
panic(fmt.Sprintf("bridge: expected sdk.Context, got %T", ctx))
|
||||
}
|
||||
|
||||
// --- AttestBridgeRoute (Pending → Attested) -----------------------------------
|
||||
//
|
||||
// A Watcher 6-of-9 quorum (vision §7, REQ-004) must attest the route. The
|
||||
// handler consults the WatcherKeeper expected-keeper shim (by-ID-string on
|
||||
// the watcher-quorum-id). State-machine ordering:
|
||||
// ValidateBasic → load route (authz: must be Pending) → WatcherKeeper
|
||||
// quorum check → state mutation (status=Attested, set watcher-quorum-id)
|
||||
// → emit event.
|
||||
|
||||
// AttestBridgeRoute transitions a bridge route Pending → Attested.
|
||||
func (s msgServer) AttestBridgeRoute(ctx interface{}, msg *types.MsgAttestBridgeRoute) (*types.MsgAttestBridgeRouteResponse, error) {
|
||||
if err := msg.ValidateBasic(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sdkCtx := unwrapCtx(ctx)
|
||||
|
||||
// Stateful: load route; must exist and be Pending.
|
||||
r, ok := s.Keeper.GetBridgeRoute(sdkCtx, msg.BridgeID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("bridge: route %q not found", msg.BridgeID)
|
||||
}
|
||||
if r.Status != types.BridgePending {
|
||||
return nil, fmt.Errorf("bridge: route %q status %q, must be Pending to attest", msg.BridgeID, r.Status)
|
||||
}
|
||||
|
||||
// Keeper authz: Watcher quorum check via expected-keeper shim.
|
||||
if s.Keeper.watcherKeeper == nil {
|
||||
return nil, fmt.Errorf("bridge: watcher keeper shim not wired")
|
||||
}
|
||||
// The payload is the bridge-id (the route attestation payload); a real
|
||||
// watcher quorum signs a canonical payload. For simtest the shim
|
||||
// returns true/false on the quorum-id.
|
||||
if !s.Keeper.watcherKeeper.IsQuorumSigned(msg.WatcherQuorumID, []byte(msg.BridgeID)) {
|
||||
return nil, fmt.Errorf("bridge: watcher quorum %q did not reach threshold on route %q", msg.WatcherQuorumID, msg.BridgeID)
|
||||
}
|
||||
|
||||
// State mutation: status=Attested, record the watcher-quorum-id.
|
||||
r.Status = types.BridgeAttested
|
||||
r.WatcherQuorumID = msg.WatcherQuorumID
|
||||
s.Keeper.SetBridgeRoute(sdkCtx, r)
|
||||
|
||||
// Emit event.
|
||||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||
"bridge.attest",
|
||||
sdk.NewAttribute("bridge_id", msg.BridgeID),
|
||||
sdk.NewAttribute("watcher_quorum_id", msg.WatcherQuorumID),
|
||||
sdk.NewAttribute("status", string(types.BridgeAttested)),
|
||||
))
|
||||
return &types.MsgAttestBridgeRouteResponse{}, nil
|
||||
}
|
||||
|
||||
// --- ActivateBridge (Attested → Active) --------------------------------------
|
||||
//
|
||||
// The route must already be Attested. State-machine ordering:
|
||||
// ValidateBasic → load route (authz: must be Attested) → state mutation
|
||||
// (status=Active) → emit event.
|
||||
|
||||
// ActivateBridge transitions a bridge route Attested → Active.
|
||||
func (s msgServer) ActivateBridge(ctx interface{}, msg *types.MsgActivateBridge) (*types.MsgActivateBridgeResponse, error) {
|
||||
if err := msg.ValidateBasic(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sdkCtx := unwrapCtx(ctx)
|
||||
|
||||
r, ok := s.Keeper.GetBridgeRoute(sdkCtx, msg.BridgeID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("bridge: route %q not found", msg.BridgeID)
|
||||
}
|
||||
if r.Status != types.BridgeAttested {
|
||||
return nil, fmt.Errorf("bridge: route %q status %q, must be Attested to activate", msg.BridgeID, r.Status)
|
||||
}
|
||||
|
||||
r.Status = types.BridgeActive
|
||||
s.Keeper.SetBridgeRoute(sdkCtx, r)
|
||||
|
||||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||
"bridge.activate",
|
||||
sdk.NewAttribute("bridge_id", msg.BridgeID),
|
||||
sdk.NewAttribute("status", string(types.BridgeActive)),
|
||||
))
|
||||
return &types.MsgActivateBridgeResponse{}, nil
|
||||
}
|
||||
|
||||
// --- CloseBridge (Active → Closed) -------------------------------------------
|
||||
//
|
||||
// Retire the route. State-machine ordering:
|
||||
// ValidateBasic → load route (authz: must be Active) → state mutation
|
||||
// (status=Closed) → emit event.
|
||||
|
||||
// CloseBridge transitions a bridge route Active → Closed.
|
||||
func (s msgServer) CloseBridge(ctx interface{}, msg *types.MsgCloseBridge) (*types.MsgCloseBridgeResponse, error) {
|
||||
if err := msg.ValidateBasic(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sdkCtx := unwrapCtx(ctx)
|
||||
|
||||
r, ok := s.Keeper.GetBridgeRoute(sdkCtx, msg.BridgeID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("bridge: route %q not found", msg.BridgeID)
|
||||
}
|
||||
if r.Status != types.BridgeActive {
|
||||
return nil, fmt.Errorf("bridge: route %q status %q, must be Active to close", msg.BridgeID, r.Status)
|
||||
}
|
||||
|
||||
r.Status = types.BridgeClosed
|
||||
s.Keeper.SetBridgeRoute(sdkCtx, r)
|
||||
|
||||
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
|
||||
"bridge.close",
|
||||
sdk.NewAttribute("bridge_id", msg.BridgeID),
|
||||
sdk.NewAttribute("status", string(types.BridgeClosed)),
|
||||
))
|
||||
return &types.MsgCloseBridgeResponse{}, nil
|
||||
}
|
||||
|
||||
// Compile-time assertion: msgServer implements types.MsgServer.
|
||||
var _ types.MsgServer = (*msgServer)(nil)
|
||||
|
||||
// Ensure the context import is used (unwrapCtx uses context indirectly via
|
||||
// sdk.Context; this no-op reference keeps the import stable if handlers are
|
||||
// later refactored to use context.Context directly).
|
||||
var _ = context.Background
|
||||
@@ -0,0 +1,676 @@
|
||||
package keeper_test
|
||||
|
||||
// msg_server_simtest_test.go is the x/bridge keeper simtest (P1-06-01).
|
||||
//
|
||||
// D-054: simtest-grade — in-memory sdk.Context + dbm in-memory store, no
|
||||
// real IBC light clients. The simtest wires the expected-keeper shims
|
||||
// (WatcherKeeper + BreadKeeper) to in-test stubs (G-003 test exemption:
|
||||
// the test imports x/bridge/keeper + defines stub keepers that satisfy the
|
||||
// interfaces; no production struct imports across x/<module>/types).
|
||||
//
|
||||
// Coverage (A-513, G-021):
|
||||
// - OnRecvPacket: mints wrapped Bread (assert BreadKeeper.MintWrappedBread
|
||||
// called); ICS-20 v1 denom trace parse; Solana guardian sig set (2-of-N
|
||||
// stub).
|
||||
// - OnAcknowledgementPacket: deletes the in-flight record (first ack) and
|
||||
// rejects the second (REPLAY PROTECTION — G-021, A-513 CVE-class pitfall).
|
||||
// - OnTimeoutPacket: refunds the escrow exactly once (second timeout is a
|
||||
// no-op — the Refunded flag guards).
|
||||
// - BridgeStatus lifecycle: Pending → Attested (MsgAttestBridgeRoute) →
|
||||
// Active (MsgActivateBridge) → Closed (MsgCloseBridge).
|
||||
// - Solana stub guardian sig set (2-of-N).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"cosmossdk.io/log"
|
||||
"cosmossdk.io/store"
|
||||
storetypes "cosmossdk.io/store/types"
|
||||
cmtproto "github.com/cometbft/cometbft/proto/tendermint/types"
|
||||
dbm "github.com/cosmos/cosmos-db"
|
||||
"github.com/cosmos/cosmos-sdk/codec"
|
||||
codectypes "github.com/cosmos/cosmos-sdk/codec/types"
|
||||
sdk "github.com/cosmos/cosmos-sdk/types"
|
||||
channeltypes "github.com/cosmos/ibc-go/v8/modules/core/04-channel/types"
|
||||
|
||||
"github.com/oy/openyield/x/bridge/keeper"
|
||||
bridgetypes "github.com/oy/openyield/x/bridge/types"
|
||||
)
|
||||
|
||||
// --- Stub expected-keepers (G-003 test exemption) ----------------------------
|
||||
|
||||
// stubWatcherKeeper satisfies bridgetypes.WatcherKeeper for the simtest. The
|
||||
// IsQuorumSigned returns true for the configured quorum-id (the simtest
|
||||
// stubs the Watcher 6-of-9 quorum + the Solana guardian 2-of-N quorum).
|
||||
type stubWatcherKeeper struct {
|
||||
// signedQuorums maps quorum-id → true if the quorum reached threshold.
|
||||
signedQuorums map[string]bool
|
||||
// solanaCalls tracks IsQuorumSigned invocations for the Solana branch.
|
||||
solanaCalls int
|
||||
}
|
||||
|
||||
func (s *stubWatcherKeeper) IsQuorumSigned(quorumID string, payload []byte) bool {
|
||||
if quorumID == "solana-guardians" {
|
||||
s.solanaCalls++
|
||||
}
|
||||
return s.signedQuorums[quorumID]
|
||||
}
|
||||
|
||||
// stubBreadKeeper satisfies bridgetypes.BreadKeeper for the simtest. It
|
||||
// records mint/release calls for assertion.
|
||||
type stubBreadKeeper struct {
|
||||
mints []mintCall
|
||||
releases []releaseCall
|
||||
}
|
||||
|
||||
type mintCall struct {
|
||||
denom string
|
||||
amount int64
|
||||
reachID string
|
||||
}
|
||||
|
||||
type releaseCall struct {
|
||||
denom string
|
||||
amount int64
|
||||
reachID string
|
||||
}
|
||||
|
||||
func (s *stubBreadKeeper) MintWrappedBread(ctx interface{}, denom string, amount int64, holderReach string) error {
|
||||
s.mints = append(s.mints, mintCall{denom, amount, holderReach})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubBreadKeeper) ReleaseWrappedBread(ctx interface{}, denom string, amount int64, holderReach string) error {
|
||||
s.releases = append(s.releases, releaseCall{denom, amount, holderReach})
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Simtest context helper --------------------------------------------------
|
||||
|
||||
// newSimtestContext constructs an in-memory sdk.Context with a KVStore mounted
|
||||
// at the bridge store key. D-054: in-memory, no real IBC light clients.
|
||||
func newSimtestContext(t *testing.T) (sdk.Context, *stubWatcherKeeper, *stubBreadKeeper, keeper.Keeper) {
|
||||
t.Helper()
|
||||
db := dbm.NewMemDB()
|
||||
cdc := newTestCodec()
|
||||
storeKey := storetypes.NewKVStoreKey(bridgetypes.StoreKey)
|
||||
cms := store.NewCommitMultiStore(db, log.NewNopLogger(), nil)
|
||||
cms.MountStoreWithDB(storeKey, storetypes.StoreTypeDB, nil)
|
||||
if err := cms.LoadLatestVersion(); err != nil {
|
||||
t.Fatalf("load latest version: %v", err)
|
||||
}
|
||||
ctx := sdk.NewContext(cms, cmtproto.Header{}, false, log.NewNopLogger())
|
||||
|
||||
wk := &stubWatcherKeeper{signedQuorums: map[string]bool{}}
|
||||
bk := &stubBreadKeeper{}
|
||||
k := keeper.NewKeeper(cdc, storeKey, wk, bk)
|
||||
return ctx, wk, bk, k
|
||||
}
|
||||
|
||||
// newTestCodec constructs a minimal codec for the simtest (the keeper uses
|
||||
// JSON marshaling, so a bare proto codec suffices).
|
||||
func newTestCodec() codec.Codec {
|
||||
registry := codectypes.NewInterfaceRegistry()
|
||||
return codec.NewProtoCodec(registry)
|
||||
}
|
||||
|
||||
// --- ICS-20 v1 packet helpers ------------------------------------------------
|
||||
|
||||
// ics20PacketData returns the ICS-20 v1 packet payload (matches
|
||||
// keeper.ICS20PacketData).
|
||||
func ics20PacketData(denom, amount, sender, receiver string) []byte {
|
||||
bz, _ := json.Marshal(map[string]string{
|
||||
"denom": denom,
|
||||
"amount": amount,
|
||||
"sender": sender,
|
||||
"receiver": receiver,
|
||||
})
|
||||
return bz
|
||||
}
|
||||
|
||||
// newPacket constructs a real channeltypes.Packet for the simtest.
|
||||
func newPacket(sourcePort, sourceChannel string, sequence uint64, data []byte) channeltypes.Packet {
|
||||
return channeltypes.Packet{
|
||||
SourcePort: sourcePort,
|
||||
SourceChannel: sourceChannel,
|
||||
Sequence: sequence,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
// --- OnRecvPacket: mint wrapped Bread + denom trace + Solana ----------------
|
||||
|
||||
// TestOnRecvPacketMintsWrappedBread asserts OnRecvPacket mints wrapped Bread
|
||||
// for a valid ICS-20 v1 packet (EVM chain).
|
||||
func TestOnRecvPacketMintsWrappedBread(t *testing.T) {
|
||||
ctx, _, bk, k := newSimtestContext(t)
|
||||
im := keeper.NewIBCModule(k)
|
||||
|
||||
packet := newPacket("transfer.Polygon", "channel-0", 1, ics20PacketData(
|
||||
"transfer/channel-0/uatom", "1000", "sender-reach", "receiver-reach"))
|
||||
|
||||
ack := im.OnRecvPacket(ctx, packet, sdk.AccAddress([]byte("relayer")))
|
||||
if !ack.Success() {
|
||||
t.Fatalf("OnRecvPacket should succeed; got error ack")
|
||||
}
|
||||
if len(bk.mints) != 1 {
|
||||
t.Fatalf("expected 1 mint call, got %d", len(bk.mints))
|
||||
}
|
||||
if bk.mints[0].denom != "transfer/channel-0/uatom" {
|
||||
t.Errorf("mint denom = %q, want transfer/channel-0/uatom", bk.mints[0].denom)
|
||||
}
|
||||
if bk.mints[0].amount != 1000 {
|
||||
t.Errorf("mint amount = %d, want 1000", bk.mints[0].amount)
|
||||
}
|
||||
if bk.mints[0].reachID != "receiver-reach" {
|
||||
t.Errorf("mint reach = %q, want receiver-reach", bk.mints[0].reachID)
|
||||
}
|
||||
|
||||
// In-flight record written.
|
||||
if _, ok := k.GetInflight(ctx, packet.SourcePort, packet.SourceChannel, packet.Sequence); !ok {
|
||||
t.Error("in-flight record not written after OnRecvPacket")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOnRecvPacketRejectsBadDenomTrace asserts OnRecvPacket rejects a packet
|
||||
// whose denom trace lacks the `transfer/channel-N/` hop prefix.
|
||||
func TestOnRecvPacketRejectsBadDenomTrace(t *testing.T) {
|
||||
ctx, _, bk, k := newSimtestContext(t)
|
||||
im := keeper.NewIBCModule(k)
|
||||
|
||||
packet := newPacket("transfer.Polygon", "channel-0", 1, ics20PacketData(
|
||||
"uatom", "1000", "sender", "receiver")) // no hop prefix
|
||||
|
||||
ack := im.OnRecvPacket(ctx, packet, sdk.AccAddress{})
|
||||
if ack.Success() {
|
||||
t.Error("OnRecvPacket should fail on bad denom trace")
|
||||
}
|
||||
if len(bk.mints) != 0 {
|
||||
t.Errorf("no mint should happen on bad denom trace; got %d", len(bk.mints))
|
||||
}
|
||||
}
|
||||
|
||||
// TestOnRecvPacketRejectsBadICS20 asserts a malformed ICS-20 payload is rejected.
|
||||
func TestOnRecvPacketRejectsBadICS20(t *testing.T) {
|
||||
ctx, _, bk, k := newSimtestContext(t)
|
||||
im := keeper.NewIBCModule(k)
|
||||
packet := newPacket("transfer.Polygon", "channel-0", 1, []byte("not-json"))
|
||||
ack := im.OnRecvPacket(ctx, packet, sdk.AccAddress{})
|
||||
if ack.Success() {
|
||||
t.Error("OnRecvPacket should fail on malformed ICS-20")
|
||||
}
|
||||
if len(bk.mints) != 0 {
|
||||
t.Errorf("no mint on bad ICS-20; got %d", len(bk.mints))
|
||||
}
|
||||
}
|
||||
|
||||
// TestOnRecvPacketSolanaGuardianSigSet asserts the Solana branch verifies the
|
||||
// wormhole guardian sig set (2-of-N stub) from state before minting.
|
||||
func TestOnRecvPacketSolanaGuardianSigSet(t *testing.T) {
|
||||
ctx, wk, bk, k := newSimtestContext(t)
|
||||
im := keeper.NewIBCModule(k)
|
||||
|
||||
// Configure the frozen stub guardian set (D-054 — frozen in simtest).
|
||||
k.SetGuardianSet(ctx, keeper.GuardianSet{
|
||||
Guardians: []string{"guardian-1", "guardian-2", "guardian-3"},
|
||||
Threshold: 2,
|
||||
})
|
||||
wk.signedQuorums["solana-guardians"] = true
|
||||
|
||||
packet := newPacket("transfer.Solana", "channel-1", 1, ics20PacketData(
|
||||
"transfer/channel-1/wsol", "500", "sol-sender", "sol-receiver"))
|
||||
|
||||
ack := im.OnRecvPacket(ctx, packet, sdk.AccAddress{})
|
||||
if !ack.Success() {
|
||||
t.Fatalf("OnRecvPacket Solana should succeed with guardian quorum; got error")
|
||||
}
|
||||
if len(bk.mints) != 1 {
|
||||
t.Fatalf("expected 1 mint for Solana, got %d", len(bk.mints))
|
||||
}
|
||||
if bk.mints[0].denom != "transfer/channel-1/wsol" {
|
||||
t.Errorf("mint denom = %q", bk.mints[0].denom)
|
||||
}
|
||||
if wk.solanaCalls != 1 {
|
||||
t.Errorf("expected 1 Solana guardian sig check, got %d", wk.solanaCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOnRecvPacketSolanaRejectsNoGuardianSet asserts the Solana branch rejects
|
||||
// when the guardian set is not configured.
|
||||
func TestOnRecvPacketSolanaRejectsNoGuardianSet(t *testing.T) {
|
||||
ctx, _, bk, k := newSimtestContext(t)
|
||||
im := keeper.NewIBCModule(k)
|
||||
// No guardian set configured.
|
||||
|
||||
packet := newPacket("transfer.Solana", "channel-1", 1, ics20PacketData(
|
||||
"transfer/channel-1/wsol", "500", "sender", "receiver"))
|
||||
|
||||
ack := im.OnRecvPacket(ctx, packet, sdk.AccAddress{})
|
||||
if ack.Success() {
|
||||
t.Error("OnRecvPacket Solana should fail without guardian set")
|
||||
}
|
||||
if len(bk.mints) != 0 {
|
||||
t.Errorf("no mint should happen; got %d", len(bk.mints))
|
||||
}
|
||||
}
|
||||
|
||||
// TestOnRecvPacketSolanaRejectsNoQuorum asserts the Solana branch rejects when
|
||||
// the guardian sig set did not reach the 2-of-N quorum.
|
||||
func TestOnRecvPacketSolanaRejectsNoQuorum(t *testing.T) {
|
||||
ctx, wk, bk, k := newSimtestContext(t)
|
||||
im := keeper.NewIBCModule(k)
|
||||
|
||||
k.SetGuardianSet(ctx, keeper.GuardianSet{
|
||||
Guardians: []string{"guardian-1", "guardian-2", "guardian-3"},
|
||||
Threshold: 2,
|
||||
})
|
||||
wk.signedQuorums["solana-guardians"] = false // quorum NOT reached
|
||||
|
||||
packet := newPacket("transfer.Solana", "channel-1", 1, ics20PacketData(
|
||||
"transfer/channel-1/wsol", "500", "sender", "receiver"))
|
||||
ack := im.OnRecvPacket(ctx, packet, sdk.AccAddress{})
|
||||
if ack.Success() {
|
||||
t.Error("OnRecvPacket Solana should fail without quorum")
|
||||
}
|
||||
if len(bk.mints) != 0 {
|
||||
t.Errorf("no mint on Solana quorum failure; got %d", len(bk.mints))
|
||||
}
|
||||
}
|
||||
|
||||
// TestOnRecvPacketRejectsZeroAmount asserts a zero/negative amount is rejected.
|
||||
func TestOnRecvPacketRejectsZeroAmount(t *testing.T) {
|
||||
ctx, _, bk, k := newSimtestContext(t)
|
||||
im := keeper.NewIBCModule(k)
|
||||
packet := newPacket("transfer.Polygon", "channel-0", 1, ics20PacketData(
|
||||
"transfer/channel-0/uatom", "0", "sender", "receiver"))
|
||||
ack := im.OnRecvPacket(ctx, packet, sdk.AccAddress{})
|
||||
if ack.Success() {
|
||||
t.Error("OnRecvPacket should reject zero amount")
|
||||
}
|
||||
if len(bk.mints) != 0 {
|
||||
t.Errorf("no mint on zero amount; got %d", len(bk.mints))
|
||||
}
|
||||
}
|
||||
|
||||
// --- OnAcknowledgementPacket: delete-on-first-ack + ERROR-on-second (G-021) --
|
||||
|
||||
// TestOnAckPacketDeletesInflightRecord asserts OnAcknowledgementPacket deletes
|
||||
// the in-flight record on the first ack (replay protection mirroring ibc-go).
|
||||
func TestOnAckPacketDeletesInflightRecord(t *testing.T) {
|
||||
ctx, _, _, k := newSimtestContext(t)
|
||||
im := keeper.NewIBCModule(k)
|
||||
|
||||
k.SetInflight(ctx, keeper.InflightPacket{
|
||||
SourcePort: "transfer.Polygon", SourceChannel: "channel-0",
|
||||
Sequence: 7, Denom: "transfer/channel-0/uatom", Amount: 1000,
|
||||
Sender: "s", Receiver: "r",
|
||||
})
|
||||
packet := newPacket("transfer.Polygon", "channel-0", 7, ics20PacketData(
|
||||
"transfer/channel-0/uatom", "1000", "s", "r"))
|
||||
|
||||
if err := im.OnAcknowledgementPacket(ctx, packet, []byte(`{}`), sdk.AccAddress{}); err != nil {
|
||||
t.Fatalf("first ack should succeed, got: %v", err)
|
||||
}
|
||||
if _, ok := k.GetInflight(ctx, packet.SourcePort, packet.SourceChannel, packet.Sequence); ok {
|
||||
t.Error("in-flight record should be deleted after first ack")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOnAckPacketRejectsSecondAck asserts the SECOND OnAcknowledgementPacket
|
||||
// returns ERROR (G-021 — NOT a silent no-op; the A-513 CVE-class replay pitfall
|
||||
// is closed by failing loudly).
|
||||
func TestOnAckPacketRejectsSecondAck(t *testing.T) {
|
||||
ctx, _, _, k := newSimtestContext(t)
|
||||
im := keeper.NewIBCModule(k)
|
||||
|
||||
k.SetInflight(ctx, keeper.InflightPacket{
|
||||
SourcePort: "transfer.Polygon", SourceChannel: "channel-0", Sequence: 9,
|
||||
})
|
||||
packet := newPacket("transfer.Polygon", "channel-0", 9, ics20PacketData(
|
||||
"transfer/channel-0/uatom", "1000", "s", "r"))
|
||||
_ = im.OnAcknowledgementPacket(ctx, packet, []byte(`{}`), sdk.AccAddress{})
|
||||
|
||||
// Second ack: record is gone → ERROR (G-021).
|
||||
err := im.OnAcknowledgementPacket(ctx, packet, []byte(`{}`), sdk.AccAddress{})
|
||||
if err == nil {
|
||||
t.Fatal("G-021: second OnAcknowledgementPacket must return ERROR, not nil (A-513 replay pitfall)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOnAckPacketNoInflightRecordReturnsError asserts an ack with no prior
|
||||
// in-flight record returns ERROR (the replay signal — G-021).
|
||||
func TestOnAckPacketNoInflightRecordReturnsError(t *testing.T) {
|
||||
ctx, _, _, k := newSimtestContext(t)
|
||||
im := keeper.NewIBCModule(k)
|
||||
|
||||
packet := newPacket("transfer.Polygon", "channel-0", 42, ics20PacketData(
|
||||
"transfer/channel-0/uatom", "1000", "s", "r"))
|
||||
err := im.OnAcknowledgementPacket(ctx, packet, []byte(`{}`), sdk.AccAddress{})
|
||||
if err == nil {
|
||||
t.Error("ack with no in-flight record should return ERROR (G-021 replay signal)")
|
||||
}
|
||||
}
|
||||
|
||||
// --- OnTimeoutPacket: refund exactly once ------------------------------------
|
||||
|
||||
// TestOnTimeoutPacketRefundsOnce asserts OnTimeoutPacket refunds the
|
||||
// source-chain escrow via the BreadKeeper shim exactly once.
|
||||
func TestOnTimeoutPacketRefundsOnce(t *testing.T) {
|
||||
ctx, _, bk, k := newSimtestContext(t)
|
||||
im := keeper.NewIBCModule(k)
|
||||
|
||||
k.SetInflight(ctx, keeper.InflightPacket{
|
||||
SourcePort: "transfer.Polygon", SourceChannel: "channel-0",
|
||||
Sequence: 3, Denom: "transfer/channel-0/uatom", Amount: 750,
|
||||
Sender: "timeout-sender", Receiver: "r", Refunded: false,
|
||||
})
|
||||
packet := newPacket("transfer.Polygon", "channel-0", 3, ics20PacketData(
|
||||
"transfer/channel-0/uatom", "750", "timeout-sender", "r"))
|
||||
|
||||
if err := im.OnTimeoutPacket(ctx, packet, sdk.AccAddress{}); err != nil {
|
||||
t.Fatalf("first timeout should succeed: %v", err)
|
||||
}
|
||||
if len(bk.releases) != 1 {
|
||||
t.Fatalf("expected 1 release on first timeout, got %d", len(bk.releases))
|
||||
}
|
||||
if bk.releases[0].amount != 750 {
|
||||
t.Errorf("release amount = %d, want 750", bk.releases[0].amount)
|
||||
}
|
||||
if bk.releases[0].reachID != "timeout-sender" {
|
||||
t.Errorf("release reach = %q, want timeout-sender", bk.releases[0].reachID)
|
||||
}
|
||||
|
||||
// Second timeout: no-op (Refunded flag guards exactly-once).
|
||||
if err := im.OnTimeoutPacket(ctx, packet, sdk.AccAddress{}); err != nil {
|
||||
t.Fatalf("second timeout should be a no-op (nil), got: %v", err)
|
||||
}
|
||||
if len(bk.releases) != 1 {
|
||||
t.Errorf("second timeout should NOT refund again; got %d releases total", len(bk.releases))
|
||||
}
|
||||
}
|
||||
|
||||
// TestOnTimeoutPacketNoInflightRecordIsNoop asserts a timeout with no
|
||||
// in-flight record is a benign no-op (not an error).
|
||||
func TestOnTimeoutPacketNoInflightRecordIsNoop(t *testing.T) {
|
||||
ctx, _, bk, k := newSimtestContext(t)
|
||||
im := keeper.NewIBCModule(k)
|
||||
|
||||
packet := newPacket("transfer.Polygon", "channel-0", 99, ics20PacketData(
|
||||
"transfer/channel-0/uatom", "1000", "s", "r"))
|
||||
err := im.OnTimeoutPacket(ctx, packet, sdk.AccAddress{})
|
||||
if err != nil {
|
||||
t.Errorf("timeout with no in-flight record should be a no-op (nil); got %v", err)
|
||||
}
|
||||
if len(bk.releases) != 0 {
|
||||
t.Errorf("no release should happen; got %d", len(bk.releases))
|
||||
}
|
||||
}
|
||||
|
||||
// --- BridgeStatus lifecycle (MsgServer) --------------------------------------
|
||||
|
||||
// TestBridgeStatusLifecycle asserts the full BridgeStatus lifecycle:
|
||||
// Pending → Attested → Active → Closed.
|
||||
func TestBridgeStatusLifecycle(t *testing.T) {
|
||||
ctx, wk, _, k := newSimtestContext(t)
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
|
||||
k.SetBridgeRoute(ctx, bridgetypes.BridgeRoute{
|
||||
BridgeID: "bridge-1", L2Chain: "Polygon", Status: bridgetypes.BridgePending,
|
||||
})
|
||||
wk.signedQuorums["quorum-1"] = true
|
||||
|
||||
// Pending → Attested.
|
||||
if _, err := srv.AttestBridgeRoute(ctx, &bridgetypes.MsgAttestBridgeRoute{
|
||||
BridgeID: "bridge-1", WatcherQuorumID: "quorum-1", Signer: "watcher-reach",
|
||||
}); err != nil {
|
||||
t.Fatalf("AttestBridgeRoute: %v", err)
|
||||
}
|
||||
r, _ := k.GetBridgeRoute(ctx, "bridge-1")
|
||||
if r.Status != bridgetypes.BridgeAttested {
|
||||
t.Errorf("after attest, status = %q, want Attested", r.Status)
|
||||
}
|
||||
if r.WatcherQuorumID != "quorum-1" {
|
||||
t.Errorf("watcher quorum id = %q, want quorum-1", r.WatcherQuorumID)
|
||||
}
|
||||
|
||||
// Attested → Active.
|
||||
if _, err := srv.ActivateBridge(ctx, &bridgetypes.MsgActivateBridge{
|
||||
BridgeID: "bridge-1", Signer: "watcher-reach",
|
||||
}); err != nil {
|
||||
t.Fatalf("ActivateBridge: %v", err)
|
||||
}
|
||||
r, _ = k.GetBridgeRoute(ctx, "bridge-1")
|
||||
if r.Status != bridgetypes.BridgeActive {
|
||||
t.Errorf("after activate, status = %q, want Active", r.Status)
|
||||
}
|
||||
|
||||
// Active → Closed.
|
||||
if _, err := srv.CloseBridge(ctx, &bridgetypes.MsgCloseBridge{
|
||||
BridgeID: "bridge-1", Signer: "watcher-reach",
|
||||
}); err != nil {
|
||||
t.Fatalf("CloseBridge: %v", err)
|
||||
}
|
||||
r, _ = k.GetBridgeRoute(ctx, "bridge-1")
|
||||
if r.Status != bridgetypes.BridgeClosed {
|
||||
t.Errorf("after close, status = %q, want Closed", r.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAttestBridgeRouteRejectsBadStatus asserts AttestBridgeRoute rejects a
|
||||
// route that is not Pending.
|
||||
func TestAttestBridgeRouteRejectsBadStatus(t *testing.T) {
|
||||
ctx, wk, _, k := newSimtestContext(t)
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
wk.signedQuorums["quorum-1"] = true
|
||||
|
||||
k.SetBridgeRoute(ctx, bridgetypes.BridgeRoute{
|
||||
BridgeID: "bridge-2", L2Chain: "Base", Status: bridgetypes.BridgeActive,
|
||||
})
|
||||
_, err := srv.AttestBridgeRoute(ctx, &bridgetypes.MsgAttestBridgeRoute{
|
||||
BridgeID: "bridge-2", WatcherQuorumID: "quorum-1", Signer: "watcher-reach",
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("AttestBridgeRoute should reject an Active route (must be Pending)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAttestBridgeRouteRejectsNoQuorum asserts AttestBridgeRoute rejects when
|
||||
// the Watcher quorum did not reach threshold.
|
||||
func TestAttestBridgeRouteRejectsNoQuorum(t *testing.T) {
|
||||
ctx, wk, _, k := newSimtestContext(t)
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
wk.signedQuorums["quorum-1"] = false
|
||||
|
||||
k.SetBridgeRoute(ctx, bridgetypes.BridgeRoute{
|
||||
BridgeID: "bridge-3", L2Chain: "Polygon", Status: bridgetypes.BridgePending,
|
||||
})
|
||||
_, err := srv.AttestBridgeRoute(ctx, &bridgetypes.MsgAttestBridgeRoute{
|
||||
BridgeID: "bridge-3", WatcherQuorumID: "quorum-1", Signer: "watcher-reach",
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("AttestBridgeRoute should reject when Watcher quorum not signed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAttestBridgeRouteRejectsNotFound asserts AttestBridgeRoute rejects a
|
||||
// missing route.
|
||||
func TestAttestBridgeRouteRejectsNotFound(t *testing.T) {
|
||||
ctx, wk, _, k := newSimtestContext(t)
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
wk.signedQuorums["quorum-1"] = true
|
||||
|
||||
_, err := srv.AttestBridgeRoute(ctx, &bridgetypes.MsgAttestBridgeRoute{
|
||||
BridgeID: "missing", WatcherQuorumID: "quorum-1", Signer: "watcher-reach",
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("AttestBridgeRoute should reject a missing route")
|
||||
}
|
||||
}
|
||||
|
||||
// TestActivateBridgeRejectsBadStatus asserts ActivateBridge rejects a route
|
||||
// that is not Attested.
|
||||
func TestActivateBridgeRejectsBadStatus(t *testing.T) {
|
||||
ctx, _, _, k := newSimtestContext(t)
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
|
||||
k.SetBridgeRoute(ctx, bridgetypes.BridgeRoute{
|
||||
BridgeID: "bridge-4", L2Chain: "Polygon", Status: bridgetypes.BridgePending,
|
||||
})
|
||||
_, err := srv.ActivateBridge(ctx, &bridgetypes.MsgActivateBridge{
|
||||
BridgeID: "bridge-4", Signer: "watcher-reach",
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("ActivateBridge should reject a Pending route (must be Attested)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCloseBridgeRejectsBadStatus asserts CloseBridge rejects a route that is
|
||||
// not Active.
|
||||
func TestCloseBridgeRejectsBadStatus(t *testing.T) {
|
||||
ctx, _, _, k := newSimtestContext(t)
|
||||
srv := keeper.NewMsgServerImpl(k)
|
||||
|
||||
k.SetBridgeRoute(ctx, bridgetypes.BridgeRoute{
|
||||
BridgeID: "bridge-5", L2Chain: "Polygon", Status: bridgetypes.BridgeAttested,
|
||||
})
|
||||
_, err := srv.CloseBridge(ctx, &bridgetypes.MsgCloseBridge{
|
||||
BridgeID: "bridge-5", Signer: "watcher-reach",
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("CloseBridge should reject an Attested route (must be Active)")
|
||||
}
|
||||
}
|
||||
|
||||
// --- ValidateBasic (Msg types) -----------------------------------------------
|
||||
|
||||
func TestMsgAttestBridgeRouteValidateBasic(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
msg bridgetypes.MsgAttestBridgeRoute
|
||||
ok bool
|
||||
}{
|
||||
{"valid", bridgetypes.MsgAttestBridgeRoute{"b1", "q1", "s"}, true},
|
||||
{"empty bridge-id", bridgetypes.MsgAttestBridgeRoute{"", "q1", "s"}, false},
|
||||
{"empty quorum-id", bridgetypes.MsgAttestBridgeRoute{"b1", "", "s"}, false},
|
||||
{"empty signer", bridgetypes.MsgAttestBridgeRoute{"b1", "q1", ""}, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
err := c.msg.ValidateBasic()
|
||||
if c.ok && err != nil {
|
||||
t.Errorf("%s: expected ok, got %v", c.name, err)
|
||||
}
|
||||
if !c.ok && err == nil {
|
||||
t.Errorf("%s: expected error, got nil", c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgActivateBridgeValidateBasic(t *testing.T) {
|
||||
if err := (&bridgetypes.MsgActivateBridge{BridgeID: "b1", Signer: "s"}).ValidateBasic(); err != nil {
|
||||
t.Errorf("valid: %v", err)
|
||||
}
|
||||
if err := (&bridgetypes.MsgActivateBridge{BridgeID: "", Signer: "s"}).ValidateBasic(); err == nil {
|
||||
t.Error("empty bridge-id should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgCloseBridgeValidateBasic(t *testing.T) {
|
||||
if err := (&bridgetypes.MsgCloseBridge{BridgeID: "b1", Signer: "s"}).ValidateBasic(); err != nil {
|
||||
t.Errorf("valid: %v", err)
|
||||
}
|
||||
if err := (&bridgetypes.MsgCloseBridge{BridgeID: "b1", Signer: ""}).ValidateBasic(); err == nil {
|
||||
t.Error("empty signer should fail")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMsgGetSigners asserts GetSigners returns the signer reach-id as bytes.
|
||||
func TestMsgGetSigners(t *testing.T) {
|
||||
m := &bridgetypes.MsgAttestBridgeRoute{Signer: "watcher-reach"}
|
||||
addrs := m.GetSigners()
|
||||
if len(addrs) != 1 {
|
||||
t.Fatalf("expected 1 signer, got %d", len(addrs))
|
||||
}
|
||||
if string(addrs[0]) != "watcher-reach" {
|
||||
t.Errorf("signer = %q, want watcher-reach", string(addrs[0]))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Denom trace parser ------------------------------------------------------
|
||||
|
||||
func TestParseDenomTrace(t *testing.T) {
|
||||
cases := []struct {
|
||||
denom string
|
||||
wantPrefix string
|
||||
wantBase string
|
||||
}{
|
||||
{"transfer/channel-0/uatom", "transfer/channel-0", "uatom"},
|
||||
{"transfer/channel-1/wsol", "transfer/channel-1", "wsol"},
|
||||
{"uatom", "", "uatom"},
|
||||
{"", "", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
p, b := keeper.ParseDenomTrace(c.denom)
|
||||
if p != c.wantPrefix || b != c.wantBase {
|
||||
t.Errorf("ParseDenomTrace(%q) = (%q,%q), want (%q,%q)", c.denom, p, b, c.wantPrefix, c.wantBase)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDenomTrace(t *testing.T) {
|
||||
if err := keeper.ValidateDenomTrace("transfer/channel-0/uatom"); err != nil {
|
||||
t.Errorf("valid denom trace: %v", err)
|
||||
}
|
||||
if err := keeper.ValidateDenomTrace("uatom"); err == nil {
|
||||
t.Error("bare denom (no hop prefix) should fail")
|
||||
}
|
||||
if err := keeper.ValidateDenomTrace(""); err == nil {
|
||||
t.Error("empty denom should fail")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Keeper store helpers ----------------------------------------------------
|
||||
|
||||
func TestSetGetBridgeRoute(t *testing.T) {
|
||||
ctx, _, _, k := newSimtestContext(t)
|
||||
r := bridgetypes.BridgeRoute{BridgeID: "b9", L2Chain: "Polygon", Status: bridgetypes.BridgePending}
|
||||
k.SetBridgeRoute(ctx, r)
|
||||
got, ok := k.GetBridgeRoute(ctx, "b9")
|
||||
if !ok {
|
||||
t.Fatal("GetBridgeRoute: not found")
|
||||
}
|
||||
if got.L2Chain != "Polygon" {
|
||||
t.Errorf("L2Chain = %q", got.L2Chain)
|
||||
}
|
||||
if _, ok := k.GetBridgeRoute(ctx, "missing"); ok {
|
||||
t.Error("GetBridgeRoute should return false for missing route")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllBridgeRoutes(t *testing.T) {
|
||||
ctx, _, _, k := newSimtestContext(t)
|
||||
k.SetBridgeRoute(ctx, bridgetypes.BridgeRoute{BridgeID: "b1", Status: bridgetypes.BridgePending})
|
||||
k.SetBridgeRoute(ctx, bridgetypes.BridgeRoute{BridgeID: "b2", Status: bridgetypes.BridgeActive})
|
||||
all := k.AllBridgeRoutes(ctx)
|
||||
if len(all) != 2 {
|
||||
t.Errorf("expected 2 routes, got %d", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardianSetStore(t *testing.T) {
|
||||
ctx, _, _, k := newSimtestContext(t)
|
||||
gs := keeper.GuardianSet{
|
||||
Guardians: []string{"g1", "g2", "g3"}, Threshold: 2,
|
||||
}
|
||||
k.SetGuardianSet(ctx, gs)
|
||||
got, ok := k.GetGuardianSet(ctx)
|
||||
if !ok {
|
||||
t.Fatal("GetGuardianSet: not found")
|
||||
}
|
||||
if got.Threshold != 2 {
|
||||
t.Errorf("threshold = %d, want 2", got.Threshold)
|
||||
}
|
||||
if len(got.Guardians) != 3 {
|
||||
t.Errorf("guardians = %d, want 3", len(got.Guardians))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
storetypes "cosmossdk.io/store/types"
|
||||
"github.com/cosmos/cosmos-sdk/codec"
|
||||
sdk "github.com/cosmos/cosmos-sdk/types"
|
||||
"github.com/cosmos/cosmos-sdk/types/module"
|
||||
|
||||
"github.com/oy/openyield/x/bridge/keeper"
|
||||
"github.com/oy/openyield/x/bridge/types"
|
||||
)
|
||||
|
||||
// module.go holds the bridge module's AppModule + RegisterServices (P1-03-01).
|
||||
//
|
||||
// The AppModule wraps the Keeper and registers the MsgServer via
|
||||
// RegisterServices. This is the simtest-grade AppModule (D-054): the
|
||||
// RegisterServices wires the hand-rolled MsgServer (no protobuf
|
||||
// codegen per the skeleton's zero-codegen style). The MsgServer is
|
||||
// constructed directly and exposed via the module for test wiring.
|
||||
//
|
||||
// The IBCModule (porttypes.IBCModule) is constructed separately by the app
|
||||
// wiring (NewIBCModule wraps the Keeper); the AppModule does not register
|
||||
// the IBC port binding here (that is app-wiring territory, deferred — the
|
||||
// simtest wires the IBCModule directly).
|
||||
|
||||
// ConsensusVersion is the bridge module's consensus version (AppModule).
|
||||
const ConsensusVersion = 1
|
||||
|
||||
// AppModule is the bridge application module (simtest-grade — D-054).
|
||||
type AppModule struct {
|
||||
keeper keeper.Keeper
|
||||
}
|
||||
|
||||
// NewAppModule constructs a new bridge AppModule.
|
||||
func NewAppModule(cdc codec.Codec, storeKey storetypes.StoreKey, wk types.WatcherKeeper, bk types.BreadKeeper) AppModule {
|
||||
k := keeper.NewKeeper(cdc, storeKey, wk, bk)
|
||||
return AppModule{keeper: k}
|
||||
}
|
||||
|
||||
// NewKeeper exposes the keeper for app wiring / IBC module construction.
|
||||
func (am AppModule) NewKeeper() keeper.Keeper { return am.keeper }
|
||||
|
||||
// RegisterServices registers the bridge MsgServer. This is the simtest-grade
|
||||
// wiring: the MsgServer is constructed from the keeper and exposed via the
|
||||
// module's MsgServer method (tests use NewMsgServerImpl directly; the
|
||||
// configurator path is not exercised in simtest per D-054).
|
||||
func (am AppModule) RegisterServices(cfg module.Configurator) {
|
||||
// The hand-rolled MsgServer does not use the protobuf ServiceDesc
|
||||
// registration (no codegen). Tests wire the MsgServer directly via
|
||||
// keeper.NewMsgServerImpl(am.keeper). This no-op reference keeps the
|
||||
// Configurator import stable for future codegen-based wiring.
|
||||
_ = cfg
|
||||
}
|
||||
|
||||
// MsgServer returns the bridge MsgServer for this module's keeper.
|
||||
func (am AppModule) MsgServer() types.MsgServer {
|
||||
return keeper.NewMsgServerImpl(am.keeper)
|
||||
}
|
||||
|
||||
// IBCModule returns the bridge IBCModule for this module's keeper.
|
||||
func (am AppModule) IBCModule() keeper.IBCModule {
|
||||
return keeper.NewIBCModule(am.keeper)
|
||||
}
|
||||
|
||||
// Name returns the module name.
|
||||
func (AppModule) Name() string { return types.ModuleName }
|
||||
|
||||
// ConsensusVersion implements AppModule.ConsensusVersion.
|
||||
func (AppModule) ConsensusVersion() uint64 { return ConsensusVersion }
|
||||
|
||||
// InitGenesis performs genesis initialization for the bridge module.
|
||||
func (am AppModule) InitGenesis(ctx sdk.Context, cdc codec.JSONCodec, data json.RawMessage) {
|
||||
var gs types.GenesisState
|
||||
cdc.MustUnmarshalJSON(data, &gs)
|
||||
for _, r := range gs.Routes {
|
||||
am.keeper.SetBridgeRoute(ctx, r)
|
||||
}
|
||||
}
|
||||
|
||||
// ExportGenesis returns the exported genesis state as raw bytes.
|
||||
func (am AppModule) ExportGenesis(ctx sdk.Context, cdc codec.JSONCodec) json.RawMessage {
|
||||
routes := am.keeper.AllBridgeRoutes(ctx)
|
||||
gs := types.GenesisState{Routes: routes}
|
||||
return cdc.MustMarshalJSON(&gs)
|
||||
}
|
||||
|
||||
// Compile-time assertion: AppModule implements module.AppModule (simtest-grade
|
||||
// — the RegisterServices signature matches the interface; the full
|
||||
// AppModule interface is satisfied by the methods above + the
|
||||
// appmodule.AppModule methods which are not exercised in simtest per D-054).
|
||||
var _ module.HasName = AppModule{}
|
||||
var _ module.HasConsensusVersion = AppModule{}
|
||||
@@ -0,0 +1,58 @@
|
||||
package types
|
||||
|
||||
// expected_keepers.go holds the Go INTERFACES for the cross-module keepers
|
||||
// x/bridge depends on (G-003 firewall — ibc-go expected-keepers convention).
|
||||
//
|
||||
// The bridge handler references x/watcher (Watcher quorum attestation on the
|
||||
// Pending→Attested transition) and x/bread (mint/release wrapped Bread on
|
||||
// IBC packet recv/timeout). Both dependencies are expressed as INTERFACES
|
||||
// defined HERE (in x/bridge/types), NOT as struct imports of x/watcher/types
|
||||
// or x/bread/types. The concrete keepers satisfy these interfaces
|
||||
// structurally; the handler depends on the interface, preserving G-003's
|
||||
// intent (no cross-module struct coupling, no import cycles).
|
||||
//
|
||||
// Test-only cross-package imports (the G-003 test exemption) remain exempt: a
|
||||
// simtest may import both x/bridge/keeper and x/watcher/keeper (or x/bread)
|
||||
// to wire the expected-keeper shim in a test setup.
|
||||
|
||||
// WatcherKeeper is the expected-keeper interface for x/watcher (G-003).
|
||||
// The bridge handler calls it for:
|
||||
// - the Pending→Attested transition: a Watcher 6-of-9 quorum must attest
|
||||
// the route (vision §7, REQ-004). The handler consults the watcher
|
||||
// quorum by ID-string; the interface method reports whether the quorum
|
||||
// reached its threshold on the payload.
|
||||
// - the Solana wormhole-adapter branch: the guardian sig set (a 2-of-N
|
||||
// quorum, N = the wormhole guardian set) is verified via the same
|
||||
// IsQuorumSigned interface.
|
||||
//
|
||||
// No struct import of x/watcher/types — the interface is the by-ID-string
|
||||
// boundary (G-003).
|
||||
type WatcherKeeper interface {
|
||||
// IsQuorumSigned reports whether the named quorum (by-ID-string) reached
|
||||
// its threshold signature count on the payload. Used for both the
|
||||
// bridge-route Watcher attestation and the Solana guardian sig set.
|
||||
IsQuorumSigned(quorumID string, payload []byte) bool
|
||||
}
|
||||
|
||||
// BreadKeeper is the expected-keeper interface for x/bread (G-003).
|
||||
// The bridge handler calls it for:
|
||||
// - OnRecvPacket: mint wrapped Bread on the receiving chain when an ICS-20
|
||||
// v1 packet arrives (mint by denom-string + amount).
|
||||
// - OnTimeoutPacket: release (refund) the escrowed Bread exactly once
|
||||
// when a packet times out (release by denom-string + amount).
|
||||
//
|
||||
// The wrapped Bread denom is a by-ID-string (the denom trace). No struct
|
||||
// import of x/bread/types — the interface is the by-ID-string boundary
|
||||
// (G-003).
|
||||
type BreadKeeper interface {
|
||||
// MintWrappedBread mints wrapped Bread on the receiving chain for an
|
||||
// ICS-20 v1 packet recv. denom is the denom trace string; amount is the
|
||||
// grain amount to mint; holderReach is the receiver reach-id.
|
||||
MintWrappedBread(ctx interface{}, denom string, amount int64, holderReach string) error
|
||||
|
||||
// ReleaseWrappedBread releases (refunds) the escrowed Bread exactly once
|
||||
// on a packet timeout. denom is the denom trace string; amount is the
|
||||
// grain amount to release; holderReach is the sender reach-id (the
|
||||
// source-chain escrow owner).
|
||||
ReleaseWrappedBread(ctx interface{}, denom string, amount int64, holderReach string) error
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
sdk "github.com/cosmos/cosmos-sdk/types"
|
||||
)
|
||||
|
||||
// msg_bridge.go holds the bridge module's Msg* types implementing sdk.Msg
|
||||
// (G-006 controlled exception: types/ gains the cosmos-sdk import for
|
||||
// sdk.Msg). Each Msg carries a ValidateBasic (stateless) and GetSigners.
|
||||
//
|
||||
// The three bridge Msg types drive the BridgeStatus lifecycle:
|
||||
// - MsgAttestBridgeRoute: Pending → Attested (Watcher quorum-driven; the
|
||||
// handler consults the WatcherKeeper expected-keeper shim with the
|
||||
// watcher-quorum-id).
|
||||
// - MsgActivateBridge: Attested → Active (route opens for transfers).
|
||||
// - MsgCloseBridge: Active → Closed (route retired).
|
||||
//
|
||||
// All cross-module refs are by-ID-string (G-003): bridge-id is this route's
|
||||
// ID; watcher-quorum-id references an x/watcher quorum by ID-string (no
|
||||
// struct import). GetSigners returns the signer reach-ids encoded as
|
||||
// sdk.AccAddress bytes.
|
||||
|
||||
// --- MsgAttestBridgeRoute -----------------------------------------------------
|
||||
|
||||
// MsgAttestBridgeRoute transitions a bridge route Pending → Attested. A
|
||||
// Watcher 6-of-9 quorum (vision §7, REQ-004) must sign the payload; the
|
||||
// handler consults the WatcherKeeper expected-keeper shim (by-ID-string on
|
||||
// the watcher-quorum-id). ValidateBasic is stateless: non-empty bridge-id
|
||||
// and watcher-quorum-id; the current status must be Pending (the only valid
|
||||
// source state for the Attested transition target).
|
||||
type MsgAttestBridgeRoute struct {
|
||||
BridgeID string `json:"bridge_id" yaml:"bridge_id"`
|
||||
WatcherQuorumID string `json:"watcher_quorum_id" yaml:"watcher_quorum_id"`
|
||||
Signer string `json:"signer" yaml:"signer"` // signer reach-id (by-ID-string)
|
||||
}
|
||||
|
||||
// Reset implements proto.Message (sdk.Msg = proto.Message).
|
||||
func (m *MsgAttestBridgeRoute) Reset() { *m = MsgAttestBridgeRoute{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgAttestBridgeRoute) String() string {
|
||||
return fmt.Sprintf("MsgAttestBridgeRoute{BridgeID:%s WatcherQuorumID:%s Signer:%s}",
|
||||
m.BridgeID, m.WatcherQuorumID, m.Signer)
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgAttestBridgeRoute) ProtoMessage() {}
|
||||
|
||||
// ValidateBasic is the stateless validation: non-empty bridge-id, non-empty
|
||||
// watcher-quorum-id, non-empty signer. The status transition target
|
||||
// (Pending → Attested) is enforced at the handler (stateful — the handler
|
||||
// loads the route and checks status == Pending).
|
||||
func (m *MsgAttestBridgeRoute) ValidateBasic() error {
|
||||
if m.BridgeID == "" {
|
||||
return fmt.Errorf("bridge: empty bridge-id")
|
||||
}
|
||||
if m.WatcherQuorumID == "" {
|
||||
return fmt.Errorf("bridge: empty watcher-quorum-id")
|
||||
}
|
||||
if m.Signer == "" {
|
||||
return fmt.Errorf("bridge: empty signer")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes. The
|
||||
// reach-id is the by-ID-string user identifier (G-003 — no banned
|
||||
// financial-holder lexicon; use Holder/Reach).
|
||||
func (m *MsgAttestBridgeRoute) GetSigners() []sdk.AccAddress {
|
||||
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||
}
|
||||
|
||||
// --- MsgActivateBridge --------------------------------------------------------
|
||||
|
||||
// MsgActivateBridge transitions a bridge route Attested → Active. The route
|
||||
// must already be Attested (Watcher quorum confirmed); the handler enforces
|
||||
// the stateful source-status check. ValidateBasic is stateless: non-empty
|
||||
// bridge-id and signer.
|
||||
type MsgActivateBridge struct {
|
||||
BridgeID string `json:"bridge_id" yaml:"bridge_id"`
|
||||
Signer string `json:"signer" yaml:"signer"`
|
||||
}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgActivateBridge) Reset() { *m = MsgActivateBridge{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgActivateBridge) String() string {
|
||||
return fmt.Sprintf("MsgActivateBridge{BridgeID:%s Signer:%s}", m.BridgeID, m.Signer)
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgActivateBridge) ProtoMessage() {}
|
||||
|
||||
// ValidateBasic is the stateless validation: non-empty bridge-id and signer.
|
||||
func (m *MsgActivateBridge) ValidateBasic() error {
|
||||
if m.BridgeID == "" {
|
||||
return fmt.Errorf("bridge: empty bridge-id")
|
||||
}
|
||||
if m.Signer == "" {
|
||||
return fmt.Errorf("bridge: empty signer")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||
func (m *MsgActivateBridge) GetSigners() []sdk.AccAddress {
|
||||
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||
}
|
||||
|
||||
// --- MsgCloseBridge -----------------------------------------------------------
|
||||
|
||||
// MsgCloseBridge transitions a bridge route Active → Closed (retire the
|
||||
// route). The handler enforces the stateful source-status check (status ==
|
||||
// Active). ValidateBasic is stateless: non-empty bridge-id and signer.
|
||||
type MsgCloseBridge struct {
|
||||
BridgeID string `json:"bridge_id" yaml:"bridge_id"`
|
||||
Signer string `json:"signer" yaml:"signer"`
|
||||
}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgCloseBridge) Reset() { *m = MsgCloseBridge{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgCloseBridge) String() string {
|
||||
return fmt.Sprintf("MsgCloseBridge{BridgeID:%s Signer:%s}", m.BridgeID, m.Signer)
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgCloseBridge) ProtoMessage() {}
|
||||
|
||||
// ValidateBasic is the stateless validation: non-empty bridge-id and signer.
|
||||
func (m *MsgCloseBridge) ValidateBasic() error {
|
||||
if m.BridgeID == "" {
|
||||
return fmt.Errorf("bridge: empty bridge-id")
|
||||
}
|
||||
if m.Signer == "" {
|
||||
return fmt.Errorf("bridge: empty signer")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
|
||||
func (m *MsgCloseBridge) GetSigners() []sdk.AccAddress {
|
||||
return []sdk.AccAddress{[]byte(m.Signer)}
|
||||
}
|
||||
|
||||
// MsgServer is the bridge module's message server interface (one method per
|
||||
// Msg*). The keeper's msg_server.go implements this; module.go's
|
||||
// RegisterServices wires the implementation. This is the hand-rolled
|
||||
// equivalent of the protobuf-generated MsgServer interface (no codegen per
|
||||
// the skeleton's zero-codegen style).
|
||||
type MsgServer interface {
|
||||
AttestBridgeRoute(ctx interface{}, msg *MsgAttestBridgeRoute) (*MsgAttestBridgeRouteResponse, error)
|
||||
ActivateBridge(ctx interface{}, msg *MsgActivateBridge) (*MsgActivateBridgeResponse, error)
|
||||
CloseBridge(ctx interface{}, msg *MsgCloseBridge) (*MsgCloseBridgeResponse, error)
|
||||
}
|
||||
|
||||
// Response types (hand-rolled equivalents of the protobuf-generated response
|
||||
// wrappers; empty bodies — the response is the state mutation + event).
|
||||
|
||||
// MsgAttestBridgeRouteResponse is the response to MsgAttestBridgeRoute.
|
||||
type MsgAttestBridgeRouteResponse struct{}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgAttestBridgeRouteResponse) Reset() { *m = MsgAttestBridgeRouteResponse{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgAttestBridgeRouteResponse) String() string { return "MsgAttestBridgeRouteResponse{}" }
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgAttestBridgeRouteResponse) ProtoMessage() {}
|
||||
|
||||
// MsgActivateBridgeResponse is the response to MsgActivateBridge.
|
||||
type MsgActivateBridgeResponse struct{}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgActivateBridgeResponse) Reset() { *m = MsgActivateBridgeResponse{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgActivateBridgeResponse) String() string { return "MsgActivateBridgeResponse{}" }
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgActivateBridgeResponse) ProtoMessage() {}
|
||||
|
||||
// MsgCloseBridgeResponse is the response to MsgCloseBridge.
|
||||
type MsgCloseBridgeResponse struct{}
|
||||
|
||||
// Reset implements proto.Message.
|
||||
func (m *MsgCloseBridgeResponse) Reset() { *m = MsgCloseBridgeResponse{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *MsgCloseBridgeResponse) String() string { return "MsgCloseBridgeResponse{}" }
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*MsgCloseBridgeResponse) ProtoMessage() {}
|
||||
@@ -89,6 +89,20 @@ func DefaultGenesisState() *GenesisState {
|
||||
}
|
||||
}
|
||||
|
||||
// Reset implements proto.Message (codec.JSONCodec.MustMarshalJSON /
|
||||
// MustUnmarshalJSON require proto.Message; the GenesisState is the JSON
|
||||
// genesis payload and gains the gogoproto proto.Message methods here so the
|
||||
// AppModule's InitGenesis/ExportGenesis compile without protobuf codegen).
|
||||
func (m *GenesisState) Reset() { *m = GenesisState{} }
|
||||
|
||||
// String implements proto.Message.
|
||||
func (m *GenesisState) String() string {
|
||||
return fmt.Sprintf("GenesisState{Routes:%d}", len(m.Routes))
|
||||
}
|
||||
|
||||
// ProtoMessage implements proto.Message.
|
||||
func (*GenesisState) ProtoMessage() {}
|
||||
|
||||
// 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).
|
||||
|
||||
Reference in New Issue
Block a user