@oydual31/more-vaults-sdk 0.3.2 → 0.4.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.
Files changed (39) hide show
  1. package/dist/ethers/index.cjs +1794 -315
  2. package/dist/ethers/index.cjs.map +1 -1
  3. package/dist/ethers/index.d.cts +1147 -1
  4. package/dist/ethers/index.d.ts +1147 -1
  5. package/dist/ethers/index.js +1752 -317
  6. package/dist/ethers/index.js.map +1 -1
  7. package/dist/react/index.cjs.map +1 -1
  8. package/dist/react/index.d.cts +1 -1
  9. package/dist/react/index.d.ts +1 -1
  10. package/dist/react/index.js.map +1 -1
  11. package/dist/{spokeRoutes-DK7cIW4z.d.cts → spokeRoutes-BIafSbQ3.d.cts} +13 -2
  12. package/dist/{spokeRoutes-DK7cIW4z.d.ts → spokeRoutes-BIafSbQ3.d.ts} +13 -2
  13. package/dist/viem/index.cjs +34 -28
  14. package/dist/viem/index.cjs.map +1 -1
  15. package/dist/viem/index.d.cts +1 -1
  16. package/dist/viem/index.d.ts +1 -1
  17. package/dist/viem/index.js +34 -29
  18. package/dist/viem/index.js.map +1 -1
  19. package/package.json +1 -1
  20. package/src/ethers/abis.ts +92 -0
  21. package/src/ethers/chains.ts +191 -0
  22. package/src/ethers/crossChainFlows.ts +208 -0
  23. package/src/ethers/curatorMulticall.ts +195 -0
  24. package/src/ethers/curatorStatus.ts +319 -0
  25. package/src/ethers/curatorSwaps.ts +192 -0
  26. package/src/ethers/distribution.ts +156 -0
  27. package/src/ethers/index.ts +96 -1
  28. package/src/ethers/preflight.ts +225 -1
  29. package/src/ethers/redeemFlows.ts +160 -1
  30. package/src/ethers/spokeRoutes.ts +361 -0
  31. package/src/ethers/topology.ts +240 -0
  32. package/src/ethers/types.ts +95 -0
  33. package/src/ethers/userHelpers.ts +193 -0
  34. package/src/ethers/utils.ts +28 -0
  35. package/src/viem/crossChainFlows.ts +12 -22
  36. package/src/viem/index.ts +1 -0
  37. package/src/viem/preflight.ts +3 -9
  38. package/src/viem/redeemFlows.ts +4 -5
  39. package/src/viem/utils.ts +40 -0
@@ -10,6 +10,8 @@ import { BRIDGE_ABI, CONFIG_ABI, ERC20_ABI, VAULT_ABI, METADATA_ABI } from "./ab
10
10
  import type { CrossChainRequestInfo } from "./types";
11
11
  import { getVaultStatus } from "./utils";
12
12
  import type { VaultStatus } from "./utils";
13
+ import { CHAIN_ID_TO_EID, OFT_ROUTES, createChainProvider } from "./chains";
14
+ import { discoverVaultTopology, OMNI_FACTORY_ADDRESS } from "./topology";
13
15
 
14
16
  // Multicall3 — deployed at the same address on every EVM chain
15
17
  const MULTICALL3_ADDRESS = "0xcA11bde05977b3631167028862bE2a173976CA11";
@@ -478,3 +480,194 @@ export async function getVaultSummary(
478
480
  ]);
479
481
  return { ...status, ...metadata };
480
482
  }
483
+
484
+ // ─────────────────────────────────────────────────────────────────────────────
485
+
486
+ /** Minimal ABIs for SHARE_OFT discovery in getUserPositionMultiChain */
487
+ const FACTORY_COMPOSER_ABI_UH = [
488
+ "function vaultComposer(address _vault) view returns (address)",
489
+ ] as const;
490
+
491
+ const COMPOSER_SHARE_OFT_ABI_UH = [
492
+ "function SHARE_OFT() view returns (address)",
493
+ ] as const;
494
+
495
+ const OFT_PEERS_ABI_UH = [
496
+ "function peers(uint32 eid) view returns (bytes32)",
497
+ ] as const;
498
+
499
+ export interface MultiChainUserPosition {
500
+ /** Shares held directly on the hub vault (vault.balanceOf) */
501
+ hubShares: bigint;
502
+ /** Per-spoke SHARE_OFT balances normalized to vault decimals: { [chainId]: bigint } */
503
+ spokeShares: Record<number, bigint>;
504
+ /** Per-spoke SHARE_OFT raw balances in OFT native decimals: { [chainId]: bigint }
505
+ * Use these for bridgeSharesToHub() and quoteShareBridgeFee() */
506
+ rawSpokeShares: Record<number, bigint>;
507
+ /** hubShares + sum of all spokeShares (in vault decimals) */
508
+ totalShares: bigint;
509
+ /** convertToAssets(totalShares) on the hub */
510
+ estimatedAssets: bigint;
511
+ /** Share price: convertToAssets(10^decimals) */
512
+ sharePrice: bigint;
513
+ /** Vault decimals */
514
+ decimals: number;
515
+ /** Pending async withdrawal request on hub, or null */
516
+ pendingWithdrawal: {
517
+ shares: bigint;
518
+ timelockEndsAt: bigint;
519
+ canRedeemNow: boolean;
520
+ } | null;
521
+ }
522
+
523
+ /**
524
+ * Read the user's position across all chains of an omni vault.
525
+ *
526
+ * Discovers topology automatically, reads hub shares + pending withdrawal,
527
+ * then reads SHARE_OFT balances on each spoke chain in parallel.
528
+ *
529
+ * For local (single-chain) vaults, spokeShares will be empty and this
530
+ * behaves identically to getUserPosition.
531
+ *
532
+ * @param vault Vault address (same on all chains via CREATE3)
533
+ * @param user User wallet address
534
+ * @returns Aggregated position across all chains
535
+ */
536
+ export async function getUserPositionMultiChain(
537
+ vault: string,
538
+ user: string,
539
+ ): Promise<MultiChainUserPosition> {
540
+ // Step 1: discover topology
541
+ const topo = await discoverVaultTopology(vault);
542
+ const hubProvider = createChainProvider(topo.hubChainId);
543
+ if (!hubProvider) throw new Error(`No public RPC for hub chainId ${topo.hubChainId}`);
544
+
545
+ // Step 2: read hub data (shares, decimals, withdrawal request)
546
+ const mc = new Contract(MULTICALL3_ADDRESS, MULTICALL3_ABI, hubProvider);
547
+ const vaultIface = new Interface(VAULT_ABI as unknown as string[]);
548
+ const decimalsIface = new Interface(["function decimals() view returns (uint8)"]);
549
+
550
+ const b1Calls = [
551
+ { target: vault, allowFailure: false, callData: vaultIface.encodeFunctionData("balanceOf", [user]) },
552
+ { target: vault, allowFailure: false, callData: decimalsIface.encodeFunctionData("decimals") },
553
+ { target: vault, allowFailure: false, callData: vaultIface.encodeFunctionData("getWithdrawalRequest", [user]) },
554
+ ];
555
+
556
+ const [b1Raw, block] = await Promise.all([
557
+ mc.aggregate3.staticCall(b1Calls) as Promise<{ success: boolean; returnData: string }[]>,
558
+ hubProvider.getBlock("latest"),
559
+ ]);
560
+
561
+ const hubShares = vaultIface.decodeFunctionResult("balanceOf", b1Raw[0].returnData)[0] as bigint;
562
+ const decimals = Number(decimalsIface.decodeFunctionResult("decimals", b1Raw[1].returnData)[0]);
563
+ const withdrawalResult = vaultIface.decodeFunctionResult("getWithdrawalRequest", b1Raw[2].returnData);
564
+ const withdrawShares = withdrawalResult[0] as bigint;
565
+ const timelockEndsAt = withdrawalResult[1] as bigint;
566
+
567
+ // Step 3: resolve SHARE_OFT addresses for spokes (if any)
568
+ const spokeShares: Record<number, bigint> = {};
569
+ const rawSpokeShares: Record<number, bigint> = {};
570
+
571
+ if (topo.spokeChainIds.length > 0) {
572
+ let hubShareOft: string | null = null;
573
+ try {
574
+ const factory = new Contract(OMNI_FACTORY_ADDRESS, FACTORY_COMPOSER_ABI_UH, hubProvider);
575
+ const composerAddress: string = await factory.vaultComposer(vault);
576
+
577
+ if (composerAddress !== "0x0000000000000000000000000000000000000000") {
578
+ const composer = new Contract(composerAddress, COMPOSER_SHARE_OFT_ABI_UH, hubProvider);
579
+ hubShareOft = await composer.SHARE_OFT();
580
+ }
581
+ } catch { /* no composer — skip spoke reads */ }
582
+
583
+ if (hubShareOft) {
584
+ const hubShareOftContract = new Contract(hubShareOft, OFT_PEERS_ABI_UH, hubProvider);
585
+
586
+ const spokePromises = topo.spokeChainIds.map(async (spokeChainId) => {
587
+ try {
588
+ const spokeEid = CHAIN_ID_TO_EID[spokeChainId];
589
+ if (!spokeEid) return { chainId: spokeChainId, balance: 0n, rawBalance: 0n };
590
+
591
+ const spokeOftBytes32: string = await hubShareOftContract.peers(spokeEid);
592
+ const spokeOft = `0x${spokeOftBytes32.slice(-40)}`;
593
+
594
+ if (spokeOft === "0x0000000000000000000000000000000000000000") {
595
+ return { chainId: spokeChainId, balance: 0n, rawBalance: 0n };
596
+ }
597
+
598
+ const spokeProvider = createChainProvider(spokeChainId);
599
+ if (!spokeProvider) return { chainId: spokeChainId, balance: 0n, rawBalance: 0n };
600
+
601
+ // Read balance + decimals on spoke chain via Multicall3
602
+ const spokeMc = new Contract(MULTICALL3_ADDRESS, MULTICALL3_ABI, spokeProvider);
603
+ const erc20Iface = new Interface(ERC20_ABI as unknown as string[]);
604
+ const spokeDecimalsIface = new Interface(["function decimals() view returns (uint8)"]);
605
+
606
+ const spokeCalls = [
607
+ { target: spokeOft, allowFailure: false, callData: erc20Iface.encodeFunctionData("balanceOf", [user]) },
608
+ { target: spokeOft, allowFailure: false, callData: spokeDecimalsIface.encodeFunctionData("decimals") },
609
+ ];
610
+ const spokeRaw: { success: boolean; returnData: string }[] =
611
+ await spokeMc.aggregate3.staticCall(spokeCalls);
612
+
613
+ const rawBalance = erc20Iface.decodeFunctionResult("balanceOf", spokeRaw[0].returnData)[0] as bigint;
614
+ const spokeOftDecimals = Number(spokeDecimalsIface.decodeFunctionResult("decimals", spokeRaw[1].returnData)[0]);
615
+
616
+ // Normalize to vault decimals
617
+ let balance: bigint;
618
+ if (spokeOftDecimals > decimals) {
619
+ balance = rawBalance / (10n ** BigInt(spokeOftDecimals - decimals));
620
+ } else if (spokeOftDecimals < decimals) {
621
+ balance = rawBalance * (10n ** BigInt(decimals - spokeOftDecimals));
622
+ } else {
623
+ balance = rawBalance;
624
+ }
625
+
626
+ return { chainId: spokeChainId, balance, rawBalance };
627
+ } catch {
628
+ return { chainId: spokeChainId, balance: 0n, rawBalance: 0n };
629
+ }
630
+ });
631
+
632
+ const results = await Promise.all(spokePromises);
633
+ for (const { chainId, balance, rawBalance } of results) {
634
+ spokeShares[chainId] = balance;
635
+ rawSpokeShares[chainId] = rawBalance;
636
+ }
637
+ }
638
+ }
639
+
640
+ // Step 4: compute totals
641
+ const totalSpokeShares = Object.values(spokeShares).reduce((sum, b) => sum + b, 0n);
642
+ const totalShares = hubShares + totalSpokeShares;
643
+
644
+ const oneShare = 10n ** BigInt(decimals);
645
+ const vaultContract = new Contract(vault, VAULT_ABI, hubProvider);
646
+ const [estimatedAssets, sharePrice]: [bigint, bigint] = await Promise.all([
647
+ totalShares === 0n
648
+ ? Promise.resolve(0n)
649
+ : (vaultContract.convertToAssets(totalShares) as Promise<bigint>),
650
+ vaultContract.convertToAssets(oneShare) as Promise<bigint>,
651
+ ]);
652
+
653
+ // Step 5: pending withdrawal
654
+ const currentTimestamp = BigInt(block?.timestamp ?? 0);
655
+ const pendingWithdrawal = withdrawShares === 0n
656
+ ? null
657
+ : {
658
+ shares: withdrawShares,
659
+ timelockEndsAt,
660
+ canRedeemNow: timelockEndsAt === 0n || currentTimestamp >= timelockEndsAt,
661
+ };
662
+
663
+ return {
664
+ hubShares,
665
+ spokeShares,
666
+ rawSpokeShares,
667
+ totalShares,
668
+ estimatedAssets,
669
+ sharePrice,
670
+ decimals,
671
+ pendingWithdrawal,
672
+ };
673
+ }
@@ -8,6 +8,11 @@ import { Contract, Interface, ZeroAddress } from "ethers";
8
8
  import type { Provider, Signer } from "ethers";
9
9
  import { BRIDGE_ABI, CONFIG_ABI, ERC20_ABI, VAULT_ABI } from "./abis";
10
10
 
11
+ // Minimal ABI for Stargate type detection
12
+ const STARGATE_TYPE_ABI = [
13
+ "function stargateType() view returns (uint8)",
14
+ ] as const;
15
+
11
16
  // Multicall3 — deployed at the same address on every EVM chain
12
17
  const MULTICALL3_ADDRESS = "0xcA11bde05977b3631167028862bE2a173976CA11";
13
18
  const MULTICALL3_ABI = [
@@ -379,3 +384,26 @@ export async function getVaultStatus(
379
384
  issues,
380
385
  };
381
386
  }
387
+
388
+ /**
389
+ * Detect whether an OFT address is a Stargate V2 pool by calling `stargateType()`.
390
+ *
391
+ * Stargate pools implement this function (returns 0=Pool, 1=OFT).
392
+ * Standard OFTs revert because they don't have it.
393
+ *
394
+ * @param provider Read-only provider on the OFT's chain
395
+ * @param oft OFT contract address
396
+ * @returns true if the contract is a Stargate V2 pool/OFT
397
+ */
398
+ export async function detectStargateOft(
399
+ provider: Provider,
400
+ oft: string
401
+ ): Promise<boolean> {
402
+ try {
403
+ const contract = new Contract(oft, STARGATE_TYPE_ABI, provider);
404
+ await contract.stargateType();
405
+ return true;
406
+ } catch {
407
+ return false;
408
+ }
409
+ }
@@ -10,7 +10,7 @@ import {
10
10
  } from 'viem'
11
11
  import { OFT_ABI, BRIDGE_ABI, LZ_ENDPOINT_ABI } from './abis'
12
12
  import type { ComposeData, SpokeDepositResult } from './types'
13
- import { ensureAllowance } from './utils'
13
+ import { ensureAllowance, detectStargateOft } from './utils'
14
14
  import { OFT_ROUTES, EID_TO_CHAIN_ID } from './chains'
15
15
  import { OMNI_FACTORY_ADDRESS } from './topology'
16
16
  import { createChainClient } from './spokeRoutes'
@@ -38,8 +38,6 @@ const COMPOSER_ABI = [
38
38
  },
39
39
  ] as const
40
40
 
41
- const STARGATE_ASSETS = new Set(['stgUSDC', 'USDT', 'WETH'])
42
-
43
41
  /**
44
42
  * Build a LZ V2 TYPE_3 executor option that forwards native ETH to the lzCompose call.
45
43
  *
@@ -61,17 +59,6 @@ function buildLzComposeOption(gas: bigint, nativeValue: bigint): `0x${string}` {
61
59
  return `0x00030300220000${gasHex}${valueHex}` as `0x${string}`
62
60
  }
63
61
 
64
- /** Returns true if the OFT is a Stargate V2 pool (bus/taxi architecture). */
65
- function isStargateOft(oft: Address): boolean {
66
- for (const [symbol, chainMap] of Object.entries(OFT_ROUTES)) {
67
- if (!STARGATE_ASSETS.has(symbol)) continue
68
- for (const entry of Object.values(chainMap as Record<number, { oft: string; token: string }>)) {
69
- if (getAddress(entry.oft) === oft) return true
70
- }
71
- }
72
- return false
73
- }
74
-
75
62
  /**
76
63
  * Resolve the native ETH value that MoreVaultsComposer needs to receive via lzCompose.
77
64
  *
@@ -263,7 +250,7 @@ export async function depositFromSpoke(
263
250
 
264
251
  // For Stargate OFTs: extraOptions must be '0x' (rejects LZCOMPOSE type-3 options).
265
252
  // For standard OFTs: inject LZCOMPOSE option with native ETH for readFee + share send.
266
- const isStargate = isStargateOft(oft)
253
+ const isStargate = await detectStargateOft(publicClient, oft)
267
254
  let resolvedExtraOptions: `0x${string}`
268
255
  if (extraOptions !== '0x') {
269
256
  resolvedExtraOptions = extraOptions
@@ -377,7 +364,7 @@ export async function depositFromSpoke(
377
364
  // The compose message is NOT available yet — it's emitted as ComposeSent on the hub
378
365
  // after LZ delivers the message. The user must call waitForCompose() to get it,
379
366
  // then executeCompose() to execute it.
380
- const stargate = isStargateOft(getAddress(spokeOFT))
367
+ const stargate = isStargate
381
368
  let composeData: ComposeData | undefined
382
369
  if (stargate) {
383
370
  // Snapshot current hub block BEFORE waiting — this is exactly where we start
@@ -454,7 +441,7 @@ export async function quoteDepositFromSpokeFee(
454
441
  const composerBytes32 = pad(composerAddress, { size: 32 })
455
442
 
456
443
  // Match depositFromSpoke: resolve extraOptions the same way
457
- const isStargate = isStargateOft(oft)
444
+ const isStargate = await detectStargateOft(publicClient, oft)
458
445
  let resolvedExtraOptions: `0x${string}`
459
446
  if (extraOptions !== '0x') {
460
447
  resolvedExtraOptions = extraOptions
@@ -560,14 +547,17 @@ export async function waitForCompose(
560
547
  const receiverNeedle = getAddress(receiver).slice(2).toLowerCase()
561
548
  const startBlock = composeData.hubBlockStart
562
549
 
563
- // Known Stargate pool addresses on hub for composeQueue checks
564
- const knownFromAddresses: Address[] = []
550
+ // Collect all OFT addresses on the hub chain, then filter to Stargate pools on-chain
565
551
  const hubChainId = composeData.hubChainId
566
- for (const [symbol, chainMap] of Object.entries(OFT_ROUTES)) {
567
- if (!STARGATE_ASSETS.has(symbol)) continue
552
+ const candidateAddresses: Address[] = []
553
+ for (const chainMap of Object.values(OFT_ROUTES)) {
568
554
  const entry = (chainMap as Record<number, { oft: string; token: string }>)[hubChainId]
569
- if (entry) knownFromAddresses.push(getAddress(entry.oft) as Address)
555
+ if (entry) candidateAddresses.push(getAddress(entry.oft) as Address)
570
556
  }
557
+ const stargateChecks = await Promise.all(
558
+ candidateAddresses.map(async (addr) => ({ addr, isSg: await detectStargateOft(hubPublicClient, addr) })),
559
+ )
560
+ const knownFromAddresses = stargateChecks.filter((c) => c.isSg).map((c) => c.addr)
571
561
 
572
562
  let attempt = 0
573
563
  // Track the highest block we've already scanned to avoid re-scanning
package/src/viem/index.ts CHANGED
@@ -106,6 +106,7 @@ export {
106
106
  getAsyncRequestStatus,
107
107
  waitForAsyncRequest,
108
108
  getVaultStatus,
109
+ detectStargateOft,
109
110
  } from './utils'
110
111
  export type { VaultStatus, VaultMode, AsyncRequestFinalResult } from './utils'
111
112
 
@@ -11,7 +11,8 @@ import { CONFIG_ABI, BRIDGE_ABI, VAULT_ABI, ERC20_ABI, OFT_ABI } from './abis'
11
11
  import { InsufficientLiquidityError } from './errors'
12
12
  import { quoteComposeFee } from './crossChainFlows'
13
13
  import { createChainClient } from './spokeRoutes'
14
- import { EID_TO_CHAIN_ID, OFT_ROUTES } from './chains'
14
+ import { EID_TO_CHAIN_ID } from './chains'
15
+ import { detectStargateOft } from './utils'
15
16
 
16
17
  /**
17
18
  * Pre-flight checks for async cross-chain flows (D4 / D5 / R5).
@@ -305,14 +306,7 @@ export async function preflightSpokeDeposit(
305
306
  }
306
307
 
307
308
  // 3. For Stargate OFTs: check ETH on hub for TX2 (compose retry)
308
- const STARGATE_ASSETS = new Set(['stgUSDC', 'USDT', 'WETH'])
309
- let isStargate = false
310
- for (const [symbol, chainMap] of Object.entries(OFT_ROUTES)) {
311
- if (!STARGATE_ASSETS.has(symbol)) continue
312
- for (const entry of Object.values(chainMap as Record<number, { oft: string; token: string }>)) {
313
- if (getAddress(entry.oft) === oft) isStargate = true
314
- }
315
- }
309
+ const isStargate = await detectStargateOft(spokePublicClient, oft)
316
310
 
317
311
  let hubNativeBalance = 0n
318
312
  let estimatedComposeFee = 0n
@@ -15,7 +15,7 @@ import type {
15
15
  AsyncRequestResult,
16
16
  } from './types'
17
17
  import { ActionType } from './types'
18
- import { ensureAllowance, getVaultStatus, quoteLzFee } from './utils'
18
+ import { ensureAllowance, getVaultStatus, quoteLzFee, detectStargateOft } from './utils'
19
19
  import { preflightAsync, preflightRedeemLiquidity } from './preflight'
20
20
  import { EscrowNotConfiguredError } from './errors'
21
21
  import { validateWalletChain } from './chainValidation'
@@ -581,8 +581,6 @@ export interface SpokeRedeemRoute {
581
581
  symbol: string
582
582
  }
583
583
 
584
- const STARGATE_ASSETS = new Set(['stgUSDC', 'USDT', 'WETH'])
585
-
586
584
  const FACTORY_COMPOSER_ABI = [
587
585
  {
588
586
  type: 'function' as const,
@@ -664,7 +662,6 @@ export async function resolveRedeemAddresses(
664
662
  // Find matching OFT route for the vault's asset on the hub chain
665
663
  let hubAssetOft: Address | null = null
666
664
  let spokeAsset: Address | null = null
667
- let isStargate = false
668
665
  let symbol = ''
669
666
 
670
667
  for (const [sym, chainMap] of Object.entries(OFT_ROUTES)) {
@@ -675,7 +672,6 @@ export async function resolveRedeemAddresses(
675
672
  if (getAddress(hubEntry.token) === getAddress(hubAsset)) {
676
673
  hubAssetOft = getAddress(hubEntry.oft) as Address
677
674
  spokeAsset = getAddress(spokeEntry.token) as Address
678
- isStargate = STARGATE_ASSETS.has(sym)
679
675
  symbol = sym
680
676
  break
681
677
  }
@@ -688,6 +684,9 @@ export async function resolveRedeemAddresses(
688
684
  )
689
685
  }
690
686
 
687
+ // On-chain detection: Stargate pools implement stargateType(), standard OFTs revert
688
+ const isStargate = await detectStargateOft(hubPublicClient, hubAssetOft)
689
+
691
690
  return {
692
691
  hubChainId,
693
692
  spokeChainId,
package/src/viem/utils.ts CHANGED
@@ -522,3 +522,43 @@ export async function waitForAsyncRequest(
522
522
  `The request may still complete — check https://layerzeroscan.com/tx/${guid}`,
523
523
  )
524
524
  }
525
+
526
+ // ─────────────────────────────────────────────────────────────────────────────
527
+ // Stargate detection — on-chain probe
528
+ // ─────────────────────────────────────────────────────────────────────────────
529
+
530
+ const STARGATE_TYPE_ABI = [
531
+ {
532
+ type: 'function' as const,
533
+ name: 'stargateType' as const,
534
+ inputs: [] as const,
535
+ outputs: [{ type: 'uint8' as const }] as const,
536
+ stateMutability: 'view' as const,
537
+ },
538
+ ] as const
539
+
540
+ /**
541
+ * Detect whether an OFT address is a Stargate V2 pool by calling `stargateType()`.
542
+ *
543
+ * Stargate pools implement this function (returns 0=Pool, 1=OFT).
544
+ * Standard OFTs revert because they don't have it.
545
+ *
546
+ * @param publicClient Viem public client on the OFT's chain
547
+ * @param oft OFT contract address
548
+ * @returns true if the contract is a Stargate V2 pool/OFT
549
+ */
550
+ export async function detectStargateOft(
551
+ publicClient: PublicClient,
552
+ oft: Address,
553
+ ): Promise<boolean> {
554
+ try {
555
+ await publicClient.readContract({
556
+ address: getAddress(oft),
557
+ abi: STARGATE_TYPE_ABI,
558
+ functionName: 'stargateType',
559
+ })
560
+ return true
561
+ } catch {
562
+ return false
563
+ }
564
+ }