Skip to main content

Distributed Counter

This example walks you through building a basic distributed counter app, covering the full end-to-end flow of building your Sui Move module and connecting it to your React Sui dApp. The app allows users to create counters that anyone can increment, but only the owner can reset.

The guide is split into two parts:

  1. Smart Contracts: The Move code that sets up the Counter structure and logic.
  2. Frontend: A UI that enables users to create, increment, and reset Counter objects.
Additional resources

What the guide teaches

  • Shared objects: The guide teaches you how to use shared objects, in this case to create globally accessible Counter objects.

What you need

Before getting started, make sure you have:

Smart contracts

In this part of the guide, you write the Move contracts that create, increment, and reset counters. The first step is to set up a Move package for storing your Move modules.

info

To follow along with this guide, set your new Move package to counter.

Counter struct

sdk/create-dapp/templates/react-e2e-counter/move/counter/sources/counter.move
public struct Counter has key {
id: UID,
owner: address,
value: u64
}
  • The Counter type stores the address of its owner, its current value, and its own id.

Creating Counter

sdk/create-dapp/templates/react-e2e-counter/move/counter/sources/counter.move
public fun create(ctx: &mut TxContext) {
transfer::share_object(Counter {
id: object::new(ctx),
owner: ctx.sender(),
value: 0
})
}

In the create function, a new Counter object is created and shared.

Incrementing and resetting Counter

sdk/create-dapp/templates/react-e2e-counter/move/counter/sources/counter.move
public fun increment(counter: &mut Counter) {
counter.value = counter.value + 1;
}

The increment function accepts a mutable reference to any shared Counter object and will increments its value field.

sdk/create-dapp/templates/react-e2e-counter/move/counter/sources/counter.move
public fun set_value(counter: &mut Counter, value: u64, ctx: &TxContext) {
assert!(counter.owner == ctx.sender(), 0);
counter.value = value;
}

The set_value function accepts a mutable reference to any shared Counter object, the value to set its value field, and the ctx which contains the sender of the transaction. This function can only be ran by the Counter's owner.

Additional resources

Learn more about taking object references as input

Finished package

The final module should look like this

sdk/create-dapp/templates/react-e2e-counter/move/counter/sources/counter.move
module counter::counter {
public struct Counter has key {
id: UID,
owner: address,
value: u64
}

public fun create(ctx: &mut TxContext) {
transfer::share_object(Counter {
id: object::new(ctx),
owner: ctx.sender(),
value: 0
})
}

public fun increment(counter: &mut Counter) {
counter.value = counter.value + 1;
}

public fun set_value(counter: &mut Counter, value: u64, ctx: &TxContext) {
assert!(counter.owner == ctx.sender(), 0);
counter.value = value;
}
}

Deployment

info

See Publish a Package for a more detailed guide on publishing packages or Sui Client CLI for a complete reference of client commands in the Sui CLI.

Before publishing your code, you must first initialize the Sui Client CLI, if you haven't already. To do so, in a terminal or console at the root directory of the project enter sui client. If you receive the following response, complete the remaining instructions:

Config file ["<FILE-PATH>/.sui/sui_config/client.yaml"] doesn't exist, do you want to connect to a Sui Full node server [y/N]?

Enter y to proceed. You receive the following response:

Sui Full node server URL (Defaults to Sui Devnet if not specified) :

Leave this blank (press Enter). You receive the following response:

Select key scheme to generate keypair (0 for ed25519, 1 for secp256k1, 2: for secp256r1):

Select 0. Now you should have a Sui address set up.

Next, configure the Sui CLI to use testnet as the active environment, as well. If you haven't already set up a testnet environment, do so by running the following command in a terminal or console:

sui client new-env --alias testnet --rpc https://fullnode.testnet.sui.io:443

Run the following command to activate the testnet environment:

sui client switch --env testnet

Before being able to publish your package to Testnet, you need Testnet SUI tokens. To get some, join the Sui Discord, complete the verification steps, enter the #testnet-faucet channel and type !faucet <WALLET ADDRESS>. For other ways to get SUI in your Testnet account, see Get SUI Tokens.

Now that you have an account with some Testnet SUI, you can deploy your contracts. To publish your package, use the following command in the same terminal or console:

sui client publish --gas-budget <GAS-BUDGET>

For the gas budget, use a standard value such as 20000000.

The output of this command contains a packageID value that you need to save to use the package.

Partial snippet of CLI deployment output.

╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Object Changes │
├──────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Created Objects: │
│ ┌── │
│ │ ObjectID: 0x7530c33e4cf3345236601d69303e3fab84efc294194a810dc1cfea13c009e77f │
│ │ Sender: 0x8e8cae7791a93778800b88b6a274de5c32a86484593568d38619c7ea71999654 │
│ │ Owner: Account Address ( 0x8e8cae7791a93778800b88b6a274de5c32a86484593568d38619c7ea71999654 ) │
│ │ ObjectType: 0x2::package::UpgradeCap │
│ │ Version: 47482286 │
│ │ Digest: 5aEez7HkJ82Xs5ZArPHJF6Ty38UtprsCvEiyy22hBVRE │
│ └── │
│ Mutated Objects: │
│ ┌── │
│ │ ObjectID: 0x0fcc6d770d80aa409a9645e78ac4810be6400919ac7f507bddd2f9d279da509f │
│ │ Sender: 0x8e8cae7791a93778800b88b6a274de5c32a86484593568d38619c7ea71999654 │
│ │ Owner: Account Address ( 0x8e8cae7791a93778800b88b6a274de5c32a86484593568d38619c7ea71999654 ) │
│ │ ObjectType: 0x2::coin::Coin<0x2::sui::SUI> │
│ │ Version: 47482286 │
│ │ Digest: A6TH6ja5TM4S6nZBwB14AB17ZgixCijYX1aNMGHF3syv │
│ └── │
│ Published Objects: │
│ ┌── │
│ │ PackageID: 0x7b6a8f5782e57cd948dc75ee098b73046a79282183d51eefb83d31ec95c312aa │
│ │ Version: 1 │
│ │ Digest: FKAZc1cmQ9FUYudDQBjZPTb1uXDnekKRUbAALuVnwURC │
│ │ Modules: counter │
│ └── │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯

Store the PackageID value you receive in your own response to connect to your frontend.

Next steps

Well done. You have written and deployed the Move package! 🚀

To turn this into a complete dApp, you need to create a frontend.

Frontend

In this final part of the app example, you build a frontend (UI) that allows end users to create, increment, and reset Counter objects.

info

To skip building the frontend and test out your newly deployed package, create this example using the following template and follow the instructions in the template's README.md file:

pnpm create @mysten/dapp --template react-e2e-counter

or

yarn create @mysten/dapp --template react-e2e-counter

Prerequisites

Before getting started, make sure you have:

Additional resources
  • Tooling: Sui Typescript SDK for basic usage on how to interact with Sui using Typescript.
  • Tooling: Sui dApp Kit to learn basic building blocks for developing a dApp in the Sui ecosystem with React.js.
  • Tooling: @mysten/dapp, used within this project to quickly scaffold a React-based Sui dApp.

Overview

The UI design consists of two parts:

  • A button for users to create new Counter objects
  • A Counter UI for users to view the value, and to increment and reset the Counter object.

Scaffold a new app

The first step is to set up the client app. Run the following command to scaffold a new app.

pnpm create @mysten/dapp --template react-client-dapp

or

yarn create @mysten/dapp --template react-client-dapp

Connecting your deployed package

Add the packageId value you saved from deploying your package to a new src/constants.ts file in your project:

  export const DEVNET_COUNTER_PACKAGE_ID = "0xTODO";
export const TESTNET_COUNTER_PACKAGE_ID = "0x7b6a8f5782e57cd948dc75ee098b73046a79282183d51eefb83d31ec95c312aa";
export const MAINNET_COUNTER_PACKAGE_ID = "0xTODO";

Update the src/networkConfig.ts file to include the packageID constants.

sdk/create-dapp/templates/react-e2e-counter/src/networkConfig.ts
import { getFullnodeUrl } from "@mysten/sui/client";
import {
DEVNET_COUNTER_PACKAGE_ID,
TESTNET_COUNTER_PACKAGE_ID,
MAINNET_COUNTER_PACKAGE_ID,
} from "./constants.ts";
import { createNetworkConfig } from "@mysten/dapp-kit";

const { networkConfig, useNetworkVariable, useNetworkVariables } =
createNetworkConfig({
devnet: {
url: getFullnodeUrl("devnet"),
variables: {
counterPackageId: DEVNET_COUNTER_PACKAGE_ID,
},
},
testnet: {
url: getFullnodeUrl("testnet"),
variables: {
counterPackageId: TESTNET_COUNTER_PACKAGE_ID,
},
},
mainnet: {
url: getFullnodeUrl("mainnet"),
variables: {
counterPackageId: MAINNET_COUNTER_PACKAGE_ID,
},
},
});

export { useNetworkVariable, useNetworkVariables, networkConfig };

Creating Counter

You need a way to create a new Counter object. Do this by creating a new CreateCounter component in the new src/CreateCounter.tsx file:

import { Button, Container } from '@radix-ui/themes';

export function CreateCounter( { onCreated }: { onCreated: (id: string) => void; } ) {

function create() {
// TODO
}

return (
<Container>
<Button
size="3"
onClick={() => {
create();
}}
>
Create Counter
</Button>
</Container>
);
}

This component renders a button that enables the user to create a counter. Now, update your create function so that it calls the create function in your Move module.

To do this, you need to construct a Transaction, with the appropriate moveCall transaction, and then sign and execute the programmable transaction block (PTB).

First, import Transaction from @mysten/sui, COUNTER_PACKAGE_ID from your constants.ts file created previously, and useSignAndExecuteTransaction from @mysten/dapp-kit.

import { useSignAndExecuteTransaction, useSuiClient } from '@mysten/dapp-kit';
import { Transaction } from '@mysten/sui/transactions';

import { useNetworkVariable } from "./networkConfig";

Next, call the useSignAndExecuteTransaction hook in your component, which provides a mutate function you can use in your create function:

export function CreateCounter( { onCreated }: { onCreated: (id: string) => void; } ) {
const counterPackageId = useNetworkVariable("counterPackageId");
const suiClient = useSuiClient();
const { mutate: signAndExecute } = useSignAndExecuteTransaction({
execute: async ({ bytes, signature }) =>
await suiClient.executeTransactionBlock({
transactionBlock: bytes,
signature,
options: {
// Raw effects are required so the effects can be reported back to the wallet
showRawEffects: true,
showEffects: true,
},
}),
});

function create() {
// TODO
}

return (
<Container>
<Button
size="3"
onClick={() => {
create();
}}
>
Create Counter
</Button>
</Container>
);

}

Finally, construct your Transaction:

sdk/create-dapp/templates/react-e2e-counter/src/CreateCounter.tsx
import { Transaction } from "@mysten/sui/transactions";
import { Button, Container } from "@radix-ui/themes";
import { useSignAndExecuteTransaction, useSuiClient } from "@mysten/dapp-kit";
import { useNetworkVariable } from "./networkConfig";

export function CreateCounter({
onCreated,
}: {
onCreated: (id: string) => void;
}) {
const counterPackageId = useNetworkVariable("counterPackageId");
const suiClient = useSuiClient();
const { mutate: signAndExecute } = useSignAndExecuteTransaction({
execute: async ({ bytes, signature }) =>
await suiClient.executeTransactionBlock({
transactionBlock: bytes,
signature,
options: {
showRawEffects: true,
showEffects: true,
},
}),
});

return (
<Container>
<Button
size="3"
onClick={() => {
create();
}}
>
Create Counter
</Button>
</Container>
);

function create() {
const tx = new Transaction();

tx.moveCall({
arguments: [],
target: `${counterPackageId}::counter::create`,
});

signAndExecute(
{
transaction: tx,
},
{
onSuccess: (result) => {
const objectId = result.effects?.created?.[0]?.reference?.objectId;
if (objectId) {
onCreated(objectId);
}
},
},
);
}
}

You now have a functional component that can create a new Counter object, and because the showRawEffects and showEffects options are toggled on, the transaction results ensure finality. The SuiClient is used to set this up.

Set up routing

Now that your users can create counters, you need a way to route to them. Routing in a React app can be complex, but this example keeps it basic. Set up your src/App.tsx file so that you render the CreateCounter component by default, and if you want to display a specific counter you can put its ID into the hash portion of the URL.

sdk/create-dapp/templates/react-e2e-counter/src/App.tsx
import { ConnectButton, useCurrentAccount } from "@mysten/dapp-kit";
import { isValidSuiObjectId } from "@mysten/sui/utils";
import { Box, Container, Flex, Heading } from "@radix-ui/themes";
import { useState } from "react";
import { Counter } from "./Counter";
import { CreateCounter } from "./CreateCounter";

function App() {
const currentAccount = useCurrentAccount();
const [counterId, setCounter] = useState(() => {
const hash = window.location.hash.slice(1);
return isValidSuiObjectId(hash) ? hash : null;
});

return (
<>
<Flex
position="sticky"
px="4"
py="2"
justify="between"
style={{
borderBottom: "1px solid var(--gray-a2)",
}}
>
<Box>
<Heading>dApp Starter Template</Heading>
</Box>

<Box>
<ConnectButton />
</Box>
</Flex>
<Container>
<Container
mt="5"
pt="2"
px="4"
style={{ background: "var(--gray-a2)", minHeight: 500 }}
>
{currentAccount ? (
counterId ? (
<Counter id={counterId} />
) : (
<CreateCounter
onCreated={(id) => {
window.location.hash = id;
setCounter(id);
}}
/>
)
) : (
<Heading>Please connect your wallet</Heading>
)}
</Container>
</Container>
</>
);
}

export default App;

This sets up your app to read the hash from the URL, and get the counter's ID if the hash is a valid object ID. Then, it either renders a Counter (which you define in the next step) if you have a counter ID, or the CreateCounter button from the previous step. When a counter is created, you update the URL, and set the counter ID.

Building your counter user interface

Create a new file: src/Counter.tsx.

For your counter, you want to display three elements:

  • The current count, which you fetch from the object using the getObject RPC method.
  • An increment button, which calls the increment Move function.
  • A reset button, which calls the set_value Move function with 0. This is only shown if the current user owns the counter.
import {
useCurrentAccount,
useSuiClientQuery,
} from "@mysten/dapp-kit";
import { Button, Flex, Heading, Text } from "@radix-ui/themes";


export function Counter({ id }: { id: string }) {
const currentAccount = useCurrentAccount();
const { data, isPending, error, refetch } = useSuiClientQuery("getObject", {
id,
options: {
showContent: true,
showOwner: true,
},
});

const executeMoveCall = (method: "increment" | "reset") => {
// TODO
};

if (isPending) return <Text>Loading...</Text>;

if (error) return <Text>Error: {error.message}</Text>;

if (!data.data) return <Text>Not found</Text>;

const ownedByCurrentAccount =
getCounterFields(data.data)?.owner === currentAccount?.address;

return (
<>
<Heading size="3">Counter {id}</Heading>

<Flex direction="column" gap="2">
<Text>Count: {getCounterFields(data.data)?.value}</Text>
<Flex direction="row" gap="2">
<Button onClick={() => executeMoveCall("increment")}>
Increment
</Button>
{ownedByCurrentAccount ? (
<Button onClick={() => executeMoveCall("reset")}>Reset</Button>
) : null}
</Flex>
</Flex>
</>
);
}
function getCounterFields(data: SuiObjectData) {
if (data.content?.dataType !== "moveObject") {
return null;
}

return data.content.fields as { value: number; owner: string };
}

This snippet has a few new concepts to examine. It uses the useSuiClientQuery hook to make the getObject RPC call. This returns a data object representing your counter. dApp Kit doesn't know which fields your counter object has, so define a getCounterFields helper that gets the counter fields, and adds a type-cast so that you can access the expected value and owner fields in your component.

The code also adds an executeMoveCall function that still needs implementing. This works just like the create function you used to create the counter. Instead of using a callback prop like you did for CreateCounter, you can use the refetch provided by useSuiClientQuery to reload your Counter object after you've executed your PTB.

sdk/create-dapp/templates/react-e2e-counter/src/Counter.tsx
import {
useCurrentAccount,
useSignAndExecuteTransaction,
useSuiClient,
useSuiClientQuery,
} from "@mysten/dapp-kit";
import type { SuiObjectData } from "@mysten/sui/client";
import { Transaction } from "@mysten/sui/transactions";
import { Button, Flex, Heading, Text } from "@radix-ui/themes";
import { useNetworkVariable } from "./networkConfig";

export function Counter({ id }: { id: string }) {
const counterPackageId = useNetworkVariable("counterPackageId");
const suiClient = useSuiClient();
const currentAccount = useCurrentAccount();
const { mutate: signAndExecute } = useSignAndExecuteTransaction({
execute: async ({ bytes, signature }) =>
await suiClient.executeTransactionBlock({
transactionBlock: bytes,
signature,
options: {
showRawEffects: true,
showEffects: true,
},
}),
});
const { data, isPending, error, refetch } = useSuiClientQuery("getObject", {
id,
options: {
showContent: true,
showOwner: true,
},
});

const executeMoveCall = (method: "increment" | "reset") => {
const tx = new Transaction();

if (method === "reset") {
tx.moveCall({
arguments: [tx.object(id), tx.pure.u64(0)],
target: `${counterPackageId}::counter::set_value`,
});
} else {
tx.moveCall({
arguments: [tx.object(id)],
target: `${counterPackageId}::counter::increment`,
});
}

signAndExecute(
{
transaction: tx,
},
{
onSuccess: async () => {
await refetch();
},
},
);
};

if (isPending) return <Text>Loading...</Text>;

if (error) return <Text>Error: {error.message}</Text>;

if (!data.data) return <Text>Not found</Text>;

const ownedByCurrentAccount =
getCounterFields(data.data)?.owner === currentAccount?.address;

return (
<>
<Heading size="3">Counter {id}</Heading>

<Flex direction="column" gap="2">
<Text>Count: {getCounterFields(data.data)?.value}</Text>
<Flex direction="row" gap="2">
<Button onClick={() => executeMoveCall("increment")}>
Increment
</Button>
{ownedByCurrentAccount ? (
<Button onClick={() => executeMoveCall("reset")}>Reset</Button>
) : null}
</Flex>
</Flex>
</>
);
}
function getCounterFields(data: SuiObjectData) {
if (data.content?.dataType !== "moveObject") {
return null;
}

return data.content.fields as { value: number; owner: string };
}

Your counter app is now ready to count. To learn more about dApp Kit, check out the dApp Kit docs.