Menu

© 2026 Crypto Daves

solana Aug 05, 2026 13 min read 38 views

Check USDT Balance on Solana Using PHP & Laravel

Check USDT balance on Solana using PHP & Laravel with the getTokenAccountsByOwner RPC method. This complete guide covers Associated Token Accounts, raw vs displayed balance, production-ready PHP code, Laravel service implementation, caching strategies, multi-wallet batching, and common RPC errors. Perfect for developers building trading bots, payment apps, or DeFi dashboards that need reliable USDT SPL token balance checks.

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.

Wallet USDT ATA (and other tokens)

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
Public RPC (rate‑limited)
https://api.mainnet-beta.solana.com

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.

SPL Token Program ID: TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA

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"
  }
}
mint amount decimals uiAmountString

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)
Recommendation: always use uiAmountString for display – it's a string and preserves precision.

USDT on Solana: The Bigger Picture

Mint: Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB decimals: 6 issuer: Tether Limited

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

DEX liquidity & arbitrage

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 & margin

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.

Exchange settlement

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.

Remittances & emerging markets

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.

Developer takeaway: USDT's deep cross-exchange liquidity makes it a common settlement asset for trading bots, arbitrage systems, and payment apps — reliably reading its on-chain balance, as covered in this guide, is usually step one for any of those.

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;
    }
}
Run with: php artisan solana:usdt-balance YourWalletAddress

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 codeLikely causeFix
429Rate limit exceeded on public RPCAdd exponential backoff, or switch to a dedicated provider (NOWNodes, QuickNode, etc.)
403Blocked / restricted API key or IPCheck provider dashboard for key restrictions or usage caps
-32602Invalid params — usually a malformed wallet addressValidate the address is a valid base58 public key before sending the request
-32601Method not foundProvider may not support getTokenAccountsByOwner on that plan tier — check their docs
Empty value arrayWallet has no ATA for any SPL token, or none for USDT specificallyNot an error — treat as a zero USDT balance
cURL timeoutPublic RPC under load, or network/firewall issueIncrease CURLOPT_TIMEOUT, retry, or switch providers
The public api.mainnet-beta.solana.com endpoint is not meant for production traffic — its rate limits are tight and can change without notice. For anything user‑facing, use a dedicated RPC provider. With that said, feel free to check out NOWNodes which is a multichain node provider. The list of networks updates continuously based on user demand. All blockchains can be deployed on dedicated servers.

Native SOL vs SPL Tokens (USDT)

FeatureNative SOLUSDT (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.

Next steps: Check USDT Balance on Tron Using PHP, Subscribe to our telegram channel @cryptodavescom for regular updates

Comments (0)

Leave a comment