-
Notifications
You must be signed in to change notification settings - Fork 21
Adding an action to DAO DAO
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:
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.
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.
An action's logic in DAO DAO consists of four parts.
- A pretty form component which collects and displays user input.
- 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. - A
useTransformToCosmos
hook which takes the same parameters asuseDefaults
and returns a function. The function takes an object in the shape of the data interface (the same shape as the object returned byuseDefaults
) and converts it to a JSON object that the underlying blockchain can parse and execute (i.e. a Cosmos message). - 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 theExecute Smart Contract
action, and all actions are subsets of theCustom
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.
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.
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
}
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.
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 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:
- A
coreAddress
string. This is the address of the instantiatedcw-core
smart contract. - A
proposalModule
object. This is the proposal module being used for this proposal. - A
fieldNamePrefix
string. Since actions are all included in onereact-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 thefieldName
props with thefieldNamePrefix
. - 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. - An
index
number. The index of this action in the list of all actions. - A
data
object. This simply provides direct access to the data object for this action. This is essentially a shortcut for usingreact-hook-form
'swatch
andgetValue
functions to access field values. - A
Loader
component. This should be used when displaying a loader instead of using theLoader
from the@dao-dao/ui
package. Actions may appear in other contexts, such as SDAs, where different logos and loaders are used, and the generalLoader
component is the DAO DAO logo. - A
Logo
component. This should be used when displaying a logo instead of using theLogo
from the@dao-dao/ui
package, for the same reason as theLoader
above. - 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:
- 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 ofx
button on your action. TheActionCard
handles this for you as long as you pass theonRemove
through. - An
errors
object. This is just the nested object within the errors object thatreact-hook-form
uses that corresponds to the action's form data. This functions similarly tofieldNamePrefix
from above, except the prefixing is already done for you. For example, to find if there are any errors for thename
field, we can just check the value oferrors?.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.
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...