Skip to content

Adding an action to DAO DAO

Noah Saso edited this page Jul 29, 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 one 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.

A map of action code

The code related to actions can be found in the actions package in packages/actions in the dao-ui repo. In that folder, there are a couple pieces:

  • actions/* holds all of the logic related to each action.
  • actions/index.tsx holds a list of common actions avaliable in all DAOs (i.e. DAOs with any voting modules or proposal modules).
  • components/* holds all of the React components related to each action and general components used by many actions.

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 the particular state package we're using. For example, currently we 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.

The voting module adapters (packages/voting-module-adapter) and proposal module adapters (packages/proposal-module-adapter) occasionally have actions that only apply to DAOs and proposals that use them. For example, the cw20-staked-balance-voting voting module 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 laid out/created in a very similar way to the actions package itself.

The constituent parts of an action's logic

An action's logic in DAO DAO consists of four parts.

  1. A pretty form component which collects and displays user input.
  2. A useDefaults React hook which takes the address of the core DAO contract the proposal is associated with and the chosen proposal module, uses hooks to fetch any necessary state, and subsequently returns an object containing default values for the action's user input fields which the component lets the user change. The returned object should conform to the data interface for this action.
  3. A useTransformToCosmos hook which takes the same parameters as useDefaults and returns a function. The function takes an object in the shape of the data interface (the same shape as the object returned by useDefaults) and converts it to a JSON object that the underlying blockchain can parse and execute (i.e. a Cosmos message).
  4. A useDecodedCosmosMsg hook which takes a decoded Cosmos message object, as well as the same parameters as the other hooks, and returns whether or not the message matches the action AND a data interface object with the values extracted from the message if it's a match. This is used when rendering a proposal that has been created, matching messages to actions, so that the associated message 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 the displayed action. 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.

Once all of these pieces are completed, as well as the action metadata (like label, description, etc.), they are included in an Action type object and added to the common action list.

Let's walk through an example.

An example

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/actions/actions/.

Before we actually get to writing action code, we'll also 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/state/clients/cw-core'

type UpdateInfoData = ConfigResponse

With that done, we can move down our list of "constituent parts of an action's logic" 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 just query for the state on-chain, and return it. We'll add that to our UpdateInfo.tsx file:

const useDefaults: UseDefaults<UpdateInfoData> = (coreAddress: string) => {
  const config = useRecoilValue(
    configSelector({ contractAddress: coreAddress })
  )

  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 into an update_config Cosmos message. This really isn't the most complex affair, especially since the form data is equivalent to the expected config type:

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

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 Cosmos 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 is deemed to be fishy, the caller is informed that there is no match.

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

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.

The component

The final, and potentially most fun step in this whole process, is to write the UI that the user will interact with. In the DAO DAO UI, 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.

We'll start the last leg of our journey by creating a new UpdateInfo.tsx file in packages/actions/components/ and exporting it in packages/actions/components/index.tsx (so we can neatly import it in our action logic file with all the hooks in it) like below:

export * from './UpdateInfo'

Action components take a handful of props:

  1. A coreAddress string. This is the address of the instantiated cw-core smart contract.
  2. A proposalModule object. This is the proposal module being used for this proposal.
  3. 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 use input is stored in state/handled 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 really need to do is use the provided common inputs from @dao-dao/ui and ensure to prefix the fieldName props with the fieldNamePrefix.
  4. An allActionsWithData object array. This contains a list of all action keys and data objects in the proposal. One example of its use is when there are multiple instantiate actions, we can use the position (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.
  5. An index number. The index of this action in the list of all actions.
  6. 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.
  7. A Loader component. This should be used when displaying a loader instead of using the Loader from the @dao-dao/ui package. Actions may appear in other contexts, such as SDAs, where different logos and loaders are used, and the general Loader component is the DAO DAO logo.
  8. A Logo component. This should be used when displaying a logo instead of using the Logo from the @dao-dao/ui package, for the same reason as the Loader above.
  9. A 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 = true, there are a couple more props present:

  1. An onRemove function. This is a function provided by the caller that should be called when this action is removed from the action list by the proposal creator. 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 the onRemove through.
  2. An errors object. This is just the nested object within the errors object that react-hook-form uses that corresponds to the action's form data. This functions similarly to fieldNamePrefix from above, except the prefixing is already done for you. For example, to find if there are any errors for the name field, we can just check the value of errors?.name.

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

The @dao-dao/ui package contains a number of input components designed to work quite well in situations like these. You can browse them by taking a look at the files in packages/ui/components/input/.

Lets walk through an extremely simple action component to get a feel for what this all 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:

export const UpdateInfoComponent: ActionComponent = ({
  fieldNamePrefix,
  errors,
  onRemove,
  const { register } = useFormContext()

  return (
    <ActionCard
      Icon={UpdateInfoIcon}
      onRemove={onRemove}
      title={t('title.updateInfo')}
    >
      <TextInput
        placeholder={t('info.daoNamePlaceholder')}
        register={register}
        validation={[validateRequired]}
      />
      <InputErrorMessage error={errors?.name} />
    </ActionCard>
  )
}

export const UpdateInfoIcon = () => {
  const { t } = useTranslation()
  return <Emoji label={t('emoji.info')} symbol="ℹ️" />
}

Here we have a single TextInput component. It is associated with the name field because it has the field name fieldNamePrefix + 'name', and if

Q: How are default values filled in here?

A: react-hook-form handles this. The caller will take the default values returned earlier and tell it to fill in fields. So long as you correctly use the fieldNamePrefix value before your field names, they will all magically be filled in.

Q: How does this actually get submitted?

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

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 action! It will all make sense, eventually...