Skip to main content

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: 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:

  1. Social Recovery - Secure account recovery
  2. 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