@fairblock/stabletrust 1.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 +304 -0
- package/package.json +41 -0
- package/pkg/LICENSE +176 -0
- package/pkg/confidential_transfer_proof_generation.d.ts +65 -0
- package/pkg/confidential_transfer_proof_generation.js +654 -0
- package/pkg/confidential_transfer_proof_generation_bg.wasm +0 -0
- package/pkg/confidential_transfer_proof_generation_bg.wasm.d.ts +17 -0
- package/pkg/package.json +28 -0
- package/pkg/proof_generator_wasm_bg.wasm +0 -0
- package/src/client.js +885 -0
- package/src/constants.js +32 -0
- package/src/crypto.js +90 -0
- package/src/index.d.ts +275 -0
- package/src/index.js +5 -0
- package/src/utils.js +84 -0
- package/src/wasm-loader.js +85 -0
package/src/client.js
ADDED
|
@@ -0,0 +1,885 @@
|
|
|
1
|
+
import { ethers } from "ethers";
|
|
2
|
+
import {
|
|
3
|
+
CONTRACT_ABI,
|
|
4
|
+
ERC20_ABI,
|
|
5
|
+
DEFAULT_CONFIG,
|
|
6
|
+
TEMPO_FEE_TOKEN_ADDRESS,
|
|
7
|
+
} from "./constants.js";
|
|
8
|
+
import { deriveKeys, decryptCiphertext, combineCiphertext } from "./crypto.js";
|
|
9
|
+
import { encodeTransferProof, encodeWithdrawProof, sleep } from "./utils.js";
|
|
10
|
+
import { initializeWasm } from "./wasm-loader.js";
|
|
11
|
+
|
|
12
|
+
// Auto-initialize WASM on first use
|
|
13
|
+
let wasmModulePromise = null;
|
|
14
|
+
|
|
15
|
+
function getWasmModule() {
|
|
16
|
+
if (!wasmModulePromise) {
|
|
17
|
+
wasmModulePromise = initializeWasm();
|
|
18
|
+
}
|
|
19
|
+
return wasmModulePromise;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* ConfidentialTransferClient - Main SDK class for confidential transfers
|
|
24
|
+
*/
|
|
25
|
+
export class ConfidentialTransferClient {
|
|
26
|
+
/**
|
|
27
|
+
* Create a new ConfidentialTransferClient instance
|
|
28
|
+
*
|
|
29
|
+
* @param {string} rpcUrl - RPC endpoint URL
|
|
30
|
+
* @param {string} contractAddress - Confidential transfer contract address
|
|
31
|
+
* @param {number} chainId - Chain ID
|
|
32
|
+
*/
|
|
33
|
+
constructor(rpcUrl, contractAddress, chainId) {
|
|
34
|
+
// Validate required config
|
|
35
|
+
if (!rpcUrl) {
|
|
36
|
+
throw new Error("rpcUrl is required");
|
|
37
|
+
}
|
|
38
|
+
if (!contractAddress) {
|
|
39
|
+
throw new Error("contractAddress is required");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Build config
|
|
43
|
+
this.config = {
|
|
44
|
+
rpcUrl,
|
|
45
|
+
contractAddress,
|
|
46
|
+
chainId,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// WASM will be auto-initialized on first use
|
|
50
|
+
this._wasmModule = null;
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
this.provider = new ethers.JsonRpcProvider(this.config.rpcUrl);
|
|
54
|
+
this.contract = new ethers.Contract(
|
|
55
|
+
this.config.contractAddress,
|
|
56
|
+
CONTRACT_ABI,
|
|
57
|
+
this.provider,
|
|
58
|
+
);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`Failed to initialize contracts: ${error.message}. Check your RPC URL and contract addresses.`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Get WASM module (auto-initializes if needed)
|
|
68
|
+
* @private
|
|
69
|
+
*/
|
|
70
|
+
async _getWasm() {
|
|
71
|
+
if (!this._wasmModule) {
|
|
72
|
+
this._wasmModule = await getWasmModule();
|
|
73
|
+
}
|
|
74
|
+
return this._wasmModule;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Get token contract for a specific token
|
|
79
|
+
* @private
|
|
80
|
+
*/
|
|
81
|
+
_getTokenContract(tokenAddress) {
|
|
82
|
+
return new ethers.Contract(tokenAddress, ERC20_ABI, this.provider);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Derive encryption keys for a wallet
|
|
87
|
+
*
|
|
88
|
+
* @param {ethers.Wallet|ethers.Signer} wallet - The wallet to derive keys for
|
|
89
|
+
* @returns {Promise<{publicKey: string, privateKey: string}>}
|
|
90
|
+
*/
|
|
91
|
+
async deriveKeys(wallet) {
|
|
92
|
+
try {
|
|
93
|
+
if (!wallet) {
|
|
94
|
+
throw new Error("Wallet is required");
|
|
95
|
+
}
|
|
96
|
+
const wasm = await this._getWasm();
|
|
97
|
+
return await deriveKeys(
|
|
98
|
+
wallet,
|
|
99
|
+
{
|
|
100
|
+
chainId: this.config.chainId,
|
|
101
|
+
contractAddress: this.config.contractAddress,
|
|
102
|
+
},
|
|
103
|
+
wasm.generate_deterministic_keypair,
|
|
104
|
+
);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
throw new Error(`Failed to derive keys: ${error.message}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get account information from the contract
|
|
112
|
+
*
|
|
113
|
+
* @param {string} address - Account address
|
|
114
|
+
* @returns {Promise<Object>} Account core information
|
|
115
|
+
*/
|
|
116
|
+
async getAccountInfo(address) {
|
|
117
|
+
try {
|
|
118
|
+
if (!address || !ethers.isAddress(address)) {
|
|
119
|
+
throw new Error(`Invalid address: ${address}`);
|
|
120
|
+
}
|
|
121
|
+
return await this.contract.getAccountCore(address);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
throw new Error(`Failed to get account info: ${error.message}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Create a confidential account if it doesn't exist and wait for finalization
|
|
129
|
+
*
|
|
130
|
+
* @param {ethers.Wallet|ethers.Signer} wallet - The wallet to create account for
|
|
131
|
+
* @param {Object} [options] - Options
|
|
132
|
+
* @param {boolean} [options.waitForFinalization=true] - Wait for account finalization
|
|
133
|
+
* @param {number} [options.maxAttempts=30] - Max attempts to wait for finalization
|
|
134
|
+
* @returns {Promise<{publicKey: string, privateKey: string}>} The derived keys
|
|
135
|
+
*/
|
|
136
|
+
async ensureAccount(wallet, options = {}) {
|
|
137
|
+
const { waitForFinalization = true, maxAttempts = 30 } = options;
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const address = await wallet.getAddress();
|
|
141
|
+
const keys = await this.deriveKeys(wallet);
|
|
142
|
+
let accountInfo = await this.getAccountInfo(address);
|
|
143
|
+
|
|
144
|
+
if (!accountInfo.exists) {
|
|
145
|
+
const tx = await this.contract
|
|
146
|
+
.connect(wallet)
|
|
147
|
+
.createConfidentialAccount(Buffer.from(keys.publicKey, "base64"));
|
|
148
|
+
|
|
149
|
+
const receipt = await tx.wait();
|
|
150
|
+
if (!receipt || receipt.status === 0) {
|
|
151
|
+
throw new Error("Account creation transaction failed");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Refresh account info after creation
|
|
155
|
+
accountInfo = await this.getAccountInfo(address);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (waitForFinalization) {
|
|
159
|
+
let attempts = 0;
|
|
160
|
+
while (!accountInfo.finalized && attempts < maxAttempts) {
|
|
161
|
+
await sleep(2000);
|
|
162
|
+
accountInfo = await this.getAccountInfo(address);
|
|
163
|
+
attempts++;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!accountInfo.finalized) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`Account finalization timeout after ${maxAttempts} attempts. The account was created but may not be ready yet.`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return keys;
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (error.message.includes("Account finalization timeout")) {
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
throw new Error(`Failed to ensure account: ${error.message}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Get decrypted balance for an address
|
|
184
|
+
*
|
|
185
|
+
* @param {string} address - Account address
|
|
186
|
+
* @param {string} privateKey - Private key for decryption
|
|
187
|
+
* @param {string} tokenAddress - Token address
|
|
188
|
+
* @param {Object} [options] - Options
|
|
189
|
+
* @param {string} [options.type='available'] - Balance type: 'available' or 'pending'
|
|
190
|
+
* @returns {Promise<{amount: number, ciphertext: string|null}>}
|
|
191
|
+
*/
|
|
192
|
+
async getBalance(address, privateKey, tokenAddress, options = {}) {
|
|
193
|
+
const { type = "available" } = options;
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
if (!address || !ethers.isAddress(address)) {
|
|
197
|
+
throw new Error(`Invalid address: ${address}`);
|
|
198
|
+
}
|
|
199
|
+
if (!privateKey) {
|
|
200
|
+
throw new Error("Private key is required for decryption");
|
|
201
|
+
}
|
|
202
|
+
if (!tokenAddress || !ethers.isAddress(tokenAddress)) {
|
|
203
|
+
throw new Error(`Invalid token address: ${tokenAddress}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let c1, c2;
|
|
207
|
+
if (type.toLowerCase() === "pending") {
|
|
208
|
+
[c1, c2] = await this.contract.getPending(address, tokenAddress);
|
|
209
|
+
} else {
|
|
210
|
+
[c1, c2] = await this.contract.getAvailable(address, tokenAddress);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if ((!c1 || c1 === "0x") && (!c2 || c2 === "0x")) {
|
|
214
|
+
return { amount: 0, ciphertext: null };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const wasm = await this._getWasm();
|
|
218
|
+
const ciphertext = combineCiphertext(c1, c2);
|
|
219
|
+
const amount = decryptCiphertext(
|
|
220
|
+
ciphertext,
|
|
221
|
+
privateKey,
|
|
222
|
+
wasm.decrypt_ciphertext,
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
return { amount, ciphertext };
|
|
226
|
+
} catch (error) {
|
|
227
|
+
throw new Error(`Failed to get balance: ${error.message}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Deposit tokens into confidential account
|
|
233
|
+
*
|
|
234
|
+
* @param {ethers.Wallet|ethers.Signer} wallet - The wallet to deposit from
|
|
235
|
+
* @param {string} tokenAddress - Token address to deposit
|
|
236
|
+
* @param {bigint|string|number} amount - Amount to deposit (in token units)
|
|
237
|
+
* @param {Object} [options] - Options
|
|
238
|
+
* @param {boolean} [options.waitForFinalization=true] - Wait for deposit finalization
|
|
239
|
+
* @returns {Promise<Object>} Transaction receipt
|
|
240
|
+
*/
|
|
241
|
+
async deposit(wallet, tokenAddress, amount, options = {}) {
|
|
242
|
+
const { waitForFinalization = true } = options;
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
if (!wallet) {
|
|
246
|
+
throw new Error("Wallet is required");
|
|
247
|
+
}
|
|
248
|
+
if (!tokenAddress || !ethers.isAddress(tokenAddress)) {
|
|
249
|
+
throw new Error(`Invalid token address: ${tokenAddress}`);
|
|
250
|
+
}
|
|
251
|
+
if (!amount || BigInt(amount) <= 0) {
|
|
252
|
+
throw new Error("Amount must be greater than 0");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const address = await wallet.getAddress();
|
|
256
|
+
const depositAmount = BigInt(amount);
|
|
257
|
+
const tokenContract = this._getTokenContract(tokenAddress);
|
|
258
|
+
|
|
259
|
+
// Check token balance
|
|
260
|
+
const tokenBalance = await tokenContract.balanceOf(address);
|
|
261
|
+
if (tokenBalance < depositAmount) {
|
|
262
|
+
throw new Error(
|
|
263
|
+
`Insufficient token balance. Required: ${depositAmount}, Available: ${tokenBalance}`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Check and approve if needed
|
|
268
|
+
const allowance = await tokenContract.allowance(
|
|
269
|
+
address,
|
|
270
|
+
this.config.contractAddress,
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
if (allowance < depositAmount) {
|
|
274
|
+
const approveTx = await tokenContract
|
|
275
|
+
.connect(wallet)
|
|
276
|
+
.approve(this.config.contractAddress, ethers.MaxUint256);
|
|
277
|
+
|
|
278
|
+
const approveReceipt = await approveTx.wait();
|
|
279
|
+
if (!approveReceipt || approveReceipt.status === 0) {
|
|
280
|
+
throw new Error("Token approval failed");
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Perform deposit
|
|
285
|
+
const depositTx = await this.contract
|
|
286
|
+
.connect(wallet)
|
|
287
|
+
.deposit(tokenAddress, depositAmount);
|
|
288
|
+
|
|
289
|
+
const receipt = await depositTx.wait();
|
|
290
|
+
if (!receipt || receipt.status === 0) {
|
|
291
|
+
throw new Error("Deposit transaction failed");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (waitForFinalization) {
|
|
295
|
+
await this._waitForGlobalState(address, "deposit");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return receipt;
|
|
299
|
+
} catch (error) {
|
|
300
|
+
if (error.message.includes("Insufficient token balance")) {
|
|
301
|
+
throw error;
|
|
302
|
+
}
|
|
303
|
+
throw new Error(`Failed to deposit: ${error.message}`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Transfer confidential tokens to another address
|
|
309
|
+
*
|
|
310
|
+
* @param {ethers.Wallet|ethers.Signer} senderWallet - Sender's wallet
|
|
311
|
+
* @param {string} recipientAddress - Recipient's address
|
|
312
|
+
* @param {string} tokenAddress - Token address to transfer
|
|
313
|
+
* @param {number} amount - Amount to transfer
|
|
314
|
+
* @param {Object} senderKeys - Sender's encryption keys
|
|
315
|
+
* @param {string} recipientPublicKey - Recipient's public key
|
|
316
|
+
* @param {string} currentBalanceCiphertext - Current balance ciphertext
|
|
317
|
+
* @param {number} currentBalance - Current balance (decrypted)
|
|
318
|
+
* @param {Object} [options] - Options
|
|
319
|
+
* @param {boolean} [options.useOffchainVerify=false] - Use offchain verification
|
|
320
|
+
* @param {boolean} [options.waitForFinalization=true] - Wait for transfer finalization
|
|
321
|
+
* @returns {Promise<Object>} Transaction receipt
|
|
322
|
+
*/
|
|
323
|
+
async transfer(
|
|
324
|
+
senderWallet,
|
|
325
|
+
recipientAddress,
|
|
326
|
+
tokenAddress,
|
|
327
|
+
amount,
|
|
328
|
+
options = {},
|
|
329
|
+
) {
|
|
330
|
+
const { useOffchainVerify = false, waitForFinalization = true } = options;
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
// Validate inputs
|
|
334
|
+
if (!senderWallet) {
|
|
335
|
+
throw new Error("Sender wallet is required");
|
|
336
|
+
}
|
|
337
|
+
if (!recipientAddress || !ethers.isAddress(recipientAddress)) {
|
|
338
|
+
throw new Error(`Invalid recipient address: ${recipientAddress}`);
|
|
339
|
+
}
|
|
340
|
+
if (!tokenAddress || !ethers.isAddress(tokenAddress)) {
|
|
341
|
+
throw new Error(`Invalid token address: ${tokenAddress}`);
|
|
342
|
+
}
|
|
343
|
+
if (!amount || amount <= 0) {
|
|
344
|
+
throw new Error("Transfer amount must be greater than 0");
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const senderAddress = await senderWallet.getAddress();
|
|
348
|
+
|
|
349
|
+
// Auto-derive sender keys
|
|
350
|
+
const derivedSenderKeys = await this.deriveKeys(senderWallet);
|
|
351
|
+
if (!derivedSenderKeys?.privateKey) {
|
|
352
|
+
throw new Error("Failed to derive sender keys");
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Auto-derive recipient public key
|
|
356
|
+
const recipientAccountInfo = await this.getAccountInfo(recipientAddress);
|
|
357
|
+
if (!recipientAccountInfo.exists) {
|
|
358
|
+
throw new Error(
|
|
359
|
+
`Recipient account does not exist. Address: ${recipientAddress}`,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
let derivedRecipientPublicKey = recipientAccountInfo.pubkey;
|
|
363
|
+
if (!derivedRecipientPublicKey) {
|
|
364
|
+
throw new Error("Recipient public key is required");
|
|
365
|
+
}
|
|
366
|
+
// Convert hex bytes to base64 if needed
|
|
367
|
+
if (
|
|
368
|
+
typeof derivedRecipientPublicKey === "string" &&
|
|
369
|
+
derivedRecipientPublicKey.startsWith("0x")
|
|
370
|
+
) {
|
|
371
|
+
derivedRecipientPublicKey = Buffer.from(
|
|
372
|
+
derivedRecipientPublicKey.slice(2),
|
|
373
|
+
"hex",
|
|
374
|
+
).toString("base64");
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Auto-fetch current balance
|
|
378
|
+
const balanceInfo = await this.getBalance(
|
|
379
|
+
senderAddress,
|
|
380
|
+
derivedSenderKeys.privateKey,
|
|
381
|
+
tokenAddress,
|
|
382
|
+
);
|
|
383
|
+
const fee = await this.getFeeAmount();
|
|
384
|
+
if (!balanceInfo) {
|
|
385
|
+
throw new Error("Failed to fetch sender balance");
|
|
386
|
+
}
|
|
387
|
+
const derivedCurrentBalanceCiphertext = balanceInfo.ciphertext;
|
|
388
|
+
const derivedCurrentBalance = balanceInfo.amount;
|
|
389
|
+
|
|
390
|
+
if (!derivedCurrentBalanceCiphertext) {
|
|
391
|
+
throw new Error(
|
|
392
|
+
"Current balance ciphertext is required. Did you call getBalance()?",
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
if (
|
|
396
|
+
derivedCurrentBalance === undefined ||
|
|
397
|
+
derivedCurrentBalance < amount
|
|
398
|
+
) {
|
|
399
|
+
throw new Error(
|
|
400
|
+
`Insufficient balance. Required: ${amount}, Available: ${derivedCurrentBalance}`,
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Generate proof
|
|
405
|
+
const proofInput = {
|
|
406
|
+
current_balance_ciphertext: derivedCurrentBalanceCiphertext,
|
|
407
|
+
current_balance:
|
|
408
|
+
typeof derivedCurrentBalance === "bigint"
|
|
409
|
+
? derivedCurrentBalance.toString()
|
|
410
|
+
: derivedCurrentBalance,
|
|
411
|
+
transfer_amount: typeof amount === "bigint" ? Number(amount) : amount,
|
|
412
|
+
source_keypair: derivedSenderKeys.privateKey,
|
|
413
|
+
destination_pubkey: derivedRecipientPublicKey,
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
const wasm = await this._getWasm();
|
|
417
|
+
const proofResult = wasm.generate_transfer_proof(
|
|
418
|
+
JSON.stringify(proofInput),
|
|
419
|
+
);
|
|
420
|
+
const proof = JSON.parse(proofResult);
|
|
421
|
+
|
|
422
|
+
if (!proof.success) {
|
|
423
|
+
throw new Error(
|
|
424
|
+
`Proof generation failed: ${proof.error || "Unknown error. Check your balance and amount."}`,
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Execute transfer based on chain type
|
|
429
|
+
// Tempo chain (42431) uses token-based(fee token for the current contract is pathUSD)fees instead of native currency
|
|
430
|
+
const receipt =
|
|
431
|
+
this.config.chainId === 42431
|
|
432
|
+
? await this._executeTempoTransfer(
|
|
433
|
+
senderWallet,
|
|
434
|
+
senderAddress,
|
|
435
|
+
recipientAddress,
|
|
436
|
+
tokenAddress,
|
|
437
|
+
proof,
|
|
438
|
+
useOffchainVerify,
|
|
439
|
+
fee,
|
|
440
|
+
)
|
|
441
|
+
: await this._executeStandardTransfer(
|
|
442
|
+
senderWallet,
|
|
443
|
+
recipientAddress,
|
|
444
|
+
tokenAddress,
|
|
445
|
+
proof,
|
|
446
|
+
useOffchainVerify,
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
if (waitForFinalization) {
|
|
450
|
+
await this._waitForGlobalState(senderAddress, "transfer");
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return receipt;
|
|
454
|
+
} catch (error) {
|
|
455
|
+
const message = error?.message ?? String(error);
|
|
456
|
+
if (
|
|
457
|
+
message.includes("Insufficient balance") ||
|
|
458
|
+
message.includes("Proof generation failed")
|
|
459
|
+
) {
|
|
460
|
+
throw error;
|
|
461
|
+
}
|
|
462
|
+
throw new Error(`Failed to transfer: ${message}`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Execute confidential transfer on Tempo chain (chainId 42431)
|
|
468
|
+
*
|
|
469
|
+
* Tempo is a stablecoin-focused chain without native currency for smart contracts.
|
|
470
|
+
* This method handles token-based fee payment using PathUSD, requiring:
|
|
471
|
+
* - Fee token approval before transfer
|
|
472
|
+
* - Fee token balance validation
|
|
473
|
+
* - Gas estimation with fallback for estimation failures
|
|
474
|
+
*
|
|
475
|
+
* @private
|
|
476
|
+
*/
|
|
477
|
+
async _executeTempoTransfer(
|
|
478
|
+
senderWallet,
|
|
479
|
+
senderAddress,
|
|
480
|
+
recipientAddress,
|
|
481
|
+
tokenAddress,
|
|
482
|
+
proof,
|
|
483
|
+
useOffchainVerify,
|
|
484
|
+
fee,
|
|
485
|
+
) {
|
|
486
|
+
const feeTokenAddress = TEMPO_FEE_TOKEN_ADDRESS;
|
|
487
|
+
let tx;
|
|
488
|
+
|
|
489
|
+
// Check if feeTokenAddress is configured - this indicates token-based fee payment
|
|
490
|
+
if (feeTokenAddress) {
|
|
491
|
+
// Approve fee token for the contract
|
|
492
|
+
const feeTokenContract = this._getTokenContract(feeTokenAddress);
|
|
493
|
+
|
|
494
|
+
// Check balance first
|
|
495
|
+
const feeTokenBalance = await feeTokenContract.balanceOf(senderAddress);
|
|
496
|
+
if (feeTokenBalance < fee) {
|
|
497
|
+
throw new Error(
|
|
498
|
+
`Insufficient fee token ${TEMPO_FEE_TOKEN_ADDRESS} balance. Required: ${fee}, Available: ${feeTokenBalance}`,
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const allowance = await feeTokenContract.allowance(
|
|
503
|
+
senderAddress,
|
|
504
|
+
this.config.contractAddress,
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
if (allowance < fee) {
|
|
508
|
+
const approveTx = await feeTokenContract
|
|
509
|
+
.connect(senderWallet)
|
|
510
|
+
.approve(this.config.contractAddress, ethers.MaxUint256);
|
|
511
|
+
|
|
512
|
+
const approveReceipt = await approveTx.wait();
|
|
513
|
+
if (!approveReceipt || approveReceipt.status === 0) {
|
|
514
|
+
throw new Error("Fee token approval failed");
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Try to estimate gas first to catch any revert reasons early
|
|
519
|
+
let gasLimit = 2_000_000n;
|
|
520
|
+
try {
|
|
521
|
+
const estimatedGas = await this.contract
|
|
522
|
+
.connect(senderWallet)
|
|
523
|
+
.transferConfidential.estimateGas(
|
|
524
|
+
recipientAddress,
|
|
525
|
+
tokenAddress,
|
|
526
|
+
ethers.getBytes(encodeTransferProof(proof.data)),
|
|
527
|
+
useOffchainVerify,
|
|
528
|
+
{ value: 0 },
|
|
529
|
+
);
|
|
530
|
+
// Add 20% buffer to estimated gas
|
|
531
|
+
gasLimit = (estimatedGas * 120n) / 100n;
|
|
532
|
+
} catch (gasEstError) {
|
|
533
|
+
// If gas estimation fails, use default gas limit
|
|
534
|
+
console.warn(
|
|
535
|
+
`Gas estimation failed, using default gas limit: ${gasEstError?.message || String(gasEstError)}`,
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
tx = await this.contract
|
|
540
|
+
.connect(senderWallet)
|
|
541
|
+
.transferConfidential(
|
|
542
|
+
recipientAddress,
|
|
543
|
+
tokenAddress,
|
|
544
|
+
ethers.getBytes(encodeTransferProof(proof.data)),
|
|
545
|
+
useOffchainVerify,
|
|
546
|
+
{ value: 0, gasLimit },
|
|
547
|
+
);
|
|
548
|
+
} else {
|
|
549
|
+
try {
|
|
550
|
+
tx = await this.contract
|
|
551
|
+
.connect(senderWallet)
|
|
552
|
+
.transferConfidential(
|
|
553
|
+
recipientAddress,
|
|
554
|
+
tokenAddress,
|
|
555
|
+
ethers.getBytes(encodeTransferProof(proof.data)),
|
|
556
|
+
useOffchainVerify,
|
|
557
|
+
{ value: fee },
|
|
558
|
+
);
|
|
559
|
+
} catch (gasError) {
|
|
560
|
+
if (
|
|
561
|
+
gasError?.code === "CALL_EXCEPTION" ||
|
|
562
|
+
gasError?.code === "UNKNOWN_ERROR" ||
|
|
563
|
+
gasError?.message?.includes("estimateGas") ||
|
|
564
|
+
gasError?.message?.includes("missing revert data")
|
|
565
|
+
) {
|
|
566
|
+
const gasLimit = 2_000_000n;
|
|
567
|
+
tx = await this.contract
|
|
568
|
+
.connect(senderWallet)
|
|
569
|
+
.transferConfidential(
|
|
570
|
+
recipientAddress,
|
|
571
|
+
tokenAddress,
|
|
572
|
+
ethers.getBytes(encodeTransferProof(proof.data)),
|
|
573
|
+
useOffchainVerify,
|
|
574
|
+
{ value: fee, gasLimit },
|
|
575
|
+
);
|
|
576
|
+
} else {
|
|
577
|
+
throw gasError;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const receipt = await tx.wait();
|
|
583
|
+
if (!receipt || receipt.status === 0) {
|
|
584
|
+
throw new Error("Transfer transaction failed");
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
return receipt;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Execute confidential transfer on standard chains
|
|
592
|
+
* @private
|
|
593
|
+
*/
|
|
594
|
+
async _executeStandardTransfer(
|
|
595
|
+
senderWallet,
|
|
596
|
+
recipientAddress,
|
|
597
|
+
tokenAddress,
|
|
598
|
+
proof,
|
|
599
|
+
useOffchainVerify,
|
|
600
|
+
) {
|
|
601
|
+
const fee = await this.contract.feeAmount();
|
|
602
|
+
const tx = await this.contract
|
|
603
|
+
.connect(senderWallet)
|
|
604
|
+
.transferConfidential(
|
|
605
|
+
recipientAddress,
|
|
606
|
+
tokenAddress,
|
|
607
|
+
ethers.getBytes(encodeTransferProof(proof.data)),
|
|
608
|
+
useOffchainVerify,
|
|
609
|
+
{ value: fee },
|
|
610
|
+
);
|
|
611
|
+
|
|
612
|
+
const receipt = await tx.wait();
|
|
613
|
+
if (!receipt || receipt.status === 0) {
|
|
614
|
+
throw new Error("Transfer transaction failed");
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return receipt;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Apply pending balance to available balance
|
|
622
|
+
*
|
|
623
|
+
* @param {ethers.Wallet|ethers.Signer} wallet - The wallet to apply pending for
|
|
624
|
+
* @param {Object} [options] - Options
|
|
625
|
+
* @param {boolean} [options.waitForFinalization=true] - Wait for operation finalization
|
|
626
|
+
* @returns {Promise<Object>} Transaction receipt
|
|
627
|
+
*/
|
|
628
|
+
async applyPending(wallet, options = {}) {
|
|
629
|
+
const { waitForFinalization = true } = options;
|
|
630
|
+
|
|
631
|
+
try {
|
|
632
|
+
if (!wallet) {
|
|
633
|
+
throw new Error("Wallet is required");
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const address = await wallet.getAddress();
|
|
637
|
+
|
|
638
|
+
const tx = await this.contract.connect(wallet).applyPending();
|
|
639
|
+
const receipt = await tx.wait();
|
|
640
|
+
|
|
641
|
+
if (!receipt || receipt.status === 0) {
|
|
642
|
+
throw new Error("Apply pending transaction failed");
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (waitForFinalization) {
|
|
646
|
+
await this._waitForGlobalState(address, "apply pending");
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
return receipt;
|
|
650
|
+
} catch (error) {
|
|
651
|
+
throw new Error(`Failed to apply pending: ${error.message}`);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Withdraw confidential tokens to public ERC20
|
|
657
|
+
*
|
|
658
|
+
* @param {ethers.Wallet|ethers.Signer} wallet - The wallet to withdraw from
|
|
659
|
+
* @param {string} tokenAddress - Token address to withdraw
|
|
660
|
+
* @param {number} amount - Amount to withdraw
|
|
661
|
+
* @param {Object} keys - Encryption keys
|
|
662
|
+
* @param {string} currentBalanceCiphertext - Current balance ciphertext
|
|
663
|
+
* @param {number} currentBalance - Current balance (decrypted)
|
|
664
|
+
* @param {Object} [options] - Options
|
|
665
|
+
* @param {boolean} [options.useOffchainVerify=false] - Use offchain verification
|
|
666
|
+
* @param {boolean} [options.waitForFinalization=true] - Wait for withdrawal finalization
|
|
667
|
+
* @returns {Promise<Object>} Transaction receipt
|
|
668
|
+
*/
|
|
669
|
+
async withdraw(wallet, tokenAddress, amount, options = {}) {
|
|
670
|
+
const { useOffchainVerify = false, waitForFinalization = true } = options;
|
|
671
|
+
|
|
672
|
+
try {
|
|
673
|
+
// Validate inputs
|
|
674
|
+
if (!wallet) {
|
|
675
|
+
throw new Error("Wallet is required");
|
|
676
|
+
}
|
|
677
|
+
if (!tokenAddress || !ethers.isAddress(tokenAddress)) {
|
|
678
|
+
throw new Error(`Invalid token address: ${tokenAddress}`);
|
|
679
|
+
}
|
|
680
|
+
if (!amount || amount <= 0) {
|
|
681
|
+
throw new Error("Withdrawal amount must be greater than 0");
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Auto-derive keys
|
|
685
|
+
const derivedKeys = await this.deriveKeys(wallet);
|
|
686
|
+
if (!derivedKeys?.privateKey) {
|
|
687
|
+
throw new Error("Failed to derive keys");
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const balanceInfo = await this.getBalance(
|
|
691
|
+
wallet.address,
|
|
692
|
+
derivedKeys.privateKey,
|
|
693
|
+
tokenAddress,
|
|
694
|
+
{
|
|
695
|
+
type: "available",
|
|
696
|
+
},
|
|
697
|
+
);
|
|
698
|
+
if (!balanceInfo) {
|
|
699
|
+
throw new Error("Failed to fetch sender balance");
|
|
700
|
+
}
|
|
701
|
+
const currentBalanceCiphertext = balanceInfo.ciphertext;
|
|
702
|
+
const currentBalance = balanceInfo.amount;
|
|
703
|
+
|
|
704
|
+
if (!currentBalanceCiphertext) {
|
|
705
|
+
throw new Error(
|
|
706
|
+
"Current balance ciphertext is required. Did you call getBalance()?",
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
if (currentBalance === undefined || currentBalance < amount) {
|
|
710
|
+
throw new Error(
|
|
711
|
+
`Insufficient balance. Required: ${amount}, Available: ${currentBalance}`,
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const address = await wallet.getAddress();
|
|
716
|
+
|
|
717
|
+
// Generate withdrawal proof
|
|
718
|
+
const withdrawInput = {
|
|
719
|
+
current_balance_ciphertext: currentBalanceCiphertext,
|
|
720
|
+
current_balance:
|
|
721
|
+
typeof currentBalance === "bigint"
|
|
722
|
+
? Number(currentBalance)
|
|
723
|
+
: currentBalance,
|
|
724
|
+
withdraw_amount: typeof amount === "bigint" ? Number(amount) : amount,
|
|
725
|
+
keypair: derivedKeys.privateKey,
|
|
726
|
+
};
|
|
727
|
+
|
|
728
|
+
const wasm = await this._getWasm();
|
|
729
|
+
const proofResult = wasm.generate_withdraw_proof(
|
|
730
|
+
JSON.stringify(withdrawInput),
|
|
731
|
+
);
|
|
732
|
+
const proof = JSON.parse(proofResult);
|
|
733
|
+
|
|
734
|
+
if (!proof.success) {
|
|
735
|
+
throw new Error(
|
|
736
|
+
`Withdrawal proof generation failed: ${proof.error || "Unknown error. Check your balance and amount."}`,
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Execute withdrawal
|
|
741
|
+
const tx = await this.contract
|
|
742
|
+
.connect(wallet)
|
|
743
|
+
.withdraw(
|
|
744
|
+
tokenAddress,
|
|
745
|
+
BigInt(amount),
|
|
746
|
+
ethers.getBytes(encodeWithdrawProof(proof.data)),
|
|
747
|
+
useOffchainVerify,
|
|
748
|
+
);
|
|
749
|
+
|
|
750
|
+
const receipt = await tx.wait();
|
|
751
|
+
if (!receipt || receipt.status === 0) {
|
|
752
|
+
throw new Error("Withdrawal transaction failed");
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
if (waitForFinalization) {
|
|
756
|
+
await this._waitForGlobalState(address, "withdraw");
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
return receipt;
|
|
760
|
+
} catch (error) {
|
|
761
|
+
const message = error?.message ?? String(error);
|
|
762
|
+
if (
|
|
763
|
+
message.includes("Insufficient balance") ||
|
|
764
|
+
message.includes("proof generation failed")
|
|
765
|
+
) {
|
|
766
|
+
throw error;
|
|
767
|
+
}
|
|
768
|
+
throw new Error(`Failed to withdraw: ${message}`);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Wait for pending action to complete (internal method)
|
|
774
|
+
*
|
|
775
|
+
* @param {string} address - Account address
|
|
776
|
+
* @param {string} actionLabel - Label for error messages
|
|
777
|
+
* @private
|
|
778
|
+
*/
|
|
779
|
+
async _waitForGlobalState(address, actionLabel) {
|
|
780
|
+
// Initial cooldown to allow the relayer/indexer to pick up the transaction
|
|
781
|
+
await sleep(10000);
|
|
782
|
+
|
|
783
|
+
let attempts = 0;
|
|
784
|
+
const maxAttempts = 60;
|
|
785
|
+
|
|
786
|
+
while (attempts < maxAttempts) {
|
|
787
|
+
try {
|
|
788
|
+
const info = await this.contract.getAccountCore(address);
|
|
789
|
+
if (!info.hasPendingAction) {
|
|
790
|
+
return; // Success
|
|
791
|
+
}
|
|
792
|
+
} catch (error) {
|
|
793
|
+
// If we can't get account info, wait and retry
|
|
794
|
+
console.warn(
|
|
795
|
+
`Warning: Failed to check account state (attempt ${attempts + 1}): ${error.message}`,
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
await sleep(3000);
|
|
800
|
+
attempts++;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
throw new Error(
|
|
804
|
+
`Timeout waiting for ${actionLabel} to complete. The transaction may still be processing. Please check your account later.`,
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* Wait for pending balance to appear
|
|
810
|
+
*
|
|
811
|
+
* @param {string} address - Account address
|
|
812
|
+
* @param {string} privateKey - Private key for decryption
|
|
813
|
+
* @param {string} tokenAddress - Token address
|
|
814
|
+
* @param {Object} [options] - Options
|
|
815
|
+
* @param {number} [options.maxAttempts=60] - Maximum polling attempts
|
|
816
|
+
* @param {number} [options.intervalMs=3000] - Polling interval in milliseconds
|
|
817
|
+
* @returns {Promise<{amount: number, ciphertext: string}>}
|
|
818
|
+
*/
|
|
819
|
+
async waitForPendingBalance(address, privateKey, tokenAddress, options = {}) {
|
|
820
|
+
const { maxAttempts = 60, intervalMs = 3000 } = options;
|
|
821
|
+
|
|
822
|
+
try {
|
|
823
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
824
|
+
const pending = await this.getBalance(
|
|
825
|
+
address,
|
|
826
|
+
privateKey,
|
|
827
|
+
tokenAddress,
|
|
828
|
+
{
|
|
829
|
+
type: "pending",
|
|
830
|
+
},
|
|
831
|
+
);
|
|
832
|
+
|
|
833
|
+
if (pending.amount > 0) {
|
|
834
|
+
return pending;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
await sleep(intervalMs);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
throw new Error(
|
|
841
|
+
`Timeout waiting for pending balance after ${maxAttempts} attempts. The transfer may still be processing.`,
|
|
842
|
+
);
|
|
843
|
+
} catch (error) {
|
|
844
|
+
if (error.message.includes("Timeout waiting for pending balance")) {
|
|
845
|
+
throw error;
|
|
846
|
+
}
|
|
847
|
+
throw new Error(`Failed to wait for pending balance: ${error.message}`);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Get the current fee amount for confidential transfers
|
|
853
|
+
*
|
|
854
|
+
* @returns {Promise<bigint>} Fee amount in wei
|
|
855
|
+
*/
|
|
856
|
+
async getFeeAmount() {
|
|
857
|
+
try {
|
|
858
|
+
return await this.contract.feeAmount();
|
|
859
|
+
} catch (error) {
|
|
860
|
+
throw new Error(`Failed to get fee amount: ${error.message}`);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Get ERC20 token balance
|
|
866
|
+
*
|
|
867
|
+
* @param {string} address - Account address
|
|
868
|
+
* @param {string} tokenAddress - Token address
|
|
869
|
+
* @returns {Promise<bigint>} Token balance
|
|
870
|
+
*/
|
|
871
|
+
async getTokenBalance(address, tokenAddress) {
|
|
872
|
+
try {
|
|
873
|
+
if (!address || !ethers.isAddress(address)) {
|
|
874
|
+
throw new Error(`Invalid address: ${address}`);
|
|
875
|
+
}
|
|
876
|
+
if (!tokenAddress || !ethers.isAddress(tokenAddress)) {
|
|
877
|
+
throw new Error(`Invalid token address: ${tokenAddress}`);
|
|
878
|
+
}
|
|
879
|
+
const tokenContract = this._getTokenContract(tokenAddress);
|
|
880
|
+
return await tokenContract.balanceOf(address);
|
|
881
|
+
} catch (error) {
|
|
882
|
+
throw new Error(`Failed to get token balance: ${error.message}`);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
}
|