@opaquecash/opaque 0.1.2 → 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/chains.d.ts.map +1 -1
- package/dist/chains.js +19 -28
- package/dist/chains.js.map +1 -1
- package/dist/client.d.ts +577 -11
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +1340 -20
- package/dist/client.js.map +1 -1
- package/dist/crypto/dksap.d.ts +15 -0
- package/dist/crypto/dksap.d.ts.map +1 -1
- package/dist/crypto/dksap.js +22 -3
- package/dist/crypto/dksap.js.map +1 -1
- package/dist/index.d.ts +16 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -2
- package/dist/index.js.map +1 -1
- package/dist/resolve.d.ts +103 -0
- package/dist/resolve.d.ts.map +1 -0
- package/dist/resolve.js +221 -0
- package/dist/resolve.js.map +1 -0
- package/dist/signer.d.ts +41 -0
- package/dist/signer.d.ts.map +1 -0
- package/dist/signer.js +47 -0
- package/dist/signer.js.map +1 -0
- package/package.json +30 -10
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";
|
|
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
|
+
];
|
|
4
22
|
import { checkAnnouncement, checkAnnouncementViewTag, 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";
|
|
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 {
|
|
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 =
|
|
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,6 +747,96 @@ 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
|
*/
|
|
@@ -222,10 +856,18 @@ export class OpaqueClient {
|
|
|
222
856
|
: Number(vtRaw);
|
|
223
857
|
if (!Number.isFinite(vt) || !Number.isInteger(vt) || vt < 0 || vt > 255)
|
|
224
858
|
continue;
|
|
225
|
-
|
|
226
|
-
|
|
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 {
|
|
227
869
|
continue;
|
|
228
|
-
|
|
870
|
+
}
|
|
229
871
|
if (!ok)
|
|
230
872
|
continue;
|
|
231
873
|
owned.push({
|
|
@@ -239,6 +881,126 @@ export class OpaqueClient {
|
|
|
239
881
|
}
|
|
240
882
|
return owned;
|
|
241
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
|
+
}
|
|
242
1004
|
/**
|
|
243
1005
|
* Reconstruct the 32-byte secp256k1 private key that controls `output`’s one-time stealth address.
|
|
244
1006
|
* Uses the same WASM path as the on-chain scanner (`reconstruct_signing_key_wasm`).
|
|
@@ -299,6 +1061,40 @@ export class OpaqueClient {
|
|
|
299
1061
|
totalRaw: totals.get(t.address.toLowerCase()) ?? 0n,
|
|
300
1062
|
}));
|
|
301
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
|
+
}
|
|
302
1098
|
/**
|
|
303
1099
|
* PSR: map owned attestation markers to {@link DiscoveredTrait} list.
|
|
304
1100
|
*/
|
|
@@ -338,8 +1134,406 @@ export class OpaqueClient {
|
|
|
338
1134
|
buildAssignReputationTransaction(recipientMetaAddressHex, attestationId) {
|
|
339
1135
|
return this.buildAnnounceTransactionRequest(this.prepareReputationAssignment(recipientMetaAddressHex, attestationId));
|
|
340
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
|
+
// ---------------------------------------------------------------------------
|
|
1145
|
+
/**
|
|
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
|
+
}
|
|
341
1282
|
/**
|
|
342
|
-
*
|
|
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).
|
|
343
1537
|
*/
|
|
344
1538
|
announcementsJsonForReputationWitness(rows) {
|
|
345
1539
|
return indexerAnnouncementsToScannerJson(rows);
|
|
@@ -357,18 +1551,22 @@ export class OpaqueClient {
|
|
|
357
1551
|
return this.getStealthSignerPrivateKey({ ephemeralPublicKey });
|
|
358
1552
|
}
|
|
359
1553
|
/**
|
|
360
|
-
* Groth16 proof bundle for
|
|
361
|
-
* When `artifacts` is omitted, wasm/zkey are loaded from the default hosted paths on
|
|
362
|
-
* (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`.
|
|
363
1560
|
*/
|
|
364
1561
|
async generateReputationProof(params) {
|
|
365
1562
|
await ensureBufferPolyfill();
|
|
366
1563
|
return runGenerateReputationProof({
|
|
367
|
-
wasm: this.wasm,
|
|
368
1564
|
trait: params.trait,
|
|
369
1565
|
stealthPrivKeyBytes: params.stealthPrivKeyBytes,
|
|
370
1566
|
externalNullifier: params.externalNullifier,
|
|
371
|
-
|
|
1567
|
+
issuerPkX: params.issuerPkX,
|
|
1568
|
+
traitDataHash: params.traitDataHash,
|
|
1569
|
+
nonce: params.nonce,
|
|
372
1570
|
artifacts: params.artifacts,
|
|
373
1571
|
onProgress: params.onProgress,
|
|
374
1572
|
});
|
|
@@ -393,9 +1591,37 @@ export class OpaqueClient {
|
|
|
393
1591
|
async simulateReputationVerification(wallet, args) {
|
|
394
1592
|
return simulateVerifyReputation(this.publicClient, wallet, this.getReputationVerifierAddress(), args);
|
|
395
1593
|
}
|
|
396
|
-
/**
|
|
397
|
-
|
|
398
|
-
|
|
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);
|
|
399
1625
|
}
|
|
400
1626
|
getReputationVerifierAddress() {
|
|
401
1627
|
if (!this.reputationVerifier) {
|
|
@@ -431,6 +1657,100 @@ function bytesToHex(b) {
|
|
|
431
1657
|
.map((x) => x.toString(16).padStart(2, "0"))
|
|
432
1658
|
.join("");
|
|
433
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
|
+
}
|
|
434
1754
|
function hexPayloadByteLength(h) {
|
|
435
1755
|
const s = h.startsWith("0x") ? h.slice(2) : h;
|
|
436
1756
|
return s.length / 2;
|