Listing a Spot Market
This page walks through everything needed to bring a new token to spot trading on Rubin: an ERC-20 contract on the Rubin EVM, its registration as a bank coin, the asset and the CLOB pair created by governance, and the conversion of tokens between the EVM and the bank so that holders can deposit them into a spot subaccount.
The examples use MARTI, a token listed on the Rubin testnet, with the contract 0x7Cc695905159ACCB91656BCd36E86d74161B4E4c. Replace the addresses, ids and amounts with your own.
How the pieces fit together
Three things are decided by governance, once per token: the ERC-20 ↔ coin pair, the asset, and the market. Everything a holder does afterwards — converting, depositing, trading, withdrawing — is permissionless and signed with the holder's own key.
1. The ERC-20 contract
Any standard ERC-20 works. The token below is the one used for MARTI: a minimal contract with an owner who can mint, with no dependencies. An OpenZeppelin ERC20 + Ownable contract is an equally good choice.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
/// @title MARTI — ERC-20 issued and owned by an account.
/// @notice Registered in x/erc20 as OWNER_EXTERNAL: the contract, not a chain
/// module, is the source of truth for supply.
contract MartyToken {
string public constant name = "Marti";
string public constant symbol = "MARTI";
uint8 public constant decimals = 6;
uint256 public totalSupply;
address public owner;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
error NotOwner();
error ZeroAddress();
error InsufficientBalance();
error InsufficientAllowance();
modifier onlyOwner() {
if (msg.sender != owner) revert NotOwner();
_;
}
constructor(address initialOwner, uint256 initialSupply) {
if (initialOwner == address(0)) revert ZeroAddress();
owner = initialOwner;
emit OwnershipTransferred(address(0), initialOwner);
_mint(initialOwner, initialSupply);
}
function transfer(address to, uint256 value) external returns (bool) {
_transfer(msg.sender, to, value);
return true;
}
function approve(address spender, uint256 value) external returns (bool) {
allowance[msg.sender][spender] = value;
emit Approval(msg.sender, spender, value);
return true;
}
function transferFrom(address from, address to, uint256 value) external returns (bool) {
uint256 allowed = allowance[from][msg.sender];
if (allowed != type(uint256).max) {
if (allowed < value) revert InsufficientAllowance();
allowance[from][msg.sender] = allowed - value;
}
_transfer(from, to, value);
return true;
}
function mint(address to, uint256 value) external onlyOwner {
_mint(to, value);
}
function burn(uint256 value) external {
uint256 bal = balanceOf[msg.sender];
if (bal < value) revert InsufficientBalance();
balanceOf[msg.sender] = bal - value;
totalSupply -= value;
emit Transfer(msg.sender, address(0), value);
}
function transferOwnership(address newOwner) external onlyOwner {
if (newOwner == address(0)) revert ZeroAddress();
emit OwnershipTransferred(owner, newOwner);
owner = newOwner;
}
function _transfer(address from, address to, uint256 value) internal {
if (to == address(0)) revert ZeroAddress();
uint256 bal = balanceOf[from];
if (bal < value) revert InsufficientBalance();
balanceOf[from] = bal - value;
balanceOf[to] += value;
emit Transfer(from, to, value);
}
function _mint(address to, uint256 value) internal {
if (to == address(0)) revert ZeroAddress();
totalSupply += value;
balanceOf[to] += value;
emit Transfer(address(0), to, value);
}
}Deploying
Deploy with any EVM tooling (Hardhat, Foundry, Remix or a script) against the Rubin EVM JSON-RPC — https://evm-rpc.testnet.rubin.trade for testnet, https://evm-rpc.rubin.trade for mainnet. Gas is paid in the native token from the deployer's account. With ethers:
import { ethers } from 'ethers';
import solc from 'solc';
const provider = new ethers.JsonRpcProvider('https://evm-rpc.testnet.rubin.trade');
const wallet = ethers.Wallet.fromPhrase(process.env.MNEMONIC!, provider); // m/44'/60'/0'/0/0
// Compile MartyToken.sol (abi + bytecode), e.g. with solc:
const { abi, evm } = compileWithSolc('MartyToken.sol'); // see solc docs
const factory = new ethers.ContractFactory(abi, evm.bytecode.object, wallet);
const initialSupply = 1_000_000n * 10n ** 6n; // 1,000,000 MARTI with 6 decimals
const token = await factory.deploy(wallet.address, initialSupply);
await token.waitForDeployment();
console.log('contract:', await token.getAddress()); // 0x7Cc6… ← goes into the proposal2. The governance proposal
One proposal carries the whole listing. Messages execute in order, so the token pair exists before the asset references its denom, and the asset exists before the CLOB pair references its id. All three messages are authorised by the governance module account (ritbitd query auth module-account gov).
{
"title": "MARTI: register ERC-20, create asset, list MARTI-USDC",
"metadata": "",
"summary": "Registers ERC-20 0x7Cc695905159ACCB91656BCd36E86d74161B4E4c in x/erc20 (denom erc20:0x7Cc695905159ACCB91656BCd36E86d74161B4E4c), creates asset 10 MARTI and lists the spot pair MARTI-USDC as CLOB pair 1004.",
"deposit": "10000000000000000000000urit",
"messages": [
{
"@type": "/cosmos.evm.erc20.v1.MsgRegisterERC20",
"signer": "rit10d07y265gmmuvt4z0w9aw880jnsr700j086rvs",
"erc20addresses": ["0x7Cc695905159ACCB91656BCd36E86d74161B4E4c"]
},
{
"@type": "/ritbit.assets.MsgCreateAsset",
"authority": "rit10d07y265gmmuvt4z0w9aw880jnsr700j086rvs",
"asset": {
"id": 10,
"symbol": "MARTI",
"denom": "erc20:0x7Cc695905159ACCB91656BCd36E86d74161B4E4c",
"denom_exponent": -6,
"has_market": false,
"market_id": 0,
"atomic_resolution": -6,
"is_collateral": false,
"collateral_weight_ppm": 0
}
},
{
"@type": "/ritbit.clob.MsgCreateClobPair",
"authority": "rit10d07y265gmmuvt4z0w9aw880jnsr700j086rvs",
"clob_pair": {
"id": 1004,
"spot_clob_metadata": { "base_asset_id": 10, "quote_asset_id": 0 },
"step_base_quantums": "1000",
"subticks_per_tick": 10000,
"quantum_conversion_exponent": -6,
"status": "STATUS_ACTIVE"
}
}
]
}MsgRegisterERC20
Registers the contract in the x/erc20 module and creates the token pair 0x7Cc6… ↔ erc20:0x7Cc695905159ACCB91656BCd36E86d74161B4E4c. The denom is always erc20: followed by the checksummed contract address. The contract keeps control of supply (contract_owner: OWNER_EXTERNAL); the module only escrows tokens that have been converted to coins. Several contracts can be registered in one message.
MsgCreateAsset
| Field | Value |
|---|---|
id | Next free asset id (ritbitd query assets list-asset). 0 is USDC, 2 is RUB1. |
symbol | Ticker symbol; becomes the BASE part of spot tickers. |
denom | The bank denom — for an ERC-20 token, the erc20:0x… denom from the previous message. |
denom_exponent | -decimals of the coin: one MARTI = 10⁶ base units, so -6. |
atomic_resolution | Exponent of one quantum of the asset in subaccounts. Usually equal to denom_exponent; a larger (less negative) value lets 18-decimal tokens fit the 64-bit fill fields. |
has_market, market_id | Whether the asset has an oracle price in x/prices. Not required for a base asset: spot prices come from the order book. Required for a quote asset other than USDC. |
is_collateral, collateral_weight_ppm | Whether the asset counts as margin collateral for perpetual trading. false / 0 for a plain spot token. |
MsgCreateClobPair
| Field | Value |
|---|---|
id | Any unused CLOB pair id. |
spot_clob_metadata | base_asset_id — the asset created above; quote_asset_id — 0 (USDC) or 2 (RUB1). |
step_base_quantums | Order size step in base quantums. 1000 with atomic_resolution -6 = 0.001 MARTI. |
subticks_per_tick | Price tick in subticks. |
quantum_conversion_exponent | Links subticks to the quote leg; see Quantums and Subticks and the price formula on Spot Markets. |
status | STATUS_ACTIVE to open trading immediately, or STATUS_INITIALIZING and a later MsgUpdateClobPair. |
With these values the pair trades MARTI in steps of 0.001 at prices in steps of 0.01 USDC.
Submitting and voting
ritbitd tx gov submit-proposal proposal.json \
--from <key> --chain-id <chain-id> --gas 600000 --fees <fee>A three-message proposal needs more gas than the default 200 000. The deposit must reach min_deposit (ritbitd query gov params) before the voting period starts. Validators and delegators vote with:
ritbitd tx gov vote <proposal-id> yes --from <key> --chain-id <chain-id>When the proposal passes, all three messages execute in the same block. The CLOB module emits a SpotMarketCreate event, the indexer picks it up and the pair appears in /v4/spotMarkets within a few seconds; the web app lists it under Spot on its next market refresh.
Verifying
# token pair
curl https://rest.testnet.rubin.trade/cosmos/evm/erc20/v1/token_pairs
# asset
curl https://rest.testnet.rubin.trade/ritbit/assets/asset/10
# CLOB pair
curl https://rest.testnet.rubin.trade/ritbit/clob/clob_pair/1004
# indexer
curl https://indexer.testnet.rubin.trade/v4/spotMarkets?ticker=MARTI-USDC3. Converting tokens between the EVM and the bank
Spot trading settles in bank coins, so an ERC-20 balance has to be converted before it can be deposited into a subaccount — and converted back if the holder wants it as an ERC-20 again. Conversions are signed by the holder; no governance is involved once the pair is registered and enabled. Amounts are in the token's smallest unit (1 MARTI = 1000000).
ERC-20 → bank coins: MsgConvertERC20
{
"@type": "/cosmos.evm.erc20.v1.MsgConvertERC20",
"contract_address": "0x7Cc695905159ACCB91656BCd36E86d74161B4E4c",
"amount": "100000000",
"sender": "0x719009A9631327E48319aE10a4b9BE9BC143C8BD",
"receiver": "rit1wxgqn2trzvn7fqce4cg2fwd7n0q58j9aqehtk4"
}sender— the EVM (0x) form of the signer; this is the account whose ERC-20 balance is debited.receiver— therit1account credited witherc20:0x7Cc6…coins. Usually the same account in its native form, but it can be any address.
The module moves the ERC-20 tokens into its escrow account and mints an equal amount of coins. With the CLI:
ritbitd tx erc20 convert-erc20 0x7Cc695905159ACCB91656BCd36E86d74161B4E4c 100000000 \
--from <key> --chain-id <chain-id> --fees <fee>
# optional third argument: receiver rit1… (defaults to the sender)Bank coins → ERC-20: MsgConvertCoin
{
"@type": "/cosmos.evm.erc20.v1.MsgConvertCoin",
"coin": { "denom": "erc20:0x7Cc695905159ACCB91656BCd36E86d74161B4E4c", "amount": "100000000" },
"sender": "rit1wxgqn2trzvn7fqce4cg2fwd7n0q58j9aqehtk4",
"receiver": "0x719009A9631327E48319aE10a4b9BE9BC143C8BD"
}sender— therit1account whose coins are burned.receiver— the0xaddress that receives the ERC-20 tokens from escrow.
ritbitd tx erc20 convert-coin 100000000erc20:0x7Cc695905159ACCB91656BCd36E86d74161B4E4c \
--from <key> --chain-id <chain-id> --fees <fee>
# optional second argument: receiver 0x… (defaults to the sender's EVM address)Checking balances
# bank coins
ritbitd query bank balances rit1wxgqn2trzvn7fqce4cg2fwd7n0q58j9aqehtk4
# or
curl https://rest.testnet.rubin.trade/cosmos/bank/v1beta1/balances/rit1wxgqn2trzvn7fqce4cg2fwd7n0q58j9aqehtk4The ERC-20 side is an ordinary balanceOf(address) call through the EVM JSON-RPC, or the token page in the explorer.
Pausing conversions
Governance can stop conversions for a token without delisting it: MsgToggleConversion { authority, token } flips enabled on the pair. While disabled, both MsgConvertERC20 and MsgConvertCoin for that token are rejected; existing coins keep trading.
4. From bank coins to a spot order
With coins in the main account the holder deposits into spot subaccount 1 and trades:
# deposit 100 MARTI (asset 10) into subaccount 1
ritbitd tx sending deposit-to-subaccount <key> rit1wxgqn2trzvn7fqce4cg2fwd7n0q58j9aqehtk4 1 10 100000000 \
--chain-id <chain-id> --fees <fee>
# withdraw back to the main account
ritbitd tx sending withdraw-from-subaccount <key> 1 rit1wxgqn2trzvn7fqce4cg2fwd7n0q58j9aqehtk4 10 100000000 \
--chain-id <chain-id> --fees <fee>The quantity is in asset quantums (10^atomic_resolution), here 100000000 = 100 MARTI. Depositing, placing orders and reading balances from code is covered in the Spot Trading guide.
Reference
- Spot Markets — market mechanics, subaccounts, fees.
- Governance Functionalities — the other governance-controlled parameters.
x/erc20is the cosmos/evm ERC-20 module;MsgRegisterERC20,MsgConvertERC20,MsgConvertCoinandMsgToggleConversionare defined incosmos/evm/erc20/v1/tx.proto.MsgCreateAsset—ritbit/assets/tx.proto;MsgCreateClobPairandSpotClobMetadata—ritbit/clob/tx.proto,ritbit/clob/clob_pair.proto.