Workshop IV Capstone — Build a dApp with IOTA dApp Kit
In this capstone, you will build a small React dApp on top of the contracts you created in Workshops I–III. You’ll wire up wallet connect, display owned objects, and trigger entry functions from the browser using the IOTA dApp Kit.
We will focus purely on functionality, not design, so the UI will be very basic. You can always enhance it later with your own styles or a component library.
Prerequisites
- Node.js 20+ and a package manager (pnpm or npm)
- A deployed Move package from prior workshops (PKG_ID)
- Shared object IDs you plan to use (e.g., Roles/Registry IDs) and any capability IDs, if you plan to call admin-gated entries
- IOTA CLI configured on Testnet and funded for testing
Create the app (template)
Use the official scaffolder to start a pre-configured React app with the dApp Kit.
npm create @iota/dapp
Wire up providers
Wrap your app with the IotaClientProvider and WalletProvider and add the CSS for the dApp Kit components.
// src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { createNetworkConfig, IotaClientProvider, WalletProvider } from '@iota/dapp-kit';
import { getFullnodeUrl } from '@iota/iota-sdk/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import '@iota/dapp-kit/dist/index.css';
import App from './App';
const { networkConfig } = createNetworkConfig({
testnet: { url: getFullnodeUrl('testnet') },
// localnet: { url: getFullnodeUrl('localnet') }, // optional
});
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<IotaClientProvider networks={networkConfig} defaultNetwork="testnet">
<WalletProvider>
<App />
</WalletProvider>
</IotaClientProvider>
</QueryClientProvider>
</React.StrictMode>
);
Connect a wallet
This is exactly like a login flow in a traditional web app, except you connect your wallet instead of entering a username/password.
Use the prebuilt ConnectButton to handle wallet connect/disconnect.
// src/App.tsx
import { ConnectButton, useCurrentAccount } from '@iota/dapp-kit';
export default function App() {
const account = useCurrentAccount();
return (
<div style={{ padding: 16 }}>
<ConnectButton />
{account && <div style={{ marginTop: 12 }}>Connected: {account.address}</div>}
</div>
);
}
Read objects owned by the user
Grants are represented as on-chain objects owned by a user’s address. To see your grants, you first need to read all objects owned by the connected address.
Query the fullnode using dApp Kit’s useIotaClientQuery
.
// src/components/OwnedObjects.tsx
import { useCurrentAccount, useIotaClientQuery } from '@iota/dapp-kit';
export function OwnedObjects() {
const account = useCurrentAccount();
const { data, isPending, isError, error } = useIotaClientQuery(
'getOwnedObjects',
account ? { owner: account.address } : undefined,
);
if (!account) return null;
if (isPending) return <div>Loading owned objects…</div>;
if (isError) return <div>Error: {String(error)}</div>;
return <pre style={{ whiteSpace: 'pre-wrap' }}>{JSON.stringify(data, null, 2)}</pre>;
}
Add it to App
to render when connected.
// src/App.tsx (continued)
import { OwnedObjects } from './components/OwnedObjects';
// … inside App()
{account && (
<>
<OwnedObjects />
</>
)}
Now your App.tsx
should look like:
// src/App.tsx
import { ConnectButton, useCurrentAccount } from '@iota/dapp-kit';
import { OwnedObjects } from './components/OwnedObjects';
export default function App() {
const account = useCurrentAccount();
return (
<div style={{ padding: 16 }}>
<ConnectButton />
{account && (
<>
<div style={{ marginTop: 12 }}>Connected: {account.address}</div>
<OwnedObjects />
</>
)}
</div>
);
}
Build a call: request a grant (example)
As a concrete example, call your lifecycle entry to request a grant. This is user-driven (no admin cap needed) and writes the request to a shared Requests
queue. It does not mint a grant yet.
Create a small form that builds a transaction and signs + executes it.
// src/components/RequestGrant.tsx
import { useCurrentAccount, useSignAndExecuteTransaction } from '@iota/dapp-kit';
import { Transaction } from '@iota/iota-sdk/transactions';
import { useState } from 'react';
// Configure your package + shared Requests object ID here (or import from a config file)
const PKG_ID = '0x<your_package_id>'; // replace
const REQUESTS_ID = '0x<shared_requests_object_id>'; // replace
export function RequestGrant() {
const account = useCurrentAccount();
const [amount, setAmount] = useState('1000');
const { mutate: signAndExecute } = useSignAndExecuteTransaction();
if (!account) return null;
return (
<form
onSubmit={(e) => {
e.preventDefault();
const tx = new Transaction();
tx.moveCall({
target: `${PKG_ID}::lifecycle::request_grant`,
arguments: [
// requests: &mut Requests
tx.object(REQUESTS_ID),
// amount: u64
tx.pure.u64(BigInt(amount)),
],
typeArguments: [],
});
signAndExecute(
{ transaction: tx },
{ onSuccess: (res) => console.log('digest', res.digest) }
);
}}
>
<label>
Amount
<input value={amount} onChange={(e) => setAmount(e.target.value)} />
</label>
<button type="submit">Request Grant</button>
</form>
);
}
Add it to App
below the wallet connection when connected.
// src/App.tsx (continued)
import { RequestGrant } from './components/RequestGrant';
// … inside App()
{account && <RequestGrant />}
Now your App.tsx
should look like:
// src/App.tsx
import { ConnectButton, useCurrentAccount } from '@iota/dapp-kit';
import { OwnedObjects } from './components/OwnedObjects';
import { RequestGrant } from './components/RequestGrant';
export default function App() {
const account = useCurrentAccount();
return (
<div style={{ padding: 16 }}>
<ConnectButton />
{account && (
<>
<div style={{ marginTop: 12 }}>Connected: {account.address}</div>
<OwnedObjects />
<RequestGrant />
</>
)}
</div>
);
}
Approve a request from the CLI
An issuer (admin-gated) should approve a student’s request by calling lifecycle::approve_grant
from the CLI. This mints and transfers the Grant to the student by calling into the grant
module.
You need the following IDs:
PKG_ID
: the published package IDREQUESTS_ID
: the shared Requests object IDROLES_ID
: the shared Roles object ID (from Workshop III)ADMIN_CAP_ID
: the AdminCap object ID held by an issuer/adminSTUDENT_ADDR
: the student’s address that submitted the request
Example using the CLI:
iota client call \
--package <PACKAGE-ID> \
--module lifecycle \
--function approve_grant \
--args <REQUESTS_ID> <STUDENT_ADDR> <ROLES_ID> <ADMIN_CAP_ID> \
--gas-budget 80000000
Notes:
- Double-check argument order:
approve_grant(requests, student, roles, cap, ctx)
. - The function reads and removes the student’s pending amount from
Requests
, then mints and transfers aGrant
to the student address.
How are we still indexing this grant if we approve it this way?
See your grant in the dApp
After a successful approve_grant
call, refresh your dApp. The OwnedObjects
section should now list the new Grant
object owned by the student’s address.
Optional: Admin panel actions
If you plan to expose admin-only operations (e.g., creating the shared Roles object or indexing grants), gate them in the UI by checking that the connected account is allowed (for example, show the admin panel only for a known publisher address). These calls typically require passing capability object IDs in the move call, which you should store in a config file and never hardcode in public deployments.
- Example admin calls (adjust to your modules):
- access::create_roles(&AdminCap)
- registry::index_grant(&mut Registry, student, grant_id, &AdminCap)
Reuse the useSignAndExecuteTransaction
pattern from RequestGrant.
Run the app
npm run dev
Open the local URL and connect a wallet to try reading owned objects and submitting a request. After an issuer approves your request from the CLI, refresh the page to see the minted Grant among your owned objects.
Next steps
- Add a dashboard for admins/issuers:
- List pending requests from the shared
Requests
object. - Approve/deny requests with a single click (backend via dApp Kit calls gated by wallet address, or CLI).
- List pending requests from the shared
- Improve the UI:
- Replace inline styles with a component library (e.g., Tailwind, Mantine, MUI).
- Add success/error toasts, spinners, and empty states.
- Surface on-chain events (e.g.,
GrantRequested
,GrantApproved
) in the UI.