Contract Interactions with Smart Accounts
This tutorial demonstrates how to interact with smart contracts using your smart account. You'll learn to execute transactions with sponsored gas payments and ERC-20 token-based fees.
Overview
Smart accounts enable flexible contract interactions:
- Sponsored Transactions: dApp pays gas fees for users
- Token-Based Payments: Users pay gas with ERC-20 tokens
- Batch Operations: Execute multiple contract calls in one transaction
- Custom Validation: Implement custom signature schemes
Basic Contract Interaction Component
Let's build a component that can interact with any smart contract:
components/ContractInteraction.tsx
import { useState, useMemo } from 'react'
import { encodeFunctionData, type Abi, type Address } from 'viem'
import { useStartale } from '../providers/StartaleAccountProvider'
// Example contract configurations
const PREDEFINED_CONTRACTS = {
counter: {
name: 'Counter Contract',
address: '0x6bcf154A6B80fDE9bd1556d39C9bCbB19B539Bd8' as Address,
abi: [
{
name: 'increment',
type: 'function',
inputs: [],
outputs: [],
stateMutability: 'nonpayable',
},
{
name: 'getCount',
type: 'function',
inputs: [],
outputs: [{ type: 'uint256', name: 'count' }],
stateMutability: 'view',
},
] as Abi,
},
diceRoll: {
name: 'Dice Roll Ledger',
address: '0x298D8873bA2B2879580105b992049201B60c1975' as Address,
abi: [
{
name: 'rollDice',
type: 'function',
inputs: [{ name: 'amount', type: 'uint256' }],
outputs: [],
stateMutability: 'nonpayable',
},
{
name: 'getLastRoll',
type: 'function',
inputs: [{ name: 'player', type: 'address' }],
outputs: [{ type: 'uint256', name: 'roll' }],
stateMutability: 'view',
},
] as Abi,
},
}
export function ContractInteraction() {
const { startaleClient, startaleTokenClient } = useStartale()
const [selectedContract, setSelectedContract] = useState<keyof typeof PREDEFINED_CONTRACTS>('counter')
const [selectedFunction, setSelectedFunction] = useState('')
const [functionArgs, setFunctionArgs] = useState<Record<string, string>>({})
const [isLoading, setIsLoading] = useState(false)
const [txHash, setTxHash] = useState<string>()
const contract = PREDEFINED_CONTRACTS[selectedContract]
// Get available functions from ABI
const availableFunctions = useMemo(() => {
return contract.abi
.filter((item) => item.type === 'function' && item.stateMutability !== 'view')
.map((fn) => fn.name)
}, [contract])
// Get function definition
const functionDef = useMemo(() => {
if (!selectedFunction) return undefined
return contract.abi.find(
(fn) => fn.type === 'function' && fn.name === selectedFunction
)
}, [selectedFunction, contract])
return (
<div className="contract-interaction">
<h3>Contract Interaction</h3>
{/* Contract Selection */}
<div className="form-group">
<label>Select Contract:</label>
<select
value={selectedContract}
onChange={(e) => setSelectedContract(e.target.value as keyof typeof PREDEFINED_CONTRACTS)}
>
{Object.entries(PREDEFINED_CONTRACTS).map(([key, contract]) => (
<option key={key} value={key}>{contract.name}</option>
))}
</select>
</div>
{/* Function Selection */}
<div className="form-group">
<label>Select Function:</label>
<select
value={selectedFunction}
onChange={(e) => setSelectedFunction(e.target.value)}
>
<option value="">Choose function...</option>
{availableFunctions.map((fnName) => (
<option key={fnName} value={fnName}>{fnName}</option>
))}
</select>
</div>
{/* Function Arguments */}
{functionDef && 'inputs' in functionDef && functionDef.inputs.length > 0 && (
<div className="form-group">
<label>Function Arguments:</label>
{functionDef.inputs.map((input) => (
<div key={input.name} className="argument-input">
<label>{input.name} ({input.type}):</label>
<input
type="text"
value={functionArgs[input.name] || ''}
onChange={(e) => setFunctionArgs(prev => ({
...prev,
[input.name]: e.target.value
}))}
placeholder={`Enter ${input.type} value`}
/>
</div>
))}
</div>
)}
{/* Action Buttons */}
<div className="action-buttons">
<button
onClick={() => executeTransaction('sponsored')}
disabled={!selectedFunction || isLoading}
className="btn-primary"
>
{isLoading ? 'Executing...' : 'Execute (Sponsored)'}
</button>
<button
onClick={() => executeTransaction('token')}
disabled={!selectedFunction || isLoading}
className="btn-secondary"
>
{isLoading ? 'Executing...' : 'Execute (Token Payment)'}
</button>
</div>
{/* Transaction Result */}
{txHash && (
<div className="transaction-result">
<h4>Transaction Successful!</h4>
<p>Hash: <code>{txHash}</code></p>
<a
href={`https://explorer-testnet.soneium.org/tx/${txHash}`}
target="_blank"
rel="noopener noreferrer"
>
View on Explorer
</a>
</div>
)}
</div>
)
async function executeTransaction(paymentMethod: 'sponsored' | 'token') {
if (!selectedFunction || !functionDef) return
const client = paymentMethod === 'sponsored' ? startaleClient : startaleTokenClient
if (!client) {
alert('Client not initialized')
return
}
setIsLoading(true)
setTxHash(undefined)
try {
// Prepare function arguments
const args = 'inputs' in functionDef
? functionDef.inputs.map(input => functionArgs[input.name] || '')
: []
// Encode function call
const data = encodeFunctionData({
abi: contract.abi,
functionName: selectedFunction,
args,
})
console.log('Executing transaction:', {
contract: contract.address,
function: selectedFunction,
args,
paymentMethod,
})
// Send user operation
const hash = await client.sendUserOperation({
calls: [{
to: contract.address,
data,
value: 0n,
}],
})
setTxHash(hash)
console.log('Transaction successful:', hash)
} catch (error) {
console.error('Transaction failed:', error)
alert(`Transaction failed: ${(error as Error).message}`)
} finally {
setIsLoading(false)
}
}
}
Advanced Features
Batch Transactions
Execute multiple contract calls in a single user operation:
utils/batchTransactions.ts
import { encodeFunctionData } from 'viem'
import type { StartaleAccountClient } from '@startale-scs/aa-sdk'
export async function executeBatchTransaction(
client: StartaleAccountClient,
calls: Array<{
to: `0x${string}`
data: `0x${string}`
value?: bigint
}>
) {
console.log('Executing batch transaction with', calls.length, 'calls')
const hash = await client.sendUserOperation({
calls: calls.map(call => ({
to: call.to,
data: call.data,
value: call.value || 0n,
})),
})
return hash
}
// Example usage
const batchCalls = [
{
to: '0x...' as `0x${string}`,
data: encodeFunctionData({
abi: counterAbi,
functionName: 'increment',
args: [],
}),
},
{
to: '0x...' as `0x${string}`,
data: encodeFunctionData({
abi: diceAbi,
functionName: 'rollDice',
args: [5],
}),
},
]
await executeBatchTransaction(client, batchCalls)
Gas Estimation
Check estimated gas costs before execution:
utils/gasEstimation.ts
export async function estimateUserOperationGas(
client: StartaleAccountClient,
calls: Array<{ to: `0x${string}`; data: `0x${string}`; value?: bigint }>
) {
try {
// Note: This is a simplified example
// Actual gas estimation would depend on the specific client implementation
const userOp = await client.prepareUserOperation({
calls,
})
console.log('Estimated gas:', {
callGasLimit: userOp.callGasLimit,
verificationGasLimit: userOp.verificationGasLimit,
preVerificationGas: userOp.preVerificationGas,
})
return userOp
} catch (error) {
console.error('Gas estimation failed:', error)
throw error
}
}
Error Handling
Implement comprehensive error handling:
utils/errorHandling.ts
export function handleTransactionError(error: Error): string {
if (error.message.includes('insufficient funds')) {
return 'Insufficient balance for gas fees'
}
if (error.message.includes('execution reverted')) {
return 'Transaction reverted - check contract conditions'
}
if (error.message.includes('paymaster')) {
return 'Paymaster error - check sponsorship eligibility'
}
if (error.message.includes('bundler')) {
return 'Bundler error - try again in a moment'
}
return `Transaction failed: ${error.message}`
}
Integration Example
Here's how to integrate contract interactions into your main app:
components/MainApp.tsx
import { useState } from 'react'
import { useStartale } from '../providers/StartaleAccountProvider'
import { ContractInteraction } from './ContractInteraction'
export function MainApp() {
const { startaleAccount, startaleClient, isLoading } = useStartale()
const [selectedTab, setSelectedTab] = useState('contract')
if (isLoading) {
return <div>Setting up your smart account...</div>
}
if (!startaleAccount || !startaleClient) {
return <div>Please connect your wallet to continue</div>
}
return (
<div className="main-app">
<header>
<h1>Smart Account Demo</h1>
<p>Account: {startaleAccount.address}</p>
</header>
<nav className="tabs">
<button
className={selectedTab === 'contract' ? 'active' : ''}
onClick={() => setSelectedTab('contract')}
>
Contract Interaction
</button>
{/* Add other tabs for social recovery, sessions, etc. */}
</nav>
<main>
{selectedTab === 'contract' && <ContractInteraction />}
</main>
</div>
)
}
Key Concepts
Sponsored vs Token Payments
- Sponsored: dApp covers gas costs, users transact for free
- Token Payments: Users pay gas with ERC-20 tokens instead of native ETH
User Operations vs Transactions
- Traditional transactions are signed and sent directly to the network
- User operations are sent to bundlers, which package them into transactions
- This enables gas sponsorship and custom validation logic
Next Steps
Now that you can interact with contracts, learn about:
- Social Recovery - Secure account recovery
- Smart Sessions - Reduce signature friction
Troubleshooting
Transaction fails with "insufficient funds":
- For sponsored transactions: Check paymaster configuration
- For token payments: Ensure sufficient token balance
"Execution reverted" errors:
- Check contract function requirements
- Verify function arguments are correct
- Test with a contract view function first
Bundler connection issues:
- Verify bundler URL and API key
- Check network connectivity
- Try with a different bundler endpoint