DEV Community

Mehran Mazhar
Mehran Mazhar

Posted on

Making MetaMask and TronLink sign transactions for a custom Rust blockchain

A few months ago I wrote How I Built a Custom Rust Blockchain for On-Chain Ride Lifecycle. Clutch is a ride-sharing protocol where the rider pays the driver directly, and every step of a ride (request, offer, accept, pay, cancel) is a native transaction type on a small Proof-of-Authority chain written in Rust.

Since then it went from "testnet with a faucet" to a mainnet that holds real money. This post is about the parts I did not expect to be hard: getting normal wallets to sign for a chain they have never heard of, and keeping the token honest once it is backed by real USDT.

If you read part 1: block rewards are gone. The block author is now paid by a flat fee on each transaction.

The problem: wallets refuse to sign a bare hash

In part 1, the SDK held a local key and signed the transaction hash directly:

hash = keccak256(rlp([from, nonce, chain_id, data]))
signature = secp256k1_sign(keccak256(hash_as_hex_string))
Enter fullscreen mode Exit fullscreen mode

That works for an SDK. It does not work for MetaMask or TronLink. A wallet will not sign 32 opaque bytes for you, and it is right not to: a bare hash could be an Ethereum transaction on another chain.

So the node now accepts three schemes, each checked against its own digest:

Scheme Who signs What is signed
1 SDK key, treasury, faucet keccak256(hash)
2 MetaMask, Trust Wallet (personal_sign, EIP-191) "\x19Ethereum Signed Message:\n" + len + "clutch-tx:{chain_id}:{hash}"
3 TronLink (signMessageV2, TIP-191) "\x19TRON Signed Message:\n" + len + "clutch-tx:{chain_id}:{hash}"

The text the user sees in the wallet popup is clutch-tx:{chain_id}:{hash}. The chain id is in it so a signature for the testnet can never be replayed on the mainnet.

The nice surprise was TRON. A TRON account is the same kind of key as an Ethereum account: same curve, same Keccak-256, same 20 address bytes. TRON just writes them in base58 with a 0x41 prefix. So supporting TronLink was one more prefix, not a new crypto stack. A user signed in with TronLink has a Clutch address that is literally their TRON address in hex.

The catch: this is a consensus change. A validator without scheme 2 rejects any block containing a scheme-2 transaction. On a testnet you just upgrade. On our young mainnet it meant starting a new chain, which we only allowed because a script first proved that no token had ever been minted on the old one. The day real balances exist, the same change needs a planned rolling upgrade of every validator from the same block height.

Sending from MetaMask: pretending to be just enough of an EVM

Signing ride transactions in the app was solved. But people also wanted to open MetaMask, see their CLT balance, and press Send.

MetaMask can only send by signing a legacy Ethereum transaction (EIP-155). So the Hub API exposes a small JSON-RPC endpoint at /rpc that answers the calls a wallet makes (eth_chainId, eth_getBalance, eth_sendRawTransaction and friends), and the node gained one new transaction type, WalletTransfer:

  • The node decodes the raw Ethereum transaction and recovers from from its signature.
  • It refuses anything that is not a plain value transfer: data, typed transactions, no chain id, a high s.
  • Wallet values are in wei (18 decimals); CLT has 6. So value = CLT units × 10^12, and any value with more than 6 decimals is refused, never rounded. Rounding money silently is how you end up with balances nobody can explain.
  • The transaction's hash is the real Ethereum hash, rebuilt from the fields and signature, so MetaMask, the explorer and the node all show the same hash.

This is also a consensus change, so it is switched on per chain by config: a wallet_chain_id and a wallet_transfers_from_block height that every validator must carry. Below that height the type is refused everywhere. The wallet chain ids (20770 mainnet, 20771 testnet) are registered on chainlist, so "Add network" works with one click.

Real money: CLT backed 1:1 by USDT

1 USD = 1,000,000 CLT base units. CLT only enters the chain through a Mint transaction, and only one address can sign it: the mint_authority, fixed in the genesis block. A separate treasury service watches for USDT deposits (TRC-20 on Tron, BEP-20 on BNB Chain), and mints the matching CLT to the depositor (minus the transfer relay's fee, for deposits that go through one). Withdrawals are the reverse: a Burn on Clutch, then a USDT payout on Tron.

Every mint and burn carries a reference, and the chain stores a processed_ref_{ref} marker so the same deposit can never be credited twice, even if the treasury retries after a crash.

Three bugs that could have created money

These are the ones that scared me. All are fixed, all have tests, and I am writing them down because each one looked harmless.

1. Two transfers in one block minted money. Block state is validated first and applied as one deferred RocksDB batch at the end. So two transfers from the same account in one block were both validated against the pre-block balance, and the second write to the balance key won. Send 10 to A and 10 to B from a balance of 10: both pass, both recipients get 10, the sender ends at 0. Fix: one transaction per account per block, enforced when authoring and when importing. The same staleness broke the exactly-once marker (two mints with one ref in one block), so a block also may not repeat a ref.

2. The block author's fee erased the author's own transaction. The fee credit to the block author was appended after the per-transaction writes, so it became the last write to the author's balance key and silently discarded any transaction the author had sent in that block. The recipient kept the money, the author's debit vanished: unbacked CLT. Fix: fold the fee into the already-staged write instead of appending, and abort the import if that fails.

3. A debug flag deleted the chain on every deploy. developer_mode = true made the node delete its database on graceful shutdown. Our stage configs had it for a month. Whichever node stopped cleanly inside Docker's 30-second grace period lost its chain; the ones that got SIGKILLed kept theirs. So the data loss moved between nodes and looked like a volume problem, then a sync problem, then a deploy-script problem. The node now refuses to delete its database when a data path is mounted.

The lesson I took from all three: with a deferred write batch, "last write wins" is a money bug waiting for a second writer.

Where it is now, honestly

  • The mainnet has been live since October 2026 and holds real USDT.
  • It is alpha: $100 per deposit, $200 per day, withdrawals of $25 to $50 on Tron.
  • The mint and payout keys are on the server, not in a KMS yet. The three validators run on one host. There has been no security audit.

Because of that, I am asking people to try the free testnet first, where nothing costs real money: app-stage.clutchprotocol.io. Sign in with MetaMask or TronLink, and do one ride as passenger or driver (two browser windows work).

I am looking for 10 people to finish one ride and tell me the first thing that confused them or broke. Comments here are perfect.

Links

Questions I would love answers to:

  1. Would you have done wallet support differently, for example an EVM-compatible layer instead of a custom transfer type?
  2. How would you roll out a signature-scheme change on a chain that already holds balances?
  3. Would you use this as a rider or a driver, and what would stop you?

Top comments (0)