* clear

* feat: Add everything

* fix: Commenht
This commit is contained in:
Prad Nukala
2025-10-03 14:45:52 -04:00
committed by GitHub
parent 43b4a11c06
commit 13e6c3e84d
1935 changed files with 655061 additions and 40058 deletions
+357
View File
@@ -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
}
+442
View File
@@ -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))
}
+191
View File
@@ -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)
}
})
}
}
+316
View File
@@ -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
}
+268
View File
@@ -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)
}