Registry and identity
Returns the 66-byte stealth meta-address derived from the wallet signature.
const meta = client.getMetaAddressHex();
Resolve ANY supported recipient identity to a validated 66-byte meta-address. This is
the one entry point for “who am I paying”: it accepts the same inputs you can pass to
sendStealthPayment({ recipient }).
const r = await client.resolveRecipient("alice.opq.eth");
// { metaAddressHex, source, input }
| Input | Resolution path | source |
|---|
66-byte meta-address (optionally st:opq:-prefixed) | Validated and passed through | "meta-address" |
0x... 20-byte EVM address | ERC-6538 StealthMetaAddressRegistry | "evm-registry" |
| Solana base58 pubkey | stealth-registry PDA (needs solana config) | "solana-registry" |
ipfs://CID or bare CID | DID document fetched via gateways (configure ipfs) | "ipfs-did" |
ONS name (alice.opq.eth; testnet parent opqtest.eth) | Solana mirror PDA first, canonical OpaqueNameRegistry fallback | "ons-mirror" / "ons-registry" |
other *.eth name | ENS com.opaque.meta text record (needs ens config) | "ens-text" |
*.sol name | SNS Records V2 TXT record (needs solana config or sns.getRecord) | "sns-record" |
Every path validates that both 33-byte halves of the result are valid compressed
secp256k1 points before returning. Unregistered, unset, or malformed identities throw
with a path-specific message.
ONS names (ONS.md). Subnames
of the Opaque Name Service parent resolve mirror-first: with solana configured, the
SDK derives the mirror PDA from keccak256(name) and reads one Solana account, no
Ethereum RPC. Without solana
(or when the mirror has no record yet), it falls back to the canonical
OpaqueNameRegistry wildcard resolver over your EVM RPC. The mirror lags the canonical
record by Wormhole latency (eventually consistent, canonical-chain-wins). Defaults
(parent name, registry, mirror program) ship in @opaquecash/deployments; override
with ons: { parentName, registry, mirrorProgram }.
// The named entry point if you only want the meta-address:
const meta = await client.resolveOpaqueMetaAddress("alice.opqtest.eth");
.sol names. With solana configured, the SDK reads the domain’s Records V2 TXT
record via the bundled @bonfida/spl-name-service reader and validates it as a CSAP
2.9 value (st:opq:-prefixed or raw 132-hex). Inject sns: { getRecord } to mock or
to read from a custom source.
ONS name management
Register, claim, and track ONS names for this wallet’s meta-address (see the
naming guide for the consistency model):
// Ethereum (authoritative immediately; mirror follows after Wormhole relay):
const txHash = await client.registerOpaqueName("alice");
// Solana-only claim (PROVISIONAL until the canonical registry confirms):
const { signature, name } = await client.claimOpaqueName("bob");
// Track a claim through pending / confirmed / lost / expired:
const { state } = await client.getOpaqueNameStatus("bob.opqtest.eth");
// Close a finished claim and reclaim the provisional rent:
await client.reconcileOpaqueName("bob.opqtest.eth");
| Method | Chain / signer | Notes |
|---|
registerOpaqueName(label) | Ethereum signer | First-come-first-served on the canonical registry |
claimOpaqueName(label) | solanaWallet | Provisional; loses to a concurrent Ethereum registration |
getOpaqueNameStatus(name) | Solana reads | none / pending / confirmed / lost / expired |
reconcileOpaqueName(name) | solanaWallet | Permissionless close; rent refunds to the claimer |
ENS setup. The *.eth path reads the com.opaque.meta text record (CSAP 2.9).
Pass an ENS-capable viem PublicClient (mainnet or Sepolia; your scan RPC usually is
not one), or inject a custom reader:
import { createPublicClient, http } from "viem";
import { mainnet } from "viem/chains";
const client = await OpaqueClient.create({
// ...
ens: { client: createPublicClient({ chain: mainnet, transport: http(ensRpcUrl) }) },
});
// Or a custom reader (tests, alternative resolvers):
ens: { getText: async (name, key) => myResolver.text(name, key) }
IPFS setup. The ipfs:// path fetches a DID document from public gateways
(ipfs.io, cloudflare-ipfs.com by default; tried in order) and extracts the
meta-address from either a service entry of type OpaqueStealthMetaAddress or a
top-level com.opaque.meta / opaqueMetaAddress field:
ipfs: {
gateways: ["https://my-gateway.example"], // optional override
fetch: myFetch, // optional (e.g. a local node / Helia loader)
}
Lower-level Ethereum-only registry lookup. Unlike resolveRecipient, it does not throw
for unregistered recipients, so it suits “is this person on Opaque yet?” UI checks.
const res = await client.resolveRecipientMetaAddress("0xRecipient...");
// { recipientAddress, registered, metaAddressHex? }
Encode registerKeys calldata without submitting.
const req = client.buildRegisterMetaAddressTransaction();
// { to, data, chainId, metaAddressHex }
await walletClient.sendTransaction({ to: req.to, data: req.data });
Submit meta-address registration on "ethereum" or "solana".
const { txHash, metaAddressHex, chain } = await client.registerMetaAddress("ethereum");
Signers: ethereumWalletClient / ethereumProvider (EVM) or solanaWallet (Solana).
Check if this wallet’s meta-address is registered.
const registered = await client.isMetaAddressRegistered("solana");
Send
Derive one-time stealth material for sending. Pure crypto, no chain call.
const send = client.prepareStealthSend(metaAddressHex);
/*
schemeId, stealthAddress, viewTag,
ephemeralPublicKey, ephemeralPrivateKey,
metadata, stealthPubKey
*/
sendStealthPayment(params)
High-level native send + announce (optional relay, optional delayed announce).
import { parseEther } from "viem";
const result = await client.sendStealthPayment({
chain: "ethereum",
recipient: "0x...", // anything resolveRecipient accepts: EOA, meta-address,
// Solana pubkey, ipfs:// DID, or *.eth name
amount: parseEther("0.01"),
announce: true, // default
relay: false, // set true for cross-chain UAB
delayAnnouncement: 60_000, // optional ms; see anonymity utilities below
token: undefined, // ERC-20 address / SPL mint; omit for the native asset
gasDrop: undefined, // optional native top-up to the stealth address with a token send
batchId: undefined, // Solana Wormhole nonce when relay: true
});
Returns: SendStealthPaymentResult:
{ chain, txHash, announceTxHash?, announcePromise?, stealthAddress, destination?, ephemeralPublicKey, metaAddressHex }
Solana bundles transfer + announce in one transaction (unless delayAnnouncement is
set, which forces two). Ethereum submits the transfer first, then the announce.
prepareGhostReceive()
Derive stealth receive material for your own meta-address (no announce yet).
const ghost = client.prepareGhostReceive();
// Store ghost.ephemeralPrivateKey securely
buildAnnounceTransactionRequest(send)
Build StealthAddressAnnouncer.announce calldata.
const req = client.buildAnnounceTransactionRequest(send);
// { to, data, chainId, summary }
buildAnnounceTransactionRequestForGhost(ephemeralPrivateKey)
Rebuild announce calldata from a stored ephemeral private key only.
const req = client.buildAnnounceTransactionRequestForGhost(storedEphemeralPriv);
Anonymity utilities
Two tools for growing the anonymity set around your payments (guide section 17).
delayAnnouncement (send option)
Decouple send time from announce time: the value transfer is submitted immediately,
the announcement only after the delay, breaking the timing correlation between the two
on-chain events.
const result = await client.sendStealthPayment({
chain: "ethereum",
recipient,
amount: parseEther("0.01"),
delayAnnouncement: 5 * 60_000, // announce 5 minutes after the transfer
});
console.log("transfer:", result.txHash);
// result.announceTxHash is undefined; the pending announce is a promise:
const announceTx = await result.announcePromise;
Await (or attach a handler to) announcePromise, and keep the process alive until it
resolves. If your process exits first, the announcement is never submitted and the
recipient cannot discover the payment by scanning.
generateDummyAnnouncements(n)
Mint n decoy announcements. Each is a fully valid DKSAP announcement to a freshly
generated THROWAWAY meta-address whose private keys are discarded: on-chain it is
indistinguishable from a real payment announcement (valid curve points, correctly
derived view tag), but nobody will ever match or spend it.
const dummies = client.generateDummyAnnouncements(5);
// DummyAnnouncement[]: PrepareStealthSendResult + the throwaway metaAddressHex
buildDummyAnnouncementTransactions(n)
Convenience over generateDummyAnnouncements: ready-to-submit announce calldata.
Broadcast from any account, interleaved with real sends; announcements carry no value
and anyone may announce.
for (const tx of client.buildDummyAnnouncementTransactions(3)) {
await walletClient.sendTransaction({ to: tx.to, data: tx.data });
}
Scan and filter
fetchAnnouncementRows(chain, opts?)
Fetch ALL native announcements on a chain as indexer-shaped rows, unfiltered, with full
on-chain metadata. Raw input for the metadata-aware scanners (filterOwnedAnnouncements,
discoverTraitsV2, and legacy discoverTraits); unlike scan, nothing is dropped.
const rows = await client.fetchAnnouncementRows("ethereum", {
fromBlock: 5_000_000n, // EVM lower bound; omit to scan from the adapter default
toBlock: undefined, // EVM upper bound; omit for chain tip
solanaLimit: 1000, // max Solana signatures (when chain is "solana")
});
// IndexerAnnouncement[]
Cross-chain (UAB) announcements are not included: the 96-byte Wormhole payload only carries a
24-byte metadata tail, so relayed rows cannot hold attestation metadata. Fetch rows natively on
each chain instead (UAB outputs still surface in scan / fetchCrossChainAnnouncements).
filterOwnedAnnouncements(rows)
WASM scan: filter indexer rows to outputs owned by this wallet. Malformed announcements
(for example an ephemeral key that is not a valid curve point) are skipped, never fatal;
anyone can announce.
const owned = await client.filterOwnedAnnouncements(indexerRows);
// OwnedStealthOutput[]
scan(opts)
Unified multichain inbox.
const inbox = await client.scan({
chains: ["ethereum", "solana"],
fromBlock: 5_000_000n,
toBlock: undefined,
solanaLimit: 1000,
includeCrossChain: true,
});
// UnifiedOwnedOutput[], tagged with chain, chainId, source
Balances
getBalancesFromAnnouncements(rows)
Filter owned outputs and sum balances per tracked token (EVM RPC).
const totals = await client.getBalancesFromAnnouncements(rows);
// TokenBalanceSummary[]
getBalancesForOutputs(outputs)
Native balance per UnifiedOwnedOutput (multichain).
const balances = await client.getBalancesForOutputs(inbox);
// OutputBalance[]
Sweep and keys
sweep(params)
Sweep the full native balance from a stealth output, or the full balance of token
(ERC-20 address / SPL mint) when set. closeAccount (Solana) reclaims the token-account rent.
const { chain, tx } = await client.sweep({
output: inbox[0],
chain: "ethereum",
destination: "0xFresh...",
token: "0xUSDC...", // omit for native; ERC-20 sweep needs native gas at the stealth address
});
For a stealth address holding a token but no gas, use buildGaslessTokenSweep and a relayer
(see Gasless token sweep).
getStealthSignerPrivateKey(output)
Reconstruct the 32-byte secp256k1 private key for an owned output.
const priv = client.getStealthSignerPrivateKey({ ephemeralPublicKey: output.ephemeralPublicKey });
getStealthSignerPrivateKeyFromEphemeralPrivateKey(ephemeralPrivateKey)
Same reconstruction from a ghost-stored ephemeral secret.
const priv = client.getStealthSignerPrivateKeyFromEphemeralPrivateKey(ghost.ephemeralPrivateKey);