Full Source
The complete version of every file you create for the wallet + contract integration. Each block has a copy button — drop the files into the starter as-is.Two values to set for your own setup: replace
YOUR_PROJECT_ID in
web3-provider.tsx with a WalletConnect projectId (free at
cloud.reown.com), and update the addresses in
contracts.ts if you redeploy the contracts.src/lib/contracts.ts
src/lib/contracts.ts
// Contract addresses + ABIs for the DevWeb3Jogja "Tabungan Crypto" workshop.
// Repo kontrak: https://github.com/DevWeb3Jogja/workshop-amikom
//
// Deployed to Sepolia (chainId 11155111) via script/Deploy.s.sol.
// Versi ini: mint() dibuka untuk semua user (faucet) — bukan onlyOwner.
export const RUPIAH_TOKEN_ADDRESS =
"0x7C78582DEa2a03d982D5fb0975fb4895E731dD05" as const // Sepolia
export const VAULT_ADDRESS =
"0x8E81aB5f111670D732D961e892E94aCFcD593b07" as const // Sepolia
export const TOKEN_SYMBOL = "IDRT"
export const TOKEN_DECIMALS = 18
export const rupiahTokenAbi = [
{
type: "function",
name: "balanceOf",
stateMutability: "view",
inputs: [{ name: "account", type: "address" }],
outputs: [{ type: "uint256" }],
},
{
type: "function",
name: "allowance",
stateMutability: "view",
inputs: [
{ name: "owner", type: "address" },
{ name: "spender", type: "address" },
],
outputs: [{ type: "uint256" }],
},
{
type: "function",
name: "approve",
stateMutability: "nonpayable",
inputs: [
{ name: "spender", type: "address" },
{ name: "amount", type: "uint256" },
],
outputs: [{ type: "bool" }],
},
{
type: "function",
name: "mint",
stateMutability: "nonpayable",
inputs: [
{ name: "to", type: "address" },
{ name: "amount", type: "uint256" },
],
outputs: [],
},
] as const
export const vaultAbi = [
{
type: "function",
name: "balances",
stateMutability: "view",
inputs: [{ name: "user", type: "address" }],
outputs: [{ type: "uint256" }],
},
{
type: "function",
name: "totalDeposits",
stateMutability: "view",
inputs: [],
outputs: [{ type: "uint256" }],
},
{
type: "function",
name: "deposit",
stateMutability: "nonpayable",
inputs: [{ name: "amount", type: "uint256" }],
outputs: [],
},
{
type: "function",
name: "withdraw",
stateMutability: "nonpayable",
inputs: [{ name: "amount", type: "uint256" }],
outputs: [],
},
] as const
src/lib/helpers.ts
src/lib/helpers.ts
import { formatUnits } from "viem"
const numberFmt = new Intl.NumberFormat("en-US", { maximumFractionDigits: 4 })
/** Saldo (bigint, `decimals` desimal) → string dengan pemisah ribuan, mis. "1,000,000". */
export const formatAmount = (value: bigint | undefined, decimals: number) =>
numberFmt.format(Number(formatUnits(value ?? 0n, decimals)))
/** Sisakan angka + satu titik desimal (nilai mentah untuk state input). */
export const cleanNumeric = (s: string) => {
const cleaned = s.replace(/[^\d.]/g, "")
const parts = cleaned.split(".")
return parts.length > 2 ? `${parts[0]}.${parts.slice(1).join("")}` : cleaned
}
/** Pemisah ribuan untuk tampilan input, mis. "1000000" → "1,000,000". */
export const groupDigits = (raw: string) => {
if (!raw) return ""
const [int, dec] = raw.split(".")
const grouped = (int || "0").replace(/\B(?=(\d{3})+(?!\d))/g, ",")
return dec === undefined ? grouped : `${grouped}.${dec}`
}
/** Singkat hash tx: "0x1234ab…cd9f01". */
export const shortHash = (h?: string) =>
h ? `${h.slice(0, 10)}…${h.slice(-6)}` : ""
/** Ambil pesan error ringkas dari error wagmi/viem. */
export const shortError = (e: unknown) => {
const x = e as { shortMessage?: string; message?: string } | null | undefined
return x?.shortMessage ?? x?.message?.split("\n")[0] ?? "error"
}
src/components/web3-provider.tsx
src/components/web3-provider.tsx
import "@rainbow-me/rainbowkit/styles.css"
import type { ReactNode } from "react"
import {
darkTheme,
getDefaultConfig,
lightTheme,
RainbowKitProvider,
} from "@rainbow-me/rainbowkit"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { fallback, http } from "viem"
import { WagmiProvider } from "wagmi"
import { sepolia } from "wagmi/chains"
import { useTheme } from "@/components/theme-provider"
// Get a free projectId at https://cloud.reown.com (WalletConnect Cloud)
// and replace the value below.
const config = getDefaultConfig({
appName: "DevWeb3Jogja Workshop Amikom",
projectId: "YOUR_PROJECT_ID",
chains: [sepolia],
// RPC Sepolia dengan fallback (dipakai berurutan bila satu bermasalah):
// - RPC default sering lambat → useWaitForTransactionReceipt stuck "loading".
// - sebagian RPC menolak gas ("gas limit too high"), jadi dRPC didahulukan.
transports: {
[sepolia.id]: fallback([
http("https://sepolia.drpc.org"),
http("https://ethereum-sepolia.publicnode.com"),
http("https://rpc.sepolia.org"),
]),
},
})
const queryClient = new QueryClient()
export function Web3Provider({ children }: Readonly<{ children: ReactNode }>) {
const { resolvedTheme } = useTheme()
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
<RainbowKitProvider
theme={resolvedTheme === "dark" ? darkTheme() : lightTheme()}
>
{children}
</RainbowKitProvider>
</QueryClientProvider>
</WagmiProvider>
)
}
export default Web3Provider
src/main.tsx
src/main.tsx
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import "./index.css"
import App from "./App.tsx"
import { ThemeProvider } from "@/components/theme-provider.tsx"
import { Web3Provider } from "@/components/web3-provider.tsx"
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ThemeProvider>
<Web3Provider>
<App />
</Web3Provider>
</ThemeProvider>
</StrictMode>
)
src/App.tsx
src/App.tsx
import { Navbar } from "@/components/navbar"
import { VaultCard } from "@/components/vault-card"
import { ConnectButton } from "@rainbow-me/rainbowkit"
export function App() {
return (
<main id="home" className="bg-grid min-h-svh px-6 py-12">
<div className="mx-auto flex max-w-3xl flex-col gap-10">
<header className="flex flex-col gap-3">
<Navbar />
<h1 className="font-display text-4xl leading-tight tracking-tight sm:text-5xl">
Workshop Amikom
</h1>
</header>
<ConnectButton accountStatus="avatar" showBalance={false} />
<VaultCard />
</div>
</main>
)
}
export default App
src/components/vault-card.tsx
src/components/vault-card.tsx
import { useEffect, useState } from "react"
import { parseUnits } from "viem"
import {
useAccount,
useReadContract,
useWaitForTransactionReceipt,
useWriteContract,
} from "wagmi"
import { ArrowUpRightIcon } from "@/components/icons"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import {
RUPIAH_TOKEN_ADDRESS,
TOKEN_DECIMALS,
TOKEN_SYMBOL,
VAULT_ADDRESS,
rupiahTokenAbi,
vaultAbi,
} from "@/lib/contracts"
import {
cleanNumeric,
formatAmount,
groupDigits,
shortError,
shortHash,
} from "@/lib/helpers"
type TxStatus = { ok: boolean; hash?: `0x${string}`; msg?: string } | null
export function VaultCard() {
const { address } = useAccount()
const [amount, setAmount] = useState("")
const [lastAction, setLastAction] = useState("")
const [showApproveNote, setShowApproveNote] = useState(false)
const opts = { query: { enabled: !!address } }
const wallet = useReadContract({
address: RUPIAH_TOKEN_ADDRESS,
abi: rupiahTokenAbi,
functionName: "balanceOf",
args: address ? [address] : undefined,
...opts,
})
const savings = useReadContract({
address: VAULT_ADDRESS,
abi: vaultAbi,
functionName: "balances",
args: address ? [address] : undefined,
...opts,
})
const allowance = useReadContract({
address: RUPIAH_TOKEN_ADDRESS,
abi: rupiahTokenAbi,
functionName: "allowance",
args: address ? [address, VAULT_ADDRESS] : undefined,
...opts,
})
const {
writeContract,
data: hash,
isPending,
error: writeError,
} = useWriteContract()
const {
isLoading,
isSuccess,
isError,
error: receiptError,
} = useWaitForTransactionReceipt({ hash })
useEffect(() => {
if (isSuccess) {
wallet.refetch()
savings.refetch()
allowance.refetch()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSuccess])
let wei: bigint | undefined
try {
const n = amount.startsWith(".") ? `0${amount}` : amount
wei = n ? parseUnits(n, TOKEN_DECIMALS) : undefined
} catch {
wei = undefined
}
const busy = isPending || isLoading
const disabled = !address || !wei || busy
const show = (v?: bigint) =>
address ? `${formatAmount(v, TOKEN_DECIMALS)} ${TOKEN_SYMBOL}` : "—"
// Deposit butuh allowance ≥ jumlah; kalau kurang, Approve dulu.
const needApprove = wei !== undefined && (allowance.data ?? 0n) < wei
// Status transaksi diturunkan dari state hook (tanpa setState-di-effect).
let status: TxStatus = null
if (!busy) {
if (isSuccess && hash) {
status = { ok: true, hash }
} else if (writeError || isError) {
status = { ok: false, hash, msg: shortError(writeError ?? receiptError) }
}
}
const label = (text: string) =>
lastAction === text && busy ? "Loading…" : text
return (
<Card>
<CardHeader>
<CardTitle>Tabungan Vault</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4 text-sm">
<div className="grid grid-cols-2 gap-3">
<div className="border-2 border-border bg-muted/40 px-3 py-2">
<p className="text-xs text-muted-foreground">Saldo dompet</p>
<p className="font-display text-base leading-tight">
{show(wallet.data)}
</p>
</div>
<div className="border-2 border-border bg-muted/40 px-3 py-2">
<p className="text-xs text-muted-foreground">Tabunganmu</p>
<p className="font-display text-base leading-tight">
{show(savings.data)}
</p>
</div>
</div>
<Input
inputMode="decimal"
placeholder={`Jumlah ${TOKEN_SYMBOL}`}
value={groupDigits(amount)}
onChange={(e) => setAmount(cleanNumeric(e.target.value))}
/>
<div className="flex flex-wrap gap-2">
<Button
variant="violet"
disabled={disabled}
onClick={() => {
setLastAction("Mint")
if (address && wei)
writeContract({
address: RUPIAH_TOKEN_ADDRESS,
abi: rupiahTokenAbi,
functionName: "mint",
args: [address, wei],
})
}}
>
{label("Mint")}
</Button>
<Button
variant="cyan"
disabled={disabled}
onClick={() => {
setLastAction("Approve")
if (wei)
writeContract({
address: RUPIAH_TOKEN_ADDRESS,
abi: rupiahTokenAbi,
functionName: "approve",
args: [VAULT_ADDRESS, wei],
})
}}
>
{label("Approve")}
</Button>
<Button
variant="lime"
disabled={disabled}
onClick={() => {
if (needApprove) {
setShowApproveNote(true)
return
}
setShowApproveNote(false)
setLastAction("Deposit")
if (wei)
writeContract({
address: VAULT_ADDRESS,
abi: vaultAbi,
functionName: "deposit",
args: [wei],
})
}}
>
{label("Deposit")}
</Button>
<Button
variant="orange"
disabled={disabled}
onClick={() => {
setLastAction("Withdraw")
if (wei)
writeContract({
address: VAULT_ADDRESS,
abi: vaultAbi,
functionName: "withdraw",
args: [wei],
})
}}
>
{label("Withdraw")}
</Button>
</div>
{showApproveNote && needApprove && (
<p className="text-xs text-muted-foreground">
Allowance kurang — klik <strong>Approve</strong> dulu sebelum
Deposit.
</p>
)}
{status && (
<p className="text-xs">
Transaksi {lastAction} {status.ok ? "sukses" : "gagal"}:{" "}
{status.hash ? (
<a
href={`https://sepolia.etherscan.io/tx/${status.hash}`}
target="_blank"
rel="noopener noreferrer"
className="group/tx inline-flex items-center gap-0.5 font-bold"
>
<span className="group-hover/tx:underline">
{shortHash(status.hash)}
</span>
<ArrowUpRightIcon className="size-3 shrink-0" />
</a>
) : (
<span className="font-bold">{status.msg}</span>
)}
</p>
)}
</CardContent>
</Card>
)
}
export default VaultCard
That’s every file. Combined with the neo-brutalism starter, you have the
complete dApp. 🎉
