Skip to content

Commit dce4aac

Browse files
authored
feat: compute transferred amount with actual balance query (#436)
* compute transferred amount with actual balance query * return both balance change and amount in packet to support various usecase
1 parent 0d65de9 commit dce4aac

11 files changed

Lines changed: 528 additions & 46 deletions

File tree

proto/initia/ibchooks/v1/types.proto

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ syntax = "proto3";
22
package initia.ibchooks.v1;
33

44
import "amino/amino.proto";
5+
import "cosmos/base/v1beta1/coin.proto";
56
import "gogoproto/gogo.proto";
67

78
option go_package = "github.com/initia-labs/initia/x/ibc-hooks/types";
@@ -24,3 +25,19 @@ message ACL {
2425
string address = 1;
2526
bool allowed = 2;
2627
}
28+
29+
// TransferFunds defines the transfer funds.
30+
message TransferFunds {
31+
cosmos.base.v1beta1.Coin balance_change = 1 [
32+
(gogoproto.moretags) = "yaml:\"balance_change\"",
33+
(gogoproto.castrepeated) = "github.com/cosmos/cosmos-sdk/types.Coin",
34+
(gogoproto.nullable) = false,
35+
(amino.dont_omitempty) = true
36+
];
37+
cosmos.base.v1beta1.Coin amount_in_packet = 2 [
38+
(gogoproto.moretags) = "yaml:\"amount_in_packet\"",
39+
(gogoproto.castrepeated) = "github.com/cosmos/cosmos-sdk/types.Coin",
40+
(gogoproto.nullable) = false,
41+
(amino.dont_omitempty) = true
42+
];
43+
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
# Transfer Funds Custom Query
2+
3+
## Overview
4+
5+
The Transfer Funds Custom Query is a feature within the IBC Hooks module that provides Move smart contracts with access to IBC transfer packet information through a custom query interface. This enables contracts to retrieve details about the actual amount transferred and balance changes that occurred during IBC transfers.
6+
7+
## Purpose
8+
9+
The custom query system provides Move contracts with real-time access to IBC transfer data by:
10+
11+
1. Capturing transfer information during IBC packet processing
12+
2. Exposing this data through a dedicated custom query interface
13+
3. Enabling contracts to make informed decisions based on actual transfer amounts
14+
4. Automatically managing data lifecycle (set, query, clear)
15+
16+
## Architecture
17+
18+
### Data Flow
19+
20+
```plaintext
21+
IBC Transfer Packet Received
22+
23+
Record Balance Before Transfer
24+
25+
Execute Underlying Transfer Logic
26+
27+
Record Balance After Transfer
28+
29+
Calculate Balance Change
30+
31+
Store TransferFunds Data
32+
33+
Execute Move Contract Hook
34+
35+
Contract Queries TransferFunds via Custom Query
36+
37+
Contract Performs Custom Logic
38+
39+
Clear TransferFunds Data
40+
```
41+
42+
### Data Structure
43+
44+
The `TransferFunds` struct contains:
45+
46+
```go
47+
type TransferFunds struct {
48+
BalanceChange types.Coin `json:"balance_change"` // Actual balance change
49+
AmountInPacket types.Coin `json:"amount_in_packet"` // Amount specified in packet
50+
}
51+
```
52+
53+
- **BalanceChange**: The actual change in balance that occurred during the transfer
54+
- **AmountInPacket**: The amount that was specified in the original IBC packet
55+
56+
## Implementation Details
57+
58+
### Storage
59+
60+
The transfer funds data is stored in a transient collection, meaning it's only available for the duration of the current transaction and is automatically cleared afterward. This ensures data is only accessible during hook execution.
61+
62+
```go
63+
transferFunds collections.Item[types.TransferFunds]
64+
```
65+
66+
## Query Interface
67+
68+
### Custom Query Name
69+
70+
```plantext
71+
move_hook_get_transfer_funds
72+
```
73+
74+
### Query Parameters
75+
76+
- **Input**: Empty byte array (`[]byte{}`)
77+
- **Output**: JSON-encoded `TransferFunds` or `null` (`0x1::option::Option<TransferFunds>`)
78+
79+
### Response Format
80+
81+
#### When data is available
82+
83+
```json
84+
{
85+
"balance_change": {
86+
"denom": "uinit",
87+
"amount": "1000000"
88+
},
89+
"amount_in_packet": {
90+
"denom": "uinit",
91+
"amount": "1000000"
92+
}
93+
}
94+
```
95+
96+
#### When no data is available
97+
98+
```json
99+
null
100+
```
101+
102+
## Move Contract Integration
103+
104+
### Example Contract Usage
105+
106+
```move
107+
module std::hook_sender {
108+
use initia_std::coin;
109+
use initia_std::query;
110+
use initia_std::string::String;
111+
use initia_std::json;
112+
use initia_std::option::{Self, Option};
113+
114+
struct TransferFunds has copy, drop {
115+
balance_change: Coin,
116+
amount_in_packet: Coin,
117+
}
118+
119+
struct Coin has copy, drop {
120+
denom: String,
121+
amount: u64,
122+
}
123+
124+
public entry fun send_funds(sender: &signer, receiver: address) {
125+
// Execute custom query to get transfer funds data
126+
let response = query::query_custom(b"move_hook_get_transfer_funds", b"");
127+
let res = json::unmarshal<Option<TransferFunds>>(response);
128+
129+
// Ensure data is available
130+
assert!(option::is_some(&res), 1000);
131+
132+
// Extract the transfer funds data
133+
let res = option::borrow(&res);
134+
135+
// Get coin metadata for the balance change denom
136+
let coin_metadata = coin::denom_to_metadata(res.balance_change.denom);
137+
138+
// Transfer the actual balance change amount
139+
coin::transfer(sender, receiver, coin_metadata, res.balance_change.amount);
140+
}
141+
}
142+
```
143+
144+
### Key Points for Move Contracts
145+
146+
1. **Query Name**: Use `b"move_hook_get_transfer_funds"` as the custom query name
147+
2. **Empty Parameters**: Pass empty byte array `b""` as parameters
148+
3. **Optional Response**: The response is wrapped in an `Option<TransferFunds>`
149+
4. **Null Handling**: Check if the option contains data before using it
150+
5. **Data Availability**: Data is only available during hook execution

x/ibc-hooks/keeper/keeper.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ type Keeper struct {
2828
ac address.Codec
2929

3030
// these are used for custom queries
31-
transferFunds collections.Item[sdk.Coin]
31+
transferFunds collections.Item[types.TransferFunds]
3232
}
3333

3434
func NewKeeper(
@@ -52,7 +52,7 @@ func NewKeeper(
5252

5353
ACLs: collections.NewMap(sb, types.ACLPrefix, "acls", collections.BytesKey, collections.BoolValue),
5454
Params: collections.NewItem(sb, types.ParamsKey, "params", codec.CollValue[types.Params](cdc)),
55-
transferFunds: collections.NewItem(transientSb, types.TransferFundsKey, "transfer_funds", codec.CollValue[sdk.Coin](cdc)),
55+
transferFunds: collections.NewItem(transientSb, types.TransferFundsKey, "transfer_funds", codec.CollValue[types.TransferFunds](cdc)),
5656

5757
ac: ac,
5858
}
@@ -80,10 +80,10 @@ func (k Keeper) Logger(ctx context.Context) log.Logger {
8080
return sdkCtx.Logger().With("module", "x/"+types.ModuleName)
8181
}
8282

83-
func (k Keeper) SetTransferFunds(ctx context.Context, transferFunds sdk.Coin) error {
83+
func (k Keeper) SetTransferFunds(ctx context.Context, transferFunds types.TransferFunds) error {
8484
return k.transferFunds.Set(ctx, transferFunds)
8585
}
8686

8787
func (k Keeper) EmptyTransferFunds(ctx context.Context) error {
88-
return k.transferFunds.Set(ctx, sdk.Coin{})
88+
return k.transferFunds.Remove(ctx)
8989
}

x/ibc-hooks/keeper/vm_custom_query.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@ import (
66
"errors"
77

88
"cosmossdk.io/collections"
9-
sdk "github.com/cosmos/cosmos-sdk/types"
109
)
1110

11+
// GetTransferFunds is a custom query that returns the transfer funds.
1212
func (k Keeper) GetTransferFunds(ctx context.Context, _ []byte) ([]byte, error) {
1313
transferFunds, err := k.transferFunds.Get(ctx)
1414
if errors.Is(err, collections.ErrNotFound) {
15-
return json.Marshal(sdk.Coin{})
15+
return json.Marshal(nil)
1616
} else if err != nil {
1717
return nil, err
1818
}

x/ibc-hooks/keeper/vm_custom_query_test.go

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"testing"
77

8+
"github.com/initia-labs/initia/x/ibc-hooks/types"
89
"github.com/stretchr/testify/require"
910

1011
sdkmath "cosmossdk.io/math"
@@ -17,34 +18,34 @@ func TestGetTransferFunds(t *testing.T) {
1718
res, err := input.IBCHooksKeeper.GetTransferFunds(ctx, nil)
1819
require.NoError(t, err)
1920

20-
var coin sdk.Coin
21-
coinbz, err := json.Marshal(coin)
21+
nullBz, err := json.Marshal(nil)
2222
require.NoError(t, err)
23+
require.True(t, bytes.Equal(res, nullBz))
2324

24-
require.True(t, bytes.Equal(res, coinbz))
25-
25+
var coin sdk.Coin
2626
coin.Denom = "init"
2727
coin.Amount = sdkmath.NewInt(10000)
2828

29-
err = input.IBCHooksKeeper.SetTransferFunds(ctx, coin)
29+
expected := types.TransferFunds{
30+
AmountInPacket: coin,
31+
BalanceChange: coin.Sub(coin),
32+
}
33+
err = input.IBCHooksKeeper.SetTransferFunds(ctx, expected)
3034
require.NoError(t, err)
3135

3236
res, err = input.IBCHooksKeeper.GetTransferFunds(ctx, nil)
3337
require.NoError(t, err)
3438

35-
coinbz, err = json.Marshal(coin)
39+
expectedBz, err := json.Marshal(expected)
3640
require.NoError(t, err)
3741

38-
require.True(t, bytes.Equal(res, coinbz))
42+
require.True(t, bytes.Equal(res, expectedBz))
3943

4044
err = input.IBCHooksKeeper.EmptyTransferFunds(ctx)
4145
require.NoError(t, err)
4246

4347
res, err = input.IBCHooksKeeper.GetTransferFunds(ctx, nil)
4448
require.NoError(t, err)
4549

46-
coinbz, err = json.Marshal(sdk.Coin{})
47-
require.NoError(t, err)
48-
49-
require.True(t, bytes.Equal(res, coinbz))
50+
require.True(t, bytes.Equal(res, nullBz))
5051
}

x/ibc-hooks/move-hooks/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Move hooks is an IBC middleware that parses an ICS20 transfer, and if the `memo`
1515
### Move Contract Execution Format
1616

1717
Before we dive into the IBC metadata format, we show the hook data format, so the reader has a sense of what are the fields we need to be setting in.
18-
The move `MsgExecute` is defined [here](../../move/types/tx.pb.go) and other types are defined [here](./message.go) as the following type:
18+
The move `MsgExecute` is defined [tx.pb.go](../../move/types/tx.pb.go) and other types are defined [message.go](./message.go) as the following type:
1919

2020
```go
2121
// HookData defines a wrapper for move execute message

x/ibc-hooks/move-hooks/receive.go

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@ import (
44
"errors"
55
"fmt"
66

7+
"cosmossdk.io/math"
78
transfertypes "github.com/cosmos/ibc-go/v8/modules/apps/transfer/types"
89
channeltypes "github.com/cosmos/ibc-go/v8/modules/core/04-channel/types"
910
ibcexported "github.com/cosmos/ibc-go/v8/modules/core/exported"
1011

1112
sdk "github.com/cosmos/cosmos-sdk/types"
1213

13-
sdkmath "cosmossdk.io/math"
1414
ibchooks "github.com/initia-labs/initia/x/ibc-hooks"
1515
ibchookstypes "github.com/initia-labs/initia/x/ibc-hooks/types"
1616
nfttransfertypes "github.com/initia-labs/initia/x/ibc/nft-transfer/types"
@@ -56,29 +56,59 @@ func (h MoveHooks) onRecvIcs20Packet(
5656
data.Receiver = intermediateSender
5757
packet.Data = data.GetBytes()
5858

59+
// get intermediate address
60+
intermediateAddr, err := h.ac.StringToBytes(intermediateSender)
61+
if err != nil {
62+
return newEmitErrorAcknowledgement(err)
63+
}
64+
65+
// get balance before underlying OnRecvPacket() call
66+
denom := ibchookstypes.GetReceivedTokenDenom(packet, data)
67+
beforeBalance, err := h.moveKeeper.MoveBankKeeper().GetBalance(ctx, intermediateAddr, denom)
68+
if err != nil {
69+
return newEmitErrorAcknowledgement(err)
70+
}
71+
72+
// call underlying OnRecvPacket()
5973
ack := im.App.OnRecvPacket(ctx, packet, relayer)
6074
if !ack.Success() {
6175
return ack
6276
}
6377

64-
denom := ibchookstypes.GetReceivedTokenDenom(packet, data)
78+
// get balance after underlying OnRecvPacket() call
79+
afterBalance, err := h.moveKeeper.MoveBankKeeper().GetBalance(ctx, intermediateAddr, denom)
80+
if err != nil {
81+
return newEmitErrorAcknowledgement(err)
82+
}
6583

66-
transferFundsAmount, ok := sdkmath.NewIntFromString(data.Amount)
84+
// compute amount in packet
85+
amountInPacket, ok := math.NewIntFromString(data.Amount)
6786
if !ok {
6887
return newEmitErrorAcknowledgement(errors.New("invalid amount for transfer"))
6988
}
70-
transferFunds := sdk.NewCoin(denom, transferFundsAmount)
71-
if err := im.HooksKeeper.SetTransferFunds(ctx, transferFunds); err != nil {
89+
90+
// compute balance change
91+
balanceChange := math.ZeroInt()
92+
if afterBalance.GT(beforeBalance) {
93+
balanceChange = afterBalance.Sub(beforeBalance)
94+
}
95+
96+
// store transfer funds to be used in contract call
97+
if err := im.HooksKeeper.SetTransferFunds(ctx, ibchookstypes.TransferFunds{
98+
BalanceChange: sdk.NewCoin(denom, balanceChange),
99+
AmountInPacket: sdk.NewCoin(denom, amountInPacket),
100+
}); err != nil {
72101
return newEmitErrorAcknowledgement(err)
73102
}
74103

104+
// execute contract call
75105
msg.Sender = intermediateSender
76106
_, err = h.execMsg(ctx, msg)
77107
if err != nil {
78108
return newEmitErrorAcknowledgement(err)
79109
}
80110

81-
// clear transfer funds
111+
// clear transfer funds to be used in next contract call
82112
if err := im.HooksKeeper.EmptyTransferFunds(ctx); err != nil {
83113
return newEmitErrorAcknowledgement(err)
84114
}

0 commit comments

Comments
 (0)