Skip to content

do-not-merge Prototype ift tokenfactory#8927

Draft
dianab-cl wants to merge 12 commits intomainfrom
prototype-ift-tokenfactory
Draft

do-not-merge Prototype ift tokenfactory#8927
dianab-cl wants to merge 12 commits intomainfrom
prototype-ift-tokenfactory

Conversation

@dianab-cl
Copy link
Copy Markdown
Contributor

Description

closes: #XXXX


Before we can merge this PR, please make sure that all the following items have been
checked off. If any of the checklist items are not applicable, please leave them but
write a little note why.

  • Linked to GitHub issue with discussion and accepted design, OR link to spec that describes this work.
  • Include changelog entry when appropriate (e.g. chores should be omitted from changelog).
  • Wrote unit and integration tests if relevant.
  • Updated documentation (docs/) if anything is changed.
  • Added godoc comments if relevant.
  • Self-reviewed Files changed in the GitHub PR explorer.
  • Provide a conventional commit message to follow the repository standards.

@dianab-cl dianab-cl requested a review from a team as a code owner May 6, 2026 12:05
@codecov
Copy link
Copy Markdown

codecov Bot commented May 6, 2026

Codecov Report

❌ Patch coverage is 15.58989% with 1202 lines in your changes missing coverage. Please review.
✅ Project coverage is 62.61%. Comparing base (c8f106c) to head (b007672).

Files with missing lines Patch % Lines
modules/apps/prototypes/ift/keeper/msg_server.go 0.00% 238 Missing ⚠️
modules/apps/prototypes/ift/keeper/callbacks.go 0.00% 118 Missing ⚠️
...dules/apps/prototypes/tokenfactory/keeper/denom.go 0.00% 84 Missing ⚠️
modules/apps/prototypes/ift/types/autocli.go 0.00% 81 Missing ⚠️
...s/apps/prototypes/tokenfactory/keeper/mint_burn.go 0.00% 77 Missing ⚠️
...ules/apps/prototypes/tokenfactory/types/autocli.go 0.00% 60 Missing ⚠️
modules/apps/prototypes/tokenfactory/cli/tx.go 0.00% 54 Missing ⚠️
modules/apps/prototypes/ift/keeper/keeper.go 0.00% 50 Missing ⚠️
modules/apps/prototypes/tokenfactory/types/msgs.go 0.00% 50 Missing ⚠️
.../apps/prototypes/tokenfactory/keeper/msg_server.go 0.00% 45 Missing ⚠️
... and 18 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #8927      +/-   ##
==========================================
- Coverage   66.52%   62.61%   -3.91%     
==========================================
  Files         327      357      +30     
  Lines       17143    18567    +1424     
==========================================
+ Hits        11404    11626     +222     
- Misses       5048     6245    +1197     
- Partials      691      696       +5     
Flag Coverage Δ
08-wasm 65.04% <ø> (ø)
ibc-go 62.54% <15.58%> (-4.04%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 6, 2026

Greptile Summary

This PR introduces two new prototype modules: a TokenFactory (mint/burn/admin lifecycle for custom denoms) and an IFT (Inter-chain Fungible Token) bridge that burns tokens on the source chain, sends a GMP cross-chain call to mint them on the destination, and handles ack/timeout refunds via IBC callbacks.

  • IFT bridge flow (ift/keeper/): registers bridges per (denom, clientID), burns on transfer, mints on receipt via ICS27/GMP, and stores pending transfers for refund on failure.
  • TokenFactory (tokenfactory/keeper/): provides CreateDenom, Mint, Burn, ChangeAdmin, and RenounceAdmin with per-denom authority metadata stored in a collections.Map.
  • Three constructor types are supported for the counterparty chain: EVM, CosmosSDK (CosmosTx), and Solana (Borsh-encoded Anchor instructions with on-chain PDA derivation).

Confidence Score: 2/5

The IFT bridge's refund mechanism is broken: ack and timeout callbacks look up pending transfers by channel ID while the store is keyed by client ID, so every failed cross-chain transfer permanently destroys the sender's tokens.

Three independent defects affect the correctness of the core token bridge flow. The callback key mismatch (SourceChannel vs clientID) means the refund path is entirely inoperative. The burn-before-sequence-persist ordering means a GMP response parse failure can result in an unrecoverable burn. The genesis validation bug blocks upgrades on any chain that has renounced at least one denom admin.

callbacks.go (both ack and timeout callback), msg_server.go (IFTTransfer ordering), and tokenfactory/types/genesis.go (ValidateGenesis admin check) need fixes before this is production-ready.

Important Files Changed

Filename Overview
modules/apps/prototypes/ift/keeper/callbacks.go IBC ack and timeout callbacks look up pending transfers by packet.SourceChannel (channel ID) but the store is written with a client ID, so refunds will never execute.
modules/apps/prototypes/ift/keeper/msg_server.go Tokens are burned before the GMP sequence is obtained and persisted; a mid-flow error leaves the burn irreversible within the same state transition.
modules/apps/prototypes/tokenfactory/types/genesis.go ValidateGenesis calls AccAddressFromBech32 on the admin field unconditionally, rejecting valid renounced-admin denoms (empty string) and breaking genesis round-trips.
modules/apps/prototypes/tokenfactory/keeper/mint_burn.go Public MintTo/BurnFrom skip admin authorization; design is intentional for IFT but makes the interface unsafe for future callers.
modules/apps/prototypes/ift/types/constructor_solana.go Well-structured Solana PDA derivation; error from PublicKeyFromBase58 is silently discarded on line 94 (guarded by a prior validation call, but still fragile).
modules/apps/prototypes/tokenfactory/keeper/denom.go Denom lifecycle (create, change admin, renounce) is correctly implemented with proper authorization checks.
modules/apps/prototypes/ift/keeper/keeper.go Keeper setup, collection definitions, and helper methods (Set/Remove/Has for pending transfers) are straightforward and correct.

Sequence Diagram

sequenceDiagram
    participant User
    participant IFTKeeper
    participant TokenFactory
    participant GMP
    participant IBC
    participant Callbacks

    User->>IFTKeeper: MsgIFTTransfer(denom, clientID, receiver, amount)
    IFTKeeper->>TokenFactory: BurnFrom(denom, amount, sender)
    IFTKeeper->>GMP: MsgSendCall(payload, clientID, timeout)
    GMP->>IBC: send_packet(sequence)
    IBC-->>GMP: sequence
    GMP-->>IFTKeeper: MsgSendCallResponse{sequence}
    IFTKeeper->>IFTKeeper: SetPendingTransfer(clientID, sequence)

    Note over IBC,Callbacks: On ACK / Timeout
    IBC->>Callbacks: IBCOnAcknowledgementPacketCallback(packet)
    Callbacks->>IFTKeeper: GetPendingTransfer(packet.SourceChannel, sequence)
    Note right of Callbacks: ⚠ SourceChannel ≠ clientID → always not found

    alt Success ACK
        IFTKeeper->>IFTKeeper: RemovePendingTransfer
    else Error ACK or Timeout
        IFTKeeper->>TokenFactory: MintTo(denom, amount, sender) [REFUND]
    end

    Note over User,IBC: Incoming mint path
    User->>IFTKeeper: MsgIFTMint(denom, receiver, amount)
    IFTKeeper->>IFTKeeper: Verify ICS27 account + bridge match
    IFTKeeper->>TokenFactory: MintTo(denom, amount, receiver)
Loading

Reviews (1): Last reviewed commit: "Merge branch 'main' into prototype-ift-t..." | Re-trigger Greptile

Comment on lines +79 to +89
pending, found, err := k.GetPendingTransferByClientSequence(cachedCtx, packet.SourceChannel, packet.Sequence)
if err != nil {
return err
}

if !found {
k.Logger(cachedCtx).Debug("IFT ack callback: skipping non-IFT packet",
"clientId", packet.SourceChannel,
"sequence", packet.Sequence)
return nil
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 packet.SourceChannel vs msg.ClientId key mismatch breaks refunds

PendingTransferStore is written in IFTTransfer with key (msg.ClientId, sequence) — a client ID such as "07-tendermint-0". The ack and timeout callbacks look up the same store using (packet.SourceChannel, sequence) — which in IBC v1 packet semantics is a channel ID such as "channel-0". These identifiers are distinct, so GetPendingTransferByClientSequence will always return found=false, the callbacks will silently log "skipping non-IFT packet", and every failed or timed-out transfer will permanently destroy the sender's tokens instead of refunding them.

Comment on lines +158 to +168
pending, found, err := k.GetPendingTransferByClientSequence(cachedCtx, packet.SourceChannel, packet.Sequence)
if err != nil {
return err
}

if !found {
k.Logger(cachedCtx).Debug("IFT timeout callback: skipping non-IFT packet",
"clientId", packet.SourceChannel,
"sequence", packet.Sequence)
return nil
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Same packet.SourceChannel vs client ID mismatch in timeout callback

The timeout callback has the identical key mismatch as the ack callback (line 79): it looks up the pending transfer by packet.SourceChannel (channel ID) instead of the client ID used when writing the record. The end result is that timed-out transfers are never refunded.

Comment on lines +31 to +33
if _, err := sdk.AccAddressFromBech32(denom.AuthorityMetadata.Admin); err != nil {
return err
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 ValidateGenesis rejects valid renounced-admin denoms

RenounceAdmin sets AuthorityMetadata.Admin to "". However, ValidateGenesis calls sdk.AccAddressFromBech32(denom.AuthorityMetadata.Admin) unconditionally, and AccAddressFromBech32("") returns an error. Any chain state containing a renounced-admin denom will fail genesis export/import, making upgrades impossible once any admin is renounced. The check should be skipped when Admin is empty.

Suggested change
if _, err := sdk.AccAddressFromBech32(denom.AuthorityMetadata.Admin); err != nil {
return err
}
if denom.AuthorityMetadata.Admin != "" {
if _, err := sdk.AccAddressFromBech32(denom.AuthorityMetadata.Admin); err != nil {
return err
}
}

Comment on lines +195 to +293
if err := m.k.tokenFactoryKeeper.BurnFrom(ctx, msg.Denom, msg.Amount, sender); err != nil {
return nil, errorsmod.Wrapf(types.ErrBurnFailed, "failed to burn tokens: %s", err)
}

counterpartyInfo, found := m.k.ibcClientV2Keeper.GetClientCounterparty(ctx, msg.ClientId)
if !found {
return nil, errorsmod.Wrapf(types.ErrInvalidClientID, "counterparty for client %s not found", msg.ClientId)
}

// Construct mint call payload based on constructor type
var payload []byte
var encoding string
constructorType := types.ParseConstructorType(bridge.IftSendCallConstructor)

switch constructorType {
case types.ConstructorSolana:
encoding = gmptypes.EncodingProtobuf
constructor, err := types.NewSolanaConstructor(bridge.IftSendCallConstructor, bridge.CounterpartyIftAddress, m.k.GetModuleAddress().String(), counterpartyInfo.ClientId)
if err != nil {
return nil, errorsmod.Wrapf(types.ErrConstructMintCallFailed, "failed to build solana constructor: %s", err)
}
receiver := strings.TrimSpace(msg.Receiver)
payload, err = constructor.ConstructMintCall(m.k.cdc, receiver, msg.Amount, "", "")
if err != nil {
return nil, errorsmod.Wrapf(types.ErrConstructMintCallFailed, "failed to construct mint call: %s", err)
}

case types.ConstructorEVM:
var err error
payload, err = types.ConstructMintCall(m.k.cdc, msg.Receiver, msg.Amount, bridge.IftSendCallConstructor, "", "")
if err != nil {
return nil, errorsmod.Wrapf(types.ErrConstructMintCallFailed, "failed to construct mint call: %s", err)
}

case types.ConstructorCosmos:
// For CosmosTx, we need to know the ICA address that will execute the message
accountID := gmptypes.NewAccountIdentifier(msg.ClientId, m.k.GetModuleAddress().String(), nil)
icaAddr, err := gmptypes.BuildAddressPredictable(&accountID)
if err != nil {
return nil, errorsmod.Wrapf(types.ErrConstructMintCallFailed, "failed to compute ICA address: %s", err)
}
icaAddress := icaAddr.String()
payload, err = types.ConstructMintCall(m.k.cdc, msg.Receiver, msg.Amount, bridge.IftSendCallConstructor, msg.Denom, icaAddress)
if err != nil {
return nil, errorsmod.Wrapf(types.ErrConstructMintCallFailed, "failed to construct mint call: %s", err)
}

default:
return nil, errorsmod.Wrapf(types.ErrInvalidConstructorType, "unknown constructor type: %s", constructorType)
}

// Send via ICS27-GMP
sendMsg := &gmptypes.MsgSendCall{
Sender: m.k.GetModuleAddress().String(),
SourceClient: bridge.ClientId,
Receiver: bridge.CounterpartyIftAddress,
Salt: nil,
Payload: payload,
TimeoutTimestamp: msg.TimeoutTimestamp,
Memo: "",
Encoding: encoding,
}

handler := m.k.msgRouter.Handler(sendMsg)
if handler == nil {
return nil, errorsmod.Wrap(types.ErrSendCallFailed, "no handler for MsgSendCall")
}

res, err := handler(ctx, sendMsg)
if err != nil {
return nil, errorsmod.Wrapf(types.ErrSendCallFailed, "failed to send GMP call: %s", err)
}

// Propagate events from the GMP handler (critical for relayer to detect send_packet)
ctx.EventManager().EmitEvents(res.GetEvents())

// Extract sequence from response - fail if we cannot get the sequence
// since callbacks need it to match pending transfers
if len(res.MsgResponses) == 0 {
return nil, errorsmod.Wrap(types.ErrSendCallFailed, "no response from GMP send call")
}
var sendResp gmptypes.MsgSendCallResponse
if err := sendResp.Unmarshal(res.MsgResponses[0].Value); err != nil {
return nil, errorsmod.Wrapf(types.ErrSendCallFailed, "failed to unmarshal GMP response: %s", err)
}
sequence := sendResp.Sequence

// Store pending transfer
pending := types.PendingTransfer{
Denom: msg.Denom,
ClientId: msg.ClientId,
Sequence: sequence,
Sender: msg.Signer,
Amount: msg.Amount,
}

if err := m.k.SetPendingTransfer(ctx, msg.ClientId, sequence, pending); err != nil {
return nil, err
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Burn-before-send leaves tokens destroyed if GMP send fails mid-flow

IFTTransfer burns the caller's tokens (line 195) before dispatching the GMP MsgSendCall (line 263). If the handler dispatch succeeds but sendResp.Unmarshal fails (line 278), or if SetPendingTransfer returns an error (line 291), the function returns an error to the caller — but the tokens are already burned. The Cosmos SDK's transaction-level revert would undo the burn if the entire tx fails, but if the error originates inside the same tx (e.g. the handler writes state before the unmarshal fails) the revert semantics depend on whether a CacheContext was used. The operations should be reordered so the burn only occurs after the sequence is successfully obtained and persisted.

Comment on lines +16 to +61
func (k Keeper) MintTo(ctx context.Context, denom string, amount math.Int, to sdk.AccAddress) error {
coin := sdk.NewCoin(denom, amount)
if err := k.bankKeeper.MintCoins(ctx, types.ModuleName, sdk.NewCoins(coin)); err != nil {
return err
}

if err := k.bankKeeper.SendCoinsFromModuleToAccount(ctx, types.ModuleName, to, sdk.NewCoins(coin)); err != nil {
return err
}

sdkCtx := sdk.UnwrapSDKContext(ctx)
sdkCtx.EventManager().EmitEvent(
sdk.NewEvent(
types.TypeEvtMint,
sdk.NewAttribute(types.AttributeKeyDenom, denom),
sdk.NewAttribute(types.AttributeKeyAmount, amount.String()),
sdk.NewAttribute(types.AttributeKeyMintTo, to.String()),
),
)

return nil
}

// BurnFrom burns tokens of denom from address.
// MUST fail if address does not have enough balance or burn is not permitted.
func (k Keeper) BurnFrom(ctx context.Context, denom string, amount math.Int, from sdk.AccAddress) error {
coin := sdk.NewCoin(denom, amount)
if err := k.bankKeeper.SendCoinsFromAccountToModule(ctx, from, types.ModuleName, sdk.NewCoins(coin)); err != nil {
return err
}

if err := k.bankKeeper.BurnCoins(ctx, types.ModuleName, sdk.NewCoins(coin)); err != nil {
return err
}

sdkCtx := sdk.UnwrapSDKContext(ctx)
sdkCtx.EventManager().EmitEvent(
sdk.NewEvent(
types.TypeEvtBurn,
sdk.NewAttribute(types.AttributeKeyDenom, denom),
sdk.NewAttribute(types.AttributeKeyAmount, amount.String()),
sdk.NewAttribute(types.AttributeKeyBurnFrom, from.String()),
),
)

return nil
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Public MintTo and BurnFrom bypass admin authorization

The private mintToWithAdmin and burnFromWithAdmin helpers both call validateMintBurnPermission before touching the bank module. The public MintTo (line 16) and BurnFrom (line 41) exposed via the TokenFactoryKeeper interface skip that check entirely. Any module wired to this keeper can mint or burn an arbitrary registered denom without being its admin. This is intentional for the IFT bridge flow today, but as more modules adopt the same interface the authorization model becomes implicit and easy to violate in future integrations.

return nil, err
}

receiverPubkey, _ := solana.PublicKeyFromBase58(receiver)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Ignored error from solana.PublicKeyFromBase58 at line 94

receiverPubkey, _ := solana.PublicKeyFromBase58(receiver) silently discards the error. The ValidateSolanaAddress(receiver) call two lines above would already have caught an invalid address, but the blank identifier here is a fragility: if the validation logic is ever relaxed or the code is reordered, a zero public key will be used without warning. Prefer capturing and returning the error for correctness.

Comment on lines +44 to +46
for key := range wrapper {
return key
}
@dianab-cl dianab-cl changed the title Prototype ift tokenfactory do-not-merge Prototype ift tokenfactory May 6, 2026
@dianab-cl dianab-cl marked this pull request as draft May 6, 2026 15:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants