@opaquecash/opaque 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js CHANGED
@@ -1,14 +1,37 @@
1
- import { createPublicClient, http, encodeFunctionData, getAddress, hexToBytes, } from "viem";
1
+ import { createPublicClient, createWalletClient, custom, defineChain, http, encodeFunctionData, getAddress, hexToBytes, keccak256, } from "viem";
2
+ import { sepolia } from "viem/chains";
3
+ import { Keypair, PublicKey, SystemProgram, Transaction, } from "@solana/web3.js";
2
4
  import { EIP5564_SCHEME_SECP256K1 } from "@opaquecash/stealth-core";
3
- import { stealthMetaAddressRegistryAbi, stealthAddressAnnouncerAbi, getStealthMetaAddress as readRegistryMetaAddress, } from "@opaquecash/stealth-chain";
4
- import { encodeAttestationMetadata, initStealthWasm, reconstructSigningKey, scanAttestationsJson, } from "@opaquecash/stealth-wasm";
5
- import { attestationsToDiscoveredTraits, buildActionScope, externalNullifierFromScope, } from "@opaquecash/psr-core";
6
- import { fetchLatestValidRoot, fetchRootHistory, isRootValid, simulateVerifyReputation, submitVerifyReputation, verifyReputationView, } from "@opaquecash/psr-chain";
5
+ import { stealthMetaAddressRegistryAbi, stealthAddressAnnouncerAbi, getStealthMetaAddress as readRegistryMetaAddress, EvmAdapter, sweepStealthNative, } from "@opaquecash/stealth-chain";
6
+ import { SolanaAdapter, deriveStealthSolanaAddress, deriveStealthSolanaAddressFromStealthPrivKey, fetchOnsMirrorRecord, fetchSnsTxtRecord, fetchOnsClaimStatus, fetchWormholeMessageFee, buildOnsClaimInstruction, buildOnsReconcileInstruction, } from "@opaquecash/stealth-chain-solana";
7
+ import { getOnsDeployment } from "@opaquecash/deployments";
8
+ /** Minimal write/read surface of the canonical OpaqueNameRegistry (spec/ONS.md §2). */
9
+ const onsNameRegistryAbi = [
10
+ {
11
+ type: "function",
12
+ name: "register",
13
+ stateMutability: "payable",
14
+ inputs: [
15
+ { name: "label", type: "string" },
16
+ { name: "spendPubKey", type: "bytes" },
17
+ { name: "viewPubKey", type: "bytes" },
18
+ ],
19
+ outputs: [{ name: "node", type: "bytes32" }],
20
+ },
21
+ ];
22
+ import { checkAnnouncement, checkAnnouncementViewTag, encodeAttestationMetadata, initStealthWasm, reconstructSigningKey, scanAttestationsJson, } from "@opaquecash/stealth-wasm";
23
+ import { attestationsToDiscoveredTraits, buildActionScope, encodeAttestationData, encodeV2AttestationMetadata, externalNullifierFromScope, fieldDefsToString, parseFieldDefs, randomNonce, } from "@opaquecash/psr-core";
24
+ import { fetchLatestValidRoot, fetchRootHistory, isRootValid, simulateVerifyReputation, submitVerifyReputation, verifyReputationView, requirePsrV2Config, fetchSchema as evmFetchSchema, fetchSchemasForWallet as evmFetchSchemasForWallet, fetchAttestationsIssuedBy as evmFetchAttestationsIssuedBy, isAuthorizedIssuer as evmIsAuthorizedIssuer, registerSchema as evmRegisterSchema, addDelegate as evmAddDelegate, removeDelegate as evmRemoveDelegate, deprecateSchema as evmDeprecateSchema, attest as evmAttest, announceV2Attestation as evmAnnounceV2Attestation, } from "@opaquecash/psr-chain";
25
+ import { computeSchemaId as solanaComputeSchemaId, deriveSchemaPda, deriveAttestationPda, buildRegisterSchemaInstruction, buildAddDelegateInstruction, buildRemoveDelegateInstruction, buildDeprecateSchemaInstruction, buildAttestInstruction, fetchAllSchemas as solanaFetchAllSchemas, fetchAllAttestations as solanaFetchAllAttestations, fetchAttestationPda as solanaFetchAttestationPda, submitReputationProof as solanaSubmitReputationProof, } from "@opaquecash/psr-chain-solana";
7
26
  import { ensureBufferPolyfill, generateReputationProof as runGenerateReputationProof, } from "@opaquecash/psr-prover";
8
27
  import { aggregateBalancesByToken, } from "@opaquecash/stealth-balance";
9
- import { deriveKeysFromSignature, keysToStealthMetaAddress, stealthMetaAddressToHex, computeStealthAddressAndViewTag, recomputeStealthSendFromEphemeralPrivateKey, ephemeralPrivateKeyToCompressedPublicKey, } from "./crypto/dksap.js";
28
+ import { buildAnnounceWithRelayRequest as uabBuildAnnounceWithRelayRequest, fetchCrossChainAnnouncements as uabFetchCrossChainAnnouncements, toIndexerAnnouncement as uabToIndexerAnnouncement, getUabDeployment, } from "@opaquecash/uab";
29
+ import { deriveKeysFromSignature, keysToStealthMetaAddress, stealthMetaAddressToHex, computeStealthAddressAndViewTag, recomputeStealthSendFromEphemeralPrivateKey, ephemeralPrivateKeyToCompressedPublicKey, generateRandomMetaAddress, } from "./crypto/dksap.js";
10
30
  import { getChainDeployment as getChainDeploymentInfo, getSupportedChainIds, requireChainDeployment, NATIVE_TOKEN_ADDRESS, } from "./chains.js";
11
31
  import { indexerAnnouncementsToScannerJson, } from "./indexer/normalize.js";
32
+ import { namehash, normalize as normalizeEnsName } from "viem/ens";
33
+ import { ipfsPathFromInput, isEnsNameInput, isEvmAddressInput, isOnsNameInput, isSnsNameInput, isSolanaPubkeyInput, META_ADDRESS_VALUE_PREFIX, OPAQUE_META_RECORD_KEY, parseMetaAddressValue, resolveEnsMetaAddress, resolveIpfsDidMetaAddress, resolveSnsMetaAddress, } from "./resolve.js";
34
+ import { requestSetupSignature, selectSigner, } from "./signer.js";
12
35
  const ERC20_BALANCE_ABI = [
13
36
  {
14
37
  type: "function",
@@ -31,6 +54,10 @@ export class OpaqueClient {
31
54
  metaAddressHex;
32
55
  publicClient;
33
56
  wasm;
57
+ evmAdapter;
58
+ solanaAdapter;
59
+ evmWalletClientCache;
60
+ solanaWalletCache;
34
61
  constructor(config, deployment, wasm, keys) {
35
62
  this.config = config;
36
63
  this.deployment = deployment;
@@ -60,9 +87,9 @@ export class OpaqueClient {
60
87
  */
61
88
  static async create(config) {
62
89
  const deployment = requireChainDeployment(config.chainId);
63
- const wasm = await initStealthWasm({
64
- moduleSpecifier: config.wasmModuleSpecifier,
65
- });
90
+ const wasm = config.wasmModuleSpecifier
91
+ ? await initStealthWasm({ moduleSpecifier: config.wasmModuleSpecifier })
92
+ : wasmUnavailable();
66
93
  const { viewingKey, spendingKey } = deriveKeysFromSignature(config.walletSignature);
67
94
  const { S, metaAddress } = keysToStealthMetaAddress(viewingKey, spendingKey);
68
95
  const metaAddressHex = stealthMetaAddressToHex(metaAddress);
@@ -73,6 +100,38 @@ export class OpaqueClient {
73
100
  metaAddressHex,
74
101
  });
75
102
  }
103
+ /**
104
+ * Construct a client from wallet(s) in the {@link UnifiedSigner} shape — the
105
+ * one-adapter entry point for integrators (Phase 2.5). Pass at most one wallet per
106
+ * chain; the FIRST wallet is prompted for the {@link SETUP_MESSAGE} setup signature
107
+ * (HKDF entropy) unless a cached `walletSignature` is supplied. Each wallet is also
108
+ * wired as that chain's write signer (`ethereumProvider`/`ethereumWalletClient`,
109
+ * `solanaWallet`), so PSR writes and sends work without further config.
110
+ */
111
+ static async fromWallet(params) {
112
+ const { wallets, walletSignature, ...rest } = params;
113
+ const list = Array.isArray(wallets) ? wallets : [wallets];
114
+ if (list.length === 0 && !walletSignature) {
115
+ throw new Error("Opaque: fromWallet needs at least one wallet (or a cached walletSignature).");
116
+ }
117
+ const evm = selectSigner(list, "ethereum");
118
+ const solana = selectSigner(list, "solana");
119
+ const signature = walletSignature ?? (await requestSetupSignature(list[0]));
120
+ return OpaqueClient.create({
121
+ ...rest,
122
+ walletSignature: signature,
123
+ // Zero address keeps reads working for Solana-only sessions; never used for writes.
124
+ ethereumAddress: evm?.address ?? "0x0000000000000000000000000000000000000000",
125
+ ethereumProvider: evm?.provider,
126
+ ethereumWalletClient: evm?.walletClient,
127
+ solanaWallet: solana?.signTransaction != null
128
+ ? {
129
+ publicKey: solana.publicKey,
130
+ signTransaction: solana.signTransaction,
131
+ }
132
+ : undefined,
133
+ });
134
+ }
76
135
  /** Chain id from configuration. */
77
136
  getChainId() {
78
137
  return this.config.chainId;
@@ -123,6 +182,156 @@ export class OpaqueClient {
123
182
  metaAddressHex: bytes,
124
183
  };
125
184
  }
185
+ /**
186
+ * Resolve ANY supported recipient identity to its 66-byte meta-address (CSAP §2.9):
187
+ *
188
+ * | Input | Path |
189
+ * |-------|------|
190
+ * | 66-byte meta-address (optionally `st:opq:`-prefixed) | validated and passed through |
191
+ * | `0x…` 20-byte EVM address | ERC-6538 `StealthMetaAddressRegistry` |
192
+ * | Solana base58 pubkey | `stealth-registry` PDA (needs {@link OpaqueClientConfig.solana}) |
193
+ * | `ipfs://…` / bare CID | DID document fetch via gateways (configure {@link OpaqueClientConfig.ipfs}) |
194
+ * | ONS name (`alice.opq.eth`) | Solana mirror PDA first, canonical OpaqueNameRegistry fallback (spec/ONS.md) |
195
+ * | other `*.eth` | ENS `com.opaque.meta` text record (needs {@link OpaqueClientConfig.ens}) |
196
+ * | `*.sol` | SNS Records V2 TXT record (needs {@link OpaqueClientConfig.solana} or `sns.getRecord`) |
197
+ *
198
+ * Every path point-validates both 33-byte halves before returning. Throws with a
199
+ * path-specific message when the identity is unregistered, unset, or malformed.
200
+ */
201
+ async resolveRecipient(input) {
202
+ const trimmed = input.trim();
203
+ if (trimmed.startsWith(META_ADDRESS_VALUE_PREFIX) ||
204
+ /^(0x)?[0-9a-fA-F]{132}$/.test(trimmed)) {
205
+ const meta = parseMetaAddressValue(trimmed);
206
+ if (!meta) {
207
+ throw new Error("Opaque: recipient looks like a meta-address but failed validation (both 33-byte halves must be valid compressed secp256k1 points).");
208
+ }
209
+ return { metaAddressHex: meta, source: "meta-address", input: trimmed };
210
+ }
211
+ const cidPath = ipfsPathFromInput(trimmed);
212
+ if (cidPath) {
213
+ const meta = await resolveIpfsDidMetaAddress(cidPath, this.resolveTransports());
214
+ return { metaAddressHex: meta, source: "ipfs-did", input: trimmed };
215
+ }
216
+ if (isEnsNameInput(trimmed)) {
217
+ const onsParent = this.onsParentName();
218
+ if (onsParent && isOnsNameInput(trimmed, onsParent)) {
219
+ return this.resolveOnsName(trimmed.toLowerCase());
220
+ }
221
+ const meta = await resolveEnsMetaAddress(trimmed, this.resolveTransports());
222
+ return { metaAddressHex: meta, source: "ens-text", input: trimmed };
223
+ }
224
+ if (isSnsNameInput(trimmed)) {
225
+ const meta = await resolveSnsMetaAddress(trimmed.toLowerCase(), this.snsGetRecord());
226
+ return { metaAddressHex: meta, source: "sns-record", input: trimmed };
227
+ }
228
+ if (isEvmAddressInput(trimmed)) {
229
+ const res = await this.resolveRecipientMetaAddress(trimmed);
230
+ if (!res.registered || !res.metaAddressHex) {
231
+ throw new Error(`Opaque: ${trimmed} has no registered meta-address on Ethereum.`);
232
+ }
233
+ return {
234
+ metaAddressHex: res.metaAddressHex,
235
+ source: "evm-registry",
236
+ input: trimmed,
237
+ };
238
+ }
239
+ if (isSolanaPubkeyInput(trimmed)) {
240
+ const meta = await this.getSolanaAdapter().resolveMetaAddress(trimmed);
241
+ if (!meta) {
242
+ throw new Error(`Opaque: ${trimmed} has no registered meta-address on Solana.`);
243
+ }
244
+ return { metaAddressHex: meta, source: "solana-registry", input: trimmed };
245
+ }
246
+ throw new Error(`Opaque: unrecognised recipient "${trimmed}" (expected a meta-address, EVM address, Solana pubkey, ipfs:// CID, or *.eth name).`);
247
+ }
248
+ /**
249
+ * Resolve an Opaque Name Service name (`alice.opq.eth`; `alice.opqtest.eth` on
250
+ * testnet) to its meta-address (spec/ONS.md §7). Tries the Solana mirror PDA first
251
+ * (one account read, no Ethereum RPC — needs {@link OpaqueClientConfig.solana});
252
+ * falls back to the canonical OpaqueNameRegistry (ENSIP-10) over the scan RPC.
253
+ * Both paths point-validate the 33-byte halves. Mirror records lag the canonical
254
+ * record by Wormhole latency (eventually consistent, canonical-chain-wins).
255
+ */
256
+ async resolveOpaqueMetaAddress(name) {
257
+ const { metaAddressHex } = await this.resolveOnsName(name.trim().toLowerCase());
258
+ return metaAddressHex;
259
+ }
260
+ /** ONS resolution: mirror-PDA-first, canonical-registry fallback. */
261
+ async resolveOnsName(name) {
262
+ // 1. Solana mirror PDA (cheap, chain-local).
263
+ if (this.config.solana) {
264
+ try {
265
+ const adapter = this.getSolanaAdapter();
266
+ const mirrorProgram = this.config.ons?.mirrorProgram
267
+ ? new PublicKey(this.config.ons.mirrorProgram)
268
+ : adapter.deployment.onsMirror;
269
+ const record = await fetchOnsMirrorRecord(adapter.connection, mirrorProgram, name);
270
+ if (record) {
271
+ const meta = parseMetaAddressValue(record.metaAddressHex);
272
+ if (meta)
273
+ return { metaAddressHex: meta, source: "ons-mirror", input: name };
274
+ }
275
+ }
276
+ catch {
277
+ // Mirror unavailable (RPC outage, cluster mismatch): fall through to canonical.
278
+ }
279
+ }
280
+ // 2. Canonical OpaqueNameRegistry (ENSIP-10 wildcard resolver) on the EVM RPC.
281
+ const registry = this.config.ons?.registry ?? getOnsDeployment(this.config.chainId)?.registry;
282
+ if (!registry) {
283
+ throw new Error(`Opaque: cannot resolve ${name} — no ONS mirror record and no OpaqueNameRegistry ` +
284
+ `is known for chainId ${this.config.chainId} (pass config.ons.registry).`);
285
+ }
286
+ const value = (await this.publicClient.readContract({
287
+ address: registry,
288
+ abi: [
289
+ {
290
+ type: "function",
291
+ name: "text",
292
+ stateMutability: "view",
293
+ inputs: [
294
+ { name: "node", type: "bytes32" },
295
+ { name: "key", type: "string" },
296
+ ],
297
+ outputs: [{ name: "", type: "string" }],
298
+ },
299
+ ],
300
+ functionName: "text",
301
+ args: [namehash(name), OPAQUE_META_RECORD_KEY],
302
+ }));
303
+ const meta = value ? parseMetaAddressValue(value) : null;
304
+ if (!meta) {
305
+ throw new Error(`Opaque: ${name} is not registered with the Opaque Name Service.`);
306
+ }
307
+ return { metaAddressHex: meta, source: "ons-registry", input: name };
308
+ }
309
+ /** The ONS parent name in force (config override, else bundled deployment), if any. */
310
+ onsParentName() {
311
+ return (this.config.ons?.parentName?.toLowerCase() ??
312
+ getOnsDeployment(this.config.chainId)?.parentName);
313
+ }
314
+ /** The `.sol` record reader: injected, else the bundled Records V2 TXT reader. */
315
+ snsGetRecord() {
316
+ if (this.config.sns?.getRecord)
317
+ return this.config.sns.getRecord;
318
+ if (!this.config.solana)
319
+ return undefined;
320
+ return (domain) => fetchSnsTxtRecord(this.getSolanaAdapter().connection, domain);
321
+ }
322
+ /** Build the {@link ResolveTransports} from config (ENS reader + IPFS gateways). */
323
+ resolveTransports() {
324
+ const ens = this.config.ens;
325
+ const ensGetText = ens?.getText ??
326
+ (ens?.client
327
+ ? (name, key) => ens.client.getEnsText({ name: normalizeEnsName(name), key })
328
+ : undefined);
329
+ return {
330
+ ensGetText,
331
+ ipfsGateways: this.config.ipfs?.gateways,
332
+ fetchFn: this.config.ipfs?.fetch,
333
+ };
334
+ }
126
335
  /**
127
336
  * Encode `registerKeys` for the user's meta-address (they submit with `ethereumAddress`).
128
337
  */
@@ -140,6 +349,158 @@ export class OpaqueClient {
140
349
  metaAddressHex: this.metaAddressHex,
141
350
  };
142
351
  }
352
+ /**
353
+ * Register THIS wallet's 66-byte meta-address on-chain so others can resolve it, dispatching on
354
+ * `chain`. Submits the transaction with the configured signer (`ethereumWalletClient` /
355
+ * `ethereumProvider` for Ethereum, `solanaWallet` for Solana) and returns the tx id. For a
356
+ * calldata-only request you submit yourself, see {@link buildRegisterMetaAddressTransaction}
357
+ * (Ethereum) or `SolanaAdapter.buildRegisterKeysInstruction`.
358
+ */
359
+ async registerMetaAddress(chain) {
360
+ const schemeId = BigInt(EIP5564_SCHEME_SECP256K1);
361
+ if (chain === "ethereum") {
362
+ const wc = this.evmWalletClient();
363
+ const txHash = await wc.writeContract({
364
+ address: this.registry,
365
+ abi: stealthMetaAddressRegistryAbi,
366
+ functionName: "registerKeys",
367
+ args: [schemeId, this.metaAddressHex],
368
+ account: this.config.ethereumAddress,
369
+ chain: wc.chain ?? this.viemChain(),
370
+ });
371
+ return { chain, txHash, metaAddressHex: this.metaAddressHex };
372
+ }
373
+ if (chain === "solana") {
374
+ const wallet = this.requireSolanaWallet();
375
+ const ix = this.getSolanaAdapter().buildRegisterKeysInstruction(wallet.publicKey, hexToBytes(this.metaAddressHex), schemeId);
376
+ const txHash = await this.sendSolanaTx([ix]);
377
+ return { chain, txHash, metaAddressHex: this.metaAddressHex };
378
+ }
379
+ throw new Error(`Opaque: unsupported register chain "${chain}"`);
380
+ }
381
+ /**
382
+ * Whether THIS wallet's meta-address is already registered on `chain` (Ethereum reads its
383
+ * configured `ethereumAddress`; Solana reads the `solanaWallet` pubkey).
384
+ */
385
+ async isMetaAddressRegistered(chain) {
386
+ if (chain === "ethereum") {
387
+ const res = await this.resolveRecipientMetaAddress(this.config.ethereumAddress);
388
+ return res.registered;
389
+ }
390
+ if (chain === "solana") {
391
+ const wallet = this.requireSolanaWallet();
392
+ return this.getSolanaAdapter().isRegistered(wallet.publicKey.toBase58());
393
+ }
394
+ throw new Error(`Opaque: unsupported register chain "${chain}"`);
395
+ }
396
+ // ------------------------------------------------------------------ ONS names
397
+ /**
398
+ * Register `label`.<parent> for THIS wallet's meta-address on the canonical
399
+ * OpaqueNameRegistry (Ethereum; spec/ONS.md §4.1). Immediately authoritative;
400
+ * the Solana mirror follows after Wormhole relay. Submits with the configured
401
+ * Ethereum signer and returns the tx hash.
402
+ */
403
+ async registerOpaqueName(label) {
404
+ const registry = this.requireOnsRegistry();
405
+ const { spendPubKey, viewPubKey } = this.ownMetaAddressHalves();
406
+ const wc = this.evmWalletClient();
407
+ return wc.writeContract({
408
+ address: registry,
409
+ abi: onsNameRegistryAbi,
410
+ functionName: "register",
411
+ args: [label.toLowerCase(), spendPubKey, viewPubKey],
412
+ account: this.config.ethereumAddress,
413
+ chain: wc.chain ?? this.viemChain(),
414
+ });
415
+ }
416
+ /**
417
+ * Claim `label`.<parent> from Solana (spec/ONS.md §4.2). Creates a PROVISIONAL
418
+ * claim and publishes it to the canonical registry via Wormhole; it becomes
419
+ * authoritative only when the registry confirms (mirror record appears), and it
420
+ * loses to any concurrent direct Ethereum registration. Track with
421
+ * {@link getOpaqueNameStatus}; surface `pending` in UI (never as owned).
422
+ */
423
+ async claimOpaqueName(label) {
424
+ const parentName = this.requireOnsParentName();
425
+ const adapter = this.getSolanaAdapter();
426
+ const wallet = this.requireSolanaWallet();
427
+ const { spendPubKey, viewPubKey } = this.ownMetaAddressHalves();
428
+ const message = Keypair.generate();
429
+ const fee = await fetchWormholeMessageFee(adapter.connection, adapter.deployment.wormholeCore);
430
+ const ix = buildOnsClaimInstruction({
431
+ registrationProgramId: this.onsRegistrationProgram(),
432
+ wormholeCore: adapter.deployment.wormholeCore,
433
+ claimer: wallet.publicKey,
434
+ label,
435
+ parentName,
436
+ spendPubKey: hexToBytes(spendPubKey),
437
+ viewPubKey: hexToBytes(viewPubKey),
438
+ wormholeMessage: message.publicKey,
439
+ wormholeFee: fee,
440
+ });
441
+ const signature = await this.sendSolanaTx([ix], [message]);
442
+ return { signature, name: `${label.toLowerCase()}.${parentName}` };
443
+ }
444
+ /**
445
+ * Reconciliation state of an ONS name's Solana-originated claim
446
+ * (`none`/`pending`/`confirmed`/`lost`/`expired`; spec/ONS.md §6), plus the
447
+ * mirror record when one exists. Two Solana account reads.
448
+ */
449
+ async getOpaqueNameStatus(name) {
450
+ const adapter = this.getSolanaAdapter();
451
+ return fetchOnsClaimStatus(adapter.connection, this.onsRegistrationProgram(), this.onsMirrorProgram(), name.trim().toLowerCase());
452
+ }
453
+ /**
454
+ * Close a finished provisional claim (confirmed / lost / expired) and refund its
455
+ * rent to the claimer. Permissionless; submits with the configured Solana wallet.
456
+ */
457
+ async reconcileOpaqueName(name) {
458
+ const status = await this.getOpaqueNameStatus(name);
459
+ if (!status.claim)
460
+ throw new Error(`Opaque: no provisional claim for ${name}.`);
461
+ const wallet = this.requireSolanaWallet();
462
+ const ix = buildOnsReconcileInstruction({
463
+ registrationProgramId: this.onsRegistrationProgram(),
464
+ mirrorProgramId: this.onsMirrorProgram(),
465
+ fullName: name.trim().toLowerCase(),
466
+ claimer: status.claim.claimer,
467
+ payer: wallet.publicKey,
468
+ });
469
+ return this.sendSolanaTx([ix]);
470
+ }
471
+ /** The ONS parent name in force, or throw with setup guidance. */
472
+ requireOnsParentName() {
473
+ const parent = this.onsParentName();
474
+ if (!parent) {
475
+ throw new Error(`Opaque: no ONS deployment is known for chainId ${this.config.chainId} ` +
476
+ "(pass config.ons.parentName).");
477
+ }
478
+ return parent;
479
+ }
480
+ requireOnsRegistry() {
481
+ const registry = this.config.ons?.registry ?? getOnsDeployment(this.config.chainId)?.registry;
482
+ if (!registry) {
483
+ throw new Error(`Opaque: no OpaqueNameRegistry is known for chainId ${this.config.chainId} ` +
484
+ "(pass config.ons.registry).");
485
+ }
486
+ return registry;
487
+ }
488
+ onsMirrorProgram() {
489
+ return this.config.ons?.mirrorProgram
490
+ ? new PublicKey(this.config.ons.mirrorProgram)
491
+ : this.getSolanaAdapter().deployment.onsMirror;
492
+ }
493
+ onsRegistrationProgram() {
494
+ return this.getSolanaAdapter().deployment.onsRegistration;
495
+ }
496
+ /** Split this wallet's meta-address (CSAP V‖S) into its 33-byte halves. */
497
+ ownMetaAddressHalves() {
498
+ const hex = this.metaAddressHex.slice(2);
499
+ return {
500
+ viewPubKey: `0x${hex.slice(0, 66)}`,
501
+ spendPubKey: `0x${hex.slice(66, 132)}`,
502
+ };
503
+ }
143
504
  /**
144
505
  * Derive a one-time stealth address for sending to a recipient meta-address.
145
506
  */
@@ -152,8 +513,190 @@ export class OpaqueClient {
152
513
  ephemeralPublicKey: r.ephemeralPubKey,
153
514
  ephemeralPrivateKey: r.ephemeralPriv,
154
515
  metadata: r.metadata,
516
+ stealthPubKey: r.stealthPubKeyUncompressed,
155
517
  };
156
518
  }
519
+ /**
520
+ * High-level send: resolve the recipient, derive a one-time stealth destination, transfer the
521
+ * native asset, and publish the discovery announcement — in one call, dispatching on `chain`.
522
+ *
523
+ * Solana bundles the transfer and `announce` (or `announce_with_relay` when `relay` is set) into a
524
+ * single transaction and returns its signature. Ethereum submits the value transfer first, then
525
+ * the announce, returning both tx hashes. Token (SPL/ERC-20) sends are not yet supported.
526
+ * Requires the chain's signer (`solanaWallet` / `ethereumWalletClient` or `ethereumProvider`).
527
+ */
528
+ async sendStealthPayment(params) {
529
+ if (params.token) {
530
+ throw new Error("Opaque: token (SPL/ERC-20) stealth sends are not yet supported; send the native asset.");
531
+ }
532
+ const metaAddressHex = await this.resolveSendRecipientMeta(params.chain, params.recipient);
533
+ const send = this.prepareStealthSend(metaAddressHex);
534
+ const ephemeralPublicKey = bytesToHex0x(send.ephemeralPublicKey);
535
+ const wantAnnounce = params.announce ?? true;
536
+ if (params.chain === "solana") {
537
+ const wallet = this.requireSolanaWallet();
538
+ const adapter = this.getSolanaAdapter();
539
+ const destination = deriveStealthSolanaAddress(send.stealthPubKey);
540
+ const transferIx = SystemProgram.transfer({
541
+ fromPubkey: wallet.publicKey,
542
+ toPubkey: new PublicKey(destination),
543
+ lamports: params.amount,
544
+ });
545
+ const buildAnnounceIxs = async () => {
546
+ if (params.relay) {
547
+ const wormholeFee = await adapter.fetchWormholeMessageFee();
548
+ const { instruction, messageKeypair } = adapter.buildAnnounceWithRelay({
549
+ caller: wallet.publicKey,
550
+ stealthAddress: hexToBytes(send.stealthAddress),
551
+ ephemeralPubKey: send.ephemeralPublicKey,
552
+ metadata: send.metadata,
553
+ schemeId: send.schemeId,
554
+ batchId: params.batchId,
555
+ wormholeFee,
556
+ });
557
+ return { ixs: [instruction], signers: [messageKeypair] };
558
+ }
559
+ return {
560
+ ixs: [
561
+ adapter.buildAnnounceInstruction({
562
+ caller: wallet.publicKey,
563
+ stealthAddress: hexToBytes(send.stealthAddress),
564
+ ephemeralPubKey: send.ephemeralPublicKey,
565
+ metadata: send.metadata,
566
+ schemeId: send.schemeId,
567
+ }),
568
+ ],
569
+ signers: [],
570
+ };
571
+ };
572
+ const wantsAnyAnnounce = params.relay || wantAnnounce;
573
+ if (params.delayAnnouncement != null && wantsAnyAnnounce) {
574
+ const txHash = await this.sendSolanaTx([transferIx]);
575
+ const announcePromise = (async () => {
576
+ await sleep(params.delayAnnouncement);
577
+ const { ixs, signers } = await buildAnnounceIxs();
578
+ return this.sendSolanaTx(ixs, signers);
579
+ })();
580
+ return {
581
+ chain: "solana",
582
+ txHash,
583
+ announcePromise,
584
+ stealthAddress: send.stealthAddress,
585
+ destination,
586
+ ephemeralPublicKey,
587
+ metaAddressHex,
588
+ };
589
+ }
590
+ const ixs = [transferIx];
591
+ const extraSigners = [];
592
+ if (wantsAnyAnnounce) {
593
+ const announce = await buildAnnounceIxs();
594
+ ixs.push(...announce.ixs);
595
+ extraSigners.push(...announce.signers);
596
+ }
597
+ const txHash = await this.sendSolanaTx(ixs, extraSigners);
598
+ return {
599
+ chain: "solana",
600
+ txHash,
601
+ stealthAddress: send.stealthAddress,
602
+ destination,
603
+ ephemeralPublicKey,
604
+ metaAddressHex,
605
+ };
606
+ }
607
+ if (params.chain === "ethereum") {
608
+ const wc = this.evmWalletClient();
609
+ const viemChain = wc.chain ?? this.viemChain();
610
+ const txHash = await wc.sendTransaction({
611
+ account: this.config.ethereumAddress,
612
+ chain: viemChain,
613
+ to: send.stealthAddress,
614
+ value: params.amount,
615
+ });
616
+ const submitAnnounce = async () => {
617
+ if (params.relay) {
618
+ const req = await this.buildAnnounceWithRelayRequest(send);
619
+ return wc.sendTransaction({
620
+ account: this.config.ethereumAddress,
621
+ chain: viemChain,
622
+ to: req.to,
623
+ data: req.data,
624
+ value: req.value,
625
+ });
626
+ }
627
+ const req = this.buildAnnounceTransactionRequest(send);
628
+ return wc.sendTransaction({
629
+ account: this.config.ethereumAddress,
630
+ chain: viemChain,
631
+ to: req.to,
632
+ data: req.data,
633
+ });
634
+ };
635
+ const wantsAnyAnnounce = params.relay || wantAnnounce;
636
+ if (params.delayAnnouncement != null && wantsAnyAnnounce) {
637
+ const announcePromise = (async () => {
638
+ await sleep(params.delayAnnouncement);
639
+ return submitAnnounce();
640
+ })();
641
+ return {
642
+ chain: "ethereum",
643
+ txHash,
644
+ announcePromise,
645
+ stealthAddress: send.stealthAddress,
646
+ ephemeralPublicKey,
647
+ metaAddressHex,
648
+ };
649
+ }
650
+ let announceTxHash;
651
+ if (wantsAnyAnnounce) {
652
+ announceTxHash = await submitAnnounce();
653
+ }
654
+ return {
655
+ chain: "ethereum",
656
+ txHash,
657
+ announceTxHash,
658
+ stealthAddress: send.stealthAddress,
659
+ ephemeralPublicKey,
660
+ metaAddressHex,
661
+ };
662
+ }
663
+ throw new Error(`Opaque: unsupported send chain "${params.chain}"`);
664
+ }
665
+ /**
666
+ * Resolve a {@link SendStealthPaymentParams.recipient} to a 66-byte meta-address.
667
+ * Delegates to {@link resolveRecipient}, so sends accept every supported identity
668
+ * form (meta-address, registry address/pubkey, `ipfs://` DID, `*.eth`) on any chain —
669
+ * meta-addresses are chain-neutral.
670
+ */
671
+ async resolveSendRecipientMeta(_chain, recipient) {
672
+ const resolved = await this.resolveRecipient(recipient);
673
+ return resolved.metaAddressHex;
674
+ }
675
+ /**
676
+ * Anonymity-set utility (guide §17): mint `n` decoy announcements. Each one is a fully
677
+ * valid DKSAP announcement to a freshly generated THROWAWAY meta-address whose private
678
+ * keys are discarded — on-chain it is indistinguishable from a real payment
679
+ * announcement (valid curve points, correctly derived view tag), but nobody will ever
680
+ * match or spend it. Submit them (e.g. via {@link buildDummyAnnouncementTransactions})
681
+ * interleaved with real sends to grow every recipient's anonymity set.
682
+ */
683
+ generateDummyAnnouncements(n) {
684
+ if (!Number.isInteger(n) || n < 0) {
685
+ throw new Error("Opaque: generateDummyAnnouncements needs a non-negative integer count.");
686
+ }
687
+ return Array.from({ length: n }, () => {
688
+ const metaAddressHex = generateRandomMetaAddress();
689
+ return { ...this.prepareStealthSend(metaAddressHex), metaAddressHex };
690
+ });
691
+ }
692
+ /**
693
+ * Convenience over {@link generateDummyAnnouncements}: `n` ready-to-submit `announce`
694
+ * calldata requests for this chain's announcer. Broadcast them from any account —
695
+ * announcements carry no value and any caller may announce.
696
+ */
697
+ buildDummyAnnouncementTransactions(n) {
698
+ return this.generateDummyAnnouncements(n).map((d) => this.buildAnnounceTransactionRequest(d));
699
+ }
157
700
  /**
158
701
  * Manual “ghost” receive: derive a one-time stealth address for **this** wallet’s meta-address
159
702
  * without any on-chain announcement yet. Cryptographically this is {@link prepareStealthSend}
@@ -178,6 +721,7 @@ export class OpaqueClient {
178
721
  ephemeralPublicKey: r.ephemeralPubKey,
179
722
  ephemeralPrivateKey: r.ephemeralPriv,
180
723
  metadata: r.metadata,
724
+ stealthPubKey: r.stealthPubKeyUncompressed,
181
725
  });
182
726
  }
183
727
  /**
@@ -203,32 +747,260 @@ export class OpaqueClient {
203
747
  },
204
748
  };
205
749
  }
750
+ // ---------------------------------------------------------------------------
751
+ // Universal Announcement Bus (cross-chain announcements over Wormhole)
752
+ // ---------------------------------------------------------------------------
753
+ /** Resolve UAB addresses for this chain (config override takes precedence over the known deployment). */
754
+ uabAddresses() {
755
+ const d = getUabDeployment(this.config.chainId);
756
+ const uabSender = this.config.contracts?.uabSender ?? d?.uabSender;
757
+ const uabReceiver = this.config.contracts?.uabReceiver ?? d?.uabReceiver;
758
+ const wormholeCore = this.config.contracts?.wormholeCore ?? d?.wormholeCore;
759
+ if (!uabSender || !uabReceiver || !wormholeCore) {
760
+ throw new Error(`UAB not configured for chainId ${this.config.chainId}; pass contracts.{uabSender,uabReceiver,wormholeCore}`);
761
+ }
762
+ return { uabSender, uabReceiver, wormholeCore, fromBlock: d?.fromBlock ?? 0n };
763
+ }
764
+ /**
765
+ * Build a `{to,data,value}` request for a CROSS-CHAIN announce (`announceWithRelay`): it emits the
766
+ * local announcement AND publishes the 96-byte payload through Wormhole. `value` is the Wormhole
767
+ * message fee. Pass the same {@link PrepareStealthSendResult} you'd use for a native announce.
768
+ */
769
+ async buildAnnounceWithRelayRequest(send, opts = {}) {
770
+ const { uabSender, wormholeCore } = this.uabAddresses();
771
+ const req = await uabBuildAnnounceWithRelayRequest(this.publicClient, {
772
+ uabSender,
773
+ wormholeCore,
774
+ schemeId: send.schemeId,
775
+ stealthAddress: send.stealthAddress,
776
+ ephemeralPubKey: (`0x${bytesToHex(send.ephemeralPublicKey)}`),
777
+ metadata: (`0x${bytesToHex(send.metadata)}`),
778
+ consistencyLevel: opts.consistencyLevel,
779
+ });
780
+ return { ...req, chainId: this.config.chainId };
781
+ }
782
+ /**
783
+ * Build a CROSS-CHAIN announce for either chain, dispatching on `chain`. Emits the local
784
+ * announcement AND relays the 96-byte payload over Wormhole. Ethereum returns a `{to,data,value}`
785
+ * request (`value` is the Wormhole fee); Solana returns `instructions` + extra `signers` (the
786
+ * fresh Wormhole message keypair) — both must co-sign with the wallet. Pass the same
787
+ * {@link PrepareStealthSendResult} you'd use for a native announce.
788
+ *
789
+ * EVM honours `consistencyLevel`; Solana honours `batchId` (Wormhole nonce) and `wormholeFee`
790
+ * (auto-fetched from the core bridge when omitted; 0 on devnet).
791
+ */
792
+ async buildAnnounceWithRelay(chain, send, opts = {}) {
793
+ if (chain === "ethereum") {
794
+ const req = await this.buildAnnounceWithRelayRequest(send, {
795
+ consistencyLevel: opts.consistencyLevel,
796
+ });
797
+ return {
798
+ chain: "ethereum",
799
+ to: req.to,
800
+ data: req.data,
801
+ value: req.value,
802
+ chainId: req.chainId,
803
+ };
804
+ }
805
+ if (chain === "solana") {
806
+ const adapter = this.getSolanaAdapter();
807
+ const caller = this.requireSolanaWallet().publicKey;
808
+ const wormholeFee = opts.wormholeFee ?? (await adapter.fetchWormholeMessageFee());
809
+ const { instruction, messageKeypair } = adapter.buildAnnounceWithRelay({
810
+ caller,
811
+ stealthAddress: hexToBytes(send.stealthAddress),
812
+ ephemeralPubKey: send.ephemeralPublicKey,
813
+ metadata: send.metadata,
814
+ schemeId: send.schemeId,
815
+ batchId: opts.batchId,
816
+ wormholeFee,
817
+ });
818
+ return { chain: "solana", instructions: [instruction], signers: [messageKeypair] };
819
+ }
820
+ throw new Error(`Opaque: unsupported announce-with-relay chain "${chain}"`);
821
+ }
822
+ /**
823
+ * Read inbound CROSS-CHAIN announcements (from the UABReceiver) as indexer-shaped rows, ready to
824
+ * pass into {@link filterOwnedAnnouncements} alongside native rows.
825
+ */
826
+ async fetchCrossChainAnnouncements(opts = {}) {
827
+ const { uabReceiver, fromBlock } = this.uabAddresses();
828
+ const records = await uabFetchCrossChainAnnouncements(this.publicClient, {
829
+ uabReceiver,
830
+ fromBlock: opts.fromBlock ?? fromBlock,
831
+ toBlock: opts.toBlock,
832
+ });
833
+ return records.map(uabToIndexerAnnouncement);
834
+ }
835
+ /** Discover stealth outputs owned by this user that arrived via the cross-chain UAB. */
836
+ async scanCrossChain(opts = {}) {
837
+ const rows = await this.fetchCrossChainAnnouncements(opts);
838
+ return this.filterOwnedAnnouncements(rows);
839
+ }
206
840
  /**
207
841
  * Filter indexer announcements down to outputs owned by this user (WASM scan).
208
842
  */
209
843
  async filterOwnedAnnouncements(rows) {
210
844
  if (rows.length === 0)
211
845
  return [];
212
- const json = indexerAnnouncementsToScannerJson(rows);
213
- const out = scanAttestationsJson(this.wasm, json, this.viewingKey, this.spendPubKey);
214
- const list = JSON.parse(out);
215
846
  const owned = [];
216
- for (const att of list) {
217
- const row = rows.find((r) => r.transactionHash.toLowerCase() === att.tx_hash.toLowerCase() &&
218
- r.stealthAddress.toLowerCase() === att.stealth_address.toLowerCase());
219
- const epk = (`0x${att.ephemeral_pubkey.map((b) => b.toString(16).padStart(2, "0")).join("")}`);
847
+ for (const row of rows) {
848
+ const eph = hexToBytes(row.etherealPublicKey);
849
+ if (eph.length !== 33)
850
+ continue;
851
+ const vtRaw = row?.viewTag;
852
+ const vt = typeof vtRaw === "number"
853
+ ? vtRaw
854
+ : typeof vtRaw === "string"
855
+ ? Number.parseInt(vtRaw, 10)
856
+ : Number(vtRaw);
857
+ if (!Number.isFinite(vt) || !Number.isInteger(vt) || vt < 0 || vt > 255)
858
+ continue;
859
+ // Anyone can announce; skip rows whose ephemeral key is 33 bytes but not a valid
860
+ // curve point (the WASM throws "Invalid public key" on them) instead of aborting.
861
+ let ok = false;
862
+ try {
863
+ const tagResult = checkAnnouncementViewTag(this.wasm, vt, this.viewingKey, eph);
864
+ if (tagResult === "NoMatch")
865
+ continue;
866
+ ok = checkAnnouncement(this.wasm, row.stealthAddress, vt, this.viewingKey, this.spendPubKey, eph);
867
+ }
868
+ catch {
869
+ continue;
870
+ }
871
+ if (!ok)
872
+ continue;
220
873
  owned.push({
221
- stealthAddress: getAddress(att.stealth_address),
222
- transactionHash: att.tx_hash,
223
- blockNumber: att.block_number,
224
- logIndex: row?.logIndex ?? 0,
225
- viewTag: row?.viewTag ?? 0,
226
- ephemeralPublicKey: epk,
227
- attestationId: att.attestation_id,
874
+ stealthAddress: getAddress(row.stealthAddress),
875
+ transactionHash: row.transactionHash,
876
+ blockNumber: Number(row.blockNumber),
877
+ logIndex: row.logIndex,
878
+ viewTag: vt,
879
+ ephemeralPublicKey: row.etherealPublicKey,
228
880
  });
229
881
  }
230
882
  return owned;
231
883
  }
884
+ // ---------------------------------------------------------------------------
885
+ // Unified cross-chain inbox
886
+ // ---------------------------------------------------------------------------
887
+ /**
888
+ * Scan one or more chains for stealth outputs owned by this wallet and return a single,
889
+ * merged inbox. Each chain's native announcements are fetched through its {@link ChainAdapter}
890
+ * and run through the same WASM view-tag + DKSAP filter ({@link filterOwnedAnnouncements}), so
891
+ * detection is identical across chains. Outputs are tagged with their source `chain` / `chainId`.
892
+ *
893
+ * `"ethereum"` reuses this client's viem client + configured announcer/registry. `"solana"`
894
+ * requires {@link OpaqueClientConfig.solana} (connection / rpcUrl / cluster; defaults to devnet).
895
+ * The viewing/spending keys are chain-neutral, so one wallet's inbox spans both chains.
896
+ */
897
+ async scan(opts) {
898
+ const out = [];
899
+ for (const chain of opts.chains) {
900
+ const adapter = this.getAdapter(chain);
901
+ const announcements = await adapter.fetchAnnouncements({
902
+ fromCursor: opts.fromBlock,
903
+ toCursor: opts.toBlock,
904
+ limit: opts.solanaLimit,
905
+ includeCrossChain: opts.includeCrossChain,
906
+ });
907
+ // Adapters may merge cross-chain (UAB) announcements relayed to their chain;
908
+ // those keep their origin chainId, which distinguishes them from native ones.
909
+ const native = announcements.filter((a) => a.chainId === adapter.chainId);
910
+ const relayed = announcements.filter((a) => a.chainId !== adapter.chainId);
911
+ for (const [list, source] of [
912
+ [native, "native"],
913
+ [relayed, "uab"],
914
+ ]) {
915
+ if (list.length === 0)
916
+ continue;
917
+ const rows = list.map(announcementToIndexerRow);
918
+ const owned = await this.filterOwnedAnnouncements(rows);
919
+ for (const o of owned) {
920
+ out.push({ ...o, chain, chainId: adapter.chainId, source });
921
+ }
922
+ }
923
+ }
924
+ const uabConfigured = getUabDeployment(this.config.chainId) != null ||
925
+ this.config.contracts?.uabReceiver != null;
926
+ const includeCrossChain = opts.includeCrossChain ?? (opts.chains.includes("ethereum") && uabConfigured);
927
+ if (includeCrossChain) {
928
+ const crossOwned = await this.scanCrossChain({
929
+ fromBlock: opts.fromBlock,
930
+ toBlock: opts.toBlock,
931
+ });
932
+ const evmChainId = this.getAdapter("ethereum").chainId;
933
+ for (const o of crossOwned) {
934
+ out.push({ ...o, chain: "ethereum", chainId: evmChainId, source: "uab" });
935
+ }
936
+ }
937
+ return out;
938
+ }
939
+ /**
940
+ * Fetch ALL native announcements on `chain` as indexer-shaped rows — unfiltered, with their
941
+ * full on-chain `metadata`. This is the raw input for the metadata-aware scanners:
942
+ * {@link discoverTraits} / {@link getReputationTraitsFromAnnouncements} (PSR attestation
943
+ * markers) and {@link filterOwnedAnnouncements}. Unlike {@link scan}, nothing is dropped, so
944
+ * callers can decode announcement metadata that ownership filtering would discard.
945
+ *
946
+ * Note: cross-chain (UAB) announcements are NOT included — the 96-byte Wormhole payload only
947
+ * carries a 24-byte metadata tail, which cannot hold the 130-byte V2 attestation metadata.
948
+ * For trait discovery, fetch rows natively on each chain instead.
949
+ */
950
+ async fetchAnnouncementRows(chain, opts = {}) {
951
+ const announcements = await this.getAdapter(chain).fetchAnnouncements({
952
+ fromCursor: opts.fromBlock,
953
+ toCursor: opts.toBlock,
954
+ limit: opts.solanaLimit,
955
+ });
956
+ return announcements.map(announcementToIndexerRow);
957
+ }
958
+ /** Lazily build and cache the {@link ChainAdapter} for a chain. */
959
+ getAdapter(chain) {
960
+ if (chain === "ethereum") {
961
+ this.evmAdapter ??= new EvmAdapter({
962
+ publicClient: this.publicClient,
963
+ announcerAddress: this.announcer,
964
+ registryAddress: this.registry,
965
+ evmChainId: this.config.chainId,
966
+ schemeId: BigInt(EIP5564_SCHEME_SECP256K1),
967
+ });
968
+ return this.evmAdapter;
969
+ }
970
+ if (chain === "solana") {
971
+ return this.getSolanaAdapter();
972
+ }
973
+ throw new Error(`Opaque: unsupported scan chain "${chain}"`);
974
+ }
975
+ /** Lazily build and cache the concrete {@link SolanaAdapter}. */
976
+ getSolanaAdapter() {
977
+ this.solanaAdapter ??= new SolanaAdapter(this.config.solana ?? {});
978
+ return this.solanaAdapter;
979
+ }
980
+ /**
981
+ * Sweep the full native balance of an owned stealth output to `destination`, signed by the
982
+ * reconstructed one-time key (the on-chain `from` is the stealth address itself). Works for
983
+ * Ethereum (ETH) and Solana (SOL); `"solana"` requires {@link OpaqueClientConfig.solana}.
984
+ */
985
+ async sweep(params) {
986
+ const stealthPrivKey = this.getStealthSignerPrivateKey(params.output);
987
+ if (params.chain === "ethereum") {
988
+ const hash = await sweepStealthNative(this.publicClient, {
989
+ stealthPrivKey,
990
+ destination: getAddress(params.destination),
991
+ rpcUrl: this.config.rpcUrl,
992
+ });
993
+ return { chain: "ethereum", tx: hash };
994
+ }
995
+ if (params.chain === "solana") {
996
+ const { signature } = await this.getSolanaAdapter().sweepStealthSol({
997
+ stealthPrivKey,
998
+ destination: params.destination,
999
+ });
1000
+ return { chain: "solana", tx: signature };
1001
+ }
1002
+ throw new Error(`Opaque: unsupported sweep chain "${params.chain}"`);
1003
+ }
232
1004
  /**
233
1005
  * Reconstruct the 32-byte secp256k1 private key that controls `output`’s one-time stealth address.
234
1006
  * Uses the same WASM path as the on-chain scanner (`reconstruct_signing_key_wasm`).
@@ -289,6 +1061,40 @@ export class OpaqueClient {
289
1061
  totalRaw: totals.get(t.address.toLowerCase()) ?? 0n,
290
1062
  }));
291
1063
  }
1064
+ /**
1065
+ * Native balance per owned stealth output across chains. Ethereum reads the stealth address
1066
+ * directly; Solana reconstructs the one-time key (WASM), derives the Solana stealth account, and
1067
+ * reads its lamports. Pass the {@link UnifiedOwnedOutput}s from {@link scan}. SPL/ERC-20 token
1068
+ * sums for the EVM tracked-token set live in {@link getBalancesFromAnnouncements}.
1069
+ */
1070
+ async getBalancesForOutputs(outputs) {
1071
+ const result = [];
1072
+ for (const o of outputs) {
1073
+ if (o.chain === "ethereum") {
1074
+ const wei = await this.publicClient.getBalance({
1075
+ address: getAddress(o.stealthAddress),
1076
+ });
1077
+ result.push({
1078
+ chain: "ethereum",
1079
+ stealthAddress: o.stealthAddress,
1080
+ address: o.stealthAddress,
1081
+ nativeRaw: wei,
1082
+ });
1083
+ }
1084
+ else if (o.chain === "solana") {
1085
+ const stealthPrivKey = this.getStealthSignerPrivateKey(o);
1086
+ const address = deriveStealthSolanaAddressFromStealthPrivKey(stealthPrivKey);
1087
+ const lamports = await this.getSolanaAdapter().connection.getBalance(new PublicKey(address));
1088
+ result.push({
1089
+ chain: "solana",
1090
+ stealthAddress: o.stealthAddress,
1091
+ address,
1092
+ nativeRaw: BigInt(lamports),
1093
+ });
1094
+ }
1095
+ }
1096
+ return result;
1097
+ }
292
1098
  /**
293
1099
  * PSR: map owned attestation markers to {@link DiscoveredTrait} list.
294
1100
  */
@@ -328,8 +1134,406 @@ export class OpaqueClient {
328
1134
  buildAssignReputationTransaction(recipientMetaAddressHex, attestationId) {
329
1135
  return this.buildAnnounceTransactionRequest(this.prepareReputationAssignment(recipientMetaAddressHex, attestationId));
330
1136
  }
1137
+ // ---------------------------------------------------------------------------
1138
+ // PSR admin API (cross-chain): schema + attestation lifecycle.
1139
+ //
1140
+ // Every method dispatches on `chain` and behaves identically from the caller's view, returning
1141
+ // the chain-neutral SchemaV2 / AttestationV2 shapes. Ethereum writes need `ethereumProvider`;
1142
+ // Solana writes need `solanaWallet`. The recipient/field/expiry/announce semantics match the
1143
+ // reference frontends byte-for-byte.
1144
+ // ---------------------------------------------------------------------------
331
1145
  /**
332
- * JSON array string for {@link generateReputationProof} when passing `attestationsJson` (WASM Merkle witness).
1146
+ * Register a new schema (attestation class + issuance rules). Returns the tx id and derived
1147
+ * `schemaId`. `fieldDefinitions` accepts an ABI string or {@link FieldDef}s.
1148
+ */
1149
+ async createSchema(chain, params) {
1150
+ const fieldDefinitions = normalizeFieldDefs(params.fieldDefinitions);
1151
+ if (chain === "ethereum") {
1152
+ const cfg = requirePsrV2Config(this.config.chainId);
1153
+ const clients = this.evmWriteClients();
1154
+ const schemaExpiryBlock = await this.resolveEvmExpiryBlock(params.schemaExpiry);
1155
+ return evmRegisterSchema(clients, cfg, {
1156
+ name: params.name,
1157
+ fieldDefinitions,
1158
+ revocable: params.revocable,
1159
+ resolver: params.resolver ? getAddress(params.resolver) : undefined,
1160
+ schemaExpiryBlock,
1161
+ });
1162
+ }
1163
+ if (chain === "solana") {
1164
+ const wallet = this.requireSolanaWallet();
1165
+ const programs = this.getSolanaAdapter().deployment;
1166
+ const schemaId = solanaComputeSchemaId(wallet.publicKey, params.name);
1167
+ const schemaPda = deriveSchemaPda(programs.schemaRegistry, wallet.publicKey, schemaId);
1168
+ const schemaExpirySlot = await this.resolveSolanaExpirySlot(params.schemaExpiry);
1169
+ const ix = buildRegisterSchemaInstruction({
1170
+ schemaRegistryProgramId: programs.schemaRegistry,
1171
+ authority: wallet.publicKey,
1172
+ schemaPda,
1173
+ schemaId,
1174
+ name: params.name,
1175
+ fieldDefinitions,
1176
+ revocable: params.revocable,
1177
+ resolver: params.resolver ? new PublicKey(params.resolver) : null,
1178
+ schemaExpirySlot,
1179
+ });
1180
+ const txHash = await this.sendSolanaTx([ix]);
1181
+ return { txHash, schemaId: bytesToHex0x(schemaId) };
1182
+ }
1183
+ throw unsupportedPsrChain(chain);
1184
+ }
1185
+ /** Schemas where this client's wallet is the authority OR an authorized delegate. */
1186
+ async getMySchemas(chain) {
1187
+ if (chain === "ethereum") {
1188
+ const cfg = requirePsrV2Config(this.config.chainId);
1189
+ return evmFetchSchemasForWallet(this.publicClient, cfg, this.config.ethereumAddress);
1190
+ }
1191
+ if (chain === "solana") {
1192
+ const wallet = this.requireSolanaWallet();
1193
+ const programs = this.getSolanaAdapter().deployment;
1194
+ const me = wallet.publicKey.toBase58();
1195
+ const all = await solanaFetchAllSchemas(this.getSolanaAdapter().connection, programs.schemaRegistry);
1196
+ return all
1197
+ .filter(({ schema }) => schema.authority.toBase58() === me ||
1198
+ schema.delegates.some((d) => d.toBase58() === me))
1199
+ .map(({ address, schema }) => solanaSchemaToV2(address, schema));
1200
+ }
1201
+ throw unsupportedPsrChain(chain);
1202
+ }
1203
+ /** Authority-only, irreversible: deprecate a schema (blocks new attestations). */
1204
+ async deprecateSchema(chain, schemaId) {
1205
+ if (chain === "ethereum") {
1206
+ const cfg = requirePsrV2Config(this.config.chainId);
1207
+ const txHash = await evmDeprecateSchema(this.evmWriteClients(), cfg, schemaId);
1208
+ return { txHash };
1209
+ }
1210
+ if (chain === "solana") {
1211
+ const wallet = this.requireSolanaWallet();
1212
+ const programs = this.getSolanaAdapter().deployment;
1213
+ const schemaPda = deriveSchemaPda(programs.schemaRegistry, wallet.publicKey, hexToBytes(schemaId));
1214
+ const ix = buildDeprecateSchemaInstruction({
1215
+ schemaRegistryProgramId: programs.schemaRegistry,
1216
+ authority: wallet.publicKey,
1217
+ schemaPda,
1218
+ });
1219
+ return { txHash: await this.sendSolanaTx([ix]) };
1220
+ }
1221
+ throw unsupportedPsrChain(chain);
1222
+ }
1223
+ /** Authority-only: authorize `delegate` to issue under `schemaId`. */
1224
+ async addSchemaDelegate(chain, schemaId, delegate) {
1225
+ if (chain === "ethereum") {
1226
+ const cfg = requirePsrV2Config(this.config.chainId);
1227
+ const txHash = await evmAddDelegate(this.evmWriteClients(), cfg, schemaId, getAddress(delegate));
1228
+ return { txHash };
1229
+ }
1230
+ if (chain === "solana") {
1231
+ const wallet = this.requireSolanaWallet();
1232
+ const programs = this.getSolanaAdapter().deployment;
1233
+ const schemaPda = deriveSchemaPda(programs.schemaRegistry, wallet.publicKey, hexToBytes(schemaId));
1234
+ const ix = buildAddDelegateInstruction({
1235
+ schemaRegistryProgramId: programs.schemaRegistry,
1236
+ authority: wallet.publicKey,
1237
+ schemaPda,
1238
+ delegate: new PublicKey(delegate),
1239
+ });
1240
+ return { txHash: await this.sendSolanaTx([ix]) };
1241
+ }
1242
+ throw unsupportedPsrChain(chain);
1243
+ }
1244
+ /** Authority-only: revoke a delegate's issuance rights under `schemaId`. */
1245
+ async removeSchemaDelegate(chain, schemaId, delegate) {
1246
+ if (chain === "ethereum") {
1247
+ const cfg = requirePsrV2Config(this.config.chainId);
1248
+ const txHash = await evmRemoveDelegate(this.evmWriteClients(), cfg, schemaId, getAddress(delegate));
1249
+ return { txHash };
1250
+ }
1251
+ if (chain === "solana") {
1252
+ const wallet = this.requireSolanaWallet();
1253
+ const programs = this.getSolanaAdapter().deployment;
1254
+ const schemaPda = deriveSchemaPda(programs.schemaRegistry, wallet.publicKey, hexToBytes(schemaId));
1255
+ const ix = buildRemoveDelegateInstruction({
1256
+ schemaRegistryProgramId: programs.schemaRegistry,
1257
+ authority: wallet.publicKey,
1258
+ schemaPda,
1259
+ delegate: new PublicKey(delegate),
1260
+ });
1261
+ return { txHash: await this.sendSolanaTx([ix]) };
1262
+ }
1263
+ throw unsupportedPsrChain(chain);
1264
+ }
1265
+ /** Attestations issued by this client's wallet. */
1266
+ async getMyIssuedAttestations(chain) {
1267
+ if (chain === "ethereum") {
1268
+ const cfg = requirePsrV2Config(this.config.chainId);
1269
+ return evmFetchAttestationsIssuedBy(this.publicClient, cfg, this.config.ethereumAddress);
1270
+ }
1271
+ if (chain === "solana") {
1272
+ const wallet = this.requireSolanaWallet();
1273
+ const programs = this.getSolanaAdapter().deployment;
1274
+ const me = wallet.publicKey.toBase58();
1275
+ const all = await solanaFetchAllAttestations(this.getSolanaAdapter().connection, programs.attestationEngineV2);
1276
+ return all
1277
+ .filter(({ attestation }) => attestation.issuer.toBase58() === me)
1278
+ .map(({ address, attestation }) => solanaAttestationToV2(address, attestation));
1279
+ }
1280
+ throw unsupportedPsrChain(chain);
1281
+ }
1282
+ /**
1283
+ * Issue a schema-bound attestation against a stealth identity. Resolves the recipient to a
1284
+ * `stealth_address_hash`, encodes the field values per the schema, submits `attest`, and
1285
+ * (when the recipient is a meta-address and `announce` is not `false`) publishes a discovery
1286
+ * announcement so the recipient's scanner can find the trait. Verifies the wallet is an
1287
+ * authorized issuer first.
1288
+ */
1289
+ async issueAttestation(chain, params) {
1290
+ if (chain === "ethereum") {
1291
+ const cfg = requirePsrV2Config(this.config.chainId);
1292
+ const clients = this.evmWriteClients();
1293
+ const schema = await evmFetchSchema(this.publicClient, cfg, params.schemaId);
1294
+ if (!schema)
1295
+ throw new Error(`Opaque PSR: schema ${params.schemaId} not found on Ethereum.`);
1296
+ const authorized = await evmIsAuthorizedIssuer(this.publicClient, cfg, params.schemaId, this.config.ethereumAddress);
1297
+ if (!authorized) {
1298
+ throw new Error(`Opaque PSR: ${this.config.ethereumAddress} is not an authorized issuer for schema ${params.schemaId}.`);
1299
+ }
1300
+ const resolved = this.resolveStealthAddressHash(params.recipient);
1301
+ const expirationBlock = await this.resolveEvmExpiryBlock(params.expiration);
1302
+ const { txHash, uid } = await evmAttest(clients, cfg, {
1303
+ schemaId: params.schemaId,
1304
+ stealthAddressHash: resolved.hash,
1305
+ fieldValues: params.fieldValues,
1306
+ fieldDefs: parseFieldDefs(schema.fieldDefinitions),
1307
+ expirationBlock,
1308
+ refUid: params.refUid,
1309
+ });
1310
+ const wantAnnounce = params.announce ?? resolved.ephemeralPubKey != null;
1311
+ if (wantAnnounce && resolved.stealthAddress && resolved.ephemeralPubKey && resolved.viewTag != null) {
1312
+ const metadata = encodeV2AttestationMetadata({
1313
+ viewTag: resolved.viewTag,
1314
+ schemaId: params.schemaId,
1315
+ issuer: this.config.ethereumAddress,
1316
+ uid,
1317
+ nonce: randomNonce(),
1318
+ });
1319
+ try {
1320
+ await evmAnnounceV2Attestation(clients, this.announcer, {
1321
+ stealthAddress: resolved.stealthAddress,
1322
+ ephemeralPubKey: bytesToHex0x(resolved.ephemeralPubKey),
1323
+ metadata,
1324
+ });
1325
+ }
1326
+ catch {
1327
+ // Announcement is a discovery convenience; issuance already succeeded.
1328
+ }
1329
+ }
1330
+ return { txHash, uid, stealthAddressHash: resolved.hash };
1331
+ }
1332
+ if (chain === "solana") {
1333
+ const wallet = this.requireSolanaWallet();
1334
+ const conn = this.getSolanaAdapter().connection;
1335
+ const programs = this.getSolanaAdapter().deployment;
1336
+ const schemaIdBytes = hexToBytes(params.schemaId);
1337
+ const found = await this.fetchSolanaSchemaById(schemaIdBytes);
1338
+ if (!found)
1339
+ throw new Error(`Opaque PSR: schema ${params.schemaId} not found on Solana.`);
1340
+ const me = wallet.publicKey.toBase58();
1341
+ const authorized = found.schema.authority.toBase58() === me ||
1342
+ found.schema.delegates.some((d) => d.toBase58() === me);
1343
+ if (!authorized) {
1344
+ throw new Error(`Opaque PSR: ${me} is not an authorized issuer for schema ${params.schemaId}.`);
1345
+ }
1346
+ const resolved = this.resolveStealthAddressHash(params.recipient);
1347
+ const stealthHashBytes = hexToBytes(resolved.hash);
1348
+ const dataBytes = hexToBytes(encodeAttestationData(params.fieldValues, parseFieldDefs(found.schema.fieldDefinitions)));
1349
+ const expirationSlot = await this.resolveSolanaExpirySlot(params.expiration);
1350
+ const refUid = params.refUid ? hexToBytes(params.refUid) : new Uint8Array(32);
1351
+ const schemaPda = deriveSchemaPda(programs.schemaRegistry, found.schema.authority, schemaIdBytes);
1352
+ const attestationPda = deriveAttestationPda(programs.attestationEngineV2, schemaIdBytes, wallet.publicKey, stealthHashBytes);
1353
+ const resolverProgram = found.schema.resolver.equals(PublicKey.default) ? undefined : found.schema.resolver;
1354
+ const ix = buildAttestInstruction({
1355
+ attestationProgramId: programs.attestationEngineV2,
1356
+ issuer: wallet.publicKey,
1357
+ schemaPda,
1358
+ attestationPda,
1359
+ stealthAddressHash: stealthHashBytes,
1360
+ data: dataBytes,
1361
+ expirationSlot,
1362
+ refUid,
1363
+ resolverProgram,
1364
+ });
1365
+ const txHash = await this.sendSolanaTx([ix]);
1366
+ const confirmed = await solanaFetchAttestationPda(conn, attestationPda);
1367
+ const uid = confirmed ? bytesToHex0x(confirmed.uid) : ZERO_BYTES32_HEX;
1368
+ const wantAnnounce = params.announce ?? resolved.ephemeralPubKey != null;
1369
+ if (wantAnnounce && confirmed && resolved.stealthAddress && resolved.ephemeralPubKey && resolved.viewTag != null) {
1370
+ const metadata = buildSolanaV2AttestationMetadata(resolved.viewTag, schemaIdBytes, wallet.publicKey, confirmed.uid);
1371
+ const announceIx = this.getSolanaAdapter().buildAnnounceInstruction({
1372
+ caller: wallet.publicKey,
1373
+ stealthAddress: hexToBytes(resolved.stealthAddress),
1374
+ ephemeralPubKey: resolved.ephemeralPubKey,
1375
+ metadata,
1376
+ });
1377
+ try {
1378
+ await this.sendSolanaTx([announceIx]);
1379
+ }
1380
+ catch {
1381
+ // Announcement is a discovery convenience; issuance already succeeded.
1382
+ }
1383
+ }
1384
+ return { txHash, uid, stealthAddressHash: resolved.hash };
1385
+ }
1386
+ throw unsupportedPsrChain(chain);
1387
+ }
1388
+ // --- PSR admin helpers ----------------------------------------------------
1389
+ /** EIP-1193 provider or a clear error. */
1390
+ requireEthereumProvider() {
1391
+ const p = this.config.ethereumProvider;
1392
+ if (!p) {
1393
+ throw new Error("Opaque PSR: ethereumProvider is required for Ethereum PSR writes. Pass it to OpaqueClient.create.");
1394
+ }
1395
+ return p;
1396
+ }
1397
+ /** Viem chain for the configured chain id (bundled Sepolia, else a minimal definition). */
1398
+ viemChain() {
1399
+ if (this.config.chainId === sepolia.id)
1400
+ return sepolia;
1401
+ return defineChain({
1402
+ id: this.config.chainId,
1403
+ name: `chain-${this.config.chainId}`,
1404
+ nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
1405
+ rpcUrls: { default: { http: [this.config.rpcUrl] } },
1406
+ });
1407
+ }
1408
+ /** Lazily build the signing wallet client for Ethereum PSR writes. */
1409
+ evmWalletClient() {
1410
+ if (!this.evmWalletClientCache) {
1411
+ this.evmWalletClientCache =
1412
+ this.config.ethereumWalletClient ??
1413
+ createWalletClient({
1414
+ account: this.config.ethereumAddress,
1415
+ chain: this.viemChain(),
1416
+ transport: custom(this.requireEthereumProvider()),
1417
+ });
1418
+ }
1419
+ return this.evmWalletClientCache;
1420
+ }
1421
+ evmWriteClients() {
1422
+ return {
1423
+ publicClient: this.publicClient,
1424
+ walletClient: this.evmWalletClient(),
1425
+ account: this.config.ethereumAddress,
1426
+ };
1427
+ }
1428
+ /** Solana signer (normalized) or a clear error. */
1429
+ requireSolanaWallet() {
1430
+ if (!this.solanaWalletCache) {
1431
+ const w = this.config.solanaWallet;
1432
+ if (!w) {
1433
+ throw new Error("Opaque PSR: solanaWallet ({ publicKey, signTransaction }) is required for Solana PSR writes. Pass it to OpaqueClient.create.");
1434
+ }
1435
+ this.solanaWalletCache = {
1436
+ publicKey: typeof w.publicKey === "string" ? new PublicKey(w.publicKey) : w.publicKey,
1437
+ signTransaction: w.signTransaction,
1438
+ };
1439
+ }
1440
+ return this.solanaWalletCache;
1441
+ }
1442
+ /**
1443
+ * Sign + send + confirm a Solana transaction built from `ixs`. Any `extraSigners` (e.g. the
1444
+ * Wormhole message keypair for `announce_with_relay`) partial-sign before the wallet signs as
1445
+ * fee payer.
1446
+ */
1447
+ async sendSolanaTx(ixs, extraSigners = []) {
1448
+ const wallet = this.requireSolanaWallet();
1449
+ const conn = this.getSolanaAdapter().connection;
1450
+ const tx = new Transaction();
1451
+ for (const ix of ixs)
1452
+ tx.add(ix);
1453
+ tx.feePayer = wallet.publicKey;
1454
+ const latest = await conn.getLatestBlockhash("confirmed");
1455
+ tx.recentBlockhash = latest.blockhash;
1456
+ if (extraSigners.length > 0)
1457
+ tx.partialSign(...extraSigners);
1458
+ const signed = await wallet.signTransaction(tx);
1459
+ const signature = await conn.sendRawTransaction(signed.serialize());
1460
+ await conn.confirmTransaction({ signature, ...latest }, "confirmed");
1461
+ return signature;
1462
+ }
1463
+ /**
1464
+ * Resolve a recipient to a 32-byte `stealth_address_hash` (and, for a meta-address, the
1465
+ * ephemeral material needed to announce). Matches the frontends: 66-byte meta-address → DKSAP →
1466
+ * `keccak256(stealthAddress)`; 20-byte stealth address → `keccak256(address)`; 32-byte → as-is.
1467
+ */
1468
+ resolveStealthAddressHash(recipient) {
1469
+ const trimmed = recipient.trim();
1470
+ const normalized = (trimmed.startsWith("0x") ? trimmed : `0x${trimmed}`);
1471
+ const hexLen = normalized.length - 2;
1472
+ if (hexLen === 132) {
1473
+ const r = computeStealthAddressAndViewTag(normalized);
1474
+ return {
1475
+ hash: keccak256(r.stealthAddress),
1476
+ stealthAddress: r.stealthAddress,
1477
+ ephemeralPubKey: r.ephemeralPubKey,
1478
+ viewTag: r.viewTag,
1479
+ };
1480
+ }
1481
+ if (hexLen === 40) {
1482
+ return { hash: keccak256(getAddress(normalized)), stealthAddress: getAddress(normalized) };
1483
+ }
1484
+ if (hexLen === 64) {
1485
+ return { hash: normalized };
1486
+ }
1487
+ throw new Error("Opaque PSR: recipient must be a 66-byte meta-address, 20-byte stealth address, or 32-byte hash (hex).");
1488
+ }
1489
+ /** Resolve a {@link PsrExpiryInput} to an absolute Ethereum block (0 = no expiry). */
1490
+ async resolveEvmExpiryBlock(expiry) {
1491
+ if (!expiry)
1492
+ return 0n;
1493
+ if (expiry.slotOrBlock != null)
1494
+ return BigInt(expiry.slotOrBlock);
1495
+ if (expiry.dateTime) {
1496
+ const targetMs = Date.parse(expiry.dateTime);
1497
+ if (!Number.isFinite(targetMs))
1498
+ throw new Error(`Opaque PSR: invalid expiry dateTime "${expiry.dateTime}".`);
1499
+ const nowMs = Date.now();
1500
+ if (targetMs <= nowMs)
1501
+ throw new Error("Opaque PSR: expiry must be in the future.");
1502
+ const current = await this.publicClient.getBlockNumber();
1503
+ const blocks = Math.ceil((targetMs - nowMs) / 12_000); // ~12s/block
1504
+ return current + BigInt(Math.max(1, blocks));
1505
+ }
1506
+ return 0n;
1507
+ }
1508
+ /** Resolve a {@link PsrExpiryInput} to an absolute Solana slot (0 = no expiry). */
1509
+ async resolveSolanaExpirySlot(expiry) {
1510
+ if (!expiry)
1511
+ return 0;
1512
+ if (expiry.slotOrBlock != null)
1513
+ return Number(expiry.slotOrBlock);
1514
+ if (expiry.dateTime) {
1515
+ const targetMs = Date.parse(expiry.dateTime);
1516
+ if (!Number.isFinite(targetMs))
1517
+ throw new Error(`Opaque PSR: invalid expiry dateTime "${expiry.dateTime}".`);
1518
+ const nowMs = Date.now();
1519
+ if (targetMs <= nowMs)
1520
+ throw new Error("Opaque PSR: expiry must be in the future.");
1521
+ const currentSlot = await this.getSolanaAdapter().connection.getSlot("confirmed");
1522
+ const slots = Math.ceil((targetMs - nowMs) / 400); // ~400ms/slot
1523
+ return currentSlot + Math.max(1, slots);
1524
+ }
1525
+ return 0;
1526
+ }
1527
+ /** Find a Solana schema by `schemaId` (PSR schemas have no id-indexed PDA across authorities). */
1528
+ async fetchSolanaSchemaById(schemaIdBytes) {
1529
+ const programs = this.getSolanaAdapter().deployment;
1530
+ const all = await solanaFetchAllSchemas(this.getSolanaAdapter().connection, programs.schemaRegistry);
1531
+ const target = bytesToHex(schemaIdBytes);
1532
+ return all.find(({ schema }) => bytesToHex(schema.schemaId) === target) ?? null;
1533
+ }
1534
+ /**
1535
+ * JSON array string in the Rust scanner's announcement format (general scanner interop;
1536
+ * no longer consumed by {@link generateReputationProof}, which builds V2 witnesses directly).
333
1537
  */
334
1538
  announcementsJsonForReputationWitness(rows) {
335
1539
  return indexerAnnouncementsToScannerJson(rows);
@@ -347,18 +1551,22 @@ export class OpaqueClient {
347
1551
  return this.getStealthSignerPrivateKey({ ephemeralPublicKey });
348
1552
  }
349
1553
  /**
350
- * Groth16 proof bundle for `OpaqueReputationVerifier` (requires `snarkjs`).
351
- * When `artifacts` is omitted, wasm/zkey are loaded from the default hosted paths on opaque.cash
352
- * (same as the Opaque frontend `/circuits/...` assets).
1554
+ * V2 Groth16 proof bundle for the reputation verifiers (requires `snarkjs`).
1555
+ * When `artifacts` is omitted, the V2 wasm/zkey are loaded from the default hosted paths on
1556
+ * opaque.cash (same as the Opaque frontend `/circuits/v2/...` assets).
1557
+ *
1558
+ * Public signals: `[merkle_root, attestation_id, external_nullifier, nullifier_hash]`;
1559
+ * `ProofData.nullifier` carries `nullifier_hash`.
353
1560
  */
354
1561
  async generateReputationProof(params) {
355
1562
  await ensureBufferPolyfill();
356
1563
  return runGenerateReputationProof({
357
- wasm: this.wasm,
358
1564
  trait: params.trait,
359
1565
  stealthPrivKeyBytes: params.stealthPrivKeyBytes,
360
1566
  externalNullifier: params.externalNullifier,
361
- attestationsJson: params.attestationsJson,
1567
+ issuerPkX: params.issuerPkX,
1568
+ traitDataHash: params.traitDataHash,
1569
+ nonce: params.nonce,
362
1570
  artifacts: params.artifacts,
363
1571
  onProgress: params.onProgress,
364
1572
  });
@@ -383,9 +1591,37 @@ export class OpaqueClient {
383
1591
  async simulateReputationVerification(wallet, args) {
384
1592
  return simulateVerifyReputation(this.publicClient, wallet, this.getReputationVerifierAddress(), args);
385
1593
  }
386
- /** Broadcast `verifyReputation` (consumes nullifier when successful). */
387
- async submitReputationVerification(wallet, args) {
388
- return submitVerifyReputation(this.publicClient, wallet, this.getReputationVerifierAddress(), args);
1594
+ /**
1595
+ * Broadcast a reputation proof to the verifier, dispatching on `chain` (consumes the nullifier on
1596
+ * success). Uses the configured signer for each chain — `ethereumWalletClient` / `ethereumProvider`
1597
+ * for Ethereum, `solanaWallet` for Solana. The same {@link VerifyReputationArgs} feeds both:
1598
+ * `proofData` (from {@link generateReputationProof}), `merkleRoot`, and `externalNullifier`.
1599
+ */
1600
+ async submitReputationVerification(chain, args) {
1601
+ if (chain === "ethereum") {
1602
+ // The configured wallet client carries a concrete chain at runtime (viemChain / wagmi);
1603
+ // cast to satisfy submitVerifyReputation's `TChain extends Chain` constraint.
1604
+ const wallet = this.evmWalletClient();
1605
+ const txHash = await submitVerifyReputation(this.publicClient, wallet, this.getReputationVerifierAddress(), args);
1606
+ return { txHash };
1607
+ }
1608
+ if (chain === "solana") {
1609
+ const wallet = this.requireSolanaWallet();
1610
+ const adapter = this.getSolanaAdapter();
1611
+ const txHash = await solanaSubmitReputationProof(adapter.connection, {
1612
+ reputationProgramId: adapter.deployment.reputationVerifier,
1613
+ groth16ProgramId: adapter.deployment.groth16Verifier,
1614
+ proof: args.proofData.proof,
1615
+ merkleRoot: args.merkleRoot,
1616
+ nullifier: args.proofData.nullifier,
1617
+ externalNullifier: args.externalNullifier,
1618
+ attestationId: args.proofData.attestationId,
1619
+ publicKey: wallet.publicKey,
1620
+ signTransaction: wallet.signTransaction,
1621
+ });
1622
+ return { txHash };
1623
+ }
1624
+ throw unsupportedPsrChain(chain);
389
1625
  }
390
1626
  getReputationVerifierAddress() {
391
1627
  if (!this.reputationVerifier) {
@@ -421,6 +1657,100 @@ function bytesToHex(b) {
421
1657
  .map((x) => x.toString(16).padStart(2, "0"))
422
1658
  .join("");
423
1659
  }
1660
+ function sleep(ms) {
1661
+ return new Promise((resolve) => setTimeout(resolve, ms));
1662
+ }
1663
+ /**
1664
+ * Stand-in WASM module for clients created without a `wasmModuleSpecifier`: any property access
1665
+ * throws a clear error. The PSR admin API never touches it; scan/sweep/proof/trait methods do.
1666
+ */
1667
+ function wasmUnavailable() {
1668
+ return new Proxy({}, {
1669
+ get() {
1670
+ throw new Error("Opaque: this method requires the cryptography WASM module. Pass `wasmModuleSpecifier` to " +
1671
+ "OpaqueClient.create. (Scanning, sweeping, trait discovery, key reconstruction, and proof " +
1672
+ "generation need it; PSR schema/attestation admin does not.)");
1673
+ },
1674
+ });
1675
+ }
1676
+ /** All-zero bytes32 as `0x`-hex (no attestation uid). */
1677
+ const ZERO_BYTES32_HEX = ("0x" + "00".repeat(32));
1678
+ function bytesToHex0x(b) {
1679
+ return ("0x" + bytesToHex(b));
1680
+ }
1681
+ function unsupportedPsrChain(chain) {
1682
+ return new Error(`Opaque PSR: unsupported chain "${chain}".`);
1683
+ }
1684
+ /** Accept an ABI string or {@link FieldDef}s and return the canonical ABI string. */
1685
+ function normalizeFieldDefs(fieldDefinitions) {
1686
+ return typeof fieldDefinitions === "string"
1687
+ ? fieldDefinitions
1688
+ : fieldDefsToString(fieldDefinitions);
1689
+ }
1690
+ /** Convert a parsed Solana schema PDA into the chain-neutral {@link SchemaV2}. */
1691
+ function solanaSchemaToV2(address, s) {
1692
+ return {
1693
+ address: address.toBase58(),
1694
+ schemaId: bytesToHex0x(s.schemaId),
1695
+ authority: s.authority.toBase58(),
1696
+ resolver: s.resolver.toBase58(),
1697
+ revocable: s.revocable,
1698
+ name: s.name,
1699
+ fieldDefinitions: s.fieldDefinitions,
1700
+ version: s.version,
1701
+ delegates: s.delegates.map((d) => d.toBase58()),
1702
+ createdAt: Number(s.createdAt),
1703
+ schemaExpirySlot: Number(s.schemaExpirySlot),
1704
+ deprecated: s.deprecated,
1705
+ };
1706
+ }
1707
+ /** Convert a parsed Solana attestation PDA into the chain-neutral {@link AttestationV2}. */
1708
+ function solanaAttestationToV2(address, a) {
1709
+ return {
1710
+ address: address.toBase58(),
1711
+ uid: bytesToHex0x(a.uid),
1712
+ schemaId: bytesToHex0x(a.schemaId),
1713
+ issuer: a.issuer.toBase58(),
1714
+ stealthAddressHash: bytesToHex0x(a.stealthAddressHash),
1715
+ dataHex: bytesToHex0x(a.data),
1716
+ createdAt: Number(a.createdAt),
1717
+ expirationSlot: Number(a.expirationSlot),
1718
+ revocationSlot: Number(a.revocationSlot),
1719
+ refUid: bytesToHex0x(a.refUid),
1720
+ };
1721
+ }
1722
+ /**
1723
+ * Build the 130-byte V2 attestation announcement metadata for Solana (the issuer is a 32-byte
1724
+ * Ed25519 pubkey, so this is the Solana counterpart to `encodeV2AttestationMetadata`):
1725
+ * `viewTag(1) || 0xB2 || schemaId(32) || issuer(32) || uid(32) || nonce(32)`.
1726
+ */
1727
+ function buildSolanaV2AttestationMetadata(viewTag, schemaIdBytes, issuer, uid) {
1728
+ const metadata = new Uint8Array(130);
1729
+ metadata[0] = viewTag & 0xff;
1730
+ metadata[1] = 0xb2;
1731
+ metadata.set(schemaIdBytes.slice(0, 32), 2);
1732
+ metadata.set(issuer.toBytes(), 34);
1733
+ metadata.set(uid.slice(0, 32), 66);
1734
+ metadata.set(hexToBytes(randomNonce()), 98);
1735
+ return metadata;
1736
+ }
1737
+ /**
1738
+ * Map a chain-neutral {@link Announcement} (from any {@link ChainAdapter}) into the
1739
+ * {@link IndexerAnnouncement} row shape consumed by {@link OpaqueClient.filterOwnedAnnouncements}.
1740
+ * `txHash` passes through verbatim (an EVM `0x` hash or a Solana base58 signature); `cursor`
1741
+ * (EVM block / Solana slot) becomes `blockNumber`.
1742
+ */
1743
+ export function announcementToIndexerRow(a) {
1744
+ return {
1745
+ blockNumber: (a.cursor ?? 0n).toString(),
1746
+ etherealPublicKey: (`0x${bytesToHex(a.ephemeralPubKey)}`),
1747
+ logIndex: a.logIndex ?? 0,
1748
+ metadata: (`0x${bytesToHex(a.metadata)}`),
1749
+ stealthAddress: a.stealthAddress,
1750
+ transactionHash: (a.txHash ?? "0x"),
1751
+ viewTag: a.viewTag,
1752
+ };
1753
+ }
424
1754
  function hexPayloadByteLength(h) {
425
1755
  const s = h.startsWith("0x") ? h.slice(2) : h;
426
1756
  return s.length / 2;