diff --git a/cmd/util.go b/cmd/util.go index bbc901e4f2..cf48b00e78 100644 --- a/cmd/util.go +++ b/cmd/util.go @@ -111,9 +111,13 @@ func PersistentPreRunEnv(cmd *cobra.Command, nodeType node.Type, _ []string) err return err } + err = state.ParseFlags(cmd, &cfg.State) + if err != nil { + return err + } + rpc_cfg.ParseFlags(cmd, &cfg.RPC) gateway.ParseFlags(cmd, &cfg.Gateway) - state.ParseFlags(cmd, &cfg.State) switch nodeType { case node.Light: diff --git a/nodebuilder/state/cmd/state.go b/nodebuilder/state/cmd/state.go index 031c118f98..ff7ef609ca 100644 --- a/nodebuilder/state/cmd/state.go +++ b/nodebuilder/state/cmd/state.go @@ -12,6 +12,10 @@ import ( "github.com/celestiaorg/celestia-node/state" ) +var ( + amount uint64 +) + func init() { Cmd.AddCommand( accountAddressCmd, @@ -26,6 +30,16 @@ func init() { queryDelegationCmd, queryUnbondingCmd, queryRedelegationCmd, + grantFeeCmd, + revokeGrantFeeCmd, + ) + + grantFeeCmd.PersistentFlags().Uint64Var( + &amount, + "amount", + 0, + "specifies the spend limit(in utia) for the grantee.\n"+ + "The default value is 0 which means the grantee does not have a spend limit.", ) } @@ -402,6 +416,75 @@ var queryRedelegationCmd = &cobra.Command{ }, } +var grantFeeCmd = &cobra.Command{ + Use: "grant-fee [granteeAddress] [fee] [gasLimit]", + Short: "Grant an allowance to a specified grantee account to pay the fees for their transactions.\n" + + "Grantee can spend any amount of tokens in case the spend limit is not set.", + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cmdnode.ParseClientFromCtx(cmd.Context()) + if err != nil { + return err + } + defer client.Close() + + granteeAddr, err := parseAddressFromString(args[0]) + if err != nil { + return fmt.Errorf("error parsing an address:%v", err) + } + + fee, err := strconv.ParseInt(args[1], 10, 64) + if err != nil { + return fmt.Errorf("error parsing a fee:%v", err) + } + gasLimit, err := strconv.ParseUint(args[2], 10, 64) + if err != nil { + return fmt.Errorf("error parsing a gas limit:%v", err) + } + + txResponse, err := client.State.GrantFee( + cmd.Context(), + granteeAddr.Address.(state.AccAddress), + math.NewInt(int64(amount)), math.NewInt(fee), gasLimit, + ) + return cmdnode.PrintOutput(txResponse, err, nil) + }, +} + +var revokeGrantFeeCmd = &cobra.Command{ + Use: "revoke-grant-fee [granteeAddress] [fee] [gasLimit]", + Short: "Removes permission for grantee to submit PFB transactions which will be paid by granter.", + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cmdnode.ParseClientFromCtx(cmd.Context()) + if err != nil { + return err + } + defer client.Close() + + granteeAddr, err := parseAddressFromString(args[0]) + if err != nil { + return fmt.Errorf("error parsing an address:%v", err) + } + + fee, err := strconv.ParseInt(args[1], 10, 64) + if err != nil { + return fmt.Errorf("error parsing a fee:%v", err) + } + gasLimit, err := strconv.ParseUint(args[2], 10, 64) + if err != nil { + return fmt.Errorf("error parsing a gas limit:%v", err) + } + + txResponse, err := client.State.RevokeGrantFee( + cmd.Context(), + granteeAddr.Address.(state.AccAddress), + math.NewInt(fee), gasLimit, + ) + return cmdnode.PrintOutput(txResponse, err, nil) + }, +} + func parseAddressFromString(addrStr string) (state.Address, error) { var address state.Address err := address.UnmarshalJSON([]byte(addrStr)) diff --git a/nodebuilder/state/config.go b/nodebuilder/state/config.go index f42e646b76..e3589c7a68 100644 --- a/nodebuilder/state/config.go +++ b/nodebuilder/state/config.go @@ -2,6 +2,8 @@ package state import ( "github.com/cosmos/cosmos-sdk/crypto/keyring" + + "github.com/celestiaorg/celestia-node/state" ) var defaultKeyringBackend = keyring.BackendTest @@ -11,12 +13,14 @@ var defaultKeyringBackend = keyring.BackendTest type Config struct { KeyringAccName string KeyringBackend string + GranterAddress state.AccAddress } func DefaultConfig() Config { return Config{ KeyringAccName: "", KeyringBackend: defaultKeyringBackend, + GranterAddress: state.AccAddress{}, } } diff --git a/nodebuilder/state/core.go b/nodebuilder/state/core.go index f8f8508540..51f2a6bfbf 100644 --- a/nodebuilder/state/core.go +++ b/nodebuilder/state/core.go @@ -19,12 +19,13 @@ func coreAccessor( signer *apptypes.KeyringSigner, sync *sync.Syncer[*header.ExtendedHeader], fraudServ libfraud.Service[*header.ExtendedHeader], + opts []state.Option, ) (*state.CoreAccessor, Module, *modfraud.ServiceBreaker[*state.CoreAccessor, *header.ExtendedHeader]) { - ca := state.NewCoreAccessor(signer, sync, corecfg.IP, corecfg.RPCPort, corecfg.GRPCPort) - - return ca, ca, &modfraud.ServiceBreaker[*state.CoreAccessor, *header.ExtendedHeader]{ + ca := state.NewCoreAccessor(signer, sync, corecfg.IP, corecfg.RPCPort, corecfg.GRPCPort, opts...) + sBreaker := &modfraud.ServiceBreaker[*state.CoreAccessor, *header.ExtendedHeader]{ Service: ca, FraudType: byzantine.BadEncoding, FraudServ: fraudServ, } + return ca, ca, sBreaker } diff --git a/nodebuilder/state/flags.go b/nodebuilder/state/flags.go index 7e35bfa078..75d5f0fb28 100644 --- a/nodebuilder/state/flags.go +++ b/nodebuilder/state/flags.go @@ -3,6 +3,7 @@ package state import ( "fmt" + sdktypes "github.com/cosmos/cosmos-sdk/types" "github.com/spf13/cobra" flag "github.com/spf13/pflag" ) @@ -10,6 +11,8 @@ import ( var ( keyringAccNameFlag = "keyring.accname" keyringBackendFlag = "keyring.backend" + + granterAddressFlag = "granter.address" ) // Flags gives a set of hardcoded State flags. @@ -21,15 +24,25 @@ func Flags() *flag.FlagSet { flags.String(keyringBackendFlag, defaultKeyringBackend, fmt.Sprintf("Directs node's keyring signer to use the given "+ "backend. Default is %s.", defaultKeyringBackend)) + flags.String(granterAddressFlag, "", "Account address that will pay for all transactions submitted from the node.") return flags } // ParseFlags parses State flags from the given cmd and saves them to the passed config. -func ParseFlags(cmd *cobra.Command, cfg *Config) { +func ParseFlags(cmd *cobra.Command, cfg *Config) error { keyringAccName := cmd.Flag(keyringAccNameFlag).Value.String() if keyringAccName != "" { cfg.KeyringAccName = keyringAccName } cfg.KeyringBackend = cmd.Flag(keyringBackendFlag).Value.String() + + addr := cmd.Flag(granterAddressFlag).Value.String() + if addr == "" { + return nil + } + + sdkAddress, err := sdktypes.AccAddressFromBech32(addr) + cfg.GranterAddress = sdkAddress + return err } diff --git a/nodebuilder/state/mocks/api.go b/nodebuilder/state/mocks/api.go index 1861a86e66..5e132f6e21 100644 --- a/nodebuilder/state/mocks/api.go +++ b/nodebuilder/state/mocks/api.go @@ -130,6 +130,21 @@ func (mr *MockModuleMockRecorder) Delegate(arg0, arg1, arg2, arg3, arg4 interfac return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delegate", reflect.TypeOf((*MockModule)(nil).Delegate), arg0, arg1, arg2, arg3, arg4) } +// GrantFee mocks base method. +func (m *MockModule) GrantFee(arg0 context.Context, arg1 types.AccAddress, arg2, arg3 math.Int, arg4 uint64) (*types.TxResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GrantFee", arg0, arg1, arg2, arg3, arg4) + ret0, _ := ret[0].(*types.TxResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GrantFee indicates an expected call of GrantFee. +func (mr *MockModuleMockRecorder) GrantFee(arg0, arg1, arg2, arg3, arg4 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GrantFee", reflect.TypeOf((*MockModule)(nil).GrantFee), arg0, arg1, arg2, arg3, arg4) +} + // QueryDelegation mocks base method. func (m *MockModule) QueryDelegation(arg0 context.Context, arg1 types.ValAddress) (*types0.QueryDelegationResponse, error) { m.ctrl.T.Helper() @@ -175,6 +190,21 @@ func (mr *MockModuleMockRecorder) QueryUnbonding(arg0, arg1 interface{}) *gomock return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QueryUnbonding", reflect.TypeOf((*MockModule)(nil).QueryUnbonding), arg0, arg1) } +// RevokeGrantFee mocks base method. +func (m *MockModule) RevokeGrantFee(arg0 context.Context, arg1 types.AccAddress, arg2 math.Int, arg3 uint64) (*types.TxResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RevokeGrantFee", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(*types.TxResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RevokeGrantFee indicates an expected call of RevokeGrantFee. +func (mr *MockModuleMockRecorder) RevokeGrantFee(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RevokeGrantFee", reflect.TypeOf((*MockModule)(nil).RevokeGrantFee), arg0, arg1, arg2, arg3) +} + // SubmitPayForBlob mocks base method. func (m *MockModule) SubmitPayForBlob(arg0 context.Context, arg1 math.Int, arg2 uint64, arg3 []*blob.Blob) (*types.TxResponse, error) { m.ctrl.T.Helper() diff --git a/nodebuilder/state/module.go b/nodebuilder/state/module.go index 733419a918..e8d701de75 100644 --- a/nodebuilder/state/module.go +++ b/nodebuilder/state/module.go @@ -21,10 +21,14 @@ var log = logging.Logger("module/state") func ConstructModule(tp node.Type, cfg *Config, coreCfg *core.Config) fx.Option { // sanitize config values before constructing module cfgErr := cfg.Validate() - + opts := make([]state.Option, 0) + if !cfg.GranterAddress.Empty() { + opts = append(opts, state.WithGranter(cfg.GranterAddress)) + } baseComponents := fx.Options( fx.Supply(*cfg), fx.Error(cfgErr), + fx.Supply(opts), fxutil.ProvideIf(coreCfg.IsEndpointConfigured(), fx.Annotate( coreAccessor, fx.OnStart(func(ctx context.Context, diff --git a/nodebuilder/state/state.go b/nodebuilder/state/state.go index 52a2317445..949d7f9a26 100644 --- a/nodebuilder/state/state.go +++ b/nodebuilder/state/state.go @@ -92,6 +92,21 @@ type Module interface { srcValAddr, dstValAddr state.ValAddress, ) (*types.QueryRedelegationsResponse, error) + + GrantFee( + ctx context.Context, + grantee state.AccAddress, + amount, + fee state.Int, + gasLim uint64, + ) (*state.TxResponse, error) + + RevokeGrantFee( + ctx context.Context, + grantee state.AccAddress, + fee state.Int, + gasLim uint64, + ) (*state.TxResponse, error) } // API is a wrapper around Module for the RPC. @@ -160,6 +175,20 @@ type API struct { srcValAddr, dstValAddr state.ValAddress, ) (*types.QueryRedelegationsResponse, error) `perm:"read"` + GrantFee func( + ctx context.Context, + grantee state.AccAddress, + amount, + fee state.Int, + gasLim uint64, + ) (*state.TxResponse, error) `perm:"write"` + + RevokeGrantFee func( + ctx context.Context, + grantee state.AccAddress, + fee state.Int, + gasLim uint64, + ) (*state.TxResponse, error) `perm:"write"` } } @@ -256,3 +285,22 @@ func (api *API) QueryRedelegations( func (api *API) Balance(ctx context.Context) (*state.Balance, error) { return api.Internal.Balance(ctx) } + +func (api *API) GrantFee( + ctx context.Context, + grantee state.AccAddress, + amount, + fee state.Int, + gasLim uint64, +) (*state.TxResponse, error) { + return api.Internal.GrantFee(ctx, grantee, amount, fee, gasLim) +} + +func (api *API) RevokeGrantFee( + ctx context.Context, + grantee state.AccAddress, + fee state.Int, + gasLim uint64, +) (*state.TxResponse, error) { + return api.Internal.RevokeGrantFee(ctx, grantee, fee, gasLim) +} diff --git a/nodebuilder/state/stub.go b/nodebuilder/state/stub.go index 30a431aba5..58f39dd426 100644 --- a/nodebuilder/state/stub.go +++ b/nodebuilder/state/stub.go @@ -110,3 +110,22 @@ func (s stubbedStateModule) QueryRedelegations( ) (*types.QueryRedelegationsResponse, error) { return nil, ErrNoStateAccess } + +func (s stubbedStateModule) GrantFee( + _ context.Context, + _ state.AccAddress, + _, + _ state.Int, + _ uint64, +) (*state.TxResponse, error) { + return nil, ErrNoStateAccess +} + +func (s stubbedStateModule) RevokeGrantFee( + _ context.Context, + _ state.AccAddress, + _ state.Int, + _ uint64, +) (*state.TxResponse, error) { + return nil, ErrNoStateAccess +} diff --git a/state/core_access.go b/state/core_access.go index c50fbdea9f..76bfe26d44 100644 --- a/state/core_access.go +++ b/state/core_access.go @@ -13,9 +13,11 @@ import ( nodeservice "github.com/cosmos/cosmos-sdk/client/grpc/node" storetypes "github.com/cosmos/cosmos-sdk/store/types" sdktypes "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" sdktx "github.com/cosmos/cosmos-sdk/types/tx" auth "github.com/cosmos/cosmos-sdk/x/auth/types" banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + "github.com/cosmos/cosmos-sdk/x/feegrant" stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" logging "github.com/ipfs/go-log/v2" "github.com/tendermint/tendermint/crypto/merkle" @@ -40,7 +42,23 @@ var ( ErrInvalidAmount = errors.New("state: amount must be greater than zero") ) -const maxRetries = 5 +const ( + maxRetries = 5 + + // gasMultiplier is used to increase gas limit in case if tx has additional options. + gasMultiplier = 1.1 +) + +// Option is the functional option that is applied to the coreAccessor instance +// to configure parameters. +type Option func(ca *CoreAccessor) + +// WithGranter is a functional option to configure the granter address parameter. +func WithGranter(addr AccAddress) Option { + return func(ca *CoreAccessor) { + ca.granter = addr + } +} // CoreAccessor implements service over a gRPC connection // with a celestia-core node. @@ -51,8 +69,9 @@ type CoreAccessor struct { signer *apptypes.KeyringSigner getter libhead.Head[*header.ExtendedHeader] - stakingCli stakingtypes.QueryClient - rpcCli rpcclient.ABCIClient + stakingCli stakingtypes.QueryClient + feeGrantCli feegrant.QueryClient + rpcCli rpcclient.ABCIClient prt *merkle.ProofRuntime @@ -70,6 +89,11 @@ type CoreAccessor struct { // will find a proposer that does accept the transaction. Better would be // to set a global min gas price that correct processes conform to. minGasPrice float64 + + // granter stores the address of the external node that will pay for all transactions, submitted + // by the local node. + // empty granter means that the local node will pay for the transactions. + granter AccAddress } // NewCoreAccessor dials the given celestia-core endpoint and @@ -81,12 +105,14 @@ func NewCoreAccessor( coreIP, rpcPort string, grpcPort string, + options ...Option, ) *CoreAccessor { // create verifier prt := merkle.DefaultProofRuntime() prt.RegisterOpDecoder(storetypes.ProofOpIAVLCommitment, storetypes.CommitmentOpDecoder) prt.RegisterOpDecoder(storetypes.ProofOpSimpleMerkleCommitment, storetypes.CommitmentOpDecoder) - return &CoreAccessor{ + + ca := &CoreAccessor{ signer: signer, getter: getter, coreIP: coreIP, @@ -94,6 +120,12 @@ func NewCoreAccessor( grpcPort: grpcPort, prt: prt, } + + for _, opt := range options { + opt(ca) + } + return ca + } func (ca *CoreAccessor) Start(ctx context.Context) error { @@ -117,6 +149,7 @@ func (ca *CoreAccessor) Start(ctx context.Context) error { // create the staking query client stakingCli := stakingtypes.NewQueryClient(ca.coreConn) ca.stakingCli = stakingCli + ca.feeGrantCli = feegrant.NewQueryClient(ca.coreConn) // create ABCI query client cli, err := http.New(fmt.Sprintf("http://%s:%s", ca.coreIP, ca.rpcPort), "/websocket") if err != nil { @@ -214,6 +247,13 @@ func (ca *CoreAccessor) SubmitPayForBlob( minGasPrice := ca.getMinGasPrice() + var feeGrant apptypes.TxBuilderOption + + // set granter and update gasLimit in case node run in a grantee mode + if !ca.granter.Empty() { + feeGrant = apptypes.SetFeeGranter(ca.granter) + gasLim = uint64(float64(gasLim) * gasMultiplier) + } // set the fee for the user as the minimum gas price multiplied by the gas limit estimatedFee := false if fee.IsNegative() { @@ -223,14 +263,17 @@ func (ca *CoreAccessor) SubmitPayForBlob( var lastErr error for attempt := 0; attempt < maxRetries; attempt++ { + options := []apptypes.TxBuilderOption{apptypes.SetGasLimit(gasLim), withFee(fee)} + if feeGrant != nil { + options = append(options, feeGrant) + } response, err := appblob.SubmitPayForBlob( ctx, ca.signer, ca.coreConn, sdktx.BroadcastMode_BROADCAST_MODE_BLOCK, appblobs, - apptypes.SetGasLimit(gasLim), - withFee(fee), + options..., ) // the node is capable of changing the min gas price at any time so we must be able to detect it and @@ -256,6 +299,10 @@ func (ca *CoreAccessor) SubmitPayForBlob( if response != nil && response.Code != 0 { err = errors.Join(err, sdkErrors.ABCIError(response.Codespace, response.Code, response.Logs.String())) } + + if err != nil && errors.Is(err, sdkerrors.ErrNotFound) && !ca.granter.Empty() { + return response, errors.New("granter has revoked the grant") + } return response, err } return nil, fmt.Errorf("failed to submit blobs after %d attempts: %w", maxRetries, lastErr) @@ -522,6 +569,56 @@ func (ca *CoreAccessor) QueryRedelegations( }) } +func (ca *CoreAccessor) GrantFee( + ctx context.Context, + grantee AccAddress, + amount, + fee Int, + gasLim uint64, +) (*TxResponse, error) { + granter, err := ca.signer.GetSignerInfo().GetAddress() + if err != nil { + return nil, err + } + + allowance := &feegrant.BasicAllowance{} + if !amount.IsZero() { + // set spend limit + allowance.SpendLimit = sdktypes.NewCoins(sdktypes.NewCoin(app.BondDenom, amount)) + } + + msg, err := feegrant.NewMsgGrantAllowance(allowance, granter, grantee) + if err != nil { + return nil, nil + } + + signedTx, err := ca.constructSignedTx(ctx, msg, apptypes.SetGasLimit(gasLim), withFee(fee)) + if err != nil { + return nil, err + } + return ca.SubmitTx(ctx, signedTx) +} + +func (ca *CoreAccessor) RevokeGrantFee( + ctx context.Context, + grantee AccAddress, + fee Int, + gasLim uint64, +) (*TxResponse, error) { + granter, err := ca.signer.GetSignerInfo().GetAddress() + if err != nil { + return nil, err + } + + msg := feegrant.NewMsgRevokeAllowance(granter, grantee) + signedTx, err := ca.constructSignedTx(ctx, &msg, apptypes.SetGasLimit(gasLim), withFee(fee)) + if err != nil { + return nil, err + } + + return ca.SubmitTx(ca.ctx, signedTx) +} + func (ca *CoreAccessor) LastPayForBlob() int64 { ca.lock.Lock() defer ca.lock.Unlock()