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/`. 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/` (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/`). 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.". 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