Distributed Counter
This example walks you through building a basic distributed counter app, covering the full end-to-end flow connecting your Move code to your client app. The app allows you to create counters that anyone can increment, but only the owner can reset. This example assumes you already have a React App set with dApp Kit, and it's required Providers
as described in Client App with Sui TypeScript SDK.
You must use the pnpm or yarn package managers to create Sui project scaffolds. Follow the pnpm install or yarn install instructions, if needed.
If haven't followed Client App with Sui TypeScript SDK, run the following command in a terminal or console to scaffold a new app:
pnpm create @mysten/dapp --template react-client-dapp
or
yarn create @mysten/dapp --template react-client-dapp
To get a head start, you can automatically create this example using the following template
value instead:
pnpm create @mysten/dapp --template react-e2e-counter
or
yarn create @mysten/dapp --template react-e2e-counter
Adding a Move module
The first element you need is a Move package to interact with. This example doesn't go in-depth on the Move code itself, but covers how to deploy it, and connect it to your dApp.
First, create a new move
directory at the root of your project to place your Move code and then make it the active directory:
mkdir move
cd move
Next, use the Sui Client CLI to generate a new Move package. If you have Sui installed, the Sui CLI is on your system. Run the following command in your terminal or console:
sui move new counter
This creates a new, empty Move package in a new move/counter
directory with a Move.toml file, and an empty sources
directory.
Add your Move code under sources
by creating a new counter.move
file:
module counter::counter {
use sui::transfer;
use sui::object::{Self, UID};
use sui::tx_context::{Self, TxContext};
/// A shared counter.
struct Counter has key {
id: UID,
owner: address,
value: u64
}
/// Create and share a Counter object.
public fun create(ctx: &mut TxContext) {
transfer::share_object(Counter {
id: object::new(ctx),
owner: tx_context::sender(ctx),
value: 0
})
}
/// Increment a counter by 1.
public fun increment(counter: &mut Counter) {
counter.value = counter.value + 1;
}
/// Set value (only runnable by the Counter owner)
public fun set_value(counter: &mut Counter, value: u64, ctx: &TxContext) {
assert!(counter.owner == tx_context::sender(ctx), 0);
counter.value = value;
}
}
Now that you have your Move code, you need to publish it. The Client App with Sui TypeScript SDK example and the app template use testnet
by default, so configure your code to match the network you want to deploy to.
First, update the Sui dependency in Move.toml by changing the rev
from framework/testnet
to framework/devnet
.
...
[dependencies]
Sui = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "framework/devnet" }
...
Next, configure the Sui CLI to use devnet
as the active environment, as well. If you haven't already set up a devnet
environment you can do so by running the following command in a terminal or console:
sui client new-env --alias devnet --rpc https://fullnode.devnet.sui.io:443
Run the following command to activate the devnet
environment:
sui client switch --env devnet
Now, publish your Move code with the following command:
sui client publish --gas-budget 10000000 counter
See Sui Client CLI for more information about client
commands in the Sui CLI.
The output of this command contains a packageId
value that you need to save to use the package.
----- Object changes ----
Array [
Object {
...
},
Object {
...
},
Object {
"type": String("published"),
"packageId": String("0xcd16d38ec30a4ad609336b51f6859a6b1014c50801b47845ac7a251e436cccf7"),
"version": String("1"),
"digest": String("4bCjupBDiaANmBySAtxuAdXEvGdKW4wrya6sbmRvynEe"),
"modules": Array [
String("counter"),
],
},
]
----- Balance changes ----
Add the packageId
value you receive in your own response to a new constants.ts
file in your project:
export const COUNTER_PACKAGE_ID =
"0xcd16d38ec30a4ad609336b51f6859a6b1014c50801b47845ac7a251e436cccf7";
Creating a counter
Now that you've published your Move code, you can start building your UI to use your Move package. You need a way to create a new Counter
object. Do this by creating a new CreateCounter
component:
export function CreateCounter(props: { onCreated: (id: string) => void }) {
return (
<div>
<button
onClick={() => {
create();
}}
>
Create Counter
</button>
</div>
);
function create() {
props.onCreated('TODO');
}
}
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 TransactionBlock
, with the appropriate moveCall
transaction, and then sign and execute the programmable transaction block (PTB).
First, import TransactionBlock
from @mysten/sui.js
, COUNTER_PACKAGE_ID
from your constants.ts file created previously, and useSignAndExecuteTransactionBlock
from @mysten/dapp-kit
.
import { useSignAndExecuteTransactionBlock } from '@mysten/dapp-kit';
import { TransactionBlock } from '@mysten/sui.js/transactions';
import { COUNTER_PACKAGE_ID } from './constants';
Next, call the useSignAndExecuteTransactionBlock
hook in your component, which provides a mutate
function you can use in your create
function:
export function CreateCounter(props: { onCreated: (id: string) => void }) {
const { mutate: signAndExecute } = useSignAndExecuteTransactionBlock();
return (
<div>
<button
onClick={() => {
create();
}}
>
Create Counter
</button>
</div>
);
function create() {
// TODO
}
}
Finally, construct your TransactionBlock
:
function create() {
const txb = new TransactionBlock();
txb.moveCall({
arguments: [],
target: `${COUNTER_PACKAGE_ID}::counter::create`,
});
signAndExecute(
{
transactionBlock: txb,
options: {
// We need the effects to get the objectId of the created counter object
showEffects: true,
},
},
{
onSuccess: (tx) => {
// The first created object in this TransactionBlock should be the new Counter
const objectId = tx.effects?.created?.[0]?.reference?.objectId;
if (objectId) {
props.onCreated(objectId);
}
},
},
);
}
}
You now have a functional component that can create a new Counter
object, but if you use it as is, you might run into some consistency issues where you successfully execute the TransactionBlock
, but the data isn't yet indexed to read from an RPC node. To ensure the TransactionBlock
is available, you can use the waitForTransactionBlock
method of SuiClient
.
To get an instance of SuiClient
, you can use the useSuiClient
hook from dApp Kit:
import { useSignAndExecuteTransactionBlock, useSuiClient } from '@mysten/dapp-kit';
export function CreateCounter(props: { onCreated: (id: string) => void }) {
const suiClient = useSuiClient();
const { mutate: signAndExecute } = useSignAndExecuteTransactionBlock();
return <button />;
}
Now you can use the suiClient
in your create
function to wait until the TransactionBlock
is indexed:
function create() {
const txb = new TransactionBlock();
txb.moveCall({
arguments: [],
target: `${COUNTER_PACKAGE_ID}::counter::create`,
});
signAndExecute(
{
transactionBlock: txb,
options: {
showEffects: true,
},
},
{
onSuccess: (tx) => {
suiClient
.waitForTransactionBlock({
digest: tx.digest,
})
.then(() => {
const objectId = tx.effects?.created?.[0]?.reference?.objectId;
if (objectId) {
props.onCreated(objectId);
}
});
},
},
);
}
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 App 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.
import { ConnectButton, useCurrentAccount } from '@mysten/dapp-kit';
import { isValidSuiObjectId } from '@mysten/sui.js/utils';
import { useState } from 'react';
export default function App() {
const currentAccount = useCurrentAccount();
const [counterId, setCounter] = useState(() => {
const hash = window.location.hash.slice(1);
return isValidSuiObjectId(hash) ? hash : null;
});
return (
<div>
<nav>
<ConnectButton />
</nav>
<section>
{!currentAccount ? (
'Please connect your wallet'
) : counterId ? (
<Counter id={counterId} />
) : (
<CreateCounter
onCreated={(id) => {
window.location.hash = id;
setCounter(id);
}}
/>
)}
</section>
</div>
);
}
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
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 with0
. This is only shown if the current user owns the counter.
import { useCurrentAccount, useSuiClientQuery } from '@mysten/dapp-kit';
import { SuiObjectData } from '@mysten/sui.js/client';
export function Counter({ id }: { id: string }) {
const currentAccount = useCurrentAccount();
const { data, refetch } = useSuiClientQuery('getObject', {
id,
options: {
showContent: true,
},
});
if (!data?.data) return <div>Not found</div>;
const ownedByCurrentAccount = getCounterFields(data.data)?.owner === currentAccount?.address;
return (
<div>
<div>Count: {getCounterFields(data.data)?.value}</div>
<button onClick={() => executeMoveCall('increment')}>Increment</button>
{ownedByCurrentAccount ? (
<button onClick={() => executeMoveCall('reset')}>Reset</button>
) : null}
</div>
);
function executeMoveCall(method: 'increment' | 'reset') {
// TODO
}
}
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.
import {
useCurrentAccount,
useSignAndExecuteTransactionBlock,
useSuiClient,
useSuiClientQuery,
} from '@mysten/dapp-kit';
import { SuiObjectData } from '@mysten/sui.js/client';
import { TransactionBlock } from '@mysten/sui.js/transactions';
import { COUNTER_PACKAGE_ID } from './constants';
export function Counter({ id }: { id: string }) {
const currentAccount = useCurrentAccount();
const suiClient = useSuiClient();
const { mutate: signAndExecute } = useSignAndExecuteTransactionBlock();
// ...
function executeMoveCall(method: 'increment' | 'reset') {
const txb = new TransactionBlock();
if (method === 'reset') {
txb.moveCall({
arguments: [txb.object(id), txb.pure.u64(0)],
target: `${COUNTER_PACKAGE_ID}::counter::set_value`,
});
} else {
txb.moveCall({
arguments: [txb.object(id)],
target: `${COUNTER_PACKAGE_ID}::counter::increment`,
});
}
signAndExecute(
{
transactionBlock: txb,
},
{
onSuccess: (tx) => {
suiClient.waitForTransactionBlock({ digest: tx.digest }).then(() => {
refetch();
});
},
},
);
}
}
Your counter app is now ready to count. To learn more about dApp Kit, check out the dApp Kit docs.