Status DataClose notification

ZIGChain Disclosed Report

Denom-trace mismatch enables voucher-to-native refund on timeout

Company
Created date
Jan 02 2026

Target

hidden

Vulnerability Details

Summary

Timeout refunds in the Tokenwrapper module validate only the base denom and do not confirm the denom trace matches the configured IBC path, allowing vouchers from other paths to be treated as eligible for native refunds. This behavior can convert unrelated IBC vouchers into native uzig during OnTimeoutPacket(), which may reduce the module wallet balance used for legitimate refunds. Severity is assessed as Critical because it enables loss of module-held funds with moderate prerequisites.

Finding Description

OnTimeoutPacket() validates the channel and packet data, checks that IBC settings exist, and then validates the denom by comparing only the base denom to the module denom. The denom trace path is not validated before calling handleRefund(), which uses the provided denom to derive an IBC denom and swaps vouchers for native tokens. This creates a gap where any voucher whose base denom is uzig can be refunded to native tokens, even if its trace path does not match the configured Axelar channel path.

Technical observations that lead to the behavior are as follows:

  • OnTimeoutPacket() extracts the base denom with transfertypes.ExtractDenomFromPath() and validates it using validateIBCDenomIsModuleDenom(), which compares only the base denom and ignores the path in x/tokenwrapper/module/on_timeout_packet.go:71 and x/tokenwrapper/module/validators.go:195.
  • validateIBCSettingsMatchOnSendPacket() confirms only packet ports and channels and does not validate the denom trace path in x/tokenwrapper/module/validators.go:224.
  • SendPacket() constructs the expected denom trace as transfer/<nativeChannel>/<moduleDenom> and then replaces the packet denom with this prefixed value in x/tokenwrapper/module/send_packet.go:165 and x/tokenwrapper/module/send_packet.go:200.
  • handleRefund() derives the IBC denom from the provided denom and performs the voucher lock and native unlock without checking the denom path in x/tokenwrapper/module/handlers.go:31.

Attack path in detail:

  1. Obtain an IBC voucher on ZIGChain whose base denom is uzig but whose denom trace path is not the configured Axelar path, such as transfer/channel-99/uzig.
  2. Send that voucher over the configured Axelar channel with a short timeout so it will trigger OnTimeoutPacket().
  3. OnTimeoutPacket() passes the base denom check in validateIBCDenomIsModuleDenom() and the channel check in validateIBCSettingsMatchOnSendPacket() because both rely on ports/channels and base denom only.
  4. handleRefund() locks the unrelated voucher and unlocks native uzig to the sender, drawing from the module wallet.

Impact Explanation

Successful exploitation allows conversion of unrelated IBC vouchers into native uzig held by the module wallet, reducing reserves that are intended to cover legitimate refunds. If the module wallet is sufficiently funded, this can lead to substantial loss of protocol funds and disrupts the integrity of the wrapper’s accounting expectations.

Likelihood Explanation

Exploitation requires access to IBC vouchers whose base denom matches the module denom and the ability to route them through the configured channel with a timeout. This is feasible in typical IBC environments where denom collisions are possible across chains or where a user can acquire uzig vouchers from another path.

Recommendation

Consider validating the full denom trace path before refunding in OnTimeoutPacket(). One approach could be to compare both the base denom and the expected denom path derived from the configured native port/channel before calling handleRefund(). The following diff illustrates a minimal guard:

-	baseDenom := transfertypes.ExtractDenomFromPath(data.Denom).Base
-	if !im.validateIBCDenomIsModuleDenom(ctx, baseDenom) {
-		info := fmt.Sprintf("packet denom is not the module denom, skipping refunding: %s", baseDenom)
+	denomTrace := transfertypes.ExtractDenomFromPath(data.Denom)
+	expectedPrefix := transfertypes.NewHop(im.keeper.GetNativePort(ctx), im.keeper.GetNativeChannel(ctx)).String()
+	// Ensure denom trace matches the configured path before refunding.
+	if denomTrace.Base != im.keeper.GetDenom(ctx) || denomTrace.Path != expectedPrefix {
+		info := fmt.Sprintf("packet denom trace mismatch, skipping refunding: %s", data.Denom)
 		types.EmitTokenWrapperInfoEvent(ctx, info)
 		im.keeper.Logger().Info(info)
 		return im.app.OnTimeoutPacket(ctx, channelVersion, packet, relayer)
 	}

Consider applying the same trace validation in OnAcknowledgementPacket() to keep refund behavior consistent across failure paths.

Validation steps

Paste in ``

func TestOnTimeoutPacket_RejectsMismatchedDenomTrace(t *testing.T) {
	// Test case: OnTimeoutPacket should not refund when denom trace doesn't match module path

	fixture := getTimeoutPacketPositiveFixture()

	k, ctx, bankKeeper := keepertest.TokenwrapperKeeperWithBank(t)

	k.SetEnabled(ctx, true)
	k.SetNativePort(ctx, fixture.nativePort)
	k.SetNativeChannel(ctx, fixture.nativeChannel)
	k.SetCounterpartyPort(ctx, fixture.counterpartyPort)
	k.SetCounterpartyChannel(ctx, fixture.counterpartyChannel)
	k.SetDenom(ctx, fixture.moduleDenom)
	k.SetCounterpartyClientId(ctx, fixture.counterpartyClientId)
	_ = k.SetDecimalDifference(ctx, fixture.decimalDifference)

	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	transferKeeperMock := mocks.NewMockTransferKeeper(ctrl)
	appMock := mocks.NewMockCallbacksCompatibleModule(ctrl)
	channelKeeperMock := mocks.NewMockChannelKeeper(ctrl)
	connectionKeeperMock := mocks.NewMockConnectionKeeper(ctrl)

	ibcModule := tokenwrapper.NewIBCModule(
		k,
		transferKeeperMock,
		bankKeeper,
		appMock,
		channelKeeperMock,
		connectionKeeperMock,
	)

	mismatchedChannel := "channel-99"
	packetDenom := fmt.Sprintf("%s/%s", transfertypes.NewHop(fixture.nativePort, mismatchedChannel).String(), fixture.moduleDenom)
	expectedDenom := fmt.Sprintf("%s/%s", transfertypes.NewHop(fixture.nativePort, fixture.nativeChannel).String(), fixture.moduleDenom)
	require.NotEqual(t, expectedDenom, packetDenom)

	data := transfertypes.FungibleTokenPacketData{
		Denom:    packetDenom,
		Amount:   fixture.amount,
		Sender:   fixture.sender,
		Receiver: fixture.receiver,
	}
	dataBz, err := transfertypes.ModuleCdc.MarshalJSON(&data)
	require.NoError(t, err)

	packet := channeltypes.Packet{
		Sequence:           1,
		SourcePort:         fixture.nativePort,
		SourceChannel:      fixture.nativeChannel,
		DestinationPort:    fixture.counterpartyPort,
		DestinationChannel: fixture.counterpartyChannel,
		Data:               dataBz,
	}

	channelKeeperMock.EXPECT().
		GetChannel(ctx, fixture.nativePort, fixture.nativeChannel).
		Return(
			channeltypes.Channel{
				State:          channeltypes.OPEN,
				ConnectionHops: []string{fixture.nativeConnectionId},
				Counterparty: channeltypes.Counterparty{
					PortId:    fixture.counterpartyPort,
					ChannelId: fixture.counterpartyChannel,
				},
			}, true).
		Times(1)

	senderAddr, err := sdk.AccAddressFromBech32(fixture.sender)
	require.NoError(t, err)

	amount, ok := sdkmath.NewIntFromString(fixture.amount)
	require.True(t, ok, "failed to parse amount")
	ibcDenom := transfertypes.ExtractDenomFromPath(packetDenom).IBCDenom()
	ibcCoins := sdk.NewCoins(sdk.NewCoin(ibcDenom, amount))
	require.NoError(t, bankKeeper.MintCoins(ctx, "mint", ibcCoins))
	require.NoError(t, bankKeeper.SendCoinsFromModuleToAccount(ctx, "mint", senderAddr, ibcCoins))

	conversionFactor := sdkmath.NewInt(1000000000000) // 1e12
	convertedAmount := amount.Quo(conversionFactor)
	nativeCoins := sdk.NewCoins(sdk.NewCoin("uzig", convertedAmount))
	require.NoError(t, bankKeeper.MintCoins(ctx, "mint", nativeCoins))
	require.NoError(t, bankKeeper.SendCoinsFromModuleToModule(ctx, "mint", "tokenwrapper", nativeCoins))

	appMock.EXPECT().
		OnTimeoutPacket(ctx, "ics20-1", packet, sdk.AccAddress{}).
		Return(nil).
		Times(1)

	err = ibcModule.OnTimeoutPacket(ctx, "ics20-1", packet, sdk.AccAddress{})
	require.NoError(t, err)

	senderNativeBalance := bankKeeper.GetBalance(ctx, senderAddr, "uzig")
	require.True(t, senderNativeBalance.Amount.IsZero(), "expected no native refund for mismatched denom trace")
}

Run

go test ./x/tokenwrapper/module -run TestOnTimeoutPacket_RejectsMismatchedDenomTrace -count=1

Expected Output

--- FAIL: TestOnTimeoutPacket_RejectsMismatchedDenomTrace
    on_timeout_packet_test.go:452: expected no native refund for mismatched denom trace

The test constructs a timeout packet whose denom trace does not match the module’s configured path but still results in a native uzig refund. The failing assertion shows native balance increases when it should remain zero.

Attachments

hidden
CommentsReport History
Comments on this report are hidden
Details
Statedisclosed
Severity
Critical
Bounty$443
Visibilitypartially
VulnerabilityBlockchain
Participants
hidden