Skip to content

Adding an action to DAO DAO

noah edited this page Dec 15, 2022 · 6 revisions

DAO DAO actions allow users to interact with the chain using a nice UI instead of typing complex JSON incantations. For example, the JSON for sending 1 $JUNO from a DAO's treasury looks like this:

{
  bank: {
    send: {
      amount: [{ denom: "ujuno", amount: "1000000" }],
      to_address: "receiver"
    }
  }
}

Making people type this out every time they'd like to spend some tokens is a pain, so we made a spend action. The spend action is a little form wherein proposal creators can select a token denomination and an amount, and the corresponding JSON will be generated for them. It looks something like this:

image

This is nice. What is also nice is that writing your own custom actions isn't too hard. Here we'll walk through the process of doing that.

Explaining the layout of action code

The code related to actions can be found in packages/stateful/actions. In that folder, there are a couple pieces:

  • actions/ holds all of the logic related to each action.
  • actions/index.tsx is responsible for registering the available actions.
  • components/ holds all of the stateless React components related to each action as well as general components used by many actions.
  • react/ holds the React Context provider and hooks used to access actions from React apps. This does not matter for building your own actions and can be ignored.

Action types are stored separately in packages/types/actions.ts.

We split the business logic and visual logic into two parts because we want to minimize the amount of dependency the visual logic has on state. We currently fetch all of our state directly from a RPC node, but down the line we may want to switch over to using an indexer. If we did that swap, we wouldn't need to change any UI code as the components are stateless and do not know where state is coming from. Additionally, this allows us to create stateless Storybook stories and iterate on the visual design quickly (see the Manage SubDaos story for an example).

Adapters

The voting module adapters (packages/stateful/voting-module-adapter) and proposal module adapters (packages/stateful/proposal-module-adapter) occasionally have actions that only apply to DAOs and proposals that use them. For example, the CwdVotingCw20Staked voting module adapter takes advantage of CW20 governance tokens, and as such would want to provide an action that lets the DAO mint more governance tokens. Both adapter systems contain useActions hooks that let you add actions just for those contexts, and are organized and defined in a very similar way to the actions package itself. See the CwdVotingCw20Staked voting module adapter's useActions hook and its mint action definition for an example.

Something to keep in mind, which is explained further below, is that actions are defined as maker functions that take an options parameter so they can dynamically respond to the context they are being used in. These options are provided through the actions provider and can be accessed on all DAO pages through the useActionOptions hook. Since the context for all actions is the same, these options can be accessed in the adapter code via that hook, as you can see is being done for the mint action in CwdVotingCw20Staked voting module adapter's useActions hook mentioned above.

Action definition

The core action logic can be found in the actions/ folder of packages/stateful/actions, and the Action type definition can be found here. Below is each field of the Action type definition, explained:

  1. A unique key differentiates it from other actions. For a core action defined in the stateful package, a new enum variant would be added to the CoreActionKey enum in the type definition file. For an action defined inside an adapter, such as the mint action referenced previously, the enum variant would be added to the AdapterActionKey enum instead to differentiate it from the actions provided by the React Context provider available on all DAO pages. Adapter actions are only available in the context of the adapter, and are thus manually inserted at a later point. See how actions are pieced together from various sources in the dApp here.
  2. An Icon, label, and description display in the action list.
  3. A pretty, stateless form Component collects and displays user input.
  4. A useDefaults hook fetches any necessary state, and subsequently returns an object containing default values for the action's user input fields, which the stateless component lets the user change. The returned object should conform to the data interface for this action.
  5. A useTransformToCosmos hook returns a function that takes an object in the shape of the data interface (the same shape as the object returned by useDefaults) and converts it into a JSON object that the underlying blockchain can parse and execute (i.e. a CosmWasm message).
  6. A useDecodedCosmosMsg hook takes a decoded CosmWasm message object and returns (1) whether or not the message matches the action and, if the message is a match, (2) an object conforming to the same data interface referenced above with the values extracted from the message. This is used when rendering a proposal that has been created, matching CosmWasm messages to actions, so that the associated data can be rendered using the pretty action Components. This hook is called for all actions on every message, and the first match returned is displayed. Many actions are subsets of the Execute Smart Contract action, and all actions are subsets of the Custom action, so those two are always checked last, in that order.

Actions are defined as maker functions which take some options to provide context. This type can also be found in the action types definition file towards the bottom. If an action maker returns null, it will not be included. This may happen if the action does not fit into the context provided by the options parameter. For example, the Manage SubDaos action is only relevant for v2 DAOS, and as such its maker function returns null in the context of a wallet or a v1 DAO, seen here.

Component definition

Components define the UI form the user interacts with to create and view actions. We use react-hook-form for all of our forms. If at any point you're confused by the form code, that's likely the place to look.

Action components take a handful of props. The props interface is defined in the action types definition file referenced previously, and its properties are explained below:

  1. A fieldNamePrefix string. Since actions are all included in one react-hook-form form context, we need to perform some magic to properly register fields with the form so that user input is stored in state correctly. This string provides the prefix for field names used in the form and should be prepended to all inputs registered with the names of fields in your action data (like "name"). This is confusing and tedious, so poke around at existing actions and the relationship between their data interfaces and components to see how this functions. In essence, all you need to do is ensure to prefix the fieldName props with fieldNamePrefix. This looks something like fieldName={fieldNamePrefix + 'name'} in most of the stateless inputs that have been built for you already (mentioned below).
  2. An allActionsWithData object array. This contains a list of all action keys and data objects in the proposal. You likely won't need to use this. It was added so that smart contract instantiate actions could extract their instantiated contract address from the transaction log and display them, after a proposal is executed. When there are multiple instantiate actions, we use the position (i.e. index) of each instantiate action in relation to the other instantiate actions to match the action to the instantiated contract address from the transaction log. Without access to the other action data and types, we would not be able to understand the effects of each action within the larger proposal context and its resulting transaction log.
  3. An index number. The index of this action in the list of all actions.
  4. A data object. This simply provides direct access to the data object for this action. This is essentially a shortcut for using react-hook-form's watch and getValue functions to access field values.
  5. An isCreating boolean. If this is true, the action is being displayed in the context of creating a new proposal and should be editable. If false, the action is being displayed as part of an existing proposal and should be read only.

When isCreating is true, there are a couple more props present:

  1. An onRemove function which removes this action. Typically, this will be associated with some sort of x button on your action. The ActionCard handles this for you as long as you pass onRemove through.
  2. An errors object, which may be undefined. This is the nested object within the errors object of react-hook-form's formState that corresponds to the action's form field errors. For example, to find if there are any errors for the name field, we can just check the value of errors?.name.
  3. An addAction function which lets the action add another action to the current proposal.

All actions are wrapped by react-hook-form's lovely FormProvider component, and as such your action may use the useFormContext hook to get a variety of helpful methods for dealing with your form.

The @dao-dao/stateless package contains a number of input components designed to work with these forms. You can browse them by taking a look at the files in packages/stateless/components/inputs. Likely everything you need can be found in here.

Now that we've covered all the pieces, let's walk through an example building an action together.

Example walkthrough

Here we'll be writing an action that handles the business of updating the name, description, and image for a DAO. We'll call the action "Update Info".

Our first step is to create a new UpdateInfo.tsx file in packages/stateful/actions/actions/.

Before we actually get to writing the action logic, we'll want to decide what fields we'd like our form to have. In this case, as we're simply updating the config of the DAO, we can just give it the same shape as the actual DAO config. We'll make that clear by creating a new type at the top of UpdateInfo.tsx:

import { ConfigResponse } from "@dao-dao/types/contracts/CwdCore.v2";

type UpdateInfoData = ConfigResponse;

With that done, we can move down our list of properties in the Action type definition and make the required hooks.

useDefaults

For this particular action, we'd like the default values to be the values already being used by the DAO. That way, proposal creators don't need to bother with copying over all the stuff they aren't interested in changing.

Because our data has the same shape as the data on-chain, our useDefaults hook will simply query for the state on-chain, and return it. Since the maker function receives an options parameter with context information, we can access the DAO's address via options.address.

import { useRecoilValue } from "recoil";

import { configSelector } from "@dao-dao/state/recoil/selectors/contracts/CwdCore.v2";
import { UseDefaults } from "@dao-dao/types/actions";

const useDefaults: UseDefaults<UpdateInfoData> = () => {
  const config = useRecoilValue(
    configSelector({ contractAddress: options.address })
  );

  if (!config) {
    throw new Error("Failed to load config from chain.");
  }

  return config;
};

useTransformToCosmos

Having already defined the shape of our data, we can easily write the useTransformToCosmos hook. What we'll want to do is convert our data object (typed UpdateInfoData above) into an update_config smart contract execute message. Again, we can access the DAO's address via options.address from the provided options parameter in the maker function. We also take advantage of the makeWasmMessage function from the utils package which serializes the JSON into the format the chain expects, which in this case is stringifying the msg object and converting it into a base64 string. This is abstracted away so you don't have to worry about how it works.

import { useCallback } from "react";

import { UseTransformToCosmos } from "@dao-dao/types/actions";
import { makeWasmMessage } from "@dao-dao/utils";

const useTransformToCosmos: UseTransformToCosmos<UpdateInfoData> = () =>
  useCallback(
    (data: UpdateInfoData) =>
      makeWasmMessage({
        wasm: {
          execute: {
            contract_addr: options.address,
            funds: [],
            msg: {
              update_config: {
                config: data,
              },
            },
          },
        },
      }),
    []
  );

If you're not familiar with how on-chain messages work, this may appear a little arcane. Covering how all that works is out of scope for this tutorial, but Callum's excellent CosmWasm Zero To Hero guide would be a great place to look if you'd like to learn more.

useDecodedCosmosMsg

This hook is tasked with recognizing a CosmWasm message as being a certain type of action. Every one of these will have a similar shape and involve a relatively laborious process wherein the code checks that all the fields it expects to be present in the message are present. This is essentially the inverse operation of useTransformToCosmos. If they are all present, it informs the caller that it has found a match, and constructs a data object with values extracted from the message to be used and displayed in the form. If a field is missing or something doesn't look right, the caller is informed that there is no match.

Here is how the "Update Info" action would be matched and its data extracted:

import { useMemo } from "react";
import { UseDecodedCosmosMsg } from "@dao-dao/types/actions";

const useDecodedCosmosMsg: UseDecodedCosmosMsg<UpdateInfoData> = (
  msg: Record<string, any>
) =>
  useMemo(
    () =>
      "wasm" in msg &&
      "execute" in msg.wasm &&
      "update_config" in msg.wasm.execute.msg &&
      "config" in msg.wasm.execute.msg.update_config &&
      "name" in msg.wasm.execute.msg.update_config.config &&
      "description" in msg.wasm.execute.msg.update_config.config &&
      "automatically_add_cw20s" in msg.wasm.execute.msg.update_config.config &&
      "automatically_add_cw721s" in msg.wasm.execute.msg.update_config.config
        ? {
            match: true,
            data: {
              name: msg.wasm.execute.msg.update_config.config.name,
              description:
                msg.wasm.execute.msg.update_config.config.description,

              // Only add image url if it is in the message.
              ...(!!msg.wasm.execute.msg.update_config.config.image_url && {
                image_url: msg.wasm.execute.msg.update_config.config.image_url,
              }),

              automatically_add_cw20s:
                msg.wasm.execute.msg.update_config.config
                  .automatically_add_cw20s,
              automatically_add_cw721s:
                msg.wasm.execute.msg.update_config.config
                  .automatically_add_cw721s,
            },
          }
        : { match: false },
    [msg]
  );

It's a bit of a chore.

Component

Now we need to create the UI that the user will interact with. We'll start by creating a new UpdateInfo.tsx file in packages/stateful/actions/components/ and exporting it in packages/stateful/actions/components/index.tsx (so we can neatly import it in our action logic file with all the hooks inside) like below:

export * from "./UpdateInfo";

Lets walk through an extremely simple component to get a feel for what this looks like. In this example, say that we only want to allow changing the name field on the DAO config. Our action would then look something like this:

import { useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";

import { InfoEmoji, InputErrorMessage, TextInput } from "@dao-dao/stateless";
import { ActionComponent } from "@dao-dao/types/actions";
import { validateRequired } from "@dao-dao/utils";

import { ActionCard } from "./ActionCard";

export const UpdateInfoComponent: ActionComponent = ({
  fieldNamePrefix,
  errors,
  onRemove,
  isCreating,
}) => {
  const { t } = useTranslation();
  const { register } = useFormContext();

  return (
    <ActionCard
      Icon={InfoEmoji}
      onRemove={onRemove}
      title={t("title.updateInfo")}
    >
      <TextInput
        disabled={!isCreating}
        error={errors?.name}
        fieldName={fieldNamePrefix + "name"}
        placeholder={t("form.name")}
        register={register}
        validation={[validateRequired]}
      />
      <InputErrorMessage error={errors?.name} />
    </ActionCard>
  );
};

Here we have a single TextInput component. It is associated with the name field because it has the field name fieldNamePrefix + "name", and will be disabled (i.e. read only) if the action is not being created. The action title and input placeholder are retrieved from the internationalization system we use to support different languages in the UI.

We also added a new emoji component (InfoEmoji) to packages/stateless/components/emoji.tsx to use as the icon for the component. The icon can be any component, but let's use emojis to be consistent.

Q: How do I access contextual information, such as address and chainId?

A: You can access the ActionOptions via the useActionOptions hook defined in packages/stateful/actions/react/context.ts. This provides you with the current action context, such as if we're in a DAO proposal or a wallet, the address of the DAO or wallet, the chainId we're currently using, the bech32Prefix of the chainId, and the coreVersion of the DAO if in a DAO context. Check out the full type in packages/types/actions.ts.

Q: How are default values filled in here?

A: react-hook-form handles this. The caller will take the default values returned earlier and fill in the appropriate fields in the state so they get loaded in the form. As long as you correctly use the fieldNamePrefix value to prefix your field names, they will all magically sync.

Q: How does this actually get submitted?

A: More magic. This form you're writing here is actually part of a much larger form. When that much larger form gets submitted, some code runs which calls useTransformToCosmos on all the form data and outputs CosmWasm messages.

Metadata

The last thing we need to do before putting it all together is to add some metadata about our action. This consists of a couple things:

  1. Add two new translation keys to the en/translation.json locale file. To do this, we add an "updateInfo" key to the top-level "title" object and an "updateInfoActionDescription" key to the top-level "info" object:

     {
       "title": {
         "updateInfo": "Update Info"
       },
       "info": {
         "updateInfoActionDescription": "Update the DAO's name, description, and image."
       }
     }

    These keys will be surrounded by other keys in the file, but I've omitted them for brevity.

  2. Pick an icon to represent the action. We'll use the InfoEmoji component we created earlier for the stateless component.

  3. Add a new variant to the CoreActionKey enum in packages/stateful/actions/types.ts to identify this action among the rest. We add it to CoreActionKey and not AdapterActionKey since we're creating a core action and not an adapter action:

    export enum CoreActionKey {
      ...
      UpdateInfo = "updateInfo",
    }

Writing the complete action maker

Now that we have created all our hooks and component for our action, we can finally write the action maker.

Since our action is only relevant in the context of a DAO, we need to return null if the context is anything other than a DAO. We're also going to use the translation function, like in our component definition above, to get the title and description text of our action.

Putting it all together, we get the following:

import { useCallback, useMemo } from "react";
import { useRecoilValue } from "recoil";

import { configSelector } from "@dao-dao/state/recoil/selectors/contracts/CwdCore.v2";
import { InfoEmoji } from "@dao-dao/stateless";
import {
  ActionMaker,
  ActionOptionsContextType,
  CoreActionKey,
  UseDecodedCosmosMsg,
  UseDefaults,
  UseTransformToCosmos,
} from "@dao-dao/types/actions";
import { ConfigResponse } from "@dao-dao/types/contracts/CwdCore.v2";
import { makeWasmMessage } from "@dao-dao/utils";

import { UpdateInfoComponent } from "../components";

type UpdateInfoData = ConfigResponse;

export const makeUpdateInfoAction: ActionMaker<UpdateInfoData> = (options) => {
  // Only relevant in the context of a DAO. Return null if not a DAO context.
  if (options.context.type !== ActionOptionsContextType.Dao) {
    return null;
  }

  const useDefaults: UseDefaults<UpdateInfoData> = () => {
    const config = useRecoilValue(
      configSelector({ contractAddress: options.address })
    );

    if (!config) {
      throw new Error("Failed to load config from chain.");
    }

    return config;
  };

  const useTransformToCosmos: UseTransformToCosmos<UpdateInfoData> = () =>
    useCallback(
      (data: UpdateInfoData) =>
        makeWasmMessage({
          wasm: {
            execute: {
              contract_addr: options.address,
              funds: [],
              msg: {
                update_config: {
                  config: data,
                },
              },
            },
          },
        }),
      []
    );

  const useDecodedCosmosMsg: UseDecodedCosmosMsg<UpdateInfoData> = (
    msg: Record<string, any>
  ) =>
    useMemo(
      () =>
        "wasm" in msg &&
        "execute" in msg.wasm &&
        "update_config" in msg.wasm.execute.msg &&
        "config" in msg.wasm.execute.msg.update_config &&
        "name" in msg.wasm.execute.msg.update_config.config &&
        "description" in msg.wasm.execute.msg.update_config.config &&
        "automatically_add_cw20s" in
          msg.wasm.execute.msg.update_config.config &&
        "automatically_add_cw721s" in msg.wasm.execute.msg.update_config.config
          ? {
              match: true,
              data: {
                name: msg.wasm.execute.msg.update_config.config.name,
                description:
                  msg.wasm.execute.msg.update_config.config.description,

                // Only add image url if it is in the message.
                ...(!!msg.wasm.execute.msg.update_config.config.image_url && {
                  image_url:
                    msg.wasm.execute.msg.update_config.config.image_url,
                }),

                automatically_add_cw20s:
                  msg.wasm.execute.msg.update_config.config
                    .automatically_add_cw20s,
                automatically_add_cw721s:
                  msg.wasm.execute.msg.update_config.config
                    .automatically_add_cw721s,
              },
            }
          : { match: false },
      [msg]
    );

  return {
    key: CoreActionKey.UpdateInfo,
    Icon: InfoEmoji,
    label: options.t("title.updateInfo"),
    description: options.t("info.updateInfoActionDescription"),
    Component: UpdateInfoComponent,
    useDefaults,
    useTransformToCosmos,
    useDecodedCosmosMsg,
  };
};

Registering the action

Now that we've written our action maker, we need to register it by adding it to packages/stateful/actions/actions/index.tsx. Simply import the action maker and add it to the array:

import { makeUpdateInfoAction } from "./UpdateInfo";

export const getActions = (options: ActionOptions): Action[] => {
  const actionMakers = [
    ...
    // Add our action maker here.
    makeUpdateInfoAction,
  ]

  ...
}

Congratulations! You now know every step required for creating and registering an action in the DAO DAO UI. When you are ready to add your action to the UI, submit a pull request to this repository.

Check out the real Update Info action component and action logic/maker definition. This action is a bit more complex than the one we just created because it supports updating config for both V1 and V2 DAOs, but it looks mostly the same.

Closing thoughts

If you've made it this far, you may be terribly confused. This isn't your fault and is likely mostly to do with the fact that writing tutorials is quite hard, and actions have iterated to reach the complexity that they now embody. The best way to figure all this out is to read existing actions, and then go write your own! It will all make sense, eventually...

If you have any questions, drop a message in the #frontend channel on our Discord. We are always looking to help out and improve our docs.