变更记录

  • 2023 年 12 月 4 日:初始草案(@yihuang、@tac0turtle、@alexanderbez)
  • 2024 年 1 月 30 日:加入关于确定性交易编码的章节
  • 2025 年 3 月 18 日:修订实现方式,改用 Cosmos SDK KV Store,并要求每个地址使用唯一超时时间(@technicallyty)
  • 2025 年 4 月 25 日:补充说明,拒绝带有 sequence 值的无序交易。

状态

已接受,未实现

摘要

我们提出一种在不强制交易顺序、也不要求使用单调递增 sequence 的情况下实现重放攻击防护的方法。为此,我们建议使用一种基于时间的临时 sequence。

背景

账户 sequence 值用于防止重放攻击,并确保来自同一发送方的交易按顺序被纳入区块并执行。不幸的是,这使得同一发送方可靠地发送大量并发交易变得困难。受此限制影响的典型场景包括 IBC 中继器和加密货币交易所。

决策

我们提议在交易体中新增一个布尔字段 unordered,以及一个 google.protobuf.Timestamp 字段 timeout_timestamp。 无序交易将绕过传统的账户 sequence 规则,转而遵循下文描述的规则;这不会影响传统的有序交易,后者仍将像以前一样遵循相同的 sequence 规则。 我们将使用 SDK 现有的 KV Store 库,引入基于时间的临时无序 sequence 存储。具体来说,我们将复用现有的 x/auth KV store 来存储这些无序 sequence。 当一笔无序交易被纳入区块时,会将 timeout_timestamp 与发送方地址字节拼接后的值记录到状态中(例如 542939323/<address_bytes>)。在多方签名场景下,会为每个签名者各记录一条状态项。 新交易会与状态进行比对,以防止重复提交。为避免状态无限增长,我们提出以下方案:
  • 为 timeout_timestamp 的取值定义上界(例如 10 分钟)。
  • 在 x/auth 中新增 PreBlocker 方法,移除 timeout_timestamp 早于当前区块时间的状态项。

交易格式

message TxBody {
  ...
          
  bool unordered = 4;
  google.protobuf.Timestamp timeout_timestamp = 5
}

重放保护

我们通过将无序 sequence 存储在 Cosmos SDK KV store 中来实现重放保护。在交易进入系统时,我们会检查该交易的无序 sequence 是否已存在于状态中,或者其 TTL 值是否已过期,也就是早于当前区块时间。如果满足任一条件,则拒绝该交易;否则,将该无序 sequence 写入状态。这部分状态将归属于 x/auth 模块。 状态会在 x/auth 的 PreBlocker 中进行处理。所有无序 sequence 早于当前区块时间的交易都会被删除。
func (am AppModule)

PreBlock(ctx context.Context) (appmodule.ResponsePreBlock, error) {
    err := am.accountKeeper.RemoveExpired(sdk.UnwrapSDKContext(ctx))
    if err != nil {
    return nil, err
}

return &sdk.ResponsePreBlock{
    ConsensusParamsChanged: false
}, nil
}
package keeper

import (
    
	sdk "github.com/cosmos/cosmos-sdk/types"
    "cosmossdk.io/collections"
    "cosmossdk.io/core/store"
)

var (
	// just arbitrarily picking some upper bound number.
	unorderedSequencePrefix = collections.NewPrefix(90)
)

type AccountKeeper struct {
	// ...
	unorderedSequences collections.KeySet[collections.Pair[uint64, []byte]]
}

func (m *AccountKeeper)

Contains(ctx sdk.Context, sender []byte, timestamp uint64) (bool, error) {
    return m.unorderedSequences.Has(ctx, collections.Join(timestamp, sender))
}

func (m *AccountKeeper)

Add(ctx sdk.Context, sender []byte, timestamp uint64)

error {
    return m.unorderedSequences.Set(ctx, collections.Join(timestamp, sender))
}

func (m *AccountKeeper)

RemoveExpired(ctx sdk.Context)

error {
    blkTime := ctx.BlockTime().UnixNano()

it, err := m.unorderedSequences.Iterate(ctx, collections.NewPrefixUntilPairRange[uint64, []byte](uint64(blkTime)))
    if err != nil {
    return err
}

defer it.Close()

keys, err := it.Keys()
    if err != nil {
    return err
}
    for _, key := range keys {
    if err := m.unorderedSequences.Remove(ctx, key); err != nil {
    return err
}
	
}

return nil
}

AnteHandler 装饰器

为了支持绕过 nonce 校验,我们必须修改现有的 IncrementSequenceDecorator AnteHandler 装饰器,使其在交易被标记为无序时跳过 nonce 校验。
func (isd IncrementSequenceDecorator)

AnteHandle(ctx sdk.Context, tx sdk.Tx, simulate bool, next sdk.AnteHandler) (sdk.Context, error) {
    if tx.UnOrdered() {
    return next(ctx, tx, simulate)
}

  // ...
}
我们还引入了一个新的装饰器,用于执行无序交易校验。
package ante

import (
    
	"slices"
    "strings"
    "time"

	sdk "github.com/cosmos/cosmos-sdk/types"
	sdkerrors "github.com/cosmos/cosmos-sdk/types/errors"
	authkeeper "github.com/cosmos/cosmos-sdk/x/auth/keeper"
	authsigning "github.com/cosmos/cosmos-sdk/x/auth/signing"

	errorsmod "cosmossdk.io/errors"
)

var _ sdk.AnteDecorator = (*UnorderedTxDecorator)(nil)

// UnorderedTxDecorator defines an AnteHandler decorator that is responsible for
// checking if a transaction is intended to be unordered and, if so, evaluates
// the transaction accordingly. An unordered transaction will bypass having its
// nonce incremented, which allows fire-and-forget transaction broadcasting,
// removing the necessity of ordering on the sender-side.
//
// The transaction sender must ensure that unordered=true and a timeout_height
// is appropriately set. The AnteHandler will check that the transaction is not
// a duplicate and will evict it from state when the timeout is reached.
//
// The UnorderedTxDecorator should be placed as early as possible in the AnteHandler
// chain to ensure that during DeliverTx, the transaction is added to the unordered sequence state.
type UnorderedTxDecorator struct {
	// maxUnOrderedTTL defines the maximum TTL a transaction can define.
	maxTimeoutDuration time.Duration
	txManager          authkeeper.UnorderedTxManager
}

func NewUnorderedTxDecorator(
	utxm authkeeper.UnorderedTxManager,
) *UnorderedTxDecorator {
    return &UnorderedTxDecorator{
    maxTimeoutDuration: 10 * time.Minute,
		txManager:          utxm,
}
}

func (d *UnorderedTxDecorator)

AnteHandle(
	ctx sdk.Context,
	tx sdk.Tx,
	_ bool,
	next sdk.AnteHandler,
) (sdk.Context, error) {
    if err := d.ValidateTx(ctx, tx); err != nil {
    return ctx, err
}

return next(ctx, tx, false)
}

func (d *UnorderedTxDecorator)

ValidateTx(ctx sdk.Context, tx sdk.Tx)

error {
    unorderedTx, ok := tx.(sdk.TxWithUnordered)
    if !ok || !unorderedTx.GetUnordered() {
		// If the transaction does not implement unordered capabilities or has the
		// unordered value as false, we bypass.
		return nil
}
    blockTime := ctx.BlockTime()
    timeoutTimestamp := unorderedTx.GetTimeoutTimeStamp()
    if timeoutTimestamp.IsZero() || timeoutTimestamp.Unix() == 0 {
    return errorsmod.Wrap(
			sdkerrors.ErrInvalidRequest,
			"unordered transaction must have timeout_timestamp set",
		)
}
    if timeoutTimestamp.Before(blockTime) {
    return errorsmod.Wrap(
			sdkerrors.ErrInvalidRequest,
			"unordered transaction has a timeout_timestamp that has already passed",
		)
}
    if timeoutTimestamp.After(blockTime.Add(d.maxTimeoutDuration)) {
    return errorsmod.Wrapf(
			sdkerrors.ErrInvalidRequest,
			"unordered tx ttl exceeds %s",
			d.maxTimeoutDuration.String(),
		)
}
    execMode := ctx.ExecMode()
    if execMode == sdk.ExecModeSimulate {
    return nil
}

signerAddrs, err := getSigners(tx)
    if err != nil {
    return err
}
    for _, signer := range signerAddrs {
    contains, err := d.txManager.Contains(ctx, signer, uint64(unorderedTx.GetTimeoutTimeStamp().Unix()))
    if err != nil {
    return errorsmod.Wrap(
				sdkerrors.ErrIO,
				"failed to check contains",
			)
}
    if contains {
    return errorsmod.Wrapf(
				sdkerrors.ErrInvalidRequest,
				"tx is duplicated for signer %x", signer,
			)
}
    if err := d.txManager.Add(ctx, signer, uint64(unorderedTx.GetTimeoutTimeStamp().Unix())); err != nil {
    return errorsmod.Wrap(
				sdkerrors.ErrIO,
				"failed to add unordered sequence to state",
			)
}
 
}

return nil
}

func getSigners(tx sdk.Tx) ([][]byte, error) {
    sigTx, ok := tx.(authsigning.SigVerifiableTx)
    if !ok {
    return nil, errorsmod.Wrap(sdkerrors.ErrTxDecode, "invalid tx type")
}

return sigTx.GetSigners()
}

无序 Sequence

无序 sequence 提供了一种简单直接的机制,用于同时防御交易可塑性和交易重复问题。需要特别注意的是,无序 sequence 仍然必须保持唯一。不过,它的值不再像常规 sequence 那样必须严格递增,节点接收这些交易的顺序也不再重要。客户端可以像下面这样构造无序交易:
for _, tx := range txs {
    tx.SetUnordered(true)

tx.SetTimeoutTimestamp(time.Now() + 1 * time.Nanosecond)
}
我们会拒绝同时设置了 sequence 和无序超时的交易。这样做是为了避免对用户意图作出假设。

状态管理

无序 sequence 的存储将通过 Cosmos SDK 的 KV Store 服务来实现。

关于先前设计迭代的说明

先前版本的无序交易实现依赖一套临时拼凑的状态管理系统,这带来了严重风险,也为重复交易处理提供了攻击面。它依赖应用的优雅关闭流程来刷新当前无序 sequence 映射的状态。如果网络中有三分之二节点崩溃,而优雅关闭又未触发,系统就会丢失该映射中所有 sequence 的追踪,从而使这些交易能够被重放。此 ADR 更新版本中提出的实现通过直接写入 Cosmos KV Store 解决了这一问题。 虽然这种方式性能较低,但在初始实现中,我们选择优先采用更安全的路径,并将性能优化推迟到积累更多真实世界影响数据,以及拥有更经充分验证的优化方案之后再进行。 此外,先前版本依赖哈希来创建我们称之为“无序 sequence”的值。Cosmos SDK 的签名模式中已知存在交易可塑性问题。该 ADR 通过强制使用一次性的无序 nonce 来规避这一问题,而不是从交易字节中派生 nonce。

影响

正面

  • 支持无序交易纳入区块,从而能够一次性“发出即忘记”地发送多笔交易。

负面

  • 需要额外的存储开销。
  • 每笔交易要求使用唯一时间戳,会给客户端带来少量额外开销。客户端必须确保每笔交易的超时时间戳都不同。不过,纳秒级差异已经足够。
  • 与使用非默克尔化存储或临时方法相比,使用 Cosmos SDK KV store 更慢,因此区块时间可能会因此变长。

参考资料


Changelog

  • Dec 4, 2023: Initial Draft (@yihuang, @tac0turtle, @alexanderbez)
  • Jan 30, 2024: Include section on deterministic transaction encoding
  • Mar 18, 2025: Revise implementation to use Cosmos SDK KV Store and require unique timeouts per-address (@technicallyty)
  • Apr 25, 2025: Add note about rejecting unordered txs with sequence values.

Status

ACCEPTED Not Implemented

Abstract

We propose a way to do replay-attack protection without enforcing the order of transactions and without requiring the use of monotonically increasing sequences. Instead, we propose the use of a time-based, ephemeral sequence.

Context

Account sequence values serve to prevent replay attacks and ensure transactions from the same sender are included into blocks and executed in sequential order. Unfortunately, this makes it difficult to reliably send many concurrent transactions from the same sender. Victims of such limitations include IBC relayers and crypto exchanges.

Decision

We propose adding a boolean field unordered and a google.protobuf.Timestamp field timeout_timestamp to the transaction body. Unordered transactions will bypass the traditional account sequence rules and follow the rules described below, without impacting traditional ordered transactions which will follow the same sequence rules as before. We will introduce new storage of time-based, ephemeral unordered sequences using the SDK’s existing KV Store library. Specifically, we will leverage the existing x/auth KV store to store the unordered sequences. When an unordered transaction is included in a block, a concatenation of the timeout_timestamp and sender’s address bytes will be recorded to state (i.e. 542939323/<address_bytes>). In cases of multi-party signing, one entry per signer will be recorded to state. New transactions will be checked against the state to prevent duplicate submissions. To prevent the state from growing indefinitely, we propose the following:
  • Define an upper bound for the value of timeout_timestamp (i.e. 10 minutes).
  • Add PreBlocker method x/auth that removes state entries with a timeout_timestamp earlier than the current block time.

Transaction Format

message TxBody {
  ...
          
  bool unordered = 4;
  google.protobuf.Timestamp timeout_timestamp = 5
}

Replay Protection

We facilitate replay protection by storing the unordered sequence in the Cosmos SDK KV store. Upon transaction ingress, we check if the transaction’s unordered sequence exists in state, or if the TTL value is stale, i.e. before the current block time. If so, we reject it. Otherwise, we add the unordered sequence to the state. This section of the state will belong to the x/auth module. The state is evaluated during x/auth’s PreBlocker. All transactions with an unordered sequence earlier than the current block time will be deleted.
func (am AppModule)

PreBlock(ctx context.Context) (appmodule.ResponsePreBlock, error) {
    err := am.accountKeeper.RemoveExpired(sdk.UnwrapSDKContext(ctx))
    if err != nil {
    return nil, err
}

return &sdk.ResponsePreBlock{
    ConsensusParamsChanged: false
}, nil
}
package keeper

import (
    
	sdk "github.com/cosmos/cosmos-sdk/types"
    "cosmossdk.io/collections"
    "cosmossdk.io/core/store"
)

var (
	// just arbitrarily picking some upper bound number.
	unorderedSequencePrefix = collections.NewPrefix(90)
)

type AccountKeeper struct {
	// ...
	unorderedSequences collections.KeySet[collections.Pair[uint64, []byte]]
}

func (m *AccountKeeper)

Contains(ctx sdk.Context, sender []byte, timestamp uint64) (bool, error) {
    return m.unorderedSequences.Has(ctx, collections.Join(timestamp, sender))
}

func (m *AccountKeeper)

Add(ctx sdk.Context, sender []byte, timestamp uint64)

error {
    return m.unorderedSequences.Set(ctx, collections.Join(timestamp, sender))
}

func (m *AccountKeeper)

RemoveExpired(ctx sdk.Context)

error {
    blkTime := ctx.BlockTime().UnixNano()

it, err := m.unorderedSequences.Iterate(ctx, collections.NewPrefixUntilPairRange[uint64, []byte](uint64(blkTime)))
    if err != nil {
    return err
}

defer it.Close()

keys, err := it.Keys()
    if err != nil {
    return err
}
    for _, key := range keys {
    if err := m.unorderedSequences.Remove(ctx, key); err != nil {
    return err
}
	
}

return nil
}

AnteHandler Decorator

To facilitate bypassing nonce verification, we must modify the existing IncrementSequenceDecorator AnteHandler decorator to skip the nonce verification when the transaction is marked as unordered.
func (isd IncrementSequenceDecorator)

AnteHandle(ctx sdk.Context, tx sdk.Tx, simulate bool, next sdk.AnteHandler) (sdk.Context, error) {
    if tx.UnOrdered() {
    return next(ctx, tx, simulate)
}

  // ...
}
We also introduce a new decorator to perform the unordered transaction verification.
package ante

import (
    
	"slices"
    "strings"
    "time"

	sdk "github.com/cosmos/cosmos-sdk/types"
	sdkerrors "github.com/cosmos/cosmos-sdk/types/errors"
	authkeeper "github.com/cosmos/cosmos-sdk/x/auth/keeper"
	authsigning "github.com/cosmos/cosmos-sdk/x/auth/signing"

	errorsmod "cosmossdk.io/errors"
)

var _ sdk.AnteDecorator = (*UnorderedTxDecorator)(nil)

// UnorderedTxDecorator defines an AnteHandler decorator that is responsible for
// checking if a transaction is intended to be unordered and, if so, evaluates
// the transaction accordingly. An unordered transaction will bypass having its
// nonce incremented, which allows fire-and-forget transaction broadcasting,
// removing the necessity of ordering on the sender-side.
//
// The transaction sender must ensure that unordered=true and a timeout_height
// is appropriately set. The AnteHandler will check that the transaction is not
// a duplicate and will evict it from state when the timeout is reached.
//
// The UnorderedTxDecorator should be placed as early as possible in the AnteHandler
// chain to ensure that during DeliverTx, the transaction is added to the unordered sequence state.
type UnorderedTxDecorator struct {
	// maxUnOrderedTTL defines the maximum TTL a transaction can define.
	maxTimeoutDuration time.Duration
	txManager          authkeeper.UnorderedTxManager
}

func NewUnorderedTxDecorator(
	utxm authkeeper.UnorderedTxManager,
) *UnorderedTxDecorator {
    return &UnorderedTxDecorator{
    maxTimeoutDuration: 10 * time.Minute,
		txManager:          utxm,
}
}

func (d *UnorderedTxDecorator)

AnteHandle(
	ctx sdk.Context,
	tx sdk.Tx,
	_ bool,
	next sdk.AnteHandler,
) (sdk.Context, error) {
    if err := d.ValidateTx(ctx, tx); err != nil {
    return ctx, err
}

return next(ctx, tx, false)
}

func (d *UnorderedTxDecorator)

ValidateTx(ctx sdk.Context, tx sdk.Tx)

error {
    unorderedTx, ok := tx.(sdk.TxWithUnordered)
    if !ok || !unorderedTx.GetUnordered() {
		// If the transaction does not implement unordered capabilities or has the
		// unordered value as false, we bypass.
		return nil
}
    blockTime := ctx.BlockTime()
    timeoutTimestamp := unorderedTx.GetTimeoutTimeStamp()
    if timeoutTimestamp.IsZero() || timeoutTimestamp.Unix() == 0 {
    return errorsmod.Wrap(
			sdkerrors.ErrInvalidRequest,
			"unordered transaction must have timeout_timestamp set",
		)
}
    if timeoutTimestamp.Before(blockTime) {
    return errorsmod.Wrap(
			sdkerrors.ErrInvalidRequest,
			"unordered transaction has a timeout_timestamp that has already passed",
		)
}
    if timeoutTimestamp.After(blockTime.Add(d.maxTimeoutDuration)) {
    return errorsmod.Wrapf(
			sdkerrors.ErrInvalidRequest,
			"unordered tx ttl exceeds %s",
			d.maxTimeoutDuration.String(),
		)
}
    execMode := ctx.ExecMode()
    if execMode == sdk.ExecModeSimulate {
    return nil
}

signerAddrs, err := getSigners(tx)
    if err != nil {
    return err
}
    for _, signer := range signerAddrs {
    contains, err := d.txManager.Contains(ctx, signer, uint64(unorderedTx.GetTimeoutTimeStamp().Unix()))
    if err != nil {
    return errorsmod.Wrap(
				sdkerrors.ErrIO,
				"failed to check contains",
			)
}
    if contains {
    return errorsmod.Wrapf(
				sdkerrors.ErrInvalidRequest,
				"tx is duplicated for signer %x", signer,
			)
}
    if err := d.txManager.Add(ctx, signer, uint64(unorderedTx.GetTimeoutTimeStamp().Unix())); err != nil {
    return errorsmod.Wrap(
				sdkerrors.ErrIO,
				"failed to add unordered sequence to state",
			)
}
 
}

return nil
}

func getSigners(tx sdk.Tx) ([][]byte, error) {
    sigTx, ok := tx.(authsigning.SigVerifiableTx)
    if !ok {
    return nil, errorsmod.Wrap(sdkerrors.ErrTxDecode, "invalid tx type")
}

return sigTx.GetSigners()
}

Unordered Sequences

Unordered sequences provide a simple, straightforward mechanism to protect against both transaction malleability and transaction duplication. It is important to note that the unordered sequence must still be unique. However, the value is not required to be strictly increasing as with regular sequences, and the order in which the node receives the transactions no longer matters. Clients can handle building unordered transactions similarly to the code below:
for _, tx := range txs {
    tx.SetUnordered(true)

tx.SetTimeoutTimestamp(time.Now() + 1 * time.Nanosecond)
}
We will reject transactions that have both sequence and unordered timeouts set. We do this to avoid assuming the intent of the user.

State Management

The storage of unordered sequences will be facilitated using the Cosmos SDK’s KV Store service.

Note On Previous Design Iteration

The previous iteration of unordered transactions worked by using an ad-hoc state-management system that posed severe risks and a vector for duplicated tx processing. It relied on graceful app closure which would flush the current state of the unordered sequence mapping. If the 2/3’s of the network crashed, and the graceful closure did not trigger, the system would lose track of all sequences in the mapping, allowing those transactions to be replayed. The implementation proposed in the updated version of this ADR solves this by writing directly to the Cosmos KV Store. While this is less performant, for the initial implementation, we opted to choose a safer path and postpone performance optimizations until we have more data on real-world impacts and a more battle-tested approach to optimization. Additionally, the previous iteration relied on using hashes to create what we call an “unordered sequence.” There are known issues with transaction malleability in Cosmos SDK signing modes. This ADR gets away from this problem by enforcing single-use unordered nonces, instead of deriving nonces from bytes in the transaction.

Consequences

Positive

  • Support unordered transaction inclusion, enabling the ability to “fire and forget” many transactions at once.

Negative

  • Requires additional storage overhead.
  • Requirement of unique timestamps per transaction causes a small amount of additional overhead for clients. Clients must ensure each transaction’s timeout timestamp is different. However, nanosecond differentials suffice.
  • Usage of Cosmos SDK KV store is slower in comparison to using a non-merklized store or ad-hoc methods, and block times may slow down as a result.

References