@@ -5,6 +5,8 @@ package txm_test
55import (
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+
2131func 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