Skip to content

Commit 005bb9a

Browse files
Fix: update gas coin reservation (#458)
* move coin reservation to preparePTBTransaction in TXM and make it atomic over all coins * update gas coin manager and associated tests
1 parent 7667cad commit 005bb9a

5 files changed

Lines changed: 273 additions & 47 deletions

File tree

relayer/txm/confirmer.go

Lines changed: 29 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ func handleSuccess(txm *SuiTxm, tx SuiTx) error {
104104
}
105105
txm.lggr.Infow("Transaction finalized", "transactionID", tx.TransactionID)
106106

107-
if err := txm.coinManager.ReleaseCoins(tx.TransactionID); err != nil {
107+
if err := tx.CoinManager.ReleaseCoins(tx.TransactionID); err != nil {
108108
// This error is not critical, can be safely ignored as the coins will auto-release after the default TTL
109109
txm.lggr.Debugw("Failed to release coins", "transactionID", tx.TransactionID, "error", err)
110110
}
@@ -117,8 +117,8 @@ func handleTransactionError(ctx context.Context, txm *SuiTxm, tx SuiTx, result *
117117

118118
txError := suierrors.ParseSuiErrorMessage(result.Error)
119119

120-
// Check if the error is a locked object error, mark the coin as reserved if it is not already
121-
// to avoid other transactions from using it
120+
// Check if the error is a locked object error, and exclude the coin so other
121+
// transactions (and this transaction's own coin-refresh retry) do not re-select it.
122122
if objectID, version, ok := suierrors.ExtractLockedObjectRef(result.Error); ok {
123123
txm.lggr.Infow("Detected locked coin at confirmation time",
124124
"txID", tx.TransactionID,
@@ -127,28 +127,20 @@ func handleTransactionError(ctx context.Context, txm *SuiTxm, tx SuiTx, result *
127127
)
128128

129129
coinID, err := transaction.ConvertSuiAddressStringToBytes(models.SuiAddress(objectID))
130-
if err == nil && !txm.coinManager.IsCoinReserved(*coinID) {
131-
// Coin lock duration
132-
expiry := DefaultLockedCoinTTL
133-
134-
// The coin is not recorded is not marked as reserved, mark it as reserved
135-
err = txm.coinManager.TryReserveCoins(ctx, tx.TransactionID, []transaction.SuiObjectRef{
136-
{
137-
ObjectId: *coinID,
138-
Version: 0,
139-
Digest: nil,
140-
},
141-
}, &expiry)
142-
143-
if err != nil {
144-
// This is not a critical error, so we continue
145-
txm.lggr.Debugw(
146-
"Failed to mark locked coin as reserved",
147-
"transactionID", tx.TransactionID,
148-
"objectID", objectID,
149-
"error", err,
150-
)
151-
}
130+
if err != nil {
131+
txm.lggr.Debugw(
132+
"Failed to convert locked coin object ID",
133+
"transactionID", tx.TransactionID,
134+
"objectID", objectID,
135+
"error", err,
136+
)
137+
} else {
138+
// Use a standalone, long-lived exclusion keyed by the coin itself (not by
139+
// txID). The upcoming coin-refresh retry calls ReleaseCoins(txID), which
140+
// would otherwise wipe a txID-scoped reservation for this coin; the
141+
// standalone exclusion survives that release and keeps the locked coin out
142+
// of re-selection until its TTL expires.
143+
tx.CoinManager.ReserveLockedCoin(*coinID, DefaultLockedCoinTTL)
152144
}
153145
}
154146

@@ -188,6 +180,16 @@ func handleGasBumpRetry(ctx context.Context, txm *SuiTxm, tx SuiTx, txError *sui
188180
return err
189181
}
190182

183+
// Release the current reservation before the transaction is rebuilt. UpdateTransactionGas
184+
// re-selects and re-reserves coins via preparePTBTransaction; without releasing first, the
185+
// rebuild would exclude this transaction's own still-reserved coins and either pick a
186+
// different set (orphaning the old reservation until its TTL) or fail if no other coins are
187+
// free. Releasing lets the rebuild re-select the same coins with the bumped budget.
188+
if err := tx.CoinManager.ReleaseCoins(tx.TransactionID); err != nil {
189+
// Not critical - coins auto-release after TTL.
190+
txm.lggr.Debugw("Failed to release coins before gas bump rebuild", "transactionID", tx.TransactionID, "error", err)
191+
}
192+
191193
if err := txm.transactionRepository.UpdateTransactionGas(ctx, txm.keystoreService, txm.suiGateway, tx.TransactionID, &updatedGas); err != nil {
192194
txm.lggr.Errorw("Failed to update transaction gas", "transactionID", tx.TransactionID, "error", err)
193195
return err
@@ -206,7 +208,7 @@ func handleCoinRefreshRetry(ctx context.Context, txm *SuiTxm, tx SuiTx, txError
206208
txm.lggr.Infow("Coin refresh strategy - refreshing coins for locked coin error", "transactionID", tx.TransactionID)
207209

208210
// Release the old coins that are locked
209-
if err := txm.coinManager.ReleaseCoins(tx.TransactionID); err != nil {
211+
if err := tx.CoinManager.ReleaseCoins(tx.TransactionID); err != nil {
210212
// This is not critical - coins will auto-release after TTL
211213
txm.lggr.Debugw("Failed to release old coins", "transactionID", tx.TransactionID, "error", err)
212214
}
@@ -275,7 +277,7 @@ func markTransactionFailed(txm *SuiTxm, tx SuiTx, txError *suierrors.SuiError) e
275277

276278
txm.lggr.Infow("Transaction failed", "transactionID", tx.TransactionID)
277279

278-
if err := txm.coinManager.ReleaseCoins(tx.TransactionID); err != nil {
280+
if err := tx.CoinManager.ReleaseCoins(tx.TransactionID); err != nil {
279281
// This error is not critical, can be safely ignored as the coins will auto-release after the default TTL
280282
txm.lggr.Debugw("Failed to release coins", "transactionID", tx.TransactionID, "error", err)
281283
}

relayer/txm/gas_coin_manager.go

Lines changed: 75 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/hex"
66
"fmt"
7+
"sync"
78
"time"
89

910
"github.com/block-vision/sui-go-sdk/models"
@@ -21,15 +22,25 @@ const (
2122

2223
type GasCoinManager interface {
2324
TryReserveCoins(ctx context.Context, txID string, paymentCoins []transaction.SuiObjectRef, expiry *time.Duration) error
25+
ReserveLockedCoin(coinID models.SuiAddressBytes, ttl time.Duration)
2426
ReleaseCoins(txID string) error
2527
IsCoinReserved(coinID models.SuiAddressBytes) bool
2628
}
2729

30+
// lockedCoinKeyPrefix namespaces the cache entries used to exclude coins that the
31+
// chain reported as locked. These entries are deliberately kept separate from the
32+
// txID-associated reservations so that ReleaseCoins (which only unwinds a txID's
33+
// reservation) can never remove a locked-coin exclusion before its TTL expires.
34+
const lockedCoinKeyPrefix = "locked:"
35+
2836
// SuiGasCoinManager is the concrete implementation of GasCoinManager.
2937
type SuiGasCoinManager struct {
3038
lggr logger.Logger
3139
client client.SuiPTBClient
3240
coinsCache *cache.Cache
41+
// mu guards the whole check-then-reserve sequence so that reservations are
42+
// atomic and all-or-nothing across the full coin set.
43+
mu sync.Mutex
3344
}
3445

3546
// NewGasCoinManager creates a new SuiGasCoinManager.
@@ -48,29 +59,62 @@ func (m *SuiGasCoinManager) TryReserveCoins(
4859
coinIDs []transaction.SuiObjectRef,
4960
expiry *time.Duration,
5061
) error {
62+
m.mu.Lock()
63+
defer m.mu.Unlock()
64+
65+
// First pass: verify the entire set is available before writing anything.
66+
// Holding the lock across the check and the writes closes the check-then-set
67+
// race between concurrent callers, and validating upfront makes the
68+
// reservation all-or-nothing (no partial reservations leaked on failure).
5169
for _, coin := range coinIDs {
52-
if m.IsCoinReserved(coin.ObjectId) {
70+
if m.isCoinReserved(coin.ObjectId) {
5371
return fmt.Errorf("coin %s is already reserved", hex.EncodeToString(coin.ObjectId[:]))
5472
}
73+
}
5574

56-
coinID := hex.EncodeToString(coin.ObjectId[:])
57-
expiresAt := DefaultAllocationTimeout
58-
59-
if expiry != nil {
60-
expiresAt = *expiry
61-
}
75+
expiresAt := DefaultAllocationTimeout
76+
if expiry != nil {
77+
expiresAt = *expiry
78+
}
6279

80+
// Second pass: reserve the full set now that every coin is known to be free.
81+
for _, coin := range coinIDs {
82+
coinID := hex.EncodeToString(coin.ObjectId[:])
6383
m.coinsCache.Set(coinID, true, expiresAt)
6484
}
6585

66-
m.coinsCache.Set(txID, coinIDs, DefaultAllocationTimeout)
86+
// Track the reserved set under the txID using the same TTL so ReleaseCoins can
87+
// always unwind the individual coin entries for the reservation's lifetime.
88+
m.coinsCache.Set(txID, coinIDs, expiresAt)
6789

6890
return nil
6991
}
7092

93+
// ReserveLockedCoin marks a single coin object as excluded for the given TTL,
94+
// independently of any transaction ID. Unlike TryReserveCoins, the exclusion is
95+
// not associated with a txID and therefore is never removed by ReleaseCoins; it
96+
// persists until the TTL expires. This is used to exclude a coin that the chain
97+
// reported as locked so it is not re-selected while the on-chain lock persists,
98+
// even if that same coin was previously reserved as a payment coin (whose
99+
// reservation is released during the coin-refresh retry).
100+
func (m *SuiGasCoinManager) ReserveLockedCoin(coinID models.SuiAddressBytes, ttl time.Duration) {
101+
m.mu.Lock()
102+
defer m.mu.Unlock()
103+
104+
m.coinsCache.Set(lockedCoinCacheKey(coinID), true, ttl)
105+
}
106+
107+
// lockedCoinCacheKey returns the namespaced cache key for a locked-coin exclusion.
108+
func lockedCoinCacheKey(coinID models.SuiAddressBytes) string {
109+
return lockedCoinKeyPrefix + hex.EncodeToString(coinID[:])
110+
}
111+
71112
// ReleaseCoins only releases reservations stored under a txID key (txID -> []SuiObjectRef).
72113
// It does not work with coinID keys (coinID -> bool) and cannot unlock those entries directly.
73114
func (m *SuiGasCoinManager) ReleaseCoins(txID string) error {
115+
m.mu.Lock()
116+
defer m.mu.Unlock()
117+
74118
coinIDs, ok := m.coinsCache.Get(txID)
75119
if !ok {
76120
return fmt.Errorf("no coins reserved for transaction %s", txID)
@@ -86,7 +130,27 @@ func (m *SuiGasCoinManager) ReleaseCoins(txID string) error {
86130
}
87131

88132
func (m *SuiGasCoinManager) IsCoinReserved(coinID models.SuiAddressBytes) bool {
89-
coinIDStr := hex.EncodeToString(coinID[:])
90-
isReserved, found := m.coinsCache.Get(coinIDStr)
91-
return found && isReserved.(bool)
133+
m.mu.Lock()
134+
defer m.mu.Unlock()
135+
return m.isCoinReserved(coinID)
136+
}
137+
138+
// isCoinReserved is the lock-free implementation of IsCoinReserved. Callers must
139+
// already hold m.mu. A coin is considered reserved if it has either a txID-scoped
140+
// reservation (TryReserveCoins) or a standalone locked-coin exclusion
141+
// (ReserveLockedCoin).
142+
func (m *SuiGasCoinManager) isCoinReserved(coinID models.SuiAddressBytes) bool {
143+
if isReserved, found := m.coinsCache.Get(hex.EncodeToString(coinID[:])); found {
144+
if reserved, ok := isReserved.(bool); ok && reserved {
145+
return true
146+
}
147+
}
148+
149+
if locked, found := m.coinsCache.Get(lockedCoinCacheKey(coinID)); found {
150+
if isLocked, ok := locked.(bool); ok && isLocked {
151+
return true
152+
}
153+
}
154+
155+
return false
92156
}

relayer/txm/gas_coin_manager_test.go

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ package txm_test
55
import (
66
"context"
77
"fmt"
8+
"sync"
9+
"sync/atomic"
810
"testing"
911
"time"
1012

@@ -18,6 +20,14 @@ import (
1820
"github.com/smartcontractkit/chainlink-sui/relayer/txm"
1921
)
2022

23+
// mustCoinRef builds a SuiObjectRef from a 0x-prefixed 32-byte address string.
24+
func mustCoinRef(t *testing.T, addr string) transaction.SuiObjectRef {
25+
t.Helper()
26+
b, err := transaction.ConvertSuiAddressStringToBytes(models.SuiAddress(addr))
27+
require.NoError(t, err)
28+
return transaction.SuiObjectRef{ObjectId: *b, Version: 1, Digest: nil}
29+
}
30+
2131
func TestSuiGasCoinManager_TryReserveCoins(t *testing.T) {
2232
lggr := logger.Test(t)
2333
mockClient := &testutils.FakeSuiPTBClient{}
@@ -137,3 +147,137 @@ func TestSuiGasCoinManager_TryReserveCoins(t *testing.T) {
137147
}, 45*time.Second, 10*time.Second)
138148
})
139149
}
150+
151+
// TestSuiGasCoinManager_AtomicReservation exercises the atomic, all-or-nothing
152+
// behaviour of TryReserveCoins that guards against two transactions reserving the
153+
// same gas coin.
154+
func TestSuiGasCoinManager_AtomicReservation(t *testing.T) {
155+
lggr := logger.Test(t)
156+
mockClient := &testutils.FakeSuiPTBClient{}
157+
ctx := context.Background()
158+
159+
t.Run("concurrent reservations of the same coin: exactly one wins", func(t *testing.T) {
160+
gcm := txm.NewGasCoinManager(lggr, mockClient)
161+
coin := mustCoinRef(t, fmt.Sprintf("0x%064x", 1))
162+
163+
const numGoroutines = 50
164+
var wg sync.WaitGroup
165+
var successes atomic.Int32
166+
start := make(chan struct{})
167+
168+
for i := 0; i < numGoroutines; i++ {
169+
wg.Add(1)
170+
go func(i int) {
171+
defer wg.Done()
172+
<-start // release all goroutines at once to maximize contention
173+
txID := fmt.Sprintf("tx-%d", i)
174+
if err := gcm.TryReserveCoins(ctx, txID, []transaction.SuiObjectRef{coin}, nil); err == nil {
175+
successes.Add(1)
176+
}
177+
}(i)
178+
}
179+
180+
close(start)
181+
wg.Wait()
182+
183+
assert.Equal(t, int32(1), successes.Load(), "exactly one caller should reserve the coin")
184+
assert.True(t, gcm.IsCoinReserved(coin.ObjectId))
185+
})
186+
187+
t.Run("reservation is all-or-nothing on partial overlap", func(t *testing.T) {
188+
gcm := txm.NewGasCoinManager(lggr, mockClient)
189+
coinA := mustCoinRef(t, fmt.Sprintf("0x%064x", 0xAA))
190+
coinB := mustCoinRef(t, fmt.Sprintf("0x%064x", 0xBB))
191+
192+
// Reserve coinA under tx1.
193+
require.NoError(t, gcm.TryReserveCoins(ctx, "tx1", []transaction.SuiObjectRef{coinA}, nil))
194+
195+
// tx2 requests [coinB, coinA]; coinA is already taken, so the whole call must
196+
// fail without leaving coinB reserved.
197+
err := gcm.TryReserveCoins(ctx, "tx2", []transaction.SuiObjectRef{coinB, coinA}, nil)
198+
require.Error(t, err)
199+
assert.Contains(t, err.Error(), "is already reserved")
200+
assert.False(t, gcm.IsCoinReserved(coinB.ObjectId), "coinB must not be leaked when the reservation fails")
201+
202+
// The failed reservation left no txID mapping to release.
203+
assert.Error(t, gcm.ReleaseCoins("tx2"))
204+
205+
// coinB is still free and can be claimed by another transaction.
206+
assert.NoError(t, gcm.TryReserveCoins(ctx, "tx3", []transaction.SuiObjectRef{coinB}, nil))
207+
})
208+
209+
t.Run("concurrent disjoint reservations all succeed", func(t *testing.T) {
210+
gcm := txm.NewGasCoinManager(lggr, mockClient)
211+
212+
const numGoroutines = 50
213+
// Pre-build coin refs so the goroutines don't call require/t inside them.
214+
coins := make([]transaction.SuiObjectRef, numGoroutines)
215+
for i := range coins {
216+
coins[i] = mustCoinRef(t, fmt.Sprintf("0x%064x", 0x1000+i))
217+
}
218+
219+
var wg sync.WaitGroup
220+
var successes atomic.Int32
221+
start := make(chan struct{})
222+
223+
for i := 0; i < numGoroutines; i++ {
224+
wg.Add(1)
225+
go func(i int) {
226+
defer wg.Done()
227+
<-start
228+
if err := gcm.TryReserveCoins(ctx, fmt.Sprintf("tx-%d", i), []transaction.SuiObjectRef{coins[i]}, nil); err == nil {
229+
successes.Add(1)
230+
}
231+
}(i)
232+
}
233+
234+
close(start)
235+
wg.Wait()
236+
237+
assert.Equal(t, int32(numGoroutines), successes.Load(), "all disjoint reservations should succeed")
238+
})
239+
}
240+
241+
// TestSuiGasCoinManager_ReserveLockedCoin verifies that a locked-coin exclusion is
242+
// independent of any txID reservation and survives ReleaseCoins, which is what keeps
243+
// a chain-locked coin out of re-selection during the coin-refresh retry.
244+
func TestSuiGasCoinManager_ReserveLockedCoin(t *testing.T) {
245+
lggr := logger.Test(t)
246+
mockClient := &testutils.FakeSuiPTBClient{}
247+
ctx := context.Background()
248+
249+
t.Run("exclusion survives ReleaseCoins of the payment reservation", func(t *testing.T) {
250+
gcm := txm.NewGasCoinManager(lggr, mockClient)
251+
coin := mustCoinRef(t, fmt.Sprintf("0x%064x", 0xCC))
252+
253+
// The coin was reserved as a normal payment coin under the transaction's ID.
254+
require.NoError(t, gcm.TryReserveCoins(ctx, "tx-locked", []transaction.SuiObjectRef{coin}, nil))
255+
assert.True(t, gcm.IsCoinReserved(coin.ObjectId))
256+
257+
// The chain reports it as locked; add a standalone exclusion (as handleTransactionError does).
258+
gcm.ReserveLockedCoin(coin.ObjectId, txm.DefaultLockedCoinTTL)
259+
260+
// The coin-refresh retry releases the payment reservation for this txID.
261+
require.NoError(t, gcm.ReleaseCoins("tx-locked"))
262+
263+
// The standalone exclusion must survive, so the coin is still excluded and
264+
// cannot be re-selected or re-reserved by any transaction.
265+
assert.True(t, gcm.IsCoinReserved(coin.ObjectId), "locked coin exclusion must survive ReleaseCoins")
266+
err := gcm.TryReserveCoins(ctx, "tx-other", []transaction.SuiObjectRef{coin}, nil)
267+
assert.Error(t, err, "a locked coin must not be reservable by another transaction")
268+
})
269+
270+
t.Run("exclusion is created even when the coin was never a payment reservation", func(t *testing.T) {
271+
gcm := txm.NewGasCoinManager(lggr, mockClient)
272+
coin := mustCoinRef(t, fmt.Sprintf("0x%064x", 0xDD))
273+
274+
assert.False(t, gcm.IsCoinReserved(coin.ObjectId))
275+
gcm.ReserveLockedCoin(coin.ObjectId, txm.DefaultLockedCoinTTL)
276+
assert.True(t, gcm.IsCoinReserved(coin.ObjectId))
277+
278+
// There is no txID mapping for a locked-only exclusion, so ReleaseCoins keyed
279+
// by the coin's own hex finds nothing and the exclusion remains.
280+
assert.Error(t, gcm.ReleaseCoins(fmt.Sprintf("0x%064x", 0xDD)))
281+
assert.True(t, gcm.IsCoinReserved(coin.ObjectId))
282+
})
283+
}

0 commit comments

Comments
 (0)