diff --git a/.env b/.env index 5f424cb4679..8924a93d47d 100644 --- a/.env +++ b/.env @@ -6,6 +6,7 @@ VITE_FEATURE_POLYGON=true VITE_FEATURE_GNOSIS=true VITE_FEATURE_ARBITRUM=true VITE_FEATURE_SOLANA=true +VITE_FEATURE_STARGATE_SWAP=true VITE_FEATURE_STARKNET=true VITE_FEATURE_SUI=true VITE_FEATURE_MAYACHAIN=true diff --git a/.env.development b/.env.development index 48d2f40d91b..3efb00a6079 100644 --- a/.env.development +++ b/.env.development @@ -112,3 +112,4 @@ VITE_PROXY_API_BASE_URL=https://dev-api.proxy.shapeshift.com # Agentic Chat # VITE_AGENTIC_SERVER_BASE_URL=http://localhost:4111 +VITE_FEATURE_STARGATE_SWAP=true diff --git a/.env.production b/.env.production index a8162c25ac2..97307880122 100644 --- a/.env.production +++ b/.env.production @@ -6,6 +6,7 @@ VITE_FEATURE_THORCHAIN_TCY_ACTIVITY=false VITE_FEATURE_AGENTIC_CHAT=false VITE_FEATURE_FLOWEVM=false VITE_FEATURE_CELO=false +VITE_FEATURE_STARGATE_SWAP=false # mixpanel VITE_MIXPANEL_TOKEN=9d304465fc72224aead9e027e7c24356 diff --git a/e2e/fixtures/stargate-usdc-eth-to-arb.yaml b/e2e/fixtures/stargate-usdc-eth-to-arb.yaml new file mode 100644 index 00000000000..fdd09368584 --- /dev/null +++ b/e2e/fixtures/stargate-usdc-eth-to-arb.yaml @@ -0,0 +1,102 @@ +name: Stargate USDC Ethereum → Arbitrum +description: > + Cross-chain USDC swap via Stargate V2 swapper: sell USDC on Ethereum mainnet, + receive USDC on Arbitrum One. Verifies that Stargate appears as a quote source, + that slippage is respected, and that the transaction builds and signs correctly. +route: /trade +depends_on: + - wallet-health.yaml + +steps: + - name: Dismiss stale notifications + instruction: > + Dismiss any lingering notifications, toasts, or feedback dialogs from + previous tests (close buttons, "Maybe Later", etc.). + expected: Clean trade page with no overlays + screenshot: true + + - name: Select USDC (Ethereum) as sell asset + instruction: > + Click the sell asset selector. Search for "USDC". From the results, + select "USD Coin (USDC)" on the Ethereum chain (not Arbitrum, not Base). + Verify USDC Ethereum is selected as the sell asset. + expected: USDC on Ethereum mainnet is the sell asset + screenshot: true + + - name: Select USDC (Arbitrum) as buy asset + instruction: > + Click the buy/receive asset selector. Search for "USDC". From the results, + expand USDC if needed and select "USD Coin (USDC)" on the Arbitrum One chain. + Verify USDC on Arbitrum is selected as the buy asset. + expected: USDC on Arbitrum One is the buy asset + screenshot: true + + - name: Toggle to fiat input mode + instruction: > + Click the "≈ $0.00" button below the sell amount to toggle to fiat/USD input mode. + If already in fiat mode (placeholder shows "$0"), skip this step. + expected: Sell input is in fiat/USD mode + screenshot: false + + - name: Enter swap amount ($1) + instruction: > + Click the sell amount input and type "1" character by character using press + (NOT fill). Wait for the value to register. + expected: $1.00 entered as sell amount + screenshot: true + + - name: Wait for Stargate quote + instruction: > + Wait up to 15 seconds for a quote to appear. Verify that: + 1. The "You Get" field shows a USDC amount close to $1 (slightly less due to fees) + 2. Stargate appears as the swapper source (look for "Stargate" label) + 3. The "Preview Trade" button is enabled + If multiple swappers are shown, check that Stargate is one of them. + expected: > + Quote shown with a USDC receive amount slightly less than sell amount, + Stargate visible as source, Preview Trade button enabled + screenshot: true + + - name: Verify protocol fee is displayed + instruction: > + In the quote view, look for a fee breakdown section. Verify that a protocol fee + is shown (the Stargate OFT fee, typically 0.5-1% of the swap amount). + Note the fee amount shown. + expected: Protocol fee visible in the quote breakdown + screenshot: true + + - name: Preview trade + instruction: > + Click the "Preview Trade" button. Wait for the "Confirm Details" screen to appear + showing: sell amount (USDC Ethereum), receive amount (USDC Arbitrum), swapper name + (Stargate), estimated fees, and a "Confirm and Trade" button. + expected: > + Confirm Details screen visible with cross-chain summary: + sell = USDC on Ethereum, receive = USDC on Arbitrum, swapper = Stargate + screenshot: true + + - name: Confirm and sign + instruction: > + Click the "Confirm and Trade" button. Wait up to 30 seconds for the + "Sign & Swap" button to become enabled (not loading/disabled). + Then click "Sign & Swap". The native wallet signs automatically. + expected: Transaction submitted, cross-chain swap in progress + screenshot: true + + - name: Wait for Stargate bridge completion + instruction: > + Cross-chain Stargate bridges take 1-3 minutes (LayerZero messaging). + Wait up to 300 seconds for the swap to complete. Check every 10 seconds. + Look for: trade page reappearing, success notification, Arbitrum USDC + balance increased, or "Awaiting swap" disappearing. + A feedback dialog may appear — dismiss it with "Maybe Later". + expected: > + Swap completed — back on trade page, USDC balance on Arbitrum increased, + no "Awaiting swap" visible + screenshot: true + + - name: Clean up notifications + instruction: > + Dismiss any remaining notifications, feedback dialogs, or toasts. + expected: Clean trade page, ready for next test + screenshot: true diff --git a/headers/csps/defi/swappers/Stargate.ts b/headers/csps/defi/swappers/Stargate.ts new file mode 100644 index 00000000000..8df257ecf64 --- /dev/null +++ b/headers/csps/defi/swappers/Stargate.ts @@ -0,0 +1,5 @@ +import type { Csp } from '../../../types' + +export const csp: Csp = { + 'connect-src': ['https://api-mainnet.layerzero-scan.com'], +} diff --git a/headers/csps/index.ts b/headers/csps/index.ts index 93352adb685..a01c8e569d1 100644 --- a/headers/csps/index.ts +++ b/headers/csps/index.ts @@ -67,6 +67,7 @@ import { csp as cowSwap } from './defi/swappers/CowSwap' import { csp as nearIntents } from './defi/swappers/NearIntents' import { csp as oneInch } from './defi/swappers/OneInch' import { csp as portals } from './defi/swappers/Portals' +import { csp as stargate } from './defi/swappers/Stargate' import { csp as stonfi } from './defi/swappers/Stonfi' import { csp as sunio } from './defi/swappers/Sunio' import { csp as thor } from './defi/swappers/Thor' @@ -193,6 +194,7 @@ export const csps = [ sunio, thor, butterSwap, + stargate, foxPage, walletConnectToDapps, coinbase, diff --git a/packages/swapper/src/constants.ts b/packages/swapper/src/constants.ts index 40f5536380d..6084787e855 100644 --- a/packages/swapper/src/constants.ts +++ b/packages/swapper/src/constants.ts @@ -26,6 +26,8 @@ import { portalsApi } from './swappers/PortalsSwapper/endpoints' import { portalsSwapper } from './swappers/PortalsSwapper/PortalsSwapper' import { relaySwapper } from './swappers/RelaySwapper' import { relayApi } from './swappers/RelaySwapper/endpoints' +import { stargateApi } from './swappers/StargateSwapper/endpoints' +import { stargateSwapper } from './swappers/StargateSwapper/StargateSwapper' import { stonfiApi } from './swappers/StonfiSwapper/endpoints' import { stonfiSwapper } from './swappers/StonfiSwapper/StonfiSwapper' import { sunioApi } from './swappers/SunioSwapper/endpoints' @@ -116,6 +118,10 @@ export const swappers: Record = ...debridgeSwapper, ...debridgeApi, }, + [SwapperName.Stargate]: { + ...stargateSwapper, + ...stargateApi, + }, [SwapperName.Test]: undefined, } @@ -135,6 +141,7 @@ const DEFAULT_AVNU_SLIPPAGE_DECIMAL_PERCENTAGE = '0.02' const DEFAULT_STONFI_SLIPPAGE_DECIMAL_PERCENTAGE = '0.01' // deBridge API off-chain simulation overestimates output on some chains (e.g. SEI ~2.4%), so auto slippage (1%) is insufficient const DEFAULT_DEBRIDGE_SLIPPAGE_DECIMAL_PERCENTAGE = '0.03' +const DEFAULT_STARGATE_SLIPPAGE_DECIMAL_PERCENTAGE = '0.005' export const getDefaultSlippageDecimalPercentageForSwapper = ( swapperName: SwapperName | undefined, @@ -175,6 +182,8 @@ export const getDefaultSlippageDecimalPercentageForSwapper = ( return DEFAULT_AVNU_SLIPPAGE_DECIMAL_PERCENTAGE case SwapperName.Stonfi: return DEFAULT_STONFI_SLIPPAGE_DECIMAL_PERCENTAGE + case SwapperName.Stargate: + return DEFAULT_STARGATE_SLIPPAGE_DECIMAL_PERCENTAGE default: return assertUnreachable(swapperName) } @@ -186,6 +195,8 @@ export const isAutoSlippageSupportedBySwapper = (swapperName: SwapperName): bool case SwapperName.Across: case SwapperName.Debridge: return true + case SwapperName.Stargate: + return false default: return false } diff --git a/packages/swapper/src/index.ts b/packages/swapper/src/index.ts index 33e507c1c0b..ab775ae4a6a 100644 --- a/packages/swapper/src/index.ts +++ b/packages/swapper/src/index.ts @@ -11,6 +11,7 @@ export * from './swappers/SunioSwapper' export * from './swappers/CowSwapper' export * from './swappers/DebridgeSwapper' export * from './swappers/PortalsSwapper' +export * from './swappers/StargateSwapper' export * from './swappers/ThorchainSwapper' export * from './swappers/MayachainSwapper' export * from './swappers/ButterSwap' diff --git a/packages/swapper/src/swappers/StargateSwapper/StargateSwapper.ts b/packages/swapper/src/swappers/StargateSwapper/StargateSwapper.ts new file mode 100644 index 00000000000..9b4fa914f86 --- /dev/null +++ b/packages/swapper/src/swappers/StargateSwapper/StargateSwapper.ts @@ -0,0 +1,6 @@ +import type { Swapper } from '../../types' +import { executeEvmTransaction } from '../../utils' + +export const stargateSwapper: Swapper = { + executeEvmTransaction, +} diff --git a/packages/swapper/src/swappers/StargateSwapper/constant.ts b/packages/swapper/src/swappers/StargateSwapper/constant.ts new file mode 100644 index 00000000000..479202841ca --- /dev/null +++ b/packages/swapper/src/swappers/StargateSwapper/constant.ts @@ -0,0 +1,107 @@ +import type { ChainId } from '@shapeshiftoss/caip' +import { + arbitrumChainId, + avalancheChainId, + baseChainId, + blastChainId, + bscChainId, + ethChainId, + gnosisChainId, + lineaChainId, + mantleChainId, + optimismChainId, + polygonChainId, + scrollChainId, + sonicChainId, +} from '@shapeshiftoss/caip' + +const metisChainId: ChainId = 'eip155:1088' as ChainId + +export const chainIdToStargateEndpointId = { + [ethChainId]: 30101, + [arbitrumChainId]: 30110, + [optimismChainId]: 30111, + [baseChainId]: 30184, + [polygonChainId]: 30109, + [bscChainId]: 30102, + [avalancheChainId]: 30106, + [scrollChainId]: 30214, + [lineaChainId]: 30183, + [mantleChainId]: 30181, + [gnosisChainId]: 30145, + [metisChainId]: 30151, + [sonicChainId]: 30332, + [blastChainId]: 30243, +} as const satisfies Record + +export const STARGATE_SUPPORTED_CHAIN_IDS: ChainId[] = Object.keys( + chainIdToStargateEndpointId, +) as ChainId[] + +export const stargateContractsByChainAndAsset: Record> = { + [ethChainId]: { + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': '0xc026395860Db2d07ee33e05fE50ed7bD583189C7', + '0xdac17f958d2ee523a2206206994597c13d831ec7': '0x933597a323Eb81cAe705C5bC29985172fd5A3973', + '0x0000000000000000000000000000000000000000': '0x77b2043768d28E9C9aB44E1aBfC95944bcE57931', + '0x9e32b13ce7f2e80a01932b42553652e053d6ed8e': '0xcDafB1b2dB43f366E48e6F614b8DCCBFeeFEEcD3', + '0xd5f7838f5c461feff7fe49ea5ebaf7728bb0adfa': '0x268Ca24DAefF1FaC2ed883c598200CcbB79E931D', + '0x1abaea1f7c830bd89acc67ec4af516284b1bc33c': '0x783129E4d7bA0Af0C896c239E57C06DF379aAE8c', + }, + [arbitrumChainId]: { + '0xaf88d065e77c8cc2239327c5edb3a432268e5831': '0xe8CDF27AcD73a434D661C84887215F7598e7d0d3', + '0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9': '0xcE8CcA271Ebc0533920C83d39F417ED6A0abB7D0', + '0x0000000000000000000000000000000000000000': '0xA45B5130f36CDcA45667738e2a258AB09f4A5f7F', + }, + [optimismChainId]: { + '0x0b2c639c533813f4aa9d7837caf62653d097ff85': '0xcE8CcA271Ebc0533920C83d39F417ED6A0abB7D0', + '0x94b008aa00579c1307b0ef2c499ad98a8ce58e58': '0x19cFCE47eD54a88614648DC3f19A5980097007dD', + '0x0000000000000000000000000000000000000000': '0xe8CDF27AcD73a434D661C84887215F7598e7d0d3', + }, + [baseChainId]: { + '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913': '0x27a16dc786820B16E5c9028b75B99F6f604b5d26', + '0x0000000000000000000000000000000000000000': '0xdc181Bd607330aeeBEF6ea62e03e5e1Fb4B6F7C7', + }, + [polygonChainId]: { + '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359': '0x9Aa02D4Fae7F58b8E8f34c66E756cC734DAc7fe4', + '0xc2132d05d31c914a87c6611c10748aeb04b58e8f': '0xd47b03ee6d86Cf251ee7860FB2ACf9f91B9fD4d7', + }, + [bscChainId]: { + '0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d': '0x962Bd449E630b0d928f308Ce63f1A21F02576057', + '0x55d398326f99059ff775485246999027b3197955': '0x138EB30f73BC423c6455C53df6D89CB01d9eBc63', + }, + [avalancheChainId]: { + '0xb97ef9ef8734c71904d8002f8b6bc66dd9c48a6e': '0x5634c4a5FEd09819E3c46D86A965Dd9447d86e47', + '0x9702230a8ea53601f5cd2dc00fdbc13d4df4a8c7': '0x12dC9256Acc9895B076f6638D628382881e62CeE', + }, + [scrollChainId]: { + '0x06efdbff2a14a7c8e15944d1f4a48f9f95f663a4': '0x3Fc69CC4A842838bCDC9499178740226062b14E4', + '0x0000000000000000000000000000000000000000': '0xC2b638Cb5042c1B3c5d5C969361fB50569840583', + }, + [lineaChainId]: { + '0x0000000000000000000000000000000000000000': '0x81F6138153d473E8c5EcebD3DC8Cd4903506B075', + }, + [mantleChainId]: { + '0x09bc4e0d864854c6afb6eb9a9cdf58ac190d0df9': '0xAc290Ad4e0c891FDc295ca4F0a6214cf6dC6acDC', + '0x201eba5cc46d216ce6dc03f6a759e8e766e956ae': '0xB715B85682B731dB9D5063187C450095c91C57FC', + '0xdeaddeaddeaddeaddeaddeaddeaddeaddead1111': '0x4c1d3Fc3fC3c177c3b633427c2F769276c547463', + '0xcda86a272531e8640cd7f1a92c01839911b90bb0': '0xF7628d84a2BbD9bb9c8E686AC95BB5d55169F3F1', + }, + [gnosisChainId]: { + '0x2a22f9c3b484c3629090feed35f17ff8f88f76f0': '0xB1EeAD6959cb5bB9B20417d6689922523B2B86C3', + '0x6a023ccd1ff6f2045c3309768ead9e68f978f6e1': '0xe9aBA835f813ca05E50A6C0ce65D0D74390F7dE7', + }, + [metisChainId]: { + '0xbb06dca3ae6887fabf931640f67cab3e3a16f4dc': '0x4dCBFC0249e8d5032F89D6461218a9D2eFff5125', + '0x420000000000000000000000000000000000000a': '0x36ed193dc7160D3858EC250e69D12B03Ca087D08', + '0xdeaddeaddeaddeaddeaddeaddeaddeaddead0000': '0xD9050e7043102a0391F81462a3916326F86331F0', + }, + [sonicChainId]: { + '0x29219dd400f2bf60e5a23d13be72b486d4038894': '0xA272fFe20cFfe769CdFc4b63088DCD2C82a2D8F9', + }, +} + +export const STARGATE_NATIVE_ASSET_ADDRESS = '0x0000000000000000000000000000000000000000' + +export const DEFAULT_STARGATE_USER_ADDRESS = '0x0000000000000000000000000000000000000dead' + +export const DEFAULT_STARGATE_GAS_LIMIT = '500000' diff --git a/packages/swapper/src/swappers/StargateSwapper/endpoints.ts b/packages/swapper/src/swappers/StargateSwapper/endpoints.ts new file mode 100644 index 00000000000..d7a3c1aab71 --- /dev/null +++ b/packages/swapper/src/swappers/StargateSwapper/endpoints.ts @@ -0,0 +1,149 @@ +import { evm, isEvmChainId } from '@shapeshiftoss/chain-adapters' +import { TxStatus } from '@shapeshiftoss/unchained-client' +import BigNumber from 'bignumber.js' + +import type { SwapperApi } from '../../types' +import { checkEvmSwapStatus, getExecutableTradeStep, isExecutableTradeQuote } from '../../utils' +import { getTradeQuote } from './getTradeQuote/getTradeQuote' +import { getTradeRate } from './getTradeRate/getTradeRate' +import { stargateService } from './utils/stargateService' + +type LayerZeroMessageStatus = 'INFLIGHT' | 'CONFIRMING' | 'DELIVERED' | 'FAILED' + +type LayerZeroMessage = { + status: LayerZeroMessageStatus + dstTxHash: string | undefined +} + +type LayerZeroScanResponse = { + messages: LayerZeroMessage[] +} + +export const stargateApi: SwapperApi = { + getTradeQuote: (input, deps) => getTradeQuote(input, deps), + getTradeRate: (input, deps) => getTradeRate(input, deps), + getEvmTransactionFees: async ({ + from, + stepIndex, + tradeQuote, + supportsEIP1559, + assertGetEvmChainAdapter, + }) => { + if (!isExecutableTradeQuote(tradeQuote)) throw new Error('Unable to execute a trade rate quote') + + const step = getExecutableTradeStep(tradeQuote, stepIndex) + + const { stargateTransactionMetadata, sellAsset } = step + if (!stargateTransactionMetadata) throw new Error('Missing Stargate transaction metadata') + + const { to, value, data } = stargateTransactionMetadata + + const adapter = assertGetEvmChainAdapter(sellAsset.chainId) + + const feeData = await evm.getFees({ adapter, data, to, value, from, supportsEIP1559 }) + + return feeData.networkFeeCryptoBaseUnit + }, + getUnsignedEvmTransaction: async ({ + from, + stepIndex, + tradeQuote, + supportsEIP1559, + assertGetEvmChainAdapter, + }) => { + if (!isExecutableTradeQuote(tradeQuote)) throw new Error('Unable to execute a trade rate quote') + + const step = getExecutableTradeStep(tradeQuote, stepIndex) + + const { accountNumber, stargateTransactionMetadata, sellAsset } = step + if (!stargateTransactionMetadata) throw new Error('Missing Stargate transaction metadata') + + const { to, value, data, gasLimit: gasLimitFromApi } = stargateTransactionMetadata + + const adapter = assertGetEvmChainAdapter(sellAsset.chainId) + + const feeData = await evm.getFees({ adapter, data, to, value, from, supportsEIP1559 }) + + const unsignedTx = await adapter.buildCustomApiTx({ + accountNumber, + data, + from, + to, + value, + ...feeData, + gasLimit: BigNumber.max(gasLimitFromApi ?? '0', feeData.gasLimit).toFixed(), + }) + + return unsignedTx + }, + checkTradeStatus: async ({ + txHash, + chainId, + address, + assertGetEvmChainAdapter, + fetchIsSmartContractAddressQuery, + }) => { + if (isEvmChainId(chainId)) { + const sourceTxStatus = await checkEvmSwapStatus({ + txHash, + chainId, + address, + assertGetEvmChainAdapter, + fetchIsSmartContractAddressQuery, + }) + + if (sourceTxStatus.status !== TxStatus.Confirmed) return sourceTxStatus + } + + const maybeStatusResponse = await stargateService.get( + `https://api-mainnet.layerzero-scan.com/tx/${txHash}`, + ) + + if (maybeStatusResponse.isErr()) { + return { + buyTxHash: undefined, + status: TxStatus.Pending, + message: undefined, + } + } + + const { data: statusResponse } = maybeStatusResponse.unwrap() + + const firstMessage = statusResponse.messages[0] as LayerZeroMessage | undefined + + const status = (() => { + switch (firstMessage?.status) { + case 'INFLIGHT': + case 'CONFIRMING': + return TxStatus.Pending + case 'DELIVERED': + return TxStatus.Confirmed + case 'FAILED': + return TxStatus.Failed + default: + return TxStatus.Pending + } + })() + + const message = (() => { + switch (firstMessage?.status) { + case 'INFLIGHT': + return 'Cross-chain message in flight...' + case 'CONFIRMING': + return 'Confirming on destination chain...' + case 'FAILED': + return 'Cross-chain transfer failed' + default: + return undefined + } + })() + + const buyTxHash = firstMessage?.dstTxHash + + return { + status, + buyTxHash, + message, + } + }, +} diff --git a/packages/swapper/src/swappers/StargateSwapper/getTradeQuote/getTradeQuote.test.ts b/packages/swapper/src/swappers/StargateSwapper/getTradeQuote/getTradeQuote.test.ts new file mode 100644 index 00000000000..87b5dccc386 --- /dev/null +++ b/packages/swapper/src/swappers/StargateSwapper/getTradeQuote/getTradeQuote.test.ts @@ -0,0 +1,311 @@ +import { + arbitrumChainId, + baseChainId, + ethChainId, + optimismChainId, + polygonChainId, +} from '@shapeshiftoss/caip' +import type { EvmChainAdapter } from '@shapeshiftoss/chain-adapters' +import { describe, expect, it, vi } from 'vitest' + +import type { GetEvmTradeQuoteInputBase, SwapperDeps } from '../../../types' +import { SwapperName, TradeQuoteError } from '../../../types' +import { + ETH, + ETH_ARBITRUM, + ETH_BASE, + FOX_MAINNET, + USDC_ARBITRUM, + USDC_BASE, + USDC_MAINNET, + USDC_OPTIMISM, + USDC_POLYGON, + USDT_ARBITRUM, + USDT_MAINNET, +} from '../../utils/test-data/assets' +import { getTradeQuote } from './getTradeQuote' + +vi.mock('@shapeshiftoss/contracts', () => ({ + viemClientByChainId: { + [ethChainId]: { + call: vi.fn().mockResolvedValue({ data: '0xdeadbeef' }), + }, + [arbitrumChainId]: { + call: vi.fn().mockResolvedValue({ data: '0xdeadbeef' }), + }, + [optimismChainId]: { + call: vi.fn().mockResolvedValue({ data: '0xdeadbeef' }), + }, + [baseChainId]: { + call: vi.fn().mockResolvedValue({ data: '0xdeadbeef' }), + }, + [polygonChainId]: { + call: vi.fn().mockResolvedValue({ data: '0xdeadbeef' }), + }, + }, +})) + +vi.mock('@shapeshiftoss/chain-adapters', async () => { + const actual = await vi.importActual('@shapeshiftoss/chain-adapters') + return { + ...actual, + evm: { + getFees: vi.fn().mockResolvedValue({ networkFeeCryptoBaseUnit: '21000' }), + calcNetworkFeeCryptoBaseUnit: vi.fn().mockReturnValue('21000'), + }, + } +}) + +vi.mock('../utils/helpers', () => ({ + encodeQuoteOFT: vi.fn().mockReturnValue('0x11'), + decodeQuoteOFTResult: vi + .fn() + .mockReturnValue([{}, [], { amountReceivedLD: 990_000_000n, amountSentLD: 1_000_000_000n }]), + encodeQuoteSend: vi.fn().mockReturnValue('0x22'), + decodeQuoteSendResult: vi.fn().mockReturnValue({ + nativeFee: 1_000_000_000_000_000n, + lzTokenFee: 0n, + }), + encodeSend: vi.fn().mockReturnValue('0x33'), +})) + +describe('Stargate getTradeQuote', () => { + const mockAdapter = { + getGasFeeData: vi.fn().mockResolvedValue({ + average: { gasPrice: '42', maxFeePerGas: '42' }, + }), + } as unknown as EvmChainAdapter + + const deps = { + assertGetEvmChainAdapter: () => mockAdapter, + } as unknown as SwapperDeps + + const commonInput = { + sellAsset: USDC_MAINNET, + buyAsset: USDC_ARBITRUM, + accountNumber: 0, + affiliateBps: '0', + sellAmountIncludingProtocolFeesCryptoBaseUnit: '1000000000', + sendAddress: '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266', + receiveAddress: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + slippageTolerancePercentageDecimal: '0.005', + } as unknown as GetEvmTradeQuoteInputBase + + it('returns error when sendAddress is missing', async () => { + const result = await getTradeQuote({ ...commonInput, sendAddress: undefined }, deps) + expect(result.isErr()).toBe(true) + const err = result.unwrapErr() + expect(err.message).toBe('sendAddress is required') + expect(err.code).toBe(TradeQuoteError.InternalError) + }) + + it('returns error when receiveAddress is missing', async () => { + const result = await getTradeQuote( + { ...commonInput, receiveAddress: undefined as unknown as string }, + deps, + ) + expect(result.isErr()).toBe(true) + const err = result.unwrapErr() + expect(err.message).toBe('receiveAddress is required') + expect(err.code).toBe(TradeQuoteError.InternalError) + }) + + it('returns UnsupportedTradePair error for same-chain swap', async () => { + const result = await getTradeQuote({ ...commonInput, buyAsset: USDC_MAINNET }, deps) + expect(result.isErr()).toBe(true) + expect(result.unwrapErr().code).toBe(TradeQuoteError.UnsupportedTradePair) + }) + + it('returns UnsupportedChain error for unsupported sell chain', async () => { + // ETH on mainnet → USDC Arbitrum but sell asset on a non-Stargate chain + const result = await getTradeQuote( + { + ...commonInput, + sellAsset: { ...ETH, chainId: 'eip155:5' as const }, // Goerli (not supported) + }, + deps, + ) + expect(result.isErr()).toBe(true) + expect(result.unwrapErr().code).toBe(TradeQuoteError.UnsupportedChain) + }) + + it('returns a valid quote for USDC Ethereum → USDC Arbitrum', async () => { + const result = await getTradeQuote(commonInput, deps) + expect(result.isOk()).toBe(true) + + const quotes = result.unwrap() + expect(quotes).toHaveLength(1) + + const quote = quotes[0] + expect(quote.swapperName).toBe(SwapperName.Stargate) + expect(quote.receiveAddress).toBe(commonInput.receiveAddress) + expect(quote.quoteOrRate).toBe('quote') + expect(quote.slippageTolerancePercentageDecimal).toBe('0.005') + }) + + it('quote step has correct source and chain metadata', async () => { + const result = await getTradeQuote(commonInput, deps) + const step = result.unwrap()[0].steps[0] + + expect(step.source).toBe(SwapperName.Stargate) + expect(step.sellAsset.chainId).toBe(ethChainId) + expect(step.buyAsset.chainId).toBe(arbitrumChainId) + expect(step.estimatedExecutionTimeMs).toBe(60_000) + }) + + it('quote step includes protocol fees and network fee', async () => { + const result = await getTradeQuote(commonInput, deps) + const step = result.unwrap()[0].steps[0] + + expect(step.feeData.networkFeeCryptoBaseUnit).toBeDefined() + expect(step.feeData.protocolFees).toBeDefined() + const protocolFee = step.feeData.protocolFees?.[USDC_MAINNET.assetId] + expect(protocolFee).toBeDefined() + // fee = amountSentLD - amountReceivedLD = 1_000_000_000 - 990_000_000 = 10_000_000 + expect(protocolFee?.amountCryptoBaseUnit).toBe('10000000') + }) + + it('quote step has stargateTransactionMetadata', async () => { + const result = await getTradeQuote(commonInput, deps) + const step = result.unwrap()[0].steps[0] + + expect(step.stargateTransactionMetadata).toBeDefined() + expect(step.stargateTransactionMetadata?.to).toBeDefined() + expect(step.stargateTransactionMetadata?.data).toBe('0x33') + }) + + it('buyAmountAfterFees reflects the received amount from quoteOFT', async () => { + const result = await getTradeQuote(commonInput, deps) + const step = result.unwrap()[0].steps[0] + + // mocked amountReceivedLD = 990_000_000 + expect(step.buyAmountAfterFeesCryptoBaseUnit).toBe('990000000') + expect(step.buyAmountBeforeFeesCryptoBaseUnit).toBe('1000000000') + }) + + it('slippage is applied: minAmountLD = detailDstAmountLD * (1 - slippage)', async () => { + const { encodeSend } = await import('../utils/helpers') + + await getTradeQuote(commonInput, deps) + + // encodeSend should have been called with sendParam.minAmountLD = 990_000_000 * 0.995 = 985_050_000 + const sendParamArg = vi.mocked(encodeSend).mock.calls[0][0] + expect(sendParamArg.minAmountLD).toBe(985_050_000n) + }) + + it('returns UnsupportedChain error for unsupported buy chain', async () => { + const result = await getTradeQuote( + { + ...commonInput, + buyAsset: { ...USDC_ARBITRUM, chainId: 'eip155:5' as const }, // Goerli (not supported) + }, + deps, + ) + expect(result.isErr()).toBe(true) + expect(result.unwrapErr().code).toBe(TradeQuoteError.UnsupportedChain) + }) + + it('returns UnsupportedTradePair error when sell asset has no Stargate contract', async () => { + // FOX has no Stargate pool on mainnet + const result = await getTradeQuote({ ...commonInput, sellAsset: FOX_MAINNET }, deps) + expect(result.isErr()).toBe(true) + expect(result.unwrapErr().code).toBe(TradeQuoteError.UnsupportedTradePair) + }) + + it('returns a valid quote for ETH (mainnet) → ETH (Arbitrum) native bridge', async () => { + const result = await getTradeQuote( + { ...commonInput, sellAsset: ETH, buyAsset: ETH_ARBITRUM }, + deps, + ) + expect(result.isOk()).toBe(true) + + const step = result.unwrap()[0].steps[0] + expect(step.sellAsset.chainId).toBe(ethChainId) + expect(step.buyAsset.chainId).toBe(arbitrumChainId) + // native sell: txValue = nativeFee + sellAmount + // mocked nativeFee = 1_000_000_000_000_000, sellAmount = 1_000_000_000 + expect(step.stargateTransactionMetadata?.value).toBe('1000001000000000') + }) + + it('returns a valid quote for USDC (Arbitrum) → USDC (Mainnet) reverse direction', async () => { + const result = await getTradeQuote( + { ...commonInput, sellAsset: USDC_ARBITRUM, buyAsset: USDC_MAINNET }, + deps, + ) + expect(result.isOk()).toBe(true) + + const step = result.unwrap()[0].steps[0] + expect(step.sellAsset.chainId).toBe(arbitrumChainId) + expect(step.buyAsset.chainId).toBe(ethChainId) + expect(step.source).toBe(SwapperName.Stargate) + // non-native sell: txValue = nativeFee only + expect(step.stargateTransactionMetadata?.value).toBe('1000000000000000') + }) + + it('returns a valid quote for USDC (Mainnet) → USDC (Optimism)', async () => { + const result = await getTradeQuote( + { ...commonInput, sellAsset: USDC_MAINNET, buyAsset: USDC_OPTIMISM }, + deps, + ) + expect(result.isOk()).toBe(true) + const step = result.unwrap()[0].steps[0] + expect(step.sellAsset.chainId).toBe(ethChainId) + expect(step.buyAsset.chainId).toBe(optimismChainId) + }) + + it('returns a valid quote for USDC (Mainnet) → USDC (Base)', async () => { + const result = await getTradeQuote( + { ...commonInput, sellAsset: USDC_MAINNET, buyAsset: USDC_BASE }, + deps, + ) + expect(result.isOk()).toBe(true) + const step = result.unwrap()[0].steps[0] + expect(step.sellAsset.chainId).toBe(ethChainId) + expect(step.buyAsset.chainId).toBe(baseChainId) + }) + + it('returns a valid quote for USDC (Mainnet) → USDC (Polygon)', async () => { + const result = await getTradeQuote( + { ...commonInput, sellAsset: USDC_MAINNET, buyAsset: USDC_POLYGON }, + deps, + ) + expect(result.isOk()).toBe(true) + const step = result.unwrap()[0].steps[0] + expect(step.sellAsset.chainId).toBe(ethChainId) + expect(step.buyAsset.chainId).toBe(polygonChainId) + }) + + it('returns a valid quote for USDT (Mainnet) → USDT (Arbitrum)', async () => { + const result = await getTradeQuote( + { ...commonInput, sellAsset: USDT_MAINNET, buyAsset: USDT_ARBITRUM }, + deps, + ) + expect(result.isOk()).toBe(true) + const step = result.unwrap()[0].steps[0] + expect(step.sellAsset.chainId).toBe(ethChainId) + expect(step.buyAsset.chainId).toBe(arbitrumChainId) + expect(step.sellAsset.symbol).toBe('USDT') + }) + + it('returns a valid quote for ETH (Mainnet) → ETH (Base) native bridge', async () => { + const result = await getTradeQuote({ ...commonInput, sellAsset: ETH, buyAsset: ETH_BASE }, deps) + expect(result.isOk()).toBe(true) + const step = result.unwrap()[0].steps[0] + expect(step.sellAsset.chainId).toBe(ethChainId) + expect(step.buyAsset.chainId).toBe(baseChainId) + // native sell: txValue = nativeFee + sellAmount + expect(step.stargateTransactionMetadata?.value).toBe('1000001000000000') + }) + + it('returns a valid quote for USDC (Base) → USDC (Arbitrum) L2-to-L2', async () => { + const result = await getTradeQuote( + { ...commonInput, sellAsset: USDC_BASE, buyAsset: USDC_ARBITRUM }, + deps, + ) + expect(result.isOk()).toBe(true) + const step = result.unwrap()[0].steps[0] + expect(step.sellAsset.chainId).toBe(baseChainId) + expect(step.buyAsset.chainId).toBe(arbitrumChainId) + expect(step.source).toBe(SwapperName.Stargate) + }) +}) diff --git a/packages/swapper/src/swappers/StargateSwapper/getTradeQuote/getTradeQuote.ts b/packages/swapper/src/swappers/StargateSwapper/getTradeQuote/getTradeQuote.ts new file mode 100644 index 00000000000..57ef1f64d8f --- /dev/null +++ b/packages/swapper/src/swappers/StargateSwapper/getTradeQuote/getTradeQuote.ts @@ -0,0 +1,51 @@ +import type { Result } from '@sniptt/monads' +import { Err } from '@sniptt/monads' + +import type { CommonTradeQuoteInput, SwapErrorRight, SwapperDeps, TradeQuote } from '../../../types' +import { TradeQuoteError } from '../../../types' +import { makeSwapErrorRight } from '../../../utils' +import { fetchStargateTrade } from '../utils/fetchStargateTrade' + +export const getTradeQuote = ( + input: CommonTradeQuoteInput, + deps: SwapperDeps, +): Promise> => { + if (!input.sendAddress) { + return Promise.resolve( + Err( + makeSwapErrorRight({ + message: 'sendAddress is required', + code: TradeQuoteError.InternalError, + details: { field: 'sendAddress' }, + }), + ), + ) + } + + if (!input.receiveAddress) { + return Promise.resolve( + Err( + makeSwapErrorRight({ + message: 'receiveAddress is required', + code: TradeQuoteError.InternalError, + details: { field: 'receiveAddress' }, + }), + ), + ) + } + + const args = { + buyAsset: input.buyAsset, + receiveAddress: input.receiveAddress, + sellAmountIncludingProtocolFeesCryptoBaseUnit: + input.sellAmountIncludingProtocolFeesCryptoBaseUnit, + sellAsset: input.sellAsset, + sendAddress: input.sendAddress, + quoteOrRate: 'quote' as const, + accountNumber: input.accountNumber, + affiliateBps: input.affiliateBps, + slippageTolerancePercentageDecimal: input.slippageTolerancePercentageDecimal, + } + + return fetchStargateTrade({ input: args, deps }) +} diff --git a/packages/swapper/src/swappers/StargateSwapper/getTradeRate/getTradeRate.ts b/packages/swapper/src/swappers/StargateSwapper/getTradeRate/getTradeRate.ts new file mode 100644 index 00000000000..d53f84d4022 --- /dev/null +++ b/packages/swapper/src/swappers/StargateSwapper/getTradeRate/getTradeRate.ts @@ -0,0 +1,24 @@ +import type { Result } from '@sniptt/monads' + +import type { GetTradeRateInput, SwapErrorRight, SwapperDeps, TradeRate } from '../../../types' +import { fetchStargateTrade } from '../utils/fetchStargateTrade' + +export const getTradeRate = ( + input: GetTradeRateInput, + deps: SwapperDeps, +): Promise> => { + const args = { + quoteOrRate: 'rate' as const, + buyAsset: input.buyAsset, + receiveAddress: input.receiveAddress, + sellAmountIncludingProtocolFeesCryptoBaseUnit: + input.sellAmountIncludingProtocolFeesCryptoBaseUnit, + sellAsset: input.sellAsset, + sendAddress: input.sendAddress, + accountNumber: input.accountNumber, + affiliateBps: input.affiliateBps, + slippageTolerancePercentageDecimal: input.slippageTolerancePercentageDecimal, + } + + return fetchStargateTrade({ input: args, deps }) +} diff --git a/packages/swapper/src/swappers/StargateSwapper/index.ts b/packages/swapper/src/swappers/StargateSwapper/index.ts new file mode 100644 index 00000000000..200c41f3622 --- /dev/null +++ b/packages/swapper/src/swappers/StargateSwapper/index.ts @@ -0,0 +1,2 @@ +export { stargateSwapper } from './StargateSwapper' +export { stargateApi } from './endpoints' diff --git a/packages/swapper/src/swappers/StargateSwapper/types.ts b/packages/swapper/src/swappers/StargateSwapper/types.ts new file mode 100644 index 00000000000..1d1122cb382 --- /dev/null +++ b/packages/swapper/src/swappers/StargateSwapper/types.ts @@ -0,0 +1,30 @@ +import type { Address, Hex } from 'viem' + +export type StargateTransactionMetadata = { + to: Address + data: Hex + value: string + gasLimit: string +} + +export type StargateSendParam = { + dstEid: number + to: Hex + amountLD: bigint + minAmountLD: bigint + extraOptions: Hex + composeMsg: Hex + oftCmd: Hex +} + +export type StargateQuoteOFTResponse = { + minAmountLD: bigint + maxAmountLD: bigint + detailDstAmountLD: bigint + detailFeeAmountLD: bigint +} + +export type StargateMessagingFee = { + nativeFee: bigint + lzTokenFee: bigint +} diff --git a/packages/swapper/src/swappers/StargateSwapper/utils/fetchStargateTrade.ts b/packages/swapper/src/swappers/StargateSwapper/utils/fetchStargateTrade.ts new file mode 100644 index 00000000000..4bb0e093783 --- /dev/null +++ b/packages/swapper/src/swappers/StargateSwapper/utils/fetchStargateTrade.ts @@ -0,0 +1,380 @@ +import type { AssetId, ChainId } from '@shapeshiftoss/caip' +import { fromAssetId } from '@shapeshiftoss/caip' +import { evm } from '@shapeshiftoss/chain-adapters' +import { viemClientByChainId } from '@shapeshiftoss/contracts' +import type { Result } from '@sniptt/monads' +import { Err, Ok } from '@sniptt/monads' +import BigNumber from 'bignumber.js' +import type { Address, Hex } from 'viem' +import { pad } from 'viem' + +import type { + SwapErrorRight, + SwapperDeps, + TradeQuote, + TradeQuoteStep, + TradeRate, + TradeRateStep, +} from '../../../types' +import { SwapperName, TradeQuoteError } from '../../../types' +import { getInputOutputRate, makeSwapErrorRight } from '../../../utils' +import { isNativeEvmAsset } from '../../utils/helpers/helpers' +import { + chainIdToStargateEndpointId, + DEFAULT_STARGATE_GAS_LIMIT, + DEFAULT_STARGATE_USER_ADDRESS, + STARGATE_NATIVE_ASSET_ADDRESS, + STARGATE_SUPPORTED_CHAIN_IDS, + stargateContractsByChainAndAsset, +} from '../constant' +import type { StargateMessagingFee, StargateSendParam, StargateTransactionMetadata } from '../types' +import { + decodeQuoteOFTResult, + decodeQuoteSendResult, + encodeQuoteOFT, + encodeQuoteSend, + encodeSend, +} from './helpers' + +type StargateTradeInput = { + sellAsset: { + assetId: AssetId + chainId: ChainId + precision: number + symbol: string + } & Record + buyAsset: { + assetId: AssetId + chainId: ChainId + precision: number + symbol: string + } & Record + sellAmountIncludingProtocolFeesCryptoBaseUnit: string + sendAddress: T extends 'quote' ? string : string | undefined + receiveAddress: T extends 'quote' ? string : string | undefined + accountNumber: T extends 'quote' ? number : undefined + affiliateBps: string + slippageTolerancePercentageDecimal?: string + quoteOrRate: T +} + +const getStargateAssetAddress = (assetId: AssetId): string => { + if (isNativeEvmAsset(assetId)) return STARGATE_NATIVE_ASSET_ADDRESS + const { assetReference } = fromAssetId(assetId) + return assetReference.toLowerCase() +} + +export async function fetchStargateTrade(args: { + input: StargateTradeInput<'quote'> + deps: SwapperDeps +}): Promise> + +export async function fetchStargateTrade(args: { + input: StargateTradeInput<'rate'> + deps: SwapperDeps +}): Promise> + +export async function fetchStargateTrade({ + input, + deps, +}: { + input: StargateTradeInput + deps: SwapperDeps +}): Promise> { + const { + sellAsset, + buyAsset, + sellAmountIncludingProtocolFeesCryptoBaseUnit, + accountNumber, + affiliateBps, + } = input + + if (sellAsset.chainId === buyAsset.chainId) { + return Err( + makeSwapErrorRight({ + message: 'Stargate does not support same-chain swaps', + code: TradeQuoteError.UnsupportedTradePair, + }), + ) + } + + if (!STARGATE_SUPPORTED_CHAIN_IDS.includes(sellAsset.chainId)) { + return Err( + makeSwapErrorRight({ + message: `Sell asset chain '${sellAsset.chainId}' not supported by Stargate`, + code: TradeQuoteError.UnsupportedChain, + }), + ) + } + + if (!STARGATE_SUPPORTED_CHAIN_IDS.includes(buyAsset.chainId)) { + return Err( + makeSwapErrorRight({ + message: `Buy asset chain '${buyAsset.chainId}' not supported by Stargate`, + code: TradeQuoteError.UnsupportedChain, + }), + ) + } + + const sellAssetAddress = getStargateAssetAddress(sellAsset.assetId) + const chainContracts = stargateContractsByChainAndAsset[sellAsset.chainId] + const contractAddress = chainContracts?.[sellAssetAddress] as Address | undefined + + if (!contractAddress) { + return Err( + makeSwapErrorRight({ + message: `No Stargate contract found for asset ${sellAsset.assetId} on chain ${sellAsset.chainId}`, + code: TradeQuoteError.UnsupportedTradePair, + }), + ) + } + + const dstEid = + chainIdToStargateEndpointId[buyAsset.chainId as keyof typeof chainIdToStargateEndpointId] + + if (dstEid === undefined) { + return Err( + makeSwapErrorRight({ + message: `No LayerZero endpoint ID found for chain ${buyAsset.chainId}`, + code: TradeQuoteError.UnsupportedChain, + }), + ) + } + + const receiveAddress = + input.quoteOrRate === 'rate' + ? input.receiveAddress ?? DEFAULT_STARGATE_USER_ADDRESS + : input.receiveAddress + + const sendAddress = + input.quoteOrRate === 'rate' + ? input.sendAddress ?? DEFAULT_STARGATE_USER_ADDRESS + : input.sendAddress + + if (!receiveAddress) { + return Err( + makeSwapErrorRight({ + message: 'Receive address is required', + code: TradeQuoteError.InternalError, + }), + ) + } + + if (!sendAddress) { + return Err( + makeSwapErrorRight({ + message: 'Send address is required', + code: TradeQuoteError.InternalError, + }), + ) + } + + const paddedReceiveAddress = pad(receiveAddress as Hex, { size: 32 }) + const amountLD = BigInt(sellAmountIncludingProtocolFeesCryptoBaseUnit) + + const sendParam: StargateSendParam = { + dstEid, + to: paddedReceiveAddress, + amountLD, + minAmountLD: 0n, + extraOptions: '0x' as Hex, + composeMsg: '0x' as Hex, + oftCmd: '0x' as Hex, + } + + const publicClient = viemClientByChainId[sellAsset.chainId] + + if (!publicClient) { + return Err( + makeSwapErrorRight({ + message: `No public client found for chain ${sellAsset.chainId}`, + code: TradeQuoteError.InternalError, + }), + ) + } + + try { + const quoteOFTCalldata = encodeQuoteOFT(sendParam) + + const quoteOFTResult = await publicClient.call({ + to: contractAddress, + data: quoteOFTCalldata, + }) + + if (!quoteOFTResult.data) { + return Err( + makeSwapErrorRight({ + message: 'quoteOFT returned no data', + code: TradeQuoteError.NoRouteFound, + }), + ) + } + + const [_limit, _oftFeeDetails, receipt] = decodeQuoteOFTResult(quoteOFTResult.data as Hex) + + const detailDstAmountLD = receipt.amountReceivedLD + const detailFeeAmountLD = receipt.amountSentLD - receipt.amountReceivedLD + + // Apply slippage to minAmountLD so the on-chain send() will revert if the + // fill is worse than the quoted amount minus user-selected (or default) slippage. + const DEFAULT_SLIPPAGE_BPS = 50n // 0.5% + const slippageBps = input.slippageTolerancePercentageDecimal + ? BigInt(Math.round(parseFloat(input.slippageTolerancePercentageDecimal) * 10000)) + : DEFAULT_SLIPPAGE_BPS + sendParam.minAmountLD = (detailDstAmountLD * (10000n - slippageBps)) / 10000n + + const quoteSendCalldata = encodeQuoteSend(sendParam, false) + + const quoteSendResult = await publicClient.call({ + to: contractAddress, + data: quoteSendCalldata, + }) + + if (!quoteSendResult.data) { + return Err( + makeSwapErrorRight({ + message: 'quoteSend returned no data', + code: TradeQuoteError.NoRouteFound, + }), + ) + } + + const { nativeFee, lzTokenFee } = decodeQuoteSendResult(quoteSendResult.data as Hex) as { + nativeFee: bigint + lzTokenFee: bigint + } + const messagingFee: StargateMessagingFee = { nativeFee, lzTokenFee } + + const buyAmountAfterFeesCryptoBaseUnit = detailDstAmountLD.toString() + const buyAmountBeforeFeesCryptoBaseUnit = (detailDstAmountLD + detailFeeAmountLD).toString() + const protocolFeeAmountCryptoBaseUnit = detailFeeAmountLD.toString() + const nativeFeeCryptoBaseUnit = messagingFee.nativeFee.toString() + + const rate = getInputOutputRate({ + sellAmountCryptoBaseUnit: sellAmountIncludingProtocolFeesCryptoBaseUnit, + buyAmountCryptoBaseUnit: buyAmountAfterFeesCryptoBaseUnit, + sellAsset: sellAsset as Parameters[0]['sellAsset'], + buyAsset: buyAsset as Parameters[0]['buyAsset'], + }) + + const sendCalldata = encodeSend( + sendParam, + { nativeFee: messagingFee.nativeFee, lzTokenFee: messagingFee.lzTokenFee }, + sendAddress as Hex, + ) + + const isNative = isNativeEvmAsset(sellAsset.assetId) + const txValue = isNative + ? new BigNumber(nativeFeeCryptoBaseUnit) + .plus(sellAmountIncludingProtocolFeesCryptoBaseUnit) + .toFixed(0) + : nativeFeeCryptoBaseUnit + + const adapter = deps.assertGetEvmChainAdapter(sellAsset.chainId) + const { average } = await adapter.getGasFeeData() + const supportsEIP1559 = 'maxFeePerGas' in average + + let gasLimit = DEFAULT_STARGATE_GAS_LIMIT + const networkFeeCryptoBaseUnit = await (async () => { + try { + const feeData = await evm.getFees({ + adapter, + data: sendCalldata, + to: contractAddress, + value: txValue, + from: sendAddress, + supportsEIP1559, + }) + gasLimit = feeData.gasLimit ?? gasLimit + return feeData.networkFeeCryptoBaseUnit + } catch (e) { + console.warn('[Stargate] Fee estimation failed, using fallback gas limit', { + error: e instanceof Error ? e.message : String(e), + sellAsset: sellAsset.assetId, + }) + return evm.calcNetworkFeeCryptoBaseUnit({ + ...average, + supportsEIP1559, + gasLimit, + }) + } + })() + + const stargateTransactionMetadata: StargateTransactionMetadata = { + to: contractAddress, + data: sendCalldata, + value: txValue, + gasLimit, + } + + const protocolFees: Record< + AssetId, + { + amountCryptoBaseUnit: string + asset: { symbol: string; chainId: ChainId; precision: number } + requiresBalance: boolean + } + > = { + [sellAsset.assetId]: { + amountCryptoBaseUnit: protocolFeeAmountCryptoBaseUnit, + asset: { + symbol: sellAsset.symbol, + chainId: sellAsset.chainId, + precision: sellAsset.precision, + }, + requiresBalance: false, + }, + } + + const step: TradeQuoteStep | TradeRateStep = { + allowanceContract: contractAddress, + rate, + buyAmountBeforeFeesCryptoBaseUnit, + buyAmountAfterFeesCryptoBaseUnit, + sellAmountIncludingProtocolFeesCryptoBaseUnit, + buyAsset: buyAsset as TradeQuoteStep['buyAsset'], + sellAsset: sellAsset as TradeQuoteStep['sellAsset'], + accountNumber: accountNumber as number | undefined, + feeData: { + networkFeeCryptoBaseUnit, + protocolFees, + }, + source: SwapperName.Stargate, + estimatedExecutionTimeMs: 60_000, + stargateTransactionMetadata, + } + + const baseQuoteOrRate = { + id: `stargate-${sellAsset.chainId}-${buyAsset.chainId}-${Date.now()}`, + rate, + swapperName: SwapperName.Stargate, + affiliateBps, + slippageTolerancePercentageDecimal: input.slippageTolerancePercentageDecimal, + } + + if (input.quoteOrRate === 'quote') { + const tradeQuote: TradeQuote = { + ...baseQuoteOrRate, + steps: [step as TradeQuoteStep], + receiveAddress, + quoteOrRate: 'quote' as const, + } + return Ok([tradeQuote]) + } + + const tradeRate: TradeRate = { + ...baseQuoteOrRate, + steps: [step as TradeRateStep], + receiveAddress, + quoteOrRate: 'rate' as const, + } + return Ok([tradeRate]) + } catch (e) { + return Err( + makeSwapErrorRight({ + message: `Stargate quote failed: ${e instanceof Error ? e.message : String(e)}`, + code: TradeQuoteError.NoRouteFound, + }), + ) + } +} diff --git a/packages/swapper/src/swappers/StargateSwapper/utils/helpers.ts b/packages/swapper/src/swappers/StargateSwapper/utils/helpers.ts new file mode 100644 index 00000000000..1fb4f3d5e0c --- /dev/null +++ b/packages/swapper/src/swappers/StargateSwapper/utils/helpers.ts @@ -0,0 +1,171 @@ +import type { Abi, Hex } from 'viem' +import { decodeFunctionResult, encodeFunctionData } from 'viem' + +import type { StargateSendParam } from '../types' + +const SendParamTuple = { + type: 'tuple', + name: 'sendParam', + components: [ + { name: 'dstEid', type: 'uint32' }, + { name: 'to', type: 'bytes32' }, + { name: 'amountLD', type: 'uint256' }, + { name: 'minAmountLD', type: 'uint256' }, + { name: 'extraOptions', type: 'bytes' }, + { name: 'composeMsg', type: 'bytes' }, + { name: 'oftCmd', type: 'bytes' }, + ], +} as const + +export const IStargateAbi = [ + { + name: 'quoteOFT', + type: 'function', + stateMutability: 'view', + inputs: [SendParamTuple], + outputs: [ + { + type: 'tuple', + name: 'limit', + components: [ + { name: 'minAmountLD', type: 'uint256' }, + { name: 'maxAmountLD', type: 'uint256' }, + ], + }, + { + type: 'tuple[]', + name: 'oftFeeDetails', + components: [ + { name: 'feeAmountLD', type: 'int256' }, + { name: 'description', type: 'string' }, + ], + }, + { + type: 'tuple', + name: 'receipt', + components: [ + { name: 'amountSentLD', type: 'uint256' }, + { name: 'amountReceivedLD', type: 'uint256' }, + ], + }, + ], + }, + { + name: 'quoteSend', + type: 'function', + stateMutability: 'view', + inputs: [SendParamTuple, { name: '_payInLzToken', type: 'bool' }], + outputs: [ + { + type: 'tuple', + name: 'fee', + components: [ + { name: 'nativeFee', type: 'uint256' }, + { name: 'lzTokenFee', type: 'uint256' }, + ], + }, + ], + }, + { + name: 'send', + type: 'function', + stateMutability: 'payable', + inputs: [ + SendParamTuple, + { + type: 'tuple', + name: '_fee', + components: [ + { name: 'nativeFee', type: 'uint256' }, + { name: 'lzTokenFee', type: 'uint256' }, + ], + }, + { name: '_refundAddress', type: 'address' }, + ], + outputs: [ + { + type: 'tuple', + name: 'msgReceipt', + components: [ + { name: 'guid', type: 'bytes32' }, + { name: 'nonce', type: 'uint64' }, + { + type: 'tuple', + name: 'fee', + components: [ + { name: 'nativeFee', type: 'uint256' }, + { name: 'lzTokenFee', type: 'uint256' }, + ], + }, + ], + }, + { + type: 'tuple', + name: 'oftReceipt', + components: [ + { name: 'amountSentLD', type: 'uint256' }, + { name: 'amountReceivedLD', type: 'uint256' }, + ], + }, + ], + }, +] as const satisfies Abi + +type SendParamArgs = { + dstEid: number + to: Hex + amountLD: bigint + minAmountLD: bigint + extraOptions: Hex + composeMsg: Hex + oftCmd: Hex +} + +const toSendParamArgs = (param: StargateSendParam): SendParamArgs => ({ + dstEid: param.dstEid, + to: param.to, + amountLD: param.amountLD, + minAmountLD: param.minAmountLD, + extraOptions: param.extraOptions, + composeMsg: param.composeMsg, + oftCmd: param.oftCmd, +}) + +export const encodeQuoteOFT = (sendParam: StargateSendParam): Hex => + encodeFunctionData({ + abi: IStargateAbi, + functionName: 'quoteOFT', + args: [toSendParamArgs(sendParam)], + }) + +export const decodeQuoteOFTResult = (data: Hex) => + decodeFunctionResult({ + abi: IStargateAbi, + functionName: 'quoteOFT', + data, + }) + +export const encodeQuoteSend = (sendParam: StargateSendParam, payInLzToken: boolean): Hex => + encodeFunctionData({ + abi: IStargateAbi, + functionName: 'quoteSend', + args: [toSendParamArgs(sendParam), payInLzToken], + }) + +export const decodeQuoteSendResult = (data: Hex) => + decodeFunctionResult({ + abi: IStargateAbi, + functionName: 'quoteSend', + data, + }) + +export const encodeSend = ( + sendParam: StargateSendParam, + fee: { nativeFee: bigint; lzTokenFee: bigint }, + refundAddress: Hex, +): Hex => + encodeFunctionData({ + abi: IStargateAbi, + functionName: 'send', + args: [toSendParamArgs(sendParam), fee, refundAddress], + }) diff --git a/packages/swapper/src/swappers/StargateSwapper/utils/stargateService.ts b/packages/swapper/src/swappers/StargateSwapper/utils/stargateService.ts new file mode 100644 index 00000000000..341cff31e59 --- /dev/null +++ b/packages/swapper/src/swappers/StargateSwapper/utils/stargateService.ts @@ -0,0 +1,15 @@ +import axios from 'axios' + +import { makeSwapperAxiosServiceMonadic } from '../../../utils' + +const axiosConfig = { + timeout: 10000, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, +} + +const stargateServiceBase = axios.create(axiosConfig) + +export const stargateService = makeSwapperAxiosServiceMonadic(stargateServiceBase) diff --git a/packages/swapper/src/swappers/utils/test-data/assets.ts b/packages/swapper/src/swappers/utils/test-data/assets.ts index 011a6048e05..8008c74c270 100644 --- a/packages/swapper/src/swappers/utils/test-data/assets.ts +++ b/packages/swapper/src/swappers/utils/test-data/assets.ts @@ -2,6 +2,8 @@ import { arbitrumChainId, avalancheAssetId, avalancheChainId, + baseAssetId, + baseChainId, bscAssetId, bscChainId, ethAssetId, @@ -13,6 +15,8 @@ import { gnosisChainId, optimismAssetId, optimismChainId, + polygonAssetId, + polygonChainId, thorchainAssetId, thorchainChainId, } from '@shapeshiftoss/caip' @@ -252,3 +256,119 @@ export const RUNE: Asset = { explorerTxLink: 'https://viewblock.io/thorchain/tx/', relatedAssetKey: null, } + +// Stargate-supported assets + +export const USDT_MAINNET: Asset = { + assetId: 'eip155:1/erc20:0xdac17f958d2ee523a2206206994597c13d831ec7', + chainId: ethChainId, + symbol: 'USDT', + name: 'Tether USD', + precision: 6, + color: '#26A17B', + icon: 'https://assets.coingecko.com/coins/images/325/thumb/Tether.png?1668148663', + explorer: 'https://etherscan.io', + explorerAddressLink: 'https://etherscan.io/address/', + explorerTxLink: 'https://etherscan.io/tx/', + relatedAssetKey: null, +} + +export const USDT_ARBITRUM: Asset = { + assetId: 'eip155:42161/erc20:0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9', + chainId: arbitrumChainId, + symbol: 'USDT', + name: 'Tether USD on Arbitrum', + precision: 6, + color: '#26A17B', + icon: 'https://assets.coingecko.com/coins/images/325/thumb/Tether.png?1668148663', + explorer: 'https://arbiscan.io', + explorerAddressLink: 'https://arbiscan.io/address/', + explorerTxLink: 'https://arbiscan.io/tx/', + relatedAssetKey: null, +} + +export const USDC_OPTIMISM: Asset = { + assetId: 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + chainId: optimismChainId, + symbol: 'USDC', + name: 'USD Coin on Optimism', + precision: 6, + color: '#2373CB', + icon: 'https://rawcdn.githack.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + explorer: 'https://optimistic.etherscan.io', + explorerAddressLink: 'https://optimistic.etherscan.io/address/', + explorerTxLink: 'https://optimistic.etherscan.io/tx/', + relatedAssetKey: null, +} + +export const USDC_BASE: Asset = { + assetId: 'eip155:8453/erc20:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', + chainId: baseChainId, + symbol: 'USDC', + name: 'USD Coin on Base', + precision: 6, + color: '#2373CB', + icon: 'https://rawcdn.githack.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + explorer: 'https://basescan.org', + explorerAddressLink: 'https://basescan.org/address/', + explorerTxLink: 'https://basescan.org/tx/', + relatedAssetKey: null, +} + +export const USDC_POLYGON: Asset = { + assetId: 'eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + chainId: polygonChainId, + symbol: 'USDC', + name: 'USD Coin on Polygon', + precision: 6, + color: '#2373CB', + icon: 'https://rawcdn.githack.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + explorer: 'https://polygonscan.com', + explorerAddressLink: 'https://polygonscan.com/address/', + explorerTxLink: 'https://polygonscan.com/tx/', + relatedAssetKey: null, +} + +export const ETH_BASE: Asset = { + assetId: baseAssetId, + chainId: baseChainId, + name: 'Ethereum on Base', + networkName: 'Base', + symbol: 'ETH', + precision: 18, + color: '#5C6BC0', + networkColor: '#0052FF', + icon: 'https://rawcdn.githack.com/trustwallet/assets/32e51d582a890b3dd3135fe3ee7c20c2fd699a6d/blockchains/ethereum/info/logo.png', + explorer: 'https://basescan.org', + explorerAddressLink: 'https://basescan.org/address/', + explorerTxLink: 'https://basescan.org/tx/', + relatedAssetKey: null, +} + +export const MATIC: Asset = { + assetId: polygonAssetId, + chainId: polygonChainId, + name: 'Polygon', + symbol: 'MATIC', + precision: 18, + color: '#8247E5', + icon: 'https://assets.coingecko.com/coins/images/4713/thumb/polygon.png?1698233745', + explorer: 'https://polygonscan.com', + explorerAddressLink: 'https://polygonscan.com/address/', + explorerTxLink: 'https://polygonscan.com/tx/', + relatedAssetKey: null, +} + +export const BSC_USDC: Asset = { + assetId: 'eip155:56/erc20:0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d', + chainId: bscChainId, + symbol: 'USDC', + name: 'USD Coin on BNB Smart Chain', + precision: 18, + color: '#2373CB', + icon: 'https://rawcdn.githack.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + explorer: 'https://bscscan.com', + explorerAddressLink: 'https://bscscan.com/address/', + explorerTxLink: 'https://bscscan.com/tx/', + relatedAssetKey: null, +} diff --git a/packages/swapper/src/types.ts b/packages/swapper/src/types.ts index dbca4b28c35..16d070df1f0 100644 --- a/packages/swapper/src/types.ts +++ b/packages/swapper/src/types.ts @@ -47,6 +47,7 @@ import type { AcrossTransactionMetadata } from './swappers/AcrossSwapper/utils/t import type { CowMessageToSign } from './swappers/CowSwapper/types' import type { DebridgeTransactionMetadata } from './swappers/DebridgeSwapper/utils/types' import type { RelayTransactionMetadata } from './swappers/RelaySwapper/utils/types' +import type { StargateTransactionMetadata } from './swappers/StargateSwapper/types' import type { makeSwapperAxiosServiceMonadic } from './utils' // TODO: Rename all properties in this type to be camel case and not react specific @@ -106,6 +107,7 @@ export enum SwapperName { Stonfi = 'STON.fi', Across = 'Across', Debridge = 'deBridge', + Stargate = 'Stargate', } export type SwapSource = SwapperName | `${SwapperName} • ${string}` @@ -510,6 +512,7 @@ export type TradeQuoteStep = { } acrossTransactionMetadata?: AcrossTransactionMetadata debridgeTransactionMetadata?: DebridgeTransactionMetadata + stargateTransactionMetadata?: StargateTransactionMetadata affiliateFee?: AffiliateFee } diff --git a/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/SwapperIcon.tsx b/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/SwapperIcon.tsx index 187578f40cb..466bf04c173 100644 --- a/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/SwapperIcon.tsx +++ b/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/SwapperIcon.tsx @@ -16,6 +16,7 @@ import MayachainIcon from './maya_logo.png' import NearIntentsIcon from './near-intents-icon.png' import PortalsIcon from './portals-icon.png' import RelayIcon from './relay-icon.svg' +import StargateIcon from './stargate-icon.png' import StonfiIcon from './stonfi-icon.png' import SunioIcon from './sunio-icon.png' import THORChainIcon from './thorchain-icon.png' @@ -66,6 +67,8 @@ export const SwapperIcon = ({ return AcrossIcon case SwapperName.Debridge: return DebridgeIcon + case SwapperName.Stargate: + return StargateIcon case SwapperName.Test: return '' default: diff --git a/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/stargate-icon.png b/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/stargate-icon.png new file mode 100644 index 00000000000..c48e306ed4d Binary files /dev/null and b/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/stargate-icon.png differ diff --git a/src/config.ts b/src/config.ts index 662cb529474..7cf1d7798f6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -279,6 +279,7 @@ const validators = { VITE_ACROSS_INTEGRATOR_ID: str({ default: '' }), VITE_FEATURE_DEBRIDGE_SWAP: bool({ default: false }), VITE_DEBRIDGE_API_URL: url({ default: 'https://dln.debridge.finance/v1.0' }), + VITE_FEATURE_STARGATE_SWAP: bool({ default: false }), VITE_FEATURE_TX_HISTORY_BYE_BYE: bool({ default: false }), VITE_AFFILIATE_REVENUE_URL: url(), VITE_FEATURE_LEDGER_READ_ONLY: bool({ default: false }), diff --git a/src/state/helpers.ts b/src/state/helpers.ts index ab979b91d28..07dff41f36f 100644 --- a/src/state/helpers.ts +++ b/src/state/helpers.ts @@ -21,6 +21,7 @@ export const isCrossAccountTradeSupported = (swapperName: SwapperName) => { case SwapperName.Sunio: case SwapperName.Across: case SwapperName.Debridge: + case SwapperName.Stargate: return true case SwapperName.Zrx: case SwapperName.CowSwap: @@ -55,6 +56,7 @@ export const getEnabledSwappers = ( StonfiSwap, AcrossSwap, DebridgeSwap, + StargateSwap, }: FeatureFlags, isCrossAccountTrade: boolean, walletName?: string, @@ -112,6 +114,8 @@ export const getEnabledSwappers = ( AcrossSwap && (!isCrossAccountTrade || isCrossAccountTradeSupported(SwapperName.Across)), [SwapperName.Debridge]: DebridgeSwap && (!isCrossAccountTrade || isCrossAccountTradeSupported(SwapperName.Debridge)), + [SwapperName.Stargate]: + StargateSwap && (!isCrossAccountTrade || isCrossAccountTradeSupported(SwapperName.Stargate)), [SwapperName.Test]: false, } } diff --git a/src/state/slices/preferencesSlice/preferencesSlice.ts b/src/state/slices/preferencesSlice/preferencesSlice.ts index 898fad5e6f8..7d17d78c65d 100644 --- a/src/state/slices/preferencesSlice/preferencesSlice.ts +++ b/src/state/slices/preferencesSlice/preferencesSlice.ts @@ -126,6 +126,7 @@ export type FeatureFlags = { StonfiSwap: boolean AcrossSwap: boolean DebridgeSwap: boolean + StargateSwap: boolean LazyTxHistory: boolean LedgerReadOnly: boolean QuickBuy: boolean @@ -299,6 +300,7 @@ const initialState: Preferences = { StonfiSwap: getConfig().VITE_FEATURE_STONFI_SWAP, AcrossSwap: getConfig().VITE_FEATURE_ACROSS_SWAP, DebridgeSwap: getConfig().VITE_FEATURE_DEBRIDGE_SWAP, + StargateSwap: getConfig().VITE_FEATURE_STARGATE_SWAP, LazyTxHistory: getConfig().VITE_FEATURE_TX_HISTORY_BYE_BYE, LedgerReadOnly: getConfig().VITE_FEATURE_LEDGER_READ_ONLY, QuickBuy: getConfig().VITE_FEATURE_QUICK_BUY, diff --git a/src/test/mocks/store.ts b/src/test/mocks/store.ts index 8ee38716dcf..5ae2a03337e 100644 --- a/src/test/mocks/store.ts +++ b/src/test/mocks/store.ts @@ -198,6 +198,7 @@ export const mockStore: ReduxState = { StonfiSwap: false, AcrossSwap: false, DebridgeSwap: false, + StargateSwap: false, LazyTxHistory: false, QuickBuy: false, NewWalletManager: false,