Frontend Integration
Now connect the React app to the deployed contracts using wagmi hooks.
1. Contract addresses & ABIs
Keep addresses and minimal ABIs in one place:
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: [{ type: "address" }, { type: "address" }], outputs: [{ type: "uint256" }] },
{ type: "function" , name: "approve" , stateMutability: "nonpayable" ,
inputs: [{ type: "address" }, { type: "uint256" }], outputs: [{ type: "bool" }] },
{ type: "function" , name: "mint" , stateMutability: "nonpayable" ,
inputs: [{ type: "address" }, { type: "uint256" }], outputs: [] },
] as const
export const vaultAbi = [
{ type: "function" , name: "balances" , stateMutability: "view" ,
inputs: [{ type: "address" }], outputs: [{ type: "uint256" }] },
{ type: "function" , name: "deposit" , stateMutability: "nonpayable" ,
inputs: [{ type: "uint256" }], outputs: [] },
{ type: "function" , name: "withdraw" , stateMutability: "nonpayable" ,
inputs: [{ type: "uint256" }], outputs: [] },
] as const
2. Read balances
useReadContract reads on-chain view functions. Enable each only when a wallet
is connected:
const { address } = useAccount ()
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 ,
})
3. Write transactions
useWriteContract sends a transaction; useWaitForTransactionReceipt watches it.
After success, refetch the reads so balances update:
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 ()
}
}, [ isSuccess ]) // eslint-disable-line react-hooks/exhaustive-deps
The four actions, all via the same writeContract:
// 1) Mint (open faucet) — to your own address
writeContract ({ address: RUPIAH_TOKEN_ADDRESS , abi: rupiahTokenAbi ,
functionName: "mint" , args: [ address , wei ] })
// 2) Approve the Vault
writeContract ({ address: RUPIAH_TOKEN_ADDRESS , abi: rupiahTokenAbi ,
functionName: "approve" , args: [ VAULT_ADDRESS , wei ] })
// 3) Deposit into the Vault
writeContract ({ address: VAULT_ADDRESS , abi: vaultAbi ,
functionName: "deposit" , args: [ wei ] })
// 4) Withdraw back to your wallet
writeContract ({ address: VAULT_ADDRESS , abi: vaultAbi ,
functionName: "withdraw" , args: [ wei ] })
wei is the amount parsed to 18 decimals with parseUnits(amount, 18).
4. Friendly numbers
Balances and the input are easier to read with thousand separators:
import { formatUnits } from "viem"
const numberFmt = new Intl . NumberFormat ( "en-US" , { maximumFractionDigits: 4 })
// 1_000_000n → "1,000,000"
export const formatAmount = ( value : bigint | undefined , decimals : number ) =>
numberFmt . format ( Number ( formatUnits ( value ?? 0 n , decimals )))
// group the input while typing: "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 } `
}
5. Loading & transaction status
While a tx is in flight, show the clicked button as Loading…, and after it
settles show a link to Etherscan:
const busy = isPending || isLoading
const label = ( text : string ) =>
lastAction === text && busy ? "Loading…" : text
// derive status without setState-in-effect
let status : { ok : boolean ; hash ?: `0x ${ string } ` ; msg ?: string } | null = null
if ( ! busy ) {
if ( isSuccess && hash ) status = { ok: true , hash }
else if ( writeError || isError )
status = { ok: false , hash , msg: shortError ( writeError ?? receiptError ) }
}
{ status && (
< p >
Transaksi { lastAction } { status . ok ? "sukses" : "gagal" } : { " " }
{ status . hash
? < a href = { `https://sepolia.etherscan.io/tx/ ${ status . hash } ` } target = "_blank" rel = "noopener noreferrer" > { shortHash ( status . hash ) } ↗ </ a >
: < span > { status . msg } </ span > }
</ p >
)}
6. Guard deposit on allowance
deposit reverts if you haven’t approved enough — and a failed gas estimate
surfaces as a confusing “gas limit too high” . Catch it before sending: if the
allowance is too low, show a hint instead of a doomed transaction.
const needApprove = wei !== undefined && ( allowance . data ?? 0 n ) < wei
// on Deposit click:
if ( needApprove ) {
setShowApproveNote ( true ) // "Allowance kurang — Approve dulu"
return // don't send a tx that will revert
}
After Approve succeeds, allowance refetches → needApprove becomes false →
Deposit works.
The full flow
Connect
Connect MetaMask on Sepolia.
Mint
Click Mint to get IDRT (open faucet).
Approve
Enter an amount, click Approve .
Deposit
Click Deposit — tokens move into the Vault.
Withdraw
Click Withdraw to pull them back out.
That’s the full loop: a neo-brutalism dApp reading and writing real contracts on
Sepolia. 🎉
Full Source Copy every file in full from one page.