feat: [ADR-070] Unordered Transactions (1/2) (#18641)
Co-authored-by: yihuang <huang@crypto.com> Co-authored-by: Facundo <facundomedica@gmail.com>
This commit is contained in:
co-authored by
yihuang
Facundo
parent
d520fcf7cc
commit
6ec53aaf54
@@ -2,7 +2,7 @@
|
||||
|
||||
## Changelog
|
||||
|
||||
* Dec 4, 2023: Initial Draft
|
||||
* Dec 4, 2023: Initial Draft (@yihuang, @tac0turtle, @alexanderbez)
|
||||
|
||||
## Status
|
||||
|
||||
@@ -53,79 +53,140 @@ message TxBody {
|
||||
}
|
||||
```
|
||||
|
||||
### `DedupTxHashManager`
|
||||
### Replay Protection
|
||||
|
||||
In order to provide replay protection, a user should ensure that the transaction's
|
||||
TTL value is relatively short-lived but long enough to provide enough time to be
|
||||
included in a block, e.g. ~H+50.
|
||||
|
||||
We facilitate this by storing the transaction's hash in a durable map, `UnorderedTxManager`,
|
||||
to prevent duplicates, i.e. replay attacks. Upon transaction ingress during `CheckTx`,
|
||||
we check if the transaction's hash exists in this map or if the TTL value is stale,
|
||||
i.e. before the current block. If so, we reject it. Upon inclusion in a block
|
||||
during `DeliverTx`, the transaction's hash is set in the map along with it's TTL
|
||||
value.
|
||||
|
||||
This map is evaluated at the end of each block, e.g. ABCI `Commit`, and all stale
|
||||
transactions, i.e. transactions's TTL value who's now beyond the committed block,
|
||||
are purged from the map.
|
||||
|
||||
An important point to note is that in theory, it may be possible to submit an unordered
|
||||
transaction twice, or multiple times, before the transaction is included in a block.
|
||||
However, we'll note a few important layers of protection and mitigation:
|
||||
|
||||
* Assuming CometBFT is used as the underlying consensus engine and a non-noop mempool
|
||||
is used, CometBFT will reject the duplicate for you.
|
||||
* For applications that leverage ABCI++, `ProcessProposal` should evaluate and reject
|
||||
malicious proposals with duplicate transactions.
|
||||
* For applications that leverage their own application mempool, their mempool should
|
||||
reject the duplicate for you.
|
||||
* Finally, worst case if the duplicate transaction is somehow selected for a block
|
||||
proposal, 2nd and all further attempts to evaluate it, will fail during `DeliverTx`,
|
||||
so worst case you just end up filling up block space with a duplicate transaction.
|
||||
|
||||
```golang
|
||||
type TxHash [32]byte
|
||||
|
||||
const PurgeLoopSleepMS = 500
|
||||
|
||||
// DedupTxHashManager contains the tx hash dictionary for duplicates checking,
|
||||
// and expire them when block number progresses.
|
||||
type DedupTxHashManager struct {
|
||||
mutex sync.RWMutex
|
||||
// tx hash -> expire block number
|
||||
// for duplicates checking and expiration
|
||||
hashes map[TxHash]uint64
|
||||
// channel to receive latest block numbers
|
||||
// UnorderedTxManager contains the tx hash dictionary for duplicates checking,
|
||||
// and expire them when block production progresses.
|
||||
type UnorderedTxManager struct {
|
||||
// blockCh defines a channel to receive newly committed block heights
|
||||
blockCh chan uint64
|
||||
|
||||
mu sync.RWMutex
|
||||
// txHashes defines a map from tx hash -> TTL value, which is used for duplicate
|
||||
// checking and replay protection, as well as purging the map when the TTL is
|
||||
// expired.
|
||||
txHashes map[TxHash]uint64
|
||||
}
|
||||
|
||||
func NewDedupTxHashManager() *DedupTxHashManager {
|
||||
m := &DedupTxHashManager{
|
||||
hashes: make(map[TxHash]uint64),
|
||||
blockCh: make(ch *uint64, 16),
|
||||
func NewUnorderedTxManager() *UnorderedTxManager {
|
||||
m := &UnorderedTxManager{
|
||||
blockCh: make(chan uint64, 16),
|
||||
txHashes: make(map[TxHash]uint64),
|
||||
}
|
||||
go m.purgeLoop()
|
||||
return m
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func (dtm *DedupTxHashManager) Close() error {
|
||||
close(dtm.blockCh)
|
||||
dtm.blockCh = nil
|
||||
func (m *UnorderedTxManager) Start() {
|
||||
go m.purgeLoop()
|
||||
}
|
||||
|
||||
func (m *UnorderedTxManager) Close() error {
|
||||
close(m.blockCh)
|
||||
m.blockCh = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dtm *DedupTxHashManager) Contains(hash TxHash) (ok bool) {
|
||||
dtm.mutex.RLock()
|
||||
defer dtm.mutex.RUnlock()
|
||||
func (m *UnorderedTxManager) Contains(hash TxHash) bool{
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
_, ok = dtm.hashes[hash]
|
||||
return
|
||||
_, ok := m.txHashes[hash]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (dtm *DedupTxHashManager) Size() int {
|
||||
dtm.mutex.RLock()
|
||||
defer dtm.mutex.RUnlock()
|
||||
func (m *UnorderedTxManager) Size() int {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
return len(dtm.hashes)
|
||||
return len(m.txHashes)
|
||||
}
|
||||
|
||||
func (dtm *DedupTxHashManager) Add(hash TxHash, expire uint64) (ok bool) {
|
||||
dtm.mutex.Lock()
|
||||
defer dtm.mutex.Unlock()
|
||||
func (m *UnorderedTxManager) Add(hash TxHash, expire uint64) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
dtm.hashes[hash] = expire
|
||||
return
|
||||
m.txHashes[hash] = expire
|
||||
}
|
||||
|
||||
// OnNewBlock send the latest block number to the background purge loop,
|
||||
// it should be called in abci commit event.
|
||||
func (dtm *DedupTxHashManager) OnNewBlock(blockNumber uint64) {
|
||||
dtm.blockCh <- &blockNumber
|
||||
// OnNewBlock send the latest block number to the background purge loop, which
|
||||
// should be called in ABCI Commit event.
|
||||
func (m *UnorderedTxManager) OnNewBlock(blockHeight uint64) {
|
||||
m.blockCh <- blockHeight
|
||||
}
|
||||
|
||||
// purgeLoop removes expired tx hashes at background
|
||||
func (dtm *DedupTxHashManager) purgeLoop() error {
|
||||
// expiredTxs returns expired tx hashes based on the provided block height.
|
||||
func (m *UnorderedTxManager) expiredTxs(blockHeight uint64) []TxHash {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
var result []TxHash
|
||||
for txHash, expire := range m.txHashes {
|
||||
if blockHeight > expire {
|
||||
result = append(result, txHash)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (m *UnorderedTxManager) purge(txHashes []TxHash) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
for _, txHash := range txHashes {
|
||||
delete(m.txHashes, txHash)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// purgeLoop removes expired tx hashes in the background
|
||||
func (m *UnorderedTxManager) purgeLoop() error {
|
||||
for {
|
||||
blocks := channelBatchRecv(dtm.blockCh)
|
||||
blocks := channelBatchRecv(m.blockCh)
|
||||
if len(blocks) == 0 {
|
||||
// channel closed
|
||||
break
|
||||
}
|
||||
|
||||
latest := *blocks[len(blocks)-1]
|
||||
hashes := dtm.expired(latest)
|
||||
hashes := m.expired(latest)
|
||||
if len(hashes) > 0 {
|
||||
dtm.purge(hashes)
|
||||
m.purge(hashes)
|
||||
}
|
||||
|
||||
// avoid burning cpu in catching up phase
|
||||
@@ -133,28 +194,6 @@ func (dtm *DedupTxHashManager) purgeLoop() error {
|
||||
}
|
||||
}
|
||||
|
||||
// expired find out expired tx hashes based on latest block number
|
||||
func (dtm *DedupTxHashManager) expired(block uint64) []TxHash {
|
||||
dtm.mutex.RLock()
|
||||
defer dtm.mutex.RUnlock()
|
||||
|
||||
var result []TxHash
|
||||
for h, expire := range dtm.hashes {
|
||||
if block > expire {
|
||||
result = append(result, h)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (dtm *DedupTxHashManager) purge(hashes []TxHash) {
|
||||
dtm.mutex.Lock()
|
||||
defer dtm.mutex.Unlock()
|
||||
|
||||
for _, hash := range hashes {
|
||||
delete(dtm.hashes, hash)
|
||||
}
|
||||
}
|
||||
|
||||
// channelBatchRecv try to exhaust the channel buffer when it's not empty,
|
||||
// and block when it's empty.
|
||||
@@ -176,9 +215,11 @@ func channelBatchRecv[T any](ch <-chan *T) []*T {
|
||||
}
|
||||
```
|
||||
|
||||
### Ante Handlers
|
||||
### AnteHandler Decorator
|
||||
|
||||
Bypass the nonce decorator for un-ordered transactions.
|
||||
In order to facilitate bypassing nonce verification, we have to modify the existing
|
||||
`IncrementSequenceDecorator` AnteHandler decorator to skip the nonce verification
|
||||
when the transaction is marked as un-ordered.
|
||||
|
||||
```golang
|
||||
func (isd IncrementSequenceDecorator) AnteHandle(ctx sdk.Context, tx sdk.Tx, simulate bool, next sdk.AnteHandler) (sdk.Context, error) {
|
||||
@@ -186,25 +227,26 @@ func (isd IncrementSequenceDecorator) AnteHandle(ctx sdk.Context, tx sdk.Tx, sim
|
||||
return next(ctx, tx, simulate)
|
||||
}
|
||||
|
||||
// the previous logic
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
A decorator for the new logic.
|
||||
In addition, we need to introduce a new decorator to perform the un-ordered transaction
|
||||
verification and map lookup.
|
||||
|
||||
```golang
|
||||
type TxHash [32]byte
|
||||
|
||||
const (
|
||||
// MaxUnOrderedTTL defines the maximum ttl an un-order tx can set
|
||||
MaxUnOrderedTTL = 1024
|
||||
// DefaultMaxUnOrderedTTL defines the default maximum TTL an un-ordered transaction
|
||||
// can set.
|
||||
DefaultMaxUnOrderedTTL = 1024
|
||||
)
|
||||
|
||||
type DedupTxDecorator struct {
|
||||
m *DedupTxHashManager
|
||||
m *UnorderedTxManager
|
||||
maxUnOrderedTTL uint64
|
||||
}
|
||||
|
||||
func (dtd *DedupTxDecorator) AnteHandle(ctx sdk.Context, tx sdk.Tx, simulate bool, next sdk.AnteHandler) (sdk.Context, error) {
|
||||
func (d *DedupTxDecorator) AnteHandle(ctx sdk.Context, tx sdk.Tx, simulate bool, next sdk.AnteHandler) (sdk.Context, error) {
|
||||
// only apply to un-ordered transactions
|
||||
if !tx.UnOrdered() {
|
||||
return next(ctx, tx, simulate)
|
||||
@@ -214,18 +256,18 @@ func (dtd *DedupTxDecorator) AnteHandle(ctx sdk.Context, tx sdk.Tx, simulate boo
|
||||
return nil, errorsmod.Wrap(sdkerrors.ErrLogic, "unordered tx must set timeout-height")
|
||||
}
|
||||
|
||||
if tx.TimeoutHeight() > ctx.BlockHeight() + MaxUnOrderedTTL {
|
||||
return nil, errorsmod.Wrapf(sdkerrors.ErrLogic, "unordered tx ttl exceeds %d", MaxUnOrderedTTL)
|
||||
if tx.TimeoutHeight() > ctx.BlockHeight() + d.maxUnOrderedTTL {
|
||||
return nil, errorsmod.Wrapf(sdkerrors.ErrLogic, "unordered tx ttl exceeds %d", d.maxUnOrderedTTL)
|
||||
}
|
||||
|
||||
// check for duplicates
|
||||
if dtd.m.Contains(tx.Hash()) {
|
||||
if d.m.Contains(tx.Hash()) {
|
||||
return nil, errorsmod.Wrap(sdkerrors.ErrLogic, "tx is duplicated")
|
||||
}
|
||||
|
||||
if !ctx.IsCheckTx() {
|
||||
// a new tx included in the block, add the hash to the dictionary
|
||||
dtd.m.Add(tx.Hash(), tx.TimeoutHeight())
|
||||
// a new tx included in the block, add the hash to the unordered tx manager
|
||||
d.m.Add(tx.Hash(), tx.TimeoutHeight())
|
||||
}
|
||||
|
||||
return next(ctx, tx, simulate)
|
||||
@@ -234,16 +276,24 @@ func (dtd *DedupTxDecorator) AnteHandle(ctx sdk.Context, tx sdk.Tx, simulate boo
|
||||
|
||||
### `OnNewBlock`
|
||||
|
||||
Wire the `OnNewBlock` method of `DedupTxHashManager` into the BaseApp's ABCI Commit event.
|
||||
Wire the `OnNewBlock` method of `UnorderedTxManager` into the BaseApp's ABCI `Commit` event.
|
||||
|
||||
### Start Up
|
||||
### State Management
|
||||
|
||||
On start up, the node needs to re-fill the tx hash dictionary of `DedupTxHashManager`
|
||||
by scanning `MaxUnOrderedTTL` number of historical blocks for existing un-expired
|
||||
un-ordered transactions.
|
||||
On start up, the node needs to ensure the TxManager's state contains all un-expired
|
||||
transactions that have been committed to the chain. This is critical since if the
|
||||
state is not properly initialized, the node will not reject duplicate transactions
|
||||
and thus will not provide replay protection, and will likely get an app hash mismatch error.
|
||||
|
||||
An alternative design is to store the tx hash dictionary in kv store, then no need
|
||||
to warm up on start up.
|
||||
We propose to write all un-expired unordered transactions from the TxManager's to
|
||||
file on disk. On start up, the node will read this file and re-populate the TxManager's
|
||||
map. The write to file will happen when the node gracefully shuts down on `Close()`.
|
||||
|
||||
Note, this is not a perfect solution, in the context of store v1. With store v2,
|
||||
we can omit explicit file handling altogether and simply write the all the transactions
|
||||
to non-consensus state, i.e State Storage (SS).
|
||||
|
||||
Alternatively, we can write all the transactions to consensus state.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
Reference in New Issue
Block a user