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:
Aleksandr Bezobchuk
2024-01-04 16:58:52 +00:00
committed by GitHub
co-authored by yihuang Facundo
parent d520fcf7cc
commit 6ec53aaf54
26 changed files with 1518 additions and 311 deletions
+136 -86
View File
@@ -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