Burn & Mint Client
This page shows the source of VIA's burn-and-mint reference client for Stellar, piece by piece. The excerpts come from the audited source, and you receive the complete package during onboarding. Sending burns the token on Stellar. Receiving mints it. Total supply across chains stays constant.
This is a read-through, not a deploy tutorial. The code is the client behind VIAT, the reference token deployed on Stellar mainnet and testnet with routes to Ethereum. Launching your own token on Stellar is a guided process with VIA. Transferring VIAT through the deployed contracts is permissionless. See Integration Paths for both routes.
Inside the Reference Client
The reference client is one Soroban contract that plays two roles: a fungible token and a VIA message client. It builds on two libraries:
- OpenZeppelin's Stellar contracts supply the token:
FungibleTokenfor balances and transfers,FungibleBurnablefor burns, and the ownership helpers. - VIA's
message-clientcrate supplies the messaging: the#[default_impl]macro, theProcessFromGatewayRequesttype, and theabimodule.
#[contract]
pub struct ClientTestV4;
#[contractimpl(contracttrait)]
impl FungibleToken for ClientTestV4 {
type ContractType = FungibleTokenBase;
}
#[contractimpl(contracttrait)]
impl FungibleBurnable for ClientTestV4 {}
The messaging half is one attribute. #[default_impl] generates every method a VIA client needs — owner, gateway, endpoints, send, and the receive entry point — so the contract body holds only the token logic:
#[default_impl]
#[contractimpl]
impl ClientTestV4 {
pub fn __constructor(
env: Env,
owner: Address,
name: String,
symbol: String,
decimals: u32,
initial_supply: i128,
) {
FungibleTokenBase::set_metadata(&env, decimals, name, symbol);
if initial_supply > 0 {
FungibleTokenBase::mint(&env, &owner, initial_supply);
}
}
The macro requires a constructor whose first two parameters are env: Env and owner: Address. It wraps the constructor so that the owner is set before the body runs. The contract is never deployed in an unowned state.
initial_supply is the full supply, already scaled to 7 decimals. Mint it on one chain only. The deployment on the other side of the route starts at zero and receives tokens only when they are transferred in. That is what keeps total supply constant.
bridge: The Send Path
A user calls bridge() on the client to move tokens off Stellar. The function checks the inputs, burns the tokens, and hands the message to the gateway:
pub fn bridge(
env: &Env,
from: Address,
destination_chain: u64,
amount: i128,
recipient: Bytes,
text: Bytes,
) -> u128 {
if amount <= 0 {
panic_with_error!(env, Error::InvalidAmount);
}
if recipient.is_empty() {
panic_with_error!(env, Error::InvalidRecipient);
}
let endpoint = Self::get_endpoint_by_chain_id(env, destination_chain);
if endpoint.is_empty() {
panic_with_error!(env, Error::MissingChainEndpoint);
}
Self::burn(env, from, amount);
// 7 decimals on Stellar, 18 on the wire
let normalized_amount = amount * 10i128.pow(11);
let confirmations: u32 = 0;
let chain_data = Self::encode_abi_chain_data(env, &recipient, normalized_amount, &text);
Self::message_send(env, destination_chain, chain_data, confirmations)
}
Four rules:
- The amount must be positive and the recipient non-empty. Anything else stops before the burn.
- The destination must be a registered route. If no endpoint exists for
destination_chain, the call fails withMissingChainEndpoint. - The sender burns exactly the amount.
burn()comes fromFungibleBurnableand requiresfromto authorize the transaction. Nobody can burn your tokens for you. - Amounts scale to 18 decimals. Stellar tokens use 7 decimals; the wire format uses 18. The client multiplies by 10^11 so the destination reads the same number of whole tokens.
Then message_send(), generated by the macro, looks up the endpoint for the destination chain, builds a SendRequest with this contract as the sender, and calls send() on the gateway. The gateway assigns the message ID, emits send_requested, and returns the ID, which bridge() returns to the caller.
The recipient bytes use the destination's own address format: a 20-byte address for Ethereum. The text field is a free memo.
message_process: The Receive Path
When VIA delivers a message to Stellar, the gateway calls message_process_from_gateway() on the client. The macro-generated wrapper verifies two things before it calls your code: a gateway is configured, and the message's sender matches the endpoint registered for its source chain. Then it calls message_process():
fn message_process(env: &Env, message: &ProcessFromGatewayRequest) {
let (recipient, normalized_amount, text) =
ClientTestV4::decode_abi_chain_data(env, &message.on_chain_data);
// 18 decimals on the wire, 7 on Stellar
let local_amount = normalized_amount / 10i128.pow(11);
let recipient_address = match Address::from_xdr(env, &recipient) {
Ok(address) => address,
Err(_) => panic_with_error!(env, Error::InvalidRecipient),
};
if local_amount > 0 {
FungibleTokenBase::mint(env, &recipient_address, local_amount);
}
ClientTestV4::set_received_texts(env, &text);
}
The receive path re-checks everything it depends on:
- Only the gateway can call it. The generated
message_processentry point carries#[only_admin], and the admin role is the gateway address set byset_message_gateway(). A direct call from any other address fails authorization. - Only registered senders pass. The wrapper compares the message's
senderwith the endpoint stored forsource_chain_id. A mismatch fails withInvalidChainSender. - The recipient must decode to a Stellar address. The payload carries the recipient as an XDR-encoded
Address. VIA's message layer restores that encoding from the 32-byte ID the source chain sent, soAddress::from_xdr()succeeds for a funded account or a contract. - Mint exactly the amount. The wire amount scales back down to 7 decimals, and the token mints it to the recipient. A zero amount mints nothing; the memo is still stored.
The stored memos live in a bounded ring buffer of 100 entries, each up to 512 bytes. Larger memos are ignored, which keeps storage costs fixed.
The Payload
Both directions share one payload layout, encoded with the crate's abi module in Solidity's abi.encode format:
pub fn encode_abi_chain_data(env: &Env, recipient: &Bytes, amount: i128, text: &Bytes) -> Bytes {
let values = [
AbiValue::Bytes(recipient.clone()),
AbiValue::I128(amount),
AbiValue::Bytes(text.clone()),
];
abi_encode(env, &values)
}
| Field | Type | Stellar → Ethereum | Ethereum → Stellar |
|---|---|---|---|
recipient | bytes | The 20-byte Ethereum address | The 32-byte Stellar account or contract ID |
amount | int128 / uint256 | 18-decimal amount | 18-decimal amount |
text | bytes / string | Free memo | Free memo |
decode_abi_chain_data() reads the same three fields back. bytes and string encode identically, and an int128 amount occupies the same 32-byte word as a uint256, so the EVM contract and the Stellar client read each other's payloads without a translation step.
What the Macro Generates
#[default_impl] adds these methods to the contract. The owner-only ones require the owner's authorization.
| Method | Who calls it | What it does |
|---|---|---|
set_message_owner(new_owner) | Owner | Transfers ownership |
set_message_gateway(gateway) | Owner | Sets the gateway on the first call; later calls start a two-step rotation |
transfer_message_gateway(gateway) | The new gateway | Completes a pending rotation |
get_message_gateway() | Anyone | Returns the active gateway |
set_message_endpoints(chains, endpoints) | Owner | Registers the counterpart contract for each chain ID |
get_endpoint_by_chain_id(chain_id) | Anyone | Returns a registered endpoint, or empty bytes |
validate_chain_sender(chain_id, sender) | Anyone | Fails unless sender matches the endpoint for chain_id |
message_send(chain_id, chain_data, confirmations) | Your contract | Sends a message through the gateway; returns the message ID |
message_process_from_gateway(message) | The gateway | Validates the sender, then calls message_process |
set_project_signers(signers, required) | Owner | Adds a project signer layer on the gateway for this contract |
set_contract_relayers(relayers) | Owner | Restricts delivery to this contract to the listed relayers |
A gateway rotation is two steps on purpose. The owner names the new gateway, which becomes pending for 1,000 ledgers. The rotation completes only when the new gateway accepts it through its own owner-only accept_admin_transfer(). A typo in the address cannot strand the client on a gateway that does not exist.
Configuration After Deploy
Deploying the .wasm is one step. Wiring the client takes two more, both by the owner:
- Point the client at the gateway. Call
set_message_gateway()with the VIA gateway address for the network — see Deployed Contracts. - Register the routes. Call
set_message_endpoints()with each destination's VIA chain ID and its counterpart contract in 32-byte form. For Ethereum, that is the token contract's address left-padded with zeros. Do the reverse on the Ethereum side: register the Stellar client's 32-byte contract ID withsetMessageEndpoints().
The same .wasm deploys to testnet and mainnet. Only the constructor arguments, the gateway address, and the endpoints differ per network.
Errors
The crate defines the errors the client can return:
| Code | Error | Meaning |
|---|---|---|
| 1 | ChainsEndpointsMislength | The chains and endpoints lists have different lengths |
| 2 | MissingMessageGateway | No gateway is set |
| 3 | MissingChainEndpoint | No endpoint is registered for the destination chain |
| 4 | InvalidRecipient | The recipient is empty or does not decode to an address |
| 5 | InvalidAmount | The amount is zero or negative |
| 6 | SenderLengthMismatch | The message sender and the registered endpoint differ in length |
| 7 | InvalidChainSender | The message sender does not match the registered endpoint |
| 8 | NoPendingAdmin | No gateway rotation is pending |
| 9 | NoAdminSet | No gateway has ever been set |
The gateway has its own error set. Those errors surface to the relayer that submits process(), not to your client.
The EVM Side of the Route
On Ethereum, the counterpart is a standard VIA token contract. VIAMintBurnTokenStellar.sol is the version built for Stellar routes: its messageProcess() decodes the (bytes, int128, bytes) payload above and mints to the 20-byte recipient. You receive it during onboarding, like the Rust source on this page. The published EVM contract sources are on the Contract Source page.
Where the Rest Lives
The client builds on VIA's message-client crate and OpenZeppelin's Stellar token library. You receive the full package, with the gateway addresses and chain IDs for your target networks, when you start an integration.
Launching your own cross-chain token on Stellar is a guided process: you build, deploy, and configure your client, and VIA wires your integration into the message layer.
- Integration Paths — choose your route onto Stellar
- Burn & Mint Token — the EVM counterpart of this client