This page describes a durable deposit scanner for exchange and custody systems. It covers successful native CTM transfers, ERC-20 transfers, idempotent crediting, backfill, retries, and finality handling through node interfaces.

Data sources

Deposit typePrimary sourceSuccess signalNotes
Native CTM transfereth_getBlockByNumber plus transaction receiptsReceipt status == 1, transaction to is monitored, value > 0Captures direct EVM native transfers. Internal value movements from contract execution require tracing or an indexer.
ERC-20 transfereth_getLogs for Transfer(address,address,uint256)Log exists in a successful receiptCredit by transactionHash, logIndex, token contract, recipient, and value.
Cosmos SDK transferREST/gRPC or CometBFT transaction queriesCosmos transaction code is successAdd only if your product supports Cosmos-native send paths.
For most exchange integrations, use EVM JSON-RPC as the primary source and keep a local scanner database with durable cursors.

Scanner workflow

  1. Maintain one cursor per chain and asset family: native CTM, each ERC-20 contract, and any Cosmos-native flow.
  2. Poll eth_blockNumber and compute a safe target height. c8ntinuum has 1-block finality under standard CometBFT; exchanges should use 60 confirmations plus an optional 1-2 block scanner buffer for RPC, log, or receipt lag.
  3. Scan bounded ranges. Keep range sizes below the current RPC provider’s documented limits; the eth_getLogs block range cap is 10000.
  4. For native CTM, fetch full blocks and inspect transactions whose to is a monitored deposit address and value is non-zero.
  5. Fetch the receipt for each candidate and require status == 1.
  6. For ERC-20 deposits, query eth_getLogs by token contract, Transfer topic, and indexed recipient topics when practical.
  7. Fetch receipts for log-bearing transactions when the RPC response does not already prove transaction success.
  8. Upsert deposits idempotently before crediting user balances.
  9. Advance the durable cursor only after all records in the scanned range are stored and reconciled.
  10. Reconcile credited deposits against balances and explorer or indexer data during operations.

Idempotency keys

Use deterministic keys so retries cannot double-credit.
Asset typeSuggested idempotency key
Native CTMchainId:txHash:native
ERC-20chainId:txHash:logIndex:tokenAddress
Cosmos SDK transferchainId:txHash:eventIndex:messageIndex
Store the raw block number, block hash, transaction hash, from address, to address, asset identifier, amount, receipt status, and credited account ID.

Finality and confirmations

c8ntinuum blocks have 1-block finality under standard CometBFT. Exchange integrations should require 60 confirmations before crediting deposits; at the expected 4s block time, that is about 240 seconds, or 4 minutes. Add an optional 1-2 block scanner buffer only to absorb RPC, log, or receipt lag. Even with BFT finality, scanners should handle:
  • RPC provider lag or inconsistent responses between endpoints.
  • Temporary null receipts immediately after block discovery.
  • Duplicate logs during retries.
  • Cursor rollback by operator action during backfills.
  • Contract-internal native transfers that are not visible without traces or an indexer.

JavaScript scanner excerpt

import { JsonRpcProvider, getAddress, id, zeroPadValue } from "ethers";

const provider = new JsonRpcProvider("https://public-evm-rpc.c8ntinuum.com");
const chainId = 2184;
const transferTopic = id("Transfer(address,address,uint256)");

const monitored = new Set([
  getAddress("0x1111111111111111111111111111111111111111"),
  getAddress("0x2222222222222222222222222222222222222222"),
]);

async function scanNativeBlock(height) {
  const block = await provider.send("eth_getBlockByNumber", [
    "0x" + height.toString(16),
    true,
  ]);

  for (const tx of block.transactions) {
    if (!tx.to || tx.value === "0x0") continue;
    const to = getAddress(tx.to);
    if (!monitored.has(to)) continue;

    const receipt = await provider.getTransactionReceipt(tx.hash);
    if (!receipt || receipt.status !== 1) continue;

    await upsertDeposit({
      idempotencyKey: `${chainId}:${tx.hash}:native`,
      blockNumber: Number(receipt.blockNumber),
      blockHash: receipt.blockHash,
      txHash: tx.hash,
      from: getAddress(tx.from),
      to,
      asset: "CTM",
      amountWei: BigInt(tx.value).toString(),
    });
  }
}

async function scanErc20(tokenAddress, fromBlock, toBlock) {
  for (const address of monitored) {
    const recipientTopic = zeroPadValue(address, 32);
    const logs = await provider.getLogs({
      address: tokenAddress,
      topics: [transferTopic, null, recipientTopic],
      fromBlock,
      toBlock,
    });

    for (const log of logs) {
      const receipt = await provider.getTransactionReceipt(log.transactionHash);
      if (!receipt || receipt.status !== 1) continue;

      await upsertDeposit({
        idempotencyKey: `${chainId}:${log.transactionHash}:${log.index}:${tokenAddress}`,
        blockNumber: log.blockNumber,
        blockHash: log.blockHash,
        txHash: log.transactionHash,
        token: getAddress(tokenAddress),
        to: address,
        amountRaw: BigInt(log.data).toString(),
      });
    }
  }
}

async function upsertDeposit(deposit) {
  // Store first, then credit from a separate idempotent ledger step.
  console.log(deposit);
}

Java scanner excerpt

import java.math.BigInteger;
import java.util.List;
import java.util.Set;
import org.web3j.crypto.Keys;
import org.web3j.protocol.Web3j;
import org.web3j.protocol.core.DefaultBlockParameterNumber;
import org.web3j.protocol.core.methods.request.EthFilter;
import org.web3j.protocol.core.methods.response.EthBlock;
import org.web3j.protocol.core.methods.response.EthGetTransactionReceipt;
import org.web3j.protocol.core.methods.response.EthLog;
import org.web3j.protocol.http.HttpService;
import org.web3j.utils.Numeric;

Web3j web3j = Web3j.build(new HttpService("https://public-evm-rpc.c8ntinuum.com"));
long chainId = 2184L;
String transferTopic = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
Set<String> monitored = Set.of(Keys.toChecksumAddress("0x1111111111111111111111111111111111111111"));

void scanNativeBlock(BigInteger height) throws Exception {
  EthBlock block = web3j
      .ethGetBlockByNumber(new DefaultBlockParameterNumber(height), true)
      .send();

  for (EthBlock.TransactionResult<?> result : block.getBlock().getTransactions()) {
    EthBlock.TransactionObject tx = (EthBlock.TransactionObject) result.get();
    if (tx.getTo() == null || tx.getValue().signum() == 0) continue;

    String to = Keys.toChecksumAddress(tx.getTo());
    if (!monitored.contains(to)) continue;

    EthGetTransactionReceipt receiptResponse =
        web3j.ethGetTransactionReceipt(tx.getHash()).send();
    if (receiptResponse.getTransactionReceipt().isEmpty()) continue;
    var receipt = receiptResponse.getTransactionReceipt().get();
    if (!receipt.isStatusOK()) continue;

    upsertDeposit(chainId + ":" + tx.getHash() + ":native", tx.getHash(), to, tx.getValue());
  }
}

void scanErc20(String token, BigInteger fromBlock, BigInteger toBlock) throws Exception {
  for (String address : monitored) {
    String topicTo = "0x" + Numeric.cleanHexPrefix(address).toLowerCase();
    topicTo = "0x" + "0".repeat(24) + Numeric.cleanHexPrefix(topicTo);

    EthFilter filter = new EthFilter(
        new DefaultBlockParameterNumber(fromBlock),
        new DefaultBlockParameterNumber(toBlock),
        token);
    filter.addSingleTopic(transferTopic);
    filter.addOptionalTopics(null);
    filter.addSingleTopic(topicTo);

    EthLog logs = web3j.ethGetLogs(filter).send();
    for (EthLog.LogResult<?> item : logs.getLogs()) {
      EthLog.LogObject log = (EthLog.LogObject) item.get();
      EthGetTransactionReceipt receiptResponse =
          web3j.ethGetTransactionReceipt(log.getTransactionHash()).send();
      if (receiptResponse.getTransactionReceipt().isEmpty()) continue;
      if (!receiptResponse.getTransactionReceipt().get().isStatusOK()) continue;

      String key = chainId + ":" + log.getTransactionHash() + ":" + log.getLogIndex() + ":" + token;
      upsertDeposit(key, log.getTransactionHash(), address, Numeric.toBigInt(log.getData()));
    }
  }
}

void upsertDeposit(String idempotencyKey, String txHash, String to, BigInteger amount) {
  System.out.println(idempotencyKey + " " + txHash + " " + to + " " + amount);
}

Go scanner excerpt

package scanner

import (
	"context"
	"fmt"
	"math/big"

	"github.com/ethereum/go-ethereum"
	"github.com/ethereum/go-ethereum/common"
	"github.com/ethereum/go-ethereum/core/types"
	"github.com/ethereum/go-ethereum/ethclient"
	"github.com/ethereum/go-ethereum/crypto"
)

var transferTopic = crypto.Keccak256Hash([]byte("Transfer(address,address,uint256)"))

func ScanNativeBlock(ctx context.Context, client *ethclient.Client, chainID int64, height uint64, monitored map[common.Address]bool) error {
	block, err := client.BlockByNumber(ctx, new(big.Int).SetUint64(height))
	if err != nil {
		return err
	}

	for _, tx := range block.Transactions() {
		if tx.To() == nil || tx.Value().Sign() == 0 || !monitored[*tx.To()] {
			continue
		}

		receipt, err := client.TransactionReceipt(ctx, tx.Hash())
		if err != nil || receipt.Status != types.ReceiptStatusSuccessful {
			continue
		}

		key := fmt.Sprintf("%d:%s:native", chainID, tx.Hash())
		if err := UpsertDeposit(key, tx.Hash(), *tx.To(), tx.Value()); err != nil {
			return err
		}
	}
	return nil
}

func ScanERC20(ctx context.Context, client *ethclient.Client, chainID int64, token common.Address, fromBlock, toBlock uint64, recipients []common.Address) error {
	for _, recipient := range recipients {
		query := ethereum.FilterQuery{
			FromBlock: new(big.Int).SetUint64(fromBlock),
			ToBlock:   new(big.Int).SetUint64(toBlock),
			Addresses: []common.Address{token},
			Topics: [][]common.Hash{
				{transferTopic},
				nil,
				{common.BytesToHash(recipient.Bytes())},
			},
		}

		logs, err := client.FilterLogs(ctx, query)
		if err != nil {
			return err
		}

		for _, log := range logs {
			receipt, err := client.TransactionReceipt(ctx, log.TxHash)
			if err != nil || receipt.Status != types.ReceiptStatusSuccessful {
				continue
			}
			amount := new(big.Int).SetBytes(log.Data)
			key := fmt.Sprintf("%d:%s:%d:%s", chainID, log.TxHash, log.Index, token)
			if err := UpsertDeposit(key, log.TxHash, recipient, amount); err != nil {
				return err
			}
		}
	}
	return nil
}

func UpsertDeposit(key string, txHash common.Hash, to common.Address, amount *big.Int) error {
	return nil
}

Rust scanner excerpt

use alloy::primitives::{keccak256, Address, B256, U256};
use alloy::providers::{Provider, ProviderBuilder};
use alloy::rpc::types::{BlockNumberOrTag, Filter};
use std::collections::HashSet;

const CHAIN_ID: u64 = 2184;

async fn scan_native_block<P: Provider>(
    provider: &P,
    height: u64,
    monitored: &HashSet<Address>,
) -> eyre::Result<()> {
    let block = provider
        .get_block_by_number(BlockNumberOrTag::Number(height), true.into())
        .await?
        .ok_or_else(|| eyre::eyre!("missing block"))?;

    for tx in block.transactions.into_transactions() {
        let Some(to) = tx.to else { continue };
        if tx.value == U256::ZERO || !monitored.contains(&to) {
            continue;
        }

        let Some(receipt) = provider.get_transaction_receipt(tx.hash).await? else {
            continue;
        };
        if !receipt.status() {
            continue;
        }

        let key = format!("{CHAIN_ID}:{}:native", tx.hash);
        upsert_deposit(&key, tx.hash, to, tx.value).await?;
    }

    Ok(())
}

async fn scan_erc20<P: Provider>(
    provider: &P,
    token: Address,
    from_block: u64,
    to_block: u64,
    recipients: &[Address],
) -> eyre::Result<()> {
    let transfer_topic: B256 = keccak256("Transfer(address,address,uint256)");

    for recipient in recipients {
        let filter = Filter::new()
            .address(token)
            .event_signature(transfer_topic)
            .topic2(B256::left_padding_from(recipient.as_slice()))
            .from_block(from_block)
            .to_block(to_block);

        for log in provider.get_logs(&filter).await? {
            let Some(receipt) = provider.get_transaction_receipt(log.transaction_hash.unwrap()).await? else {
                continue;
            };
            if !receipt.status() {
                continue;
            }

            let amount = U256::from_be_slice(&log.data().data);
            let key = format!(
                "{CHAIN_ID}:{}:{}:{}",
                log.transaction_hash.unwrap(),
                log.log_index.unwrap_or_default(),
                token
            );
            upsert_deposit(&key, log.transaction_hash.unwrap(), *recipient, amount).await?;
        }
    }

    Ok(())
}

async fn upsert_deposit(key: &str, tx_hash: B256, to: Address, amount: U256) -> eyre::Result<()> {
    println!("{key} {tx_hash} {to} {amount}");
    Ok(())
}

#[tokio::main]
async fn main() -> eyre::Result<()> {
    let provider = ProviderBuilder::new().on_http(
        "https://public-evm-rpc.c8ntinuum.com".parse()?,
    );
    let monitored = HashSet::new();
    scan_native_block(&provider, 123456, &monitored).await?;
    Ok(())
}

Reconciliation checklist

  • Compare scanner height to eth_blockNumber and CometBFT /status.
  • Verify every credited transaction has a successful receipt.
  • Re-run the scanner over already-credited ranges and confirm idempotency prevents duplicate credits.
  • Reconcile hot-wallet, cold-wallet, and deposit-address balances with eth_getBalance and token balanceOf.
  • Alert on cursor stalls, receipt fetch failures, RPC 429 responses, unusually large block lag, and unmatched pending credits.