Smart Contract Development Guide

This guide covers writing, deploying, and interacting with smart contracts on the XChain Platform.

Prerequisites

  • An address with XCHAIN tokens (for gas fees)
  • Access to an encoder service (to broadcast transactions)
  • Familiarity with JavaScript (ES2020)

Writing a Contract

Contracts are plain JavaScript files that export either a function or an object with named methods. Every method receives the xchain gateway object as its sole argument.

Minimal Contract

module.exports = function(xchain) {
    xchain.log('hello from contract');
    return 'hello';
};

Multi-Method Contract

module.exports = {
    initialize: function(xchain) {
        xchain.state.set('owner', xchain.getSourceAddress());
        xchain.state.set('total', '0');
    },
    deposit: function(xchain) {
        var amount = xchain.getInputParam(0);
        xchain.require(amount, 'amount required');
        xchain.require(xchain.math.gt(amount, '0'), 'amount must be positive');

        var total = xchain.state.get('total') || '0';
        xchain.state.set('total', xchain.math.add(total, amount));
    },
    withdraw: function(xchain) {
        xchain.require(
            xchain.getSourceAddress() === xchain.state.get('owner'),
            'only owner can withdraw'
        );
        var amount = xchain.getInputParam(0);
        var tick = xchain.getInputParam(1);
        xchain.emit.send({
            destination: xchain.getSourceAddress(),
            tick: tick,
            quantity: amount
        });
    }
};

Constructor

If a contract exports an initialize method and the DEPLOY action includes CONSTRUCTOR_PARAMS, the VM calls initialize immediately after deployment. Constructor state changes and emissions are processed atomically with the deployment — if the constructor fails (or any of its emissions does), the contract is not deployed.

Constructors may emit any action a method can, including emit.execute — so a contract can register itself with a registry contract in the same transaction that deploys it. The constructor runs at call depth 0; remember the contract’s derived address holds no tokens yet, so token-moving emissions (emit.send, …) from a constructor will fail the deployment unless the tokens were somehow pre-funded.

Supported JavaScript

Contracts support ES2020 syntax. This includes:

  • let, const, arrow functions, template literals, destructuring
  • class declarations and methods
  • for...of, for...in, spread operator
  • Optional chaining (?.) and nullish coalescing (??)
  • async/await syntax is parseable but contracts execute synchronously — Promises never resolve

Not supported:

  • ES2021+ features (class fields #private, Object.hasOwn(), top-level await)
  • import/export (use module.exports)
  • require(), eval(), Function() constructor

All Arithmetic Must Use xchain.math

Native JavaScript arithmetic (+, -, *, /) uses IEEE 754 floating-point, which can produce subtly different results across V8 versions. This would cause contract hash divergence between indexer nodes.

// WRONG — non-deterministic
var total = parseFloat(a) + parseFloat(b);

// CORRECT — deterministic
var total = xchain.math.add(a, b);

All xchain.math operations accept and return strings. This ensures no precision loss.

State Management

The state API provides get(key), set(key, value), has(key), and delete(key). There is no keys() or entries() method — this is deliberate to avoid key-ordering non-determinism.

Pattern: Manual Index for Collections

// Adding a participant
var count = parseInt(xchain.state.get('participants_count') || '0');
xchain.state.set('participant_' + count, address);
xchain.state.set('participants_count', String(count + 1));

// Iterating participants
var count = parseInt(xchain.state.get('participants_count') || '0');
for (var i = 0; i < count; i++) {
    var addr = xchain.state.get('participant_' + i);
    // ... process addr
}

Pattern: Duplicate Detection

Without state.keys(), use a reverse lookup to detect duplicates:

if (xchain.state.has('participant_by_addr_' + addr))
    xchain.revert('already a participant');

xchain.state.set('participant_' + count, addr);
xchain.state.set('participant_by_addr_' + addr, String(count));
xchain.state.set('participants_count', String(count + 1));

Pattern: JSON for Small Datasets

xchain.state.set('config', JSON.stringify({ fee: '100', admin: 'addr1', paused: false }));
var config = JSON.parse(xchain.state.get('config'));

Emitting Actions

Contracts emit platform actions using xchain.emit.*. Each emission costs 500 gas and is capped at 50 per execution.

// Send tokens from the contract to an address
xchain.emit.send({
    destination: xchain.getSourceAddress(),
    tick: 'MYTOKEN',
    quantity: '100'
});

// Issue a new token
xchain.emit.issue({
    tick: 'NEWTOKEN',
    maxSupply: '1000000',
    decimals: '8',
    description: 'Created by contract'
});

Emitted actions use the contract’s derived address (C:<CHAIN>:<action_index>) as the source. The contract can only spend tokens deposited to its derived address. The EXECUTE caller pays protocol fees on emitted actions.

Snapshot Semantics

Emitted actions are queued during execution and processed after the VM returns. A contract cannot observe the effects of its own emissions within the same execution. getBalance() reflects the state at the start of execution.

Atomicity

If any emitted action fails validation (e.g., insufficient balance), ALL state changes and ALL earlier emissions are rolled back. The caller is still charged gas.

Deploying a Contract

  1. Write your contract as a JavaScript file
  2. Hex-encode the UTF-8 source: Buffer.from(source, 'utf8').toString('hex')
  3. Broadcast a DEPLOY action: DEPLOY|0|<hex_code>|<gas_limit>|<constructor_params>

The indexer validates the code syntax before charging gas. If syntax is invalid, the deployment is rejected without cost.

Deploy-Time Validation

The VM performs three checks before deployment:

  1. V8 syntax check — the code must parse as valid JavaScript
  2. Acorn metering pass — the code must be parseable by acorn (ES2020 maximum)
  3. Reserved identifier check — the code must not use __gas (reserved for gas metering)

A non-blocking float warning is also generated if decimal number literals are detected in the code. This warning appears in the execution record but does not prevent deployment.

Gas Costs

Operation Gas
Computation (per control flow point) 1
State read (state.get, state.has, getBalance, getTokenInfo) 100
State write (state.set) 200
State delete (state.delete) 100
Oracle read 100
Cross-chain read 100
Action emission 500
Cross-contract call (emit.execute) 500 + the call’s gasLimit (unused part refunded on success)

Indexed for loops cost 2 gas per iteration, not 1. The gas meter injects a control-flow charge at the top of the loop body and a second charge into the update expression (for (…; i++) is metered as for (…; (__gas(1), i++))). So a for loop running N iterations costs 2 × N computation gas. while, do-while, for-in, and for-of loops have no update expression and cost 1 gas per iteration. Budget indexed for loops accordingly.

The gas ceiling is 1,000,000 per execution. Deployment gas is calculated as VM_DEPLOY_BASE + (code_bytes * VM_DEPLOY_PER_BYTE), plus constructor gas if a constructor runs.

Debugging

  • Use xchain.log() to add messages to the execution log (up to 100 entries, 1KB each)
  • Logs are preserved even when execution fails — check the execution record in the explorer
  • Use xchain.revert('descriptive message') for clear error reporting
  • The explorer shows full execution details: gas used, state changes, emitted actions, error messages

Asking the Outside World — xchain.attestation.*

A contract can ask a question to a registered external provider and have the validator network deliver the answer back on-chain. The contract method that issued the request returns immediately; the answer arrives later as a callback into a method you name. The platform handles provider lookup, validator coordination, signature aggregation, and the on-chain write of the response — your contract just sends a question and writes a callback.

Request

xchain.attestation.request(
    providerId,        // 'http_get' or 'llm' (governance-controlled list)
    payload,           // string — provider-specific (URL for http_get, JSON envelope for llm)
    callbackMethod,    // method on this contract to invoke when the answer arrives
    callbackParams,    // array — your own context, echoed back (each element is delivered to the callback as a string; see "A note on callback param types" below)
    options            // { redundancy: 1|3|5, deadlineBlocks: number }
);
  • redundancy is the number of independent validators that must agree before the response is written to chain. 1 is the cheapest path (one validator’s answer is final); 3 or 5 triggers a consensus round across multiple validators.
  • deadlineBlocks is how many blocks the request waits before it expires. If no agreed-upon answer arrives in time, the callback is still invoked — with status='expired' and an empty response — so your contract can react to silence.

xchain.attestation.request costs VM_EMISSION (500 gas, standard action-emission overhead) plus VM_ATTEST_REQUEST (5,000 gas, the attestation request charge) — 5,500 gas total per call, on top of the request’s gas escrow.

Callback

Define a method that consumes the result. It receives the request id, the provider id, the status, the response payload, and any params you supplied at request time — in that order — through the standard xchain.getInputParam(i) accessor:

module.exports = {
    askLlm: function(xchain) {
        xchain.attestation.request(
            'llm',
            JSON.stringify({ prompt: 'Reply with only the number 1 if true, 0 if false: "the sky is blue"', max_tokens: 8 }),
            'handleVerdict',
            [xchain.getSourceAddress(), 42],   // your context — echoed back to handleVerdict (42 is a numeric round id)
            { redundancy: 1, deadlineBlocks: 20 }
        );
    },

    handleVerdict: function(xchain) {
        var requestId       = xchain.getInputParam(0);
        var providerId      = xchain.getInputParam(1);
        var status          = xchain.getInputParam(2);   // 'ok' | 'timeout' | 'no_quorum' | 'provider_error' | 'expired'
        var responsePayload = xchain.getInputParam(3);
        var caller          = xchain.getInputParam(4);   // your context (a string)
        var roundId         = parseInt(xchain.getInputParam(5), 10);   // re-parse: the 42 you passed arrives as the string '42'

        if (status !== 'ok') {
            xchain.log('attestation failed: ' + status);
            return;
        }

        // responsePayload is whatever the provider returned — for llm, the model's reply text.
        xchain.state.set('last_verdict_' + caller + '_' + roundId, responsePayload);
    }
};

A note on callback param types. The callbackParams you supply are echoed back through the VM parameter bus, which is string-typed — so every element is delivered to the callback as a string, regardless of the type you passed. A request that supplies [42, true, null] reaches the callback as ['42', 'true', 'null']. This has always been the case; it is a property of the string-based wire format, not a recent change. Re-parse numeric or boolean context inside the callback with parseInt, parseFloat, or JSON.parse as the example above does for roundId.

Inside the callback, xchain.getSourceAddress() returns the contract’s own derived address — the platform invokes the callback as if the contract were calling itself. The callback runs in its own savepoint: if the callback throws or runs out of gas, the response is still recorded on-chain (so the request doesn’t get retried) but the contract’s state changes are rolled back.

Providers

Two providers ship in the initial release. Governance can add more over time.

Provider What it does Payload Consensus
http_get Fetches an HTTPS URL and returns the response body URL string Exact byte-equality across validators
llm Sends a prompt to an approved language model JSON {prompt, max_tokens?, temperature?, system?} Judge-model semantic equivalence

For llm payload fields, approved models, and provider-specific limits, see protocol/providers/llm.md. For the full protocol-level lifecycle, see protocol/actions/ATTEST.md.

Patterns

  • Single-shot AI verdict. redundancy: 1 + tight max_tokens. Cheapest path; fine for non-critical use.
  • Auditable AI verdict. redundancy: 3 or 5. Multiple validators independently fetch and a judge model decides whether they agree. Use when the contract’s decision needs to be verifiable by anyone replaying the chain.
  • Real-world data trigger. http_get against an HTTPS endpoint that returns deterministic content (price API, official data feed, JSON record). Pair with redundancy: 3 to require exact agreement across validators.
  • Deadline as fallback. Always handle status='expired' — the validator network may be unavailable, the provider may be offline, or the response may simply have arrived too late. Treat absence of an answer as a real outcome.

Contract-Targeted Staking — xchain.contract.*

A contract can declare itself stakeable at deploy time. Once deployed, anyone can lock any token against the contract; the contract’s own code decides what staking unlocks, and the contract can slash any of its stakers’ locked tokens at any time. Slashed tokens are routed to a destination locked in at deploy time (a specific address or the chain’s burn address).

Declaring a contract stakeable

Add two trailing fields to your DEPLOY action:

DEPLOY|1|<hex code>|<gas_limit>|<constructor_params>|<cooldown_blocks>|<slash_destination>
Field Notes
COOLDOWN_BLOCKS How long a staker waits after calling UNSTAKE before their tokens are returned. Bounded [1, 100000]. Omit to make the contract not stakeable.
SLASH_DESTINATION Address that receives slashed tokens, or the keyword BURN. If COOLDOWN_BLOCKS is set but SLASH_DESTINATION is omitted, defaults to BURN.

Both fields are locked permanently at deploy time. Neither you nor anyone else can change them later. Design carefully — stakers will inspect these before locking up.

Reading stake state from inside the contract

// How much has a specific staker locked, in a given token?
var amount = xchain.contract.getStake(signingPubkey, 'XCHAIN');

// What is the total staked across everyone for a given token?
var total = xchain.contract.getTotalStaked('XCHAIN');

// Who are the top stakers? Returns up to 1000 entries sorted descending.
var stakers = xchain.contract.getStakers('XCHAIN');
// → [{ pubkey: '...', amount: '500' }, { pubkey: '...', amount: '300' }, ...]

All three reads cost VM_STATE_READ (100) gas. The 1000-entry cap on getStakers is fixed — if your contract may have more stakers than that, design accordingly (don’t rely on iterating all of them in a single call).

Stakes within the 6-block activation window are not yet visible to these reads.

Slashing

xchain.contract.slash(signingPubkey, 'XCHAIN', '50');
  • The slash can only target stakers of this contract — authorization is implicit; you cannot accidentally slash someone else’s contract’s stakers.
  • Slashed tokens go to the destination you set at deploy time.
  • The slash reaches a staker’s currently-active stake first; if there is still a remainder, it pulls from the cooldown-locked balance the staker has already begun withdrawing. (Stakers cannot escape an imminent slash by initiating an unstake.)
  • Over-slash is silently capped at the staker’s available balance — no error is thrown when you ask for more than they have.
  • Atomic with the calling EXECUTE: if the calling method reverts, the slash rolls back too.

Slash costs VM_EMISSION (500) gas.

Worked example: simple bonded service

// Deploy with: COOLDOWN_BLOCKS=50, SLASH_DESTINATION=BURN
module.exports = {
    // Anyone can check: does this pubkey hold at least 100 XCHAIN against this contract?
    isQualified: function(xchain) {
        var pubkey = xchain.getInputParam(0);
        return xchain.math.gte(xchain.contract.getStake(pubkey, 'XCHAIN'), '100') ? '1' : '0';
    },

    // Owner-only — slash a misbehaving staker.
    punish: function(xchain) {
        xchain.require(
            xchain.getSourceAddress() === xchain.state.get('owner'),
            'only owner can punish'
        );
        var pubkey = xchain.getInputParam(0);
        var amount = xchain.getInputParam(1);
        xchain.contract.slash(pubkey, 'XCHAIN', amount);
    }
};

For the full protocol-level spec — wire format, isolation between contract and capability staking, cooldown sweep behavior, the slash_events table — see protocol/Contract_Staking.md.

Calling Other Contracts — emit.execute

A contract can invoke a method on another deployed contract (or itself) by emitting an EXECUTE:

xchain.emit.execute({
    contractIndex: 1234,        // the target contract's DEPLOY action index
    method: 'onPayment',        // method to invoke (max 64 bytes, no "|")
    params: ['order-7', '250'], // optional string args (max 32, 1024 bytes each, no "|")
    gasLimit: 50000             // gas you fund the callee with (min 5,000)
});

Deferred execution

The call is deferred, not inline: the callee runs after your method finishes, in the order you emitted it, within the same atomic scope. Your state changes are fully applied before the callee starts, so the callee sees your updated state — and classic re-entrancy is impossible by construction. There is no return value; a callee that must respond calls you back via its own emit.execute (the same pattern as attestation callbacks).

Inside the callee, xchain.getSourceAddress() is the calling contract’s address (C:<CHAIN>:<index>), so a callee can authenticate which contract called it.

Gas

emit.execute charges VM_EMISSION (500) + gasLimit to your gas budget at the moment you call it — you fund the callee’s entire run up front, so a call tree can never use more gas than the original EXECUTE’s ceiling. Whatever the callee doesn’t use is refunded at fee settlement, so a generous gasLimit costs nothing extra if the tree succeeds; an under-funded callee runs out of gas and fails the whole tree. gasLimit must be at least 5,000 and fit within your remaining gas.

Depth and failure semantics

  • Max call depth is 4 (a user’s EXECUTE runs at depth 0). emit.execute at the limit throws — check xchain.getCallDepth() if your contract may itself be called by other contracts.
  • Strict atomicity: if any call in the tree fails — revert, out of gas, unknown contract, invalid emission — the entire tree rolls back, including your state changes and every other emission. The original caller still pays for the gas consumed (refunds are forfeited on failure).
  • Cycles (A→B→A) are allowed within the depth budget.

Calling Contracts on Other Chains — emit.crossExecute

A contract can invoke a method on a contract deployed on a different chain (BTC/LTC/DOGE). The validator federation relays the call after your chain’s confirmation depth and relays the outcome back — there is no extra on-chain transaction, but the round trip takes minutes to tens of minutes. Design fully async: emit the call, return, and handle the outcome in the callback.

const callId = xchain.emit.crossExecute({
    targetChain: 'DOGE',          // BTC/LTC/DOGE, not your own chain
    contractIndex: 4321,          // the target contract's DEPLOY action index ON THAT CHAIN
    method: 'onArrival',          // must be in the target's crossCallable allowlist
    params: ['order-7', '250'],   // optional string args (max 32, 1024 bytes each, no "|")
    gasLimit: 50000,              // gas the remote run gets (5,000 – 200,000; NOT refunded)
    callbackMethod: 'onResult',   // REQUIRED — every call ends in exactly one callback
    callbackParams: ['ctx'],      // optional strings echoed back to you
    deadlineBlocks: 400           // optional; your-chain blocks before a local 'expired' callback
});

The outcome always arrives as a callback into your contract:

onResult: function(xchain) {
    let callId       = xchain.getInputParam(0);
    let targetChain  = xchain.getInputParam(1);
    let status       = xchain.getInputParam(2);  // ok | reverted | out_of_gas | no_contract |
                                                 // not_callable | payload_too_large | error | expired
    let returnValue  = xchain.getInputParam(3);  // target method's JSON return (<=1,024 bytes)
    // ...your callbackParams follow from index 4
}

xchain.crossChain.getCallResult(callId) returns { status, payload } once the call is terminal (the block after it resolved), null while in flight — useful for idempotency checks.

Receiving cross-chain calls — crossCallable

A contract is not callable cross-chain unless it opts in by exporting an allowlist:

module.exports = {
    crossCallable: ['onArrival'],          // only these methods accept cross-chain calls
    onArrival: function(xchain) {
        // xchain.getSourceAddress() is the CALLING contract's address on ITS chain,
        // e.g. 'C:BTC:1234' — authenticate cross-chain callers with it.
        // xchain.getCrossHops() > 0 here; calling back out consumes the hop budget.
    }
};

This allowlist is the security boundary: the federation’s signed dispatch can only reach methods you listed. Calls to anything else fail with not_callable.

Gas, hops, and failure semantics

  • Pre-paid, no refunds: crossExecute charges VM_EMISSION (500) + 2,000 (request) + gasLimit + 20,000 (callback ceiling) to your budget at emit time. Unused remote gas is not refunded — size gasLimit to the work, not generously.
  • Hop budget is 2: your call is hop 1; the remote contract calling back (or onward) is hop 2; further cross-chain calls from that context throw. xchain.getCrossHops() reports the current count.
  • Failures are delivered, not thrown: a remote revert/out-of-gas/missing contract rolls back the remote state and your callback receives the failure status. If nothing comes back before deadlineBlocks, you get a deterministic expired callback — your contract always hears exactly one outcome.
  • No value transfer: params and a return payload only. Move tokens with the cross-chain DEX, not calls.
  • Not available from constructors.

Protocol details: protocol/Cross_Chain_Calls.md and protocol/actions/XCALL.md.

Limitations

  • No synchronous cross-contract callsemit.execute() is deferred and returns no value. A callee that must respond calls back via its own emit.execute.
  • No state.keys() — contracts must manage their own key indexing for collections
  • Immutable code — deployed contracts cannot be updated. Use the proxy pattern for upgradeability.
  • No direct network access — contracts cannot make HTTP calls or read files themselves. Use xchain.attestation.request to delegate the fetch to the validator network.
  • Synchronous only — no async/await execution; Promises never resolve in the sandbox. Attestation results arrive in a separate callback EXECUTE, not as a return value.

Example: Token Vesting Contract

module.exports = {
    initialize: function(xchain) {
        xchain.state.set('beneficiary', xchain.getInputParam(0));
        xchain.state.set('token', xchain.getInputParam(1));
        xchain.state.set('totalAmount', xchain.getInputParam(2));
        xchain.state.set('startBlock', String(xchain.getBlockHeight()));
        xchain.state.set('vestingBlocks', xchain.getInputParam(3) || '1000');
        xchain.state.set('claimed', '0');
    },
    claim: function(xchain) {
        xchain.require(
            xchain.getSourceAddress() === xchain.state.get('beneficiary'),
            'only beneficiary can claim'
        );

        var currentBlock = xchain.getBlockHeight();
        var startBlock = parseInt(xchain.state.get('startBlock'));
        var vestingBlocks = parseInt(xchain.state.get('vestingBlocks'));
        var totalAmount = xchain.state.get('totalAmount');
        var claimed = xchain.state.get('claimed');

        var elapsed = currentBlock - startBlock;
        var vested;
        if (elapsed >= vestingBlocks) {
            vested = totalAmount;
        } else {
            vested = xchain.math.divide(
                xchain.math.multiply(totalAmount, String(elapsed)),
                String(vestingBlocks)
            );
        }

        var claimable = xchain.math.subtract(vested, claimed);
        xchain.require(xchain.math.gt(claimable, '0'), 'nothing to claim');

        xchain.state.set('claimed', xchain.math.add(claimed, claimable));
        xchain.emit.send({
            destination: xchain.state.get('beneficiary'),
            tick: xchain.state.get('token'),
            quantity: claimable
        });
    }
};

Copyright © 2025–2026 Dankest, LLC

Based on XChain Platform by Dankest, LLC – https://dankest.llc

Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0-or-later) with a commercial license available for proprietary use.

You may use, modify, and distribute this material under the terms of the License. See LICENSE and NOTICE for full terms. See the licensing overview.

Edit this page on GitHub ↗