Skip to content

Commit b80fff7

Browse files
Feat: Add a coin reservation abstraction to prevent equivocation within the same relayer (#304)
* add a GasCoinManager abstraction * add locked coin error parsing to reserve coins * allow setting an expiry when reserving coins * update GasCoinManager * fix txm tests * release coins on confirmer marking transaction as failed or succeeded * error checking * add separate txm error strategy for simple coin refreshing (used for locked coins) * nit --------- Co-authored-by: stackman27 <[email protected]>
1 parent e90b556 commit b80fff7

File tree

13 files changed

+564
-32
lines changed

13 files changed

+564
-32
lines changed

relayer/client/suierrors/errors.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ determine if an error is considered retryable (e.g. IsRetryable) according to Su
1313
package suierrors
1414

1515
import (
16+
"regexp"
17+
"strconv"
1618
"strings"
1719
)
1820

@@ -28,6 +30,7 @@ const (
2830
CheckpointAndConsensusErrors
2931
PublishingErrors
3032
SoftBundleErrors
33+
LockCoinErrors
3134
UnknownErrors
3235
)
3336

@@ -47,6 +50,8 @@ func (c ErrorCategory) String() string {
4750
return "Publishing Errors"
4851
case SoftBundleErrors:
4952
return "Soft Bundle Errors"
53+
case LockCoinErrors:
54+
return "Lock Coins Errors"
5055
default:
5156
return "Unknown Error Category"
5257
}
@@ -146,6 +151,9 @@ var ErrNoSharedObjectError = NewSuiError(SoftBundleErrors, "NoSharedObjectError"
146151
var ErrAlreadyExecutedError = NewSuiError(SoftBundleErrors, "AlreadyExecutedError")
147152
var ErrCertificateAlreadyProcessed = NewSuiError(SoftBundleErrors, "CertificateAlreadyProcessed")
148153

154+
// Lock Coins error
155+
var ErrLockCoins = NewSuiError(LockCoinErrors, "already locked by a different transaction")
156+
149157
// Unknown Error
150158
var ErrUnknownError = NewSuiError(UnknownErrors, "UnknownError")
151159

@@ -176,6 +184,7 @@ var suiErrorMappings = []struct {
176184
{ErrUnsupported.Error(), ErrUnsupported},
177185
{ErrMoveFunctionInputError.Error(), ErrMoveFunctionInputError},
178186
{ErrPostRandomCommandRestrictions.Error(), ErrPostRandomCommandRestrictions},
187+
{ErrObjectVersionUnavailableForConsumption.Error(), ErrObjectVersionUnavailableForConsumption},
179188

180189
// Gas Errors
181190
{ErrMissingGasPayment.Error(), ErrMissingGasPayment},
@@ -225,10 +234,17 @@ var suiErrorMappings = []struct {
225234
{ErrAlreadyExecutedError.Error(), ErrAlreadyExecutedError},
226235
{ErrCertificateAlreadyProcessed.Error(), ErrCertificateAlreadyProcessed},
227236

237+
// Lock Coins error
238+
{ErrLockCoins.Error(), ErrLockCoins},
239+
228240
// Unknown Error
229241
{ErrUnknownError.Error(), ErrUnknownError},
230242
}
231243

244+
var lockedObjectRe = regexp.MustCompile(
245+
`Object\s+\((0x[0-9a-fA-F]+),\s*SequenceNumber\((\d+)\)`,
246+
)
247+
232248
// ParseSuiErrorMessage maps a raw RPC error message to a structured error.
233249
// It iterates over the known substrings in suiErrorMappings. If a substring is found,
234250
// the corresponding error is returned. Otherwise, it returns an "Unknown error".
@@ -242,6 +258,25 @@ func ParseSuiErrorMessage(msg string) *SuiError {
242258
return NewSuiError(UnknownErrors, msg)
243259
}
244260

261+
// ExtractLockedObjectRef parses a Sui equivocation / lock error message and returns
262+
// (objectID, version, ok).
263+
func ExtractLockedObjectRef(msg string) (string, uint64, bool) {
264+
m := lockedObjectRe.FindStringSubmatch(msg)
265+
if len(m) != 3 {
266+
return "", 0, false
267+
}
268+
269+
objID := m[1]
270+
verStr := m[2]
271+
272+
ver, err := strconv.ParseUint(verStr, 10, 64)
273+
if err != nil {
274+
return objID, 0, false
275+
}
276+
277+
return objID, ver, true
278+
}
279+
245280
var ExponentialBackoffErrors = []error{
246281
ErrPackageVerificationTimeout,
247282
ErrVerifiedCheckpointNotFound,
@@ -262,3 +297,7 @@ var GasBumpErrors = []error{
262297
ErrInsufficientGas,
263298
ErrGasPriceTooHigh,
264299
}
300+
301+
var CoinRefreshErrors = []error{
302+
ErrLockCoins,
303+
}

relayer/txm/broadcaster.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,13 @@ func broadcastTransactions(loopCtx context.Context, txm *SuiTxm, transactions []
9292
// Default to retrying the transaction
9393
newState := StateRetriable
9494

95+
// Attach the transaction submission error to the transaction object. If it remains marked as retriable,
96+
// the "confirmer" loop will pick it up and potentially retry it.
97+
err = txm.transactionRepository.UpdateTransactionBroadcastError(tx.TransactionID, err.Error())
98+
if err != nil {
99+
txm.lggr.Errorw("Failed to update transaction broadcast error", "txID", tx.TransactionID, "error", err)
100+
}
101+
95102
if resp.Effects.Status.Status != "" && resp.TxDigest == "" {
96103
// Update the transaction state to Failed if the digest is empty
97104
// An empty digest indicates a total failure of the transaction
@@ -102,7 +109,6 @@ func broadcastTransactions(loopCtx context.Context, txm *SuiTxm, transactions []
102109
// Attempt updating the state if it has changed
103110
if tx.State != newState {
104111
err = txm.transactionRepository.ChangeState(tx.TransactionID, newState)
105-
106112
if err != nil {
107113
txm.lggr.Errorw("Failed to change transaction state", "txID", tx.TransactionID, "error", err)
108114
}
@@ -118,6 +124,8 @@ func broadcastTransactions(loopCtx context.Context, txm *SuiTxm, transactions []
118124
continue
119125
}
120126

127+
// Update the transaction state to submitted as we have not yet confirmed its status.
128+
// The "confirmer" loop checks the transactions statuses and possibly marks them as finalized.
121129
err = txm.transactionRepository.ChangeState(tx.TransactionID, StateSubmitted)
122130
if err != nil {
123131
txm.lggr.Errorw("Failed to change transaction state to Submitted", "txID", tx.TransactionID, "error", err)

relayer/txm/confirmer.go

Lines changed: 103 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"math"
66
"time"
77

8+
"github.com/block-vision/sui-go-sdk/models"
9+
"github.com/block-vision/sui-go-sdk/transaction"
810
"github.com/smartcontractkit/chainlink-common/pkg/services"
911

1012
"github.com/smartcontractkit/chainlink-sui/relayer/client"
@@ -56,14 +58,26 @@ func checkConfirmations(loopCtx context.Context, txm *SuiTxm) {
5658

5759
for _, tx := range inFlightTransactions {
5860
txm.lggr.Debugw("Checking transaction confirmations", "transactionID", tx.TransactionID)
59-
if tx.State != StateSubmitted {
60-
continue
61-
}
6261

63-
txm.lggr.Debugw("Transaction is in submitted state", "transactionID", tx.TransactionID)
64-
resp, err := txm.suiGateway.GetTransactionStatus(loopCtx, tx.Digest)
65-
if err != nil {
66-
txm.lggr.Errorw("Error getting transaction status", "transactionID", tx.TransactionID, "error", err)
62+
var resp client.TransactionResult
63+
var err error
64+
65+
if tx.State == StateSubmitted {
66+
txm.lggr.Debugw("Transaction is in submitted state", "transactionID", tx.TransactionID)
67+
resp, err = txm.suiGateway.GetTransactionStatus(loopCtx, tx.Digest)
68+
if err != nil {
69+
txm.lggr.Errorw("Error getting transaction status", "transactionID", tx.TransactionID, "error", err)
70+
continue
71+
}
72+
} else if tx.State == StateRetriable {
73+
txm.lggr.Debugw("Transaction is in retriable state", "transactionID", tx.TransactionID)
74+
// Check if it's a broadcast error (never made it onchain)
75+
if tx.BroadcastError == "" {
76+
continue
77+
}
78+
resp.Status = failure
79+
resp.Error = tx.BroadcastError
80+
} else {
6781
continue
6882
}
6983

@@ -88,15 +102,53 @@ func handleSuccess(txm *SuiTxm, tx SuiTx) error {
88102
return err
89103
}
90104
txm.lggr.Infow("Transaction finalized", "transactionID", tx.TransactionID)
105+
106+
if err := txm.coinManager.ReleaseCoins(tx.TransactionID); err != nil {
107+
// This error is not critical, can be safely ignored as the coins will auto-release after the default TTL
108+
txm.lggr.Debugw("Failed to release coins", "transactionID", tx.TransactionID, "error", err)
109+
}
110+
91111
return nil
92112
}
93113

94114
func handleTransactionError(ctx context.Context, txm *SuiTxm, tx SuiTx, result *client.TransactionResult) error {
95115
txm.lggr.Debugw("Handling transaction error", "transactionID", tx.TransactionID, "error", result.Error)
96116

97117
txError := suierrors.ParseSuiErrorMessage(result.Error)
98-
if txError == nil {
99-
txError = suierrors.NewSuiError(suierrors.UnknownErrors, result.Error)
118+
119+
// Check if the error is a locked object error, mark the coin as reserved if it is not already
120+
// to avoid other transactions from using it
121+
if objectID, version, ok := suierrors.ExtractLockedObjectRef(result.Error); ok {
122+
txm.lggr.Infow("Detected locked coin at confirmation time",
123+
"txID", tx.TransactionID,
124+
"objectID", objectID,
125+
"version", version,
126+
)
127+
128+
coinID, err := transaction.ConvertSuiAddressStringToBytes(models.SuiAddress(objectID))
129+
if err == nil && !txm.coinManager.IsCoinReserved(*coinID) {
130+
// Coin lock duration
131+
expiry := DefaultLockedCoinTTL
132+
133+
// The coin is not recorded is not marked as reserved, mark it as reserved
134+
err = txm.coinManager.TryReserveCoins(ctx, tx.TransactionID, []transaction.SuiObjectRef{
135+
{
136+
ObjectId: *coinID,
137+
Version: 0,
138+
Digest: nil,
139+
},
140+
}, &expiry)
141+
142+
if err != nil {
143+
// This is not a critical error, so we continue
144+
txm.lggr.Debugw(
145+
"Failed to mark locked coin as reserved",
146+
"transactionID", tx.TransactionID,
147+
"objectID", objectID,
148+
"error", err,
149+
)
150+
}
151+
}
100152
}
101153

102154
isRetryable, strategy := txm.retryManager.IsRetryable(&tx, result.Error)
@@ -111,6 +163,8 @@ func handleTransactionError(ctx context.Context, txm *SuiTxm, tx SuiTx, result *
111163
return handleExponentialBackoffRetry(txm, tx)
112164
case GasBump:
113165
return handleGasBumpRetry(ctx, txm, tx, txError)
166+
case CoinRefresh:
167+
return handleCoinRefreshRetry(ctx, txm, tx, txError)
114168
case NoRetry:
115169
return markTransactionFailed(txm, tx, txError)
116170
default:
@@ -147,6 +201,40 @@ func handleGasBumpRetry(ctx context.Context, txm *SuiTxm, tx SuiTx, txError *sui
147201
return nil
148202
}
149203

204+
func handleCoinRefreshRetry(ctx context.Context, txm *SuiTxm, tx SuiTx, txError *suierrors.SuiError) error {
205+
txm.lggr.Infow("Coin refresh strategy - refreshing coins for locked coin error", "transactionID", tx.TransactionID)
206+
207+
// Release the old coins that are locked
208+
if err := txm.coinManager.ReleaseCoins(tx.TransactionID); err != nil {
209+
// This is not critical - coins will auto-release after TTL
210+
txm.lggr.Debugw("Failed to release old coins", "transactionID", tx.TransactionID, "error", err)
211+
}
212+
213+
// Get the current transaction to ensure we have the latest state
214+
currentTx, err := txm.transactionRepository.GetTransaction(tx.TransactionID)
215+
if err != nil {
216+
txm.lggr.Errorw("Failed to get current transaction", "transactionID", tx.TransactionID, "error", err)
217+
return err
218+
}
219+
220+
// Calling UpdateTransactionGas will also update the gas coins used as the transaction gets re-built
221+
// with new (unlocked) coins.
222+
// Call chain: UpdateTransactionGas -> UpdateBSCPayload -> preparePTBTransaction (this refreshes the coins).
223+
if err := txm.transactionRepository.UpdateTransactionGas(ctx, txm.keystoreService, txm.suiGateway, tx.TransactionID, currentTx.Metadata.GasLimit); err != nil {
224+
txm.lggr.Errorw("Failed to update transaction with refreshed coins", "transactionID", tx.TransactionID, "error", err)
225+
return err
226+
}
227+
228+
if err := txm.transactionRepository.ChangeState(tx.TransactionID, StateRetriable); err != nil {
229+
txm.lggr.Errorw("Failed to update transaction state", "transactionID", tx.TransactionID, "error", err)
230+
return err
231+
}
232+
233+
txm.lggr.Infow("Transaction refreshed with new coins", "transactionID", tx.TransactionID)
234+
txm.broadcastChannel <- tx.TransactionID
235+
return nil
236+
}
237+
150238
func handleExponentialBackoffRetry(txm *SuiTxm, tx SuiTx) error {
151239
delaySeconds := float64(defaultExponentialBackoffDelaySeconds) * math.Pow(2, float64(tx.Attempt))
152240

@@ -185,5 +273,11 @@ func markTransactionFailed(txm *SuiTxm, tx SuiTx, txError *suierrors.SuiError) e
185273
}
186274

187275
txm.lggr.Infow("Transaction failed", "transactionID", tx.TransactionID)
276+
277+
if err := txm.coinManager.ReleaseCoins(tx.TransactionID); err != nil {
278+
// This error is not critical, can be safely ignored as the coins will auto-release after the default TTL
279+
txm.lggr.Debugw("Failed to release coins", "transactionID", tx.TransactionID, "error", err)
280+
}
281+
188282
return nil
189283
}

relayer/txm/confirmer_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ func TestConfirmerRoutine_GasBump(t *testing.T) {
5656
// Create a fake gas manager that returns an updated gas value.
5757
maxGasBudget := big.NewInt(12000000)
5858
gasManager := txm.NewSuiGasManager(lggr, fakeClient, *maxGasBudget, 0)
59+
coinManager := txm.NewGasCoinManager(lggr, fakeClient)
5960

6061
// For the confirmer, the keystore is not used; create a dummy signer.
6162
keystoreInstance := testutils.NewTestKeystore(t)
@@ -119,6 +120,7 @@ func TestConfirmerRoutine_GasBump(t *testing.T) {
119120
TxError: nil,
120121
GasBudget: maxGasBudget.Uint64(),
121122
Ptb: ptb,
123+
CoinManager: coinManager,
122124
}
123125
err = store.AddTransaction(tx)
124126
require.NoError(t, err)
@@ -184,6 +186,7 @@ func TestConfirmerRoutine_SuccessfulGasBumpAfterTwoAttempts(t *testing.T) {
184186
maxGasBudget := big.NewInt(10000000)
185187
percentIncrease := int64(120) // 120% (20% increase) per bump
186188
gasManager := txm.NewSuiGasManager(lggr, fakeClient, *maxGasBudget, percentIncrease)
189+
coinManager := txm.NewGasCoinManager(lggr, fakeClient)
187190

188191
// Create keystore
189192
keystoreInstance := testutils.NewTestKeystore(t)
@@ -248,6 +251,7 @@ func TestConfirmerRoutine_SuccessfulGasBumpAfterTwoAttempts(t *testing.T) {
248251
TxError: nil,
249252
GasBudget: maxGasBudget.Uint64(), // Use max budget to allow for gas bumps
250253
Ptb: ptb,
254+
CoinManager: coinManager,
251255
}
252256
err = store.AddTransaction(tx)
253257
require.NoError(t, err)
@@ -360,6 +364,7 @@ func TestConfirmerRoutine_ExponentialBackoffRetry(t *testing.T) {
360364

361365
// Add a transaction in StateSubmitted with a known digest
362366
txID := "tx-exponential-backoff-retry-test"
367+
coinManager := txm.NewGasCoinManager(lggr, fakeClient)
363368
tx := txm.SuiTx{
364369
TransactionID: txID,
365370
Sender: address,
@@ -376,6 +381,7 @@ func TestConfirmerRoutine_ExponentialBackoffRetry(t *testing.T) {
376381
TxError: nil,
377382
GasBudget: initialGasBudget,
378383
Ptb: ptb,
384+
CoinManager: coinManager,
379385
}
380386
err = store.AddTransaction(tx)
381387
require.NoError(t, err)

0 commit comments

Comments
 (0)