diff --git a/pkg/lumera/Readme.md b/pkg/lumera/Readme.md index dbba164b..c1ef31fa 100644 --- a/pkg/lumera/Readme.md +++ b/pkg/lumera/Readme.md @@ -1,153 +1,77 @@ -# Lumera Client +## Lumera Client (Slim Guide) -A Go client for interacting with the Lumera blockchain. +A minimal guide to the Lumera client -## Features +What it is -- Connect to Lumera nodes via gRPC -- Interact with all Lumera modules: - - Action module - manage and query action data - - SuperNode module - interact with supernodes - - Transaction module - broadcast and query transactions - - Node module - query node status and blockchain info -- Configurable connection options -- Clean, modular API design with clear separation of interfaces and implementations +- Lightweight client over gRPC with small modules: `Auth`, `Action`, `ActionMsg`, `SuperNode`, `Tx`, `Node`. +- Shared tx pipeline for building, simulating, signing, and broadcasting messages. -## Installation - -```bash -go get github.com/LumeraProtocol/lumera-client -``` - -## Quick Start +Create a client ```go -package main - -import ( - "context" - "fmt" - "log" - - "github.com/LumeraProtocol/lumera-client/client" +cfg, _ := lumera.NewConfig( + "https://grpc.testnet.lumera.io", // or host:port + "chain-id", + keyName, // key name in your keyring + keyring, // cosmos-sdk keyring ) - -func main() { - // Create a context - ctx := context.Background() - - // Initialize the client with options - lumeraClient, err := client.NewClient( - ctx, - client.WithGRPCAddr("localhost:9090"), - client.WithChainID("lumera-mainnet"), - client.WithTimeout(30), - ) - if err != nil { - log.Fatalf("Failed to create Lumera client: %v", err) - } - defer lumeraClient.Close() - - // Get the latest block height - latestBlock, err := lumeraClient.Node().GetLatestBlock(ctx) - if err != nil { - log.Fatalf("Failed to get latest block: %v", err) - } - - fmt.Printf("Latest block height: %d\n", latestBlock.Block.Header.Height) -} -``` - -## Examples - -The repository includes example applications demonstrating how to use the client: - -- **Basic Example**: Shows simple queries and interactions with the Lumera blockchain -- **Advanced Example**: Demonstrates a complete transaction flow with error handling and retries - -To run the examples: - -```bash -# Build and run the basic example -make run-basic - -# Build and run the advanced example -make run-advanced +cli, _ := lumera.NewClient(ctx, cfg) +defer cli.Close() ``` -## Project Structure +Using modules -``` -lumera-client/ -│ # Core client package -│ interface.go # Client interface definitions -│ client.go # Client implementation -│ config.go # Configuration types -│ options.go # Option functions -│ connection.go # Connection handling -├── modules/ # Module-specific packages -│ ├── action/ # Action module -│ ├── node/ # Node module -│ ├── supernode/ # SuperNode module -│ └── tx/ # Transaction module -└── examples/ # Example applications - └── main.go # Basic usage example - -``` +- `cli.Action()` – query actions (GetAction, GetActionFee, GetParams) +- `cli.ActionMsg()` – send action messages (see below) +- `cli.Auth()` – accounts/verify +- `cli.SuperNode()` – supernode queries +- `cli.Tx()` – tx internals (shared by helpers) +- `cli.Node()` – chain/node info -## Module Documentation +Gas and fees (tx module) -### Action Module +- Default gas price: `0.025 ulume`. +- Accepts gas price as `"0.025"` or `"0.025ulume"`. +- Validate config and surface broadcast errors automatically. -The Action module allows you to interact with Lumera actions, which are the core data processing units in the Lumera blockchain. +Override gas price at runtime (keeps other defaults): ```go -// Get action by ID -action, err := client.Action().GetAction(ctx, "action-id-123") - -// Calculate fee for action with specific data size -fee, err := client.Action().GetActionFee(ctx, "1024") // 1KB data +am := cli.ActionMsg() +am.SetTxHelperConfig(&tx.TxHelperConfig{ GasPrice: "0.025ulume" }) ``` -### Node Module +Send actions (ActionMsg) -The Node module provides information about the blockchain and node status. +RequestAction: ```go -// Get latest block -block, err := client.Node().GetLatestBlock(ctx) - -// Get specific block by height -block, err := client.Node().GetBlockByHeight(ctx, 1000) - -// Get node information -nodeInfo, err := client.Node().GetNodeInfo(ctx) +resp, err := cli.ActionMsg().RequesAction( + ctx, + "CASCADE", + metadataJSON, // stringified JSON + "23800ulume", // positive integer ulume amount + fmt.Sprintf("%d", time.Now().Add(25*time.Hour).Unix()), // future expiry +) ``` -### SuperNode Module - -The SuperNode module allows you to interact with Lumera supernodes. +FinalizeCascadeAction: ```go -// Get top supernodes for a specific block -topNodes, err := client.SuperNode().GetTopSuperNodesForBlock(ctx, 1000) - -// Get specific supernode by address -node, err := client.SuperNode().GetSuperNode(ctx, "validator-address") +resp, err := cli.ActionMsg().FinalizeCascadeAction(ctx, actionID, []string{"rqid-1", "rqid-2"}) ``` -### Transaction Module - -The Transaction module handles transaction broadcasting and querying. +Validation rules (built-in) -```go -// Broadcast a signed transaction -resp, err := client.Tx().BroadcastTx(ctx, txBytes, sdktx.BroadcastMode_BROADCAST_MODE_SYNC) - -// Simulate a transaction -sim, err := client.Tx().SimulateTx(ctx, txBytes) +- RequestAction: + - `actionType`, `metadata`, `price`, `expirationTime` required. + - `price`: must be `ulume`. + - `expirationTime`: future Unix seconds. +- FinalizeCascadeAction: + - `actionId` required; `rqIdsIds` must have non-empty entries. -// Get transaction by hash -tx, err := client.Tx().GetTx(ctx, "tx-hash") -``` +Notes +- Method name is currently `RequesAction` (typo kept for compatibility). +- Tx uses simulation + adjustment + padding before sign/broadcast. diff --git a/pkg/lumera/codec/encoding.go b/pkg/lumera/codec/encoding.go index 04a66710..e38c9cd3 100644 --- a/pkg/lumera/codec/encoding.go +++ b/pkg/lumera/codec/encoding.go @@ -1,6 +1,8 @@ package codec import ( + "sync" + actiontypes "github.com/LumeraProtocol/lumera/x/action/v1/types" "github.com/cosmos/cosmos-sdk/client" "github.com/cosmos/cosmos-sdk/codec" @@ -18,6 +20,11 @@ type EncodingConfig struct { Amino *codec.LegacyAmino } +var ( + encOnce sync.Once + encCfg EncodingConfig +) + // NewEncodingConfig creates a new EncodingConfig with all required interfaces registered func NewEncodingConfig() EncodingConfig { amino := codec.NewLegacyAmino() @@ -47,5 +54,8 @@ func RegisterInterfaces(registry codectypes.InterfaceRegistry) { // GetEncodingConfig returns the standard encoding config for Lumera client func GetEncodingConfig() EncodingConfig { - return NewEncodingConfig() + // Cache the encoding config to avoid repeated allocations and registrations + // across transactions and simulations. + encOnce.Do(func() { encCfg = NewEncodingConfig() }) + return encCfg } diff --git a/pkg/lumera/modules/action_msg/helpers.go b/pkg/lumera/modules/action_msg/helpers.go index ce958ce5..6de5fb9f 100644 --- a/pkg/lumera/modules/action_msg/helpers.go +++ b/pkg/lumera/modules/action_msg/helpers.go @@ -1,37 +1,58 @@ package action_msg import ( - "fmt" + "fmt" + "strconv" + "time" - actionapi "github.com/LumeraProtocol/lumera/api/lumera/action" - actiontypes "github.com/LumeraProtocol/lumera/x/action/v1/types" - "google.golang.org/protobuf/encoding/protojson" + actionapi "github.com/LumeraProtocol/lumera/api/lumera/action" + actiontypes "github.com/LumeraProtocol/lumera/x/action/v1/types" + "github.com/LumeraProtocol/supernode/v2/pkg/lumera/util" + "google.golang.org/protobuf/encoding/protojson" ) func validateRequestActionParams(actionType, metadata, price, expirationTime string) error { - if actionType == "" { - return fmt.Errorf("action type cannot be empty") - } - if metadata == "" { - return fmt.Errorf("metadata cannot be empty") - } - if price == "" { - return fmt.Errorf("price cannot be empty") - } - if expirationTime == "" { - return fmt.Errorf("expiration time cannot be empty") - } - return nil + if actionType == "" { + return fmt.Errorf("action type cannot be empty") + } + if metadata == "" { + return fmt.Errorf("metadata cannot be empty") + } + if price == "" { + return fmt.Errorf("price cannot be empty") + } + // Validate price: must be integer coin in ulume (e.g., "1000ulume") + if err := util.ValidateUlumeIntCoin(price); err != nil { + return fmt.Errorf("invalid price: %w", err) + } + if expirationTime == "" { + return fmt.Errorf("expiration time cannot be empty") + } + // Validate expiration is a future unix timestamp + exp, err := strconv.ParseInt(expirationTime, 10, 64) + if err != nil { + return fmt.Errorf("invalid expirationTime: %w", err) + } + // Allow small clock skew; require strictly in the future + if exp <= time.Now().Add(30*time.Second).Unix() { + return fmt.Errorf("expiration time must be in the future") + } + return nil } func validateFinalizeActionParams(actionId string, rqIdsIds []string) error { - if actionId == "" { - return fmt.Errorf("action ID cannot be empty") - } - if len(rqIdsIds) == 0 { - return fmt.Errorf("rq_ids_ids cannot be empty for cascade action") - } - return nil + if actionId == "" { + return fmt.Errorf("action ID cannot be empty") + } + if len(rqIdsIds) == 0 { + return fmt.Errorf("rq_ids_ids cannot be empty for cascade action") + } + for i, s := range rqIdsIds { + if s == "" { + return fmt.Errorf("rq_ids_ids[%d] cannot be empty", i) + } + } + return nil } func createRequestActionMessage(creator, actionType, metadata, price, expirationTime string) *actiontypes.MsgRequestAction { diff --git a/pkg/lumera/modules/action_msg/impl.go b/pkg/lumera/modules/action_msg/impl.go index 4d5d8df4..33b6ac50 100644 --- a/pkg/lumera/modules/action_msg/impl.go +++ b/pkg/lumera/modules/action_msg/impl.go @@ -65,6 +65,9 @@ func (m *module) FinalizeCascadeAction(ctx context.Context, actionId string, rqI } func (m *module) SetTxHelperConfig(config *txmod.TxHelperConfig) { + if config == nil { + return + } m.txHelper.UpdateConfig(config) } diff --git a/pkg/lumera/modules/tx/helper.go b/pkg/lumera/modules/tx/helper.go index 886510e7..9218c542 100644 --- a/pkg/lumera/modules/tx/helper.go +++ b/pkg/lumera/modules/tx/helper.go @@ -135,16 +135,43 @@ func (h *TxHelper) GetAccountInfo(ctx context.Context) (*authtypes.BaseAccount, // UpdateConfig allows updating the transaction configuration func (h *TxHelper) UpdateConfig(config *TxHelperConfig) { - h.config = &TxConfig{ - ChainID: config.ChainID, - Keyring: config.Keyring, - KeyName: config.KeyName, - GasLimit: config.GasLimit, - GasAdjustment: config.GasAdjustment, - GasPadding: config.GasPadding, - FeeDenom: config.FeeDenom, - GasPrice: config.GasPrice, - } + // Merge provided fields with existing config to avoid zeroing defaults + if h.config == nil { + h.config = &TxConfig{} + } + + // ChainID + if config.ChainID != "" { + h.config.ChainID = config.ChainID + } + // Keyring + if config.Keyring != nil { + h.config.Keyring = config.Keyring + } + // KeyName + if config.KeyName != "" { + h.config.KeyName = config.KeyName + } + // GasLimit + if config.GasLimit != 0 { + h.config.GasLimit = config.GasLimit + } + // GasAdjustment + if config.GasAdjustment != 0 { + h.config.GasAdjustment = config.GasAdjustment + } + // GasPadding + if config.GasPadding != 0 { + h.config.GasPadding = config.GasPadding + } + // FeeDenom + if config.FeeDenom != "" { + h.config.FeeDenom = config.FeeDenom + } + // GasPrice + if config.GasPrice != "" { + h.config.GasPrice = config.GasPrice + } } // GetConfig returns the current transaction configuration diff --git a/pkg/lumera/modules/tx/impl.go b/pkg/lumera/modules/tx/impl.go index f61b121b..8f973b1c 100644 --- a/pkg/lumera/modules/tx/impl.go +++ b/pkg/lumera/modules/tx/impl.go @@ -23,7 +23,9 @@ const ( DefaultGasAdjustment = float64(1.5) DefaultGasPadding = uint64(50000) DefaultFeeDenom = "ulume" - DefaultGasPrice = "0.000001" + // DefaultGasPrice is the default min gas price in denom units (e.g., ulume) + // Set to 0.025 to match chain defaults where applicable. + DefaultGasPrice = "0.025" ) // module implements the Module interface @@ -165,29 +167,87 @@ func (m *module) BroadcastTransaction(ctx context.Context, txBytes []byte) (*sdk return nil, fmt.Errorf("failed to broadcast transaction: %w", err) } + // If the chain returns a non-zero code, surface it as an error with context + if resp != nil && resp.TxResponse != nil && resp.TxResponse.Code != 0 { + return resp, fmt.Errorf( + "tx failed: code=%d codespace=%s height=%d gas_wanted=%d gas_used=%d raw_log=%s", + resp.TxResponse.Code, + resp.TxResponse.Codespace, + resp.TxResponse.Height, + resp.TxResponse.GasWanted, + resp.TxResponse.GasUsed, + resp.TxResponse.RawLog, + ) + } + return resp, nil } // CalculateFee calculates the transaction fee based on gas usage and config func (m *module) CalculateFee(gasAmount uint64, config *TxConfig) string { - gasPrice, _ := strconv.ParseFloat(config.GasPrice, 64) - feeAmount := gasPrice * float64(gasAmount) + // Determine gas price (numeric) and denom. Accept both plain number (e.g., "0.025") + // and dec-coin format (e.g., "0.025ulume"). + var ( + gasPriceF float64 + denom = config.FeeDenom + ) + + gp := config.GasPrice + + // First try: parse as decimal coin if suffix present + if gp != "" { + // Attempt dec-coin parse (handles "0.025ulume") + if decCoin, err := types.ParseDecCoin(gp); err == nil { + // Amount is a decimal string; convert to float64 for calculation + if f, errf := strconv.ParseFloat(decCoin.Amount.String(), 64); errf == nil { + gasPriceF = f + } + if denom == "" { + denom = decCoin.Denom + } + } else { + // Fallback: parse as plain float (e.g., "0.025") + if f, err2 := strconv.ParseFloat(gp, 64); err2 == nil { + gasPriceF = f + } + } + } - // Ensure we have at least 1 token as fee to meet minimum requirements + // Fallbacks if not provided or parsing failed + if gasPriceF <= 0 { + if f, err := strconv.ParseFloat(DefaultGasPrice, 64); err == nil { + gasPriceF = f + } else { + gasPriceF = 0.0 + } + } + if denom == "" { + denom = DefaultFeeDenom + } + + feeAmount := gasPriceF * float64(gasAmount) + + // Ensure we have at least 1 unit of fee to meet minimal requirements if feeAmount < 1 { feeAmount = 1 } - return fmt.Sprintf("%.0f%s", feeAmount, config.FeeDenom) + return fmt.Sprintf("%.0f%s", feeAmount, denom) } // ProcessTransaction handles the complete flow: simulate, build, sign, and broadcast func (m *module) ProcessTransaction(ctx context.Context, msgs []types.Msg, accountInfo *authtypes.BaseAccount, config *TxConfig) (*sdktx.BroadcastTxResponse, error) { + if err := validateTxConfig(config); err != nil { + return nil, fmt.Errorf("invalid tx config: %w", err) + } // Step 1: Simulate transaction to get gas estimate simRes, err := m.SimulateTransaction(ctx, msgs, accountInfo, config) if err != nil { return nil, fmt.Errorf("simulation failed: %w", err) } + if simRes == nil || simRes.GasInfo == nil || simRes.GasInfo.GasUsed == 0 { + return nil, fmt.Errorf("invalid simulation result: empty or zero gas used") + } // Step 2: Calculate gas with adjustment and padding simulatedGasUsed := simRes.GasInfo.GasUsed @@ -213,3 +273,33 @@ func (m *module) ProcessTransaction(ctx context.Context, msgs []types.Msg, accou return result, nil } + +// validateTxConfig validates critical fields of TxConfig for safe processing. +func validateTxConfig(config *TxConfig) error { + if config == nil { + return fmt.Errorf("tx config cannot be nil") + } + if config.ChainID == "" { + return fmt.Errorf("chainID cannot be empty") + } + if config.Keyring == nil { + return fmt.Errorf("keyring cannot be nil") + } + if config.KeyName == "" { + return fmt.Errorf("key name cannot be empty") + } + if config.GasAdjustment <= 0 { + return fmt.Errorf("gas adjustment must be > 0 (got %v)", config.GasAdjustment) + } + // If a gas price is provided, validate its format. Accept dec-coin or plain decimal. + if gp := config.GasPrice; gp != "" { + if decCoin, err := types.ParseDecCoin(gp); err == nil { + if config.FeeDenom != "" && config.FeeDenom != decCoin.Denom { + return fmt.Errorf("fee denom %q does not match gas price denom %q", config.FeeDenom, decCoin.Denom) + } + } else if _, err2 := strconv.ParseFloat(gp, 64); err2 != nil { + return fmt.Errorf("invalid gas price format %q; use '0.025' or '0.025ulume'", gp) + } + } + return nil +} diff --git a/pkg/lumera/util/coin.go b/pkg/lumera/util/coin.go new file mode 100644 index 00000000..561f5560 --- /dev/null +++ b/pkg/lumera/util/coin.go @@ -0,0 +1,35 @@ +package util + +import ( + "fmt" + "strings" +) + +// ValidateUlumeIntCoin checks that the input is a positive integer amount +// with the 'ulume' denom, e.g., "1000ulume". It keeps validation simple +// without pulling in SDK dependencies. +func ValidateUlumeIntCoin(s string) error { + const denom = "ulume" + if !strings.HasSuffix(s, denom) { + return fmt.Errorf("denom must be '%s'", denom) + } + num := s[:len(s)-len(denom)] + if num == "" { + return fmt.Errorf("amount is required before denom") + } + // must be all digits, no leading +/-, no decimals + var val uint64 + for i := 0; i < len(num); i++ { + c := num[i] + if c < '0' || c > '9' { + return fmt.Errorf("amount must be an integer number") + } + // simple overflow-safe accumulation for uint64 + val = val*10 + uint64(c-'0') + } + if val == 0 { + return fmt.Errorf("amount must be greater than zero") + } + return nil +} +