@fairblock/stabletrust 1.0.22 → 2.0.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/README.md +360 -106
- package/examples/anonymous-flow.js +334 -0
- package/package.json +1 -1
- package/pkg/README.md +310 -0
- package/pkg/confidential_transfer_proof_generation.d.ts +13 -9
- package/pkg/confidential_transfer_proof_generation.js +571 -604
- package/pkg/confidential_transfer_proof_generation_bg.wasm +0 -0
- package/pkg/confidential_transfer_proof_generation_bg.wasm.d.ts +2 -0
- package/src/anonymous-client.js +1371 -0
- package/src/{client.js → confidential-client.js} +11 -8
- package/src/constants.js +8 -9
- package/src/index.d.ts +344 -1
- package/src/index.js +2 -1
- package/pkg/proof_generator_wasm_bg.wasm +0 -0
|
@@ -0,0 +1,1371 @@
|
|
|
1
|
+
import { ethers } from "ethers";
|
|
2
|
+
import { initializeWasm } from "./wasm-loader.js";
|
|
3
|
+
import { encodeTransferProof, encodeWithdrawProof } from "./utils.js";
|
|
4
|
+
import { getStabletrustContractAddress } from "./constants.js";
|
|
5
|
+
|
|
6
|
+
// EIP-712 domain for anonymous operations (LibAnonAuth domain name)
|
|
7
|
+
const ANON_DOMAIN_NAME = "ConfidentialMirrorAnonymous";
|
|
8
|
+
const ANON_DOMAIN_VERSION = "1";
|
|
9
|
+
|
|
10
|
+
// Default deadline offset: 1 hour
|
|
11
|
+
const DEFAULT_DEADLINE_OFFSET = 3600;
|
|
12
|
+
|
|
13
|
+
// Minimal ABI — only used for deposit calldata encoding (reads go through Fairycloak views)
|
|
14
|
+
const DEPOSIT_INTERFACE = new ethers.Interface([
|
|
15
|
+
"function depositAnonymous(uint64 accountId, address token, uint256 plainAmount, uint256 authNonce, uint256 deadline, bytes authSig) external",
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Internal helper to parse values as BigInt, handling hex strings and null/undefined.
|
|
20
|
+
* @private
|
|
21
|
+
*/
|
|
22
|
+
function _parseBigInt(v) {
|
|
23
|
+
if (v == null || v === "") return 0n;
|
|
24
|
+
try {
|
|
25
|
+
return BigInt(v);
|
|
26
|
+
} catch {
|
|
27
|
+
return 0n;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* AnonymousTransferClient — SDK class for anonymous confidential transfers via the Fairycloak relay.
|
|
33
|
+
*
|
|
34
|
+
* Anonymous transfers are routed through the Fairycloak HTTP relay server instead of being
|
|
35
|
+
* submitted directly on-chain. The relay pays gas for all managed transactions; the user only
|
|
36
|
+
* signs EIP-712 authorisation payloads (and a raw EVM transaction for deposits).
|
|
37
|
+
*/
|
|
38
|
+
export class AnonymousTransferClient {
|
|
39
|
+
/**
|
|
40
|
+
* @param {Object} config
|
|
41
|
+
* @param {string} config.fairycloakUrl - Fairycloak relay base URL, e.g. "http://127.0.0.1:8080"
|
|
42
|
+
* @param {number|string} config.chainId - Chain ID (used to auto-resolve the contract address)
|
|
43
|
+
* @param {string} config.rpcUrl - EVM JSON-RPC endpoint (used for on-chain reads and raw-tx signing)
|
|
44
|
+
* @param {string} [config.diamondAddress] - Optional override for the diamond contract address
|
|
45
|
+
* @param {string} [config.apiKey] - Optional Fairycloak API key
|
|
46
|
+
*/
|
|
47
|
+
constructor({ fairycloakUrl, diamondAddress, chainId, rpcUrl, apiKey } = {}) {
|
|
48
|
+
if (!fairycloakUrl) throw new Error("fairycloakUrl is required");
|
|
49
|
+
if (!chainId) throw new Error("chainId is required");
|
|
50
|
+
if (!rpcUrl) throw new Error("rpcUrl is required");
|
|
51
|
+
|
|
52
|
+
const resolvedDiamondAddress =
|
|
53
|
+
diamondAddress || getStabletrustContractAddress(chainId);
|
|
54
|
+
if (!resolvedDiamondAddress || !ethers.isAddress(resolvedDiamondAddress)) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`No contract address found for chainId ${chainId}. Pass a diamondAddress explicitly or use a supported chainId.`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
this.fairycloakUrl = fairycloakUrl.replace(/\/$/, "");
|
|
61
|
+
this.diamondAddress = ethers.getAddress(resolvedDiamondAddress);
|
|
62
|
+
this.chainId = Number(chainId);
|
|
63
|
+
this.rpcUrl = rpcUrl;
|
|
64
|
+
this.apiKey = apiKey || null;
|
|
65
|
+
|
|
66
|
+
this.provider = new ethers.JsonRpcProvider(rpcUrl);
|
|
67
|
+
this._wasmModule = null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ─────────────────────── private helpers ───────────────────────
|
|
71
|
+
|
|
72
|
+
async _getWasm() {
|
|
73
|
+
if (!this._wasmModule) {
|
|
74
|
+
this._wasmModule = await initializeWasm();
|
|
75
|
+
}
|
|
76
|
+
return this._wasmModule;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
_buildDomain() {
|
|
80
|
+
return {
|
|
81
|
+
name: ANON_DOMAIN_NAME,
|
|
82
|
+
version: ANON_DOMAIN_VERSION,
|
|
83
|
+
chainId: this.chainId,
|
|
84
|
+
verifyingContract: this.diamondAddress,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
_makeDeadline(offsetSeconds = DEFAULT_DEADLINE_OFFSET) {
|
|
89
|
+
return Math.floor(Date.now() / 1000) + offsetSeconds;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Send an HTTP request to Fairycloak. Throws on non-2xx responses.
|
|
94
|
+
* @private
|
|
95
|
+
*/
|
|
96
|
+
async _fetch(method, path, body) {
|
|
97
|
+
const headers = {
|
|
98
|
+
"Content-Type": "application/json",
|
|
99
|
+
Accept: "application/json",
|
|
100
|
+
};
|
|
101
|
+
if (this.apiKey) headers["X-API-Key"] = this.apiKey;
|
|
102
|
+
|
|
103
|
+
const res = await fetch(`${this.fairycloakUrl}${path}`, {
|
|
104
|
+
method,
|
|
105
|
+
headers,
|
|
106
|
+
body: body != null ? JSON.stringify(body) : undefined,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const text = await res.text();
|
|
110
|
+
let data;
|
|
111
|
+
try {
|
|
112
|
+
data = JSON.parse(text);
|
|
113
|
+
} catch {
|
|
114
|
+
data = { raw: text };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!res.ok) {
|
|
118
|
+
const msg =
|
|
119
|
+
data?.error?.message ?? data?.error ?? text ?? `HTTP ${res.status}`;
|
|
120
|
+
throw new Error(`Fairycloak ${method} ${path} (${res.status}): ${msg}`);
|
|
121
|
+
}
|
|
122
|
+
return data;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Resolve a pubkey-or-wallet to an uncompressed 65-byte hex pubkey (0x04...).
|
|
127
|
+
* Accepts: ethers.Wallet, ethers.SigningKey, or a hex pubkey string.
|
|
128
|
+
* @private
|
|
129
|
+
*/
|
|
130
|
+
_resolvePubkey(walletOrPubkey) {
|
|
131
|
+
if (typeof walletOrPubkey === "string") {
|
|
132
|
+
return walletOrPubkey; // already a hex pubkey
|
|
133
|
+
}
|
|
134
|
+
if (walletOrPubkey.signingKey) {
|
|
135
|
+
return walletOrPubkey.signingKey.publicKey; // ethers.Wallet → uncompressed pubkey
|
|
136
|
+
}
|
|
137
|
+
if (walletOrPubkey.publicKey) {
|
|
138
|
+
return walletOrPubkey.publicKey; // ethers.SigningKey
|
|
139
|
+
}
|
|
140
|
+
throw new Error(
|
|
141
|
+
"Expected an ethers.Wallet or uncompressed hex public-key string",
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ─────────────── token decimal / scale helpers ─────────────────
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Fetch and cache token decimals.
|
|
149
|
+
* @private
|
|
150
|
+
*/
|
|
151
|
+
async _fetchTokenDecimals(tokenAddress) {
|
|
152
|
+
if (!this._tokenDecimals) this._tokenDecimals = {};
|
|
153
|
+
const key = tokenAddress.toLowerCase();
|
|
154
|
+
if (this._tokenDecimals[key] !== undefined) return this._tokenDecimals[key];
|
|
155
|
+
const contract = new ethers.Contract(
|
|
156
|
+
tokenAddress,
|
|
157
|
+
["function decimals() view returns (uint8)"],
|
|
158
|
+
this.provider,
|
|
159
|
+
);
|
|
160
|
+
const decimals = Number(await contract.decimals());
|
|
161
|
+
this._tokenDecimals[key] = decimals;
|
|
162
|
+
return decimals;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Convert a raw ERC-20 token amount to contract scale.
|
|
167
|
+
*
|
|
168
|
+
* The anonymous contract stores balances and takes deposit/withdraw amounts in
|
|
169
|
+
* **contract scale**: `rawAmount * 100 / 10^decimals`. For a 6-decimal token
|
|
170
|
+
* (USDC) that means 0.1 USDC = 100_000 raw → 10 contract scale.
|
|
171
|
+
*
|
|
172
|
+
* @param {string} tokenAddress
|
|
173
|
+
* @param {bigint} rawAmount - Amount in raw ERC-20 units (wei-scaled by token decimals)
|
|
174
|
+
* @returns {Promise<bigint>} Amount in contract scale
|
|
175
|
+
* @private
|
|
176
|
+
*/
|
|
177
|
+
async _toContractScale(tokenAddress, rawAmount) {
|
|
178
|
+
const decimals = await this._fetchTokenDecimals(tokenAddress);
|
|
179
|
+
return (rawAmount * 100n) / 10n ** BigInt(decimals);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ─────────────────────── on-chain reads ────────────────────────
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Get on-chain core state for an anonymous account.
|
|
186
|
+
*
|
|
187
|
+
* @param {number|bigint} accountId
|
|
188
|
+
* @returns {Promise<{exists:boolean, finalized:boolean, hasPendingAction:boolean, txId:bigint, elgamalPubkey:string, authNonce:bigint}>}
|
|
189
|
+
*/
|
|
190
|
+
async getAnonymousAccountInfo(accountId) {
|
|
191
|
+
try {
|
|
192
|
+
const data = await this._fetch(
|
|
193
|
+
"GET",
|
|
194
|
+
`/v1/views/anonymous/accounts/${accountId}/core`,
|
|
195
|
+
);
|
|
196
|
+
// Support both { result: {...} } and flat response shapes
|
|
197
|
+
const r =
|
|
198
|
+
data.result != null && typeof data.result === "object"
|
|
199
|
+
? data.result
|
|
200
|
+
: data;
|
|
201
|
+
return {
|
|
202
|
+
exists: r.exists ?? false,
|
|
203
|
+
finalized: r.finalized ?? false,
|
|
204
|
+
// Fairycloak may use snake_case or camelCase
|
|
205
|
+
hasPendingAction: r.pending_action ?? r.pendingAction ?? false,
|
|
206
|
+
txId: _parseBigInt(r.tx_id ?? r.txId),
|
|
207
|
+
elgamalPubkey: r.elgamal_pubkey ?? r.elgamalPubkey ?? "0x",
|
|
208
|
+
authNonce: _parseBigInt(r.auth_nonce ?? r.authNonce),
|
|
209
|
+
};
|
|
210
|
+
} catch (e) {
|
|
211
|
+
throw new Error(`Failed to get anonymous account info: ${e.message}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Read the current authNonce for an anonymous account from the chain.
|
|
217
|
+
*
|
|
218
|
+
* @param {number|bigint} accountId
|
|
219
|
+
* @returns {Promise<bigint>}
|
|
220
|
+
*/
|
|
221
|
+
async getAuthNonce(accountId) {
|
|
222
|
+
const info = await this.getAnonymousAccountInfo(accountId);
|
|
223
|
+
return info.authNonce;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Read the current anonymous account count from the contract.
|
|
228
|
+
*
|
|
229
|
+
* The contract assigns IDs starting at 1 and incrementing sequentially.
|
|
230
|
+
* The ID of the *next* account to be created is therefore `count + 1`.
|
|
231
|
+
*
|
|
232
|
+
* ```js
|
|
233
|
+
* const count = await client.getNextAccountId();
|
|
234
|
+
* const myId = Number(count) + 1; // ID that will be assigned
|
|
235
|
+
* await client.createAccount(wallet, pubkey);
|
|
236
|
+
* ```
|
|
237
|
+
*
|
|
238
|
+
* @returns {Promise<bigint>} Current account count (not the next ID)
|
|
239
|
+
*/
|
|
240
|
+
async getNextAccountId() {
|
|
241
|
+
const data = await this._fetch(
|
|
242
|
+
"GET",
|
|
243
|
+
"/v1/views/anonymous/next-account-id",
|
|
244
|
+
);
|
|
245
|
+
return BigInt(data.result);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Check whether an address is an authorised signer for an anonymous account.
|
|
250
|
+
*
|
|
251
|
+
* @param {number|bigint} accountId
|
|
252
|
+
* @param {string} signerAddress
|
|
253
|
+
* @returns {Promise<boolean>}
|
|
254
|
+
*/
|
|
255
|
+
async isAuthorizedSigner(accountId, signerAddress) {
|
|
256
|
+
const data = await this._fetch(
|
|
257
|
+
"GET",
|
|
258
|
+
`/v1/views/anonymous/accounts/${accountId}/authorized-signers/${signerAddress}`,
|
|
259
|
+
);
|
|
260
|
+
return data.result === true || data.result === "true";
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ─────────────── anonymous balance reads ───────────────────────
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Fetch the raw combined ciphertext (base64) for an anonymous account's balance.
|
|
267
|
+
* Returns null when no balance has been set yet.
|
|
268
|
+
*
|
|
269
|
+
* @param {number|bigint} accountId
|
|
270
|
+
* @param {string} tokenAddress
|
|
271
|
+
* @param {"available"|"pending"} [type="available"]
|
|
272
|
+
* @returns {Promise<string|null>} Base64 ciphertext or null
|
|
273
|
+
* @private
|
|
274
|
+
*/
|
|
275
|
+
async _getAnonymousCiphertext(accountId, tokenAddress, type = "available") {
|
|
276
|
+
const path =
|
|
277
|
+
type === "pending"
|
|
278
|
+
? `/v1/views/anonymous/accounts/${accountId}/pending/${tokenAddress}`
|
|
279
|
+
: `/v1/views/anonymous/accounts/${accountId}/available/${tokenAddress}`;
|
|
280
|
+
|
|
281
|
+
const data = await this._fetch("GET", path);
|
|
282
|
+
const { c1, c2 } = data.result;
|
|
283
|
+
|
|
284
|
+
if ((!c1 || c1 === "0x") && (!c2 || c2 === "0x")) return null;
|
|
285
|
+
|
|
286
|
+
const combined = new Uint8Array(64);
|
|
287
|
+
combined.set(ethers.getBytes(c1), 0);
|
|
288
|
+
combined.set(ethers.getBytes(c2), 32);
|
|
289
|
+
return Buffer.from(combined).toString("base64");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Decrypt an anonymous account's balance from the chain.
|
|
294
|
+
* Returns amount in **contract scale** (the raw WASM-decrypted value).
|
|
295
|
+
*
|
|
296
|
+
* @param {number|bigint} accountId
|
|
297
|
+
* @param {string} tokenAddress
|
|
298
|
+
* @param {string} elGamalPrivateKey - Base64 private key
|
|
299
|
+
* @param {"available"|"pending"} [type="available"]
|
|
300
|
+
* @returns {Promise<{amount: number, ciphertext: string|null}>}
|
|
301
|
+
* @private
|
|
302
|
+
*/
|
|
303
|
+
async _decryptAnonymousBalance(
|
|
304
|
+
accountId,
|
|
305
|
+
tokenAddress,
|
|
306
|
+
elGamalPrivateKey,
|
|
307
|
+
type = "available",
|
|
308
|
+
) {
|
|
309
|
+
const ciphertext = await this._getAnonymousCiphertext(
|
|
310
|
+
accountId,
|
|
311
|
+
tokenAddress,
|
|
312
|
+
type,
|
|
313
|
+
);
|
|
314
|
+
if (!ciphertext) return { amount: 0, ciphertext: null };
|
|
315
|
+
const wasm = await this._getWasm();
|
|
316
|
+
const result = JSON.parse(
|
|
317
|
+
wasm.decrypt_ciphertext(ciphertext, elGamalPrivateKey),
|
|
318
|
+
);
|
|
319
|
+
return { amount: result.decrypted_amount ?? 0, ciphertext };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Get the decrypted available **and** pending balances for an anonymous account.
|
|
324
|
+
*
|
|
325
|
+
* Amounts are returned in **contract scale** (the encrypted integer stored on-chain,
|
|
326
|
+
* equivalent to `tokenAmount × 100 / 10^decimals`).
|
|
327
|
+
*
|
|
328
|
+
* @param {number|bigint} accountId
|
|
329
|
+
* @param {string} tokenAddress - ERC-20 token address
|
|
330
|
+
* @param {string} elGamalPrivateKey - ElGamal private key (base64) for decryption
|
|
331
|
+
* @returns {Promise<{available: {amount: number, ciphertext: string|null}, pending: {amount: number, ciphertext: string|null}}>}
|
|
332
|
+
*/
|
|
333
|
+
async getAnonymousBalance(accountId, tokenAddress, elGamalPrivateKey) {
|
|
334
|
+
if (!ethers.isAddress(tokenAddress))
|
|
335
|
+
throw new Error(`Invalid token address: ${tokenAddress}`);
|
|
336
|
+
if (!elGamalPrivateKey) throw new Error("elGamalPrivateKey is required");
|
|
337
|
+
|
|
338
|
+
const [available, pending] = await Promise.all([
|
|
339
|
+
this._decryptAnonymousBalance(
|
|
340
|
+
accountId,
|
|
341
|
+
tokenAddress,
|
|
342
|
+
elGamalPrivateKey,
|
|
343
|
+
"available",
|
|
344
|
+
),
|
|
345
|
+
this._decryptAnonymousBalance(
|
|
346
|
+
accountId,
|
|
347
|
+
tokenAddress,
|
|
348
|
+
elGamalPrivateKey,
|
|
349
|
+
"pending",
|
|
350
|
+
),
|
|
351
|
+
]);
|
|
352
|
+
return { available, pending };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ────────────────── key derivation ─────────────────────────────
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Derive a deterministic ElGamal keypair for an anonymous account from an auth wallet.
|
|
359
|
+
* Internally signs a typed-data message unique to (chainId, diamondAddress, accountId, authWallet)
|
|
360
|
+
* and feeds the signature into the WASM key derivation function.
|
|
361
|
+
*
|
|
362
|
+
* Store the returned `privateKey` securely — it cannot be recovered without the wallet.
|
|
363
|
+
*
|
|
364
|
+
* @param {ethers.Wallet|ethers.Signer} authWallet
|
|
365
|
+
* @param {number|bigint} accountId
|
|
366
|
+
* @returns {Promise<{publicKey:string, privateKey:string}>} Base-64 encoded keypair
|
|
367
|
+
*/
|
|
368
|
+
async deriveAnonymousKeys(authWallet, accountId) {
|
|
369
|
+
try {
|
|
370
|
+
const wasm = await this._getWasm();
|
|
371
|
+
const address = await authWallet.getAddress();
|
|
372
|
+
|
|
373
|
+
const domain = {
|
|
374
|
+
name: "ConfidentialTokens",
|
|
375
|
+
version: "1",
|
|
376
|
+
chainId: this.chainId,
|
|
377
|
+
verifyingContract: this.diamondAddress,
|
|
378
|
+
};
|
|
379
|
+
const types = {
|
|
380
|
+
DeriveAnonymousElGamalKey: [
|
|
381
|
+
{ name: "purpose", type: "string" },
|
|
382
|
+
{ name: "accountId", type: "uint256" },
|
|
383
|
+
{ name: "user", type: "address" },
|
|
384
|
+
{ name: "context", type: "bytes32" },
|
|
385
|
+
],
|
|
386
|
+
};
|
|
387
|
+
const contextHash = ethers.keccak256(
|
|
388
|
+
ethers.AbiCoder.defaultAbiCoder().encode(
|
|
389
|
+
["uint256", "address", "uint256", "string"],
|
|
390
|
+
[
|
|
391
|
+
this.chainId,
|
|
392
|
+
this.diamondAddress,
|
|
393
|
+
BigInt(accountId),
|
|
394
|
+
"anonymous-main",
|
|
395
|
+
],
|
|
396
|
+
),
|
|
397
|
+
);
|
|
398
|
+
const message = {
|
|
399
|
+
purpose: "anonymous-elgamal-key-derive-v1",
|
|
400
|
+
accountId: BigInt(accountId),
|
|
401
|
+
user: address.toLowerCase(),
|
|
402
|
+
context: contextHash,
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
const signature = await authWallet.signTypedData(domain, types, message);
|
|
406
|
+
const domainContext = JSON.stringify({
|
|
407
|
+
chainId: this.chainId.toString(),
|
|
408
|
+
verifyingContract: this.diamondAddress,
|
|
409
|
+
user: address.toLowerCase(),
|
|
410
|
+
accountId: String(accountId),
|
|
411
|
+
purpose: "anonymous-elgamal-key-derive-v1",
|
|
412
|
+
version: "1",
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
const keypair = JSON.parse(
|
|
416
|
+
wasm.generate_deterministic_keypair(signature.slice(2), domainContext),
|
|
417
|
+
);
|
|
418
|
+
return {
|
|
419
|
+
publicKey: keypair.public_key,
|
|
420
|
+
privateKey: keypair.private_key,
|
|
421
|
+
};
|
|
422
|
+
} catch (e) {
|
|
423
|
+
throw new Error(`Failed to derive anonymous keys: ${e.message}`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ─────────────────── proof generation helpers ──────────────────
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Generate a ZK transfer proof using the WASM module.
|
|
431
|
+
*
|
|
432
|
+
* Pass the result's `proofHex` directly to `transferToPublic` or `transferToAnonymous`.
|
|
433
|
+
*
|
|
434
|
+
* @param {string} elGamalPrivateKey - ElGamal private key (base64)
|
|
435
|
+
* @param {Object} params
|
|
436
|
+
* @param {string} params.currentBalanceCiphertext - Combined ciphertext (base64, 64 bytes)
|
|
437
|
+
* @param {number} params.currentBalanceContractScale - Balance in contract scale (token_amount × 100 / 10^decimals)
|
|
438
|
+
* @param {number} params.transferAmountContractScale - Transfer amount in the same scale
|
|
439
|
+
* @param {string} params.destinationPublicKey - Recipient ElGamal pubkey (base64)
|
|
440
|
+
* @returns {Promise<string>} ABI-encoded proof as "0x..." hex
|
|
441
|
+
*/
|
|
442
|
+
async generateTransferProof(
|
|
443
|
+
elGamalPrivateKey,
|
|
444
|
+
{
|
|
445
|
+
currentBalanceCiphertext,
|
|
446
|
+
currentBalanceContractScale,
|
|
447
|
+
transferAmountContractScale,
|
|
448
|
+
destinationPublicKey,
|
|
449
|
+
},
|
|
450
|
+
) {
|
|
451
|
+
const wasm = await this._getWasm();
|
|
452
|
+
const input = {
|
|
453
|
+
current_balance_ciphertext: currentBalanceCiphertext,
|
|
454
|
+
current_balance: currentBalanceContractScale,
|
|
455
|
+
transfer_amount: transferAmountContractScale,
|
|
456
|
+
source_keypair: elGamalPrivateKey,
|
|
457
|
+
destination_pubkey: destinationPublicKey,
|
|
458
|
+
};
|
|
459
|
+
const result = JSON.parse(
|
|
460
|
+
wasm.generate_transfer_proof(JSON.stringify(input)),
|
|
461
|
+
);
|
|
462
|
+
if (!result.success)
|
|
463
|
+
throw new Error(
|
|
464
|
+
`Transfer proof generation failed: ${result.error ?? "unknown error"}`,
|
|
465
|
+
);
|
|
466
|
+
return (
|
|
467
|
+
"0x" +
|
|
468
|
+
Buffer.from(ethers.getBytes(encodeTransferProof(result.data))).toString(
|
|
469
|
+
"hex",
|
|
470
|
+
)
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Generate a ZK withdraw proof using the WASM module.
|
|
476
|
+
*
|
|
477
|
+
* Pass the result's `proofHex` directly to `withdraw`.
|
|
478
|
+
*
|
|
479
|
+
* @param {string} elGamalPrivateKey - ElGamal private key (base64)
|
|
480
|
+
* @param {Object} params
|
|
481
|
+
* @param {string} params.currentBalanceCiphertext - Combined ciphertext (base64, 64 bytes)
|
|
482
|
+
* @param {number} params.currentBalanceContractScale - Balance in contract scale
|
|
483
|
+
* @param {number} params.withdrawAmountContractScale - Withdraw amount in the same scale
|
|
484
|
+
* @param {number|bigint} params.nonce - Next user tx-id: pass `accountInfo.txId + 1n` (NOT the current txId)
|
|
485
|
+
* @returns {Promise<string>} ABI-encoded proof as "0x..." hex
|
|
486
|
+
*/
|
|
487
|
+
async generateWithdrawProof(
|
|
488
|
+
elGamalPrivateKey,
|
|
489
|
+
{
|
|
490
|
+
currentBalanceCiphertext,
|
|
491
|
+
currentBalanceContractScale,
|
|
492
|
+
withdrawAmountContractScale,
|
|
493
|
+
nonce,
|
|
494
|
+
},
|
|
495
|
+
) {
|
|
496
|
+
if (nonce === undefined || nonce === null)
|
|
497
|
+
throw new Error("nonce is required for withdraw proof generation");
|
|
498
|
+
|
|
499
|
+
const wasm = await this._getWasm();
|
|
500
|
+
const input = {
|
|
501
|
+
current_balance_ciphertext: currentBalanceCiphertext,
|
|
502
|
+
current_balance: currentBalanceContractScale,
|
|
503
|
+
withdraw_amount: withdrawAmountContractScale,
|
|
504
|
+
keypair: elGamalPrivateKey,
|
|
505
|
+
nonce: Number(nonce),
|
|
506
|
+
};
|
|
507
|
+
const result = JSON.parse(
|
|
508
|
+
wasm.generate_withdraw_proof(JSON.stringify(input)),
|
|
509
|
+
);
|
|
510
|
+
if (!result.success)
|
|
511
|
+
throw new Error(
|
|
512
|
+
`Withdraw proof generation failed: ${result.error ?? "unknown error"}`,
|
|
513
|
+
);
|
|
514
|
+
return (
|
|
515
|
+
"0x" +
|
|
516
|
+
Buffer.from(ethers.getBytes(encodeWithdrawProof(result.data))).toString(
|
|
517
|
+
"hex",
|
|
518
|
+
)
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// ─────────────────── Fairycloak operations ─────────────────────
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Create a new anonymous account via Fairycloak.
|
|
526
|
+
*
|
|
527
|
+
* The relay submits the `createAnonymousAccount` transaction and pays gas.
|
|
528
|
+
*
|
|
529
|
+
* @param {ethers.Wallet|ethers.Signer} authWallet - Initial auth signer
|
|
530
|
+
* @param {string} elgamalPublicKey - ElGamal public key as base64 or "0x"-prefixed hex (32 bytes)
|
|
531
|
+
* @param {Object} [options]
|
|
532
|
+
* @param {number} [options.deadlineOffset=3600] - Deadline in seconds from now
|
|
533
|
+
* @returns {Promise<{request_id:string, tx_hash:string, status:string, action:string}>}
|
|
534
|
+
*/
|
|
535
|
+
async createAccount(authWallet, elgamalPublicKey, options = {}) {
|
|
536
|
+
const { deadlineOffset = DEFAULT_DEADLINE_OFFSET } = options;
|
|
537
|
+
try {
|
|
538
|
+
const authAddress = await authWallet.getAddress();
|
|
539
|
+
const authPubkey = this._resolvePubkey(authWallet); // uncompressed 65-byte pubkey
|
|
540
|
+
|
|
541
|
+
// Normalise elgamal key to hex
|
|
542
|
+
const elgamalHex = elgamalPublicKey.startsWith("0x")
|
|
543
|
+
? elgamalPublicKey
|
|
544
|
+
: "0x" + Buffer.from(elgamalPublicKey, "base64").toString("hex");
|
|
545
|
+
|
|
546
|
+
const clientRequestId = ethers.hexlify(ethers.randomBytes(32));
|
|
547
|
+
const deadline = this._makeDeadline(deadlineOffset);
|
|
548
|
+
|
|
549
|
+
// EIP-712 struct hashes for dynamic fields
|
|
550
|
+
const initialSignersHash = ethers.keccak256(
|
|
551
|
+
ethers.AbiCoder.defaultAbiCoder().encode(
|
|
552
|
+
["address[]"],
|
|
553
|
+
[[authAddress]],
|
|
554
|
+
),
|
|
555
|
+
);
|
|
556
|
+
const elgamalPubkeyHash = ethers.keccak256(elgamalHex);
|
|
557
|
+
|
|
558
|
+
const domain = this._buildDomain();
|
|
559
|
+
const types = {
|
|
560
|
+
AnonymousCreate: [
|
|
561
|
+
{ name: "initialSignersHash", type: "bytes32" },
|
|
562
|
+
{ name: "elgamalPubkeyHash", type: "bytes32" },
|
|
563
|
+
{ name: "clientRequestId", type: "bytes32" },
|
|
564
|
+
{ name: "deadline", type: "uint256" },
|
|
565
|
+
],
|
|
566
|
+
};
|
|
567
|
+
const value = {
|
|
568
|
+
initialSignersHash,
|
|
569
|
+
elgamalPubkeyHash,
|
|
570
|
+
clientRequestId,
|
|
571
|
+
deadline: BigInt(deadline),
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
const signature = await authWallet.signTypedData(domain, types, value);
|
|
575
|
+
|
|
576
|
+
return await this._fetch("POST", "/v1/anonymous/accounts", {
|
|
577
|
+
elgamal_pubkey: elgamalHex,
|
|
578
|
+
auth_pubkeys: [authPubkey],
|
|
579
|
+
client_request_id: clientRequestId,
|
|
580
|
+
deadline: String(deadline),
|
|
581
|
+
signature,
|
|
582
|
+
});
|
|
583
|
+
} catch (e) {
|
|
584
|
+
const msg = e?.message ?? String(e);
|
|
585
|
+
if (msg.startsWith("Fairycloak")) throw e;
|
|
586
|
+
throw new Error(`Failed to create anonymous account: ${msg}`);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Update the set of authorised signers for an anonymous account via Fairycloak.
|
|
592
|
+
*
|
|
593
|
+
* @param {ethers.Wallet|ethers.Signer} authWallet - A currently authorised signer
|
|
594
|
+
* @param {number|bigint} accountId
|
|
595
|
+
* @param {Object} keys
|
|
596
|
+
* @param {Array<string|ethers.Wallet>} [keys.add=[]] - Pubkeys/wallets to add
|
|
597
|
+
* @param {Array<string|ethers.Wallet>} [keys.remove=[]] - Pubkeys/wallets to remove
|
|
598
|
+
* @param {Object} [options]
|
|
599
|
+
* @param {number} [options.deadlineOffset=3600]
|
|
600
|
+
* @returns {Promise<{request_id:string, tx_hash:string, status:string}>}
|
|
601
|
+
*/
|
|
602
|
+
async updateAuthKeys(
|
|
603
|
+
authWallet,
|
|
604
|
+
accountId,
|
|
605
|
+
{ add = [], remove = [] } = {},
|
|
606
|
+
options = {},
|
|
607
|
+
) {
|
|
608
|
+
const { deadlineOffset = DEFAULT_DEADLINE_OFFSET } = options;
|
|
609
|
+
try {
|
|
610
|
+
const authNonce = await this.getAuthNonce(accountId);
|
|
611
|
+
const deadline = this._makeDeadline(deadlineOffset);
|
|
612
|
+
|
|
613
|
+
const addPubkeys = add.map((w) => this._resolvePubkey(w));
|
|
614
|
+
const removePubkeys = remove.map((w) => this._resolvePubkey(w));
|
|
615
|
+
|
|
616
|
+
// EIP-712 signs over address arrays, not raw pubkeys
|
|
617
|
+
const addAddresses = addPubkeys.map((pk) => ethers.computeAddress(pk));
|
|
618
|
+
const removeAddresses = removePubkeys.map((pk) =>
|
|
619
|
+
ethers.computeAddress(pk),
|
|
620
|
+
);
|
|
621
|
+
|
|
622
|
+
const addSignersHash = ethers.keccak256(
|
|
623
|
+
ethers.AbiCoder.defaultAbiCoder().encode(["address[]"], [addAddresses]),
|
|
624
|
+
);
|
|
625
|
+
const removeSignersHash = ethers.keccak256(
|
|
626
|
+
ethers.AbiCoder.defaultAbiCoder().encode(
|
|
627
|
+
["address[]"],
|
|
628
|
+
[removeAddresses],
|
|
629
|
+
),
|
|
630
|
+
);
|
|
631
|
+
|
|
632
|
+
const domain = this._buildDomain();
|
|
633
|
+
const types = {
|
|
634
|
+
AnonymousUpdateKeys: [
|
|
635
|
+
{ name: "accountId", type: "uint64" },
|
|
636
|
+
{ name: "addSignersHash", type: "bytes32" },
|
|
637
|
+
{ name: "removeSignersHash", type: "bytes32" },
|
|
638
|
+
{ name: "authNonce", type: "uint256" },
|
|
639
|
+
{ name: "deadline", type: "uint256" },
|
|
640
|
+
],
|
|
641
|
+
};
|
|
642
|
+
const value = {
|
|
643
|
+
accountId: BigInt(accountId),
|
|
644
|
+
addSignersHash,
|
|
645
|
+
removeSignersHash,
|
|
646
|
+
authNonce,
|
|
647
|
+
deadline: BigInt(deadline),
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
const signature = await authWallet.signTypedData(domain, types, value);
|
|
651
|
+
|
|
652
|
+
return await this._fetch("POST", "/v1/anonymous/keys/update", {
|
|
653
|
+
account_id: Number(accountId),
|
|
654
|
+
add_pubkeys: addPubkeys,
|
|
655
|
+
remove_pubkeys: removePubkeys,
|
|
656
|
+
auth_nonce: String(authNonce),
|
|
657
|
+
deadline: String(deadline),
|
|
658
|
+
signature,
|
|
659
|
+
});
|
|
660
|
+
} catch (e) {
|
|
661
|
+
const msg = e?.message ?? String(e);
|
|
662
|
+
if (msg.startsWith("Fairycloak")) throw e;
|
|
663
|
+
throw new Error(`Failed to update auth keys: ${msg}`);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Deposit tokens into an anonymous account by forwarding a user-signed raw EVM transaction.
|
|
669
|
+
*
|
|
670
|
+
* The user's wallet pays gas for this transaction (unlike other operations where Fairycloak
|
|
671
|
+
* pays gas). Fairycloak validates and broadcasts the raw tx unchanged.
|
|
672
|
+
*
|
|
673
|
+
* Steps performed internally:
|
|
674
|
+
* 1. ERC-20 approve (if allowance is insufficient)
|
|
675
|
+
* 2. Sign EIP-712 deposit auth payload
|
|
676
|
+
* 3. Build and sign the raw `depositAnonymous(...)` EVM transaction
|
|
677
|
+
* 4. Submit to Fairycloak `/v1/anonymous/deposit/raw-tx`
|
|
678
|
+
*
|
|
679
|
+
* @param {ethers.Wallet} authWallet - Wallet that owns the tokens and signs the tx
|
|
680
|
+
* @param {number|bigint} accountId - Target anonymous account ID
|
|
681
|
+
* @param {string} tokenAddress - ERC-20 token address
|
|
682
|
+
* @param {bigint|string|number} amount - Amount in token units (wei-scaled by token decimals)
|
|
683
|
+
* @param {Object} [options]
|
|
684
|
+
* @param {number} [options.deadlineOffset=3600]
|
|
685
|
+
* @param {bigint} [options.gasLimit=600000n]
|
|
686
|
+
* @returns {Promise<{request_id:string, tx_hash:string, status:string}>}
|
|
687
|
+
*/
|
|
688
|
+
async deposit(authWallet, accountId, tokenAddress, amount, options = {}) {
|
|
689
|
+
const { deadlineOffset = DEFAULT_DEADLINE_OFFSET, gasLimit = 600000n } =
|
|
690
|
+
options;
|
|
691
|
+
try {
|
|
692
|
+
if (!ethers.isAddress(tokenAddress))
|
|
693
|
+
throw new Error(`Invalid token address: ${tokenAddress}`);
|
|
694
|
+
const amountBig = BigInt(amount);
|
|
695
|
+
if (amountBig <= 0n) throw new Error("Amount must be greater than 0");
|
|
696
|
+
|
|
697
|
+
const depositorAddress = await authWallet.getAddress();
|
|
698
|
+
const walletWithProvider = authWallet.connect
|
|
699
|
+
? authWallet.connect(this.provider)
|
|
700
|
+
: authWallet;
|
|
701
|
+
|
|
702
|
+
// 1. Approve if needed
|
|
703
|
+
const tokenContract = new ethers.Contract(
|
|
704
|
+
tokenAddress,
|
|
705
|
+
[
|
|
706
|
+
"function balanceOf(address) view returns (uint256)",
|
|
707
|
+
"function allowance(address,address) view returns (uint256)",
|
|
708
|
+
"function approve(address,uint256) returns (bool)",
|
|
709
|
+
],
|
|
710
|
+
walletWithProvider,
|
|
711
|
+
);
|
|
712
|
+
|
|
713
|
+
const [balance, allowance] = await Promise.all([
|
|
714
|
+
tokenContract.balanceOf(depositorAddress),
|
|
715
|
+
tokenContract.allowance(depositorAddress, this.diamondAddress),
|
|
716
|
+
]);
|
|
717
|
+
|
|
718
|
+
if (balance < amountBig)
|
|
719
|
+
throw new Error(
|
|
720
|
+
`Insufficient token balance. Required: ${amountBig}, Available: ${balance}`,
|
|
721
|
+
);
|
|
722
|
+
|
|
723
|
+
if (allowance < amountBig) {
|
|
724
|
+
const approveTx = await tokenContract.approve(
|
|
725
|
+
this.diamondAddress,
|
|
726
|
+
ethers.MaxUint256,
|
|
727
|
+
);
|
|
728
|
+
const receipt = await approveTx.wait();
|
|
729
|
+
if (!receipt || receipt.status === 0)
|
|
730
|
+
throw new Error("Token approval failed");
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Convert raw ERC-20 units → contract scale.
|
|
734
|
+
// The contract stores and accepts amounts as `rawAmount * 100 / 10^decimals`
|
|
735
|
+
// and internally calls `safeTransferFrom(user, contract, plainAmount * tokenMul)`
|
|
736
|
+
// where `tokenMul = 10^decimals / 100`. Passing raw units here would cause the
|
|
737
|
+
// contract to attempt a 10_000× larger transfer on a 6-decimal token, reverting.
|
|
738
|
+
const plainAmountContractScale = await this._toContractScale(
|
|
739
|
+
tokenAddress,
|
|
740
|
+
amountBig,
|
|
741
|
+
);
|
|
742
|
+
if (plainAmountContractScale <= 0n)
|
|
743
|
+
throw new Error(
|
|
744
|
+
`Amount too small: ${amountBig} raw units rounds to 0 in contract scale`,
|
|
745
|
+
);
|
|
746
|
+
|
|
747
|
+
// 2. EIP-712 deposit auth signature
|
|
748
|
+
const authNonce = await this.getAuthNonce(accountId);
|
|
749
|
+
const deadline = this._makeDeadline(deadlineOffset);
|
|
750
|
+
|
|
751
|
+
const domain = this._buildDomain();
|
|
752
|
+
const types = {
|
|
753
|
+
AnonymousDeposit: [
|
|
754
|
+
{ name: "accountId", type: "uint64" },
|
|
755
|
+
{ name: "token", type: "address" },
|
|
756
|
+
{ name: "plainAmount", type: "uint256" },
|
|
757
|
+
{ name: "authNonce", type: "uint256" },
|
|
758
|
+
{ name: "deadline", type: "uint256" },
|
|
759
|
+
],
|
|
760
|
+
};
|
|
761
|
+
const value = {
|
|
762
|
+
accountId: BigInt(accountId),
|
|
763
|
+
token: tokenAddress,
|
|
764
|
+
plainAmount: plainAmountContractScale,
|
|
765
|
+
authNonce,
|
|
766
|
+
deadline: BigInt(deadline),
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
const authSig = await authWallet.signTypedData(domain, types, value);
|
|
770
|
+
|
|
771
|
+
// 3. Build and sign the raw depositAnonymous transaction
|
|
772
|
+
const calldata = DEPOSIT_INTERFACE.encodeFunctionData(
|
|
773
|
+
"depositAnonymous",
|
|
774
|
+
[
|
|
775
|
+
BigInt(accountId),
|
|
776
|
+
tokenAddress,
|
|
777
|
+
plainAmountContractScale,
|
|
778
|
+
authNonce,
|
|
779
|
+
BigInt(deadline),
|
|
780
|
+
authSig,
|
|
781
|
+
],
|
|
782
|
+
);
|
|
783
|
+
|
|
784
|
+
const [nonce, feeData] = await Promise.all([
|
|
785
|
+
this.provider.getTransactionCount(depositorAddress),
|
|
786
|
+
this.provider.getFeeData(),
|
|
787
|
+
]);
|
|
788
|
+
|
|
789
|
+
let txObj;
|
|
790
|
+
if (feeData.maxFeePerGas) {
|
|
791
|
+
txObj = {
|
|
792
|
+
to: this.diamondAddress,
|
|
793
|
+
data: calldata,
|
|
794
|
+
nonce,
|
|
795
|
+
chainId: BigInt(this.chainId),
|
|
796
|
+
gasLimit,
|
|
797
|
+
maxFeePerGas: (feeData.maxFeePerGas * 12n) / 10n,
|
|
798
|
+
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas ?? 0n,
|
|
799
|
+
value: 0n,
|
|
800
|
+
};
|
|
801
|
+
} else {
|
|
802
|
+
txObj = {
|
|
803
|
+
to: this.diamondAddress,
|
|
804
|
+
data: calldata,
|
|
805
|
+
nonce,
|
|
806
|
+
chainId: BigInt(this.chainId),
|
|
807
|
+
gasLimit,
|
|
808
|
+
gasPrice: (feeData.gasPrice * 11n) / 10n,
|
|
809
|
+
value: 0n,
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const signedTx = await authWallet.signTransaction(txObj);
|
|
814
|
+
|
|
815
|
+
// 4. Submit to Fairycloak
|
|
816
|
+
return await this._fetch("POST", "/v1/anonymous/deposit/raw-tx", {
|
|
817
|
+
raw_tx: signedTx,
|
|
818
|
+
});
|
|
819
|
+
} catch (e) {
|
|
820
|
+
const msg = e?.message ?? String(e);
|
|
821
|
+
if (msg.startsWith("Fairycloak") || msg.startsWith("Insufficient"))
|
|
822
|
+
throw e;
|
|
823
|
+
throw new Error(`Failed to deposit: ${msg}`);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* Transfer from an anonymous account to a public EVM address via Fairycloak.
|
|
829
|
+
*
|
|
830
|
+
* **Auto-proof mode** (recommended): provide `elGamalPrivateKey`, `amount` (in token units),
|
|
831
|
+
* and `destinationPublicKey`. The SDK fetches the ciphertext from the chain, decrypts the
|
|
832
|
+
* current balance, and generates the ZK proof automatically.
|
|
833
|
+
*
|
|
834
|
+
* **Manual proof mode**: provide a pre-computed `proof` hex string from `generateTransferProof()`.
|
|
835
|
+
*
|
|
836
|
+
* @param {ethers.Wallet|ethers.Signer} authWallet - Authorised signer for the anonymous account
|
|
837
|
+
* @param {number|bigint} accountId - Sender's anonymous account ID
|
|
838
|
+
* @param {Object} params
|
|
839
|
+
* @param {string} params.recipient - Recipient EVM address (must have a confidential account)
|
|
840
|
+
* @param {string} params.token - Token address
|
|
841
|
+
* @param {string} [params.proof] - Pre-computed ZK proof ("0x..." hex)
|
|
842
|
+
* @param {string} [params.elGamalPrivateKey] - ElGamal private key (base64) for auto-proof
|
|
843
|
+
* @param {bigint|string|number} [params.amount] - Transfer amount in token units for auto-proof
|
|
844
|
+
* @param {string} [params.destinationPublicKey] - Recipient ElGamal pubkey (base64) for auto-proof
|
|
845
|
+
* @param {Object} [options]
|
|
846
|
+
* @param {number} [options.deadlineOffset=3600]
|
|
847
|
+
* @returns {Promise<{request_id:string, tx_hash:string, status:string}>}
|
|
848
|
+
*/
|
|
849
|
+
async transferToPublic(
|
|
850
|
+
authWallet,
|
|
851
|
+
accountId,
|
|
852
|
+
{
|
|
853
|
+
recipient,
|
|
854
|
+
token,
|
|
855
|
+
proof,
|
|
856
|
+
elGamalPrivateKey,
|
|
857
|
+
amount,
|
|
858
|
+
destinationPublicKey,
|
|
859
|
+
},
|
|
860
|
+
options = {},
|
|
861
|
+
) {
|
|
862
|
+
const { deadlineOffset = DEFAULT_DEADLINE_OFFSET } = options;
|
|
863
|
+
try {
|
|
864
|
+
if (!ethers.isAddress(recipient))
|
|
865
|
+
throw new Error(`Invalid recipient address: ${recipient}`);
|
|
866
|
+
if (!ethers.isAddress(token))
|
|
867
|
+
throw new Error(`Invalid token address: ${token}`);
|
|
868
|
+
|
|
869
|
+
let proofHex;
|
|
870
|
+
if (proof) {
|
|
871
|
+
proofHex = proof.startsWith("0x") ? proof : `0x${proof}`;
|
|
872
|
+
} else {
|
|
873
|
+
if (!elGamalPrivateKey)
|
|
874
|
+
throw new Error("Either proof or elGamalPrivateKey must be provided");
|
|
875
|
+
if (amount === undefined || amount === null)
|
|
876
|
+
throw new Error("amount is required when auto-generating proof");
|
|
877
|
+
|
|
878
|
+
// Convert raw ERC-20 units → contract scale for proof generation
|
|
879
|
+
const transferAmountContractScale = Number(
|
|
880
|
+
await this._toContractScale(token, BigInt(amount)),
|
|
881
|
+
);
|
|
882
|
+
|
|
883
|
+
// Auto-resolve destinationPublicKey from the recipient's on-chain confidential account
|
|
884
|
+
let destPubkey = destinationPublicKey;
|
|
885
|
+
if (!destPubkey) {
|
|
886
|
+
const recipientData = await this._fetch(
|
|
887
|
+
"GET",
|
|
888
|
+
`/v1/views/accounts/${recipient}/core`,
|
|
889
|
+
);
|
|
890
|
+
if (!recipientData.result.exists)
|
|
891
|
+
throw new Error(
|
|
892
|
+
`Recipient ${recipient} has no confidential account on-chain`,
|
|
893
|
+
);
|
|
894
|
+
destPubkey = Buffer.from(
|
|
895
|
+
ethers.getBytes(recipientData.result.pubkey),
|
|
896
|
+
).toString("base64");
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
const { ciphertext, amount: currentBalance } =
|
|
900
|
+
await this._decryptAnonymousBalance(
|
|
901
|
+
accountId,
|
|
902
|
+
token,
|
|
903
|
+
elGamalPrivateKey,
|
|
904
|
+
"available",
|
|
905
|
+
);
|
|
906
|
+
if (!ciphertext)
|
|
907
|
+
throw new Error(
|
|
908
|
+
"No available balance found for this anonymous account and token",
|
|
909
|
+
);
|
|
910
|
+
|
|
911
|
+
proofHex = await this.generateTransferProof(elGamalPrivateKey, {
|
|
912
|
+
currentBalanceCiphertext: ciphertext,
|
|
913
|
+
currentBalanceContractScale: currentBalance,
|
|
914
|
+
transferAmountContractScale,
|
|
915
|
+
destinationPublicKey: destPubkey,
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const authNonce = await this.getAuthNonce(accountId);
|
|
920
|
+
const deadline = this._makeDeadline(deadlineOffset);
|
|
921
|
+
const proofHash = ethers.keccak256(proofHex);
|
|
922
|
+
|
|
923
|
+
const domain = this._buildDomain();
|
|
924
|
+
const types = {
|
|
925
|
+
AnonymousTransferToPublic: [
|
|
926
|
+
{ name: "senderId", type: "uint64" },
|
|
927
|
+
{ name: "recipient", type: "address" },
|
|
928
|
+
{ name: "token", type: "address" },
|
|
929
|
+
{ name: "proofHash", type: "bytes32" },
|
|
930
|
+
{ name: "authNonce", type: "uint256" },
|
|
931
|
+
{ name: "deadline", type: "uint256" },
|
|
932
|
+
],
|
|
933
|
+
};
|
|
934
|
+
const value = {
|
|
935
|
+
senderId: BigInt(accountId),
|
|
936
|
+
recipient,
|
|
937
|
+
token,
|
|
938
|
+
proofHash,
|
|
939
|
+
authNonce,
|
|
940
|
+
deadline: BigInt(deadline),
|
|
941
|
+
};
|
|
942
|
+
|
|
943
|
+
const signature = await authWallet.signTypedData(domain, types, value);
|
|
944
|
+
|
|
945
|
+
return await this._fetch("POST", "/v1/anonymous/transfers/public", {
|
|
946
|
+
sender_id: Number(accountId),
|
|
947
|
+
recipient,
|
|
948
|
+
token,
|
|
949
|
+
proof: proofHex,
|
|
950
|
+
auth_nonce: String(authNonce),
|
|
951
|
+
deadline: String(deadline),
|
|
952
|
+
signature,
|
|
953
|
+
});
|
|
954
|
+
} catch (e) {
|
|
955
|
+
const msg = e?.message ?? String(e);
|
|
956
|
+
if (msg.startsWith("Fairycloak") || msg.startsWith("Invalid")) throw e;
|
|
957
|
+
throw new Error(`Failed to transfer to public: ${msg}`);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Transfer from one anonymous account to another via Fairycloak.
|
|
963
|
+
*
|
|
964
|
+
* **Auto-proof mode** (recommended): provide `elGamalPrivateKey`, `amount` (in token units),
|
|
965
|
+
* and `destinationPublicKey`. The SDK fetches the ciphertext, decrypts the balance, and generates
|
|
966
|
+
* the ZK proof automatically.
|
|
967
|
+
*
|
|
968
|
+
* **Manual proof mode**: provide a pre-computed `proof` hex string from `generateTransferProof()`.
|
|
969
|
+
*
|
|
970
|
+
* @param {ethers.Wallet|ethers.Signer} authWallet - Authorised signer for the sender account
|
|
971
|
+
* @param {number|bigint} senderAccountId
|
|
972
|
+
* @param {Object} params
|
|
973
|
+
* @param {number|bigint} params.recipientId - Recipient anonymous account ID
|
|
974
|
+
* @param {string} params.token - Token address
|
|
975
|
+
* @param {string} [params.proof] - Pre-computed ZK proof ("0x..." hex)
|
|
976
|
+
* @param {string} [params.elGamalPrivateKey] - ElGamal private key (base64) for auto-proof
|
|
977
|
+
* @param {bigint|string|number} [params.amount] - Transfer amount in token units for auto-proof
|
|
978
|
+
* @param {string} [params.destinationPublicKey] - Recipient ElGamal pubkey (base64) for auto-proof
|
|
979
|
+
* @param {Object} [options]
|
|
980
|
+
* @param {number} [options.deadlineOffset=3600]
|
|
981
|
+
* @returns {Promise<{request_id:string, tx_hash:string, status:string}>}
|
|
982
|
+
*/
|
|
983
|
+
async transferToAnonymous(
|
|
984
|
+
authWallet,
|
|
985
|
+
senderAccountId,
|
|
986
|
+
{
|
|
987
|
+
recipientId,
|
|
988
|
+
token,
|
|
989
|
+
proof,
|
|
990
|
+
elGamalPrivateKey,
|
|
991
|
+
amount,
|
|
992
|
+
destinationPublicKey,
|
|
993
|
+
},
|
|
994
|
+
options = {},
|
|
995
|
+
) {
|
|
996
|
+
const { deadlineOffset = DEFAULT_DEADLINE_OFFSET } = options;
|
|
997
|
+
try {
|
|
998
|
+
if (!ethers.isAddress(token))
|
|
999
|
+
throw new Error(`Invalid token address: ${token}`);
|
|
1000
|
+
|
|
1001
|
+
let proofHex;
|
|
1002
|
+
if (proof) {
|
|
1003
|
+
proofHex = proof.startsWith("0x") ? proof : `0x${proof}`;
|
|
1004
|
+
} else {
|
|
1005
|
+
if (!elGamalPrivateKey)
|
|
1006
|
+
throw new Error("Either proof or elGamalPrivateKey must be provided");
|
|
1007
|
+
if (amount === undefined || amount === null)
|
|
1008
|
+
throw new Error("amount is required when auto-generating proof");
|
|
1009
|
+
|
|
1010
|
+
// Convert raw ERC-20 units → contract scale for proof generation
|
|
1011
|
+
const transferAmountContractScale = Number(
|
|
1012
|
+
await this._toContractScale(token, BigInt(amount)),
|
|
1013
|
+
);
|
|
1014
|
+
|
|
1015
|
+
// Auto-resolve destinationPublicKey from the recipient's anonymous account on-chain
|
|
1016
|
+
let destPubkey = destinationPublicKey;
|
|
1017
|
+
if (!destPubkey) {
|
|
1018
|
+
const recipientData = await this._fetch(
|
|
1019
|
+
"GET",
|
|
1020
|
+
`/v1/views/anonymous/accounts/${recipientId}/core`,
|
|
1021
|
+
);
|
|
1022
|
+
if (!recipientData.result.exists)
|
|
1023
|
+
throw new Error(
|
|
1024
|
+
`Recipient anonymous account ${recipientId} does not exist on-chain`,
|
|
1025
|
+
);
|
|
1026
|
+
destPubkey = Buffer.from(
|
|
1027
|
+
ethers.getBytes(recipientData.result.elgamal_pubkey),
|
|
1028
|
+
).toString("base64");
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
const { ciphertext, amount: currentBalance } =
|
|
1032
|
+
await this._decryptAnonymousBalance(
|
|
1033
|
+
senderAccountId,
|
|
1034
|
+
token,
|
|
1035
|
+
elGamalPrivateKey,
|
|
1036
|
+
"available",
|
|
1037
|
+
);
|
|
1038
|
+
if (!ciphertext)
|
|
1039
|
+
throw new Error(
|
|
1040
|
+
"No available balance found for this anonymous account and token",
|
|
1041
|
+
);
|
|
1042
|
+
|
|
1043
|
+
proofHex = await this.generateTransferProof(elGamalPrivateKey, {
|
|
1044
|
+
currentBalanceCiphertext: ciphertext,
|
|
1045
|
+
currentBalanceContractScale: currentBalance,
|
|
1046
|
+
transferAmountContractScale,
|
|
1047
|
+
destinationPublicKey: destPubkey,
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
const authNonce = await this.getAuthNonce(senderAccountId);
|
|
1052
|
+
const deadline = this._makeDeadline(deadlineOffset);
|
|
1053
|
+
const proofHash = ethers.keccak256(proofHex);
|
|
1054
|
+
|
|
1055
|
+
const domain = this._buildDomain();
|
|
1056
|
+
const types = {
|
|
1057
|
+
AnonymousTransferToAnonymous: [
|
|
1058
|
+
{ name: "senderId", type: "uint64" },
|
|
1059
|
+
{ name: "recipientId", type: "uint64" },
|
|
1060
|
+
{ name: "token", type: "address" },
|
|
1061
|
+
{ name: "proofHash", type: "bytes32" },
|
|
1062
|
+
{ name: "authNonce", type: "uint256" },
|
|
1063
|
+
{ name: "deadline", type: "uint256" },
|
|
1064
|
+
],
|
|
1065
|
+
};
|
|
1066
|
+
const value = {
|
|
1067
|
+
senderId: BigInt(senderAccountId),
|
|
1068
|
+
recipientId: BigInt(recipientId),
|
|
1069
|
+
token,
|
|
1070
|
+
proofHash,
|
|
1071
|
+
authNonce,
|
|
1072
|
+
deadline: BigInt(deadline),
|
|
1073
|
+
};
|
|
1074
|
+
|
|
1075
|
+
const signature = await authWallet.signTypedData(domain, types, value);
|
|
1076
|
+
|
|
1077
|
+
return await this._fetch("POST", "/v1/anonymous/transfers/anonymous", {
|
|
1078
|
+
sender_id: Number(senderAccountId),
|
|
1079
|
+
recipient_id: Number(recipientId),
|
|
1080
|
+
token,
|
|
1081
|
+
proof: proofHex,
|
|
1082
|
+
auth_nonce: String(authNonce),
|
|
1083
|
+
deadline: String(deadline),
|
|
1084
|
+
signature,
|
|
1085
|
+
});
|
|
1086
|
+
} catch (e) {
|
|
1087
|
+
const msg = e?.message ?? String(e);
|
|
1088
|
+
if (msg.startsWith("Fairycloak") || msg.startsWith("Invalid")) throw e;
|
|
1089
|
+
throw new Error(`Failed to transfer to anonymous: ${msg}`);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* Apply a pending balance for an anonymous account via Fairycloak.
|
|
1095
|
+
*
|
|
1096
|
+
* After receiving an anonymous-to-anonymous transfer, the recipient must call
|
|
1097
|
+
* this to move the pending credit into their available balance.
|
|
1098
|
+
*
|
|
1099
|
+
* @param {ethers.Wallet|ethers.Signer} authWallet
|
|
1100
|
+
* @param {number|bigint} accountId
|
|
1101
|
+
* @param {Object} [options]
|
|
1102
|
+
* @param {number} [options.deadlineOffset=3600]
|
|
1103
|
+
* @returns {Promise<{request_id:string, tx_hash:string, status:string}>}
|
|
1104
|
+
*/
|
|
1105
|
+
async applyPending(authWallet, accountId, options = {}) {
|
|
1106
|
+
const { deadlineOffset = DEFAULT_DEADLINE_OFFSET } = options;
|
|
1107
|
+
try {
|
|
1108
|
+
const authNonce = await this.getAuthNonce(accountId);
|
|
1109
|
+
const deadline = this._makeDeadline(deadlineOffset);
|
|
1110
|
+
|
|
1111
|
+
const domain = this._buildDomain();
|
|
1112
|
+
const types = {
|
|
1113
|
+
AnonymousApplyPending: [
|
|
1114
|
+
{ name: "accountId", type: "uint64" },
|
|
1115
|
+
{ name: "authNonce", type: "uint256" },
|
|
1116
|
+
{ name: "deadline", type: "uint256" },
|
|
1117
|
+
],
|
|
1118
|
+
};
|
|
1119
|
+
const value = {
|
|
1120
|
+
accountId: BigInt(accountId),
|
|
1121
|
+
authNonce,
|
|
1122
|
+
deadline: BigInt(deadline),
|
|
1123
|
+
};
|
|
1124
|
+
|
|
1125
|
+
const signature = await authWallet.signTypedData(domain, types, value);
|
|
1126
|
+
|
|
1127
|
+
return await this._fetch("POST", "/v1/anonymous/apply", {
|
|
1128
|
+
account_id: Number(accountId),
|
|
1129
|
+
auth_nonce: String(authNonce),
|
|
1130
|
+
deadline: String(deadline),
|
|
1131
|
+
signature,
|
|
1132
|
+
});
|
|
1133
|
+
} catch (e) {
|
|
1134
|
+
const msg = e?.message ?? String(e);
|
|
1135
|
+
if (msg.startsWith("Fairycloak")) throw e;
|
|
1136
|
+
throw new Error(`Failed to apply pending: ${msg}`);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
/**
|
|
1141
|
+
* Withdraw from an anonymous account to a public EVM address via Fairycloak.
|
|
1142
|
+
*
|
|
1143
|
+
* **Auto-proof mode** (recommended): provide `elGamalPrivateKey`. The SDK fetches the token
|
|
1144
|
+
* decimals, derives the contract-scale amount from `plainAmount`, and generates the ZK proof
|
|
1145
|
+
* automatically.
|
|
1146
|
+
*
|
|
1147
|
+
* **Manual proof mode**: provide a pre-computed `proof` hex string from `generateWithdrawProof()`.
|
|
1148
|
+
*
|
|
1149
|
+
* @param {ethers.Wallet|ethers.Signer} authWallet - Authorised signer for the anonymous account
|
|
1150
|
+
* @param {number|bigint} accountId
|
|
1151
|
+
* @param {Object} params
|
|
1152
|
+
* @param {string} params.destination - Destination EVM address
|
|
1153
|
+
* @param {string} params.token - Token address
|
|
1154
|
+
* @param {bigint|string|number} params.plainAmount - Withdrawal amount in token units
|
|
1155
|
+
* @param {string} [params.proof] - Pre-computed ZK proof ("0x..." hex)
|
|
1156
|
+
* @param {string} [params.elGamalPrivateKey] - ElGamal private key (base64) for auto-proof
|
|
1157
|
+
* @param {Object} [options]
|
|
1158
|
+
* @param {number} [options.deadlineOffset=3600]
|
|
1159
|
+
* @returns {Promise<{request_id:string, tx_hash:string, status:string}>}
|
|
1160
|
+
*/
|
|
1161
|
+
async withdraw(
|
|
1162
|
+
authWallet,
|
|
1163
|
+
accountId,
|
|
1164
|
+
{
|
|
1165
|
+
destination,
|
|
1166
|
+
token,
|
|
1167
|
+
plainAmount,
|
|
1168
|
+
proof,
|
|
1169
|
+
elGamalPrivateKey,
|
|
1170
|
+
},
|
|
1171
|
+
options = {},
|
|
1172
|
+
) {
|
|
1173
|
+
const { deadlineOffset = DEFAULT_DEADLINE_OFFSET } = options;
|
|
1174
|
+
try {
|
|
1175
|
+
if (!ethers.isAddress(destination))
|
|
1176
|
+
throw new Error(`Invalid destination address: ${destination}`);
|
|
1177
|
+
if (!ethers.isAddress(token))
|
|
1178
|
+
throw new Error(`Invalid token address: ${token}`);
|
|
1179
|
+
|
|
1180
|
+
const amountBig = BigInt(plainAmount);
|
|
1181
|
+
|
|
1182
|
+
// Convert raw ERC-20 units → contract scale.
|
|
1183
|
+
// The contract's withdrawAnonymous expects plainAmount in contract scale
|
|
1184
|
+
// and internally calls `safeTransfer(destination, plainAmount * tokenMul)`.
|
|
1185
|
+
const withdrawAmountContractScale = await this._toContractScale(
|
|
1186
|
+
token,
|
|
1187
|
+
amountBig,
|
|
1188
|
+
);
|
|
1189
|
+
if (withdrawAmountContractScale <= 0n)
|
|
1190
|
+
throw new Error(
|
|
1191
|
+
`Amount too small: ${amountBig} raw units rounds to 0 in contract scale`,
|
|
1192
|
+
);
|
|
1193
|
+
|
|
1194
|
+
// Single call — gives us both txId (proof nonce) and authNonce (EIP-712 sig).
|
|
1195
|
+
// txId is the contract's per-account transaction counter ("user tx-id").
|
|
1196
|
+
// Withdraw proofs bind to the *next* tx id (txId + 1) — the contract increments
|
|
1197
|
+
// on every action, and the proof must commit to what the counter will be after
|
|
1198
|
+
// submission. authNonce is the separate EIP-712 replay-protection nonce.
|
|
1199
|
+
const accountInfo = await this.getAnonymousAccountInfo(accountId);
|
|
1200
|
+
const authNonce = accountInfo.authNonce;
|
|
1201
|
+
const txId = accountInfo.txId;
|
|
1202
|
+
|
|
1203
|
+
let proofHex;
|
|
1204
|
+
if (proof) {
|
|
1205
|
+
proofHex = proof.startsWith("0x") ? proof : `0x${proof}`;
|
|
1206
|
+
} else {
|
|
1207
|
+
if (!elGamalPrivateKey)
|
|
1208
|
+
throw new Error("Either proof or elGamalPrivateKey must be provided");
|
|
1209
|
+
|
|
1210
|
+
const { ciphertext, amount: currentBalance } =
|
|
1211
|
+
await this._decryptAnonymousBalance(
|
|
1212
|
+
accountId,
|
|
1213
|
+
token,
|
|
1214
|
+
elGamalPrivateKey,
|
|
1215
|
+
"available",
|
|
1216
|
+
);
|
|
1217
|
+
if (!ciphertext)
|
|
1218
|
+
throw new Error(
|
|
1219
|
+
"No available balance found for this anonymous account and token",
|
|
1220
|
+
);
|
|
1221
|
+
|
|
1222
|
+
proofHex = await this.generateWithdrawProof(elGamalPrivateKey, {
|
|
1223
|
+
currentBalanceCiphertext: ciphertext,
|
|
1224
|
+
currentBalanceContractScale: currentBalance,
|
|
1225
|
+
withdrawAmountContractScale: Number(withdrawAmountContractScale),
|
|
1226
|
+
nonce: txId + 1n, // proof binds to *next* txId (current + 1)
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
const deadline = this._makeDeadline(deadlineOffset);
|
|
1230
|
+
const proofHash = ethers.keccak256(proofHex);
|
|
1231
|
+
|
|
1232
|
+
const domain = this._buildDomain();
|
|
1233
|
+
const types = {
|
|
1234
|
+
AnonymousWithdraw: [
|
|
1235
|
+
{ name: "accountId", type: "uint64" },
|
|
1236
|
+
{ name: "destination", type: "address" },
|
|
1237
|
+
{ name: "token", type: "address" },
|
|
1238
|
+
{ name: "plainAmount", type: "uint256" },
|
|
1239
|
+
{ name: "proofHash", type: "bytes32" },
|
|
1240
|
+
{ name: "authNonce", type: "uint256" },
|
|
1241
|
+
{ name: "deadline", type: "uint256" },
|
|
1242
|
+
],
|
|
1243
|
+
};
|
|
1244
|
+
const value = {
|
|
1245
|
+
accountId: BigInt(accountId),
|
|
1246
|
+
destination,
|
|
1247
|
+
token,
|
|
1248
|
+
plainAmount: withdrawAmountContractScale,
|
|
1249
|
+
proofHash,
|
|
1250
|
+
authNonce,
|
|
1251
|
+
deadline: BigInt(deadline),
|
|
1252
|
+
};
|
|
1253
|
+
|
|
1254
|
+
const signature = await authWallet.signTypedData(domain, types, value);
|
|
1255
|
+
|
|
1256
|
+
return await this._fetch("POST", "/v1/anonymous/withdraw", {
|
|
1257
|
+
account_id: Number(accountId),
|
|
1258
|
+
destination,
|
|
1259
|
+
token,
|
|
1260
|
+
plain_amount: String(withdrawAmountContractScale),
|
|
1261
|
+
proof: proofHex,
|
|
1262
|
+
auth_nonce: String(authNonce),
|
|
1263
|
+
deadline: String(deadline),
|
|
1264
|
+
signature,
|
|
1265
|
+
});
|
|
1266
|
+
} catch (e) {
|
|
1267
|
+
const msg = e?.message ?? String(e);
|
|
1268
|
+
if (msg.startsWith("Fairycloak") || msg.startsWith("Invalid")) throw e;
|
|
1269
|
+
throw new Error(`Failed to withdraw: ${msg}`);
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/**
|
|
1274
|
+
* Get total, available, and pending balance (contract scale).
|
|
1275
|
+
*
|
|
1276
|
+
* @param {number|bigint} accountId
|
|
1277
|
+
* @param {string} tokenAddress
|
|
1278
|
+
* @param {string} elGamalPrivateKey
|
|
1279
|
+
* @returns {Promise<{ amount: number, available: number, pending: number }>}
|
|
1280
|
+
*/
|
|
1281
|
+
async getBalance(accountId, tokenAddress, elGamalPrivateKey) {
|
|
1282
|
+
try {
|
|
1283
|
+
if (!ethers.isAddress(tokenAddress)) {
|
|
1284
|
+
throw new Error(`Invalid token address: ${tokenAddress}`);
|
|
1285
|
+
}
|
|
1286
|
+
if (!elGamalPrivateKey) {
|
|
1287
|
+
throw new Error("elGamalPrivateKey is required");
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
const { available, pending } = await this.getAnonymousBalance(
|
|
1291
|
+
accountId,
|
|
1292
|
+
tokenAddress,
|
|
1293
|
+
elGamalPrivateKey,
|
|
1294
|
+
);
|
|
1295
|
+
|
|
1296
|
+
const availableAmt = available?.amount ?? 0;
|
|
1297
|
+
const pendingAmt = pending?.amount ?? 0;
|
|
1298
|
+
|
|
1299
|
+
return {
|
|
1300
|
+
amount: availableAmt + pendingAmt,
|
|
1301
|
+
available: availableAmt,
|
|
1302
|
+
pending: pendingAmt,
|
|
1303
|
+
};
|
|
1304
|
+
} catch (e) {
|
|
1305
|
+
throw new Error(`Failed to get balance: ${e.message}`);
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// ──────────────────── request tracking ─────────────────────────
|
|
1310
|
+
|
|
1311
|
+
/**
|
|
1312
|
+
* Get the current status of a Fairycloak request.
|
|
1313
|
+
*
|
|
1314
|
+
* Terminal statuses: `completed`, `failed`
|
|
1315
|
+
* Transaction statuses: `confirmed`, `mined`, `submitted`
|
|
1316
|
+
* Initial status: `accepted`
|
|
1317
|
+
*
|
|
1318
|
+
* @param {string} requestId
|
|
1319
|
+
* @returns {Promise<Object>}
|
|
1320
|
+
*/
|
|
1321
|
+
async getRequestStatus(requestId) {
|
|
1322
|
+
return await this._fetch("GET", `/v1/requests/${requestId}`);
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
/**
|
|
1326
|
+
* Fetch the durable event history for a Fairycloak request.
|
|
1327
|
+
*
|
|
1328
|
+
* Useful for reconnect/recovery in frontend applications.
|
|
1329
|
+
*
|
|
1330
|
+
* @param {string} requestId
|
|
1331
|
+
* @param {Object} [options]
|
|
1332
|
+
* @param {number} [options.afterSeq=0] - Only return events with sequence > afterSeq
|
|
1333
|
+
* @param {number} [options.limit=100]
|
|
1334
|
+
* @returns {Promise<{request_id:string, events:Array}>}
|
|
1335
|
+
*/
|
|
1336
|
+
async getRequestEvents(requestId, { afterSeq = 0, limit = 100 } = {}) {
|
|
1337
|
+
return await this._fetch(
|
|
1338
|
+
"GET",
|
|
1339
|
+
`/v1/requests/${requestId}/events/history?after_seq=${afterSeq}&limit=${limit}`,
|
|
1340
|
+
);
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/**
|
|
1344
|
+
* Poll a Fairycloak request until it reaches a terminal state or times out.
|
|
1345
|
+
*
|
|
1346
|
+
* Terminal states: `completed`, `confirmed`, `failed`
|
|
1347
|
+
*
|
|
1348
|
+
* @param {string} requestId
|
|
1349
|
+
* @param {Object} [options]
|
|
1350
|
+
* @param {number} [options.timeoutMs=120000] - Total timeout in milliseconds
|
|
1351
|
+
* @param {number} [options.pollIntervalMs=2000]
|
|
1352
|
+
* @returns {Promise<Object>} Final request record
|
|
1353
|
+
*/
|
|
1354
|
+
async waitForRequest(
|
|
1355
|
+
requestId,
|
|
1356
|
+
{ timeoutMs = 120000, pollIntervalMs = 2000 } = {},
|
|
1357
|
+
) {
|
|
1358
|
+
const terminal = new Set(["completed", "confirmed", "failed"]);
|
|
1359
|
+
const cutoff = Date.now() + timeoutMs;
|
|
1360
|
+
|
|
1361
|
+
while (Date.now() < cutoff) {
|
|
1362
|
+
const status = await this.getRequestStatus(requestId);
|
|
1363
|
+
if (terminal.has(status.status)) return status;
|
|
1364
|
+
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
throw new Error(
|
|
1368
|
+
`Timeout waiting for Fairycloak request ${requestId} after ${timeoutMs}ms`,
|
|
1369
|
+
);
|
|
1370
|
+
}
|
|
1371
|
+
}
|