TriportRPC

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:

  1. 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.
  2. 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 to and value. newHeads gives you the block announcement (pro tier on /ws/eth), and eth_getBlockByNumber with 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 as trace_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:

  1. Store the lt and hash of the newest transaction you have fully processed.
  2. On each tick, call getTransactions for your address without a cursor to get the newest page.
  3. If the page does not reach your stored transaction, keep paging older with the lt/hash of the last item until it does (or pass your stored lt as to_lt).
  4. 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.

TriggerCursor you storeCatch-up read after a gap
SolanaaccountSubscribe notificationLast processed signaturegetSignaturesForAddress from that signature, then getTransaction
Ethereumlogs notification (tokens); new block (ETH)Last processed block numbereth_getLogs over the missed range; block scan and traces for ETH
StellarTransactions SSE framepaging_token of the last processed paymentHorizon account payments from that token, ascending
TONYour own timerlt + hash of the last processed transactiongetTransactions 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

SolanaEthereumStellarTON
Push for this job on TriportYes, /ws/solYes, /ws/ethYes, SSE (transactions channel; status Limited)No
What the push carriesNew account stateContract logsTransaction records
Native-asset blind spotNone for the account itselfNative ETH emits no logNone (payments are read over REST)
Token blind spotTokens live in token accountsNone for ERC-20 TransferNoneNot covered by this post
Replay from the streamNoNoNo (cursor=now only)
Minimum tier for the pushfreebasic (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

  1. Triport, accountSubscribe: one account per subscription, state-change notifications that can collapse several writes, resubscribe after reconnect, token-account use case. Streaming overview: accountSubscribe free on /ws/sol.
  2. Triport, Ethereum Pub/Sub /ws/eth: logs basic, newHeads pro, the USDT Transfer filter example.
  3. EIP-20, token standard (Transfer event) — 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).
  4. Triport, trace_block on Ethereum: pro and business tiers.
  5. Triport, Monitoring Stellar payments with Horizon SSE and the Horizon SSE reference: two channels, cursor=now, paging_token, 410 beyond_retention.
  6. Triport, getTransactions on TON: newest-first, lt + hash cursor pair, to_lt, ton_read_rpc_heavy 15/20/100/250 RPS (default, unmeasured), no TON stream or WebSocket.
  7. TON Docs, Payment processing overview — https://docs.ton.org/applications/payments/overview, read 2026-09-22.
  8. Production checks, 2026-09-22, on the EU and CA origins: /ws/sol and /ws/eth upgrades succeeded; Stellar SSE and Horizon account payments answered; TON getTransactions answered with data; /ws/ton returned 404.