Skip to main content
The Opaque Name Service maps alice.opq.eth-style names to CSAP meta-addresses on both chains. A MetaMask sender resolves through the canonical OpaqueNameRegistry, an ENSIP-10 wildcard resolver on Ethereum. A Phantom sender resolves the same name from a read-only mirror PDA on Solana: one account fetch, no Ethereum RPC. The mirror is written exclusively from Wormhole VAAs emitted by the canonical registry. The full protocol is specified in ONS.md. On testnet the parent name in force is opqtest.eth (Sepolia); production opq.eth is deferred until mainnet. The parent name, registry address, and mirror program ship in @opaquecash/deployments (see Deployments).
Consistency model: eventually consistent, canonical-chain-wins. There is no atomic cross-chain claim. Ethereum registrations are immediately authoritative; the Solana mirror catches up after Wormhole finality (about 20 to 40 minutes for the Ethereum leg). Solana-originated claims are provisional until the canonical registry confirms them, and they lose to any concurrent direct Ethereum registration. See ONS.md section 6 for the normative client behaviour.

How a name resolves

resolveRecipient (and therefore sendStealthPayment) accepts every name form directly. Four paths exist, per ONS.md section 7:
InputPathRequires
alice.opqtest.ethSolana mirror PDA first, canonical registry fallbacksolana config for the mirror path
Any other *.ethENS com.opaque.meta text recordens config
*.solSNS Records V2 TXT recordsolana config (or sns.getRecord)
Raw meta-addressValidated and passed throughnothing
Every path validates that both 33-byte halves of the result are valid compressed secp256k1 points before returning.

Step 1: Resolve a name

const client = await OpaqueClient.create({
  chainId: 11155111,
  rpcUrl: sepoliaRpcUrl,
  solana: { cluster: "devnet" },   // enables the mirror and SNS paths
  // ...keys
});

// The named entry point when you only need the meta-address:
const meta = await client.resolveOpaqueMetaAddress("alice.opqtest.eth");

// Or with the resolution source, through the universal resolver:
const r = await client.resolveRecipient("alice.opqtest.eth");
// r.source: "ons-mirror" (Solana PDA, no Ethereum RPC) or "ons-registry" (canonical)
Sends accept the same inputs, so this works without an explicit resolution step:
await client.sendStealthPayment({
  chain: "solana",
  recipient: "alice.opqtest.eth",
  amount: 1_000_000n,
  announce: true,
});
The runnable example sdk/examples/ons-resolve.ts resolves a name through both paths and asserts they return the same meta-address.

Step 2: Register a name on Ethereum

Registration on the canonical registry is first-come-first-served and immediately authoritative. registerOpaqueName submits with the configured Ethereum signer and registers the wallet’s own meta-address halves:
const txHash = await client.registerOpaqueName("alice");
// alice.opqtest.eth now resolves on Ethereum in the same block;
// the Solana mirror follows after the Wormhole relay delivers it.
The registry can also source your meta-address live from your ERC-6538 registry entry instead of pinning explicit keys (pass empty key arguments when calling the contract directly). A registry-sourced name tracks future key rotations, but the Solana mirror holds a snapshot: after rotating keys in the ERC-6538 registry, call the registry’s permissionless sync(label) to re-publish the mirror payload.

Step 3: Claim a name from Solana

A Solana-only user claims without ever touching Ethereum. The claim is provisional:
const { signature, name } = await client.claimOpaqueName("bob");
// name = "bob.opqtest.eth"; the claim travels to Ethereum via Wormhole.
Track the claim through its four states and surface them in your UI. Names never resolve from provisional claims; only canonical or mirrored records serve senders.
const { state } = await client.getOpaqueNameStatus("bob.opqtest.eth");
StateMeaningUI guidance
pendingClaim sent; no mirror record yetShow “pending confirmation (about 20 to 40 min)”; never show as owned
confirmedMirror record carries your pubkeyShow owned; offer reconcile to reclaim the provisional rent
lostMirror record carries another ownerThe canonical chain won the race; offer reconcile and a fresh name
expiredNo mirror record after 24 hoursDelivery failure; offer reconcile, then retry the claim
// Close a finished claim (confirmed, lost, or expired) and refund its rent:
await client.reconcileOpaqueName("bob.opqtest.eth");

Use an existing .eth name

You do not need an ONS name if you already own an ENS name. Publish your meta-address as a com.opaque.meta text record (ENSIP-5 reverse-DNS key form, defined in CSAP.md section 2.9) and every Opaque client resolves it. The record value is your 66-byte meta-address in the CSAP serialisation (viewing half first), optionally prefixed with st:opq: for self-description. The SDK returns exactly the right value:
const value = `st:opq:${client.getMetaAddressHex()}`;
// "st:opq:0x026a04...e9b5" (st:opq: + 0x + 132 hex chars)

Option A: ENS Manager app

  1. Open your name at app.ens.domains with the wallet that owns it.
  2. Go to the Records tab and choose Edit Records.
  3. Add a Text record with key com.opaque.meta and the value above.
  4. Confirm the transaction.

Option B: programmatic (viem)

Text records live on the name’s resolver. Look the resolver up, then call setText with the wallet that owns or manages the name (see the ENS docs on text records):
import { createWalletClient, custom, publicActions } from "viem";
import { namehash } from "viem/ens";
import { mainnet } from "viem/chains";

const wallet = createWalletClient({
  chain: mainnet,                       // or sepolia for a testnet name
  transport: custom(window.ethereum),
}).extend(publicActions);

const name = "alice.eth";
const resolver = await wallet.getEnsResolver({ name });

await wallet.writeContract({
  address: resolver,
  abi: [
    {
      type: "function",
      name: "setText",
      stateMutability: "nonpayable",
      inputs: [
        { name: "node", type: "bytes32" },
        { name: "key", type: "string" },
        { name: "value", type: "string" },
      ],
      outputs: [],
    },
  ],
  functionName: "setText",
  args: [namehash(name), "com.opaque.meta", value],
  account,
});

Verify

const r = await client.resolveRecipient("alice.eth");
// r.source === "ens-text", r.metaAddressHex === client.getMetaAddressHex()
The *.eth path needs an ENS-capable client in the Opaque config (ens: { client } with a mainnet or Sepolia viem PublicClient); see OpaqueClient configuration.
If you also hold an ERC-6538 registry entry for the address the name points to, the on-chain registry stays authoritative on conflict (CSAP section 2.9). Keep the text record and the registry entry in sync after key rotations.

Use an existing .sol name

SNS (Solana Name Service) domains publish the same value in a Records V2 TXT record. The st:opq: prefix makes the value self-describing, so it coexists with other TXT uses. See the SNS developer docs and @bonfida/spl-name-service for the records API.

Write the record

import { Connection, Transaction } from "@solana/web3.js";
import {
  Record,
  createRecordV2Instruction,
  updateRecordV2Instruction,
  validateRecordV2Content,
} from "@bonfida/spl-name-service";

const connection = new Connection(solanaRpcUrl, "confirmed");
const domain = "bob";                          // for bob.sol
const value = `st:opq:${client.getMetaAddressHex()}`;

const tx = new Transaction().add(
  // First write; use updateRecordV2Instruction(...) with the same arguments
  // to change an existing record.
  createRecordV2Instruction(domain, Record.TXT, value, owner, payer),
  // Mark the record as written by the current domain owner (staleness check),
  // so readers know it was not left behind by a previous owner.
  validateRecordV2Content(true, domain, Record.TXT, owner, payer, owner),
);

// Sign with the domain owner and send through your wallet adapter:
const signature = await wallet.sendTransaction(tx, connection);

Verify

const r = await client.resolveRecipient("bob.sol");
// r.source === "sns-record", r.metaAddressHex === client.getMetaAddressHex()
The .sol path uses the bundled Records V2 TXT reader over the solana connection. Inject sns: { getRecord } to read from a custom source; see OpaqueClient configuration.

Reference

  • Protocol: ONS.md (payload formats, claim flow, reconciliation states) and CSAP.md section 2.9 (the com.opaque.meta record convention).
  • Addresses: Deployments lists the testnet registry and the ons-mirror / ons-registration programs.
  • API: Stealth API covers resolveOpaqueMetaAddress, registerOpaqueName, claimOpaqueName, getOpaqueNameStatus, and reconcileOpaqueName.