Build an application

Discover testnet configuration, read a token, and simulate a transfer with viem.

What you will build

This example connects to Real Testnet, checks the chain ID, reads a listed permissioned token, checks recipient eligibility, and simulates an ordinary transfer. It uses no private key and sends no transaction.

Use Node.js 24 and viem 2.52.2. In an empty project, run:

npm init -y
npm install viem@2.52.2

Save the following as read-and-preflight.mjs, or download the example. The default sender and recipient are deliberately unprepared addresses so you can observe a refusal before using real test wallets.

Read and simulate

read-and-preflight.mjs
import {
  BaseError, ContractFunctionRevertedError, createPublicClient,
  defineChain, getAddress, http, parseAbi, parseUnits,
} from 'viem';

const response = await fetch('https://testnet.rwa-platform.real.finance/v1/config');
if (!response.ok) throw new Error(`Configuration request failed: ${response.status}`);
const config = await response.json();
if (config.chainId !== 117711) throw new Error('Unexpected configuration chain');
const chain = defineChain({
  id: 117711, name: 'Real Testnet',
  nativeCurrency: { name: 'ASSET', symbol: 'ASSET', decimals: 18 },
  rpcUrls: { default: { http: [config.rpcUrl] } },
});
const client = createPublicClient({ chain, transport: http() });
if (await client.getChainId() !== chain.id) throw new Error('RPC chain mismatch');

// A listed test token; replace these public addresses for your own simulation.
const token = getAddress(process.argv[2] ?? '0xe157Af54109e7bc05ed868Cd859cc01908Fd1DdD');
const sender = getAddress(process.argv[3] ?? '0x0000000000000000000000000000000000000001');
const recipient = getAddress(process.argv[4] ?? '0x0000000000000000000000000000000000000002');
const blockNumber = await client.getBlockNumber();
if (!await client.getCode({ address: token, blockNumber })) throw new Error('No token code');
const tokenAbi = parseAbi([
  'function name() view returns (string)',
  'function decimals() view returns (uint8)',
  'function balanceOf(address) view returns (uint256)',
  'function identityRegistry() view returns (address)',
  'function paused() view returns (bool)',
  'function transfer(address,uint256) returns (bool)',
]);
const read = (functionName, args = []) => client.readContract({ address: token, abi: tokenAbi, functionName, args, blockNumber });
// Sequential reads also work with conservative public RPC rate limits.
const name = await read('name');
const decimals = await read('decimals');
const balance = await read('balanceOf', [sender]);
const paused = await read('paused');
const registry = await read('identityRegistry');
const recipientVerified = await client.readContract({
  address: registry, abi: parseAbi(['function isVerified(address) view returns (bool)']),
  functionName: 'isVerified', args: [recipient], blockNumber,
});
const amount = parseUnits(process.argv[5] ?? '1', decimals);
if (amount <= 0n) throw new Error('Use a positive transfer amount');
console.log({ chainId: chain.id, blockNumber: String(blockNumber), token, name, decimals,
  senderBalance: String(balance), paused, recipientVerified });
try {
  await client.simulateContract({ address: token, abi: tokenAbi, functionName: 'transfer',
    args: [recipient, amount], account: sender, blockNumber });
  console.log('Simulation succeeded. No transaction was sent.');
} catch (error) {
  const revert = error instanceof BaseError
    ? error.walk(cause => cause instanceof ContractFunctionRevertedError) : undefined;
  if (!(revert instanceof ContractFunctionRevertedError)) throw error;
  console.log(`Simulation refused: ${revert.reason ?? revert.shortMessage}`);
  console.log('No transaction was sent. Resolve balance, pause, freeze, identity, or compliance restrictions before retrying.');
}

Run it:

node read-and-preflight.mjs

To use your own token and wallets, pass public addresses and a positive human-readable token amount in this order:

node read-and-preflight.mjs TOKEN SENDER RECIPIENT AMOUNT

The optional addresses only choose which call to simulate. They do not give you authority to sign for those addresses. The reads and the simulation all use one block number, so the result describes one consistent snapshot of the chain.

Expected result

With the default addresses, the example read a token named Acme M23 Bond with 18 decimals. The token was not paused, the sender balance was zero, and the recipient was not verified. The simulation reverted with Insufficient Balance. This is the expected refusal from the contract. It does not mean the connection failed. The script rethrows other errors, such as an unreachable RPC, so that they do not look like transfer refusals.

A transfer succeeds only when all of these are true:

  • The sender has enough unfrozen balance.
  • The recipient passes the identity checks.
  • Both wallets and the token pass the freeze, pause, and compliance rules.

A successful simulation describes that block only.

Move from simulation to an application

Use the network configuration for the selected environment. Keep native ASSET available for transaction gas when you later introduce wallet-signed transactions.

Show each state separately: awaiting signature, submitted, confirmed, and indexed. How transactions settle explains what each state proves.

The platform integration guide explains session-based preflight (a check of a proposed operation before you submit it) and durable jobs. Platform preflight can add account checks that this direct contract simulation does not run.