Solidity to XChain
Audience: developers who know Solidity/EVM and want to write XChain contracts.
You already know 80% of this. An XChain contract is a deterministic JavaScript program that custodies tokens and emits validated protocol actions. The hard part is not new syntax; it is three model shifts (below). Learn those and the rest is translation.
XChain does not run EVM bytecode, and that is deliberate: the two execution models
are incompatible (account vs UTXO, msg.value vs deposits, synchronous returns vs
deferred emissions), and a leaky “EVM compatibility” layer would break money code
subtly. Instead, contracts are plain JavaScript, so your editor, type checker, test
runner, and LLM codegen all work on them natively.
The three shifts that matter
- Contracts orchestrate; they do not mutate the ledger. In Solidity you write
balances[x] += n. On XChain you cannot touch a balance. Youemit.send(...)and the protocol’s audited handler moves the tokens. A contract can only emit the same ACTIONs a user could. - There is no
msg.value. Value does not ride a call. Tokens enter a contract through a separateDEPOSITaction to the contract’s address; logic runs throughEXECUTE. To make funding atomic, the caller submits both in one transaction withBATCH. Your contract trusts its own balance (getBalance), never a caller-supplied amount. - Emissions are deferred (snapshot semantics). Emitted actions apply only after
your method returns. A contract cannot observe the effects of its own emissions
mid-call, and
getBalance/getTokenInforeflect state at the start of execution. One happy consequence: classic reentrancy is largely a non-issue.
Reach for native primitives before writing a contract
The biggest mistake an EVM dev makes on XChain is writing a contract for something that is already a first-class protocol action:
| You want | Solidity reflex | XChain native (no contract) |
|---|---|---|
| A fungible token | write an ERC-20 contract | ISSUE action (token is first-class) |
| Transfer a token | transfer() on your ERC-20 |
SEND action |
| Check a balance | balanceOf() |
explorer/SDK balance query; in-contract getBalance(addr, tick) |
| An NFT | write an ERC-721 | ISSUE with DECIMALS=0 + LOCK_MAX_SUPPLY=1 |
| Token sale | write a crowdsale | DISPENSER action, or the crowdsale template |
| Swap two tokens | write a DEX pair | ORDER / SWAP actions, or the amm template |
| Pay dividends | loop transfers | DIVIDEND action |
| Airdrop | loop transfers | AIRDROP action over a LIST |
| Enforced royalties / transfer rules | ERC-20 hooks / ERC-2981 | controller-bound token (ISSUE v6 binding a guard contract) |
Write a contract when you need custom logic, custody, or composition that the actions do not express directly (escrow, vesting, an AMM with bespoke rules, a policy guard).
Contract shape
A contract is a module of methods. Each method receives the xchain gateway.
initialize runs once at deploy (your constructor). Parameters arrive as strings
via getInputParam(i).
// SPDX-License-Identifier: MIT
/** @param {import('xchain-vm/src/gateway').XChainGateway} xchain */
module.exports = {
initialize: function (xchain) { // constructor
var owner = xchain.getInputParam(0); // params are strings
xchain.require(owner, 'owner required');
xchain.state.set('owner', owner);
},
ping: function (xchain) { // a public method
xchain.require(xchain.getSourceAddress() === xchain.state.get('owner'), 'not owner');
xchain.state.set('pinged', String(xchain.getBlockHeight()));
}
};
The @param JSDoc line is optional but recommended: it points your editor at the
typed gateway definitions (xchain-vm/src/gateway.d.ts), giving autocomplete,
type-checking, and inline docs for the entire xchain API while you author, with no
build step and nothing to install (contracts stay single-file and import-free).
Concept map
| Solidity | XChain | Notes |
|---|---|---|
constructor |
initialize(xchain) |
runs once at deploy |
function f() public |
f: function (xchain) { ... } |
invoked by name via EXECUTE |
| function arguments | xchain.getInputParam(i), getInputParamCount() |
all params are strings |
msg.sender |
xchain.getSourceAddress() |
the calling address |
address(this) |
xchain.getContractAddress() |
contract address C:CHAIN:index |
msg.value |
(none) | use DEPOSIT + BATCH; read getBalance(getContractAddress(), tick) |
block.number |
xchain.getBlockHeight() |
|
block.timestamp |
xchain.getBlockTimestamp() |
|
blockhash(n) |
xchain.getBlockHash() |
current block |
| storage variable | xchain.state.set/get/has/delete |
flat k/v; values are strings (JSON for structs) |
mapping(k => v) |
key convention, e.g. state.set('bal:' + addr, amt) |
no native map type; compose keys |
struct |
JSON string in one key | state.set('cfg', JSON.stringify(obj)) |
require(c, "m") |
xchain.require(c, 'm') |
reverts the whole execution + emissions |
revert("m") |
xchain.revert('m') |
|
emit Event(...) |
xchain.emit.broadcast(...) |
or rely on the indexer action log + explorer |
uint math / SafeMath |
xchain.math.add/subtract/multiply/divide/compare/gt/gte/lt/lte/eq/... |
bignumber, no overflow, floats are rejected at deploy |
transfer / send value |
xchain.emit.send({ ... }) |
emits a SEND; applied after return |
| external call (returns a value) | xchain.emit.execute({ contractIndex, method, params, gasLimit }) |
async, no return value; respond via a callback method |
| cross-chain call | xchain.emit.crossExecute({ targetChain, contractIndex, method, params, callbackMethod, callbackParams, deadlineBlocks }) |
bridgeless; target must export crossCallable |
modifier onlyOwner |
a guard helper from patterns/ |
check getSourceAddress() against stored owner |
Ownable / AccessControl |
patterns/ access control |
OZ-equivalent, audited |
Pausable |
patterns/ pausable (or token-level SLEEP) |
|
ReentrancyGuard |
usually unnecessary | snapshot semantics; still keep state-before-emit discipline |
view / pure |
a method that only reads | just do not write state or emit |
| oracle price feed (Chainlink) | xchain.oracle.getPrice(coinPair) |
validator-attested, built in |
| external data / API | xchain.attestation.request(providerId, payload, callbackMethod, callbackParams, opts) |
PBFT-certified; http_get and llm providers |
payable receive() |
(none) | DEPOSIT |
selfdestruct / delegatecall |
(none) | not in the model |
| gas limit | GAS_LIMIT on deploy/execute; sdk.suggestGasLimit(...) |
metered per the gas schedule |
The patterns/ column maps to the pattern library in xchain-contracts/patterns/,
which ships an OpenZeppelin alias table
so you can find each building block by the OZ name you already know.
Worked example 1: an ERC-20 is not a contract
Solidity:
contract MyToken is ERC20 {
constructor() ERC20("MyToken", "MTK") { _mint(msg.sender, 1000000e18); }
}
XChain: do not write a contract. Issue the token.
// via the SDK; the token is a first-class protocol object
await sdk.issue({ TICK: 'MTK', MAX_SUPPLY: '1000000', DECIMALS: '8' }, encoder);
// holders transfer with SEND; balances are queryable directly. No contract, no gas-per-transfer logic.
Need a transfer hook (allowlist, royalty, freeze)? That is a controller-bound token: deploy a guard contract and bind it at issue time, so the rule is enforced by the protocol on every transfer and cannot be bypassed by any marketplace.
// guard contract: the indexer calls guard(...) before a guarded action settles
module.exports = {
guard: function (xchain) {
var actionType = xchain.getInputParam(0); // e.g. 'transfer'
var to = xchain.getInputParam(2);
// deny transfers to a blocked address
if (xchain.state.get('blocked:' + to) === '1') xchain.revert('recipient blocked');
// (optional) return a royalty split via payoutLegs for 'trade'
}
};
// bound with ISSUE v6: CONTROLLER = <guard contract index>, ACTION_CLASS = 'transfer' (or 'all')
Worked example 2: Ownable counter, side by side
Solidity:
contract Counter {
address owner; uint256 count;
constructor() { owner = msg.sender; }
function inc() external { require(msg.sender == owner, "not owner"); count += 1; }
}
XChain:
module.exports = {
initialize: function (xchain) {
xchain.state.set('owner', xchain.getSourceAddress());
xchain.state.set('count', '0');
},
inc: function (xchain) {
xchain.require(xchain.getSourceAddress() === xchain.state.get('owner'), 'not owner');
xchain.state.set('count', xchain.math.add(xchain.state.get('count'), '1'));
}
};
Worked example 3: taking a deposit (the msg.value shift)
Solidity:
function deposit() external payable { balances[msg.sender] += msg.value; }
XChain: there is no payable. The caller funds the contract with a DEPOSIT and
triggers logic with EXECUTE, atomically via BATCH. The contract reads its own
balance as the source of truth.
// caller submits, in ONE transaction:
// BATCH( DEPOSIT(thisContract, 'MTK', '100'), EXECUTE(thisContract, 'onDeposit', []) )
module.exports = {
onDeposit: function (xchain) {
var tick = 'MTK';
var held = xchain.getBalance(xchain.getContractAddress(), tick) || '0';
xchain.require(xchain.math.gt(held, '0'), 'no funds received');
// credit the depositor in contract state (the LEDGER move already happened via DEPOSIT)
var key = 'bal:' + xchain.getSourceAddress();
xchain.state.set(key, xchain.math.add(xchain.state.get(key) || '0', held));
}
};
Note the pattern from the audited escrow template: settlement sends the contract’s
entire balance of the tick to the payee, which avoids stranded dust and
“overfund then under-pay” gaps. Only ever deposit the configured tick; other ticks
sent to a single-tick contract are not recoverable by that template.
Gotchas an EVM dev will hit
- Floats are rejected at deploy. No
1.5, noMath.pow/sqrt/log(stripped for cross-CPU determinism). Usexchain.math.*for all arithmetic. The linter (sdk.validateContract/npx xchain-contracts lint) catches this before you spend a transaction. - Everything is a string. Params and state values are strings; convert with
parseIntfor small counters, usexchain.mathfor token amounts (never native float math on amounts). - No return values from calls.
emit.executedoes not return; if you need a result back, the callee calls you back via its ownemit.execute(the callback pattern). Cross-chainemit.crossExecutetakes an explicitcallbackMethod. - You cannot see your own emissions. Compute everything from start-of-call state;
do not
emit.sendthen expectgetBalanceto reflect it in the same method. - Banned globals.
Date,setTimeout,fetch,eval,Function,Proxy,Reflect,RegExpconstructor, and prototype.constructorare stripped. Use thexchaincontext accessors for time and determinism. - Tooling you already know works. It is JavaScript/TypeScript: your editor, type
checker, test runner, and LLM codegen all apply. Scaffold with
npx xchain-contracts scaffold, lint withnpx xchain-contracts lint, and reusepatterns/for access control, pausing, and safe transfers.
Where to go next
- Full contract authoring guide: Smart_Contract_Development.md.
- Templates: 13 audited, forkable contracts in
xchain-contracts/. The custody and finance set isescrow,escrowDelivery,vesting,crowdsale,amm,stableVault,treasury; the auction set isenglishAuctionanddutchAuction; the oracle-consuming set ispriceBet,priceBetTimed, andurlOracle; pluscardDispenserfor randomized inventory draws. - Building blocks + OpenZeppelin aliases:
xchain-contracts/patterns/. - Typed gateway (editor autocomplete):
xchain-vm/src/gateway.d.ts. - The full gateway API and gas schedule: the developer guide on docs.xchain.io.