mirror of
https://github.com/sonr-io/sonr.git
synced 2026-08-02 09:21:39 +00:00
358 lines
10 KiB
Go
358 lines
10 KiB
Go
// Package tx provides transaction broadcasting utilities for the Sonr client SDK.
|
|
package tx
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"google.golang.org/grpc"
|
|
|
|
"github.com/cosmos/cosmos-sdk/types/tx"
|
|
|
|
"github.com/sonr-io/sonr/client/config"
|
|
"github.com/sonr-io/sonr/client/errors"
|
|
)
|
|
|
|
// BroadcastMode defines different transaction broadcasting modes.
|
|
type BroadcastMode string
|
|
|
|
const (
|
|
// BroadcastModeSync waits for the transaction to be included in a block and returns the result.
|
|
BroadcastModeSync BroadcastMode = "sync"
|
|
|
|
// BroadcastModeAsync submits the transaction and returns immediately without waiting.
|
|
BroadcastModeAsync BroadcastMode = "async"
|
|
|
|
// BroadcastModeBlock waits for the transaction to be committed and returns the full result.
|
|
BroadcastModeBlock BroadcastMode = "block"
|
|
)
|
|
|
|
// Broadcaster provides an interface for broadcasting transactions with different modes and retry logic.
|
|
type Broadcaster interface {
|
|
// Broadcasting operations
|
|
Broadcast(ctx context.Context, txBytes []byte, mode BroadcastMode) (*BroadcastResult, error)
|
|
BroadcastSync(ctx context.Context, txBytes []byte) (*BroadcastResult, error)
|
|
BroadcastAsync(ctx context.Context, txBytes []byte) (*BroadcastResult, error)
|
|
BroadcastBlock(ctx context.Context, txBytes []byte) (*BroadcastResult, error)
|
|
|
|
// Retry and monitoring
|
|
BroadcastWithRetry(ctx context.Context, txBytes []byte, mode BroadcastMode, maxRetries int) (*BroadcastResult, error)
|
|
WaitForConfirmation(ctx context.Context, txHash string, timeout time.Duration) (*TxConfirmation, error)
|
|
|
|
// Configuration
|
|
WithRetryConfig(config RetryConfig) Broadcaster
|
|
WithTimeout(timeout time.Duration) Broadcaster
|
|
}
|
|
|
|
// TxConfirmation contains information about a confirmed transaction.
|
|
type TxConfirmation struct {
|
|
TxHash string
|
|
BlockHeight int64
|
|
BlockTime time.Time
|
|
Code uint32
|
|
Log string
|
|
GasWanted int64
|
|
GasUsed int64
|
|
Events []Event
|
|
}
|
|
|
|
// RetryConfig defines retry behavior for failed broadcasts.
|
|
type RetryConfig struct {
|
|
MaxRetries int
|
|
InitialDelay time.Duration
|
|
MaxDelay time.Duration
|
|
BackoffFactor float64
|
|
}
|
|
|
|
// broadcaster implements Broadcaster.
|
|
type broadcaster struct {
|
|
grpcConn *grpc.ClientConn
|
|
config *config.NetworkConfig
|
|
txServiceClient tx.ServiceClient
|
|
retryConfig RetryConfig
|
|
timeout time.Duration
|
|
}
|
|
|
|
// NewBroadcaster creates a new transaction broadcaster.
|
|
func NewBroadcaster(grpcConn *grpc.ClientConn, cfg *config.NetworkConfig) Broadcaster {
|
|
return &broadcaster{
|
|
grpcConn: grpcConn,
|
|
config: cfg,
|
|
txServiceClient: tx.NewServiceClient(grpcConn),
|
|
retryConfig: DefaultRetryConfig(),
|
|
timeout: 30 * time.Second,
|
|
}
|
|
}
|
|
|
|
// DefaultRetryConfig returns sensible defaults for retry configuration.
|
|
func DefaultRetryConfig() RetryConfig {
|
|
return RetryConfig{
|
|
MaxRetries: 3,
|
|
InitialDelay: 1 * time.Second,
|
|
MaxDelay: 10 * time.Second,
|
|
BackoffFactor: 2.0,
|
|
}
|
|
}
|
|
|
|
// Broadcast broadcasts a transaction with the specified mode.
|
|
func (b *broadcaster) Broadcast(ctx context.Context, txBytes []byte, mode BroadcastMode) (*BroadcastResult, error) {
|
|
// Convert our mode to SDK broadcast mode
|
|
var sdkMode tx.BroadcastMode
|
|
switch mode {
|
|
case BroadcastModeSync:
|
|
sdkMode = tx.BroadcastMode_BROADCAST_MODE_SYNC
|
|
case BroadcastModeAsync:
|
|
sdkMode = tx.BroadcastMode_BROADCAST_MODE_ASYNC
|
|
case BroadcastModeBlock:
|
|
sdkMode = tx.BroadcastMode_BROADCAST_MODE_BLOCK
|
|
default:
|
|
return nil, fmt.Errorf("invalid broadcast mode: %s", mode)
|
|
}
|
|
|
|
// Create broadcast request
|
|
req := &tx.BroadcastTxRequest{
|
|
TxBytes: txBytes,
|
|
Mode: sdkMode,
|
|
}
|
|
|
|
// Apply timeout to context
|
|
broadcastCtx, cancel := context.WithTimeout(ctx, b.timeout)
|
|
defer cancel()
|
|
|
|
// Broadcast the transaction
|
|
resp, err := b.txServiceClient.BroadcastTx(broadcastCtx, req)
|
|
if err != nil {
|
|
return nil, errors.WrapError(err, errors.ErrBroadcastFailed, "failed to broadcast transaction")
|
|
}
|
|
|
|
// Convert response
|
|
return convertBroadcastResponse(resp), nil
|
|
}
|
|
|
|
// BroadcastSync broadcasts a transaction synchronously.
|
|
func (b *broadcaster) BroadcastSync(ctx context.Context, txBytes []byte) (*BroadcastResult, error) {
|
|
return b.Broadcast(ctx, txBytes, BroadcastModeSync)
|
|
}
|
|
|
|
// BroadcastAsync broadcasts a transaction asynchronously.
|
|
func (b *broadcaster) BroadcastAsync(ctx context.Context, txBytes []byte) (*BroadcastResult, error) {
|
|
return b.Broadcast(ctx, txBytes, BroadcastModeAsync)
|
|
}
|
|
|
|
// BroadcastBlock broadcasts a transaction and waits for block confirmation.
|
|
func (b *broadcaster) BroadcastBlock(ctx context.Context, txBytes []byte) (*BroadcastResult, error) {
|
|
return b.Broadcast(ctx, txBytes, BroadcastModeBlock)
|
|
}
|
|
|
|
// BroadcastWithRetry broadcasts a transaction with retry logic.
|
|
func (b *broadcaster) BroadcastWithRetry(ctx context.Context, txBytes []byte, mode BroadcastMode, maxRetries int) (*BroadcastResult, error) {
|
|
var lastErr error
|
|
delay := b.retryConfig.InitialDelay
|
|
|
|
for attempt := 0; attempt <= maxRetries; attempt++ {
|
|
result, err := b.Broadcast(ctx, txBytes, mode)
|
|
if err == nil {
|
|
return result, nil
|
|
}
|
|
|
|
lastErr = err
|
|
|
|
// Don't retry on the last attempt
|
|
if attempt == maxRetries {
|
|
break
|
|
}
|
|
|
|
// Check if error is retryable
|
|
if !isRetryableError(err) {
|
|
break
|
|
}
|
|
|
|
// Wait before retrying
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-time.After(delay):
|
|
// Exponential backoff
|
|
delay = time.Duration(float64(delay) * b.retryConfig.BackoffFactor)
|
|
if delay > b.retryConfig.MaxDelay {
|
|
delay = b.retryConfig.MaxDelay
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil, errors.WrapError(lastErr, errors.ErrBroadcastFailed, "failed to broadcast transaction after %d retries", maxRetries)
|
|
}
|
|
|
|
// WaitForConfirmation waits for a transaction to be confirmed on-chain.
|
|
func (b *broadcaster) WaitForConfirmation(ctx context.Context, txHash string, timeout time.Duration) (*TxConfirmation, error) {
|
|
// Create timeout context
|
|
confirmCtx, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
|
|
// Poll for transaction confirmation
|
|
ticker := time.NewTicker(1 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-confirmCtx.Done():
|
|
return nil, errors.WrapError(confirmCtx.Err(), errors.ErrTimeout, "timeout waiting for transaction confirmation")
|
|
case <-ticker.C:
|
|
// Try to fetch transaction
|
|
req := &tx.GetTxRequest{Hash: txHash}
|
|
resp, err := b.txServiceClient.GetTx(confirmCtx, req)
|
|
if err != nil {
|
|
// Transaction not found yet, continue polling
|
|
continue
|
|
}
|
|
|
|
// Transaction found, convert to confirmation
|
|
confirmation := &TxConfirmation{
|
|
TxHash: resp.TxResponse.TxHash,
|
|
BlockHeight: resp.TxResponse.Height,
|
|
Code: resp.TxResponse.Code,
|
|
Log: resp.TxResponse.RawLog,
|
|
GasWanted: resp.TxResponse.GasWanted,
|
|
GasUsed: resp.TxResponse.GasUsed,
|
|
// BlockTime would need to be fetched from block info
|
|
}
|
|
|
|
// Convert events
|
|
for _, event := range resp.TxResponse.Events {
|
|
e := Event{
|
|
Type: event.Type,
|
|
}
|
|
for _, attr := range event.Attributes {
|
|
e.Attributes = append(e.Attributes, Attribute{
|
|
Key: attr.Key,
|
|
Value: attr.Value,
|
|
})
|
|
}
|
|
confirmation.Events = append(confirmation.Events, e)
|
|
}
|
|
|
|
return confirmation, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// WithRetryConfig sets the retry configuration.
|
|
func (b *broadcaster) WithRetryConfig(config RetryConfig) Broadcaster {
|
|
b.retryConfig = config
|
|
return b
|
|
}
|
|
|
|
// WithTimeout sets the broadcast timeout.
|
|
func (b *broadcaster) WithTimeout(timeout time.Duration) Broadcaster {
|
|
b.timeout = timeout
|
|
return b
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
// convertBroadcastResponse converts SDK broadcast response to our format.
|
|
func convertBroadcastResponse(resp *tx.BroadcastTxResponse) *BroadcastResult {
|
|
result := &BroadcastResult{
|
|
TxHash: resp.TxResponse.TxHash,
|
|
Code: resp.TxResponse.Code,
|
|
Log: resp.TxResponse.RawLog,
|
|
GasWanted: resp.TxResponse.GasWanted,
|
|
GasUsed: resp.TxResponse.GasUsed,
|
|
Height: resp.TxResponse.Height,
|
|
}
|
|
|
|
// Convert events
|
|
for _, event := range resp.TxResponse.Events {
|
|
e := Event{
|
|
Type: event.Type,
|
|
}
|
|
for _, attr := range event.Attributes {
|
|
e.Attributes = append(e.Attributes, Attribute{
|
|
Key: attr.Key,
|
|
Value: attr.Value,
|
|
})
|
|
}
|
|
result.Events = append(result.Events, e)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// isRetryableError determines if an error is worth retrying.
|
|
func isRetryableError(err error) bool {
|
|
// Check for specific error types that are retryable
|
|
if errors.IsConnectionError(err) {
|
|
return true
|
|
}
|
|
|
|
// Timeouts are generally retryable
|
|
if errors.GetErrorCode(err) == errors.CodeTimeout {
|
|
return true
|
|
}
|
|
|
|
// Network unreachable errors are retryable
|
|
if errors.GetErrorCode(err) == errors.CodeNetworkUnreachable {
|
|
return true
|
|
}
|
|
|
|
// Other errors like invalid transaction, insufficient funds, etc. are not retryable
|
|
return false
|
|
}
|
|
|
|
// BroadcastConfig provides configuration options for broadcasting.
|
|
type BroadcastConfig struct {
|
|
Mode BroadcastMode
|
|
Timeout time.Duration
|
|
RetryConfig RetryConfig
|
|
WaitForBlock bool
|
|
}
|
|
|
|
// DefaultBroadcastConfig returns sensible defaults for broadcasting.
|
|
func DefaultBroadcastConfig() BroadcastConfig {
|
|
return BroadcastConfig{
|
|
Mode: BroadcastModeSync,
|
|
Timeout: 30 * time.Second,
|
|
RetryConfig: DefaultRetryConfig(),
|
|
WaitForBlock: false,
|
|
}
|
|
}
|
|
|
|
// BroadcastWithConfig broadcasts a transaction using the provided configuration.
|
|
func (b *broadcaster) BroadcastWithConfig(ctx context.Context, txBytes []byte, config BroadcastConfig) (*BroadcastResult, error) {
|
|
// Set timeout
|
|
originalTimeout := b.timeout
|
|
b.timeout = config.Timeout
|
|
defer func() { b.timeout = originalTimeout }()
|
|
|
|
// Set retry config
|
|
originalRetryConfig := b.retryConfig
|
|
b.retryConfig = config.RetryConfig
|
|
defer func() { b.retryConfig = originalRetryConfig }()
|
|
|
|
// Broadcast with retry
|
|
result, err := b.BroadcastWithRetry(ctx, txBytes, config.Mode, config.RetryConfig.MaxRetries)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Wait for block confirmation if requested
|
|
if config.WaitForBlock && result.TxHash != "" {
|
|
confirmation, err := b.WaitForConfirmation(ctx, result.TxHash, config.Timeout)
|
|
if err != nil {
|
|
// Return the broadcast result even if we couldn't wait for confirmation
|
|
return result, fmt.Errorf("transaction broadcast succeeded but confirmation failed: %w", err)
|
|
}
|
|
|
|
// Update result with confirmation data
|
|
result.Height = confirmation.BlockHeight
|
|
result.Code = confirmation.Code
|
|
result.Log = confirmation.Log
|
|
result.GasWanted = confirmation.GasWanted
|
|
result.GasUsed = confirmation.GasUsed
|
|
result.Events = confirmation.Events
|
|
}
|
|
|
|
return result, nil
|
|
}
|