| showOutline | title | description |
|---|---|---|
1 |
Controller React Integration |
Learn how to integrate the Cartridge Controller into your React application, including setup, configuration, and usage examples. |
This guide demonstrates how to integrate the Cartridge Controller with a React application.
:::code-group
npm install @cartridge/connector @cartridge/controller @starknet-react/core @starknet-react/chains starknet
npm install -D tailwindcss vite-plugin-mkcertpnpm add @cartridge/connector @cartridge/controller @starknet-react/core @starknet-react/chains starknet
pnpm add -D tailwindcss vite-plugin-mkcertyarn add @cartridge/connector @cartridge/controller @starknet-react/core @starknet-react/chains starknet
yarn add -D tailwindcss vite-plugin-mkcertbun add @cartridge/connector @cartridge/controller @starknet-react/core @starknet-react/chains starknet
bun add -D tailwindcss vite-plugin-mkcert:::
First, set up the Starknet provider with the Cartridge Controller connector:
You can customize the ControllerConnector by providing configuration options
during instantiation. The ControllerConnector accepts an options object that
allows you to configure various settings such as policies, RPC URLs, theme, and
more.
⚠️ Important: TheControllerConnectorinstance must be created outside of any React components. Creating it inside a component will cause the connector to be recreated on every render, which can lead to connection issues.
import { sepolia, mainnet } from "@starknet-react/chains";
import {
StarknetConfig,
jsonRpcProvider,
cartridge,
} from "@starknet-react/core";
import { ControllerConnector } from "@cartridge/connector";
import { SessionPolicies } from "@cartridge/controller";
// Define your contract addresses
const ETH_TOKEN_ADDRESS =
'0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7'
// Define session policies
const policies: SessionPolicies = {
contracts: {
[ETH_TOKEN_ADDRESS]: {
methods: [
{
name: "approve",
entrypoint: "approve",
spender: "0x1234567890abcdef1234567890abcdef12345678",
amount: "0xffffffffffffffffffffffffffffffff",
description: "Approve spending of tokens",
},
{ name: "transfer", entrypoint: "transfer" },
],
},
},
}
// Initialize the connector
const connector = new ControllerConnector({
policies,
// With the defaults, you can omit chains if you want to use:
// - chains: [
// { rpcUrl: "https://api.cartridge.gg/x/starknet/sepolia" },
// { rpcUrl: "https://api.cartridge.gg/x/starknet/mainnet" },
// ]
})
// Configure RPC provider
const provider = jsonRpcProvider({
rpc: (chain: Chain) => {
switch (chain) {
case mainnet:
default:
return { nodeUrl: 'https://api.cartridge.gg/x/starknet/mainnet' };
case sepolia:
return { nodeUrl: 'https://api.cartridge.gg/x/starknet/sepolia' }
}
},
})
export function StarknetProvider({ children }: { children: React.ReactNode }) {
return (
<StarknetConfig
autoConnect
defaultChainId={mainnet.id}
chains={[mainnet, sepolia]}
provider={provider}
connectors={[connector]}
explorer={cartridge}
>
{children}
</StarknetConfig>
)
}Use the useConnect, useDisconnect, and useAccount hooks to manage wallet
connections:
import { useEffect, useState } from 'react'
import { useAccount, useConnect, useDisconnect } from '@starknet-react/core'
import { ControllerConnector } from '@cartridge/connector'
import { Button } from '@cartridge/ui'
export function ConnectWallet() {
const { connect, connectors } = useConnect()
const { disconnect } = useDisconnect()
const { address } = useAccount()
const controller = connectors[0] as ControllerConnector
const [username, setUsername] = useState<string>()
useEffect(() => {
if (!address) return
controller.username()?.then((n) => setUsername(n))
}, [address, controller])
return (
<div>
{address && (
<>
<p>Account: {address}</p>
{username && <p>Username: {username}</p>}
</>
)}
{address ? (
<Button onClick={() => disconnect()}>Disconnect</Button>
) : (
<div className="space-y-2">
{/* Standard connection using default signupOptions */}
<Button onClick={() => connect({ connector: controller })}>
Connect
</Button>
{/* Dynamic authentication options for branded flows */}
<Button onClick={() => controller.connect({ signupOptions: ["phantom-evm"] })}>
Connect with Phantom
</Button>
<Button onClick={() => controller.connect({ signupOptions: ["google"] })}>
Connect with Google
</Button>
<Button onClick={() => controller.connect({ signupOptions: ["discord"] })}>
Connect with Discord
</Button>
</div>
)}
</div>
)
}The ControllerConnector now supports dynamic authentication configuration per connection call. This allows you to create multiple branded authentication flows while using a single Controller instance:
// Direct connector method - bypasses starknet-react state management
controller.connect({ signupOptions: ["phantom-evm"] })
// For starknet-react integration, use the standard connect method
connect({ connector: controller })- Per-call Override:
signupOptionspassed toconnect()override the constructor defaults - Branded Flows: Create specific authentication buttons like "Login with Phantom", "Login with Google"
- Single Instance: Use one Controller instance for multiple authentication methods
- React Integration: Note that direct
controller.connect()calls bypass starknet-react's state management
import { useConnect, useAccount } from '@starknet-react/core'
import { ControllerConnector } from '@cartridge/connector'
export function MultiAuthConnectWallet() {
const { connect, connectors } = useConnect()
const { address } = useAccount()
const controller = connectors[0] as ControllerConnector
const handleSpecificAuth = async (signupOptions: string[]) => {
try {
// Direct controller connection for specific auth options
await controller.connect({ signupOptions })
// Manually trigger starknet-react state update
connect({ connector: controller })
} catch (error) {
console.error('Connection failed:', error)
}
}
if (address) {
return <div>Connected: {address}</div>
}
return (
<div className="grid gap-2">
<h3>Choose your authentication method:</h3>
{/* Standard multi-option flow */}
<button onClick={() => connect({ connector: controller })}>
Connect Wallet
</button>
{/* Branded single-option flows */}
<button
onClick={() => handleSpecificAuth(["phantom-evm"])}
className="phantom-branded-button"
>
Continue with Phantom
</button>
<button
onClick={() => handleSpecificAuth(["google"])}
className="google-branded-button"
>
Continue with Google
</button>
<button
onClick={() => handleSpecificAuth(["discord"])}
className="discord-branded-button"
>
Continue with Discord
</button>
</div>
)
}For programmatic authentication without opening any UI, you can use headless mode in your React components:
import { useCallback, useState } from 'react'
import { useConnect } from '@starknet-react/core'
import { ControllerConnector } from '@cartridge/connector'
export function HeadlessLogin() {
const { connectAsync, connectors } = useConnect()
const [username, setUsername] = useState('')
const [loading, setLoading] = useState(false)
const controller = connectors[0] as ControllerConnector
const handleHeadlessLogin = useCallback(async (signer: string) => {
if (!username) {
alert('Please enter a username')
return
}
setLoading(true)
try {
// Ensure we start fresh
if (controller.account) {
await controller.disconnect()
}
// Headless authentication
const account = await controller.connect({
username,
signer,
})
if (!account) {
throw new Error('Failed to authenticate')
}
// Sync with starknet-react state
await connectAsync({ connector: controller })
alert(`Successfully authenticated as ${username}!`)
} catch (error) {
console.error('Headless authentication failed:', error)
alert('Authentication failed: ' + (error as Error).message)
} finally {
setLoading(false)
}
}, [username, controller, connectAsync])
return (
<div className="space-y-4">
<div>
<label htmlFor="username">Username:</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter your username"
disabled={loading}
/>
</div>
<div className="grid gap-2">
<button
onClick={() => handleHeadlessLogin('webauthn')}
disabled={loading || !username}
>
Login with Passkey
</button>
<button
onClick={() => handleHeadlessLogin('metamask')}
disabled={loading || !username}
>
Login with MetaMask
</button>
<button
onClick={() => handleHeadlessLogin('google')}
disabled={loading || !username}
>
Login with Google
</button>
</div>
{loading && <p>Authenticating...</p>}
</div>
)
}:::warning
Headless mode requires that the user already has the specified signer (passkey, OAuth account, EVM wallet) associated with their Cartridge username. For new user registration, use the regular connect() flow which opens the UI.
:::
Execute transactions using the account object from useAccount hook:
import { useAccount, useExplorer } from '@starknet-react/core'
import { useCallback, useState } from 'react'
const ETH_CONTRACT =
'0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7'
export const TransferEth = () => {
const [submitted, setSubmitted] = useState<boolean>(false)
const { account } = useAccount()
const explorer = useExplorer()
const [txnHash, setTxnHash] = useState<string>()
const execute = useCallback(
async (amount: string) => {
if (!account) return
setSubmitted(true)
setTxnHash(undefined)
try {
const result = await account.execute([
{
contractAddress: ETH_CONTRACT,
entrypoint: 'approve',
calldata: [account?.address, amount, '0x0'],
},
{
contractAddress: ETH_CONTRACT,
entrypoint: 'transfer',
calldata: [account?.address, amount, '0x0'],
},
])
setTxnHash(result.transaction_hash)
} catch (e) {
console.error(e)
} finally {
setSubmitted(false)
}
},
[account],
)
if (!account) return null
return (
<div>
<h2>Transfer ETH</h2>
<button onClick={() => execute('0x1C6BF52634000')} disabled={submitted}>
Transfer 0.005 ETH
</button>
{txnHash && (
<p>
Transaction hash:{' '}
<a
href={explorer.transaction(txnHash)}
target="blank"
rel="noreferrer"
>
{txnHash}
</a>
</p>
)}
</div>
)
}import { StarknetProvider } from './context/StarknetProvider'
import { ConnectWallet } from './components/ConnectWallet'
import { TransferEth } from './components/TransferEth'
function App() {
return (
<StarknetProvider>
<ConnectWallet />
<TransferEth />
</StarknetProvider>
)
}
export default AppIf you're working with the Cartridge Controller repository examples, you can use two development modes:
# Local development with local APIs
pnpm dev
# Testing with production APIs (hybrid mode)
pnpm dev:liveThe dev:live mode is useful when you need to test your React application against production data while keeping your local development environment.
Make sure to use HTTPS in development by configuring Vite:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import mkcert from 'vite-plugin-mkcert'
export default defineConfig({
plugins: [react(), mkcert()],
})If you're using external wallets (MetaMask, Rabby, etc.) with Cartridge Controller, you can wait for transaction confirmations using the externalWaitForTransaction method:
import { useState, useCallback } from 'react'
import { ControllerConnector } from '@cartridge/connector'
import { useConnect } from '@starknet-react/core'
export const ExternalWalletTransaction = () => {
const { connectors } = useConnect()
const controller = connectors[0] as ControllerConnector
const [txHash, setTxHash] = useState<string>()
const [isWaiting, setIsWaiting] = useState<boolean>(false)
const [receipt, setReceipt] = useState<any>()
const waitForTransaction = useCallback(async () => {
if (!txHash || !controller) return
setIsWaiting(true)
try {
// Wait for transaction confirmation with 30-second timeout
const response = await controller.externalWaitForTransaction(
'metamask', // or 'rabby', 'phantom', etc.
txHash,
30000 // 30 seconds
)
if (response.success) {
setReceipt(response.result)
console.log('Transaction confirmed:', response.result)
} else {
console.error('Transaction failed:', response.error)
}
} catch (error) {
console.error('Error waiting for transaction:', error)
} finally {
setIsWaiting(false)
}
}, [txHash, controller])
return (
<div>
<h2>External Wallet Transaction Monitor</h2>
<input
type="text"
placeholder="Enter transaction hash"
value={txHash || ''}
onChange={(e) => setTxHash(e.target.value)}
/>
<button
onClick={waitForTransaction}
disabled={!txHash || isWaiting}
>
{isWaiting ? 'Waiting for confirmation...' : 'Wait for Transaction'}
</button>
{receipt && (
<div>
<h3>Transaction Receipt:</h3>
<pre>{JSON.stringify(receipt, null, 2)}</pre>
</div>
)}
</div>
)
}The Controller provides several methods for interacting with external wallets:
externalSwitchChain(walletType, chainId)- Switch the connected wallet to a different chainexternalWaitForTransaction(walletType, txHash, timeoutMs?)- Wait for transaction confirmationexternalSendTransaction(walletType, transaction)- Send a transaction through the external wallet
These methods work with all supported external wallet types: metamask, rabby, phantom, argent, and walletconnect.