mirror of
https://github.com/sonr-io/sonr.git
synced 2026-08-02 17:31:39 +00:00
@@ -0,0 +1,357 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
// Package tx provides transaction building utilities for the Sonr client SDK.
|
||||
package tx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"cosmossdk.io/math"
|
||||
sdktypes "github.com/cosmos/cosmos-sdk/types"
|
||||
"github.com/cosmos/cosmos-sdk/types/tx"
|
||||
|
||||
"github.com/sonr-io/sonr/client/config"
|
||||
"github.com/sonr-io/sonr/client/errors"
|
||||
"github.com/sonr-io/sonr/client/keys"
|
||||
)
|
||||
|
||||
// TxBuilder provides an interface for building and broadcasting transactions.
|
||||
type TxBuilder interface {
|
||||
// Transaction configuration
|
||||
WithChainID(chainID string) TxBuilder
|
||||
WithGasPrice(price float64, denom string) TxBuilder
|
||||
WithGasLimit(limit uint64) TxBuilder
|
||||
WithMemo(memo string) TxBuilder
|
||||
WithTimeoutHeight(height uint64) TxBuilder
|
||||
|
||||
// Message operations
|
||||
AddMessage(msg sdktypes.Msg) TxBuilder
|
||||
AddMessages(msgs ...sdktypes.Msg) TxBuilder
|
||||
ClearMessages() TxBuilder
|
||||
|
||||
// Fee operations
|
||||
WithFee(amount sdktypes.Coins) TxBuilder
|
||||
WithGasAdjustment(adjustment float64) TxBuilder
|
||||
EstimateGas(ctx context.Context) (uint64, error)
|
||||
|
||||
// Signing and broadcasting
|
||||
Sign(ctx context.Context, keyring keys.KeyringManager) (*SignedTx, error)
|
||||
SignAndBroadcast(ctx context.Context, keyring keys.KeyringManager) (*BroadcastResult, error)
|
||||
Broadcast(ctx context.Context, signedTx *SignedTx) (*BroadcastResult, error)
|
||||
|
||||
// Simulation
|
||||
Simulate(ctx context.Context) (*SimulateResult, error)
|
||||
|
||||
// Building
|
||||
Build() (*UnsignedTx, error)
|
||||
BuildSigned(signature []byte, pubKey []byte) (*SignedTx, error)
|
||||
|
||||
// Configuration access
|
||||
Config() *TxConfig
|
||||
}
|
||||
|
||||
// TxConfig holds transaction configuration.
|
||||
type TxConfig struct {
|
||||
ChainID string
|
||||
GasPrice float64
|
||||
GasDenom string
|
||||
GasLimit uint64
|
||||
GasAdjustment float64
|
||||
Memo string
|
||||
TimeoutHeight uint64
|
||||
Fee sdktypes.Coins
|
||||
}
|
||||
|
||||
// UnsignedTx represents an unsigned transaction.
|
||||
type UnsignedTx struct {
|
||||
Messages []sdktypes.Msg
|
||||
Config *TxConfig
|
||||
SignBytes []byte
|
||||
AccountNumber uint64
|
||||
Sequence uint64
|
||||
}
|
||||
|
||||
// SignedTx represents a signed transaction.
|
||||
type SignedTx struct {
|
||||
UnsignedTx *UnsignedTx
|
||||
Signature []byte
|
||||
PubKey []byte
|
||||
TxBytes []byte
|
||||
}
|
||||
|
||||
// BroadcastResult contains the result of broadcasting a transaction.
|
||||
type BroadcastResult struct {
|
||||
TxHash string
|
||||
Code uint32
|
||||
Log string
|
||||
GasWanted int64
|
||||
GasUsed int64
|
||||
Height int64
|
||||
Events []Event
|
||||
}
|
||||
|
||||
// Event represents a transaction event.
|
||||
type Event struct {
|
||||
Type string
|
||||
Attributes []Attribute
|
||||
}
|
||||
|
||||
// Attribute represents an event attribute.
|
||||
type Attribute struct {
|
||||
Key string
|
||||
Value string
|
||||
}
|
||||
|
||||
// SimulateResult contains the result of transaction simulation.
|
||||
type SimulateResult struct {
|
||||
GasWanted int64
|
||||
GasUsed int64
|
||||
Log string
|
||||
Events []Event
|
||||
}
|
||||
|
||||
// txBuilder implements TxBuilder.
|
||||
type txBuilder struct {
|
||||
grpcConn *grpc.ClientConn
|
||||
config *config.NetworkConfig
|
||||
txConfig *TxConfig
|
||||
messages []sdktypes.Msg
|
||||
|
||||
// Cosmos SDK clients
|
||||
txServiceClient tx.ServiceClient
|
||||
}
|
||||
|
||||
// NewTxBuilder creates a new transaction builder.
|
||||
func NewTxBuilder(cfg *config.NetworkConfig, grpcConn *grpc.ClientConn) (TxBuilder, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("network configuration is required")
|
||||
}
|
||||
|
||||
if grpcConn == nil {
|
||||
return nil, fmt.Errorf("gRPC connection is required")
|
||||
}
|
||||
|
||||
txConfig := &TxConfig{
|
||||
ChainID: cfg.ChainID,
|
||||
GasPrice: cfg.GasPrice,
|
||||
GasDenom: cfg.StakingDenom,
|
||||
GasAdjustment: cfg.GasAdjustment,
|
||||
GasLimit: 200000, // Default gas limit
|
||||
}
|
||||
|
||||
return &txBuilder{
|
||||
grpcConn: grpcConn,
|
||||
config: cfg,
|
||||
txConfig: txConfig,
|
||||
messages: make([]sdktypes.Msg, 0),
|
||||
txServiceClient: tx.NewServiceClient(grpcConn),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// WithChainID sets the chain ID for the transaction.
|
||||
func (tb *txBuilder) WithChainID(chainID string) TxBuilder {
|
||||
tb.txConfig.ChainID = chainID
|
||||
return tb
|
||||
}
|
||||
|
||||
// WithGasPrice sets the gas price and denomination.
|
||||
func (tb *txBuilder) WithGasPrice(price float64, denom string) TxBuilder {
|
||||
tb.txConfig.GasPrice = price
|
||||
tb.txConfig.GasDenom = denom
|
||||
return tb
|
||||
}
|
||||
|
||||
// WithGasLimit sets the gas limit for the transaction.
|
||||
func (tb *txBuilder) WithGasLimit(limit uint64) TxBuilder {
|
||||
tb.txConfig.GasLimit = limit
|
||||
return tb
|
||||
}
|
||||
|
||||
// WithMemo sets the memo for the transaction.
|
||||
func (tb *txBuilder) WithMemo(memo string) TxBuilder {
|
||||
tb.txConfig.Memo = memo
|
||||
return tb
|
||||
}
|
||||
|
||||
// WithTimeoutHeight sets the timeout height for the transaction.
|
||||
func (tb *txBuilder) WithTimeoutHeight(height uint64) TxBuilder {
|
||||
tb.txConfig.TimeoutHeight = height
|
||||
return tb
|
||||
}
|
||||
|
||||
// AddMessage adds a single message to the transaction.
|
||||
func (tb *txBuilder) AddMessage(msg sdktypes.Msg) TxBuilder {
|
||||
tb.messages = append(tb.messages, msg)
|
||||
return tb
|
||||
}
|
||||
|
||||
// AddMessages adds multiple messages to the transaction.
|
||||
func (tb *txBuilder) AddMessages(msgs ...sdktypes.Msg) TxBuilder {
|
||||
tb.messages = append(tb.messages, msgs...)
|
||||
return tb
|
||||
}
|
||||
|
||||
// ClearMessages removes all messages from the transaction.
|
||||
func (tb *txBuilder) ClearMessages() TxBuilder {
|
||||
tb.messages = make([]sdktypes.Msg, 0)
|
||||
return tb
|
||||
}
|
||||
|
||||
// WithFee sets the transaction fee directly.
|
||||
func (tb *txBuilder) WithFee(amount sdktypes.Coins) TxBuilder {
|
||||
tb.txConfig.Fee = amount
|
||||
return tb
|
||||
}
|
||||
|
||||
// WithGasAdjustment sets the gas adjustment factor.
|
||||
func (tb *txBuilder) WithGasAdjustment(adjustment float64) TxBuilder {
|
||||
tb.txConfig.GasAdjustment = adjustment
|
||||
return tb
|
||||
}
|
||||
|
||||
// EstimateGas estimates the gas required for the transaction.
|
||||
func (tb *txBuilder) EstimateGas(ctx context.Context) (uint64, error) {
|
||||
// Build unsigned transaction for simulation
|
||||
_, err := tb.Build()
|
||||
if err != nil {
|
||||
return 0, errors.WrapError(err, errors.ErrGasEstimationFailed, "failed to build transaction for gas estimation")
|
||||
}
|
||||
|
||||
// Simulate the transaction
|
||||
simulateResult, err := tb.Simulate(ctx)
|
||||
if err != nil {
|
||||
return 0, errors.WrapError(err, errors.ErrGasEstimationFailed, "failed to simulate transaction")
|
||||
}
|
||||
|
||||
// Apply gas adjustment
|
||||
estimatedGas := float64(simulateResult.GasUsed) * tb.txConfig.GasAdjustment
|
||||
return uint64(estimatedGas), nil
|
||||
}
|
||||
|
||||
// Sign signs the transaction using the provided keyring.
|
||||
func (tb *txBuilder) Sign(ctx context.Context, keyring keys.KeyringManager) (*SignedTx, error) {
|
||||
// Build unsigned transaction
|
||||
unsignedTx, err := tb.Build()
|
||||
if err != nil {
|
||||
return nil, errors.WrapError(err, errors.ErrSigningFailed, "failed to build unsigned transaction")
|
||||
}
|
||||
|
||||
// Sign the transaction bytes using the DWN plugin
|
||||
signature, err := keyring.SignTransaction(ctx, unsignedTx.SignBytes)
|
||||
if err != nil {
|
||||
return nil, errors.WrapError(err, errors.ErrSigningFailed, "failed to sign transaction")
|
||||
}
|
||||
|
||||
// Get wallet identity for public key
|
||||
identity, err := keyring.GetIssuerDID(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.WrapError(err, errors.ErrSigningFailed, "failed to get wallet identity")
|
||||
}
|
||||
|
||||
// For now, use a placeholder for public key - this should be derived from the DID
|
||||
// TODO: Extract public key from DID or add GetPubKey method to KeyringManager
|
||||
pubKey := []byte(identity.DID) // Placeholder
|
||||
|
||||
// Build signed transaction
|
||||
signedTx, err := tb.BuildSigned(signature.Signature, pubKey)
|
||||
if err != nil {
|
||||
return nil, errors.WrapError(err, errors.ErrSigningFailed, "failed to build signed transaction")
|
||||
}
|
||||
|
||||
return signedTx, nil
|
||||
}
|
||||
|
||||
// SignAndBroadcast signs and broadcasts the transaction in one operation.
|
||||
func (tb *txBuilder) SignAndBroadcast(ctx context.Context, keyring keys.KeyringManager) (*BroadcastResult, error) {
|
||||
// Sign the transaction
|
||||
signedTx, err := tb.Sign(ctx, keyring)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Broadcast the signed transaction
|
||||
return tb.Broadcast(ctx, signedTx)
|
||||
}
|
||||
|
||||
// Broadcast broadcasts a signed transaction to the network.
|
||||
func (tb *txBuilder) Broadcast(ctx context.Context, signedTx *SignedTx) (*BroadcastResult, error) {
|
||||
// Create broadcast request
|
||||
req := &tx.BroadcastTxRequest{
|
||||
TxBytes: signedTx.TxBytes,
|
||||
Mode: tx.BroadcastMode_BROADCAST_MODE_SYNC, // Default to sync mode
|
||||
}
|
||||
|
||||
// Broadcast the transaction
|
||||
resp, err := tb.txServiceClient.BroadcastTx(ctx, req)
|
||||
if err != nil {
|
||||
return nil, errors.WrapError(err, errors.ErrBroadcastFailed, "failed to broadcast transaction")
|
||||
}
|
||||
|
||||
// Convert response to our format
|
||||
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, nil
|
||||
}
|
||||
|
||||
// Simulate simulates the transaction to estimate gas and check for errors.
|
||||
func (tb *txBuilder) Simulate(ctx context.Context) (*SimulateResult, error) {
|
||||
// Build unsigned transaction for simulation
|
||||
unsignedTx, err := tb.Build()
|
||||
if err != nil {
|
||||
return nil, errors.WrapError(err, errors.ErrGasEstimationFailed, "failed to build transaction for simulation")
|
||||
}
|
||||
|
||||
// Create simulate request
|
||||
req := &tx.SimulateRequest{
|
||||
TxBytes: unsignedTx.SignBytes, // Use sign bytes for simulation
|
||||
}
|
||||
|
||||
// Simulate the transaction
|
||||
resp, err := tb.txServiceClient.Simulate(ctx, req)
|
||||
if err != nil {
|
||||
return nil, errors.WrapError(err, errors.ErrGasEstimationFailed, "failed to simulate transaction")
|
||||
}
|
||||
|
||||
// Convert response to our format
|
||||
result := &SimulateResult{
|
||||
GasWanted: int64(resp.GasInfo.GasWanted),
|
||||
GasUsed: int64(resp.GasInfo.GasUsed),
|
||||
Log: resp.Result.Log,
|
||||
}
|
||||
|
||||
// Convert events
|
||||
for _, event := range resp.Result.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, nil
|
||||
}
|
||||
|
||||
// Build creates an unsigned transaction.
|
||||
func (tb *txBuilder) Build() (*UnsignedTx, error) {
|
||||
// Allow building without messages for testing/simulation purposes
|
||||
// Real transactions will still require messages when broadcasting
|
||||
|
||||
// Calculate fee if not set
|
||||
fee := tb.txConfig.Fee
|
||||
if fee.IsZero() {
|
||||
// Calculate fee based on gas price and limit
|
||||
gasAmount := math.NewIntFromUint64(uint64(float64(tb.txConfig.GasLimit) * tb.txConfig.GasPrice))
|
||||
fee = sdktypes.NewCoins(sdktypes.NewCoin(tb.txConfig.GasDenom, gasAmount))
|
||||
}
|
||||
|
||||
// Create sign bytes (simplified - in a real implementation this would use proper transaction encoding)
|
||||
signBytes := []byte(fmt.Sprintf("chain_id:%s,messages:%d,fee:%s,memo:%s",
|
||||
tb.txConfig.ChainID,
|
||||
len(tb.messages),
|
||||
fee.String(),
|
||||
tb.txConfig.Memo))
|
||||
|
||||
return &UnsignedTx{
|
||||
Messages: tb.messages,
|
||||
Config: tb.txConfig,
|
||||
SignBytes: signBytes,
|
||||
// TODO: Fetch account number and sequence from chain
|
||||
AccountNumber: 0,
|
||||
Sequence: 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BuildSigned creates a signed transaction from signature and public key.
|
||||
func (tb *txBuilder) BuildSigned(signature []byte, pubKey []byte) (*SignedTx, error) {
|
||||
unsignedTx, err := tb.Build()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create transaction bytes (simplified - in a real implementation this would use proper transaction encoding)
|
||||
txBytes := append(unsignedTx.SignBytes, signature...)
|
||||
txBytes = append(txBytes, pubKey...)
|
||||
|
||||
return &SignedTx{
|
||||
UnsignedTx: unsignedTx,
|
||||
Signature: signature,
|
||||
PubKey: pubKey,
|
||||
TxBytes: txBytes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Config returns the current transaction configuration.
|
||||
func (tb *txBuilder) Config() *TxConfig {
|
||||
return tb.txConfig
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
|
||||
// NewTxConfig creates a new transaction configuration with defaults.
|
||||
func NewTxConfig(chainID string) *TxConfig {
|
||||
return &TxConfig{
|
||||
ChainID: chainID,
|
||||
GasPrice: 0.001,
|
||||
GasDenom: "usnr",
|
||||
GasAdjustment: 1.5,
|
||||
GasLimit: 200000,
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultGasLimit returns the default gas limit for transactions.
|
||||
func DefaultGasLimit() uint64 {
|
||||
return 200000
|
||||
}
|
||||
|
||||
// DefaultGasPrice returns the default gas price for the Sonr network.
|
||||
func DefaultGasPrice() float64 {
|
||||
return 0.001
|
||||
}
|
||||
|
||||
// CalculateFee calculates the transaction fee based on gas price and limit.
|
||||
func CalculateFee(gasPrice float64, gasLimit uint64, denom string) sdktypes.Coins {
|
||||
gasAmount := math.NewIntFromUint64(uint64(float64(gasLimit) * gasPrice))
|
||||
return sdktypes.NewCoins(sdktypes.NewCoin(denom, gasAmount))
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package tx
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
sdk "github.com/cosmos/cosmos-sdk/types"
|
||||
banktypes "github.com/cosmos/cosmos-sdk/x/bank/types"
|
||||
|
||||
"github.com/sonr-io/sonr/client/config"
|
||||
)
|
||||
|
||||
// TxBuilderTestSuite tests the transaction builder.
|
||||
type TxBuilderTestSuite struct {
|
||||
suite.Suite
|
||||
builder TxBuilder
|
||||
config *config.NetworkConfig
|
||||
}
|
||||
|
||||
func (suite *TxBuilderTestSuite) SetupTest() {
|
||||
cfg := config.LocalNetwork()
|
||||
suite.config = &cfg
|
||||
|
||||
// Create mock gRPC connection for testing
|
||||
conn, err := grpc.Dial("localhost:9090", grpc.WithInsecure())
|
||||
suite.Require().NoError(err)
|
||||
|
||||
builder, err := NewTxBuilder(suite.config, conn)
|
||||
suite.Require().NoError(err)
|
||||
suite.builder = builder
|
||||
}
|
||||
|
||||
func (suite *TxBuilderTestSuite) TestAddMessage() {
|
||||
// Create a test message
|
||||
msg := &banktypes.MsgSend{
|
||||
FromAddress: "sonr1xyz...",
|
||||
ToAddress: "sonr1abc...",
|
||||
Amount: sdk.NewCoins(sdk.NewInt64Coin("usnr", 1000)),
|
||||
}
|
||||
|
||||
// Add message
|
||||
suite.builder.AddMessage(msg)
|
||||
|
||||
// Verify message was added
|
||||
unsignedTx, err := suite.builder.Build()
|
||||
suite.Require().NoError(err)
|
||||
suite.Require().NotNil(unsignedTx)
|
||||
suite.Require().Len(unsignedTx.Messages, 1)
|
||||
}
|
||||
|
||||
func (suite *TxBuilderTestSuite) TestWithMemo() {
|
||||
memo := "test transaction"
|
||||
suite.builder = suite.builder.WithMemo(memo)
|
||||
|
||||
unsignedTx, err := suite.builder.Build()
|
||||
suite.Require().NoError(err)
|
||||
suite.Require().NotNil(unsignedTx)
|
||||
// Memo is set internally in the transaction
|
||||
}
|
||||
|
||||
func (suite *TxBuilderTestSuite) TestWithGasLimit() {
|
||||
gasLimit := uint64(200000)
|
||||
suite.builder = suite.builder.WithGasLimit(gasLimit)
|
||||
|
||||
unsignedTx, err := suite.builder.Build()
|
||||
suite.Require().NoError(err)
|
||||
suite.Require().NotNil(unsignedTx)
|
||||
// Gas limit is set internally
|
||||
}
|
||||
|
||||
func (suite *TxBuilderTestSuite) TestWithFee() {
|
||||
fee := sdk.NewCoins(sdk.NewInt64Coin("usnr", 5000))
|
||||
suite.builder = suite.builder.WithFee(fee)
|
||||
|
||||
unsignedTx, err := suite.builder.Build()
|
||||
suite.Require().NoError(err)
|
||||
suite.Require().NotNil(unsignedTx)
|
||||
// Fee is set internally
|
||||
}
|
||||
|
||||
func (suite *TxBuilderTestSuite) TestClearMessages() {
|
||||
// Add some data
|
||||
msg := &banktypes.MsgSend{
|
||||
FromAddress: "sonr1xyz...",
|
||||
ToAddress: "sonr1abc...",
|
||||
Amount: sdk.NewCoins(sdk.NewInt64Coin("usnr", 1000)),
|
||||
}
|
||||
suite.builder = suite.builder.AddMessage(msg)
|
||||
suite.builder = suite.builder.WithMemo("test")
|
||||
|
||||
// Clear messages
|
||||
suite.builder = suite.builder.ClearMessages()
|
||||
|
||||
// Build should create transaction with no messages
|
||||
unsignedTx, err := suite.builder.Build()
|
||||
suite.Require().NoError(err)
|
||||
suite.Require().Len(unsignedTx.Messages, 0)
|
||||
}
|
||||
|
||||
func (suite *TxBuilderTestSuite) TestMultipleMessages() {
|
||||
// Add multiple messages
|
||||
msg1 := &banktypes.MsgSend{
|
||||
FromAddress: "sonr1xyz...",
|
||||
ToAddress: "sonr1abc...",
|
||||
Amount: sdk.NewCoins(sdk.NewInt64Coin("usnr", 1000)),
|
||||
}
|
||||
|
||||
msg2 := &banktypes.MsgSend{
|
||||
FromAddress: "sonr1abc...",
|
||||
ToAddress: "sonr1def...",
|
||||
Amount: sdk.NewCoins(sdk.NewInt64Coin("usnr", 2000)),
|
||||
}
|
||||
|
||||
suite.builder.AddMessage(msg1)
|
||||
suite.builder.AddMessage(msg2)
|
||||
|
||||
unsignedTx, err := suite.builder.Build()
|
||||
suite.Require().NoError(err)
|
||||
suite.Require().Len(unsignedTx.Messages, 2)
|
||||
}
|
||||
|
||||
func TestTxBuilderTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(TxBuilderTestSuite))
|
||||
}
|
||||
|
||||
// TestTxBuilderValidation tests transaction builder validation.
|
||||
func TestTxBuilderValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(TxBuilder) TxBuilder
|
||||
wantError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid transaction",
|
||||
setup: func(b TxBuilder) TxBuilder {
|
||||
msg := &banktypes.MsgSend{
|
||||
FromAddress: "sonr1xyz...",
|
||||
ToAddress: "sonr1abc...",
|
||||
Amount: sdk.NewCoins(sdk.NewInt64Coin("usnr", 1000)),
|
||||
}
|
||||
return b.AddMessage(msg).WithGasLimit(100000)
|
||||
},
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "no messages",
|
||||
setup: func(b TxBuilder) TxBuilder {
|
||||
return b.WithGasLimit(100000)
|
||||
},
|
||||
wantError: false, // Empty transactions are technically valid
|
||||
},
|
||||
{
|
||||
name: "zero gas limit",
|
||||
setup: func(b TxBuilder) TxBuilder {
|
||||
msg := &banktypes.MsgSend{
|
||||
FromAddress: "sonr1xyz...",
|
||||
ToAddress: "sonr1abc...",
|
||||
Amount: sdk.NewCoins(sdk.NewInt64Coin("usnr", 1000)),
|
||||
}
|
||||
return b.AddMessage(msg).WithGasLimit(0)
|
||||
},
|
||||
wantError: false, // Zero gas is allowed for simulation
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := config.LocalNetwork()
|
||||
conn, _ := grpc.Dial("localhost:9090", grpc.WithInsecure())
|
||||
|
||||
builder, err := NewTxBuilder(&cfg, conn)
|
||||
require.NoError(t, err)
|
||||
|
||||
builder = tt.setup(builder)
|
||||
|
||||
_, err = builder.Build()
|
||||
if tt.wantError {
|
||||
require.Error(t, err)
|
||||
if tt.errorMsg != "" {
|
||||
require.Contains(t, err.Error(), tt.errorMsg)
|
||||
}
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
// Package tx provides gas estimation and fee calculation utilities for the Sonr client SDK.
|
||||
package tx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
|
||||
sdktypes "github.com/cosmos/cosmos-sdk/types"
|
||||
"github.com/cosmos/cosmos-sdk/types/tx"
|
||||
|
||||
"github.com/sonr-io/sonr/client/config"
|
||||
"github.com/sonr-io/sonr/client/errors"
|
||||
)
|
||||
|
||||
// GasEstimator provides an interface for estimating gas costs and calculating fees.
|
||||
type GasEstimator interface {
|
||||
// Gas estimation
|
||||
EstimateGas(ctx context.Context, msgs []sdktypes.Msg) (*GasEstimate, error)
|
||||
EstimateGasForTx(ctx context.Context, unsignedTx *UnsignedTx) (*GasEstimate, error)
|
||||
|
||||
// Fee calculation
|
||||
CalculateFee(gasUsed uint64, gasPrice float64, denom string) sdktypes.Coins
|
||||
CalculateFeeWithAdjustment(gasUsed uint64, gasPrice float64, adjustment float64, denom string) sdktypes.Coins
|
||||
|
||||
// Gas configuration
|
||||
WithGasAdjustment(adjustment float64) GasEstimator
|
||||
WithMinGasPrice(price float64) GasEstimator
|
||||
WithMaxGasLimit(limit uint64) GasEstimator
|
||||
|
||||
// Utility methods
|
||||
GetRecommendedGasPrice(ctx context.Context) (float64, error)
|
||||
GetNetworkGasInfo(ctx context.Context) (*NetworkGasInfo, error)
|
||||
}
|
||||
|
||||
// GasEstimate contains the result of gas estimation.
|
||||
type GasEstimate struct {
|
||||
GasWanted uint64 // Estimated gas needed
|
||||
GasUsed uint64 // Gas used in simulation
|
||||
GasLimit uint64 // Recommended gas limit (with adjustment)
|
||||
Fee sdktypes.Coins // Calculated fee
|
||||
GasPrice float64 // Gas price used
|
||||
GasAdjustment float64 // Adjustment factor applied
|
||||
}
|
||||
|
||||
// NetworkGasInfo contains network-wide gas information.
|
||||
type NetworkGasInfo struct {
|
||||
MinGasPrice float64 // Minimum gas price accepted by validators
|
||||
MedianGasPrice float64 // Median gas price from recent transactions
|
||||
RecommendedGasPrice float64 // Recommended gas price for fast inclusion
|
||||
MaxGasLimit uint64 // Maximum gas limit per transaction
|
||||
}
|
||||
|
||||
// GasConfig holds gas estimation configuration.
|
||||
type GasConfig struct {
|
||||
Adjustment float64 // Gas adjustment factor (default: 1.5)
|
||||
MinGasPrice float64 // Minimum gas price
|
||||
MaxGasLimit uint64 // Maximum gas limit
|
||||
Denom string // Gas fee denomination
|
||||
}
|
||||
|
||||
// gasEstimator implements GasEstimator.
|
||||
type gasEstimator struct {
|
||||
grpcConn *grpc.ClientConn
|
||||
config *config.NetworkConfig
|
||||
txServiceClient tx.ServiceClient
|
||||
gasConfig GasConfig
|
||||
}
|
||||
|
||||
// NewGasEstimator creates a new gas estimator.
|
||||
func NewGasEstimator(grpcConn *grpc.ClientConn, cfg *config.NetworkConfig) GasEstimator {
|
||||
gasConfig := GasConfig{
|
||||
Adjustment: cfg.GasAdjustment,
|
||||
MinGasPrice: cfg.GasPrice,
|
||||
MaxGasLimit: 10000000, // 10M gas limit
|
||||
Denom: cfg.StakingDenom,
|
||||
}
|
||||
|
||||
return &gasEstimator{
|
||||
grpcConn: grpcConn,
|
||||
config: cfg,
|
||||
txServiceClient: tx.NewServiceClient(grpcConn),
|
||||
gasConfig: gasConfig,
|
||||
}
|
||||
}
|
||||
|
||||
// EstimateGas estimates gas for a list of messages.
|
||||
func (ge *gasEstimator) EstimateGas(ctx context.Context, msgs []sdktypes.Msg) (*GasEstimate, error) {
|
||||
if len(msgs) == 0 {
|
||||
return nil, fmt.Errorf("no messages provided for gas estimation")
|
||||
}
|
||||
|
||||
// Create a temporary transaction builder to build the transaction for simulation
|
||||
builder, err := NewTxBuilder(ge.config, ge.grpcConn)
|
||||
if err != nil {
|
||||
return nil, errors.WrapError(err, errors.ErrGasEstimationFailed, "failed to create transaction builder")
|
||||
}
|
||||
|
||||
// Add messages and build unsigned transaction
|
||||
for _, msg := range msgs {
|
||||
builder.AddMessage(msg)
|
||||
}
|
||||
|
||||
unsignedTx, err := builder.Build()
|
||||
if err != nil {
|
||||
return nil, errors.WrapError(err, errors.ErrGasEstimationFailed, "failed to build transaction for estimation")
|
||||
}
|
||||
|
||||
return ge.EstimateGasForTx(ctx, unsignedTx)
|
||||
}
|
||||
|
||||
// EstimateGasForTx estimates gas for an unsigned transaction.
|
||||
func (ge *gasEstimator) EstimateGasForTx(ctx context.Context, unsignedTx *UnsignedTx) (*GasEstimate, error) {
|
||||
// Create simulate request
|
||||
req := &tx.SimulateRequest{
|
||||
TxBytes: unsignedTx.SignBytes, // Use sign bytes for simulation
|
||||
}
|
||||
|
||||
// Simulate the transaction
|
||||
resp, err := ge.txServiceClient.Simulate(ctx, req)
|
||||
if err != nil {
|
||||
return nil, errors.WrapError(err, errors.ErrGasEstimationFailed, "failed to simulate transaction")
|
||||
}
|
||||
|
||||
gasUsed := resp.GasInfo.GasUsed
|
||||
gasWanted := resp.GasInfo.GasWanted
|
||||
|
||||
// Apply gas adjustment
|
||||
gasLimit := uint64(float64(gasUsed) * ge.gasConfig.Adjustment)
|
||||
|
||||
// Ensure gas limit doesn't exceed maximum
|
||||
if gasLimit > ge.gasConfig.MaxGasLimit {
|
||||
gasLimit = ge.gasConfig.MaxGasLimit
|
||||
}
|
||||
|
||||
// Calculate fee
|
||||
fee := ge.CalculateFee(gasLimit, ge.gasConfig.MinGasPrice, ge.gasConfig.Denom)
|
||||
|
||||
return &GasEstimate{
|
||||
GasWanted: gasWanted,
|
||||
GasUsed: gasUsed,
|
||||
GasLimit: gasLimit,
|
||||
Fee: fee,
|
||||
GasPrice: ge.gasConfig.MinGasPrice,
|
||||
GasAdjustment: ge.gasConfig.Adjustment,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CalculateFee calculates the transaction fee based on gas usage and price.
|
||||
func (ge *gasEstimator) CalculateFee(gasUsed uint64, gasPrice float64, denom string) sdktypes.Coins {
|
||||
// Calculate fee amount
|
||||
feeAmount := math.Ceil(float64(gasUsed) * gasPrice)
|
||||
|
||||
// Create coin
|
||||
feeCoin := sdktypes.NewInt64Coin(denom, int64(feeAmount))
|
||||
|
||||
return sdktypes.NewCoins(feeCoin)
|
||||
}
|
||||
|
||||
// CalculateFeeWithAdjustment calculates fee with a custom gas adjustment.
|
||||
func (ge *gasEstimator) CalculateFeeWithAdjustment(gasUsed uint64, gasPrice float64, adjustment float64, denom string) sdktypes.Coins {
|
||||
adjustedGas := uint64(float64(gasUsed) * adjustment)
|
||||
return ge.CalculateFee(adjustedGas, gasPrice, denom)
|
||||
}
|
||||
|
||||
// WithGasAdjustment sets the gas adjustment factor.
|
||||
func (ge *gasEstimator) WithGasAdjustment(adjustment float64) GasEstimator {
|
||||
ge.gasConfig.Adjustment = adjustment
|
||||
return ge
|
||||
}
|
||||
|
||||
// WithMinGasPrice sets the minimum gas price.
|
||||
func (ge *gasEstimator) WithMinGasPrice(price float64) GasEstimator {
|
||||
ge.gasConfig.MinGasPrice = price
|
||||
return ge
|
||||
}
|
||||
|
||||
// WithMaxGasLimit sets the maximum gas limit.
|
||||
func (ge *gasEstimator) WithMaxGasLimit(limit uint64) GasEstimator {
|
||||
ge.gasConfig.MaxGasLimit = limit
|
||||
return ge
|
||||
}
|
||||
|
||||
// GetRecommendedGasPrice returns the recommended gas price for the network.
|
||||
func (ge *gasEstimator) GetRecommendedGasPrice(ctx context.Context) (float64, error) {
|
||||
// TODO: Implement dynamic gas price discovery based on network conditions
|
||||
// Should query recent transactions to analyze gas price trends
|
||||
// Calculate percentile-based recommendations (e.g., 25th, 50th, 75th)
|
||||
// Consider network congestion and validator preferences
|
||||
// Return optimal gas price for desired transaction inclusion speed
|
||||
return ge.gasConfig.MinGasPrice, nil
|
||||
}
|
||||
|
||||
// GetNetworkGasInfo retrieves network-wide gas information.
|
||||
func (ge *gasEstimator) GetNetworkGasInfo(ctx context.Context) (*NetworkGasInfo, error) {
|
||||
// TODO: Implement dynamic network gas info retrieval
|
||||
// Should query validator minimum gas prices via gRPC
|
||||
// Analyze recent block gas usage patterns and limits
|
||||
// Calculate median and recommended gas prices from mempool
|
||||
// Monitor network congestion metrics for pricing recommendations
|
||||
// Query chain parameters for maximum gas limits and constraints
|
||||
return &NetworkGasInfo{
|
||||
MinGasPrice: ge.gasConfig.MinGasPrice,
|
||||
MedianGasPrice: ge.gasConfig.MinGasPrice,
|
||||
RecommendedGasPrice: ge.gasConfig.MinGasPrice,
|
||||
MaxGasLimit: ge.gasConfig.MaxGasLimit,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Utility functions and constants
|
||||
|
||||
// Default gas values for different transaction types
|
||||
const (
|
||||
// DefaultGasLimitValue is the default gas limit for transactions
|
||||
DefaultGasLimitValue = 200000
|
||||
|
||||
// SendGasLimit is the typical gas limit for send transactions
|
||||
SendGasLimit = 100000
|
||||
|
||||
// DelegateGasLimit is the typical gas limit for delegation transactions
|
||||
DelegateGasLimit = 150000
|
||||
|
||||
// ContractCallGasLimit is the typical gas limit for smart contract calls
|
||||
ContractCallGasLimit = 500000
|
||||
|
||||
// MinGasAdjustment is the minimum recommended gas adjustment
|
||||
MinGasAdjustment = 1.1
|
||||
|
||||
// MaxGasAdjustment is the maximum reasonable gas adjustment
|
||||
MaxGasAdjustment = 3.0
|
||||
)
|
||||
|
||||
// GasLimitForMessageType returns a recommended gas limit for different message types.
|
||||
func GasLimitForMessageType(msgType string) uint64 {
|
||||
switch msgType {
|
||||
case "/cosmos.bank.v1beta1.MsgSend":
|
||||
return SendGasLimit
|
||||
case "/cosmos.staking.v1beta1.MsgDelegate":
|
||||
return DelegateGasLimit
|
||||
case "/cosmos.staking.v1beta1.MsgUndelegate":
|
||||
return DelegateGasLimit
|
||||
case "/cosmos.staking.v1beta1.MsgRedelegate":
|
||||
return DelegateGasLimit * 2
|
||||
default:
|
||||
return DefaultGasLimitValue
|
||||
}
|
||||
}
|
||||
|
||||
// EstimateGasForMessages provides a quick gas estimate based on message types.
|
||||
func EstimateGasForMessages(msgs []sdktypes.Msg) uint64 {
|
||||
var totalGas uint64
|
||||
|
||||
for _, msg := range msgs {
|
||||
msgType := sdktypes.MsgTypeURL(msg)
|
||||
gas := GasLimitForMessageType(msgType)
|
||||
totalGas += gas
|
||||
}
|
||||
|
||||
// Add base transaction overhead
|
||||
totalGas += 50000
|
||||
|
||||
return totalGas
|
||||
}
|
||||
|
||||
// ValidateGasPrice checks if a gas price is reasonable.
|
||||
func ValidateGasPrice(gasPrice float64) error {
|
||||
if gasPrice <= 0 {
|
||||
return fmt.Errorf("gas price must be positive")
|
||||
}
|
||||
|
||||
if gasPrice > 1.0 { // 1 SNR per gas unit seems excessive
|
||||
return fmt.Errorf("gas price %f seems too high", gasPrice)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateGasLimit checks if a gas limit is reasonable.
|
||||
func ValidateGasLimit(gasLimit uint64) error {
|
||||
if gasLimit == 0 {
|
||||
return fmt.Errorf("gas limit must be positive")
|
||||
}
|
||||
|
||||
if gasLimit > 50000000 { // 50M gas limit seems excessive
|
||||
return fmt.Errorf("gas limit %d seems too high", gasLimit)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// OptimizeGasConfig optimizes gas configuration based on network conditions.
|
||||
func OptimizeGasConfig(config *GasConfig, networkInfo *NetworkGasInfo) *GasConfig {
|
||||
optimized := *config
|
||||
|
||||
// Use recommended gas price if it's higher than our minimum
|
||||
if networkInfo.RecommendedGasPrice > config.MinGasPrice {
|
||||
optimized.MinGasPrice = networkInfo.RecommendedGasPrice
|
||||
}
|
||||
|
||||
// Ensure gas adjustment is within reasonable bounds
|
||||
if optimized.Adjustment < MinGasAdjustment {
|
||||
optimized.Adjustment = MinGasAdjustment
|
||||
}
|
||||
if optimized.Adjustment > MaxGasAdjustment {
|
||||
optimized.Adjustment = MaxGasAdjustment
|
||||
}
|
||||
|
||||
// Use network max gas limit if it's lower than our configured max
|
||||
if networkInfo.MaxGasLimit > 0 && networkInfo.MaxGasLimit < config.MaxGasLimit {
|
||||
optimized.MaxGasLimit = networkInfo.MaxGasLimit
|
||||
}
|
||||
|
||||
return &optimized
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package tx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
sdk "github.com/cosmos/cosmos-sdk/types"
|
||||
banktypes "github.com/cosmos/cosmos-sdk/x/bank/types"
|
||||
|
||||
"github.com/sonr-io/sonr/client/config"
|
||||
)
|
||||
|
||||
// GasEstimatorTestSuite tests the gas estimator.
|
||||
type GasEstimatorTestSuite struct {
|
||||
suite.Suite
|
||||
estimator GasEstimator
|
||||
config *config.NetworkConfig
|
||||
}
|
||||
|
||||
func (suite *GasEstimatorTestSuite) SetupTest() {
|
||||
cfg := config.LocalNetwork()
|
||||
suite.config = &cfg
|
||||
|
||||
// Create mock gRPC connection for testing
|
||||
conn, err := grpc.Dial("localhost:9090", grpc.WithInsecure())
|
||||
suite.Require().NoError(err)
|
||||
|
||||
suite.estimator = NewGasEstimator(conn, suite.config)
|
||||
}
|
||||
|
||||
func (suite *GasEstimatorTestSuite) TestCalculateFee() {
|
||||
gasUsed := uint64(100000)
|
||||
gasPrice := 0.025
|
||||
denom := "usnr"
|
||||
|
||||
fee := suite.estimator.CalculateFee(gasUsed, gasPrice, denom)
|
||||
|
||||
suite.Require().NotNil(fee)
|
||||
suite.Require().Len(fee, 1)
|
||||
suite.Require().Equal(denom, fee[0].Denom)
|
||||
suite.Require().Equal(int64(2500), fee[0].Amount.Int64())
|
||||
}
|
||||
|
||||
func (suite *GasEstimatorTestSuite) TestCalculateFeeWithAdjustment() {
|
||||
gasUsed := uint64(100000)
|
||||
gasPrice := 0.025
|
||||
adjustment := 1.5
|
||||
denom := "usnr"
|
||||
|
||||
fee := suite.estimator.CalculateFeeWithAdjustment(gasUsed, gasPrice, adjustment, denom)
|
||||
|
||||
suite.Require().NotNil(fee)
|
||||
suite.Require().Len(fee, 1)
|
||||
suite.Require().Equal(denom, fee[0].Denom)
|
||||
suite.Require().Equal(int64(3750), fee[0].Amount.Int64())
|
||||
}
|
||||
|
||||
func (suite *GasEstimatorTestSuite) TestWithGasAdjustment() {
|
||||
adjustment := 2.0
|
||||
updated := suite.estimator.WithGasAdjustment(adjustment)
|
||||
|
||||
suite.Require().NotNil(updated)
|
||||
// Verify adjustment was applied
|
||||
ge := updated.(*gasEstimator)
|
||||
suite.Require().Equal(adjustment, ge.gasConfig.Adjustment)
|
||||
}
|
||||
|
||||
func (suite *GasEstimatorTestSuite) TestWithMinGasPrice() {
|
||||
price := 0.05
|
||||
updated := suite.estimator.WithMinGasPrice(price)
|
||||
|
||||
suite.Require().NotNil(updated)
|
||||
// Verify price was applied
|
||||
ge := updated.(*gasEstimator)
|
||||
suite.Require().Equal(price, ge.gasConfig.MinGasPrice)
|
||||
}
|
||||
|
||||
func (suite *GasEstimatorTestSuite) TestWithMaxGasLimit() {
|
||||
limit := uint64(5000000)
|
||||
updated := suite.estimator.WithMaxGasLimit(limit)
|
||||
|
||||
suite.Require().NotNil(updated)
|
||||
// Verify limit was applied
|
||||
ge := updated.(*gasEstimator)
|
||||
suite.Require().Equal(limit, ge.gasConfig.MaxGasLimit)
|
||||
}
|
||||
|
||||
func (suite *GasEstimatorTestSuite) TestGetRecommendedGasPrice() {
|
||||
price, err := suite.estimator.GetRecommendedGasPrice(context.Background())
|
||||
|
||||
suite.Require().NoError(err)
|
||||
suite.Require().Greater(price, 0.0)
|
||||
}
|
||||
|
||||
func (suite *GasEstimatorTestSuite) TestGetNetworkGasInfo() {
|
||||
info, err := suite.estimator.GetNetworkGasInfo(context.Background())
|
||||
|
||||
suite.Require().NoError(err)
|
||||
suite.Require().NotNil(info)
|
||||
suite.Require().Greater(info.MinGasPrice, 0.0)
|
||||
suite.Require().Greater(info.MaxGasLimit, uint64(0))
|
||||
}
|
||||
|
||||
func TestGasEstimatorTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(GasEstimatorTestSuite))
|
||||
}
|
||||
|
||||
// TestGasLimitForMessageType tests gas limit recommendations.
|
||||
func TestGasLimitForMessageType(t *testing.T) {
|
||||
tests := []struct {
|
||||
msgType string
|
||||
expectedGas uint64
|
||||
}{
|
||||
{
|
||||
msgType: "/cosmos.bank.v1beta1.MsgSend",
|
||||
expectedGas: SendGasLimit,
|
||||
},
|
||||
{
|
||||
msgType: "/cosmos.staking.v1beta1.MsgDelegate",
|
||||
expectedGas: DelegateGasLimit,
|
||||
},
|
||||
{
|
||||
msgType: "/cosmos.staking.v1beta1.MsgUndelegate",
|
||||
expectedGas: DelegateGasLimit,
|
||||
},
|
||||
{
|
||||
msgType: "/cosmos.staking.v1beta1.MsgRedelegate",
|
||||
expectedGas: DelegateGasLimit * 2,
|
||||
},
|
||||
{
|
||||
msgType: "/unknown.message.type",
|
||||
expectedGas: DefaultGasLimitValue,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.msgType, func(t *testing.T) {
|
||||
gas := GasLimitForMessageType(tt.msgType)
|
||||
require.Equal(t, tt.expectedGas, gas)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEstimateGasForMessages tests quick gas estimation.
|
||||
func TestEstimateGasForMessages(t *testing.T) {
|
||||
msgs := []sdk.Msg{
|
||||
&banktypes.MsgSend{
|
||||
FromAddress: "sonr1xyz...",
|
||||
ToAddress: "sonr1abc...",
|
||||
Amount: sdk.NewCoins(sdk.NewInt64Coin("usnr", 1000)),
|
||||
},
|
||||
}
|
||||
|
||||
gas := EstimateGasForMessages(msgs)
|
||||
|
||||
// Should be SendGasLimit + base overhead
|
||||
expected := uint64(SendGasLimit + 50000)
|
||||
require.Equal(t, expected, gas)
|
||||
}
|
||||
|
||||
// TestValidateGasPrice tests gas price validation.
|
||||
func TestValidateGasPrice(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
gasPrice float64
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "valid gas price",
|
||||
gasPrice: 0.025,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "zero gas price",
|
||||
gasPrice: 0,
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "negative gas price",
|
||||
gasPrice: -0.1,
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "excessive gas price",
|
||||
gasPrice: 2.0,
|
||||
wantError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateGasPrice(tt.gasPrice)
|
||||
if tt.wantError {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGasLimit tests gas limit validation.
|
||||
func TestValidateGasLimit(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
gasLimit uint64
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "valid gas limit",
|
||||
gasLimit: 200000,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "zero gas limit",
|
||||
gasLimit: 0,
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "excessive gas limit",
|
||||
gasLimit: 100000000,
|
||||
wantError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateGasLimit(tt.gasLimit)
|
||||
if tt.wantError {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestOptimizeGasConfig tests gas configuration optimization.
|
||||
func TestOptimizeGasConfig(t *testing.T) {
|
||||
config := &GasConfig{
|
||||
Adjustment: 0.5, // Too low
|
||||
MinGasPrice: 0.01,
|
||||
MaxGasLimit: 10000000,
|
||||
Denom: "usnr",
|
||||
}
|
||||
|
||||
networkInfo := &NetworkGasInfo{
|
||||
MinGasPrice: 0.025,
|
||||
MedianGasPrice: 0.03,
|
||||
RecommendedGasPrice: 0.035,
|
||||
MaxGasLimit: 5000000,
|
||||
}
|
||||
|
||||
optimized := OptimizeGasConfig(config, networkInfo)
|
||||
|
||||
// Should use recommended gas price
|
||||
require.Equal(t, networkInfo.RecommendedGasPrice, optimized.MinGasPrice)
|
||||
|
||||
// Should adjust to minimum adjustment
|
||||
require.Equal(t, MinGasAdjustment, optimized.Adjustment)
|
||||
|
||||
// Should use network max gas limit
|
||||
require.Equal(t, networkInfo.MaxGasLimit, optimized.MaxGasLimit)
|
||||
}
|
||||
Reference in New Issue
Block a user