@utxopia/sdk 0.1.0-alpha.1
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/LICENSE +21 -0
- package/README.md +171 -0
- package/package.json +108 -0
- package/packages/btc-client/src/esplora-client.ts +153 -0
- package/packages/btc-client/src/index.ts +3 -0
- package/packages/btc-client/src/op-return.ts +93 -0
- package/packages/btc-client/src/types.ts +112 -0
- package/packages/sdk/README.md +277 -0
- package/packages/sdk/dist/announcement-client.d.ts +64 -0
- package/packages/sdk/dist/announcement-client.js +337 -0
- package/packages/sdk/dist/auditor-ciphertext.d.ts +72 -0
- package/packages/sdk/dist/auditor-ciphertext.js +135 -0
- package/packages/sdk/dist/auditor.d.ts +115 -0
- package/packages/sdk/dist/auditor.js +292 -0
- package/packages/sdk/dist/bitcoin/ika.d.ts +47 -0
- package/packages/sdk/dist/bitcoin/ika.js +74 -0
- package/packages/sdk/dist/bitcoin/index.d.ts +2 -0
- package/packages/sdk/dist/bitcoin/index.js +1 -0
- package/packages/sdk/dist/bound-params.d.ts +96 -0
- package/packages/sdk/dist/bound-params.js +249 -0
- package/packages/sdk/dist/chadbuffer.d.ts +124 -0
- package/packages/sdk/dist/chadbuffer.js +405 -0
- package/packages/sdk/dist/claim-link.d.ts +30 -0
- package/packages/sdk/dist/claim-link.js +50 -0
- package/packages/sdk/dist/client.d.ts +233 -0
- package/packages/sdk/dist/client.js +416 -0
- package/packages/sdk/dist/commitment-tree.d.ts +268 -0
- package/packages/sdk/dist/commitment-tree.js +564 -0
- package/packages/sdk/dist/config.d.ts +177 -0
- package/packages/sdk/dist/config.js +426 -0
- package/packages/sdk/dist/core/esplora.d.ts +110 -0
- package/packages/sdk/dist/core/esplora.js +187 -0
- package/packages/sdk/dist/core/mempool.d.ts +60 -0
- package/packages/sdk/dist/core/mempool.js +107 -0
- package/packages/sdk/dist/crypto-babyjub.d.ts +105 -0
- package/packages/sdk/dist/crypto-babyjub.js +312 -0
- package/packages/sdk/dist/crypto-ed25519.d.ts +116 -0
- package/packages/sdk/dist/crypto-ed25519.js +235 -0
- package/packages/sdk/dist/crypto.d.ts +59 -0
- package/packages/sdk/dist/crypto.js +140 -0
- package/packages/sdk/dist/event-client.d.ts +64 -0
- package/packages/sdk/dist/event-client.js +165 -0
- package/packages/sdk/dist/events.d.ts +127 -0
- package/packages/sdk/dist/events.js +305 -0
- package/packages/sdk/dist/explorer.d.ts +86 -0
- package/packages/sdk/dist/explorer.js +197 -0
- package/packages/sdk/dist/index.d.ts +61 -0
- package/packages/sdk/dist/index.js +216 -0
- package/packages/sdk/dist/instructions.d.ts +1125 -0
- package/packages/sdk/dist/instructions.js +1760 -0
- package/packages/sdk/dist/keys.d.ts +467 -0
- package/packages/sdk/dist/keys.js +799 -0
- package/packages/sdk/dist/logger.d.ts +10 -0
- package/packages/sdk/dist/logger.js +40 -0
- package/packages/sdk/dist/magicblock.d.ts +90 -0
- package/packages/sdk/dist/magicblock.js +164 -0
- package/packages/sdk/dist/merkle.d.ts +82 -0
- package/packages/sdk/dist/merkle.js +141 -0
- package/packages/sdk/dist/note.d.ts +348 -0
- package/packages/sdk/dist/note.js +483 -0
- package/packages/sdk/dist/pda.d.ts +160 -0
- package/packages/sdk/dist/pda.js +384 -0
- package/packages/sdk/dist/pool-state.d.ts +100 -0
- package/packages/sdk/dist/pool-state.js +126 -0
- package/packages/sdk/dist/poseidon.d.ts +53 -0
- package/packages/sdk/dist/poseidon.js +136 -0
- package/packages/sdk/dist/prover/index.d.ts +10 -0
- package/packages/sdk/dist/prover/index.js +10 -0
- package/packages/sdk/dist/prover/mobile.d.ts +45 -0
- package/packages/sdk/dist/prover/mobile.js +227 -0
- package/packages/sdk/dist/prover/web.d.ts +136 -0
- package/packages/sdk/dist/prover/web.js +597 -0
- package/packages/sdk/dist/psbt.d.ts +73 -0
- package/packages/sdk/dist/psbt.js +202 -0
- package/packages/sdk/dist/selective-disclosure.d.ts +181 -0
- package/packages/sdk/dist/selective-disclosure.js +172 -0
- package/packages/sdk/dist/sender-memo.d.ts +149 -0
- package/packages/sdk/dist/sender-memo.js +250 -0
- package/packages/sdk/dist/sns-resolver.d.ts +121 -0
- package/packages/sdk/dist/sns-resolver.js +229 -0
- package/packages/sdk/dist/solana/connection.d.ts +77 -0
- package/packages/sdk/dist/solana/connection.js +133 -0
- package/packages/sdk/dist/solana/priority-fee.d.ts +78 -0
- package/packages/sdk/dist/solana/priority-fee.js +137 -0
- package/packages/sdk/dist/stealth.d.ts +429 -0
- package/packages/sdk/dist/stealth.js +703 -0
- package/packages/sdk/dist/taproot.d.ts +172 -0
- package/packages/sdk/dist/taproot.js +494 -0
- package/packages/sdk/dist/token-registry.d.ts +96 -0
- package/packages/sdk/dist/token-registry.js +122 -0
- package/packages/sdk/dist/utils/encoding.d.ts +19 -0
- package/packages/sdk/dist/utils/encoding.js +29 -0
- package/packages/sdk/dist/vk-registry.d.ts +100 -0
- package/packages/sdk/dist/vk-registry.js +212 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UTXOpiaClient — high-level SDK entry point.
|
|
3
|
+
*
|
|
4
|
+
* Initialize once, use simple methods everywhere. Encapsulates config,
|
|
5
|
+
* keys, Poseidon init, token IDs, and note scanning so consumers don't
|
|
6
|
+
* chain low-level SDK calls.
|
|
7
|
+
*
|
|
8
|
+
* ```typescript
|
|
9
|
+
* const client = await UTXOpiaClient.init({ network: "devnet" });
|
|
10
|
+
* await client.loginWithWallet(wallet);
|
|
11
|
+
* const notes = await client.getNotes();
|
|
12
|
+
* const balance = client.getBalance();
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* Phase 1: Init + auth + balance scanning
|
|
16
|
+
* Phase 2: Deposit + shield (future)
|
|
17
|
+
* Phase 3: Transfer + relay (future)
|
|
18
|
+
*/
|
|
19
|
+
import { type NetworkConfig, type NetworkId } from "./config";
|
|
20
|
+
import { type AuthSignatureKeyDerivationOptions, type UTXOpiaKeys, type StealthMetaAddress, type WalletSignerAdapter, type KeySetupResult } from "./keys";
|
|
21
|
+
import { type ViewOnlyKeys, type StealthOutputWithKeys, type NonInteractiveDepositResult } from "./stealth";
|
|
22
|
+
import { type UtxoDescriptor } from "./psbt";
|
|
23
|
+
import { type DepositOpReturnContext } from "./taproot";
|
|
24
|
+
export interface UTXOpiaClientConfig {
|
|
25
|
+
network?: NetworkId;
|
|
26
|
+
/** Override backend URL (default: from network config) */
|
|
27
|
+
backendUrl?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface TokenDefinition {
|
|
30
|
+
symbol: string;
|
|
31
|
+
shieldedSymbol: string;
|
|
32
|
+
mint: string;
|
|
33
|
+
}
|
|
34
|
+
export interface InboxNote {
|
|
35
|
+
id: string;
|
|
36
|
+
commitmentHex: string;
|
|
37
|
+
amount: bigint;
|
|
38
|
+
leafIndex: number;
|
|
39
|
+
tokenSymbol: string;
|
|
40
|
+
isSpent: boolean;
|
|
41
|
+
createdAt: number;
|
|
42
|
+
ephemeralPub?: Uint8Array;
|
|
43
|
+
stealthPub?: {
|
|
44
|
+
x: bigint;
|
|
45
|
+
y: bigint;
|
|
46
|
+
};
|
|
47
|
+
commitment: Uint8Array;
|
|
48
|
+
}
|
|
49
|
+
export declare class UTXOpiaClient {
|
|
50
|
+
private _keys;
|
|
51
|
+
private _viewOnlyKeys;
|
|
52
|
+
private _isViewOnly;
|
|
53
|
+
private _stealthAddress;
|
|
54
|
+
private _stealthAddressEncoded;
|
|
55
|
+
private _tokenIdCache;
|
|
56
|
+
private _eventClient;
|
|
57
|
+
private _backendUrl;
|
|
58
|
+
private _appNetworkId;
|
|
59
|
+
private constructor();
|
|
60
|
+
/**
|
|
61
|
+
* Initialize the SDK. Call once at app startup.
|
|
62
|
+
* Sets up config, initializes Poseidon hash, creates singleton.
|
|
63
|
+
*/
|
|
64
|
+
static init(opts?: UTXOpiaClientConfig): Promise<UTXOpiaClient>;
|
|
65
|
+
/**
|
|
66
|
+
* Get the initialized singleton. Throws if init() hasn't been called.
|
|
67
|
+
*/
|
|
68
|
+
static instance(): UTXOpiaClient;
|
|
69
|
+
/**
|
|
70
|
+
* Check if the client has been initialized.
|
|
71
|
+
*/
|
|
72
|
+
static get isInitialized(): boolean;
|
|
73
|
+
/**
|
|
74
|
+
* Reset the singleton (for testing only).
|
|
75
|
+
*/
|
|
76
|
+
static reset(): void;
|
|
77
|
+
/**
|
|
78
|
+
* Derive keys from a Solana wallet signature.
|
|
79
|
+
*/
|
|
80
|
+
loginWithWallet(wallet: WalletSignerAdapter): Promise<KeySetupResult>;
|
|
81
|
+
/**
|
|
82
|
+
* Derive keys from a seed (passkey PRF output or secret phrase).
|
|
83
|
+
*/
|
|
84
|
+
loginWithSeed(seed: Uint8Array): Promise<KeySetupResult>;
|
|
85
|
+
/**
|
|
86
|
+
* Derive keys from a chain-specific auth signature (wallet personal-message signing).
|
|
87
|
+
*/
|
|
88
|
+
loginWithAuthSignature(signature: Uint8Array, options?: AuthSignatureKeyDerivationOptions): Promise<KeySetupResult>;
|
|
89
|
+
/**
|
|
90
|
+
* Restore keys from previously serialized storage (e.g., localStorage).
|
|
91
|
+
* @param serialized — the object from serializeKeys()
|
|
92
|
+
* @param solanaPublicKey — the wallet public key bytes (needed for key reconstruction)
|
|
93
|
+
*/
|
|
94
|
+
restoreKeys(serialized: Record<string, unknown>, solanaPublicKey: Uint8Array): void;
|
|
95
|
+
/**
|
|
96
|
+
* Login with view-only keys (can scan but not spend).
|
|
97
|
+
*/
|
|
98
|
+
loginViewOnly(viewOnlyKeys: ViewOnlyKeys): void;
|
|
99
|
+
/**
|
|
100
|
+
* Clear all keys and reset auth state.
|
|
101
|
+
*/
|
|
102
|
+
logout(): void;
|
|
103
|
+
/**
|
|
104
|
+
* Serialize current keys for encrypted storage.
|
|
105
|
+
*/
|
|
106
|
+
serializeKeys(): Record<string, unknown> | null;
|
|
107
|
+
get keys(): UTXOpiaKeys | null;
|
|
108
|
+
get stealthAddress(): StealthMetaAddress | null;
|
|
109
|
+
get stealthAddressEncoded(): string | null;
|
|
110
|
+
get isAuthenticated(): boolean;
|
|
111
|
+
get isViewOnly(): boolean;
|
|
112
|
+
get config(): NetworkConfig;
|
|
113
|
+
/**
|
|
114
|
+
* Get token ID for a mint address. Cached after first computation.
|
|
115
|
+
*/
|
|
116
|
+
getTokenId(mintAddress: string): bigint;
|
|
117
|
+
/**
|
|
118
|
+
* Register multiple tokens for scanning. Caches their token IDs.
|
|
119
|
+
*/
|
|
120
|
+
registerTokens(tokens: TokenDefinition[]): void;
|
|
121
|
+
/**
|
|
122
|
+
* Scan for all notes belonging to the authenticated user.
|
|
123
|
+
* Fetches announcements from backend, scans locally for privacy.
|
|
124
|
+
*/
|
|
125
|
+
getNotes(tokens: TokenDefinition[]): Promise<InboxNote[]>;
|
|
126
|
+
/**
|
|
127
|
+
* Get balance per token from unspent notes.
|
|
128
|
+
*/
|
|
129
|
+
getBalance(notes: InboxNote[]): Map<string, bigint>;
|
|
130
|
+
/**
|
|
131
|
+
* Check if a deposit announcement belongs to this user (hex string inputs).
|
|
132
|
+
*/
|
|
133
|
+
isMyDeposit(ephemeralPubHex: string, npkHex: string): boolean;
|
|
134
|
+
/**
|
|
135
|
+
* Compute nullifier bytes for a note (for PDA existence checking).
|
|
136
|
+
*/
|
|
137
|
+
computeNullifier(note: {
|
|
138
|
+
leafIndex: number;
|
|
139
|
+
}): Uint8Array;
|
|
140
|
+
/**
|
|
141
|
+
* Prepare a BTC deposit: generate stealth deposit address + OP_RETURN.
|
|
142
|
+
* Returns the deposit result (btcAddress, opReturnPayload) ready for PSBT building.
|
|
143
|
+
*/
|
|
144
|
+
prepareDeposit(opts: {
|
|
145
|
+
recipient?: StealthMetaAddress;
|
|
146
|
+
network?: "mainnet" | "testnet" | "regtest";
|
|
147
|
+
opReturnContext: DepositOpReturnContext;
|
|
148
|
+
}): Promise<NonInteractiveDepositResult>;
|
|
149
|
+
/**
|
|
150
|
+
* Select UTXOs for a deposit amount. Returns the selected UTXO set.
|
|
151
|
+
*/
|
|
152
|
+
selectUtxos(utxos: UtxoDescriptor[], targetSats: number, feeRate?: number): UtxoDescriptor[];
|
|
153
|
+
/**
|
|
154
|
+
* Prepare a stealth output for shielding (SPL token → shielded commitment).
|
|
155
|
+
* Returns npkBytes, ephemeralPub, commitment, tokenId — everything needed
|
|
156
|
+
* for the on-chain shield instruction.
|
|
157
|
+
*/
|
|
158
|
+
prepareShieldOutput(opts: {
|
|
159
|
+
amount: bigint;
|
|
160
|
+
mintAddress: string;
|
|
161
|
+
recipient?: UTXOpiaKeys;
|
|
162
|
+
}): Promise<StealthOutputWithKeys & {
|
|
163
|
+
tokenId: bigint;
|
|
164
|
+
}>;
|
|
165
|
+
/**
|
|
166
|
+
* Fetch merkle proofs for multiple commitments.
|
|
167
|
+
* Used before proof generation to get the on-chain tree state.
|
|
168
|
+
*
|
|
169
|
+
* @param commitmentHexes - Array of commitment hex strings
|
|
170
|
+
* @param apiBaseUrl - Base URL for the merkle proof API (default: "" for same-origin)
|
|
171
|
+
*/
|
|
172
|
+
fetchMerkleProofs(commitmentHexes: string[], apiBaseUrl?: string): Promise<Array<{
|
|
173
|
+
commitmentHex: string;
|
|
174
|
+
root: bigint;
|
|
175
|
+
pathElements: bigint[];
|
|
176
|
+
pathIndices: number[];
|
|
177
|
+
}>>;
|
|
178
|
+
/**
|
|
179
|
+
* Hash transaction inputs and sign with EdDSA-Poseidon.
|
|
180
|
+
*
|
|
181
|
+
* @param msgHashInputs - Array of bigints to hash (merkleRoot, boundParamsHash, nullifiers, commitments)
|
|
182
|
+
* @param eddsaSeed - The EdDSA seed bytes (from UTXOpiaKeys.eddsaSeed)
|
|
183
|
+
*/
|
|
184
|
+
signTransaction(msgHashInputs: bigint[], eddsaSeed: Uint8Array): Promise<{
|
|
185
|
+
sigR8x: bigint;
|
|
186
|
+
sigR8y: bigint;
|
|
187
|
+
sigS: bigint;
|
|
188
|
+
msgHash: bigint;
|
|
189
|
+
}>;
|
|
190
|
+
/**
|
|
191
|
+
* Submit a JoinSplit transaction to the relay backend.
|
|
192
|
+
*
|
|
193
|
+
* @param payload - Transaction data including proof, nullifiers, commitments, and mode-specific fields
|
|
194
|
+
* @param relayUrl - URL for the relay endpoint (default is chain-aware from init network)
|
|
195
|
+
*/
|
|
196
|
+
submitToRelay(payload: {
|
|
197
|
+
mode: "transfer" | "unshield" | "redeem";
|
|
198
|
+
nInputs: number;
|
|
199
|
+
nOutputs: number;
|
|
200
|
+
proof: string;
|
|
201
|
+
merkleRoot: string;
|
|
202
|
+
boundParamsHash: string;
|
|
203
|
+
nullifiers: string[];
|
|
204
|
+
commitmentsOut: string[];
|
|
205
|
+
stealthData: string[];
|
|
206
|
+
relayerFeeOutputIndex?: number;
|
|
207
|
+
/**
|
|
208
|
+
* Optional Phase 2 sender memos — one 80-byte hex string per output
|
|
209
|
+
* (nonce(24) || ciphertext_and_tag(56)). Compose with the SDK helper
|
|
210
|
+
* `buildSenderMemosForTransact(viewingPrivKey, outputs)`. The relay
|
|
211
|
+
* forwards them opaquely; viewing keys stay client-side.
|
|
212
|
+
*/
|
|
213
|
+
senderMemos?: string[];
|
|
214
|
+
unshieldAmounts?: string[];
|
|
215
|
+
recipientAddresses?: string[];
|
|
216
|
+
recipientTokenAccounts?: string[];
|
|
217
|
+
redeemAmounts?: string[];
|
|
218
|
+
btcScripts?: string[];
|
|
219
|
+
requestNonces?: string[];
|
|
220
|
+
/**
|
|
221
|
+
* Optional frozen source-tree PDA (base58). Set only when a spent note was committed in a
|
|
222
|
+
* commitment tree that has since been rotated out; the relay inserts it before the proof
|
|
223
|
+
* buffer so the program can prove membership against that tree. Omit otherwise.
|
|
224
|
+
*/
|
|
225
|
+
sourceTree?: string;
|
|
226
|
+
}, relayUrl?: string): Promise<{
|
|
227
|
+
success: boolean;
|
|
228
|
+
signature?: string;
|
|
229
|
+
error?: string;
|
|
230
|
+
}>;
|
|
231
|
+
private getEventClient;
|
|
232
|
+
private defaultRelayUrl;
|
|
233
|
+
}
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UTXOpiaClient — high-level SDK entry point.
|
|
3
|
+
*
|
|
4
|
+
* Initialize once, use simple methods everywhere. Encapsulates config,
|
|
5
|
+
* keys, Poseidon init, token IDs, and note scanning so consumers don't
|
|
6
|
+
* chain low-level SDK calls.
|
|
7
|
+
*
|
|
8
|
+
* ```typescript
|
|
9
|
+
* const client = await UTXOpiaClient.init({ network: "devnet" });
|
|
10
|
+
* await client.loginWithWallet(wallet);
|
|
11
|
+
* const notes = await client.getNotes();
|
|
12
|
+
* const balance = client.getBalance();
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* Phase 1: Init + auth + balance scanning
|
|
16
|
+
* Phase 2: Deposit + shield (future)
|
|
17
|
+
* Phase 3: Transfer + relay (future)
|
|
18
|
+
*/
|
|
19
|
+
import { initPoseidon, poseidonHashSync } from "./poseidon";
|
|
20
|
+
import { computeTokenId } from "./poseidon";
|
|
21
|
+
import { initConfig, getConfig } from "./config";
|
|
22
|
+
import { parseMerkleProofResponse } from "./merkle";
|
|
23
|
+
import { eddsaPoseidonSign } from "./keys";
|
|
24
|
+
import { setupKeysFromWallet, setupKeysFromSeed, setupKeysFromAuthSignature, recreateStealthAddress, serializeKeysForStorage, deserializeKeysFromStorage, clearUTXOpiaKeys, } from "./keys";
|
|
25
|
+
import { scanUnifiedNotesMulti, scanAnnouncementsViewOnlyMulti, computeNullifierHashForNote, computeNullifierBytes, isDepositForViewerHex, createDepositFromConfig, createStealthOutputWithKeys, } from "./stealth";
|
|
26
|
+
import { selectUtxos } from "./psbt";
|
|
27
|
+
import { hexToBytes } from "./crypto";
|
|
28
|
+
import { EventClient } from "./event-client";
|
|
29
|
+
// ─── Client ─────────────────────────────────────────────────────────
|
|
30
|
+
let _instance = null;
|
|
31
|
+
export class UTXOpiaClient {
|
|
32
|
+
constructor(backendUrl, appNetworkId) {
|
|
33
|
+
this._keys = null;
|
|
34
|
+
this._viewOnlyKeys = null;
|
|
35
|
+
this._isViewOnly = false;
|
|
36
|
+
this._stealthAddress = null;
|
|
37
|
+
this._stealthAddressEncoded = null;
|
|
38
|
+
this._tokenIdCache = new Map();
|
|
39
|
+
this._eventClient = null;
|
|
40
|
+
this._backendUrl = backendUrl;
|
|
41
|
+
this._appNetworkId = appNetworkId;
|
|
42
|
+
}
|
|
43
|
+
// ─── Lifecycle ──────────────────────────────────────────────────
|
|
44
|
+
/**
|
|
45
|
+
* Initialize the SDK. Call once at app startup.
|
|
46
|
+
* Sets up config, initializes Poseidon hash, creates singleton.
|
|
47
|
+
*/
|
|
48
|
+
static async init(opts = {}) {
|
|
49
|
+
// Init config (reads env vars, sets up network)
|
|
50
|
+
if (opts.network) {
|
|
51
|
+
await initConfig({ network: opts.network });
|
|
52
|
+
}
|
|
53
|
+
// Init Poseidon (required before any hashing)
|
|
54
|
+
await initPoseidon();
|
|
55
|
+
const backendUrl = opts.backendUrl || "";
|
|
56
|
+
const appNetworkId = opts.network ||
|
|
57
|
+
(typeof process !== "undefined" && (process.env?.NEXT_PUBLIC_NETWORK || process.env?.UTXOPIA_NETWORK)) ||
|
|
58
|
+
"devnet";
|
|
59
|
+
const client = new UTXOpiaClient(backendUrl, appNetworkId);
|
|
60
|
+
_instance = client;
|
|
61
|
+
return client;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Get the initialized singleton. Throws if init() hasn't been called.
|
|
65
|
+
*/
|
|
66
|
+
static instance() {
|
|
67
|
+
if (!_instance) {
|
|
68
|
+
throw new Error("UTXOpiaClient not initialized. Call UTXOpiaClient.init() first.");
|
|
69
|
+
}
|
|
70
|
+
return _instance;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Check if the client has been initialized.
|
|
74
|
+
*/
|
|
75
|
+
static get isInitialized() {
|
|
76
|
+
return _instance !== null;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Reset the singleton (for testing only).
|
|
80
|
+
*/
|
|
81
|
+
static reset() {
|
|
82
|
+
if (_instance) {
|
|
83
|
+
_instance.logout();
|
|
84
|
+
_instance._eventClient = null;
|
|
85
|
+
}
|
|
86
|
+
_instance = null;
|
|
87
|
+
}
|
|
88
|
+
// ─── Auth ───────────────────────────────────────────────────────
|
|
89
|
+
/**
|
|
90
|
+
* Derive keys from a Solana wallet signature.
|
|
91
|
+
*/
|
|
92
|
+
async loginWithWallet(wallet) {
|
|
93
|
+
const result = await setupKeysFromWallet(wallet);
|
|
94
|
+
this._keys = result.keys;
|
|
95
|
+
this._stealthAddress = result.stealthAddress;
|
|
96
|
+
this._stealthAddressEncoded = result.stealthAddressEncoded;
|
|
97
|
+
this._isViewOnly = false;
|
|
98
|
+
this._viewOnlyKeys = null;
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Derive keys from a seed (passkey PRF output or secret phrase).
|
|
103
|
+
*/
|
|
104
|
+
async loginWithSeed(seed) {
|
|
105
|
+
const result = await setupKeysFromSeed(seed);
|
|
106
|
+
this._keys = result.keys;
|
|
107
|
+
this._stealthAddress = result.stealthAddress;
|
|
108
|
+
this._stealthAddressEncoded = result.stealthAddressEncoded;
|
|
109
|
+
this._isViewOnly = false;
|
|
110
|
+
this._viewOnlyKeys = null;
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Derive keys from a chain-specific auth signature (wallet personal-message signing).
|
|
115
|
+
*/
|
|
116
|
+
async loginWithAuthSignature(signature, options = {}) {
|
|
117
|
+
const result = await setupKeysFromAuthSignature(signature, options);
|
|
118
|
+
this._keys = result.keys;
|
|
119
|
+
this._stealthAddress = result.stealthMetaAddress;
|
|
120
|
+
this._stealthAddressEncoded = result.encodedStealthAddress;
|
|
121
|
+
this._isViewOnly = false;
|
|
122
|
+
this._viewOnlyKeys = null;
|
|
123
|
+
return {
|
|
124
|
+
keys: result.keys,
|
|
125
|
+
stealthAddress: result.stealthMetaAddress,
|
|
126
|
+
stealthAddressEncoded: result.encodedStealthAddress,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Restore keys from previously serialized storage (e.g., localStorage).
|
|
131
|
+
* @param serialized — the object from serializeKeys()
|
|
132
|
+
* @param solanaPublicKey — the wallet public key bytes (needed for key reconstruction)
|
|
133
|
+
*/
|
|
134
|
+
restoreKeys(serialized, solanaPublicKey) {
|
|
135
|
+
const keys = deserializeKeysFromStorage(serialized, solanaPublicKey);
|
|
136
|
+
const { stealthAddress, stealthAddressEncoded } = recreateStealthAddress(keys);
|
|
137
|
+
this._keys = keys;
|
|
138
|
+
this._stealthAddress = stealthAddress;
|
|
139
|
+
this._stealthAddressEncoded = stealthAddressEncoded;
|
|
140
|
+
this._isViewOnly = false;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Login with view-only keys (can scan but not spend).
|
|
144
|
+
*/
|
|
145
|
+
loginViewOnly(viewOnlyKeys) {
|
|
146
|
+
this._viewOnlyKeys = viewOnlyKeys;
|
|
147
|
+
this._isViewOnly = true;
|
|
148
|
+
this._keys = null;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Clear all keys and reset auth state.
|
|
152
|
+
*/
|
|
153
|
+
logout() {
|
|
154
|
+
if (this._keys) {
|
|
155
|
+
clearUTXOpiaKeys(this._keys);
|
|
156
|
+
}
|
|
157
|
+
this._keys = null;
|
|
158
|
+
this._viewOnlyKeys = null;
|
|
159
|
+
this._isViewOnly = false;
|
|
160
|
+
this._stealthAddress = null;
|
|
161
|
+
this._stealthAddressEncoded = null;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Serialize current keys for encrypted storage.
|
|
165
|
+
*/
|
|
166
|
+
serializeKeys() {
|
|
167
|
+
if (!this._keys)
|
|
168
|
+
return null;
|
|
169
|
+
return { ...serializeKeysForStorage(this._keys) };
|
|
170
|
+
}
|
|
171
|
+
// ─── Getters ────────────────────────────────────────────────────
|
|
172
|
+
get keys() { return this._keys; }
|
|
173
|
+
get stealthAddress() { return this._stealthAddress; }
|
|
174
|
+
get stealthAddressEncoded() { return this._stealthAddressEncoded; }
|
|
175
|
+
get isAuthenticated() { return this._keys !== null || this._viewOnlyKeys !== null; }
|
|
176
|
+
get isViewOnly() { return this._isViewOnly; }
|
|
177
|
+
get config() { return getConfig(); }
|
|
178
|
+
// ─── Token IDs ──────────────────────────────────────────────────
|
|
179
|
+
/**
|
|
180
|
+
* Get token ID for a mint address. Cached after first computation.
|
|
181
|
+
*/
|
|
182
|
+
getTokenId(mintAddress) {
|
|
183
|
+
const cached = this._tokenIdCache.get(mintAddress);
|
|
184
|
+
if (cached !== undefined)
|
|
185
|
+
return cached;
|
|
186
|
+
// Requires PublicKey — import dynamically to avoid hard dep
|
|
187
|
+
const mintBytes = hexToBytes(mintAddress.padStart(64, "0"));
|
|
188
|
+
// If it's a base58 address, convert via PublicKey
|
|
189
|
+
let bytes;
|
|
190
|
+
try {
|
|
191
|
+
// Try as raw hex first (64 chars)
|
|
192
|
+
if (mintAddress.length === 64 && /^[0-9a-fA-F]+$/.test(mintAddress)) {
|
|
193
|
+
bytes = hexToBytes(mintAddress);
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
// Assume base58 PublicKey — need to decode
|
|
197
|
+
// Use the SDK's reduceToField which handles the conversion
|
|
198
|
+
const { PublicKey } = require("@solana/web3.js");
|
|
199
|
+
bytes = new PublicKey(mintAddress).toBytes();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
bytes = mintBytes;
|
|
204
|
+
}
|
|
205
|
+
const tokenId = computeTokenId(bytes);
|
|
206
|
+
this._tokenIdCache.set(mintAddress, tokenId);
|
|
207
|
+
return tokenId;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Register multiple tokens for scanning. Caches their token IDs.
|
|
211
|
+
*/
|
|
212
|
+
registerTokens(tokens) {
|
|
213
|
+
const config = getConfig();
|
|
214
|
+
for (const token of tokens) {
|
|
215
|
+
let mint = token.mint;
|
|
216
|
+
if (!mint && (token.symbol === "BTC" || token.symbol === "zkBTC")) {
|
|
217
|
+
mint = config.zkbtcMint;
|
|
218
|
+
}
|
|
219
|
+
if (!mint)
|
|
220
|
+
continue;
|
|
221
|
+
this.getTokenId(mint); // triggers computation + cache
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// ─── Note Scanning ─────────────────────────────────────────────
|
|
225
|
+
/**
|
|
226
|
+
* Scan for all notes belonging to the authenticated user.
|
|
227
|
+
* Fetches announcements from backend, scans locally for privacy.
|
|
228
|
+
*/
|
|
229
|
+
async getNotes(tokens) {
|
|
230
|
+
if (!this._keys && !this._viewOnlyKeys)
|
|
231
|
+
return [];
|
|
232
|
+
// Fetch announcements via EventClient
|
|
233
|
+
const client = this.getEventClient();
|
|
234
|
+
const announcements = await client.fetchAll();
|
|
235
|
+
// One trial-decrypt per announcement, tested against every token id — not
|
|
236
|
+
// one full scan pass per token.
|
|
237
|
+
const config = getConfig();
|
|
238
|
+
const symbolByTokenId = new Map();
|
|
239
|
+
for (const token of tokens) {
|
|
240
|
+
let mint = token.mint;
|
|
241
|
+
if (!mint && (token.symbol === "BTC" || token.symbol === "zkBTC")) {
|
|
242
|
+
mint = config.zkbtcMint;
|
|
243
|
+
}
|
|
244
|
+
if (!mint)
|
|
245
|
+
continue;
|
|
246
|
+
const tokenId = this.getTokenId(mint);
|
|
247
|
+
// First token wins the id, matching the old loop's dedup order.
|
|
248
|
+
if (!symbolByTokenId.has(tokenId))
|
|
249
|
+
symbolByTokenId.set(tokenId, token.shieldedSymbol);
|
|
250
|
+
}
|
|
251
|
+
const tokenIds = [...symbolByTokenId.keys()];
|
|
252
|
+
const matches = this._isViewOnly && this._viewOnlyKeys
|
|
253
|
+
? await scanAnnouncementsViewOnlyMulti(this._viewOnlyKeys, announcements, tokenIds)
|
|
254
|
+
: await scanUnifiedNotesMulti(this._keys, announcements, tokenIds);
|
|
255
|
+
const scanned = matches.map((note) => ({
|
|
256
|
+
...note,
|
|
257
|
+
tokenSymbol: symbolByTokenId.get(note.tokenId),
|
|
258
|
+
}));
|
|
259
|
+
// Convert to InboxNote format
|
|
260
|
+
return scanned.map((note, index) => {
|
|
261
|
+
const rawHex = Buffer.from(note.commitment).toString("hex");
|
|
262
|
+
const commitmentHex = rawHex.toLowerCase().padStart(64, "0");
|
|
263
|
+
return {
|
|
264
|
+
id: `${commitmentHex.slice(0, 16)}-${index}`,
|
|
265
|
+
commitmentHex,
|
|
266
|
+
amount: typeof note.amount === "bigint" ? note.amount : BigInt(note.amount),
|
|
267
|
+
leafIndex: note.leafIndex,
|
|
268
|
+
tokenSymbol: note.tokenSymbol,
|
|
269
|
+
isSpent: false, // caller checks spent status separately
|
|
270
|
+
createdAt: note.blockTime ? note.blockTime * 1000 : Date.now(),
|
|
271
|
+
ephemeralPub: note.ephemeralPub,
|
|
272
|
+
stealthPub: note.stealthPub,
|
|
273
|
+
commitment: note.commitment,
|
|
274
|
+
};
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Get balance per token from unspent notes.
|
|
279
|
+
*/
|
|
280
|
+
getBalance(notes) {
|
|
281
|
+
const balances = new Map();
|
|
282
|
+
for (const note of notes) {
|
|
283
|
+
if (note.isSpent)
|
|
284
|
+
continue;
|
|
285
|
+
const current = balances.get(note.tokenSymbol) ?? 0n;
|
|
286
|
+
balances.set(note.tokenSymbol, current + note.amount);
|
|
287
|
+
}
|
|
288
|
+
return balances;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Check if a deposit announcement belongs to this user (hex string inputs).
|
|
292
|
+
*/
|
|
293
|
+
isMyDeposit(ephemeralPubHex, npkHex) {
|
|
294
|
+
if (!this._keys)
|
|
295
|
+
return false;
|
|
296
|
+
return isDepositForViewerHex(this._keys, ephemeralPubHex, npkHex);
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Compute nullifier bytes for a note (for PDA existence checking).
|
|
300
|
+
*/
|
|
301
|
+
computeNullifier(note) {
|
|
302
|
+
if (this._isViewOnly && this._viewOnlyKeys) {
|
|
303
|
+
return computeNullifierBytes(this._viewOnlyKeys.nullifyingKey, note.leafIndex);
|
|
304
|
+
}
|
|
305
|
+
if (this._keys) {
|
|
306
|
+
return computeNullifierHashForNote(this._keys, note);
|
|
307
|
+
}
|
|
308
|
+
throw new Error("Not authenticated");
|
|
309
|
+
}
|
|
310
|
+
// ─── Phase 2: Deposit + Shield ─────────────────────────────────
|
|
311
|
+
/**
|
|
312
|
+
* Prepare a BTC deposit: generate stealth deposit address + OP_RETURN.
|
|
313
|
+
* Returns the deposit result (btcAddress, opReturnPayload) ready for PSBT building.
|
|
314
|
+
*/
|
|
315
|
+
async prepareDeposit(opts) {
|
|
316
|
+
const meta = opts.recipient ?? this._stealthAddress;
|
|
317
|
+
if (!meta)
|
|
318
|
+
throw new Error("No recipient stealth address (login first or provide recipient)");
|
|
319
|
+
const network = opts.network ?? sdkBitcoinNetworkToAddressNetwork(this.config.bitcoinNetwork);
|
|
320
|
+
if (!opts.opReturnContext) {
|
|
321
|
+
throw new Error("deposit OP_RETURN context is required");
|
|
322
|
+
}
|
|
323
|
+
return createDepositFromConfig(meta, network, opts.opReturnContext);
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Select UTXOs for a deposit amount. Returns the selected UTXO set.
|
|
327
|
+
*/
|
|
328
|
+
selectUtxos(utxos, targetSats, feeRate = 2) {
|
|
329
|
+
return selectUtxos(utxos, targetSats, feeRate);
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Prepare a stealth output for shielding (SPL token → shielded commitment).
|
|
333
|
+
* Returns npkBytes, ephemeralPub, commitment, tokenId — everything needed
|
|
334
|
+
* for the on-chain shield instruction.
|
|
335
|
+
*/
|
|
336
|
+
async prepareShieldOutput(opts) {
|
|
337
|
+
const keys = opts.recipient ?? this._keys;
|
|
338
|
+
if (!keys)
|
|
339
|
+
throw new Error("No keys (login first or provide recipient)");
|
|
340
|
+
const tokenId = this.getTokenId(opts.mintAddress);
|
|
341
|
+
const output = await createStealthOutputWithKeys(keys, opts.amount, tokenId);
|
|
342
|
+
return { ...output, tokenId };
|
|
343
|
+
}
|
|
344
|
+
// ─── Phase 3: Transfer + Relay ─────────────────────────────────
|
|
345
|
+
/**
|
|
346
|
+
* Fetch merkle proofs for multiple commitments.
|
|
347
|
+
* Used before proof generation to get the on-chain tree state.
|
|
348
|
+
*
|
|
349
|
+
* @param commitmentHexes - Array of commitment hex strings
|
|
350
|
+
* @param apiBaseUrl - Base URL for the merkle proof API (default: "" for same-origin)
|
|
351
|
+
*/
|
|
352
|
+
async fetchMerkleProofs(commitmentHexes, apiBaseUrl = "") {
|
|
353
|
+
const results = await Promise.all(commitmentHexes.map(async (hex) => {
|
|
354
|
+
const resp = await fetch(`${apiBaseUrl}/api/merkle/proof?commitment=${hex}`);
|
|
355
|
+
const data = await resp.json();
|
|
356
|
+
if (!data.success) {
|
|
357
|
+
throw new Error(`Note ${hex.slice(0, 16)}... not found on-chain`);
|
|
358
|
+
}
|
|
359
|
+
const parsed = parseMerkleProofResponse(data);
|
|
360
|
+
return { commitmentHex: hex, ...parsed };
|
|
361
|
+
}));
|
|
362
|
+
// Validate all proofs share the same root
|
|
363
|
+
const roots = results.map((r) => r.root);
|
|
364
|
+
if (new Set(roots.map((r) => r.toString())).size > 1) {
|
|
365
|
+
throw new Error("Input notes have different Merkle roots — tree may have changed");
|
|
366
|
+
}
|
|
367
|
+
return results;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Hash transaction inputs and sign with EdDSA-Poseidon.
|
|
371
|
+
*
|
|
372
|
+
* @param msgHashInputs - Array of bigints to hash (merkleRoot, boundParamsHash, nullifiers, commitments)
|
|
373
|
+
* @param eddsaSeed - The EdDSA seed bytes (from UTXOpiaKeys.eddsaSeed)
|
|
374
|
+
*/
|
|
375
|
+
async signTransaction(msgHashInputs, eddsaSeed) {
|
|
376
|
+
const msgHash = poseidonHashSync(msgHashInputs);
|
|
377
|
+
const [sigR8x, sigR8y, sigS] = await eddsaPoseidonSign(eddsaSeed, msgHash);
|
|
378
|
+
return { sigR8x, sigR8y, sigS, msgHash };
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Submit a JoinSplit transaction to the relay backend.
|
|
382
|
+
*
|
|
383
|
+
* @param payload - Transaction data including proof, nullifiers, commitments, and mode-specific fields
|
|
384
|
+
* @param relayUrl - URL for the relay endpoint (default is chain-aware from init network)
|
|
385
|
+
*/
|
|
386
|
+
async submitToRelay(payload, relayUrl = this.defaultRelayUrl()) {
|
|
387
|
+
const resp = await fetch(relayUrl, {
|
|
388
|
+
method: "POST",
|
|
389
|
+
headers: { "Content-Type": "application/json" },
|
|
390
|
+
body: JSON.stringify(payload),
|
|
391
|
+
});
|
|
392
|
+
return resp.json();
|
|
393
|
+
}
|
|
394
|
+
// ─── Private helpers ────────────────────────────────────────────
|
|
395
|
+
getEventClient() {
|
|
396
|
+
if (!this._eventClient) {
|
|
397
|
+
const config = getConfig();
|
|
398
|
+
this._eventClient = new EventClient({
|
|
399
|
+
backendUrl: this._backendUrl,
|
|
400
|
+
solanaRpcUrl: config.solanaRpcUrl || "",
|
|
401
|
+
programId: config.utxopiaProgramId,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
return this._eventClient;
|
|
405
|
+
}
|
|
406
|
+
defaultRelayUrl() {
|
|
407
|
+
return `/api/sol/relay?network=${encodeURIComponent(this._appNetworkId)}`;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
function sdkBitcoinNetworkToAddressNetwork(network) {
|
|
411
|
+
if (network === "mainnet")
|
|
412
|
+
return "mainnet";
|
|
413
|
+
if (network === "regtest")
|
|
414
|
+
return "regtest";
|
|
415
|
+
return "testnet";
|
|
416
|
+
}
|