变更记录

  • 24-05-2023:初始草案

状态

已接受,并已应用于 ibc-go v7.1

背景

每个 ICS-20 转账通道都有其各自的托管银行账户。该账户用于锁定从作为代币源链的链上转出的代币(即,被转移的代币尚未返回其起源链时)。这种设计使得查询托管账户余额并找出特定通道中处于托管状态的代币总量变得容易。然而,在某些使用场景中,我们希望能够确定某一给定面额在所有已转出这些代币的通道中的总托管数量。 例如:假设 Cosmos Hub 与 Osmosis 之间有三个通道,并且在每个通道上都有 10 ATOM 从 Cosmos Hub 转移到 Osmosis,那么我们希望能够直接得知已有 30 ATOM 被转移了出去(即,锁定在各通道的托管账户中),而不需要遍历每个托管账户并对各自余额求和。 关于该特性适用的一个示例用例,请参考 Osmosis 在 #2664 中描述的速率限制用例。

决策

状态项 denom -> amount

某一给定面额的代币在托管中的总量(跨所有转账通道)会存储在状态中,并以该面额作为键:totalEscrowForDenom/{denom}。

当 amount 为负数时触发 panic

如果尝试存储负数金额,则 keeper 函数会触发 panic:
if coin.Amount.IsNegative() {
  panic(fmt.Sprintf("amount cannot be negative: %s", coin.Amount))
}

当 amount 为零时删除状态项

在为某个特定面额设置金额时,如果所有从该链转出的代币都已转回,则该值可能为零。如果发生这种情况,该面额对应的状态项将被删除,因为 Cosmos SDK 的 x/bank 模块会清理任何非零余额:
if coin.Amount.IsZero() {
  store.Delete(key) // delete the key since Cosmos SDK x/bank module will prune any non-zero balances
  return
}

将 escrow/unescrow 与设置状态项打包处理

实现了两个新函数,将托管/解除托管与在状态中设置总托管金额这两类操作打包在一起,因为这些操作需要同时执行。 对于托管代币:
// escrowToken will send the given token from the provided sender to the escrow address. It will also
// update the total escrowed amount by adding the escrowed token to the current total escrow.
func (k Keeper) escrowToken(ctx sdk.Context, sender, escrowAddress sdk.AccAddress, token sdk.Coin) error {
  if err := k.bankKeeper.SendCoins(ctx, sender, escrowAddress, sdk.NewCoins(token)); err != nil {
    // failure is expected for insufficient balances
    return err
  }

  // track the total amount in escrow keyed by denomination to allow for efficient iteration
  currentTotalEscrow := k.GetTotalEscrowForDenom(ctx, token.GetDenom())
  newTotalEscrow := currentTotalEscrow.Add(token)
  k.SetTotalEscrowForDenom(ctx, newTotalEscrow)

  return nil
}
对于解除托管代币:
// unescrowToken will send the given token from the escrow address to the provided receiver. It will also
// update the total escrow by deducting the unescrowed token from the current total escrow.
func (k Keeper) unescrowToken(ctx sdk.Context, escrowAddress, receiver sdk.AccAddress, token sdk.Coin) error {
  if err := k.bankKeeper.SendCoins(ctx, escrowAddress, receiver, sdk.NewCoins(token)); err != nil {
    // NOTE: this error is only expected to occur given an unexpected bug or a malicious
    // counterparty module. The bug may occur in bank or any part of the code that allows
    // the escrow address to be drained. A malicious counterparty module could drain the
    // escrow address by allowing more tokens to be sent back then were escrowed.
    return errorsmod.Wrap(err, "unable to unescrow tokens, this may be caused by a malicious counterparty module or a bug: please open an issue on counterparty module")
  }

  // track the total amount in escrow keyed by denomination to allow for efficient iteration
  currentTotalEscrow := k.GetTotalEscrowForDenom(ctx, token.GetDenom())
  newTotalEscrow := currentTotalEscrow.Sub(token)
  k.SetTotalEscrowForDenom(ctx, newTotalEscrow)

  return nil
}
当在 sendTransfer 中需要托管代币时,会调用 escrowToken;当在执行 OnRecvPacket、OnAcknowledgementPacket 或 OnTimeoutPacket 回调时需要解除托管代币,则会调用 unescrowToken。

用于获取金额的 gRPC 查询端点和 CLI

新增了一个 gRPC 查询端点,以便获取某一给定面额的总金额:
// TotalEscrowForDenom returns the total amount of tokens in escrow based on the denom.
rpc TotalEscrowForDenom(QueryTotalEscrowForDenomRequest) returns (QueryTotalEscrowForDenomResponse) {
  option (google.api.http).get = "/ibc/apps/transfer/v1/denoms/{denom=**}/total_escrow";
}

// QueryTotalEscrowForDenomRequest is the request type for TotalEscrowForDenom RPC method.
message QueryTotalEscrowForDenomRequest {
  string denom = 1;
}

// QueryTotalEscrowForDenomResponse is the response type for TotalEscrowForDenom RPC method.
message QueryTotalEscrowForDenomResponse {
  cosmos.base.v1beta1.Coin amount = 1 [(gogoproto.nullable) = false];
}
同时还提供了一个 CLI 查询,可通过命令行获取总金额:
query ibc-transfer total-escrow [denom]

影响

正面

  • 无需遍历即可获取某一特定面额在所有转账通道中处于托管状态的总量。

负面

无显著影响

中性

  • 链上每出现一种被转出的面额,状态中都会新增一条对应记录。

参考

Issues: PRs:

Changelog

  • 24-05-2023: Initial draft

Status

Accepted and applied in v7.1 of ibc-go

Context

Every ICS-20 transfer channel has its own escrow bank account. This account is used to lock tokens that are transferred out of a chain that acts as the source of the tokens (i.e. when the tokens being transferred have not returned to the originating chain). This design makes it easy to query the balance of the escrow accounts and find out the total amount of tokens in escrow in a particular channel. However, there are use cases where it would be useful to determine the total escrowed amount of a given denomination across all channels where those tokens have been transferred out. For example: assuming that there are three channels between Cosmos Hub to Osmosis and 10 ATOM have been transferred from the Cosmos Hub to Osmosis on each of those channels, then we would like to know that 30 ATOM have been transferred (i.e. are locked in the escrow accounts of each channel) without needing to iterate over each escrow account to add up the balances of each. For a sample use case where this feature would be useful, please refer to Osmosis’ rate limiting use case described in #2664.

Decision

State entry denom -> amount

The total amount of tokens in escrow (across all transfer channels) for a given denomination is stored in state in an entry keyed by the denomination: totalEscrowForDenom/{denom}.

Panic if amount is negative

If a negative amount is ever attempted to be stored, then the keeper function will panic:
if coin.Amount.IsNegative() {
  panic(fmt.Sprintf("amount cannot be negative: %s", coin.Amount))
}

Delete state entry if amount is zero

When setting the amount for a particular denomination, the value might be zero if all tokens that were transferred out of the chain have been transferred back. If this happens, then the state entry for this particular denomination will be deleted, since Cosmos SDK’s x/bank module prunes any non-zero balances:
if coin.Amount.IsZero() {
  store.Delete(key) // delete the key since Cosmos SDK x/bank module will prune any non-zero balances
  return
}

Bundle escrow/unescrow with setting state entry

Two new functions are implemented that bundle together the operations of escrowing/unescrowing and setting the total escrow amount in state, since these operations need to be executed together. For escrowing tokens:
// escrowToken will send the given token from the provided sender to the escrow address. It will also
// update the total escrowed amount by adding the escrowed token to the current total escrow.
func (k Keeper) escrowToken(ctx sdk.Context, sender, escrowAddress sdk.AccAddress, token sdk.Coin) error {
  if err := k.bankKeeper.SendCoins(ctx, sender, escrowAddress, sdk.NewCoins(token)); err != nil {
    // failure is expected for insufficient balances
    return err
  }

  // track the total amount in escrow keyed by denomination to allow for efficient iteration
  currentTotalEscrow := k.GetTotalEscrowForDenom(ctx, token.GetDenom())
  newTotalEscrow := currentTotalEscrow.Add(token)
  k.SetTotalEscrowForDenom(ctx, newTotalEscrow)

  return nil
}
For unescrowing tokens:
// unescrowToken will send the given token from the escrow address to the provided receiver. It will also
// update the total escrow by deducting the unescrowed token from the current total escrow.
func (k Keeper) unescrowToken(ctx sdk.Context, escrowAddress, receiver sdk.AccAddress, token sdk.Coin) error {
  if err := k.bankKeeper.SendCoins(ctx, escrowAddress, receiver, sdk.NewCoins(token)); err != nil {
    // NOTE: this error is only expected to occur given an unexpected bug or a malicious
    // counterparty module. The bug may occur in bank or any part of the code that allows
    // the escrow address to be drained. A malicious counterparty module could drain the
    // escrow address by allowing more tokens to be sent back then were escrowed.
    return errorsmod.Wrap(err, "unable to unescrow tokens, this may be caused by a malicious counterparty module or a bug: please open an issue on counterparty module")
  }

  // track the total amount in escrow keyed by denomination to allow for efficient iteration
  currentTotalEscrow := k.GetTotalEscrowForDenom(ctx, token.GetDenom())
  newTotalEscrow := currentTotalEscrow.Sub(token)
  k.SetTotalEscrowForDenom(ctx, newTotalEscrow)

  return nil
}
When tokens need to be escrowed in sendTransfer, then escrowToken is called; when tokens need to be unescrowed on execution of the OnRecvPacket, OnAcknowledgementPacket or OnTimeoutPacket callbacks, then unescrowToken is called.

gRPC query endpoint and CLI to retrieve amount

A gRPC query endpoint is added so that it is possible to retrieve the total amount for a given denomination:
// TotalEscrowForDenom returns the total amount of tokens in escrow based on the denom.
rpc TotalEscrowForDenom(QueryTotalEscrowForDenomRequest) returns (QueryTotalEscrowForDenomResponse) {
  option (google.api.http).get = "/ibc/apps/transfer/v1/denoms/{denom=**}/total_escrow";
}

// QueryTotalEscrowForDenomRequest is the request type for TotalEscrowForDenom RPC method.
message QueryTotalEscrowForDenomRequest {
  string denom = 1;
}

// QueryTotalEscrowForDenomResponse is the response type for TotalEscrowForDenom RPC method.
message QueryTotalEscrowForDenomResponse {
  cosmos.base.v1beta1.Coin amount = 1 [(gogoproto.nullable) = false];
}
And a CLI query is also available to retrieve the total amount via the command line:
query ibc-transfer total-escrow [denom]

Consequences

Positive

  • Possibility to retrieve the total amount of a particular denomination in escrow across all transfer channels without iteration.

Negative

No notable consequences

Neutral

  • A new entry is added to state for every denomination that is transferred out of the chain.

References

Issues: PRs: