6c34650a0d
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---
394 lines
14 KiB
Go
394 lines
14 KiB
Go
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
|