Skip to content

Chain watch ​

@openrampkit/adapter-chain-watch gives standing deposit addresses for the same token on the same chain. The user sends the destination token on the destination chain, so no bridge is needed: the deposit address is the destination address itself. The adapter watches the chain and reports each transfer in as one deposit. The server makes one session per deposit. No provider account is needed: only JSON-RPC URLs.

ts
import { chainWatch } from '@openrampkit/adapter-chain-watch'
import { relay } from '@openrampkit/adapter-relay'

createOpenRamp({
  // ...
  adapters: [
    relay({ apiKey: process.env.RELAY_API_KEY }), // standing addresses that convert (another token or chain)
    chainWatch({
      rpcUrls: { 'eip155:8453': process.env.BASE_RPC_URL! },
      confirmations: { 'eip155:1': 20 },
      requestsPerSecond: 10,
      minDeposit: { 'eip155:8453:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913': '1' }, // 1 USDC
    }),
  ],
})

const address = await openramp.depositAddresses.get({
  userId: user.id,
  origin: { chain: 'eip155:8453', token: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913' }, // USDC on Base
  // A deposit address that your backend made for this one user (not the user's own wallet)
  destination: { type: 'crypto', chain: 'eip155:8453', token: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', address: user.depositWallet },
  dedicatedDestination: true, // required for the same token on the same chain
})
// address.address === user.depositWallet, address.adapterId === 'chain-watch'

Read Security before you use it: the address is the destination, so the server credits every transfer into it to the user.

The adapter has standingRoutes: ['same'], so the server uses it only for a route with the same token on the same chain. A route that converts goes to another adapter, such as Relay. The adapter has no session legs: it is never part of a plan, a quote or a payment method. Its transfer leg is only for the deposit sessions of its addresses.

Options ​

The options of the shared chain watcher (createChainWatch in @openrampkit/adapter), plus minDeposit.

OptionTypeDefaultDescription
rpcUrlsRecord<string, string>noneJSON-RPC URL per CAIP-2 chain. Set your own in production.
defaultRpcUrlsRecord<string, string>public RPCs for Ethereum, Base, Arbitrum, Optimism, Polygon, Tempo, Solana (mainnet and devnet), Arbitrum Sepolia, Robinhood Chain TestnetThe URLs of the chains that rpcUrls does not name. Pass {} to watch only the chains in rpcUrls. The public RPCs have rate limits.
confirmationsnumber | Record<string, number>12EVM: the blocks on top of a log's block before the log counts. One number, or one per chain (other chains get 12).
solanaCommitment'confirmed' | 'finalized''finalized'The commitment of Solana reads
logBlockRangenumber2000The most blocks in one eth_getLogs call. At most 10000. Set it to the limit of your RPC.
maxLogPagesnumber5The most eth_getLogs pages in one poll (and Solana signature pages of 100). The next poll goes on from where this one stopped.
requestsPerSecondnumberno limitThe most RPC requests per second to one RPC URL, from this adapter instance. Calls wait for their turn.
timeoutMsnumber8000The timeout of one RPC call
minDepositRecord<string, string>any amount above 0The smallest transfer that counts, in token units, per {chain}:{token} (lower-case token address on EVM chains, native for SOL). Smaller transfers are ignored.

What it watches ​

ChainTokenHow
EVMERC-20The token's Transfer logs to the address (eth_getLogs), from the block after the address was made
EVMnativeNot supported: a native transfer has no log. depositAddresses.get throws 400.
SolanaSPLThe signatures of the address's token accounts of the mint (getSignaturesForAddress), then each transaction (getTransaction). The amount is the net change of the owner's token balance.
SolanaSOLThe signatures of the address, then each transaction. The amount is the net change of the address's lamports.

A transaction that sends more out of the address than in (a net change of 0 or less) is not a deposit.

Money safety ​

  • Confirmations and re-orgs. An EVM log counts only when its block has confirmations blocks on top. Solana reads use finalized by default. Before the leg succeeds, status() reads the receipt again: the transfer of the same token, recipient and amount must still be there, with its confirmations. A transaction that moved to a new block waits for its confirmations again. A transaction that is gone stays processing for one hour (a node can lag), then fails. A reverted transaction or another amount fails the leg at once.
  • No double counting. Each EVM log (chain, transaction hash, log index) and each Solana signature is claimed once (claimOnce, key txUsedKey: txused:...). The server keeps all txused: keys in one namespace for all adapters, so a transfer that another adapter counted (for example a Relay same-chain payment or transfer leg) is not a deposit here, and a transfer that chain-watch counted cannot complete a Relay payment. An EVM log is also skipped when a wallet payment claimed its whole transaction. The deposit id is the same on every poll ({chain}:{tx}:{logIndex}, or {chain}:{signature}), so the server makes one session per deposit, also after a restart.
  • One user per address. The first user that gets a standing address for a destination address (with its chain and token) owns it. Another user gets 409 CONFLICT. The server keeps this binding too (for any adapter that serves same-chain routes).
  • Amounts. A transfer below minDeposit is not a deposit. The session's input is the amount on chain, and status() checks that the chain still shows it. The session output is the same amount. The amount always uses the token's real decimals (known tokens, ERC-20 decimals(), or the Solana mint), never destination.decimals from the session input.
  • RPC replies. The adapter does not trust the eth_getLogs filter. A log must be a Transfer of the watched token to the address, in the block range that it asked for. Other logs are skipped with a warning. The adapter still trusts your RPC for the chain state: use an RPC that you trust (your own node or a paid provider), not a public one.
  • No lost deposits. A poll lists the deposits of the last 7 days that are at or after the server's since again, so a deposit that the server could not save comes back on the next poll.

Security ​

The address is the destination itself, so every transfer of the token into it counts as a deposit for its user, whoever sent it. If a user could choose the address, the user could choose another person's wallet, and your app would credit the user for every transfer that anyone sends to that wallet. If the address also gets other payments, each of them would count twice.

So the server has these rules:

  • Only your backend asks for it. depositAddresses.get needs dedicatedDestination: true for the same token on the same chain, else it throws 400. Set it only for an address that your backend made for this one user (for example one deposit wallet per user that your app controls). Never pass a wallet address that the user typed or connected, and never a shared address such as your treasury.
  • Never from the widget. POST /sessions/:id/deposit-address refuses the same token on the same chain (400). The browser cannot make the server watch an address, and it cannot take an address from another user.
  • One user per address. A second user for the same chain, token and address gets 409 CONFLICT.
  • Nothing else delivers to a watched address. After a same-chain address exists, a deposit session to the same chain, token and address throws 409, and so does a standing address of another route (one that converts) to it. A same-chain address for a destination that a converting standing address already delivers to also throws 409. Their deliveries would arrive as transfers into the address and count twice.
  • One claim per transfer for all adapters (see Money safety).
  • Each decision is one atomic claim, so two parties that act at the same time cannot both win:
    • A log and a wallet payment of the same transaction. A log first claims its transaction for logs (claimTransferLog in @openrampkit/adapter: the key txused:{chain}:{tx} gets the value transfer-logs), then the log itself (txused:{chain}:{tx}:{logIndex}). A same-chain wallet payment claims the same transaction key with its own ref. Exactly one of them gets the key. Before, the log and the wallet payment each checked the other's key and then claimed their own key, so a poll and a wallet payment at the same time could both count one transfer. Other logs of the same transaction (to other addresses) still count for their own owners.
    • A Solana transfer and a Solana wallet payment claim the same signature key.
    • A watched destination and a converting address to it. The server binds the destination with one key (sdwatch:...), which holds either the watching user or the converting kind. The first claim wins and the other gets 409, also when the two requests arrive at the same time. The loser leaves no mark, so the winner can get its address again.
    • Two users and one destination. One claim; the other user gets 409.
    • Two polls of one address (two sweeps, or a sweep and a get). The claim owner is the address, so both polls list the same deposits with the same ids, and the server makes one session per deposit id with an atomic claim (standingdep:...). The scan position can go back after two polls at the same time; the next poll then reads those blocks again and finds the same deposits.
  • Atomic on memory, Redis and Durable Object stores. These stores have putIfAbsent. Workers KV has no atomic operation: there a claim writes, then reads back, which catches most races but not all. Use the Durable Object store (or Redis) when chain-watch and wallet payments run on the same chain, or when two sweeps can run at the same time.

What the server cannot check, so you must:

  • Use a dedicated address per user. Do not use the user's main wallet: the user may also buy crypto to it with an onramp of another app, get a payout to it, or use it for other payments. Each of those transfers would be credited again.
  • Sessions made before the watch. The server refuses new deposit sessions to a watched address, but it does not look for open sessions that were made before. Do not watch an address that has open deposit sessions, or wait until they end.
  • Onramp deliveries. Onramp providers do not claim their delivery transactions, so the claim namespace does not stop them. The rules above stop new sessions to a watched address; a dedicated address stops the rest.

Limits ​

  • Polling only: deposits are found by sweep(). An address that was idle for a long time catches up logBlockRange × maxLogPages blocks per poll.
  • Solana: at most maxLogPages × 100 new signatures per account and poll. With more, the adapter logs a warning and skips the older ones.
  • A deposit made before the address existed is not counted.
  • Dust: with no minDeposit, any transfer above 0 makes a session (and webhooks). Set minDeposit so that spam transfers cost the sender more than they cost you.

MIT licensed