Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
## Developer Workflows
- `make sdk` (same as `make build`) compiles all packages; `make examples` or `make example-<name>` drops binaries into `build/`.
- `make test` runs `go test -race -coverprofile=coverage.out ./...` and emits `coverage.html`; `make lint` requires `golangci-lint`.
- Stick to Go 1.25.5 (per `go.mod`) and respect the `replace` pins for CometBFT/Cosmos; update both blockchain + SuperNode deps together when bumping versions.
- Stick to Go 1.26.1 (per `go.mod`) and respect the `replace` pins for CometBFT/Cosmos; update both blockchain + SuperNode deps together when bumping versions.

## Conventions
- Errors wrap context with `fmt.Errorf("context: %w", err)` so callers can unwrap lower layers.
Expand Down
19 changes: 17 additions & 2 deletions blockchain/base/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (
clientconfig "github.com/LumeraProtocol/sdk-go/client/config"
)

const defaultMaxMessageSize = 50 * 1024 * 1024

// Client provides common Cosmos SDK gRPC and tx helpers.
type Client struct {
conn *grpc.ClientConn
Expand All @@ -23,6 +25,8 @@ type Client struct {

// New creates a base blockchain client with a gRPC connection.
func New(ctx context.Context, cfg Config, kr keyring.Keyring, keyName string) (*Client, error) {
applyConfigDefaults(&cfg)

// Determine if we should use TLS based on the endpoint.
// Use TLS if: port is 443, or hostname doesn't start with "localhost"/"127.0.0.1".
useTLS := shouldUseTLS(cfg.GRPCAddr)
Expand All @@ -48,8 +52,6 @@ func New(ctx context.Context, cfg Config, kr keyring.Keyring, keyName string) (*
),
}

clientconfig.ApplyWaitTxDefaults(&cfg.WaitTx)

conn, err := grpc.NewClient(cfg.GRPCAddr, dialOpts...)
if err != nil {
return nil, fmt.Errorf("failed to connect to gRPC: %w", err)
Expand All @@ -63,6 +65,19 @@ func New(ctx context.Context, cfg Config, kr keyring.Keyring, keyName string) (*
}, nil
}

func applyConfigDefaults(cfg *Config) {
if cfg == nil {
return
}
if cfg.MaxRecvMsgSize <= 0 {
cfg.MaxRecvMsgSize = defaultMaxMessageSize
}
if cfg.MaxSendMsgSize <= 0 {
cfg.MaxSendMsgSize = defaultMaxMessageSize
}
clientconfig.ApplyWaitTxDefaults(&cfg.WaitTx)
}

// Close closes the underlying gRPC connection.
func (c *Client) Close() error {
if c.conn != nil {
Expand Down
259 changes: 190 additions & 69 deletions blockchain/base/tx.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"time"

txtypes "cosmossdk.io/api/cosmos/tx/v1beta1"
"github.com/cosmos/cosmos-sdk/client"
"github.com/cosmos/cosmos-sdk/crypto/keyring"
sdk "github.com/cosmos/cosmos-sdk/types"
signingtypes "github.com/cosmos/cosmos-sdk/types/tx/signing"
authtypes "github.com/cosmos/cosmos-sdk/x/auth/types"
Expand All @@ -17,6 +19,26 @@ import (
sdkcrypto "github.com/LumeraProtocol/sdk-go/pkg/crypto"
)

const defaultSignedTxGasLimit = 200000

// TxBuildOptions controls how a transaction is assembled and signed.
type TxBuildOptions struct {
Messages []sdk.Msg
Memo string
GasAdjustment float64
GasLimit uint64
SkipSimulation bool
AccountNumber *uint64
Sequence *uint64
FeeAmount sdk.Coins
}

// TxSignerInfo contains the signer account metadata used for signing.
type TxSignerInfo struct {
AccountNumber uint64
Sequence uint64
}

// Simulate runs a gas simulation for a provided tx bytes.
func (c *Client) Simulate(ctx context.Context, txBytes []byte) (uint64, error) {
svc := txtypes.NewServiceClient(c.conn)
Expand Down Expand Up @@ -54,9 +76,28 @@ func (c *Client) Broadcast(ctx context.Context, txBytes []byte, mode txtypes.Bro
return resp.TxResponse.GetTxhash(), nil
}

// BroadcastAndWait broadcasts signed tx bytes, then waits for final inclusion.
func (c *Client) BroadcastAndWait(ctx context.Context, txBytes []byte, mode txtypes.BroadcastMode) (string, *txtypes.GetTxResponse, error) {
txHash, err := c.Broadcast(ctx, txBytes, mode)
if err != nil {
return "", nil, err
}

resp, err := c.WaitForTxInclusion(ctx, txHash)
if err != nil {
return txHash, nil, err
}

return txHash, resp, nil
}

// BuildAndSignTx builds a transaction with one message, simulates gas, then signs it.
func (c *Client) BuildAndSignTx(ctx context.Context, msg sdk.Msg, memo string) ([]byte, error) {
return c.buildAndSignTx(ctx, msg, memo, 1.3)
return c.BuildAndSignTxWithOptions(ctx, TxBuildOptions{
Messages: []sdk.Msg{msg},
Memo: memo,
GasAdjustment: 1.3,
})
}

// BuildAndSignTxWithGasAdjustment builds a transaction with one message, simulates gas,
Expand All @@ -65,115 +106,92 @@ func (c *Client) BuildAndSignTxWithGasAdjustment(ctx context.Context, msg sdk.Ms
if gasAdjustment <= 0 {
gasAdjustment = 1.3
}
return c.buildAndSignTx(ctx, msg, memo, gasAdjustment)
return c.BuildAndSignTxWithOptions(ctx, TxBuildOptions{
Messages: []sdk.Msg{msg},
Memo: memo,
GasAdjustment: gasAdjustment,
})
}

func (c *Client) buildAndSignTx(ctx context.Context, msg sdk.Msg, memo string, gasAdjustment float64) ([]byte, error) {
if c.keyring == nil {
return nil, fmt.Errorf("keyring is required")
}
if strings.TrimSpace(c.keyName) == "" {
return nil, fmt.Errorf("key name is required")
}
if strings.TrimSpace(c.config.AccountHRP) == "" {
return nil, fmt.Errorf("account HRP is required")
}
if strings.TrimSpace(c.config.FeeDenom) == "" {
return nil, fmt.Errorf("fee denom is required")
}
if c.config.GasPrice.IsNil() || c.config.GasPrice.IsZero() {
return nil, fmt.Errorf("gas price is required")
// BuildAndSignTxWithOptions builds and signs a transaction using explicit options.
func (c *Client) BuildAndSignTxWithOptions(ctx context.Context, opts TxBuildOptions) ([]byte, error) {
txCfg, builder, signerInfo, err := c.PrepareTx(ctx, opts)
if err != nil {
return nil, err
}
return c.SignPreparedTx(ctx, txCfg, builder, signerInfo)
}

// 1) Tx config and builder
// PrepareTx builds an unsigned tx builder and resolves the signer metadata.
func (c *Client) PrepareTx(ctx context.Context, opts TxBuildOptions) (client.TxConfig, client.TxBuilder, TxSignerInfo, error) {
txCfg := sdkcrypto.NewDefaultTxConfig()
builder := txCfg.NewTxBuilder()
if err := builder.SetMsgs(msg); err != nil {
return nil, fmt.Errorf("set msgs: %w", err)
}
if memo != "" {
builder.SetMemo(memo)
}

// 2) Resolve account number/sequence BEFORE simulation
rec, err := c.keyring.Key(c.keyName)
if err != nil {
return nil, fmt.Errorf("load key %q: %w", c.keyName, err)
if err := c.validateTxBuildOptions(opts); err != nil {
return nil, nil, TxSignerInfo{}, err
}
accAddr, err := sdkcrypto.AddressFromKey(c.keyring, c.keyName, c.config.AccountHRP)
if err != nil {
return nil, fmt.Errorf("derive address for %q: %w", c.keyName, err)
if err := builder.SetMsgs(opts.Messages...); err != nil {
return nil, nil, TxSignerInfo{}, fmt.Errorf("set msgs: %w", err)
}
if opts.Memo != "" {
builder.SetMemo(opts.Memo)
}

authq := authtypes.NewQueryClient(c.conn)
acctResp, err := authq.AccountInfo(ctx, &authtypes.QueryAccountInfoRequest{
Address: accAddr,
})
rec, signerInfo, err := c.resolveSignerInfo(ctx, opts)
if err != nil {
return nil, fmt.Errorf("query account info: %w", err)
}
if acctResp == nil || acctResp.Info == nil {
return nil, fmt.Errorf("empty account info response")
return nil, nil, TxSignerInfo{}, err
}

// 3) Build placeholder signature using real sequence
pk, err := rec.GetPubKey()
if err != nil {
return nil, fmt.Errorf("get pubkey for %q: %w", c.keyName, err)
return nil, nil, TxSignerInfo{}, fmt.Errorf("get pubkey for %q: %w", c.keyName, err)
}
signMode := txCfg.SignModeHandler().DefaultMode()
placeholder := signingtypes.SignatureV2{
PubKey: pk,
Data: &signingtypes.SingleSignatureData{
SignMode: signingtypes.SignMode(signMode),
},
Sequence: acctResp.Info.Sequence, // use real sequence for simulation
Sequence: signerInfo.Sequence,
}
if err := builder.SetSignatures(placeholder); err != nil {
return nil, fmt.Errorf("set placeholder signature: %w", err)
return nil, nil, TxSignerInfo{}, fmt.Errorf("set placeholder signature: %w", err)
}

// 4) Simulate with placeholder to get gas
unsignedBytes, err := txCfg.TxEncoder()(builder.GetTx())
gas, err := c.resolveGasLimit(ctx, txCfg, builder, opts)
if err != nil {
return nil, fmt.Errorf("encode unsigned tx: %w", err)
return nil, nil, TxSignerInfo{}, err
}
builder.SetGasLimit(gas)

gasUsed, err := c.Simulate(ctx, unsignedBytes)
gas := uint64(0)
if err == nil && gasUsed > 0 {
// add an adjustable buffer
gas = uint64(float64(gasUsed) * gasAdjustment)
if gas == 0 {
gas = gasUsed
}
} else {
// On simulation failure, proceed with a conservative default gas
if builder.GetTx().GetGas() == 0 {
gas = 200000
}
if err := builder.SetSignatures(); err != nil {
return nil, nil, TxSignerInfo{}, fmt.Errorf("clear placeholder signature: %w", err)
}
builder.SetGasLimit(gas)

err = builder.SetSignatures() // clear placeholder signature
feeAmount, err := c.resolveFeeAmount(gas, opts)
if err != nil {
return nil, fmt.Errorf("clear placeholder signature: %w", err)
return nil, nil, TxSignerInfo{}, err
}
builder.SetFeeAmount(feeAmount)

// Ensure a minimum fee to satisfy chain requirements
feeDec := c.config.GasPrice.MulInt64(int64(gas)).Ceil().TruncateInt()
minFee := sdk.NewCoins(sdk.NewCoin(c.config.FeeDenom, feeDec))
builder.SetFeeAmount(minFee)
return txCfg, builder, signerInfo, nil
}

// 5) Sign with real credentials, overwriting placeholder
// SignPreparedTx signs a prepared tx builder using explicit signer info.
func (c *Client) SignPreparedTx(ctx context.Context, txCfg client.TxConfig, builder client.TxBuilder, signerInfo TxSignerInfo) ([]byte, error) {
if c.keyring == nil {
return nil, fmt.Errorf("keyring is required")
}
if strings.TrimSpace(c.keyName) == "" {
return nil, fmt.Errorf("key name is required")
}
if err := sdkcrypto.SignTxWithKeyring(
ctx, txCfg, c.keyring, c.keyName, builder,
c.config.ChainID, acctResp.Info.AccountNumber, acctResp.Info.Sequence, true,
c.config.ChainID, signerInfo.AccountNumber, signerInfo.Sequence, true,
); err != nil {
return nil, fmt.Errorf("sign tx: %w", err)
}

// 6) Encode signed tx
signedBytes, err := txCfg.TxEncoder()(builder.GetTx())
if err != nil {
return nil, fmt.Errorf("encode signed tx: %w", err)
Expand All @@ -182,6 +200,109 @@ func (c *Client) buildAndSignTx(ctx context.Context, msg sdk.Msg, memo string, g
return signedBytes, nil
}

func (c *Client) validateTxBuildOptions(opts TxBuildOptions) error {
if c.keyring == nil {
return fmt.Errorf("keyring is required")
}
if strings.TrimSpace(c.keyName) == "" {
return fmt.Errorf("key name is required")
}
if len(opts.Messages) == 0 {
return fmt.Errorf("at least one message is required")
}
if strings.TrimSpace(c.config.AccountHRP) == "" {
return fmt.Errorf("account HRP is required")
}
if opts.FeeAmount.Empty() {
if strings.TrimSpace(c.config.FeeDenom) == "" {
return fmt.Errorf("fee denom is required")
}
if c.config.GasPrice.IsNil() || c.config.GasPrice.IsZero() {
return fmt.Errorf("gas price is required")
}
}
return nil
}

func (c *Client) resolveSignerInfo(ctx context.Context, opts TxBuildOptions) (*keyring.Record, TxSignerInfo, error) {
rec, err := c.keyring.Key(c.keyName)
if err != nil {
return nil, TxSignerInfo{}, fmt.Errorf("load key %q: %w", c.keyName, err)
}

info := TxSignerInfo{}
if opts.AccountNumber != nil {
info.AccountNumber = *opts.AccountNumber
}
if opts.Sequence != nil {
info.Sequence = *opts.Sequence
}
if opts.AccountNumber != nil && opts.Sequence != nil {
return rec, info, nil
}

accAddr, err := sdkcrypto.AddressFromKey(c.keyring, c.keyName, c.config.AccountHRP)
if err != nil {
return nil, TxSignerInfo{}, fmt.Errorf("derive address for %q: %w", c.keyName, err)
}

authq := authtypes.NewQueryClient(c.conn)
acctResp, err := authq.AccountInfo(ctx, &authtypes.QueryAccountInfoRequest{
Address: accAddr,
})
if err != nil {
return nil, TxSignerInfo{}, fmt.Errorf("query account info: %w", err)
}
if acctResp == nil || acctResp.Info == nil {
return nil, TxSignerInfo{}, fmt.Errorf("empty account info response")
}
if opts.AccountNumber == nil {
info.AccountNumber = acctResp.Info.AccountNumber
}
if opts.Sequence == nil {
info.Sequence = acctResp.Info.Sequence
}

return rec, info, nil
}

func (c *Client) resolveGasLimit(ctx context.Context, txCfg client.TxConfig, builder client.TxBuilder, opts TxBuildOptions) (uint64, error) {
if opts.GasLimit > 0 {
return opts.GasLimit, nil
}
if opts.SkipSimulation {
return defaultSignedTxGasLimit, nil
}

unsignedBytes, err := txCfg.TxEncoder()(builder.GetTx())
if err != nil {
return 0, fmt.Errorf("encode unsigned tx: %w", err)
}

gasUsed, simErr := c.Simulate(ctx, unsignedBytes)
if simErr != nil || gasUsed == 0 {
return defaultSignedTxGasLimit, nil
}

gasAdjustment := opts.GasAdjustment
if gasAdjustment <= 0 {
gasAdjustment = 1.3
}
gas := uint64(float64(gasUsed) * gasAdjustment)
if gas == 0 {
gas = gasUsed
}
return gas, nil
}

func (c *Client) resolveFeeAmount(gas uint64, opts TxBuildOptions) (sdk.Coins, error) {
if !opts.FeeAmount.Empty() {
return opts.FeeAmount, nil
}
feeDec := c.config.GasPrice.MulInt64(int64(gas)).Ceil().TruncateInt()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

int64(gas) silently wraps to a negative number when gas > math.MaxInt64. Now that GasLimit is directly user-controllable via TxBuildOptions, a caller passing a very large value (or a value computed from a large simulation result with a high adjustment factor) would produce an incorrect fee. Consider adding a bounds check or using sdkmath.NewIntFromUint64(gas) to avoid the cast.

Suggested change
feeDec := c.config.GasPrice.MulInt64(int64(gas)).Ceil().TruncateInt()
feeDec := c.config.GasPrice.MulInt(sdkmath.NewIntFromUint64(gas)).Ceil().TruncateInt()

Fix it with Roo Code or mention @roomote and request a fix.

return sdk.NewCoins(sdk.NewCoin(c.config.FeeDenom, feeDec)), nil
}

// GetTx fetches a transaction by hash via the tx service.
func (c *Client) GetTx(ctx context.Context, hash string) (*txtypes.GetTxResponse, error) {
svc := txtypes.NewServiceClient(c.conn)
Expand Down
Loading