@zircuit/ovault-evm 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +141 -0
  2. package/dist/addresses.d.ts +41 -0
  3. package/dist/addresses.d.ts.map +1 -0
  4. package/dist/addresses.js +48 -0
  5. package/dist/contracts/EIP2612.d.ts +14 -0
  6. package/dist/contracts/EIP2612.d.ts.map +1 -0
  7. package/dist/contracts/EIP2612.js +38 -0
  8. package/dist/contracts/ERC20.d.ts +169 -0
  9. package/dist/contracts/ERC20.d.ts.map +1 -0
  10. package/dist/contracts/ERC20.js +222 -0
  11. package/dist/contracts/ERC4626.d.ts +2020 -0
  12. package/dist/contracts/ERC4626.d.ts.map +1 -0
  13. package/dist/contracts/ERC4626.js +2638 -0
  14. package/dist/contracts/OFT.d.ts +1736 -0
  15. package/dist/contracts/OFT.d.ts.map +1 -0
  16. package/dist/contracts/OFT.js +2251 -0
  17. package/dist/contracts/OVaultComposer.d.ts +94 -0
  18. package/dist/contracts/OVaultComposer.d.ts.map +1 -0
  19. package/dist/contracts/OVaultComposer.js +1629 -0
  20. package/dist/contracts/OVaultComposerSync.d.ts +528 -0
  21. package/dist/contracts/OVaultComposerSync.d.ts.map +1 -0
  22. package/dist/contracts/OVaultComposerSync.js +682 -0
  23. package/dist/contracts/OVaultComposerSyncNative.d.ts +560 -0
  24. package/dist/contracts/OVaultComposerSyncNative.d.ts.map +1 -0
  25. package/dist/contracts/OVaultComposerSyncNative.js +723 -0
  26. package/dist/index.d.ts +4 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +3 -0
  29. package/dist/oVaultSync/index.d.ts +3 -0
  30. package/dist/oVaultSync/index.d.ts.map +1 -0
  31. package/dist/oVaultSync/index.js +2 -0
  32. package/dist/oVaultSync/tracking.d.ts +46 -0
  33. package/dist/oVaultSync/tracking.d.ts.map +1 -0
  34. package/dist/oVaultSync/tracking.js +458 -0
  35. package/dist/oVaultSync/transfer.d.ts +116 -0
  36. package/dist/oVaultSync/transfer.d.ts.map +1 -0
  37. package/dist/oVaultSync/transfer.js +515 -0
  38. package/dist/types.d.ts +202 -0
  39. package/dist/types.d.ts.map +1 -0
  40. package/dist/types.js +18 -0
  41. package/package.json +51 -0
  42. package/src/addresses.ts +100 -0
  43. package/src/contracts/EIP2612.ts +38 -0
  44. package/src/contracts/ERC20.ts +222 -0
  45. package/src/contracts/ERC4626.ts +2638 -0
  46. package/src/contracts/OFT.ts +2251 -0
  47. package/src/contracts/OVaultComposer.ts +1629 -0
  48. package/src/contracts/OVaultComposerSync.ts +682 -0
  49. package/src/index.ts +3 -0
  50. package/src/oVaultSync/index.ts +2 -0
  51. package/src/oVaultSync/tracking.ts +614 -0
  52. package/src/oVaultSync/transfer.ts +742 -0
  53. package/src/types.ts +260 -0
@@ -0,0 +1,742 @@
1
+ import {
2
+ createPublicClient,
3
+ encodeAbiParameters,
4
+ encodeFunctionData,
5
+ erc20Abi,
6
+ http,
7
+ pad,
8
+ stringToHex,
9
+ } from "viem";
10
+ import { OFTAbi } from "../contracts/OFT";
11
+ import { OVaultComposerSyncAbi } from "../contracts/OVaultComposerSync";
12
+ import { ERC4626_ABI } from "../contracts/ERC4626";
13
+ import { Options } from "@layerzerolabs/lz-v2-utilities";
14
+ import { ERC20Abi } from "../contracts/ERC20";
15
+ import { EIP2612_ABI } from "../contracts/EIP2612";
16
+ import {
17
+ OVaultSyncInputs,
18
+ SendParams,
19
+ SendParamsInput,
20
+ GenerateOVaultSyncInputsProps,
21
+ OVaultSyncOperations,
22
+ OVaultCoreInputs,
23
+ } from "../types";
24
+ import { CHAINS, CLUSTERS } from "../addresses";
25
+
26
+ const EXTRA_GAS_COMPOSE = 300_000n;
27
+
28
+ export class OVaultSyncMessageBuilder {
29
+ private static getFormattedRefCode(code?: string): `0x${string}` {
30
+ return code
31
+ ? (stringToHex(code, { size: 32 }) as `0x${string}`)
32
+ : ("0x" as `0x${string}`);
33
+ }
34
+
35
+ /**
36
+ * Parse a string amount to BigInt with specified decimals, always truncating (never rounding up)
37
+ * This is critical for LayerZero operations to avoid precision issues
38
+ */
39
+ private static parseUnitsTruncate(value: string, decimals: number): bigint {
40
+ const strValue = String(value);
41
+ const [integerPart, decimalPart = ""] = strValue.split(".");
42
+ const truncatedDecimalPart = decimalPart
43
+ .slice(0, decimals)
44
+ .padEnd(decimals, "0");
45
+ const combined = integerPart + truncatedDecimalPart;
46
+ return BigInt(combined);
47
+ }
48
+
49
+ /**
50
+ * LayerZero uses 6 shared decimals for cross-chain transfers.
51
+ * This function removes dust below the 6 decimal precision that LayerZero won't transfer.
52
+ * For tokens with >6 decimals, this prevents quoteSend failures due to precision loss.
53
+ */
54
+ private static removeLzDust(amount: bigint, decimals: number): bigint {
55
+ if (decimals <= 6) return amount;
56
+ const dust = 10n ** BigInt(decimals - 6);
57
+ return (amount / dust) * dust;
58
+ }
59
+
60
+ static buildComposeArgument(input: SendParamsInput, messageFee: bigint) {
61
+ // For the default OVault, the compose argument is just the hub to dst chain send params
62
+ // However, if you are using a custom OVault, you can have a custom compose argument
63
+ return encodeAbiParameters(
64
+ [
65
+ {
66
+ type: "tuple",
67
+ name: "sendParams",
68
+ components: [
69
+ { name: "dstEid", type: "uint32" },
70
+ { name: "to", type: "bytes32" },
71
+ { name: "amountLD", type: "uint256" },
72
+ { name: "minAmountLD", type: "uint256" },
73
+ { name: "extraOptions", type: "bytes" },
74
+ { name: "composeMsg", type: "bytes" },
75
+ { name: "oftCmd", type: "bytes" },
76
+ ],
77
+ },
78
+ {
79
+ type: "uint256",
80
+ name: "minMsgValue",
81
+ },
82
+ ],
83
+ [this.buildHubToDstChainSendParams(input), messageFee],
84
+ );
85
+ }
86
+
87
+ /**
88
+ * Build the send params for the hub chain to the dst chain.
89
+ *
90
+ * @param input - The input parameters for the OVault.
91
+ * @returns The send params for the hub chain to the dst chain.
92
+ */
93
+ static buildHubToDstChainSendParams(input: SendParamsInput) {
94
+ const {
95
+ srcEid,
96
+ hubEid,
97
+ dstEid,
98
+ dstAddress,
99
+ dstAmount,
100
+ minDstAmount,
101
+ referralCode,
102
+ } = input;
103
+
104
+ const srcIsHubChain = srcEid === hubEid;
105
+
106
+ // This is for a basic OFT send, so the enforced options should be enough
107
+ const options = Options.newOptions();
108
+ return {
109
+ dstEid: dstEid,
110
+ to: pad(dstAddress, { size: 32 }),
111
+ amountLD: dstAmount,
112
+ minAmountLD: minDstAmount,
113
+ extraOptions: options.toHex() as `0x${string}`,
114
+ composeMsg: "0x" as `0x${string}`,
115
+ oftCmd: srcIsHubChain
116
+ ? this.getFormattedRefCode(referralCode)
117
+ : ("0x" as `0x${string}`),
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Quote the amount of shares or assets that will be received from the OVault.
123
+ *
124
+ * @param input - The input parameters for the OVault.
125
+ * @returns The amount of shares or assets that will be received from the OVault.
126
+ */
127
+ static async quoteOVaultOutput(
128
+ input: GenerateOVaultSyncInputsProps,
129
+ ): Promise<{
130
+ dstAmount: bigint;
131
+ minDstAmount: bigint;
132
+ }> {
133
+ const {
134
+ operation,
135
+ vaultAddress,
136
+ hubChain,
137
+ slippage,
138
+ srcEid,
139
+ dstEid,
140
+ hubEid,
141
+ } = input;
142
+
143
+ const client = createPublicClient({
144
+ chain: hubChain,
145
+ transport: http(input.hubRpcUrl),
146
+ });
147
+ if (input.amount == null || input.tokenHubDecimals == null) {
148
+ throw new Error("inputs cannot be null");
149
+ }
150
+ let amountInHubDecimals = this.parseUnitsTruncate(
151
+ input.amount,
152
+ input.tokenHubDecimals,
153
+ );
154
+ // Only remove LayerZero dust if this is a cross-chain operation
155
+ // LayerZero is involved if source or destination is not the hub chain
156
+ const isCrossChain = srcEid !== hubEid || dstEid !== hubEid;
157
+ if (isCrossChain) {
158
+ amountInHubDecimals = this.removeLzDust(
159
+ amountInHubDecimals,
160
+ input.tokenHubDecimals,
161
+ );
162
+ }
163
+
164
+ const outputAmount = await client.readContract({
165
+ address: vaultAddress,
166
+ abi: ERC4626_ABI,
167
+ functionName:
168
+ operation === OVaultSyncOperations.DEPOSIT
169
+ ? "previewDeposit"
170
+ : "previewRedeem",
171
+ args: [amountInHubDecimals],
172
+ });
173
+
174
+ if (slippage < 0.001)
175
+ throw new Error("Slippage must be greater or equal to 0.001 (0.1%)");
176
+ if (outputAmount === 0n) throw new Error("Output amount is too small");
177
+
178
+ const slippageAmount =
179
+ (outputAmount * BigInt(Number(slippage.toFixed(3)) * 1000)) / 1000n;
180
+
181
+ return {
182
+ dstAmount: outputAmount,
183
+ minDstAmount: outputAmount - slippageAmount,
184
+ };
185
+ }
186
+
187
+ /**
188
+ * Calculate the message fee for the hub chain. This is the fee that it costs to send a message from the hub chain to the dst chain.
189
+ *
190
+ * @param input - The input parameters for the OVault.
191
+ * @param useWalletAddress
192
+ * @returns The message fee for the hub chain.
193
+ */
194
+ static async calculateHubChainFee(
195
+ input: SendParamsInput,
196
+ useWalletAddress = true,
197
+ ) {
198
+ const {
199
+ srcEid,
200
+ operation,
201
+ amount,
202
+ composerAddress,
203
+ hubChain,
204
+ dstEid,
205
+ hubEid,
206
+ oftHubAddress,
207
+ vaultAddress,
208
+ buffer,
209
+ } = input;
210
+
211
+ // If the dst chain is the same as the hub chain, then we don't need to calculate the fee
212
+ // as we are already on the hub chain, so "send" on the OFT will not be called.
213
+ if (dstEid === hubEid) {
214
+ return {
215
+ nativeFee: 0n,
216
+ lzTokenFee: 0n,
217
+ };
218
+ }
219
+
220
+ const client = createPublicClient({
221
+ chain: hubChain,
222
+ transport: http(input.hubRpcUrl),
223
+ });
224
+
225
+ const hubSendParams = this.buildHubToDstChainSendParams(input);
226
+
227
+ // Determine the target OFT based on operation:
228
+ // - For deposits: shares are sent via share OFT
229
+ // - For redeems: assets are sent via asset OFT
230
+ const targetOft =
231
+ operation === OVaultSyncOperations.DEPOSIT ? vaultAddress : oftHubAddress;
232
+
233
+ if (srcEid === hubEid) {
234
+ // For same-chain operations, use wallet address. For cross-chain, we could use
235
+ // the OFT address since the wallet may not have the tokens yet.
236
+ if (!useWalletAddress) {
237
+ throw new Error("Wallet address is required");
238
+ }
239
+ const quote = await client.readContract({
240
+ address: composerAddress,
241
+ abi: OVaultComposerSyncAbi,
242
+ functionName: "quoteSend",
243
+ args: [input.walletAddress, targetOft, amount, hubSendParams],
244
+ });
245
+
246
+ return {
247
+ nativeFee: this.increaseByBuffer(quote.nativeFee, buffer),
248
+ lzTokenFee: this.increaseByBuffer(quote.lzTokenFee, buffer),
249
+ };
250
+ }
251
+
252
+ // We check directly on the OFT to get the message fee as the vault address or the share OFT address
253
+ // won't have shares or assets balance as we burn them.
254
+ // This is different to the original LZ implementation, where they use
255
+ // OFT adapter for the shares token in accounting chain (the tokens are locked in that case,
256
+ // not burned). In their case, they always call VaultComposer, for maxDeposit/maxRedeem checks.
257
+ const quote = await client.readContract({
258
+ address: targetOft,
259
+ abi: OFTAbi,
260
+ functionName: "quoteSend",
261
+ args: [hubSendParams as never, false],
262
+ });
263
+
264
+ return {
265
+ nativeFee: this.increaseByBuffer(quote.nativeFee, buffer),
266
+ lzTokenFee: this.increaseByBuffer(quote.lzTokenFee, buffer),
267
+ };
268
+ }
269
+
270
+ static async getMessageFee(sendParams: SendParams, input: SendParamsInput) {
271
+ const {
272
+ operation,
273
+ oftAddress,
274
+ vaultTokenAddress,
275
+ sourceChain,
276
+ srcEid,
277
+ hubEid,
278
+ buffer,
279
+ } = input;
280
+
281
+ const client = createPublicClient({
282
+ chain: sourceChain,
283
+ transport: http(input.sourceRpcUrl),
284
+ });
285
+
286
+ // If the src chain is the same as the hub chain, then the way to calculate the message fee is the same
287
+ // as calculating the hub chain fee as we are already on the hub chain.
288
+ if (srcEid === hubEid) {
289
+ return this.calculateHubChainFee(input, true);
290
+ }
291
+
292
+ const sendingOftAddress =
293
+ operation === OVaultSyncOperations.DEPOSIT
294
+ ? oftAddress
295
+ : vaultTokenAddress;
296
+
297
+ // If we are not on the hub chain, then we need to call the OFT's quoteSend function to get the message fee.
298
+ const messageFee = await client.readContract({
299
+ address: sendingOftAddress,
300
+ abi: OFTAbi,
301
+ functionName: "quoteSend",
302
+ args: [sendParams as never, false],
303
+ });
304
+
305
+ return {
306
+ nativeFee: this.increaseByBuffer(messageFee.nativeFee, buffer),
307
+ lzTokenFee: this.increaseByBuffer(messageFee.lzTokenFee, buffer),
308
+ };
309
+ }
310
+
311
+ static increaseByBuffer(amount: bigint, buffer: number = 0) {
312
+ if (!buffer || buffer <= 0) {
313
+ return amount;
314
+ }
315
+
316
+ const flatNumber = BigInt((buffer * 100).toFixed(0));
317
+
318
+ const bufferAmount = (amount * flatNumber) / 100n;
319
+ return amount + bufferAmount;
320
+ }
321
+
322
+ static async buildSendParams(input: SendParamsInput) {
323
+ const {
324
+ amount,
325
+ srcEid,
326
+ composerAddress,
327
+ hubEid,
328
+ dstAmount,
329
+ minDstAmount,
330
+ referralCode,
331
+ } = input;
332
+
333
+ const options = Options.newOptions();
334
+
335
+ const srcIsHubChain = srcEid === hubEid;
336
+
337
+ if (srcIsHubChain) {
338
+ return this.buildHubToDstChainSendParams(input);
339
+ }
340
+
341
+ // If the src chain is not the same as the hub chain, then the first message will be an LzCompose
342
+ // so we need to add the executor option
343
+ const hubMessageFee = await this.calculateHubChainFee(
344
+ {
345
+ ...input,
346
+ dstAmount,
347
+ minDstAmount,
348
+ },
349
+ false,
350
+ );
351
+
352
+ const extraGas = input.hubLzComposeGasLimit ?? EXTRA_GAS_COMPOSE;
353
+ options.addExecutorComposeOption(0, extraGas, hubMessageFee.nativeFee);
354
+
355
+ return {
356
+ // If the src chain is not the same as the hub chain, then the first message will be to the hub chain
357
+ // so we need to set the dstEid to the hub chain EID
358
+ dstEid: hubEid,
359
+ to: pad(composerAddress, { size: 32 }),
360
+ amountLD: amount,
361
+ minAmountLD: amount,
362
+ extraOptions: options.toHex() as `0x${string}`,
363
+ composeMsg: this.buildComposeArgument(input, hubMessageFee.nativeFee),
364
+ oftCmd: this.getFormattedRefCode(referralCode),
365
+ };
366
+ }
367
+
368
+ static async buildApproval(input: SendParamsInput) {
369
+ const {
370
+ tokenAddress,
371
+ sourceChain,
372
+ composerAddress,
373
+ srcEid,
374
+ hubEid,
375
+ amount,
376
+ walletAddress,
377
+ vaultTokenAddress,
378
+ oftAddress,
379
+ operation,
380
+ supportsEip2612,
381
+ requiresZeroApprovalReset,
382
+ } = input;
383
+
384
+ let spender: `0x${string}` | null = null;
385
+ let approvingTokenAddress: `0x${string}` | null = null;
386
+ const srcIsHubChain = srcEid === hubEid;
387
+
388
+ // 1. We are locking the base token on a spoke chain (UnderlyingOFTAdapter)
389
+ // In this case, we need to approve the OFTAdapter to spend the base token.
390
+ if (!srcIsHubChain && operation == OVaultSyncOperations.DEPOSIT) {
391
+ approvingTokenAddress = tokenAddress;
392
+ spender = oftAddress;
393
+ // 2. We are depositing the base token on the hub chain
394
+ // In this case, we need to approve the VaultComposer to spend the base token (depositAndSend).
395
+ } else if (srcIsHubChain && operation == OVaultSyncOperations.DEPOSIT) {
396
+ approvingTokenAddress = tokenAddress;
397
+ spender = composerAddress;
398
+ // 3. We are requesting a withdrawal on a spoke chain (VaultToken)
399
+ // No need, as it's handled by OFT.send in VaultToken
400
+ } else if (!srcIsHubChain && operation == OVaultSyncOperations.REDEEM) {
401
+ return;
402
+ // 4. We are requesting a withdrawal on the hub chain (Vault)
403
+ // In this case, we need to approve the Composer to spend the shares.
404
+ } else if (srcIsHubChain && operation == OVaultSyncOperations.REDEEM) {
405
+ approvingTokenAddress = vaultTokenAddress;
406
+ spender = composerAddress;
407
+ }
408
+
409
+ // If we couldn't determine spender/token, there's nothing to approve
410
+ if (!approvingTokenAddress || !spender) {
411
+ return;
412
+ }
413
+
414
+ const client = createPublicClient({
415
+ chain: sourceChain,
416
+ transport: http(input.sourceRpcUrl),
417
+ });
418
+
419
+ const allowance = await client.readContract({
420
+ address: approvingTokenAddress,
421
+ abi: ERC20Abi,
422
+ functionName: "allowance",
423
+ args: [walletAddress, spender],
424
+ });
425
+
426
+ if (allowance >= amount) {
427
+ // We have enough allowance, so we don't need to approve
428
+ return;
429
+ }
430
+
431
+ // Check if the asset supports EIP-2612 permit
432
+ // Only use permit for deposits with the base token (not vault shares)
433
+ const usePermit =
434
+ operation === OVaultSyncOperations.DEPOSIT && supportsEip2612 === true;
435
+
436
+ if (usePermit) {
437
+ try {
438
+ const [nonceResult, tokenNameResult, tokenVersionResult] =
439
+ await client.multicall({
440
+ contracts: [
441
+ {
442
+ address: approvingTokenAddress as `0x${string}`,
443
+ abi: EIP2612_ABI,
444
+ functionName: "nonces",
445
+ args: [walletAddress],
446
+ },
447
+ {
448
+ address: approvingTokenAddress as `0x${string}`,
449
+ abi: erc20Abi,
450
+ functionName: "name",
451
+ },
452
+ {
453
+ address: approvingTokenAddress as `0x${string}`,
454
+ abi: EIP2612_ABI,
455
+ functionName: "version",
456
+ },
457
+ ],
458
+ });
459
+
460
+ if (
461
+ nonceResult.status !== "success" ||
462
+ tokenNameResult.status !== "success"
463
+ ) {
464
+ throw new Error("Failed to fetch permit data");
465
+ }
466
+
467
+ const tokenVersion =
468
+ tokenVersionResult.status !== "success"
469
+ ? "1"
470
+ : (tokenVersionResult.result as string);
471
+
472
+ return {
473
+ tokenAddress: approvingTokenAddress,
474
+ amount,
475
+ spender,
476
+ usePermit: true,
477
+ nonce: nonceResult.result as bigint,
478
+ tokenName: tokenNameResult.result as string,
479
+ tokenVersion,
480
+ };
481
+ } catch (error) {
482
+ // If nonce fetch fails, fall back to regular approval
483
+ console.warn(
484
+ "Failed to fetch nonce for permit, falling back to approval:",
485
+ error,
486
+ );
487
+ }
488
+ }
489
+
490
+ // Some tokens (like USDT) require approval to be set to 0 before changing to a new value
491
+ // If there's an existing non-zero allowance, we need a two-step approval
492
+ const needsResetApproval =
493
+ requiresZeroApprovalReset && allowance > 0n && allowance < amount;
494
+
495
+ return {
496
+ tokenAddress: approvingTokenAddress,
497
+ amount,
498
+ spender,
499
+ usePermit: false,
500
+ needsResetApproval,
501
+ };
502
+ }
503
+
504
+ /**
505
+ * Resolve a {@link OVaultCoreInputs} into the full
506
+ * {@link GenerateOVaultSyncInputsProps} using the built-in address registry.
507
+ */
508
+ static resolveCoreInputs(
509
+ core: OVaultCoreInputs,
510
+ ): GenerateOVaultSyncInputsProps {
511
+ const {
512
+ sourceChain,
513
+ destinationChain,
514
+ token,
515
+ operation,
516
+ amount,
517
+ walletAddress,
518
+ dstAddress,
519
+ slippage = 0.01,
520
+ buffer = 0.3,
521
+ supportsEip2612 = false,
522
+ hubLzComposeGasLimit,
523
+ referralCode,
524
+ } = core;
525
+
526
+ const cluster = CLUSTERS[token];
527
+ if (!cluster) {
528
+ throw new Error(`Unsupported token: ${token}`);
529
+ }
530
+
531
+ const srcMeta = CHAINS[sourceChain];
532
+ const dstMeta = CHAINS[destinationChain];
533
+ const hubMeta = CHAINS[cluster.hubChain];
534
+
535
+ const srcDeployment = cluster.chains[sourceChain];
536
+ if (!srcDeployment) {
537
+ throw new Error(
538
+ `Token ${token} is not deployed on ${sourceChain}`,
539
+ );
540
+ }
541
+ const hubDeployment = cluster.chains[cluster.hubChain];
542
+
543
+ const srcIsHub = srcMeta.eid === hubMeta.eid;
544
+
545
+ // Token decimals: deposits use the underlying token decimals (always 6
546
+ // for USDC/USDT); redeems on spoke chains use VaultToken decimals (18).
547
+ const tokenLocalDecimals =
548
+ operation === OVaultSyncOperations.DEPOSIT
549
+ ? cluster.hubDecimals
550
+ : srcIsHub
551
+ ? cluster.hubDecimals
552
+ : 18;
553
+
554
+ return {
555
+ srcEid: srcMeta.eid,
556
+ hubEid: hubMeta.eid,
557
+ dstEid: dstMeta.eid,
558
+ walletAddress,
559
+ dstAddress,
560
+
561
+ sourceChain: srcMeta.viemChain,
562
+ hubChain: hubMeta.viemChain,
563
+
564
+ vaultAddress: hubDeployment.vault,
565
+ composerAddress: hubDeployment.composer,
566
+ oftHubAddress: hubDeployment.oft,
567
+ vaultTokenAddress: srcIsHub ? hubDeployment.vault : srcDeployment.vault,
568
+
569
+ oftAddress:
570
+ operation === OVaultSyncOperations.DEPOSIT
571
+ ? srcDeployment.oft
572
+ : srcDeployment.vault,
573
+ tokenAddress:
574
+ operation === OVaultSyncOperations.DEPOSIT
575
+ ? srcDeployment.token
576
+ : srcDeployment.vault,
577
+
578
+ operation,
579
+ amount,
580
+ tokenLocalDecimals,
581
+ tokenHubDecimals: cluster.hubDecimals,
582
+ slippage,
583
+ buffer,
584
+ supportsEip2612,
585
+ requiresZeroApprovalReset: cluster.requiresZeroApprovalReset,
586
+ hubLzComposeGasLimit,
587
+ referralCode,
588
+ hubRpcUrl: core.rpcUrls?.[cluster.hubChain],
589
+ sourceRpcUrl: core.rpcUrls?.[sourceChain],
590
+ };
591
+ }
592
+
593
+ /**
594
+ * Generate the inputs for the OVault.
595
+ *
596
+ * @param input - The input parameters for the OVault.
597
+ * @returns Inputs to call contracts to perform Deposit or Redeem on the OVault.
598
+ */
599
+ static async generateOVaultInputs(
600
+ input: OVaultCoreInputs,
601
+ ): Promise<OVaultSyncInputs> {
602
+ const resolved: GenerateOVaultSyncInputsProps = this.resolveCoreInputs(input as OVaultCoreInputs)
603
+ const {
604
+ srcEid,
605
+ hubEid,
606
+ dstEid,
607
+ operation,
608
+ amount, // amount should be not scaled (string)
609
+ dstAddress,
610
+ walletAddress: refundAddress,
611
+ composerAddress,
612
+ oftAddress, // OFT/OFTAdapter on the source chain
613
+ vaultTokenAddress, // VaultToken address on the source chain
614
+ tokenLocalDecimals,
615
+ } = resolved;
616
+
617
+ // Calculates the minimum amount based on the slippage
618
+ const outputAmount = await this.quoteOVaultOutput(resolved);
619
+
620
+ // Parse the amount
621
+ let amountInLocalDecimals = this.parseUnitsTruncate(
622
+ amount,
623
+ tokenLocalDecimals,
624
+ );
625
+
626
+ // Only remove LayerZero dust if this is a cross-chain operation
627
+ // LayerZero is involved if source or destination is not the hub chain
628
+ const isCrossChain = srcEid !== hubEid || dstEid !== hubEid;
629
+ if (isCrossChain) {
630
+ amountInLocalDecimals = this.removeLzDust(
631
+ amountInLocalDecimals,
632
+ tokenLocalDecimals,
633
+ );
634
+ }
635
+
636
+ if (amountInLocalDecimals === 0n) {
637
+ throw new Error("Amount is too small");
638
+ }
639
+
640
+ const fullInputParams = {
641
+ ...resolved,
642
+ amount: amountInLocalDecimals,
643
+ dstAmount: outputAmount.dstAmount,
644
+ minDstAmount: outputAmount.minDstAmount,
645
+ dstAddress: dstAddress ?? refundAddress,
646
+ };
647
+
648
+ const sendParams = await this.buildSendParams(fullInputParams);
649
+ const messageFee = await this.getMessageFee(sendParams, fullInputParams);
650
+
651
+ const srcIsHubChain = srcEid === hubEid;
652
+
653
+ const approvalData = await this.buildApproval(fullInputParams);
654
+
655
+ if (!srcIsHubChain) {
656
+ // Spoke chain: use 'send' or 'sendWithPermit' for deposits
657
+ const usePermit =
658
+ approvalData?.usePermit && operation === OVaultSyncOperations.DEPOSIT;
659
+
660
+ return {
661
+ messageFee,
662
+ dstAmount: {
663
+ amount: outputAmount.dstAmount,
664
+ minAmount: outputAmount.minDstAmount,
665
+ },
666
+ approval: approvalData,
667
+ txArgs: {
668
+ base: [sendParams, messageFee, refundAddress],
669
+ permitParams: usePermit ? ["deadline", "v", "r", "s"] : null,
670
+ },
671
+ contractFunctionName: usePermit ? "sendWithPermit" : "send",
672
+ contractAddress:
673
+ operation == OVaultSyncOperations.DEPOSIT
674
+ ? oftAddress
675
+ : vaultTokenAddress,
676
+ abi: OFTAbi,
677
+ };
678
+ }
679
+
680
+ // Hub chain: use 'depositAndSend' or 'depositAndSendWithPermit' for deposits
681
+ const usePermit =
682
+ approvalData?.usePermit && operation === OVaultSyncOperations.DEPOSIT;
683
+
684
+ return {
685
+ messageFee,
686
+ dstAmount: {
687
+ amount: outputAmount.dstAmount,
688
+ minAmount: outputAmount.minDstAmount,
689
+ },
690
+ approval: approvalData,
691
+ txArgs: {
692
+ base: [amountInLocalDecimals, sendParams, refundAddress ?? dstAddress],
693
+ permitParams: usePermit ? ["deadline", "v", "r", "s"] : null,
694
+ },
695
+ contractFunctionName:
696
+ operation === OVaultSyncOperations.DEPOSIT
697
+ ? usePermit
698
+ ? "depositAndSendWithPermit"
699
+ : "depositAndSend"
700
+ : "redeemAndSend",
701
+ contractAddress: composerAddress,
702
+ abi: OVaultComposerSyncAbi,
703
+ };
704
+ }
705
+
706
+ /**
707
+ * Encode to avoid ABI-union incompatibility that arises when passing the
708
+ * discriminated-union `OVaultSyncInputs` directly to viem's `writeContract`.
709
+ *
710
+ * @param inputs - The output from `generateOVaultInputs`.
711
+ * @param permitArgs - Optional permit arguments (`[deadline, v, r, s]`) to
712
+ * append when using the EIP-2612 permit flow.
713
+ */
714
+ static encodeTransactionRequest(
715
+ inputs: OVaultSyncInputs,
716
+ permitArgs?: readonly [
717
+ bigint,
718
+ number | bigint,
719
+ `0x${string}`,
720
+ `0x${string}`,
721
+ ],
722
+ ): { to: `0x${string}`; data: `0x${string}`; value: bigint } {
723
+ const args: readonly unknown[] = permitArgs
724
+ ? [...inputs.txArgs.base, ...permitArgs]
725
+ : inputs.txArgs.base;
726
+
727
+ // generateOVaultInputs always produces a consistent abi/functionName/args
728
+ // tuple, but the discriminated union prevents encodeFunctionData from
729
+ // resolving the correlation across variants.
730
+ const data = encodeFunctionData({
731
+ abi: inputs.abi,
732
+ functionName: inputs.contractFunctionName,
733
+ args,
734
+ } as Parameters<typeof encodeFunctionData>[0]);
735
+
736
+ return {
737
+ to: inputs.contractAddress,
738
+ data,
739
+ value: inputs.messageFee.nativeFee,
740
+ };
741
+ }
742
+ }