Watching incoming payments on Solana, Ethereum, Stellar and TON: one task, four designs
Detecting a deposit looks like the same feature on every chain. It is four different designs: an account subscription, a log subscription with a blind spot, a stream that only says when to look, and a poll.
A payments backend that supports four chains usually starts with one abstraction: watch(address) → onDeposit(amount). The abstraction survives about as long as the first chain that does not fit it. This post builds the same feature — tell me when value arrives at an address I control — on Solana, Ethereum, Stellar and TON, and shows where each chain forces a different design. The point is not which chain is easier. It is which assumptions break when you move the same code from one to the next.
The task, precisely
"Received" has to be defined before any code is written, because each chain answers a slightly different question:
- What moved? The chain's native asset, or a token issued by a contract or program.
- Where does the balance live? On the address itself, or in a separate account the address owns.
- What event tells you? An account-state change, a contract log, a transaction record, or nothing at all until you ask.
- When is it safe to credit? Each chain has its own notion of final; our finality comparison maps them side by side.
With that, the four designs.
Solana: accountSubscribe on the destination
Solana's closest thing to "watch this address" is accountSubscribe over the JSON-RPC pub/sub WebSocket. You subscribe with a base-58 public key; whenever the runtime commits a change to that account — lamports move, data is rewritten, the owner changes — the server pushes an accountNotification with the new state. Between changes the stream is silent. On Triport it runs on /ws/sol and is available from the 7-day Free trial up.
Two properties of this subscription decide the design:
- It reports state, not transfers. The reference is explicit that notifications fire once per committed change of state, not once per transaction: several writes in one slot can collapse into a single notification carrying the final state. A balance delta between two notifications can therefore be the sum of several payments, or a payment minus a fee. If you need one record per payment, use the notification as a trigger and fetch the transactions that touched the account.
- Tokens live somewhere else. SOL sits on the wallet's own account, but SPL token balances are held in token accounts. Watching the wallet does not show a USDC deposit; you subscribe to the relevant token account (the reference lists "watching a token account for incoming deposits" as a typical use) and decode it with
jsonParsed.
A subscription lives only as long as the socket. After a reconnect you subscribe again, and whatever changed in between is not replayed. The gap-fill is a signature list for the account from your last processed signature forward (getSignaturesForAddress), followed by a fetch of each transaction. The wallet-watch tutorial builds exactly this loop as runnable code.
Ethereum: a logs subscription, and why native ETH has no log
On Ethereum the natural push is eth_subscribe with the logs type, filtered by contract address and topics. ERC-20 defines event Transfer(address indexed _from, address indexed _to, uint256 _value), which "MUST trigger when tokens are transferred"; its first topic is the event signature and, because both addresses are indexed, the third topic is the recipient, so "USDT to my address" becomes a filter on the token contract plus the signature topic plus your address, left-padded to 32 bytes, in the third position. On Triport's /ws/eth, logs requires the basic tier.
{"jsonrpc":"2.0","id":2,"method":"eth_subscribe","params":["logs",{
"address":"0xdAC17F958D2ee523a2206206994597C13D831ec7",
"topics":["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", null, "0x000000000000000000000000<your-address-without-0x>"]
}]}The blind spot is the chain's own asset. Logs exist only when contract code emits them, and moving ETH does not emit one by itself: a wallet-to-wallet transfer produces no log, and a contract can forward ETH without emitting anything. EIP-7708, which would make every ETH transfer emit a log, puts it directly: "Logs work for ERC-20 tokens, but they do not work for ETH." When we read it on 2026-09-22 its status was Review, so no network rule makes ETH transfers visible to a log filter today.
For native ETH you need a second mechanism:
- Top-level transfers: follow new blocks and check each transaction's
toandvalue.newHeadsgives you the block announcement (pro tier on/ws/eth), andeth_getBlockByNumberwith full transactions gives you the list. - Transfers made by contracts (a smart-contract wallet paying you, a withdrawal from a protocol) never appear as top-level transactions to your address. Only a trace shows them. On Triport the
trace_*family, such astrace_block, starts at the pro tier.
Gap-fill after a reconnect is two reads: eth_getLogs over the missed block range for tokens, and a block scan (plus traces if you need them) for ETH.
Stellar: an SSE trigger plus a paged read
Stellar is the only chain here with a Server-Sent Events stream, and it is the clearest example of a stream that tells you when to look but not what happened. On Triport, Horizon REST and SSE carry the status Limited: they are served through one upstream operator, best-effort, with no dedicated SLA. The Horizon SSE surface mounts exactly two channels, ledgers and transactions, and both must start at cursor=now; there is no payments channel and no replay from an older token. So the design is: subscribe to the transactions stream as a trigger, then page your account's payments over Horizon REST from your stored paging_token, and advance that cursor only after your own work commits. Check successful before crediting, and treat the 410 beyond_retention answer at the edge of the retention window as "history not kept here", never as "nothing happened". The full implementation, including why the cursor must move after the commit and not before, is in the guide to monitoring Stellar payments with Horizon SSE.
TON: no push channel, so poll getTransactions
TON is the chain where the push abstraction simply does not exist on this gateway. Triport's TON surface is a read-only JSON-RPC catalog of 13 methods; the getTransactions reference states that no transaction stream or WebSocket subscription is published for TON, and a WebSocket upgrade to /ws/ton returned 404 when we checked on 2026-09-22.
getTransactions returns an account's transactions newest-first. The cursor is a pair: to continue, you take both lt (logical time) and hash from the oldest transaction you received and send them together; neither is a cursor on its own. to_lt sets a lower logical-time boundary. That shapes a polling loop:
- Store the
ltandhashof the newest transaction you have fully processed. - On each tick, call
getTransactionsfor your address without a cursor to get the newest page. - If the page does not reach your stored transaction, keep paging older with the
lt/hashof the last item until it does (or pass your storedltasto_lt). - Process the new transactions oldest-first, then save the newest one as your cursor.
Because TON is sharded, "received" also means "referenced by the masterchain": TON's own payment-processing guide says a transaction becomes irreversible once it appears in a masterchain block, and advises verifying that inclusion rather than trusting the shardchain alone. On Triport, getTransactions is on the ton_read_rpc_heavy budget, 15 / 20 / 100 / 250 RPS on free / basic / pro / business (default, unmeasured). Polling one address every few seconds is a rounding error against that; polling ten thousand deposit addresses is not, and is the point at which block-level scanning becomes the better design.
Reconnect and gap-fill on each chain
The four designs share one skeleton: a trigger, a cursor, and a catch-up read that runs every time the trigger restarts.
| Trigger | Cursor you store | Catch-up read after a gap | |
|---|---|---|---|
| Solana | accountSubscribe notification | Last processed signature | getSignaturesForAddress from that signature, then getTransaction |
| Ethereum | logs notification (tokens); new block (ETH) | Last processed block number | eth_getLogs over the missed range; block scan and traces for ETH |
| Stellar | Transactions SSE frame | paging_token of the last processed payment | Horizon account payments from that token, ascending |
| TON | Your own timer | lt + hash of the last processed transaction | getTransactions paging until the stored pair |
Run the catch-up read on startup and after every reconnect, not only after a detected failure. Code that runs constantly stays correct; code that runs once a quarter does not. If a listener goes silent after a reconnect, why a WebSocket subscription goes silent covers resubscribing and backfilling the gap.
Comparison table
| Solana | Ethereum | Stellar | TON | |
|---|---|---|---|---|
| Push for this job on Triport | Yes, /ws/sol | Yes, /ws/eth | Yes, SSE (transactions channel; status Limited) | No |
| What the push carries | New account state | Contract logs | Transaction records | — |
| Native-asset blind spot | None for the account itself | Native ETH emits no log | None (payments are read over REST) | — |
| Token blind spot | Tokens live in token accounts | None for ERC-20 Transfer | None | Not covered by this post |
| Replay from the stream | No | No | No (cursor=now only) | — |
| Minimum tier for the push | free | basic (logs), pro (newHeads) | free (stellar_read_rpc_heavy) | — |
When this is the wrong approach
- Thousands of addresses. Per-address subscriptions and per-address polling both scale with the address count. At that size you follow blocks (or a filtered stream) and match addresses on your side.
- You need a full history on day one. These designs detect new deposits. Reconstructing an address's past is a different job with different limits; see why an empty address history is not proof of none.
- You credit on the trigger. None of the triggers here is a finality signal. Credit after your chain-specific confirmation rule, not when the notification arrives.
- Exactly-once from the stream. None of these streams replays or deduplicates for you; idempotency keyed on the transaction id is your code's job.
Sources
- Triport,
accountSubscribe: one account per subscription, state-change notifications that can collapse several writes, resubscribe after reconnect, token-account use case. Streaming overview:accountSubscribefree on/ws/sol. - Triport, Ethereum Pub/Sub
/ws/eth:logsbasic,newHeadspro, the USDTTransferfilter example. - EIP-20, token standard (
Transferevent) — https://eips.ethereum.org/EIPS/eip-20, read 2026-09-22. EIP-7708, "ETH transfers emit a log" — https://eips.ethereum.org/EIPS/eip-7708, read 2026-09-22 (status: Review). - Triport,
trace_blockon Ethereum: pro and business tiers. - Triport, Monitoring Stellar payments with Horizon SSE and the Horizon SSE reference: two channels,
cursor=now,paging_token,410 beyond_retention. - Triport,
getTransactionson TON: newest-first,lt+hashcursor pair,to_lt,ton_read_rpc_heavy15/20/100/250 RPS (default, unmeasured), no TON stream or WebSocket. - TON Docs, Payment processing overview — https://docs.ton.org/applications/payments/overview, read 2026-09-22.
- Production checks, 2026-09-22, on the EU and CA origins:
/ws/soland/ws/ethupgrades succeeded; Stellar SSE and Horizon account payments answered; TONgetTransactionsanswered with data;/ws/tonreturned 404.