What is an Associated Token Account (ATA)?
USDT 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 USDT 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 USDT balance.
1JSON‑RPC Request
{
"jsonrpc": "2.0",
"id": 1,
"method": "getTokenAccountsByOwner",
"params": [
"YOUR_WALLET_ADDRESS",
{ "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" },
{ "encoding": "jsonParsed" }
]
}
Understanding the Response
A typical USDT token account object:
{
"mint": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB",
"owner": "YourWalletAddress",
"tokenAmount": {
"amount": "100000000",
"decimals": 6,
"uiAmount": 100.000000,
"uiAmountString": "100.000000"
}
}
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.
- 100000000 raw amount (smallest unit)
- 6 decimal precision (USDT uses 6)
- 100.000000 human‑readable (uiAmountString)
USDT on Solana: The Bigger Picture
USDT (Tether) is the largest stablecoin by market capitalization and trading volume globally, issued by Tether Limited and backed by a reserve of cash, cash equivalents, and short‑term instruments. On Solana it exists as a native SPL token deployed by Tether, not a wrapped or bridged representation, so a balance you read from the mint below is a first‑party Tether liability rather than a synthetic wrapper.
The USDT mint address is Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB and it uses 6 decimal places.
Why USDT Moved to Solana
USDT's largest historical footprint has been on Ethereum and Tron, where it built enormous liquidity for exchange settlement and remittances. Tether deployed USDT on Solana to capture the same low‑fee, high‑throughput advantage that pulled other stablecoin activity onto the chain: transfers that settle in under a second for a fraction of a cent, instead of the multi‑dollar gas costs and minutes‑long confirmation times typical of Ethereum during congestion. That made Solana USDT attractive specifically for traders moving size quickly and for exchanges that need fast, cheap deposit/withdrawal rails.
Where USDT Shows Up in the Solana Stack
USDT is one of the two dominant quote assets (alongside USDC) on Jupiter, Raydium, and Orca, and is frequently used for cross‑exchange arbitrage given its dominance on centralized venues too.
Lending markets like Kamino and Solend/save list USDT as both a collateral and borrow asset, though pool depth is usually smaller than USDC's on Solana specifically.
Because USDT already dominates spot trading pairs on major centralized exchanges globally, Solana USDT is commonly used for fast deposits/withdrawals between exchanges and on-chain wallets.
USDT has long been the preferred dollar-stablecoin in many emerging markets; its Solana deployment extends that usage to fast, low-cost transfers where legacy USDT rails (Tron, Ethereum) are slower or costlier.
USDT vs USDC on Solana
USDT and USDC are the two dominant dollar stablecoins on Solana, and they overlap heavily but aren't interchangeable at the protocol level. USDC generally has an edge in DeFi‑native integrations, deeper collateral pools on Solana‑first lending protocols, and Circle's CCTP for native cross-chain transfers. USDT generally has an edge in raw global trading volume and centralized exchange liquidity, which makes it common for traders bridging between CEX and on-chain activity. For a developer, the practical difference is just the mint address and decimals — the RPC method for reading a balance, shown below, is identical for both tokens.
As with any SPL token, never assume a token is "real" USDT based on its name or symbol alone — always validate against the canonical mint address above, since scam tokens can freely reuse a display name.
PHP Code – Get USDT Balance
This script fetches the USDT balance for a given wallet address.
$usdtMint = "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB";
$rpc = "https://api.mainnet-beta.solana.com";
$wallet = "YourWalletAddress"; // Replace with actual wallet
$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']);
}
$usdtBalance = '0';
foreach ($data['result']['value'] as $account) {
$info = $account['account']['data']['parsed']['info'];
if ($info['mint'] === $usdtMint) {
$usdtBalance = $info['tokenAmount']['uiAmountString'];
break;
}
}
echo "USDT balance: $usdtBalance\n";
Error Handling & Rate Limits
Public RPC endpoints are rate‑limited. Always handle HTTP errors and implement exponential backoff for production use.
function getUsdtBalance(string $wallet, string $rpc, string $usdtMint, 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'] === $usdtMint) {
$balance = $info['tokenAmount']['uiAmountString'];
break;
}
}
return ['balance' => $balance];
}
}
return ['error' => 'Max retries exceeded'];
}
// Usage
$result = getUsdtBalance(
'YourWalletAddress',
'https://api.mainnet-beta.solana.com',
'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB'
);
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'),
'usdt_mint' => env('SOLANA_USDT_MINT', 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB'),
],
];
Then in .env:
SOLANA_RPC_URL=https://your-dedicated-rpc-provider.com/YOUR_API_KEY
SOLANA_USDT_MINT=Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB
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/UsdtBalanceService.php
namespace App\Services\Solana;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;
class UsdtBalanceService
{
private const TOKEN_PROGRAM_ID = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA';
public function __construct(
private readonly string $rpcUrl = '',
private readonly string $usdtMint = '',
) {
$this->rpcUrl ?: $this->rpcUrl = config('services.solana.rpc_url');
$this->usdtMint ?: $this->usdtMint = config('services.solana.usdt_mint');
}
public function getBalance(string $wallet, bool $useCache = true): string
{
if ($useCache) {
return Cache::remember(
"usdt_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->usdtMint) {
return $info['tokenAmount']['uiAmountString'];
}
}
return '0';
}
}
3. Bind it in a service provider
// app/Providers/AppServiceProvider.php
use App\Services\Solana\UsdtBalanceService;
public function register(): void
{
$this->app->singleton(UsdtBalanceService::class, function () {
return new UsdtBalanceService(
config('services.solana.rpc_url'),
config('services.solana.usdt_mint'),
);
});
}
4. Use it in a controller
// app/Http/Controllers/WalletController.php
namespace App\Http\Controllers;
use App\Services\Solana\UsdtBalanceService;
use Illuminate\Http\Request;
class WalletController extends Controller
{
public function balance(Request $request, UsdtBalanceService $usdt)
{
$request->validate(['wallet' => 'required|string|min:32|max:44']);
$balance = $usdt->getBalance($request->string('wallet'));
return response()->json(['wallet' => $request->wallet, 'usdt_balance' => $balance]);
}
}
5. Optional: Artisan command
Handy for cron jobs, ops scripts, or quick CLI checks without spinning up a route.
// app/Console/Commands/CheckUsdtBalance.php
namespace App\Console\Commands;
use App\Services\Solana\UsdtBalanceService;
use Illuminate\Console\Command;
class CheckUsdtBalance extends Command
{
protected $signature = 'solana:usdt-balance {wallet}';
protected $description = 'Fetch the USDT balance for a given Solana wallet address';
public function handle(UsdtBalanceService $usdt): int
{
$balance = $usdt->getBalance($this->argument('wallet'), useCache: false);
$this->info("USDT 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 getCachedUsdtBalance(string $wallet, callable $fetcher, int $ttl = 30): string {
$key = "usdt_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 getUsdtBalancesForWallets(array $wallets, string $rpc, string $usdtMint): 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'] === $usdtMint) {
$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 USDT balance as shown above
}
Common Mistakes
- Using getBalance() – returns only native SOL, not USDT.
- Assuming wallets store tokens directly – they own ATAs, balances live inside those accounts.
- Ignoring decimals – USDT uses 6 decimals; always use the provided precision.
- Assuming every wallet holds USDT – if a wallet never received USDT, the ATA may not exist; handle as zero.
- Using the wrong mint address – double-check the USDT mint address: Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB
- Trusting a token's name/symbol alone – scam tokens can name themselves "USDT" 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, QuickNode, 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 USDT specifically | Not an error — treat as a zero USDT balance |
| cURL timeout | Public RPC under load, or network/firewall issue | Increase CURLOPT_TIMEOUT, retry, or switch providers |
Native SOL vs SPL Tokens (USDT)
| Feature | Native SOL | USDT (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 |
USDT FAQ
- What is the USDT mint address on Solana?
- Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB
- How many decimals does USDT use?
- USDT on Solana uses 6 decimal places.
- Why is my USDT balance 0 even though I have USDT?
- The wallet may not have an ATA for USDT. If it never received USDT, the account doesn't exist – treat as zero.
- Who issues USDT on Solana?
- Tether Limited, the same issuer as on Ethereum, Tron, and other chains.
Conclusion
Fetching USDT balance on Solana is straightforward using getTokenAccountsByOwner. USDT is stored in an ATA, and with the correct mint address you can retrieve precise balances in PHP.
USDT is a pillar of the Solana DeFi ecosystem, offering deep liquidity and fast settlement. This guide provides a production‑ready approach to integrate USDT into your PHP applications.