-
Notifications
You must be signed in to change notification settings - Fork 10
Names and Moves
This page covers the XayaAccounts contract — the single on-chain entry point for everything a player does in a Xaya game. You'll learn how Xaya names work as ERC-721 NFTs, how namespaces are organized, how to register a name (and what it costs), how the move() function works, and what the move envelope JSON looks like by the time it reaches your Game State Processor. Two small, working scripts (verified on Polygon mainnet) are included as reference implementations.
If you haven't read Xaya-Architecture yet, do that first — this page zooms in on the leftmost box of that picture: the contract on Polygon where names live and moves are written.
On Polygon, the entire Xaya platform is anchored by one smart contract:
| Item | Value |
|---|---|
| XayaAccounts contract | 0x8C12253F71091b9582908C8a44F78870Ec6F304F |
| WCHI token (ERC-20, 8 decimals) | 0xE79feAAA457ad7899357E8E2065a3267aC9eE601 |
| Registration policy contract | 0x997A8B19d200A453D77c3857E81Af31F680b3663 |
| Public Polygon RPC run by the Xaya team | https://polygon-node.xaya.io |
XayaAccounts does two things:
-
It's an ERC-721 NFT contract for names. Every Xaya name (like
p/alice) is a token owned by a wallet. Registering a name mints the NFT to you. -
It's the move log. Sending a game move means calling
move()on this contract with a JSON string. The JSON ends up on-chain in transaction calldata and event logs, where the XayaX bridge picks it up and feeds it to every GSP.
flowchart LR
W[Player wallet] -- "register(ns, name)" --> XA[XayaAccounts<br/>0x8C12...304F]
W -- "move(ns, name, json, ...)" --> XA
XA -- "fee in WCHI" --> WCHI[WCHI token]
XA -- "fee check" --> POL[Policy contract]
XA -- "events with move JSON" --> XX[XayaX bridge]
XX --> GSP[Your GSP]
Note that data flows one way: players write to the chain, GSPs only read. The GSP never sends transactions — see What-Makes-a-GSP.
Xaya also runs on other chains — other EVM chains use their own XayaAccounts deployment, and the original Xaya Core chain has names and moves as native features. See Other-Chains. Everything below is for Polygon mainnet.
A Xaya name is a player's identity across all Xaya games. The key properties:
- Permanent. Once registered, a name exists forever. There is no renewal fee and no expiry.
- Wallet-owned. The name is an ERC-721 token; whoever holds the token controls the name. Only the owner (or an approved operator, per standard ERC-721 rules) can send moves for it.
- Tradable. Because it's a standard NFT, a name can be transferred or sold like any other token.
-
One name plays every game. The same
p/alicesends moves for Mover, XayaShips, or any future game. Players don't need a new identity per game, and your game doesn't manage accounts at all — the chain does.
For you as a game developer this means zero account infrastructure: no signups, no password resets, no user database. If a move arrives signed by the owner of p/alice, it's authentic — the chain already checked.
Every name lives in a namespace, written as a prefix: p/alice is the name alice in namespace p.
-
p/is the player namespace. All player identities use it. When you call the contract, the namespace is passed as a separate string argument ('p'), not as part of the name. - Inside your GSP, the
p/prefix is already stripped: a move fromp/alicearrives with"name": "alice". Your game logic only ever sees bare names and can assume they're players.
The ERC-721 token ID for a name is derived deterministically from the namespace and name, and the contract can map both ways:
-
tokenIdForName('p', 'alice')is apurefunction — you can compute the token ID without any state lookup. -
tokenIdToName(tokenId)returns('p', 'alice')back.
Combined with the standard enumerable extension (ownerOf, balanceOf, tokenOfOwnerByIndex), this lets you answer questions like "which names does this wallet own?" entirely on-chain — handy for tooling and game lobbies.
This is the subset of the XayaAccounts ABI you need as a game developer (verified against the deployed contract):
move(string ns, string name, string mv, uint256 nonce, uint256 amount, address receiver) → uint256
register(string ns, string name) → uint256 [payable; fee taken in WCHI via allowance]
exists(string ns, string name) → bool
policy() → address
wchiToken() → address
tokenIdForName(string ns, string name) → uint256 (pure)
tokenIdToName(uint256 tokenId) → (string ns, string name)
ownerOf(uint256) / balanceOf(address) / tokenOfOwnerByIndex(address,uint256) [standard ERC-721 enumerable]
A few notes:
-
policy()andwchiToken()return the current policy and WCHI token addresses. Reading them at runtime (instead of hardcoding) keeps your tooling correct even if the policy is upgraded. -
exists('p', name)is the cheap way to check name availability before registering. -
registeris markedpayablebut on Polygon the fee is taken in WCHI through an ERC-20 allowance, not in POL.
Registration is a three-step flow:
sequenceDiagram
participant You as Your wallet
participant Policy as Policy contract
participant WCHI as WCHI token
participant XA as XayaAccounts
You->>Policy: checkRegistration('p', name)
Policy-->>You: fee (WCHI base units)
You->>WCHI: approve(XayaAccounts, fee)
You->>XA: register('p', name)
XA->>WCHI: transferFrom(you, fee)
XA-->>You: mint ERC-721 name token
-
Query the fee. Call
checkRegistration(ns, name)on the policy contract (its address comes fromXayaAccounts.policy()). It returns the fee in WCHI base units. WCHI has 8 decimals, so a returned value of100000000means 1 WCHI. The fee is currently a flat 1 WCHI (verified on-chain for name lengths 1–10). -
Approve. Call
approve(XayaAccounts, fee)on the WCHI token so the contract can take the fee. -
Register. Call
XayaAccounts.register('p', name). The fee is pulled from your allowance and the name is minted to your wallet as an NFT. That's it — the name is yours permanently.
You'll also need a small amount of POL in the wallet for gas (transactions on Polygon typically cost well under $0.01).
The following script (using viem, but any EVM library works the same way) implements the full flow. The fee-query read path is verified on-chain; you can run it as-is with Node 18+ after npm install viem:
#!/usr/bin/env node
// Register a Xaya name (p/ namespace) on Polygon.
//
// Usage:
// PRIVATE_KEY=0x... node register-name.mjs <name>
//
// Registration costs WCHI (currently a flat 1 WCHI), paid by approving the
// XayaAccounts contract to spend it. The wallet also needs a little POL
// for gas. The name is minted to your wallet as an ERC-721 token.
import { createWalletClient, createPublicClient, http, formatUnits } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { polygon } from 'viem/chains';
const XAYA_ACCOUNTS = '0x8C12253F71091b9582908C8a44F78870Ec6F304F';
const POLYGON_RPC = process.env.POLYGON_RPC ?? 'https://polygon-node.xaya.io';
const accountsAbi = [
{ name: 'register', type: 'function', stateMutability: 'payable',
inputs: [{ name: 'ns', type: 'string' }, { name: 'name', type: 'string' }],
outputs: [{ type: 'uint256' }] },
{ name: 'exists', type: 'function', stateMutability: 'view',
inputs: [{ name: 'ns', type: 'string' }, { name: 'name', type: 'string' }],
outputs: [{ type: 'bool' }] },
{ name: 'policy', type: 'function', stateMutability: 'view',
inputs: [], outputs: [{ type: 'address' }] },
{ name: 'wchiToken', type: 'function', stateMutability: 'view',
inputs: [], outputs: [{ type: 'address' }] },
];
const erc20Abi = [
{ name: 'balanceOf', type: 'function', stateMutability: 'view',
inputs: [{ name: 'o', type: 'address' }], outputs: [{ type: 'uint256' }] },
{ name: 'allowance', type: 'function', stateMutability: 'view',
inputs: [{ name: 'o', type: 'address' }, { name: 's', type: 'address' }],
outputs: [{ type: 'uint256' }] },
{ name: 'approve', type: 'function', stateMutability: 'nonpayable',
inputs: [{ name: 's', type: 'address' }, { name: 'a', type: 'uint256' }],
outputs: [{ type: 'bool' }] },
];
const policyAbi = [
{ name: 'checkRegistration', type: 'function', stateMutability: 'view',
inputs: [{ name: 'ns', type: 'string' }, { name: 'name', type: 'string' }],
outputs: [{ type: 'uint256' }] },
];
const [name] = process.argv.slice(2);
if (!process.env.PRIVATE_KEY || !name) {
console.error('Usage: PRIVATE_KEY=0x... node register-name.mjs <name>');
process.exit(1);
}
const account = privateKeyToAccount(process.env.PRIVATE_KEY);
const wallet = createWalletClient({ account, chain: polygon, transport: http(POLYGON_RPC) });
const client = createPublicClient({ chain: polygon, transport: http(POLYGON_RPC) });
const read = (address, abi, functionName, args = []) =>
client.readContract({ address, abi, functionName, args });
if (await read(XAYA_ACCOUNTS, accountsAbi, 'exists', ['p', name])) {
console.error(`p/${name} is already taken.`);
process.exit(1);
}
const policy = await read(XAYA_ACCOUNTS, accountsAbi, 'policy');
const wchi = await read(XAYA_ACCOUNTS, accountsAbi, 'wchiToken');
const fee = await read(policy, policyAbi, 'checkRegistration', ['p', name]);
console.log(`Registration fee for p/${name}: ${formatUnits(fee, 8)} WCHI`);
const balance = await read(wchi, erc20Abi, 'balanceOf', [account.address]);
if (balance < fee) {
console.error(`Insufficient WCHI: have ${formatUnits(balance, 8)}, need ${formatUnits(fee, 8)}`);
process.exit(1);
}
const allowance = await read(wchi, erc20Abi, 'allowance', [account.address, XAYA_ACCOUNTS]);
if (allowance < fee) {
console.log('Approving WCHI...');
const approveHash = await wallet.writeContract({
address: wchi, abi: erc20Abi, functionName: 'approve', args: [XAYA_ACCOUNTS, fee],
});
await client.waitForTransactionReceipt({ hash: approveHash });
}
console.log(`Registering p/${name}...`);
const hash = await wallet.writeContract({
address: XAYA_ACCOUNTS, abi: accountsAbi, functionName: 'register', args: ['p', name],
});
const receipt = await client.waitForTransactionReceipt({ hash });
console.log(`Registered p/${name} in block ${receipt.blockNumber} (status: ${receipt.status})`);Key lines:
- The
existscheck up front avoids wasting gas on a name that's already taken. -
policy()andwchiToken()are read from XayaAccounts itself rather than hardcoded, so the script keeps working if those addresses ever change. -
formatUnits(fee, 8)— the 8 here is WCHI's decimal count. Don't use 18 (that's POL/ETH). - The allowance check skips the
approvetransaction if a sufficient allowance is already in place from a previous run.
Running the fee query, you should see something like:
Registration fee for p/alice: 1 WCHI
Once a wallet owns a name, it plays games by calling:
move(string ns, string name, string mv, uint256 nonce, uint256 amount, address receiver)
| Parameter | What to pass |
|---|---|
ns |
Always 'p' for player moves. |
name |
The bare name without prefix, e.g. 'alice'. |
mv |
The move JSON string (the envelope, see below). |
nonce |
MAX_UINT256 (2^256 − 1) to skip the nonce check. The contract supports explicit per-name nonces for strict ordering, but for normal play you skip it — the wallet's own transaction nonce already orders your moves. |
amount |
Amount of WCHI (base units, 8 decimals) to send along with the move. Pass 0 if no payment is attached. |
receiver |
The WCHI recipient. Pass the zero address (0x0000...0000) when amount is 0. |
The amount/receiver pair lets a game attach a WCHI payment to a move atomically — for example paying an entry fee or buying an in-game item in the same transaction as the move that uses it. The GSP sees both the move and the payment in the same on-chain event, so your game rules can require "this move is only valid if it paid X WCHI to address Y." For games that don't need payments, it's always 0 and the zero address.
This exact call was sent live as part of writing these docs:
XayaAccounts.move('p', 'xsv5bob', '{"g":{"mv":{"d":"k","n":2}}}', MAX_UINT256, 0, 0x0)
Transaction 0x21f9ad768d8556d1118d64d49556406dbd7c5bfd9e287c8151a5c19ea4d0ffd3, confirmed in Polygon block 88370257, gas cost well under $0.01 in POL. A Mover GSP picked it up seconds later (Processed 1 moves forward, new state has 1 players) — you can follow that whole journey in Tutorial-Mover-Part-2-Playing.
The mv string is JSON with a fixed outer structure that tells Xaya which game(s) the move is for:
{"g":{"mv":{"d":"k","n":2}}}-
"g"is the game-moves object. - Each key inside
"g"is a game ID — heremv, the Mover game. - Each value is that game's move data, in whatever format the game defines. Mover defines
{"d": direction, "n": steps}; your game defines its own schema.
Because "g" is an object keyed by game ID, a single move() call can target several games at once:
{"g":{"mv":{"d":"k","n":2},"xy":{"...":"the other game's move here"}}}Here xy stands for some other game's ID. Each game's GSP extracts only its own entry and ignores the rest. This is one transaction fee for moves in two games — and it's also how a player could, say, perform a coordinated action across games that choose to interoperate.
Your GSP never parses the envelope — XayaX and libxayagame do that for you. By the time your ProcessForward callback runs, each move in blockData["moves"] looks like:
{
"name": "xsv5bob",
"move": {"d": "k", "n": 2}
}-
"name"is the player name without thep/prefix. -
"move"is only the inner object for your game ID — the envelope and any other games' moves are already stripped away.
(The Tutorial-Mover-Part-3-Code-Walkthrough shows real move-parsing code working with exactly these fields.)
The contract does not validate move JSON beyond storing it — anyone can send any string, even garbage, for any game ID. Validation happens entirely in the GSP: your game logic parses each move and simply ignores anything invalid (Mover logs an Ignoring invalid move warning and carries on). This is a core design point:
- The chain stays game-agnostic and cheap — no game rules in Solidity.
- Your rules are enforced by every honest GSP identically, so an invalid move has no effect anywhere. "Cheating" would require changing the rules in every GSP everyone runs — which is just playing a different game.
- Your move parser must therefore be defensive: treat the move JSON as untrusted input, check types and ranges, and never crash on malformed data. More on this in What-Makes-a-GSP.
This script sends a Mover move and is fully verified on mainnet — it produced the transaction quoted above:
#!/usr/bin/env node
// Send a Mover move on Polygon via the XayaAccounts contract.
//
// Usage:
// PRIVATE_KEY=0x... node send-move.mjs <name> <direction> <steps>
//
// Example (move p/myname up by 2):
// PRIVATE_KEY=0x... node send-move.mjs myname k 2
//
// The wallet behind PRIVATE_KEY must own the Xaya name (an ERC-721 token
// of the XayaAccounts contract) and have a little POL for gas.
import { createWalletClient, createPublicClient, http } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { polygon } from 'viem/chains';
const XAYA_ACCOUNTS = '0x8C12253F71091b9582908C8a44F78870Ec6F304F';
const POLYGON_RPC = process.env.POLYGON_RPC ?? 'https://polygon-node.xaya.io';
const MAX_UINT256 = 2n ** 256n - 1n;
const moveAbi = [
{
name: 'move',
type: 'function',
stateMutability: 'nonpayable',
inputs: [
{ name: 'ns', type: 'string' },
{ name: 'name', type: 'string' },
{ name: 'mv', type: 'string' },
{ name: 'nonce', type: 'uint256' },
{ name: 'amount', type: 'uint256' },
{ name: 'receiver', type: 'address' },
],
outputs: [{ type: 'uint256' }],
},
];
const [name, direction, steps] = process.argv.slice(2);
if (!process.env.PRIVATE_KEY || !name || !direction || !steps) {
console.error('Usage: PRIVATE_KEY=0x... node send-move.mjs <name> <direction> <steps>');
process.exit(1);
}
// The move envelope: {"g":{"mv":{...}}} tells Xaya this is a move for the
// game with ID "mv" (Mover). The inner object is the game-specific move.
const moveJson = JSON.stringify({ g: { mv: { d: direction, n: Number(steps) } } });
const account = privateKeyToAccount(process.env.PRIVATE_KEY);
const wallet = createWalletClient({ account, chain: polygon, transport: http(POLYGON_RPC) });
const client = createPublicClient({ chain: polygon, transport: http(POLYGON_RPC) });
console.log(`Sending move for p/${name}: ${moveJson}`);
const hash = await wallet.writeContract({
address: XAYA_ACCOUNTS,
abi: moveAbi,
functionName: 'move',
// 'p' is the player namespace; MAX_UINT256 skips the nonce check;
// amount 0 / zero receiver since no WCHI payment is attached.
args: ['p', name, moveJson, MAX_UINT256, 0n, '0x0000000000000000000000000000000000000000'],
});
console.log('Transaction sent:', hash);
const receipt = await client.waitForTransactionReceipt({ hash });
console.log(`Confirmed in block ${receipt.blockNumber} (status: ${receipt.status})`);Key lines:
-
MAX_UINT256 = 2n ** 256n - 1n— the sentinel nonce that tells the contract to skip its own nonce check. -
moveJsonbuilds the envelope: game IDmvas the key under"g", the game-specific{d, n}object as the value. -
The
argsarray maps one-to-one onto themove()signature: namespace'p', bare name, envelope JSON, sentinel nonce,0nWCHI, zero-address receiver. - The wallet must own the name's NFT (or be an approved ERC-721 operator) — otherwise the transaction reverts.
Run it like this:
PRIVATE_KEY=0x... node ~/mover-polygon/scripts/send-move.mjs xsv5bob k 2You should see something like:
Sending move for p/xsv5bob: {"g":{"mv":{"d":"k","n":2}}}
Transaction sent: 0x21f9ad768d8556d1118d64d49556406dbd7c5bfd9e287c8151a5c19ea4d0ffd3
Confirmed in block 88370257 (status: success)
With Polygon's ~2-second block time, the move lands fast, and a synced GSP reflects it in its game state within seconds.
- A Xaya name is a permanent, tradable ERC-721 NFT on the XayaAccounts contract; one
p/name is a player's identity in every Xaya game. - Registration: query the fee from the policy contract (flat 1 WCHI; 8 decimals) → approve WCHI →
register('p', name). - Moves are
move()calls:ns='p', sentinel nonceMAX_UINT256, optional WCHI payment viaamount/receiver. - The envelope
{"g":{"<gameid>":{...}}}routes moves to games; one transaction can carry moves for several games. - Your GSP receives the bare name and the inner move object only — and silently ignores anything invalid.
Next: see how a daemon turns this stream of moves into game state in What-Makes-a-GSP, or jump straight to playing with Tutorial-Mover-Part-2-Playing.
Xaya developer documentation — verified on Polygon mainnet. Questions? Xaya Development Discord