The EVM chain module gives you direct access to the cryptographic primitives that power stealth addresses on Horizen, Ethereum, Polygon, Base, and any other EVM-compatible chain. All operations use secp256k1 and follow the ERC-5564 stealth address scheme. Import from @wraith-protocol/sdk/chains/evm.
Most developers should use the agent client instead. These primitives are for power users who need to build custom stealth address integrations without the managed platform — for example, integrating stealth addresses into an existing wallet or dApp.
When to use primitives vs the agent client
Use the agent client (@wraith-protocol/sdk) when you want managed infrastructure: the TEE handles key storage, payment scanning, AI interaction, and on-chain transactions.
Use the EVM primitives (@wraith-protocol/sdk/chains/evm) when you need full control: you manage your own keys, query announcements yourself, and sign transactions with your own tooling.
Import
import {
deriveStealthKeys,
generateStealthAddress,
scanAnnouncements,
deriveStealthPrivateKey,
encodeStealthMetaAddress,
decodeStealthMetaAddress,
checkStealthAddress,
signNameRegistration,
metaAddressToBytes,
STEALTH_SIGNING_MESSAGE,
SCHEME_ID,
META_ADDRESS_PREFIX,
} from "@wraith-protocol/sdk/chains/evm";
import type {
HexString,
StealthKeys,
StealthMetaAddress,
GeneratedStealthAddress,
Announcement,
MatchedAnnouncement,
} from "@wraith-protocol/sdk/chains/evm";
Constants
// Prompt the user to sign exactly this message for key derivation
const STEALTH_SIGNING_MESSAGE =
"Sign this message to generate your Wraith stealth keys.\n\nChain: Horizen\nNote: This signature is used for key derivation only and does not authorize any transaction.";
const SCHEME_ID = 1n; // bigint — ERC-5564 scheme ID
const META_ADDRESS_PREFIX = "st:eth:0x"; // prefix for stealth meta-addresses
Core types
type HexString = `0x${string}`;
interface StealthKeys {
spendingKey: HexString; // 32-byte secp256k1 private key
viewingKey: HexString; // 32-byte secp256k1 private key
spendingPubKey: HexString; // 33-byte compressed public key
viewingPubKey: HexString; // 33-byte compressed public key
}
interface GeneratedStealthAddress {
stealthAddress: HexString; // 20-byte EVM address
ephemeralPubKey: HexString; // 33-byte compressed public key — publish with announcement
viewTag: number; // 0-255 — publish with announcement
}
interface Announcement {
schemeId: bigint;
stealthAddress: HexString;
caller: HexString;
ephemeralPubKey: HexString;
metadata: HexString; // first byte is the view tag
}
interface MatchedAnnouncement extends Announcement {
stealthPrivateKey: HexString; // spending key for this stealth address
}
Functions
deriveStealthKeys(signature)
Derive a spending and viewing key pair from a wallet signature. The same signature always produces the same keys — this is deterministic key derivation.
const signature = await wallet.signMessage(STEALTH_SIGNING_MESSAGE);
const keys = deriveStealthKeys(signature as HexString);
console.log(keys.spendingKey); // "0x..." (32-byte private key)
console.log(keys.viewingKey); // "0x..." (32-byte private key)
console.log(keys.spendingPubKey); // "0x02..." (33-byte compressed)
console.log(keys.viewingPubKey); // "0x03..." (33-byte compressed)
A 65-byte EIP-191 wallet signature (0x-prefixed hex). Sign STEALTH_SIGNING_MESSAGE with the user’s wallet.
Returns: StealthKeys
How it works: The 65-byte signature is split into two 32-byte halves. spendingKey = keccak256(r) and viewingKey = keccak256(s), where r = sig[0:32] and s = sig[32:64]. Compressed public keys are derived from each private key. The two keys are always distinct.
Never store the signature itself — derive the keys at runtime and keep the private keys in memory only as long as needed.
generateStealthAddress(spendingPubKey, viewingPubKey, ephemeralKey?)
Generate a one-time stealth address for a recipient. Call this on the sender’s side using the recipient’s public keys from their stealth meta-address.
const result = generateStealthAddress(
recipientKeys.spendingPubKey,
recipientKeys.viewingPubKey,
);
console.log(result.stealthAddress); // "0x..." — send funds here
console.log(result.ephemeralPubKey); // "0x..." — publish in the announcement
console.log(result.viewTag); // 0-255 — publish in the announcement
The recipient’s 33-byte compressed secp256k1 spending public key.
The recipient’s 33-byte compressed secp256k1 viewing public key.
Override the randomly generated ephemeral private key. Use only for deterministic testing — in production this should always be random.
Returns: GeneratedStealthAddress
How it works:
- Generate a random ephemeral key pair
(r, R = r × G)
- Compute ECDH shared secret:
S = r × viewingPubKey
hashedSecret = keccak256(S)
viewTag = hashedSecret[0]
stealthPoint = spendingPubKey + keccak256(S) × G
stealthAddress = keccak256(stealthPoint)[12:32]
Each call produces a different stealth address (new random ephemeral key), making payments unlinkable on-chain.
scanAnnouncements(announcements, viewingKey, spendingPubKey, spendingKey)
Scan an array of on-chain ERC-5564 announcements and return only those that belong to you. Call this on the recipient’s side periodically or after fetching new events from the subgraph or chain.
import type { Announcement } from "@wraith-protocol/sdk/chains/evm";
// Fetch announcements from your subgraph or on-chain events
const announcements: Announcement[] = [/* ... */];
const matched = scanAnnouncements(
announcements,
keys.viewingKey,
keys.spendingPubKey,
keys.spendingKey,
);
for (const m of matched) {
console.log(m.stealthAddress); // "0x..." — address you control
console.log(m.stealthPrivateKey); // "0x..." — private key for this address
}
Array of on-chain announcements fetched from the ERC-5564 announcer contract events or a subgraph.
Your 32-byte secp256k1 viewing private key (from deriveStealthKeys).
Your 33-byte compressed secp256k1 spending public key (from deriveStealthKeys).
Your 32-byte secp256k1 spending private key (from deriveStealthKeys). Used to derive the stealth private key for matched announcements.
Returns: MatchedAnnouncement[]
How it works: For each announcement, the function first checks the schemeId, then extracts the view tag from metadata[0] and performs a cheap tag comparison. Only ~1/256 non-matching announcements reach the full ECDH check. Matched announcements include the computed stealthPrivateKey.
deriveStealthPrivateKey(spendingKey, ephemeralPubKey, viewingKey)
Compute the private key that controls a specific stealth address. Call this when you want to spend from a stealth address you have already identified as yours.
import { privateKeyToAccount } from "viem/accounts";
const privateKey = deriveStealthPrivateKey(
keys.spendingKey,
stealth.ephemeralPubKey,
keys.viewingKey,
);
// Use with viem to sign and submit transactions
const account = privateKeyToAccount(privateKey);
console.log(account.address); // matches the stealth address
Your 32-byte secp256k1 spending private key.
The 33-byte ephemeral public key from the announcement.
Your 32-byte secp256k1 viewing private key.
Returns: HexString — the 32-byte private key for the stealth address
How it works:
S = viewingKey × ephemeralPubKey (ECDH shared secret)
hashScalar = keccak256(S) mod n
stealthPrivateKey = (spendingKey + hashScalar) mod n
scanAnnouncements calls this internally and includes stealthPrivateKey on each MatchedAnnouncement. Call deriveStealthPrivateKey directly only if you have an announcement and want the key without scanning a batch.
Encode two public keys into a stealth meta-address string. Share this string so senders can generate stealth addresses for you.
const metaAddress = encodeStealthMetaAddress(
keys.spendingPubKey,
keys.viewingPubKey,
);
// "st:eth:0x02abc...03def..."
Both keys must be 33-byte compressed secp256k1 points (0x02... or 0x03...).
Decode a meta-address string back into its component public keys. Use this on the sender’s side before calling generateStealthAddress.
const { spendingPubKey, viewingPubKey } = decodeStealthMetaAddress(
"st:eth:0x02abc...03def...",
);
Validates the st:eth:0x prefix, total length, and that both keys are valid secp256k1 points. Throws if validation fails.
Strip the st:eth: prefix from a meta-address, returning only the raw hex bytes. Required before passing a meta-address to the name registration functions.
const bytes = metaAddressToBytes("st:eth:0x02abc...03def...");
// "0x02abc...03def..."
End-to-end example
This example shows the full sender → recipient flow using the EVM primitives directly.
import {
deriveStealthKeys,
generateStealthAddress,
scanAnnouncements,
deriveStealthPrivateKey,
encodeStealthMetaAddress,
decodeStealthMetaAddress,
STEALTH_SIGNING_MESSAGE,
} from "@wraith-protocol/sdk/chains/evm";
import type { HexString, Announcement } from "@wraith-protocol/sdk/chains/evm";
import { privateKeyToAccount } from "viem/accounts";
// ── Recipient setup ──────────────────────────────────────────────────
// 1. Derive keys from wallet signature (done once; same sig → same keys)
const sig = await wallet.signMessage(STEALTH_SIGNING_MESSAGE);
const keys = deriveStealthKeys(sig as HexString);
// 2. Publish the stealth meta-address so senders can find you
const metaAddress = encodeStealthMetaAddress(
keys.spendingPubKey,
keys.viewingPubKey,
);
// Share "st:eth:0x..." publicly or register a .wraith name
// ── Sender ───────────────────────────────────────────────────────────
// 3. Decode the recipient's meta-address
const { spendingPubKey, viewingPubKey } = decodeStealthMetaAddress(metaAddress);
// 4. Generate a fresh one-time stealth address
const stealth = generateStealthAddress(spendingPubKey, viewingPubKey);
// stealth.stealthAddress — send ETH/tokens here
// stealth.ephemeralPubKey + stealth.viewTag — publish via ERC-5564 announcer
// ── Recipient scanning ───────────────────────────────────────────────
// 5. Fetch announcements from the subgraph (Goldsky) or chain events
const announcements: Announcement[] = [/* fetched from chain */];
const matched = scanAnnouncements(
announcements,
keys.viewingKey,
keys.spendingPubKey,
keys.spendingKey,
);
// ── Recipient spending ───────────────────────────────────────────────
// 6. Sign and submit transactions from matched stealth addresses
for (const m of matched) {
const account = privateKeyToAccount(m.stealthPrivateKey);
// account.address === m.stealthAddress
// Use account with viem walletClient to sign transactions
}