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:
// 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;
}| Member | What it does |
|---|---|
vm | The vm token this dialect serves (for example "xvm"). Matches the token you register it under. |
curve | The signing curve, one of the CurveName values. Selects the raw signing primitive the key custody uses. |
signMessage | Signs a plain string message, returning your chain's signature string. |
signMessageEncoded | Signs under a given MessageEncoding, returning { signature, messageHash }. |
signTypedData | Signs structured typed data, returning { signature, domainSeparator, structHash }. |
buildTransaction | Builds an unbound Transaction; the Address binds its own address after delegating. |
buildTransferPayload | Lowers a TransferRequest intent into a chain-specific TransferPayload (not a Transaction) that the caller spreads into buildTransaction. |
derive | Derives the address and private key at a BIP-44 index from the account seed. |
addressFromPrivateKey | Returns the address a raw private key controls; throws if the key is malformed. |
privateKeyMatches | Whether a private key controls publicKey; returns false (never throws) on any input it cannot process. |
Address resolves an asset by { symbol } down to native or { address } before delegating, so buildTransferPayload only ever sees a native or by-address request. Likewise Address matches the requested encoding to the vm before calling signMessageEncoded.
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.
The seed handed to derive is borrowed — the account zeroes it on lock. Never retain it, copy it and leave the copy, or zero it yourself.
Built-in dialects
Two dialects ship built in and register themselves when the package is imported:
| vm token | curve |
|---|---|
evm | secp256k1 |
svm | ed25519 |
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:
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.