What is an Associated Token Account (ATA)?
USDC is an SPL token and is not stored directly in a wallet. Instead, it lives in a separate Associated Token Account (ATA) owned by the wallet. The ATA address is derived from the wallet's public key and the USDC mint address.
The ATA stores: mint address, owner (wallet), balance, and decimals.
Prerequisites
- PHP 7.4 or later (cURL enabled)
- Solana RPC endpoint (public or dedicated)
- A Solana wallet address
For production: Helius, QuickNode, Alchemy, or Chainstack.
The RPC Method: getTokenAccountsByOwner
This method returns every token account owned by a wallet. It is the standard way to fetch USDC balance.
1JSON‑RPC Request
Understanding the Response
A typical USDC token account object:
Raw Amount vs Displayed Balance
The RPC returns both the raw integer and the formatted balance. Use uiAmountString for display to avoid floating‑point issues.
- 25634781 raw amount (smallest unit)
- 6 decimal precision (USDC uses 6)
- 25.634781 human‑readable (uiAmountString)
USDC on Solana: The Bigger Picture
USDC (USD Coin) is a fully reserved, dollar‑backed stablecoin issued by Circle. On Solana, it is a native SPL token — not a wrapped or bridged asset for most flows — meaning Circle mints and redeems it directly on-chain via the Multichain framework. Reserves are held in cash and short‑duration U.S. Treasuries, and attestations are published monthly by an independent accounting firm.
The USDC mint address is EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v and it uses 6 decimal places.
Why Solana Became a Major USDC Venue
Solana's sub‑second block times and sub‑cent transaction fees make it structurally different from USDC on Ethereum, where a single transfer can cost several dollars during congestion. That cost difference is what pulled high‑frequency trading, micro‑payments, and remittance use cases toward Solana specifically. Circle has also invested directly in Solana‑native tooling, and Solana is one of the reference chains for Circle's Cross‑Chain Transfer Protocol (CCTP), which lets USDC move between chains by burning and re‑minting the canonical token rather than relying on a wrapped, third‑party bridge asset — reducing bridge‑risk exposure for developers building payment flows.
Where USDC Shows Up in the Solana Stack
USDC is the dominant quote asset across Jupiter (aggregator), Raydium, and Orca. Most token pairs route through a USDC leg for pricing and liquidity depth.
Protocols like Kamino and Solend/save use USDC as a core collateral and borrow asset, since it's the deepest, most stable market on the chain.
Fintechs and payroll platforms increasingly settle cross‑border payouts in USDC on Solana because settlement is near‑instant and fees are negligible compared to traditional rails.
On/off‑ramp providers (e.g. MoonPay, Coinbase, exchanges) let users convert fiat to Solana USDC directly, which is often the first token a new Solana wallet ever holds.
USDC vs Other Solana Stablecoins
USDC isn't the only dollar‑pegged token on Solana — PayPal USD (PYUSD) and Tether's USDT also circulate on-chain, and native protocols have their own synthetic dollars in some DeFi ecosystems. USDC generally leads on integration depth and liquidity across major venues, but for any application, always verify the exact mint address you're checking against rather than assuming "USDC" refers to a single universally recognized token — a wallet could theoretically hold a token with the same name and symbol but a different mint, which is a common scam vector worth guarding against in production code.
PHP Code – Get USDC Balance
This script fetches the USDC balance for a given wallet address.
$wallet = 'YourWalletAddress';
$payload = [
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'getTokenAccountsByOwner',
'params' => [
$wallet,
['programId' => 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'],
['encoding' => 'jsonParsed']
]
];
$ch = curl_init($rpc);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
die("Error: HTTP $httpCode");
}
$data = json_decode($response, true);
if (isset($data['error'])) {
die("RPC Error: " . $data['error']['message']);
}
$usdcBalance = '0';
foreach ($data['result']['value'] as $account) {
$info = $account['account']['data']['parsed']['info'];
if ($info['mint'] === $usdcMint) {
$usdcBalance = $info['tokenAmount']['uiAmountString'];
break;
}
}
echo "USDC balance: $usdcBalance\n";
Error Handling & Rate Limits
Public RPC endpoints are rate‑limited. Always handle HTTP errors and implement exponential backoff for production use.
function getUsdcBalance(string $wallet, string $rpc, string $usdcMint, int $retries = 3): array {
$payload = [
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'getTokenAccountsByOwner',
'params' => [
$wallet,
['programId' => 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'],
['encoding' => 'jsonParsed']
]
];
for ($i = 0; $i < $retries; $i++) {
$ch = curl_init($rpc);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError) {
// Network-level failure (DNS, timeout, connection refused)
if ($i === $retries - 1) {
return ['error' => "cURL error: $curlError"];
}
sleep(pow(2, $i));
continue;
}
if ($httpCode === 429) {
sleep(pow(2, $i)); // Exponential backoff: 1s, 2s, 4s
continue;
}
if ($httpCode === 200) {
$data = json_decode($response, true);
if (isset($data['error'])) {
return ['error' => $data['error']['message']];
}
$balance = '0';
foreach ($data['result']['value'] as $account) {
$info = $account['account']['data']['parsed']['info'];
if ($info['mint'] === $usdcMint) {
$balance = $info['tokenAmount']['uiAmountString'];
break;
}
}
return ['balance' => $balance];
}
}
return ['error' => 'Max retries exceeded'];
}
// Usage
$result = getUsdcBalance(
'YourWalletAddress',
'https://api.mainnet-beta.solana.com',
'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'
);
echo $result['balance'] ?? "Error: {$result['error']}";
Laravel Implementation
For a Laravel app, wrap the RPC call in a dedicated service class instead of inline cURL. This keeps the logic testable, injectable, and easy to reuse across controllers, jobs, and Artisan commands.
1. Config
Add the RPC endpoint to config/services.php so it's environment‑driven instead of hardcoded.
return [
// ...existing services
'solana' => [
'rpc_url' => env('SOLANA_RPC_URL', 'https://api.mainnet-beta.solana.com'),
'usdc_mint' => env('SOLANA_USDC_MINT', 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'),
],
];
Then in .env:
SOLANA_RPC_URL=https://your-dedicated-rpc-provider.com/YOUR_API_KEY
SOLANA_USDC_MINT=EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v
2. Service class
Use Laravel's Http facade instead of raw cURL — it gives you retries, timeouts, and testability (via Http::fake()) out of the box.
// app/Services/Solana/UsdcBalanceService.php
namespace App\Services\Solana;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;
class UsdcBalanceService
{
private const TOKEN_PROGRAM_ID = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA';
private readonly string $rpcUrl;
private readonly string $usdcMint;
public function __construct(
string $rpcUrl = '',
string $usdcMint = '',
) {
$this->rpcUrl = $rpcUrl ?: config('services.solana.rpc_url');
$this->usdcMint = $usdcMint ?: config('services.solana.usdc_mint');
}
public function getBalance(string $wallet, bool $useCache = true): string
{
if ($useCache) {
return Cache::remember(
"usdc_balance:{$wallet}",
now()->addSeconds(30),
fn () => $this->fetchBalance($wallet)
);
}
return $this->fetchBalance($wallet);
}
private function fetchBalance(string $wallet): string
{
$response = Http::retry(3, 1000, throw: false)
->timeout(10)
->post($this->rpcUrl, [
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'getTokenAccountsByOwner',
'params' => [
$wallet,
['programId' => self::TOKEN_PROGRAM_ID],
['encoding' => 'jsonParsed'],
],
]);
if ($response->failed()) {
throw new \RuntimeException("Solana RPC request failed: {$response->status()}");
}
$accounts = $response->json('result.value', []);
foreach ($accounts as $account) {
$info = $account['account']['data']['parsed']['info'];
if ($info['mint'] === $this->usdcMint) {
return $info['tokenAmount']['uiAmountString'];
}
}
return '0';
}
}
3. Bind it in a service provider
// app/Providers/AppServiceProvider.php
namespace App\Providers;
use App\Services\Solana\UsdcBalanceService;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
$this->app->singleton(UsdcBalanceService::class, function () {
return new UsdcBalanceService(
config('services.solana.rpc_url'),
config('services.solana.usdc_mint'),
);
});
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}
4. Use it in a controller
// app/Http/Controllers/WalletController.php
namespace App\Http\Controllers;
use App\Services\Solana\UsdcBalanceService;
use Illuminate\Http\Request;
class WalletController extends Controller
{
public function balance(Request $request, UsdcBalanceService $usdc)
{
$request->validate(['wallet' => 'required|string|min:32|max:44']);
$balance = $usdc->getBalance($request->string('wallet'));
return response()->json(['wallet' => $request->wallet, 'usdc_balance' => $balance]);
}
}
5. Optional: Artisan command
Handy for cron jobs, ops scripts, or quick CLI checks without spinning up a route.
// app/Console/Commands/CheckUsdcBalance.php
namespace App\Console\Commands;
use App\Services\Solana\UsdcBalanceService;
use Illuminate\Console\Command;
class CheckUsdcBalance extends Command
{
protected $signature = 'solana:usdc-balance {wallet}';
protected $description = 'Fetch the USDC balance for a given Solana wallet address';
public function handle(UsdcBalanceService $usdc): int
{
$balance = $usdc->getBalance($this->argument('wallet'), useCache: false);
$this->info("USDC balance: {$balance}");
return self::SUCCESS;
}
}
Caching & Multi‑Wallet Lookups
Public and even dedicated RPC endpoints have rate limits. If your app checks the same wallet's balance repeatedly (dashboards, polling UIs), cache the result for a short TTL — 15 to 60 seconds is usually enough since balances don't change every second.
Plain PHP caching example (using APCu, adjust for your stack):
function getCachedUsdcBalance(string $wallet, callable $fetcher, int $ttl = 30): string {
$key = "usdc_balance_{$wallet}";
if (function_exists('apcu_fetch')) {
$cached = apcu_fetch($key, $success);
if ($success) {
return $cached;
}
}
$balance = $fetcher($wallet);
if (function_exists('apcu_store')) {
apcu_store($key, $balance, $ttl);
}
return $balance;
}
Checking Multiple Wallets Efficiently
If you need balances for many wallets (e.g. an admin dashboard), don't loop and fire one request per wallet sequentially — batch requests concurrently or use JSON‑RPC batching where your provider supports it. With plain cURL, use curl_multi to run requests in parallel:
function getUsdcBalancesForWallets(array $wallets, string $rpc, string $usdcMint): array {
$multiHandle = curl_multi_init();
$handles = [];
foreach ($wallets as $wallet) {
$ch = curl_init($rpc);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'getTokenAccountsByOwner',
'params' => [$wallet, ['programId' => 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'], ['encoding' => 'jsonParsed']],
]));
curl_multi_add_handle($multiHandle, $ch);
$handles[$wallet] = $ch;
}
$running = null;
do {
curl_multi_exec($multiHandle, $running);
curl_multi_select($multiHandle);
} while ($running > 0);
$results = [];
foreach ($handles as $wallet => $ch) {
$response = json_decode(curl_multi_getcontent($ch), true);
$balance = '0';
foreach ($response['result']['value'] ?? [] as $account) {
$info = $account['account']['data']['parsed']['info'];
if ($info['mint'] === $usdcMint) {
$balance = $info['tokenAmount']['uiAmountString'];
break;
}
}
$results[$wallet] = $balance;
curl_multi_remove_handle($multiHandle, $ch);
}
curl_multi_close($multiHandle);
return $results;
}
In Laravel, achieve the same result more cleanly with Http::pool(), which handles concurrency for you without manual curl_multi bookkeeping.
use Illuminate\Support\Facades\Http;
$responses = Http::pool(fn ($pool) => collect($wallets)->map(
fn ($wallet) => $pool->as($wallet)->post($rpcUrl, [
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'getTokenAccountsByOwner',
'params' => [$wallet, ['programId' => 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'], ['encoding' => 'jsonParsed']],
])
)->all());
foreach ($wallets as $wallet) {
$accounts = $responses[$wallet]->json('result.value', []);
// ...extract USDC balance as shown above
}
Common Mistakes
- Using getBalance() – returns only native SOL, not USDC.
- Assuming wallets store tokens directly – they own ATAs, balances live inside those accounts.
- Ignoring decimals – USDC uses 6 decimals; always use the provided precision.
- Assuming every wallet holds USDC – if a wallet never received USDC, the ATA may not exist; handle as zero.
- Using the wrong mint address – double-check the USDC mint address: EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v
- Trusting a token's name/symbol alone – scam tokens can name themselves "USDC" with a fake mint. Always compare the mint address, never the display name.
- Skipping caching in high-traffic apps – repeated uncached RPC calls for the same wallet will quickly hit rate limits; see the caching section above.
RPC Error Troubleshooting
Most "it's not working" reports trace back to one of these RPC-level issues rather than a bug in the balance‑parsing logic itself.
| HTTP / RPC code | Likely cause | Fix |
|---|---|---|
| 429 | Rate limit exceeded on public RPC | Add exponential backoff, or switch to a dedicated provider (NOWNodes,Alchemy etc.) |
| 403 | Blocked / restricted API key or IP | Check provider dashboard for key restrictions or usage caps |
| -32602 | Invalid params — usually a malformed wallet address | Validate the address is a valid base58 public key before sending the request |
| -32601 | Method not found | Provider may not support getTokenAccountsByOwner on that plan tier — check their docs |
| Empty value array | Wallet has no ATA for any SPL token, or none for USDC specifically | Not an error — treat as a zero USDC balance |
| cURL timeout | Public RPC under load, or network/firewall issue | Increase CURLOPT_TIMEOUT, retry, or switch providers |
Native SOL vs SPL Tokens (USDC)
| Feature | Native SOL | USDC (SPL) |
|---|---|---|
| Stored directly in wallet | Yes | No |
| Stored in ATA | No | Yes |
| Retrieved with getBalance() | Yes | No |
| Retrieved with getTokenAccountsByOwner() | No | Yes |
| Has a mint address | No | Yes |
USDC FAQ
- What is the USDC mint address on Solana?
- EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v
- How many decimals does USDC use?
- USDC on Solana uses 6 decimal places.
- Why is my USDC balance 0 even though I have USDC?
- The wallet may not have an ATA for USDC. If it never received USDC, the account doesn't exist – treat as zero.
- Who issues USDC on Solana?
- Circle, the same issuer as on Ethereum and other chains.
Conclusion
Fetching USDC balance on Solana is straightforward using getTokenAccountsByOwner. USDC is stored in an ATA, and with the correct mint address you can retrieve precise balances in PHP.
USDC is a pillar of the Solana DeFi ecosystem, offering speed and low cost. This guide provides a production‑ready approach to integrate USDC into your PHP applications.