Custom ChainsChainDialect

ChainDialect

Subclass the exported ChainDialect to teach the library a chain it does not ship — implementing keyless message, typed-data, transaction and transfer building plus derive/addressFromPrivateKey/privateKeyMatches against a declared curve.

ChainDialect is the public extension point for teaching wative-core a chain it does not ship. A dialect implements one chain's construction work — signing messages and typed data, building transactions, lowering transfer intents — plus that chain's key derivation, all against a single declared signing curve. Address delegates its per-vm behaviour to the dialect registered for its vm rather than branching internally, so adding a chain never means editing core.

The built-in EVM and Solana dialects are written against this exact contract. To add your own, subclass ChainDialect, implement every abstract member, and register it under a vm token — see Registering a Dialect.

The dialect contract

ChainDialect is an abstract class. A subclass must implement two readonly members and eight methods:

chain-dialect-contract.ts
// The contract shape. Every named type below — ChainCtx, MessageEncoding,
// CurveName, Transaction, EvmTxBuildParams, SvmTxBuildParams, TransferRequest,
// TransferPayload — is exported from "wative-core".

abstract class ChainDialect {
  // The vm token this dialect serves.
  abstract readonly vm: VmToken;
  // The signing curve KeyCustody uses for this chain.
  abstract readonly curve: CurveName;

  abstract signMessage(ctx: ChainCtx, message: string): string;
  abstract signMessageEncoded(
    ctx: ChainCtx,
    message: string,
    encoding: MessageEncoding,
  ): { signature: string; messageHash: string };
  abstract signTypedData(
    ctx: ChainCtx,
    typedData: unknown,
    chainId: number | bigint,
  ): { signature: string; domainSeparator: string; structHash: string };
  abstract buildTransaction(
    ctx: ChainCtx,
    params: EvmTxBuildParams | SvmTxBuildParams,
  ): Transaction;
  abstract buildTransferPayload(ctx: ChainCtx, req: TransferRequest): TransferPayload;

  abstract derive(seed: Uint8Array, index: number): { publicKey: string; privateKey: string };
  abstract addressFromPrivateKey(privateKey: string): string;
  abstract privateKeyMatches(privateKey: string, publicKey: string): boolean;
}
MemberWhat it does
vmThe vm token this dialect serves (for example "xvm"). Matches the token you register it under.
curveThe signing curve, one of the CurveName values. Selects the raw signing primitive the key custody uses.
signMessageSigns a plain string message, returning your chain's signature string.
signMessageEncodedSigns under a given MessageEncoding, returning { signature, messageHash }.
signTypedDataSigns structured typed data, returning { signature, domainSeparator, structHash }.
buildTransactionBuilds an unbound Transaction; the Address binds its own address after delegating.
buildTransferPayloadLowers a TransferRequest intent into a chain-specific TransferPayload (not a Transaction) that the caller spreads into buildTransaction.
deriveDerives the address and private key at a BIP-44 index from the account seed.
addressFromPrivateKeyReturns the address a raw private key controls; throws if the key is malformed.
privateKeyMatchesWhether a private key controls publicKey; returns false (never throws) on any input it cannot process.

Keyless by design

The signing methods never receive the private key. They operate through a ChainCtx — a dedicated keyless view of the Address exposing only publicKey, vm, network, assets, and a raw-sign handle _signBytes. At runtime the ctx is a separate object the Address builds, not the Address itself, which is what keeps the private key unreachable: the dialect calls ctx._signBytes(...) to sign and formats the result, but the key stays sealed in the internal key custody.

The three derivation methods are the deliberate exception — they handle key material directly, because no Address exists yet at derive or import time. They take no ctx: derive returns a plaintext private key straight to the caller (which seals it immediately), and the dialect retains nothing.

Built-in dialects

Two dialects ship built in and register themselves when the package is imported:

vm tokencurve
evmsecp256k1
svmed25519

Their concrete classes are internal — the public surface is the ChainDialect base class plus registerDialect. You extend the library by subclassing and registering, not by importing the built-ins. The built-in evm and svm tokens are also protected: a later registerDialect("evm", …) is refused (see Registering a Dialect).

A worked example: the xvm chain

The shipped consumer example suite includes example 20, a complete custom dialect for a toy "xvm" chain. Its formats and stand-in signer are illustrative only, but the shapes it uses are the real contract:

xvm-dialect.ts
import { Address, ChainDialect, registerDialect } from "wative-core";

// The signer subclass a chain author exports, mirroring EvmSigner / SvmSigner.
class XvmSigner extends Address {}

class XvmDialect extends ChainDialect {
  get vm() {
    return "xvm";
  }
  get curve() {
    return "ed25519";
  }

  signMessage(ctx, message) {
    const raw = ctx._signBytes(new TextEncoder().encode(`xvm:${message}`));
    return `xvm-sig:${Buffer.from(raw).toString("hex")}`;
  }
  signMessageEncoded(ctx, message, _encoding) {
    return { signature: this.signMessage(ctx, message), messageHash: `0x${toyHash(message)}` };
  }
  signTypedData(ctx, typedData, _chainId) {
    const payload = JSON.stringify(typedData);
    return {
      signature: this.signMessage(ctx, payload),
      domainSeparator: `0x${toyHash(`domain:${payload}`)}`,
      structHash: `0x${toyHash(`struct:${payload}`)}`,
    };
  }
  buildTransaction(ctx, params) {
    return { vm: "xvm", from: String(ctx.publicKey), to: params.to, value: params.value ?? 0n, nonce: params.nonce ?? 0 };
  }
  buildTransferPayload(ctx, req) {
    const payload = { vm: "xvm", from: String(ctx.publicKey), to: req.to, amount: req.amount };
    if (req.asset && req.asset.address) payload.token = req.asset.address;
    return payload;
  }
  derive(seed, index) {
    const h = toyHash(`${Buffer.from(seed).toString("hex")}:${index}`);
    return { publicKey: `xvm1${h}${index}`, privateKey: `xsk1${h}${index}` };
  }
  addressFromPrivateKey(privateKey) {
    if (typeof privateKey !== "string" || !privateKey.startsWith("xsk1")) throw new Error("malformed xvm private key");
    return `xvm1${privateKey.slice(4)}`;
  }
  privateKeyMatches(privateKey, publicKey) {
    try {
      return this.addressFromPrivateKey(privateKey) === publicKey;
    } catch {
      return false;
    }
  }
}

// One call teaches the library about "xvm" and opens the vm whitelist to that token.
registerDialect("xvm", () => new XvmDialect());

The per-chain signer pattern (class XvmSigner extends Address {}) mirrors the built-in EvmSigner / SvmSigner exported from wative-core.

Next

Last updated on