> ## Documentation Index
> Fetch the complete documentation index at: https://web3docs.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Full Source

> Complete, copy-paste-ready source for every file in the integration

# 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.

<Info>
  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](https://cloud.reown.com)), and update the addresses in
  `contracts.ts` if you redeploy the contracts.
</Info>

## src/lib/contracts.ts

```ts title="src/lib/contracts.ts" theme={null}
// 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

```ts title="src/lib/helpers.ts" theme={null}
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

```tsx title="src/components/web3-provider.tsx" theme={null}
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

```tsx title="src/main.tsx" theme={null}
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

```tsx title="src/App.tsx" theme={null}
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

```tsx title="src/components/vault-card.tsx" theme={null}
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
```

<Check>
  That's every file. Combined with the neo-brutalism starter, you have the
  complete dApp. 🎉
</Check>
