diff --git a/.env b/.env index 5f424cb4679..fbd0da403f1 100644 --- a/.env +++ b/.env @@ -116,6 +116,7 @@ VITE_FEATURE_TON=true VITE_FEATURE_EARN_TAB=true VITE_FEATURE_ACROSS_SWAP=true VITE_FEATURE_DEBRIDGE_SWAP=true +VITE_FEATURE_GARDEN_SWAP=false VITE_FEATURE_USERBACK=true VITE_FEATURE_AGENTIC_CHAT=false VITE_FEATURE_MM_NATIVE_MULTICHAIN=false @@ -330,6 +331,9 @@ VITE_FASTNEAR_API_URL=https://api.fastnear.com # jito VITE_JITO_BLOCK_ENGINE_URL=https://mainnet.block-engine.jito.wtf +# Garden +VITE_GARDEN_API_KEY= + # relay VITE_RELAY_API_URL=https://api.relay.link diff --git a/.env.development b/.env.development index 48d2f40d91b..dd6cd74038d 100644 --- a/.env.development +++ b/.env.development @@ -8,6 +8,8 @@ VITE_FEATURE_TX_HISTORY_BYE_BYE=true VITE_FEATURE_SWAPPER_FIAT_RAMPS=true VITE_FEATURE_WC_DIRECT_CONNECTION=true VITE_FEATURE_CETUS_SWAP=true +VITE_FEATURE_GARDEN_SWAP=true +VITE_GARDEN_API_KEY= VITE_FEATURE_MANTLE=true VITE_FEATURE_INK=true VITE_FEATURE_CRONOS=true diff --git a/headers/csps/defi/swappers/Garden.ts b/headers/csps/defi/swappers/Garden.ts new file mode 100644 index 00000000000..40078c8049b --- /dev/null +++ b/headers/csps/defi/swappers/Garden.ts @@ -0,0 +1,5 @@ +import type { Csp } from '../../../types' + +export const csp: Csp = { + 'connect-src': ['https://api.garden.finance'], +} diff --git a/headers/csps/index.ts b/headers/csps/index.ts index 93352adb685..89ac6f05f21 100644 --- a/headers/csps/index.ts +++ b/headers/csps/index.ts @@ -64,6 +64,7 @@ import { csp as avnu } from './defi/swappers/Avnu' import { csp as bebop } from './defi/swappers/Bebop' import { csp as butterSwap } from './defi/swappers/ButterSwap' import { csp as cowSwap } from './defi/swappers/CowSwap' +import { csp as garden } from './defi/swappers/Garden' import { csp as nearIntents } from './defi/swappers/NearIntents' import { csp as oneInch } from './defi/swappers/OneInch' import { csp as portals } from './defi/swappers/Portals' @@ -186,6 +187,7 @@ export const csps = [ avnu, bebop, cowSwap, + garden, nearIntents, oneInch, portals, diff --git a/packages/public-api/src/config.ts b/packages/public-api/src/config.ts index e9f73584b43..04c1d6fd8a0 100644 --- a/packages/public-api/src/config.ts +++ b/packages/public-api/src/config.ts @@ -30,6 +30,7 @@ export const getServerConfig = (): SwapperConfig => ({ VITE_RELAY_API_URL: env.RELAY_API_URL, VITE_BEBOP_API_KEY: env.BEBOP_API_KEY, VITE_NEAR_INTENTS_API_KEY: env.NEAR_INTENTS_API_KEY, + VITE_GARDEN_API_KEY: env.GARDEN_API_KEY, VITE_TENDERLY_API_KEY: env.TENDERLY_API_KEY, VITE_TENDERLY_ACCOUNT_SLUG: env.TENDERLY_ACCOUNT_SLUG, VITE_TENDERLY_PROJECT_SLUG: env.TENDERLY_PROJECT_SLUG, diff --git a/packages/public-api/src/env.ts b/packages/public-api/src/env.ts index 2704604dc40..840ea5c3d51 100644 --- a/packages/public-api/src/env.ts +++ b/packages/public-api/src/env.ts @@ -62,6 +62,7 @@ const envSchema = z.object({ ACROSS_INTEGRATOR_ID: z.string().default(''), BEBOP_API_KEY: z.string().min(1), CHAINFLIP_API_KEY: z.string().min(1), + GARDEN_API_KEY: z.string().default(''), NEAR_INTENTS_API_KEY: z.string().min(1), TENDERLY_API_KEY: z.string().min(1), TENDERLY_ACCOUNT_SLUG: z.string().min(1), diff --git a/packages/swapper/src/constants.ts b/packages/swapper/src/constants.ts index 40f5536380d..6b36d88faac 100644 --- a/packages/swapper/src/constants.ts +++ b/packages/swapper/src/constants.ts @@ -18,6 +18,8 @@ import { cowSwapper } from './swappers/CowSwapper/CowSwapper' import { cowApi } from './swappers/CowSwapper/endpoints' import { debridgeSwapper } from './swappers/DebridgeSwapper' import { debridgeApi } from './swappers/DebridgeSwapper/endpoints' +import { gardenApi } from './swappers/GardenSwapper/endpoints' +import { gardenSwapper } from './swappers/GardenSwapper/GardenSwapper' import { mayachainApi } from './swappers/MayachainSwapper/endpoints' import { mayachainSwapper } from './swappers/MayachainSwapper/MayachainSwapper' import { nearIntentsApi } from './swappers/NearIntentsSwapper/endpoints' @@ -116,6 +118,10 @@ export const swappers: Record = ...debridgeSwapper, ...debridgeApi, }, + [SwapperName.Garden]: { + ...gardenSwapper, + ...gardenApi, + }, [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_GARDEN_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.Garden: + return DEFAULT_GARDEN_SLIPPAGE_DECIMAL_PERCENTAGE default: return assertUnreachable(swapperName) } diff --git a/packages/swapper/src/index.ts b/packages/swapper/src/index.ts index 33e507c1c0b..e26acaf3f1e 100644 --- a/packages/swapper/src/index.ts +++ b/packages/swapper/src/index.ts @@ -1,6 +1,7 @@ export * from './constants' export * from './cowswap-utils' export * from './safe-utils' +export * from './starknet-utils' export * from './swapper' export * from './swappers/ArbitrumBridgeSwapper' export * from './swappers/AvnuSwapper' @@ -10,6 +11,7 @@ export * from './swappers/ChainflipSwapper' export * from './swappers/SunioSwapper' export * from './swappers/CowSwapper' export * from './swappers/DebridgeSwapper' +export * from './swappers/GardenSwapper' export * from './swappers/PortalsSwapper' export * from './swappers/ThorchainSwapper' export * from './swappers/MayachainSwapper' diff --git a/packages/swapper/src/starknet-utils/buildStarknetInvokeTx.ts b/packages/swapper/src/starknet-utils/buildStarknetInvokeTx.ts new file mode 100644 index 00000000000..f8dd4d30815 --- /dev/null +++ b/packages/swapper/src/starknet-utils/buildStarknetInvokeTx.ts @@ -0,0 +1,168 @@ +import type { starknet } from '@shapeshiftoss/chain-adapters' +import { toAddressNList } from '@shapeshiftoss/chain-adapters' +import { hash, num } from 'starknet' + +export const toHexString = (value: unknown): string => { + if (typeof value !== 'string') { + throw new Error(`toHexString: expected string, got ${typeof value}`) + } + if (value.startsWith('0x')) return value + if (/^[0-9a-fA-F]+$/.test(value) && /[a-fA-F]/.test(value)) { + return `0x${value}` + } + if (/^\d+$/.test(value)) { + return num.toHex(value) + } + throw new Error(`toHexString: ambiguous input ${JSON.stringify(value)}`) +} + +type StarknetEstimateResult = { + result?: { + l1_gas_consumed?: string + l1_gas_price?: string + l2_gas_consumed?: string + l2_gas_price?: string + l1_data_gas_consumed?: string + l1_data_gas_price?: string + }[] + error?: unknown +} + +export const buildStarknetInvokeTx = async ({ + formattedCalldata, + normalizedFrom, + accountNumber, + adapter, +}: { + formattedCalldata: string[] + normalizedFrom: string + accountNumber: number + adapter: starknet.ChainAdapter +}) => { + const chainIdHex = await adapter.getStarknetProvider().getChainId() + const nonce = await adapter.getNonce(normalizedFrom) + + const version = '0x3' as const + const estimateTx = { + type: 'INVOKE', + version, + sender_address: normalizedFrom, + calldata: formattedCalldata, + signature: [], + nonce, + resource_bounds: { + l1_gas: { max_amount: '0x186a0', max_price_per_unit: '0x5f5e100' }, + l2_gas: { max_amount: '0x0', max_price_per_unit: '0x0' }, + l1_data_gas: { max_amount: '0x186a0', max_price_per_unit: '0x1' }, + }, + tip: '0x0', + paymaster_data: [], + account_deployment_data: [], + nonce_data_availability_mode: 'L1', + fee_data_availability_mode: 'L1', + } + + const estimateResult: StarknetEstimateResult = await (async () => { + try { + const response = await adapter + .getStarknetProvider() + .fetch('starknet_estimateFee', [[estimateTx], ['SKIP_VALIDATE'], 'latest']) + return (await response.json()) as StarknetEstimateResult + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`starknet_estimateFee RPC call failed for ${normalizedFrom}: ${message}`) + } + })() + + if (estimateResult.error) { + throw new Error( + `starknet_estimateFee returned error for ${normalizedFrom}: ${JSON.stringify( + estimateResult.error, + )}`, + ) + } + + const feeEstimate = estimateResult.result?.[0] + if (!feeEstimate) { + throw new Error(`starknet_estimateFee returned no estimate for ${normalizedFrom}`) + } + + const l1GasConsumed = feeEstimate.l1_gas_consumed + ? BigInt(feeEstimate.l1_gas_consumed) + : BigInt('0x186a0') + const l1GasPrice = feeEstimate.l1_gas_price + ? BigInt(feeEstimate.l1_gas_price) + : BigInt('0x5f5e100') + const l2GasConsumed = feeEstimate.l2_gas_consumed + ? BigInt(feeEstimate.l2_gas_consumed) + : BigInt('0x0') + const l2GasPrice = feeEstimate.l2_gas_price ? BigInt(feeEstimate.l2_gas_price) : BigInt('0x0') + const l1DataGasConsumed = feeEstimate.l1_data_gas_consumed + ? BigInt(feeEstimate.l1_data_gas_consumed) + : BigInt('0x186a0') + const l1DataGasPrice = feeEstimate.l1_data_gas_price + ? BigInt(feeEstimate.l1_data_gas_price) + : BigInt('0x1') + + const resourceBounds = { + l1_gas: { + max_amount: (l1GasConsumed * BigInt(500)) / BigInt(100), + max_price_per_unit: (l1GasPrice * BigInt(200)) / BigInt(100), + }, + l2_gas: { + max_amount: (l2GasConsumed * BigInt(500)) / BigInt(100), + max_price_per_unit: (l2GasPrice * BigInt(200)) / BigInt(100), + }, + l1_data_gas: { + max_amount: (l1DataGasConsumed * BigInt(500)) / BigInt(100), + max_price_per_unit: (l1DataGasPrice * BigInt(200)) / BigInt(100), + }, + } + + const invokeHashInputs = { + senderAddress: normalizedFrom, + version, + compiledCalldata: formattedCalldata, + chainId: chainIdHex, + nonce, + nonceDataAvailabilityMode: 0 as const, + feeDataAvailabilityMode: 0 as const, + resourceBounds: { + l1_gas: { + max_amount: resourceBounds.l1_gas.max_amount, + max_price_per_unit: resourceBounds.l1_gas.max_price_per_unit, + }, + l2_gas: { + max_amount: resourceBounds.l2_gas.max_amount, + max_price_per_unit: resourceBounds.l2_gas.max_price_per_unit, + }, + l1_data_gas: { + max_amount: resourceBounds.l1_data_gas.max_amount, + max_price_per_unit: resourceBounds.l1_data_gas.max_price_per_unit, + }, + }, + tip: '0x0', + paymasterData: [], + accountDeploymentData: [], + } + + const txHash = hash.calculateInvokeTransactionHash(invokeHashInputs) + + return { + addressNList: toAddressNList(adapter.getBip44Params({ accountNumber })), + txHash, + _txDetails: { + fromAddress: normalizedFrom, + calldata: formattedCalldata, + nonce, + version, + resourceBounds, + chainId: chainIdHex, + nonceDataAvailabilityMode: 0 as const, + feeDataAvailabilityMode: 0 as const, + tip: '0x0', + paymasterData: [], + accountDeploymentData: [], + }, + } +} diff --git a/packages/swapper/src/starknet-utils/index.ts b/packages/swapper/src/starknet-utils/index.ts new file mode 100644 index 00000000000..627b0ab201c --- /dev/null +++ b/packages/swapper/src/starknet-utils/index.ts @@ -0,0 +1 @@ +export * from './buildStarknetInvokeTx' diff --git a/packages/swapper/src/swappers/AvnuSwapper/endpoints.ts b/packages/swapper/src/swappers/AvnuSwapper/endpoints.ts index 1b6d47a7bc6..0867dcab92e 100644 --- a/packages/swapper/src/swappers/AvnuSwapper/endpoints.ts +++ b/packages/swapper/src/swappers/AvnuSwapper/endpoints.ts @@ -1,7 +1,7 @@ import { quoteToCalls } from '@avnu/avnu-sdk' -import { toAddressNList } from '@shapeshiftoss/chain-adapters' -import { CallData, hash, num, validateAndParseAddress } from 'starknet' +import { CallData, hash, validateAndParseAddress } from 'starknet' +import { buildStarknetInvokeTx, toHexString } from '../../starknet-utils' import type { SwapperApi, TradeStatus } from '../../types' import { checkStarknetSwapStatus, @@ -12,33 +12,6 @@ import { import { getTradeQuote } from './swapperApi/getTradeQuote' import { getTradeRate } from './swapperApi/getTradeRate' -/** - * Normalize a value to hex format for Starknet RPC - * Handles various input types: decimal strings, hex strings (with/without 0x), numbers, BigInts - */ -const toHexString = (value: unknown): string => { - const strValue = String(value) - - // Already a proper hex string with 0x prefix - if (strValue.startsWith('0x')) { - return strValue - } - - // Check if it looks like a hex string without 0x prefix (contains a-f characters) - // Starknet addresses and felts often come as hex without 0x prefix - if (/^[0-9a-fA-F]+$/.test(strValue) && /[a-fA-F]/.test(strValue)) { - return `0x${strValue}` - } - - // Otherwise treat as decimal and convert to hex - try { - return num.toHex(strValue) - } catch { - // If conversion fails, assume it's already hex and add prefix - return `0x${strValue}` - } -} - export const avnuApi: SwapperApi = { getTradeQuote, getTradeRate: (input, deps) => { @@ -102,139 +75,14 @@ export const avnuApi: SwapperApi = { ) } - // Format calldata for RPC (convert all values to proper hex format) const formattedCalldata = fullCalldata.map(toHexString) - // Get nonce using adapter method (checks deployment status and returns appropriate nonce) - const chainIdHex = await adapter.getStarknetProvider().getChainId() - const nonce = await adapter.getNonce(normalizedFrom) - - // Estimate fees for the multi-call swap transaction - const version = '0x3' as const - const estimateTx = { - type: 'INVOKE', - version, - sender_address: normalizedFrom, - calldata: formattedCalldata, - signature: [], - nonce, - resource_bounds: { - l1_gas: { max_amount: '0x186a0', max_price_per_unit: '0x5f5e100' }, - l2_gas: { max_amount: '0x0', max_price_per_unit: '0x0' }, - l1_data_gas: { max_amount: '0x186a0', max_price_per_unit: '0x1' }, - }, - tip: '0x0', - paymaster_data: [], - account_deployment_data: [], - nonce_data_availability_mode: 'L1', - fee_data_availability_mode: 'L1', - } - - const estimateResponse = await adapter - .getStarknetProvider() - .fetch('starknet_estimateFee', [[estimateTx], ['SKIP_VALIDATE'], 'latest']) - const estimateResult: { - result?: { - l1_gas_consumed?: string - l1_gas_price?: string - l2_gas_consumed?: string - l2_gas_price?: string - l1_data_gas_consumed?: string - l1_data_gas_price?: string - }[] - error?: unknown - } = await estimateResponse.json() - - if (estimateResult.error) { - throw new Error(`Fee estimation failed: ${JSON.stringify(estimateResult.error)}`) - } - - const feeEstimate = estimateResult.result?.[0] - if (!feeEstimate) { - throw new Error('Fee estimation failed: no estimate returned') - } - - // Calculate resource bounds with buffer (5x gas amount, 2x gas price) - const l1GasConsumed = feeEstimate.l1_gas_consumed - ? BigInt(feeEstimate.l1_gas_consumed) - : BigInt('0x186a0') - const l1GasPrice = feeEstimate.l1_gas_price - ? BigInt(feeEstimate.l1_gas_price) - : BigInt('0x5f5e100') - const l2GasConsumed = feeEstimate.l2_gas_consumed - ? BigInt(feeEstimate.l2_gas_consumed) - : BigInt('0x0') - const l2GasPrice = feeEstimate.l2_gas_price ? BigInt(feeEstimate.l2_gas_price) : BigInt('0x0') - const l1DataGasConsumed = feeEstimate.l1_data_gas_consumed - ? BigInt(feeEstimate.l1_data_gas_consumed) - : BigInt('0x186a0') - const l1DataGasPrice = feeEstimate.l1_data_gas_price - ? BigInt(feeEstimate.l1_data_gas_price) - : BigInt('0x1') - - const resourceBounds = { - l1_gas: { - max_amount: (l1GasConsumed * BigInt(500)) / BigInt(100), - max_price_per_unit: (l1GasPrice * BigInt(200)) / BigInt(100), - }, - l2_gas: { - max_amount: (l2GasConsumed * BigInt(500)) / BigInt(100), - max_price_per_unit: (l2GasPrice * BigInt(200)) / BigInt(100), - }, - l1_data_gas: { - max_amount: (l1DataGasConsumed * BigInt(500)) / BigInt(100), - max_price_per_unit: (l1DataGasPrice * BigInt(200)) / BigInt(100), - }, - } - - // Calculate transaction hash for signing - const invokeHashInputs = { - senderAddress: normalizedFrom, - version, - compiledCalldata: formattedCalldata, - chainId: chainIdHex, - nonce, - nonceDataAvailabilityMode: 0 as const, // L1 - feeDataAvailabilityMode: 0 as const, // L1 - resourceBounds: { - l1_gas: { - max_amount: resourceBounds.l1_gas.max_amount, - max_price_per_unit: resourceBounds.l1_gas.max_price_per_unit, - }, - l2_gas: { - max_amount: resourceBounds.l2_gas.max_amount, - max_price_per_unit: resourceBounds.l2_gas.max_price_per_unit, - }, - l1_data_gas: { - max_amount: resourceBounds.l1_data_gas.max_amount, - max_price_per_unit: resourceBounds.l1_data_gas.max_price_per_unit, - }, - }, - tip: '0x0', - paymasterData: [], - accountDeploymentData: [], - } - - const txHash = hash.calculateInvokeTransactionHash(invokeHashInputs) - - // Return transaction ready for signing - return { - addressNList: toAddressNList(adapter.getBip44Params({ accountNumber })), - txHash, - _txDetails: { - fromAddress: normalizedFrom, - calldata: formattedCalldata, - nonce, - version, - resourceBounds, - chainId: chainIdHex, - nonceDataAvailabilityMode: 0 as const, - feeDataAvailabilityMode: 0 as const, - tip: '0x0', - paymasterData: [], - accountDeploymentData: [], - }, - } + return buildStarknetInvokeTx({ + formattedCalldata, + normalizedFrom, + accountNumber, + adapter, + }) }, getStarknetTransactionFees: ({ tradeQuote, stepIndex }) => { diff --git a/packages/swapper/src/swappers/GardenSwapper/GardenSwapper.ts b/packages/swapper/src/swappers/GardenSwapper/GardenSwapper.ts new file mode 100644 index 00000000000..3c9ca790390 --- /dev/null +++ b/packages/swapper/src/swappers/GardenSwapper/GardenSwapper.ts @@ -0,0 +1,10 @@ +import type { Swapper } from '../../types' +import { executeEvmTransaction, executeStarknetTransaction } from '../../utils' + +export const gardenSwapper: Swapper = { + executeEvmTransaction, + executeStarknetTransaction, + executeUtxoTransaction: (txToSign, { signAndBroadcastTransaction }) => { + return signAndBroadcastTransaction(txToSign) + }, +} diff --git a/packages/swapper/src/swappers/GardenSwapper/INTEGRATION.md b/packages/swapper/src/swappers/GardenSwapper/INTEGRATION.md new file mode 100644 index 00000000000..70646e2940a --- /dev/null +++ b/packages/swapper/src/swappers/GardenSwapper/INTEGRATION.md @@ -0,0 +1,399 @@ +# Garden Swapper Integration + +## Overview + +- **Website**: https://garden.finance +- **API Docs**: https://docs.garden.finance +- **Type**: Deposit-to-address cross-chain (HTLC + intent solvers) +- **MVP scope**: BTC ↔ strkBTC only + +The Garden integration runs on the same deposit-to-address shape as +`NearIntentsSwapper` (`getTradeQuote` resolves to a deposit address that the +user funds via `executeUtxoTransaction` or `executeStarknetTransaction`). +Status is polled via `GET /v2/orders/{id}`. + +## Why only BTC ↔ strkBTC + +Garden's `/policy` endpoint explicitly blacklists every `starknet:strkbtc` +pair except `bitcoin:btc` as of strkBTC launch (May 2026). This is enforced +locally in `utils/helpers/helpers.ts → isSupportedGardenPair`, with the +remote `/policy` errors mapped to `TradeQuoteError.NoRouteFound`. + +## API Details + +- **Base URL**: `https://api.garden.finance/v2` +- **Authentication**: `garden-app-id` header (get a key from + https://portal.garden.finance) +- **Endpoints used**: + - `GET /quote` — indicative + binding quote, supports `affiliate_fee` (bps) + - `POST /orders` — creates an order, returns chain-specific initiate data + - `GET /orders/{order_id}` — status polling + - `GET /apps/earnings` — affiliate earnings (DAO ops, weekly batch claim) + +## Response shapes (verified live via curl spike) + +### `GET /quote` +```json +{ + "status": "Ok", + "result": [{ + "source": { "asset": "starknet:strkbtc", "amount": "100000", ... }, + "destination": { "asset": "bitcoin:btc", "amount": "99150", ... }, + "solver_id": "0xa3c4b7f912e8f56d9b2a1ec44b0c578a9fe12c8d", + "estimated_time": 20, + "slippage": 0, + "fee": 30, + "fixed_fee": "0" + }] +} +``` + +Per the Garden OpenAPI spec (`docs.garden.finance/api-reference/openapi.json`): +- `fee` (integer) — **In BIPS**, where 100 bips = 1% +- `fixed_fee` (string) — **In USD** (decimal string) +- `slippage` (integer) — **In BIPS** +- `estimated_time` (integer) — In seconds + +### `POST /orders` — Bitcoin source +```json +{ + "status": "Ok", + "result": { + "order_id": "...", + "to": "bc1p0ndhv28j3qsw3vhevj2lkw9phhyyqayjxn7we3fln9z9gq9h208sm20qrh", + "amount": "100000" + } +} +``` + +### `POST /orders` — Starknet source +```json +{ + "status": "Ok", + "result": { + "order_id": "...", + "approval_transaction": { "to": "", "selector": "0x219...", "calldata": ["", "0xffff...", "0xffff..."] }, + "initiate_transaction": { "to": "", "selector": "0x2aed...", "calldata": [...] }, + "typed_data": { /* SNIP-12 — unused, we submit the direct multi-call */ } + } +} +``` + +## Fee model (verified against `api.garden.finance` live) + +Garden returns **two additive fee components per route**, both set by the +winning solver: + +- `fee` (BIPS) — percentage cut on the destination amount +- `fixed_fee` (USD string) — fixed amount added on top + +Effective fee = `amount × fee/10000 + fixed_fee_in_destination_units`. + +The split varies per route (solver competition + destination-chain gas +cost + asset liquidity premium). Snapshot 2026-05-15: + +| Route | `fee` | `fixed_fee` | Effective on $1,558 trade | +| --- | --- | --- | --- | +| `BTC → strkBTC` | 30 bps | $0 | $4.68 (30 bps) | +| `BTC → cbBTC.base` | 21 bps | $0 | $3.27 (21 bps) | +| `BTC → WBTC.eth` | 0 bps | $2 | $2.00 (13 bps) | +| `WBTC.eth → cbBTC.base` | 35 bps | $2 | $7.46 (48 bps) | + +Values are not hard-coded by Garden — they reflect solver economics +(capital lockup during HTLC settlement, destination-chain gas absorption, +inventory premium for new assets). They drift over time as liquidity +shifts. + +This fee is **already baked into the displayed rate** (`destination.amount` +is the net amount the user receives). The ShapeShift `protocolFees` field +on `TradeQuoteStep` surfaces only the affiliate cut on top, not Garden's +own fee — mirroring the AvnuSwapper convention. + +## Source-chain support is asymmetric (buy-only Solana/Tron) + +`gardenAssetRegistry.ts` enumerates every Garden-listed asset (24 entries +across 11 chains) so they all work as **destinations**: + + `BTC → solana:cbbtc`, `BTC → solana:sol`, `BTC → tron:usdt`, etc. + +`GardenSupportedSourceChainIds` in `utils/helpers/helpers.ts` is the +narrower list of chains we have execution wired for: + + Bitcoin, Litecoin, Ethereum, Base, BNB Chain, Arbitrum, Monad, + HyperEVM, MegaETH, Starknet + +Solana and Tron are **deliberately omitted** from the source list — Garden +returns valid quotes for them, but their `executeSolanaTransaction` / +`executeTronTransaction` paths aren't implemented yet in `GardenSwapper.ts` +(no `getUnsignedSolanaTransaction` / `getUnsignedTronTransaction` either). +`isSupportedGardenPair` rejects them upstream of `getTradeQuote` so the +user gets `UnsupportedTradePair` rather than a runtime crash. + +Follow-up to enable: implement Garden's gasless `typed_data` flow for +Solana SPL / Tron / Starknet sources (Garden docs: PATCH +`/v2/orders/{id}?action=initiate` with signed payload). Tracked as +`web-c68.6`. + +## Implementation notes + +### Slippage format +Garden uses **bps integer**, where 100 bps = 1%. ShapeShift internal is +decimal (0.005 = 0.5%). Conversion in `slippageDecimalToBps` (`helpers.ts`). + +### Affiliate fee +Two fields: +- `affiliate_fee=N` as a **query string int** on `GET /quote` +- `affiliate_fees: [{ asset, address, fee }]` as a **JSON array** on + `POST /orders` + +Both MUST be passed in tandem to avoid the rate ↔ quote delta gotcha +(see `.claude/skills/swapper-integration/common-gotchas.md` §6). + +### Affiliate fee asset constraint +Garden only accepts a restricted set of `(asset, address)` pairs as the +fee recipient. We verified the live API matrix during the spike: + +| Asset | Status | +| --- | --- | +| `base:cbbtc` | ✅ works | +| `ethereum:cbbtc` | ✅ works | +| `ethereum:usdc` | ✅ works | +| `base:usdc` | ❌ rejected | +| `arbitrum:usdc` | ❌ rejected | +| `arbitrum:cbbtc` | ❌ rejected | + +We use `base:cbbtc` → `DAO_TREASURY_BASE` to keep the fee BTC-denominated +and avoid the higher withdraw friction of Ethereum mainnet. The published +OpenAPI enum lists 6 options — only 3 actually accept on mainnet. + +### Affiliate fee distribution +Garden distributes affiliate fees **weekly**, batched, via an on-chain +claim transaction on the distributor contract. Treasury ops need to +periodically: +1. Call `GET /apps/earnings` to get claimable amounts +2. Submit a claim tx on the distributor contract + +This is treasury-side work, not part of the swapper itself. + +### Solver ID requirement +`POST /orders` requires `solver_id` from the quote. Omitting it returns +`"Invalid strategy id"`. We fetch a fresh quote inside `getTradeQuote` and +pass `quote.solver_id` to the order request. + +### Destination amount must match +`destination.amount` on `POST /orders` MUST equal the quote's destination +amount exactly, otherwise the order is rejected. We use +`quote.destination.amount` verbatim. + +### Bitcoin source flow +The order response gives a `to` field — a P2TR (Pay-to-Taproot) script +address. We send a UTXO transaction to this address via +`executeUtxoTransaction`. ShapeShift's UTXO adapter handles P2TR correctly +(NEAR Intents uses the same pattern). + +### Starknet source flow +The order response gives `approval_transaction` and `initiate_transaction` +with pre-built calldata. We combine these into a single Starknet INVOKE +transaction (multi-call), mirroring `AvnuSwapper.getUnsignedStarknetTransaction`. + +Garden returns selectors pre-hashed, so we do NOT call +`hash.getSelectorFromName(entrypoint)` like AvnuSwapper — we use the +selector strings as-is. + +### Status mapping +Garden does not expose a single canonical status enum at the order level. +We derive the ShapeShift `TxStatus` from the order shape: +- `destination_swap.redeem_tx_hash` populated → `Confirmed` + (use that hash as `buyTxHash`; `filled_amount` becomes + `actualBuyAmountCryptoBaseUnit`) +- `source_swap.refund_tx_hash` or `destination_swap.refund_tx_hash` populated + → `Failed` with "Swap refunded" +- Otherwise → `Pending` + +The Garden docs' definition matches: *"The swap is complete once the +`order.destination_swap.redeem_tx_hash` field is populated."* + +### Quote staleness +Garden quotes expire — the underlying HTLC `timelock` (per-swap) is +short (typically minutes-to-hours). Order creation happens inside +`getTradeQuote` to minimise the gap between quote and on-chain +initiate. If the user delays past the HTLC timeout, the solver will +issue a `refund_tx_hash` and our status mapping surfaces it as +`Failed`. + +### Min / max amounts and liquidity caps + +There are **two independent caps** on a Garden swap, both enforced by the +quote endpoint: + +1. **Per-asset hard min / max** from `GET /v2/assets`. Static, encoded + in the asset config. Examples (snapshot 2026-05-15): + - All BTC-pegged assets (`btc`, `wbtc`, `cbbtc`, `btcb`, `strkbtc`, + `ubtc`, `btc.b`): `0.0001` → `5` units (~$8 → ~$395k) + - `litecoin:ltc`: `0.01` → `6,500` LTC (~$0.57 → ~$372k) + - Stablecoins (`usdc`, `usdt`): `10` → `450,000` units (~$10 → ~$450k) + - `solana:sol`: `0.1` → `3,500` SOL (~$9 → ~$313k) + - `monad:mon`: `470` → `20,000,000` MON + Out-of-range requests return `"expected amount to be within the range + of X to Y"`, mapped to `TradeQuoteError.SellAmountBelowMinimum`. + +2. **Per-route solver liquidity cap** from `GET /v2/liquidity`. Dynamic, + refreshed continuously. Garden returns one entry per (solver, asset) + pair with `balance` / `virtual_balance` / `fiat_value`. The route + `bitcoin:btc → X` is capped by the total destination-side `X` + liquidity across all solvers. When the request exceeds it, the quote + endpoint returns `"insufficient liquidity"`, which we map to + `TradeQuoteError.NoRouteFound` so the UI prompts the user to retry + with a smaller amount. Snapshot of available destination liquidity at + integration time: + + | Destination asset | Available | Solvers | + | --- | ---: | ---: | + | `bitcoin:btc` | $1,472,534 | 4 | + | `ethereum:cbbtc` | $316,447 | 1 | + | `ethereum:wbtc` | $313,110 | 2 | + | `base:cbbtc` | $215,562 | 3 | + | `litecoin:ltc` | $127,749 | 1 | + | `arbitrum:ibtc` | $86,223 | 1 | + | `solana:cbbtc` | $79,114 | 1 | + | `bnbchain:btcb` | $74,440 | 2 | + | `starknet:strkbtc` | $70,345 | 1 | + | `starknet:wbtc` | $9,660 | 1 | + | `arbitrum:wbtc` | **$71** | 1 | + | `monad:mon` | **$36** | 1 | + + Boundary tests (BTC source, in BTC): + - `BTC → ethereum:cbbtc`: OK at 4 BTC ($316k), fails at 4.5 BTC + - `BTC → starknet:strkbtc`: OK at 0.8 BTC ($63k), fails at 0.9 BTC + - `BTC → arbitrum:wbtc`: only 0.0001 BTC ($8) works (~$71 cap) + + For the reverse direction (sell), the cap follows the destination + side. `strkbtc → btc` works only up to ~0.01 strkBTC at the time of + integration despite Bitcoin having $1.47M total — solver-specific + per-swap caps come into play for the smaller side. + +The integration does **not** pre-fetch `/v2/liquidity` to enforce caps +client-side. We rely on Garden's `/quote` endpoint as the source of +truth and surface its errors via `TradeQuoteError.NoRouteFound`. To +sanity-check live caps without firing a swap: + +```bash +curl -sL "https://api.garden.finance/v2/liquidity" \ + -H "garden-app-id: $YOUR_APP_ID" | jq '.result[].liquidity' +``` + +## Trust & security context + +Garden had a ~$10M solver-layer exploit on 2025-10-30. Per Garden's +post-mortem (with EY) the protocol's HTLC contracts were not compromised +— solver and user funds are architecturally separated, and no user funds +were affected by the exploit. Trail of Bits has audited the contracts +(Nov 2025). + +Pre-hack, ZachXBT publicly raised concerns about volume composition. +We surface these in the integration PR description so DAO/governance +can sign off explicitly. + +Garden's other integrators today include Coinbase, MetaMask, Phantom, +Robinhood Wallet, Kraken, and Ledger (~$2B+ cumulative volume per +`docs.garden.finance/llms.txt`). LI.FI's aggregator also includes +Garden (verified via `https://li.quest/v1/tools`). + +## Known gotchas + +1. **OpenAPI enum is stale**: at the time of integration, Garden's + published OpenAPI Asset enum did not include `starknet:strkbtc`. We + type the asset list manually. Re-pulling generated types from + `api-reference/openapi.json` will not detect strkBTC support. + +2. **Affiliate fee asset rejection messages** are misleading: the error + `"Asset 'base:0x5fa58e...' not found"` is returned when the affiliate + fee `asset` field is one of the rejected entries (e.g. `base:usdc`). + Use `base:cbbtc` to avoid this. + +3. **`solver_id` is required on order creation** despite being marked + optional in the OpenAPI spec. + +4. **Starknet address normalization** is required (`validateAndParseAddress` + from `starknet.js`). The address that Garden returns for the strkBTC + token has a missing leading zero relative to the canonical form + (`0x787150...` vs `0x0787150...`). Always normalize. + +## Local end-to-end testing with Merry + +Garden ships a Docker-based localnet (`merry`) that includes a Bitcoin +regtest node, EVM localnet (Ethereum + Arbitrum), a simulated solver, a +local orderbook, and a faucet. It does **not** include Starknet, so +strkBTC ↔ BTC specifically can only be exercised on mainnet. Merry is +still useful to validate the deposit-to-address flow, status polling, +and affiliate fee plumbing against a non-production environment by using +BTC ↔ Eth/Arb routes, which share the same code path. + +```bash +# Install (Docker required) +curl https://get.merry.dev | bash + +# Start everything (Bitcoin regtest, EVM nodes, orderbook, filler) +merry go + +# Endpoints: +# Bitcoin RPC localhost:18443 +# Ethereum RPC localhost:8545 +# Arbitrum RPC localhost:8546 +# Orderbook API localhost:8080 +# Bitcoin explorer localhost:5050 +# Ethereum explorer localhost:5100 + +# Fund a test address +merry faucet --to
+ +# Tail solver logs while running test swaps +merry logs -s filler + +merry stop -d # tear down + delete state +``` + +To point the swapper at the local orderbook, override +`GARDEN_API_BASE_URL` to `http://localhost:8080` in the constants file +during local test runs (do not commit that change). + +## Required follow-ups before enabling in production + +- [ ] Stand up the weekly affiliate fee claim workflow (DAO ops, off-code) +- [ ] Manual mainnet test ~$20 in each direction +- [ ] Set `VITE_FEATURE_GARDEN_SWAP=true` in `.env.production` only after + governance sign-off given Garden's history (see PR description) + +## Local build prerequisite + +`pnpm run build:packages` indirectly invokes `openapi-generator-cli` +(via `unchained-client`), which is a Node wrapper around a JDK tool. +On macOS, `brew install openjdk` and add `/opt/homebrew/opt/openjdk/bin` +to your `PATH`. Without Java the entire packages build fails before +swapper code is touched. + +## Pre-compressed asset gotcha + +`public/generated/` ships pre-compressed `.br` (Brotli) and `.gz` (gzip) +siblings of every JSON file. Vite serves these to any client sending an +`Accept-Encoding` header (i.e., every browser). When you edit a JSON +under `public/generated/` you MUST regenerate the matching `.br` and +`.gz`, otherwise browsers will silently keep loading stale content while +`curl` shows the new content. Symptom for this integration: strkBTC was +not appearing in the buy asset search even though the JSON on disk had +it and `curl` returned it. The fix: + +```bash +node -e " +const zlib = require('zlib'), fs = require('fs'); +for (const f of ['public/generated/asset-manifest.json', 'public/generated/generatedAssetData.json']) { + const d = fs.readFileSync(f); + fs.writeFileSync(f + '.br', zlib.brotliCompressSync(d, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 } })); + fs.writeFileSync(f + '.gz', zlib.gzipSync(d, { level: 9 })); +}" +``` + +This is normally handled by the asset-data regeneration script, but is +easy to miss when patching the JSONs by hand (e.g. inserting a brand-new +asset that CoinGecko hasn't indexed yet, like strkBTC at the time of +this integration). diff --git a/packages/swapper/src/swappers/GardenSwapper/constants.ts b/packages/swapper/src/swappers/GardenSwapper/constants.ts new file mode 100644 index 00000000000..2c1ec331d64 --- /dev/null +++ b/packages/swapper/src/swappers/GardenSwapper/constants.ts @@ -0,0 +1,21 @@ +import { CHAIN_NAMESPACE } from '@shapeshiftoss/caip' +import { + DAO_TREASURY_BASE, + DAO_TREASURY_BITCOIN, + DAO_TREASURY_STARKNET, +} from '@shapeshiftoss/utils' + +export const GARDEN_API_BASE_URL = 'https://api.garden.finance/v2' + +export const GARDEN_API_KEY_HEADER = 'garden-app-id' + +export const GARDEN_AFFILIATE_FEE_ASSET = 'base:cbbtc' as const +export const GARDEN_AFFILIATE_FEE_RECIPIENT = DAO_TREASURY_BASE + +const GARDEN_EVM_FEE_PLACEHOLDER = '0x000000000000000000000000000000000000dead' + +export const GARDEN_FEE_PLACEHOLDER_BY_NAMESPACE: Record = { + [CHAIN_NAMESPACE.Utxo]: DAO_TREASURY_BITCOIN, + [CHAIN_NAMESPACE.Evm]: GARDEN_EVM_FEE_PLACEHOLDER, + [CHAIN_NAMESPACE.Starknet]: DAO_TREASURY_STARKNET, +} diff --git a/packages/swapper/src/swappers/GardenSwapper/endpoints.ts b/packages/swapper/src/swappers/GardenSwapper/endpoints.ts new file mode 100644 index 00000000000..4ab175bc8d6 --- /dev/null +++ b/packages/swapper/src/swappers/GardenSwapper/endpoints.ts @@ -0,0 +1,223 @@ +import { CHAIN_NAMESPACE, fromAssetId } from '@shapeshiftoss/caip' +import { evm } from '@shapeshiftoss/chain-adapters' +import type { EvmChainId } from '@shapeshiftoss/types' +import { validateAndParseAddress } from 'starknet' + +import { buildStarknetInvokeTx, toHexString } from '../../starknet-utils' +import type { SwapperApi, TradeStatus, UtxoFeeData } from '../../types' +import { + checkStarknetSwapStatus, + createDefaultStatusResponse, + getExecutableTradeStep, + isExecutableTradeQuote, +} from '../../utils' +import { getTradeQuote } from './swapperApi/getTradeQuote' +import { getTradeRate } from './swapperApi/getTradeRate' +import { fetchGardenOrder } from './utils/fetchFromGarden' +import { mapGardenOrderToTxStatus } from './utils/helpers/helpers' + +export const gardenApi: SwapperApi = { + getTradeQuote, + getTradeRate, + + getUnsignedUtxoTransaction: ({ + stepIndex, + tradeQuote, + assertGetUtxoChainAdapter, + xpub, + accountType, + }) => { + if (!isExecutableTradeQuote(tradeQuote)) throw new Error('Unable to execute a trade rate quote') + + const step = getExecutableTradeStep(tradeQuote, stepIndex) + + const { sellAsset, accountNumber, gardenSpecific, feeData } = step + if (!gardenSpecific?.bitcoinDepositAddress) { + throw new Error('gardenSpecific.bitcoinDepositAddress is required for UTXO source') + } + if (!xpub) throw new Error('xpub is required for UTXO transactions') + + const adapter = assertGetUtxoChainAdapter(sellAsset.chainId) + + const satoshiPerByte = (feeData.chainSpecific as UtxoFeeData | undefined)?.satsPerByte + if (!satoshiPerByte) { + throw new Error('Missing satsPerByte in quote fee data') + } + + return adapter.buildSendApiTransaction({ + accountNumber, + to: gardenSpecific.bitcoinDepositAddress, + value: step.sellAmountIncludingProtocolFeesCryptoBaseUnit, + sendMax: false, + chainSpecific: { + satoshiPerByte, + accountType, + }, + xpub, + }) + }, + + getUtxoTransactionFees: ({ tradeQuote, stepIndex }) => { + if (!isExecutableTradeQuote(tradeQuote)) throw new Error('Unable to execute a trade rate quote') + const step = getExecutableTradeStep(tradeQuote, stepIndex) + if (!step.feeData.networkFeeCryptoBaseUnit) { + throw new Error('Missing network fee in quote') + } + return Promise.resolve(step.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, sellAsset, gardenSpecific } = step + if (!gardenSpecific?.evmInitiate) { + throw new Error('gardenSpecific.evmInitiate is required for EVM source') + } + + const adapter = assertGetEvmChainAdapter(sellAsset.chainId as EvmChainId) + const { to, data, value } = gardenSpecific.evmInitiate + + const feeData = await evm.getFees({ + adapter, + data, + to, + value, + from, + supportsEIP1559, + }) + + return adapter.buildSendApiTransaction({ + accountNumber, + from, + to, + value, + chainSpecific: { contractAddress: undefined, data, ...feeData }, + }) + }, + + 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 { sellAsset, gardenSpecific } = step + if (!gardenSpecific?.evmInitiate) { + throw new Error('gardenSpecific.evmInitiate is required for EVM source') + } + + const adapter = assertGetEvmChainAdapter(sellAsset.chainId as EvmChainId) + const { to, data, value } = gardenSpecific.evmInitiate + + const feeData = await evm.getFees({ + adapter, + data, + to, + value, + from, + supportsEIP1559, + }) + + return feeData.networkFeeCryptoBaseUnit + }, + + getUnsignedStarknetTransaction: ({ + stepIndex, + tradeQuote, + from, + assertGetStarknetChainAdapter, + }) => { + if (!isExecutableTradeQuote(tradeQuote)) throw new Error('Unable to execute a trade rate quote') + + const step = getExecutableTradeStep(tradeQuote, stepIndex) + + const { accountNumber, sellAsset, gardenSpecific } = step + if (!gardenSpecific?.starknetCalls || gardenSpecific.starknetCalls.length === 0) { + throw new Error('gardenSpecific.starknetCalls is required for Starknet source') + } + + const adapter = assertGetStarknetChainAdapter(sellAsset.chainId) + const normalizedFrom = validateAndParseAddress(from) + + const fullCalldata: string[] = [gardenSpecific.starknetCalls.length.toString()] + for (const call of gardenSpecific.starknetCalls) { + const normalizedContractAddress = validateAndParseAddress(call.to) + fullCalldata.push( + normalizedContractAddress, + call.selector, + call.calldata.length.toString(), + ...call.calldata.map(String), + ) + } + + const formattedCalldata = fullCalldata.map(toHexString) + + return buildStarknetInvokeTx({ + formattedCalldata, + normalizedFrom, + accountNumber, + adapter, + }) + }, + + getStarknetTransactionFees: ({ tradeQuote, stepIndex }) => { + if (!isExecutableTradeQuote(tradeQuote)) throw new Error('Unable to execute a trade rate quote') + const step = getExecutableTradeStep(tradeQuote, stepIndex) + if (!step.feeData.networkFeeCryptoBaseUnit) { + throw new Error('Missing network fee in quote') + } + return Promise.resolve(step.feeData.networkFeeCryptoBaseUnit) + }, + + checkTradeStatus: async ({ + config, + swap, + assertGetStarknetChainAdapter, + }): Promise => { + const orderId = swap?.metadata.gardenSpecific?.orderId + + if (!orderId) { + const isStarknetSource = + swap?.sellAsset?.assetId !== undefined && + fromAssetId(swap.sellAsset.assetId).chainNamespace === CHAIN_NAMESPACE.Starknet + if (swap?.sellTxHash && isStarknetSource) { + return checkStarknetSwapStatus({ + txHash: swap.sellTxHash, + assertGetStarknetChainAdapter, + }) + } + return createDefaultStatusResponse(swap?.buyTxHash) + } + + const orderResult = await fetchGardenOrder({ + apiKey: config.VITE_GARDEN_API_KEY, + orderId, + }) + + if (orderResult.isErr()) { + return createDefaultStatusResponse(swap?.buyTxHash) + } + + const order = orderResult.unwrap() + const { status, buyTxHash, message, actualBuyAmountCryptoBaseUnit } = + mapGardenOrderToTxStatus(order) + + return { + status, + buyTxHash: buyTxHash ?? swap?.buyTxHash, + message, + actualBuyAmountCryptoBaseUnit, + } + }, +} diff --git a/packages/swapper/src/swappers/GardenSwapper/gardenAssetRegistry.ts b/packages/swapper/src/swappers/GardenSwapper/gardenAssetRegistry.ts new file mode 100644 index 00000000000..127f4549b71 --- /dev/null +++ b/packages/swapper/src/swappers/GardenSwapper/gardenAssetRegistry.ts @@ -0,0 +1,119 @@ +import type { AssetId } from '@shapeshiftoss/caip' +import { + arbitrumChainId, + ASSET_NAMESPACE, + baseChainId, + bscChainId, + btcAssetId, + ethChainId, + hyperEvmChainId, + ltcChainId, + megaethChainId, + monadChainId, + solanaChainId, + starknetChainId, + toAssetId, + tronChainId, +} from '@shapeshiftoss/caip' + +export type GardenAssetEntry = { + id: string + assetId: AssetId +} + +const ltcAssetId = toAssetId({ + chainId: ltcChainId, + assetNamespace: ASSET_NAMESPACE.slip44, + assetReference: '2', +}) + +const erc20 = (chainId: typeof ethChainId, address: string): AssetId => + toAssetId({ + chainId, + assetNamespace: ASSET_NAMESPACE.erc20, + assetReference: address.toLowerCase(), + }) + +const monadNativeAssetId = toAssetId({ + chainId: monadChainId, + assetNamespace: ASSET_NAMESPACE.slip44, + assetReference: '60', +}) + +const solanaNativeAssetId = toAssetId({ + chainId: solanaChainId, + assetNamespace: ASSET_NAMESPACE.slip44, + assetReference: '501', +}) + +const splToken = (address: string): AssetId => + toAssetId({ + chainId: solanaChainId, + assetNamespace: ASSET_NAMESPACE.splToken, + assetReference: address, + }) + +const trc20 = (address: string): AssetId => + toAssetId({ + chainId: tronChainId, + assetNamespace: ASSET_NAMESPACE.trc20, + assetReference: address, + }) + +const starknetToken = (address: string): AssetId => + toAssetId({ + chainId: starknetChainId, + assetNamespace: ASSET_NAMESPACE.starknetToken, + assetReference: address, + }) + +export const gardenAssetRegistry: readonly GardenAssetEntry[] = [ + { id: 'bitcoin:btc', assetId: btcAssetId }, + { id: 'litecoin:ltc', assetId: ltcAssetId }, + { id: 'ethereum:usdt', assetId: erc20(ethChainId, '0xdAC17F958D2ee523a2206206994597C13D831ec7') }, + { id: 'ethereum:wbtc', assetId: erc20(ethChainId, '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599') }, + { + id: 'ethereum:cbbtc', + assetId: erc20(ethChainId, '0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf'), + }, + { id: 'ethereum:usdc', assetId: erc20(ethChainId, '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48') }, + { id: 'base:cbbtc', assetId: erc20(baseChainId, '0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf') }, + { id: 'base:cbltc', assetId: erc20(baseChainId, '0xcb17C9Db87B595717C857a08468793f5bAb6445F') }, + { id: 'bnbchain:btcb', assetId: erc20(bscChainId, '0x7130d2A12B9BCbFAe4f2634d864A1Ee1Ce3Ead9c') }, + { + id: 'arbitrum:wbtc', + assetId: erc20(arbitrumChainId, '0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f'), + }, + { + id: 'arbitrum:ibtc', + assetId: erc20(arbitrumChainId, '0x050C24dBf1eEc17babE5fc585F06116A259CC77A'), + }, + { id: 'monad:mon', assetId: monadNativeAssetId }, + { id: 'monad:usdc', assetId: erc20(monadChainId, '0x754704Bc059F8C67012fEd69BC8A327a5aafb603') }, + { + id: 'hyperevm:ubtc', + assetId: erc20(hyperEvmChainId, '0x9FDBdA0A5e284c32744D2f17Ee5c74B284993463'), + }, + { + id: 'megaeth:btc.b', + assetId: erc20(megaethChainId, '0xB0F70C0bD6FD87dbEb7C10dC692a2a6106817072'), + }, + { + id: 'starknet:wbtc', + assetId: starknetToken('0x3fe2b97c1fd336e750087d68b9b867997fd64a2661ff3ca5a7c771641e8e7ac'), + }, + { + id: 'starknet:strkbtc', + assetId: starknetToken('0x0787150e306e6eae6e3f79dea881770e8bbff2c1b8eb490f969669ee945b3135'), + }, + { id: 'solana:sol', assetId: solanaNativeAssetId }, + { id: 'solana:usdc', assetId: splToken('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v') }, + { id: 'solana:cbbtc', assetId: splToken('cbbtcf3aa214zXHbiAZQwf4122FBYbraNdFqgw4iMij') }, + { id: 'solana:cash', assetId: splToken('CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASH') }, + { id: 'tron:usdt', assetId: trc20('TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t') }, +] as const + +const REGISTRY_BY_ASSETID = new Map(gardenAssetRegistry.map(a => [a.assetId, a])) + +export const lookupGardenAssetByAssetId = (assetId: AssetId): GardenAssetEntry | undefined => + REGISTRY_BY_ASSETID.get(assetId) diff --git a/packages/swapper/src/swappers/GardenSwapper/index.ts b/packages/swapper/src/swappers/GardenSwapper/index.ts new file mode 100644 index 00000000000..cf8f683f051 --- /dev/null +++ b/packages/swapper/src/swappers/GardenSwapper/index.ts @@ -0,0 +1,4 @@ +export * from './GardenSwapper' +export * from './endpoints' +export * from './types' +export * from './constants' diff --git a/packages/swapper/src/swappers/GardenSwapper/swapperApi/getTradeQuote.ts b/packages/swapper/src/swappers/GardenSwapper/swapperApi/getTradeQuote.ts new file mode 100644 index 00000000000..df192939126 --- /dev/null +++ b/packages/swapper/src/swappers/GardenSwapper/swapperApi/getTradeQuote.ts @@ -0,0 +1,320 @@ +import { CHAIN_NAMESPACE, fromAssetId } from '@shapeshiftoss/caip' +import { evm } from '@shapeshiftoss/chain-adapters' +import type { EvmChainId } from '@shapeshiftoss/types' +import { bnOrZero, contractAddressOrUndefined } from '@shapeshiftoss/utils' +import type { Result } from '@sniptt/monads' +import { Err, Ok } from '@sniptt/monads' +import { v4 as uuid } from 'uuid' +import type { Hex } from 'viem' +import { fromHex } from 'viem' + +import { getDefaultSlippageDecimalPercentageForSwapper } from '../../../constants' +import type { + CommonTradeQuoteInput, + GetEvmTradeQuoteInput, + GetUtxoTradeQuoteInput, + SwapErrorRight, + SwapperDeps, + TradeQuote, +} from '../../../types' +import { SwapperName, TradeQuoteError } from '../../../types' +import { getInputOutputRate, makeSwapErrorRight } from '../../../utils' +import { buildAffiliateFee } from '../../utils/affiliateFee' +import { GARDEN_AFFILIATE_FEE_ASSET, GARDEN_AFFILIATE_FEE_RECIPIENT } from '../constants' +import type { GardenCreateOrderResult, GardenSpecificMetadata, GardenStarknetCall } from '../types' +import { isGardenBitcoinInitiate, isGardenEvmInitiate, isGardenStarknetInitiate } from '../types' +import { + buildGardenAffiliateFees, + createGardenOrder, + fetchGardenQuote, + getGardenAssetInfo, +} from '../utils/fetchFromGarden' +import { assetIdToGardenAssetId, isSupportedGardenPair } from '../utils/helpers/helpers' + +const parseGardenEvmValue = (value: string): string => { + if (value.startsWith('0x')) return fromHex(value as Hex, 'bigint').toString() + if (/^\d+$/.test(value)) return BigInt(value).toString() + throw new Error(`Garden EVM initiate value has unexpected format: ${JSON.stringify(value)}`) +} + +const buildGardenSpecific = (order: GardenCreateOrderResult): GardenSpecificMetadata => { + const base = { orderId: order.order_id } + if (isGardenBitcoinInitiate(order)) return { ...base, bitcoinDepositAddress: order.to } + if (isGardenStarknetInitiate(order)) { + const starknetCalls = [order.approval_transaction, order.initiate_transaction].filter( + (call): call is GardenStarknetCall => call !== null, + ) + return { + ...base, + starknetCalls, + } + } + if (isGardenEvmInitiate(order)) { + return { + ...base, + evmInitiate: { + to: order.initiate_transaction.to, + data: order.initiate_transaction.data, + value: parseGardenEvmValue(order.initiate_transaction.value), + allowanceContract: order.initiate_transaction.to, + }, + } + } + return base +} + +export const getTradeQuote = async ( + input: CommonTradeQuoteInput, + deps: SwapperDeps, +): Promise> => { + const { + sellAsset, + buyAsset, + accountNumber, + sendAddress, + receiveAddress, + sellAmountIncludingProtocolFeesCryptoBaseUnit: sellAmount, + slippageTolerancePercentageDecimal, + affiliateBps, + } = input + + if (!isSupportedGardenPair(sellAsset.assetId, buyAsset.assetId)) { + return Err( + makeSwapErrorRight({ + message: `Garden does not support ${sellAsset.symbol} → ${buyAsset.symbol}`, + code: TradeQuoteError.UnsupportedTradePair, + }), + ) + } + + if (accountNumber === undefined) { + return Err( + makeSwapErrorRight({ + message: 'accountNumber is required', + code: TradeQuoteError.UnknownError, + }), + ) + } + + if (sendAddress === undefined) { + return Err( + makeSwapErrorRight({ + message: 'sendAddress is required', + code: TradeQuoteError.UnknownError, + }), + ) + } + + if (receiveAddress === undefined) { + return Err( + makeSwapErrorRight({ + message: 'receiveAddress is required', + code: TradeQuoteError.UnknownError, + }), + ) + } + + const apiKey = deps.config.VITE_GARDEN_API_KEY + if (!apiKey) { + return Err( + makeSwapErrorRight({ + message: 'VITE_GARDEN_API_KEY is not configured', + code: TradeQuoteError.UnknownError, + }), + ) + } + + const fromGardenAsset = assetIdToGardenAssetId(sellAsset.assetId) + const toGardenAsset = assetIdToGardenAssetId(buyAsset.assetId) + if (!fromGardenAsset || !toGardenAsset) { + return Err( + makeSwapErrorRight({ + message: 'Asset not supported by Garden', + code: TradeQuoteError.UnsupportedTradePair, + }), + ) + } + + const sourceAssetInfoResult = await getGardenAssetInfo({ apiKey, gardenAssetId: fromGardenAsset }) + if (sourceAssetInfoResult.isErr()) return Err(sourceAssetInfoResult.unwrapErr()) + const sourceAssetInfo = sourceAssetInfoResult.unwrap() + + if (sourceAssetInfo) { + if (bnOrZero(sellAmount).lt(sourceAssetInfo.min_amount)) { + return Err( + makeSwapErrorRight({ + message: 'Sell amount below Garden minimum', + code: TradeQuoteError.SellAmountBelowMinimum, + details: { + minAmountCryptoBaseUnit: sourceAssetInfo.min_amount, + assetId: sellAsset.assetId, + }, + }), + ) + } + if (bnOrZero(sellAmount).gt(sourceAssetInfo.max_amount)) { + return Err( + makeSwapErrorRight({ + message: 'Sell amount above Garden maximum', + code: TradeQuoteError.NoRouteFound, + details: { maxAmountCryptoBaseUnit: sourceAssetInfo.max_amount }, + }), + ) + } + } + + const quoteResult = await fetchGardenQuote({ + apiKey, + from: fromGardenAsset, + to: toGardenAsset, + fromAmount: sellAmount, + affiliateBps, + }) + + if (quoteResult.isErr()) return Err(quoteResult.unwrapErr()) + const quote = quoteResult.unwrap() + + const orderResult = await createGardenOrder({ + apiKey, + request: { + source: { asset: fromGardenAsset, owner: sendAddress, amount: sellAmount }, + destination: { + asset: toGardenAsset, + owner: receiveAddress, + amount: quote.destination.amount, + }, + solver_id: quote.solver_id, + affiliate_fees: buildGardenAffiliateFees({ + affiliateBps, + asset: GARDEN_AFFILIATE_FEE_ASSET, + address: GARDEN_AFFILIATE_FEE_RECIPIENT, + }), + }, + }) + + if (orderResult.isErr()) return Err(orderResult.unwrapErr()) + const order = orderResult.unwrap() + + let gardenSpecific: GardenSpecificMetadata + try { + gardenSpecific = buildGardenSpecific(order) + } catch (error) { + return Err( + makeSwapErrorRight({ + message: 'Garden order payload failed to parse', + code: TradeQuoteError.InvalidResponse, + details: { error: error instanceof Error ? error.message : String(error) }, + }), + ) + } + + const { chainNamespace } = fromAssetId(sellAsset.assetId) + + type FeeDataResult = { + networkFeeCryptoBaseUnit: string | undefined + chainSpecific?: { satsPerByte: string } + } + + const feeData: FeeDataResult = await (async (): Promise => { + try { + if (chainNamespace === CHAIN_NAMESPACE.Utxo) { + const adapter = deps.assertGetUtxoChainAdapter(sellAsset.chainId) + const pubkey = (input as GetUtxoTradeQuoteInput).xpub + if (!pubkey || !gardenSpecific.bitcoinDepositAddress) { + return { networkFeeCryptoBaseUnit: undefined } + } + const utxoFee = await adapter.getFeeData({ + to: gardenSpecific.bitcoinDepositAddress, + value: sellAmount, + chainSpecific: { pubkey }, + sendMax: false, + }) + return { + networkFeeCryptoBaseUnit: utxoFee.fast.txFee, + chainSpecific: { satsPerByte: utxoFee.fast.chainSpecific.satoshiPerByte }, + } + } + + if (chainNamespace === CHAIN_NAMESPACE.Starknet) { + const adapter = deps.assertGetStarknetChainAdapter(sellAsset.chainId) + const tokenContractAddress = contractAddressOrUndefined(sellAsset.assetId) + const starknetFee = await adapter.getFeeData({ + to: gardenSpecific.starknetCalls?.[1]?.to ?? sendAddress, + value: sellAmount, + chainSpecific: { from: sendAddress, tokenContractAddress }, + sendMax: false, + }) + return { networkFeeCryptoBaseUnit: starknetFee.fast.txFee } + } + + if (chainNamespace === CHAIN_NAMESPACE.Evm) { + if (!gardenSpecific.evmInitiate) return { networkFeeCryptoBaseUnit: undefined } + const adapter = deps.assertGetEvmChainAdapter(sellAsset.chainId as EvmChainId) + const evmFee = await evm.getFees({ + adapter, + data: gardenSpecific.evmInitiate.data, + to: gardenSpecific.evmInitiate.to, + value: gardenSpecific.evmInitiate.value, + from: sendAddress, + supportsEIP1559: (input as GetEvmTradeQuoteInput).supportsEIP1559, + }) + return { networkFeeCryptoBaseUnit: evmFee.networkFeeCryptoBaseUnit } + } + + return { networkFeeCryptoBaseUnit: undefined } + } catch { + return { networkFeeCryptoBaseUnit: undefined } + } + })() + + const rate = getInputOutputRate({ + sellAmountCryptoBaseUnit: quote.source.amount, + buyAmountCryptoBaseUnit: quote.destination.amount, + sellAsset, + buyAsset, + }) + + const allowanceContract = gardenSpecific.evmInitiate?.allowanceContract ?? '0x0' + + const tradeQuote: TradeQuote = { + id: uuid(), + receiveAddress, + affiliateBps, + rate, + slippageTolerancePercentageDecimal: + slippageTolerancePercentageDecimal ?? + getDefaultSlippageDecimalPercentageForSwapper(SwapperName.Garden), + quoteOrRate: 'quote' as const, + swapperName: SwapperName.Garden, + steps: [ + { + accountNumber, + allowanceContract, + buyAmountBeforeFeesCryptoBaseUnit: quote.destination.amount, + buyAmountAfterFeesCryptoBaseUnit: quote.destination.amount, + buyAsset, + feeData: { + protocolFees: {}, + networkFeeCryptoBaseUnit: feeData.networkFeeCryptoBaseUnit, + ...(feeData.chainSpecific && { chainSpecific: feeData.chainSpecific }), + }, + affiliateFee: buildAffiliateFee({ + strategy: 'buy_asset', + affiliateBps, + sellAsset, + buyAsset, + sellAmountCryptoBaseUnit: quote.source.amount, + buyAmountCryptoBaseUnit: quote.destination.amount, + }), + rate, + sellAmountIncludingProtocolFeesCryptoBaseUnit: quote.source.amount, + sellAsset, + source: SwapperName.Garden, + estimatedExecutionTimeMs: quote.estimated_time * 1000, + gardenSpecific, + }, + ], + } + + return Ok([tradeQuote]) +} diff --git a/packages/swapper/src/swappers/GardenSwapper/swapperApi/getTradeRate.ts b/packages/swapper/src/swappers/GardenSwapper/swapperApi/getTradeRate.ts new file mode 100644 index 00000000000..8e1b4f137a2 --- /dev/null +++ b/packages/swapper/src/swappers/GardenSwapper/swapperApi/getTradeRate.ts @@ -0,0 +1,214 @@ +import { CHAIN_NAMESPACE, fromAssetId } from '@shapeshiftoss/caip' +import { evm } from '@shapeshiftoss/chain-adapters' +import type { EvmChainId } from '@shapeshiftoss/types' +import { bnOrZero, contractAddressOrUndefined } from '@shapeshiftoss/utils' +import type { Result } from '@sniptt/monads' +import { Err, Ok } from '@sniptt/monads' +import { v4 as uuid } from 'uuid' + +import { getDefaultSlippageDecimalPercentageForSwapper } from '../../../constants' +import type { + GetEvmTradeRateInput, + GetTradeRateInput, + GetUtxoTradeRateInput, + SwapErrorRight, + SwapperDeps, + TradeRate, +} from '../../../types' +import { SwapperName, TradeQuoteError } from '../../../types' +import { getInputOutputRate, makeSwapErrorRight } from '../../../utils' +import { buildAffiliateFee } from '../../utils/affiliateFee' +import { GARDEN_FEE_PLACEHOLDER_BY_NAMESPACE } from '../constants' +import { fetchGardenQuote, getGardenAssetInfo } from '../utils/fetchFromGarden' +import { assetIdToGardenAssetId, isSupportedGardenPair } from '../utils/helpers/helpers' + +export const getTradeRate = async ( + input: GetTradeRateInput, + deps: SwapperDeps, +): Promise> => { + const { + sellAsset, + buyAsset, + sellAmountIncludingProtocolFeesCryptoBaseUnit: sellAmount, + sendAddress, + slippageTolerancePercentageDecimal, + affiliateBps, + } = input + + if (!isSupportedGardenPair(sellAsset.assetId, buyAsset.assetId)) { + return Err( + makeSwapErrorRight({ + message: `Garden does not support ${sellAsset.symbol} → ${buyAsset.symbol}`, + code: TradeQuoteError.UnsupportedTradePair, + }), + ) + } + + const apiKey = deps.config.VITE_GARDEN_API_KEY + if (!apiKey) { + return Err( + makeSwapErrorRight({ + message: 'VITE_GARDEN_API_KEY is not configured', + code: TradeQuoteError.UnknownError, + }), + ) + } + + const fromGardenAsset = assetIdToGardenAssetId(sellAsset.assetId) + const toGardenAsset = assetIdToGardenAssetId(buyAsset.assetId) + if (!fromGardenAsset || !toGardenAsset) { + return Err( + makeSwapErrorRight({ + message: 'Asset not supported by Garden', + code: TradeQuoteError.UnsupportedTradePair, + }), + ) + } + + const sourceAssetInfoResult = await getGardenAssetInfo({ apiKey, gardenAssetId: fromGardenAsset }) + if (sourceAssetInfoResult.isErr()) return Err(sourceAssetInfoResult.unwrapErr()) + const sourceAssetInfo = sourceAssetInfoResult.unwrap() + + if (sourceAssetInfo) { + if (bnOrZero(sellAmount).lt(sourceAssetInfo.min_amount)) { + return Err( + makeSwapErrorRight({ + message: 'Sell amount below Garden minimum', + code: TradeQuoteError.SellAmountBelowMinimum, + details: { + minAmountCryptoBaseUnit: sourceAssetInfo.min_amount, + assetId: sellAsset.assetId, + }, + }), + ) + } + if (bnOrZero(sellAmount).gt(sourceAssetInfo.max_amount)) { + return Err( + makeSwapErrorRight({ + message: 'Sell amount above Garden maximum', + code: TradeQuoteError.NoRouteFound, + details: { maxAmountCryptoBaseUnit: sourceAssetInfo.max_amount }, + }), + ) + } + } + + const quoteResult = await fetchGardenQuote({ + apiKey, + from: fromGardenAsset, + to: toGardenAsset, + fromAmount: sellAmount, + affiliateBps, + }) + + if (quoteResult.isErr()) return Err(quoteResult.unwrapErr()) + const quote = quoteResult.unwrap() + + const { chainNamespace } = fromAssetId(sellAsset.assetId) + const placeholderTo = GARDEN_FEE_PLACEHOLDER_BY_NAMESPACE[chainNamespace] + + const networkFeeCryptoBaseUnit: string | undefined = await (async () => { + if (chainNamespace === CHAIN_NAMESPACE.Utxo) { + const adapter = deps.assertGetUtxoChainAdapter(sellAsset.chainId) + const pubkey = (input as GetUtxoTradeRateInput).xpub + if (!pubkey || !placeholderTo) return undefined + try { + const utxoFee = await adapter.getFeeData({ + to: placeholderTo, + value: sellAmount, + chainSpecific: { pubkey }, + sendMax: false, + }) + return utxoFee.fast.txFee + } catch { + return '0' + } + } + + if (chainNamespace === CHAIN_NAMESPACE.Starknet) { + if (!sendAddress || !placeholderTo) return undefined + const adapter = deps.assertGetStarknetChainAdapter(sellAsset.chainId) + const tokenContractAddress = contractAddressOrUndefined(sellAsset.assetId) + try { + const starknetFee = await adapter.getFeeData({ + to: placeholderTo, + value: sellAmount, + chainSpecific: { from: sendAddress, tokenContractAddress }, + sendMax: false, + }) + return starknetFee.fast.txFee + } catch { + return '0' + } + } + + if (chainNamespace === CHAIN_NAMESPACE.Evm) { + if (!sendAddress || !placeholderTo) return undefined + const adapter = deps.assertGetEvmChainAdapter(sellAsset.chainId as EvmChainId) + const contractAddress = contractAddressOrUndefined(sellAsset.assetId) + const data = evm.getErc20Data(placeholderTo, sellAmount, contractAddress) + try { + const feeData = await evm.getFees({ + adapter, + data: data || '0x', + to: contractAddress ?? placeholderTo, + value: contractAddress ? '0' : sellAmount, + from: sendAddress, + supportsEIP1559: (input as GetEvmTradeRateInput).supportsEIP1559, + }) + return feeData.networkFeeCryptoBaseUnit + } catch { + return '0' + } + } + + return undefined + })() + + const rate = getInputOutputRate({ + sellAmountCryptoBaseUnit: quote.source.amount, + buyAmountCryptoBaseUnit: quote.destination.amount, + sellAsset, + buyAsset, + }) + + const tradeRate: TradeRate = { + id: uuid(), + receiveAddress: undefined, + affiliateBps, + rate, + slippageTolerancePercentageDecimal: + slippageTolerancePercentageDecimal ?? + getDefaultSlippageDecimalPercentageForSwapper(SwapperName.Garden), + quoteOrRate: 'rate' as const, + swapperName: SwapperName.Garden, + steps: [ + { + accountNumber: undefined, + allowanceContract: '0x0', + buyAmountBeforeFeesCryptoBaseUnit: quote.destination.amount, + buyAmountAfterFeesCryptoBaseUnit: quote.destination.amount, + buyAsset, + feeData: { + protocolFees: {}, + networkFeeCryptoBaseUnit, + }, + rate, + sellAmountIncludingProtocolFeesCryptoBaseUnit: quote.source.amount, + sellAsset, + source: SwapperName.Garden, + estimatedExecutionTimeMs: quote.estimated_time * 1000, + affiliateFee: buildAffiliateFee({ + strategy: 'buy_asset', + affiliateBps, + sellAsset, + buyAsset, + sellAmountCryptoBaseUnit: quote.source.amount, + buyAmountCryptoBaseUnit: quote.destination.amount, + }), + }, + ], + } + + return Ok([tradeRate]) +} diff --git a/packages/swapper/src/swappers/GardenSwapper/types.ts b/packages/swapper/src/swappers/GardenSwapper/types.ts new file mode 100644 index 00000000000..8175a51cfec --- /dev/null +++ b/packages/swapper/src/swappers/GardenSwapper/types.ts @@ -0,0 +1,180 @@ +export type GardenAssetId = string + +export type GardenAffiliateFeeAsset = 'base:cbbtc' + +export type GardenAccount = { + asset: GardenAssetId + owner: string + amount: string +} + +export type GardenAffiliateFeeEntry = { + asset: GardenAffiliateFeeAsset + address: string + fee: number +} + +export type GardenQuoteResultItem = { + source: { asset: GardenAssetId; amount: string; display: string; value: string } + destination: { asset: GardenAssetId; amount: string; display: string; value: string } + solver_id: string + estimated_time: number + slippage: number + fee: number + fixed_fee: string +} + +export type GardenResponseEnvelope = { + status: 'Ok' | 'Error' + result?: T + error?: string +} + +export type GardenQuoteResponse = GardenResponseEnvelope + +export type GardenAssetInfo = { + id: string + min_amount: string + max_amount: string +} + +export type GardenAssetsResponse = GardenResponseEnvelope + +export type GardenOrderRequest = { + source: GardenAccount + destination: GardenAccount + solver_id?: string + affiliate_fees?: GardenAffiliateFeeEntry[] +} + +export type GardenBitcoinInitiateResult = { + order_id: string + to: string + amount: string +} + +export type GardenStarknetCall = { + to: string + selector: string + calldata: string[] +} + +export type GardenStarknetInitiateResult = { + order_id: string + approval_transaction: GardenStarknetCall | null + initiate_transaction: GardenStarknetCall +} + +export type GardenEvmTransactionData = { + chain_id: number + data: string + to: string + value: string +} + +export type GardenEvmInitiateResult = { + order_id: string + approval_transaction: GardenEvmTransactionData + initiate_transaction: GardenEvmTransactionData +} + +export type GardenCreateOrderResult = + | GardenBitcoinInitiateResult + | GardenStarknetInitiateResult + | GardenEvmInitiateResult + +export type GardenCreateOrderResponse = GardenResponseEnvelope + +export const isGardenBitcoinInitiate = ( + result: GardenCreateOrderResult, +): result is GardenBitcoinInitiateResult => + typeof result === 'object' && + result !== null && + typeof (result as GardenBitcoinInitiateResult).order_id === 'string' && + typeof (result as GardenBitcoinInitiateResult).to === 'string' && + typeof (result as GardenBitcoinInitiateResult).amount === 'string' + +export const isGardenStarknetInitiate = ( + result: GardenCreateOrderResult, +): result is GardenStarknetInitiateResult => { + if (typeof result !== 'object' || result === null) return false + if (typeof (result as GardenStarknetInitiateResult).order_id !== 'string') return false + if (!('initiate_transaction' in result)) return false + const initiate = (result as GardenStarknetInitiateResult).initiate_transaction + return ( + typeof initiate === 'object' && + initiate !== null && + typeof (initiate as GardenStarknetCall).to === 'string' && + typeof (initiate as GardenStarknetCall).selector === 'string' && + Array.isArray((initiate as GardenStarknetCall).calldata) + ) +} + +export const isGardenEvmInitiate = ( + result: GardenCreateOrderResult, +): result is GardenEvmInitiateResult => { + if (typeof result !== 'object' || result === null) return false + if (typeof (result as GardenEvmInitiateResult).order_id !== 'string') return false + if (!('initiate_transaction' in result)) return false + const initiate = (result as GardenEvmInitiateResult).initiate_transaction + return ( + typeof initiate === 'object' && + initiate !== null && + typeof (initiate as GardenEvmTransactionData).chain_id === 'number' && + typeof (initiate as GardenEvmTransactionData).to === 'string' && + typeof (initiate as GardenEvmTransactionData).data === 'string' && + typeof (initiate as GardenEvmTransactionData).value === 'string' + ) +} + +export type GardenSwapState = { + created_at: string + swap_id: string + chain: string + asset: GardenAssetId + initiator: string + redeemer: string + delegate?: string + timelock: number + filled_amount: string + asset_price: number + amount: string + secret_hash: string + secret: string + initiate_tx_hash: string + redeem_tx_hash: string + refund_tx_hash: string + initiate_block_number: string + redeem_block_number: string + refund_block_number: string + required_confirmations: number + current_confirmations: number + initiate_timestamp: string | null + redeem_timestamp: string | null + refund_timestamp: string | null + instant_refund_tx?: string +} + +export type GardenOrder = { + created_at: string + order_id: string + source_swap: GardenSwapState + destination_swap: GardenSwapState + nonce: string + affiliate_fees: GardenAffiliateFeeEntry[] + solver_id?: string +} + +export type GardenOrderResponse = GardenResponseEnvelope + +export type GardenSpecificMetadata = { + orderId: string + bitcoinDepositAddress?: string + starknetCalls?: GardenStarknetCall[] + evmInitiate?: { + to: string + data: string + value: string + allowanceContract: string + } +} diff --git a/packages/swapper/src/swappers/GardenSwapper/utils/fetchFromGarden.ts b/packages/swapper/src/swappers/GardenSwapper/utils/fetchFromGarden.ts new file mode 100644 index 00000000000..e666232e3b7 --- /dev/null +++ b/packages/swapper/src/swappers/GardenSwapper/utils/fetchFromGarden.ts @@ -0,0 +1,239 @@ +import type { Result } from '@sniptt/monads' +import { Err, Ok } from '@sniptt/monads' + +import type { SwapErrorRight } from '../../../types' +import { TradeQuoteError } from '../../../types' +import { makeSwapErrorRight } from '../../../utils' +import { GARDEN_API_BASE_URL, GARDEN_API_KEY_HEADER } from '../constants' +import type { + GardenAffiliateFeeEntry, + GardenAssetId, + GardenAssetInfo, + GardenAssetsResponse, + GardenCreateOrderResponse, + GardenCreateOrderResult, + GardenOrder, + GardenOrderRequest, + GardenOrderResponse, + GardenQuoteResponse, + GardenQuoteResultItem, +} from '../types' +import { gardenService } from './gardenService' +import { + isInsufficientLiquidityError, + isNoRouteFoundError, + isOutOfRangeError, +} from './helpers/helpers' + +const errorMessageToTradeQuoteError = (message: string | undefined): TradeQuoteError => { + if (isNoRouteFoundError(message)) return TradeQuoteError.NoRouteFound + if (isInsufficientLiquidityError(message)) return TradeQuoteError.NoRouteFound + if (isOutOfRangeError(message)) return TradeQuoteError.SellAmountBelowMinimum + return TradeQuoteError.QueryFailed +} + +const authHeaders = (apiKey: string) => ({ + headers: { [GARDEN_API_KEY_HEADER]: apiKey }, +}) + +const toErrorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error) + +export const fetchGardenQuote = async ({ + apiKey, + from, + to, + fromAmount, + affiliateBps, +}: { + apiKey: string + from: GardenAssetId + to: GardenAssetId + fromAmount: string + affiliateBps: string +}): Promise> => { + try { + const params: Record = { from, to, from_amount: fromAmount } + if (affiliateBps && affiliateBps !== '0') params.affiliate_fee = affiliateBps + + const result = await gardenService.get(`${GARDEN_API_BASE_URL}/quote`, { + params, + ...authHeaders(apiKey), + }) + + if (result.isErr()) return Err(result.unwrapErr()) + + const { data } = result.unwrap() + + if (data.status !== 'Ok' || !data.result || data.result.length === 0) { + return Err( + makeSwapErrorRight({ + message: data.error ?? 'Garden quote failed', + code: errorMessageToTradeQuoteError(data.error), + details: { error: data.error }, + }), + ) + } + + return Ok(data.result[0]) + } catch (error) { + return Err( + makeSwapErrorRight({ + message: 'Garden quote request threw', + code: TradeQuoteError.QueryFailed, + details: { error: toErrorMessage(error) }, + }), + ) + } +} + +export const createGardenOrder = async ({ + apiKey, + request, +}: { + apiKey: string + request: GardenOrderRequest +}): Promise> => { + try { + const result = await gardenService.post( + `${GARDEN_API_BASE_URL}/orders`, + request, + authHeaders(apiKey), + ) + + if (result.isErr()) return Err(result.unwrapErr()) + + const { data } = result.unwrap() + + if (data.status !== 'Ok' || !data.result) { + return Err( + makeSwapErrorRight({ + message: data.error ?? 'Garden order creation failed', + code: errorMessageToTradeQuoteError(data.error), + details: { error: data.error }, + }), + ) + } + + return Ok(data.result) + } catch (error) { + return Err( + makeSwapErrorRight({ + message: 'Garden order creation threw', + code: TradeQuoteError.QueryFailed, + details: { error: toErrorMessage(error) }, + }), + ) + } +} + +export const fetchGardenOrder = async ({ + apiKey, + orderId, +}: { + apiKey: string + orderId: string +}): Promise> => { + try { + const result = await gardenService.get( + `${GARDEN_API_BASE_URL}/orders/${orderId}`, + authHeaders(apiKey), + ) + + if (result.isErr()) return Err(result.unwrapErr()) + + const { data } = result.unwrap() + + if (data.status !== 'Ok' || !data.result) { + return Err( + makeSwapErrorRight({ + message: data.error ?? 'Garden order fetch failed', + code: TradeQuoteError.QueryFailed, + details: { error: data.error }, + }), + ) + } + + return Ok(data.result) + } catch (error) { + return Err( + makeSwapErrorRight({ + message: 'Garden order fetch threw', + code: TradeQuoteError.QueryFailed, + details: { error: toErrorMessage(error) }, + }), + ) + } +} + +const ASSETS_CACHE_TTL_MS = 60 * 60 * 1000 + +let assetsCache: { data: GardenAssetInfo[]; expiresAt: number } | null = null +let inFlightAssetsRequest: Promise> | null = null + +const fetchGardenAssets = ({ + apiKey, +}: { + apiKey: string +}): Promise> => { + if (assetsCache && Date.now() < assetsCache.expiresAt) + return Promise.resolve(Ok(assetsCache.data)) + if (inFlightAssetsRequest) return inFlightAssetsRequest + + inFlightAssetsRequest = (async () => { + try { + const result = await gardenService.get( + `${GARDEN_API_BASE_URL}/assets`, + authHeaders(apiKey), + ) + + if (result.isErr()) return Err(result.unwrapErr()) + + const { data } = result.unwrap() + + if (data.status !== 'Ok' || !data.result) { + return Err( + makeSwapErrorRight({ + message: data.error ?? 'Garden assets fetch failed', + code: TradeQuoteError.QueryFailed, + details: { error: data.error }, + }), + ) + } + + assetsCache = { data: data.result, expiresAt: Date.now() + ASSETS_CACHE_TTL_MS } + return Ok(data.result) + } finally { + inFlightAssetsRequest = null + } + })() + + return inFlightAssetsRequest +} + +export const getGardenAssetInfo = async ({ + apiKey, + gardenAssetId, +}: { + apiKey: string + gardenAssetId: GardenAssetId +}): Promise> => { + const result = await fetchGardenAssets({ apiKey }) + if (result.isErr()) return Err(result.unwrapErr()) + return Ok(result.unwrap().find(a => a.id === gardenAssetId)) +} + +export const buildGardenAffiliateFees = ({ + affiliateBps, + asset, + address, +}: { + affiliateBps: string + asset: GardenAffiliateFeeEntry['asset'] + address: string +}): GardenAffiliateFeeEntry[] | undefined => { + if (!affiliateBps || affiliateBps === '0') return undefined + const fee = Number(affiliateBps) + if (!Number.isFinite(fee) || fee <= 0) return undefined + return [{ asset, address, fee }] +} diff --git a/packages/swapper/src/swappers/GardenSwapper/utils/gardenService.ts b/packages/swapper/src/swappers/GardenSwapper/utils/gardenService.ts new file mode 100644 index 00000000000..83a95118446 --- /dev/null +++ b/packages/swapper/src/swappers/GardenSwapper/utils/gardenService.ts @@ -0,0 +1,21 @@ +import { createCache, makeSwapperAxiosServiceMonadic } from '../../../utils' + +const maxAge = 5 * 1000 +const cachedUrls = ['/quote'] + +const axiosConfig = { + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + // Garden returns HTTP 4xx with a structured body ({status: "Error", error: "..."}) + // for business errors like "Insufficient liquidity" or "expected amount to be within + // the range...". Accept all status codes so callers can inspect the body and map + // Garden's error strings to TradeQuoteError codes via errorMessageToTradeQuoteError. + validateStatus: () => true, +} + +const gardenServiceBase = createCache(maxAge, cachedUrls, axiosConfig) + +export const gardenService = makeSwapperAxiosServiceMonadic(gardenServiceBase) diff --git a/packages/swapper/src/swappers/GardenSwapper/utils/helpers/helpers.test.ts b/packages/swapper/src/swappers/GardenSwapper/utils/helpers/helpers.test.ts new file mode 100644 index 00000000000..abe63da7f3a --- /dev/null +++ b/packages/swapper/src/swappers/GardenSwapper/utils/helpers/helpers.test.ts @@ -0,0 +1,191 @@ +import { btcAssetId } from '@shapeshiftoss/caip' +import { TxStatus } from '@shapeshiftoss/unchained-client' +import { describe, expect, it } from 'vitest' + +import type { GardenOrder, GardenSwapState } from '../../types' +import { + assetIdToGardenAssetId, + isInsufficientLiquidityError, + isNoRouteFoundError, + isOutOfRangeError, + isSupportedGardenPair, + mapGardenOrderToTxStatus, +} from './helpers' + +const strkbtcAssetId = + 'starknet:SN_MAIN/token:0x0787150e306e6eae6e3f79dea881770e8bbff2c1b8eb490f969669ee945b3135' + +const emptySwap = (overrides: Partial = {}): GardenSwapState => ({ + created_at: '2026-05-15T00:00:00Z', + swap_id: 'swap_id', + chain: 'starknet', + asset: 'starknet:strkbtc', + initiator: '0x1', + redeemer: '0x2', + timelock: 43200, + filled_amount: '0', + asset_price: 79000, + amount: '100000', + secret_hash: '0xhash', + secret: '', + initiate_tx_hash: '', + redeem_tx_hash: '', + refund_tx_hash: '', + initiate_block_number: '0', + redeem_block_number: '0', + refund_block_number: '0', + required_confirmations: 1, + current_confirmations: 0, + initiate_timestamp: null, + redeem_timestamp: null, + refund_timestamp: null, + ...overrides, +}) + +const buildOrder = ( + source: Partial, + destination: Partial, + overrides: Partial = {}, +): GardenOrder => ({ + created_at: '2026-05-15T00:00:00Z', + order_id: 'order_id', + source_swap: emptySwap(source), + destination_swap: emptySwap({ chain: 'bitcoin', asset: 'bitcoin:btc', ...destination }), + nonce: '1', + affiliate_fees: [], + ...overrides, +}) + +describe('GardenSwapper helpers', () => { + describe('assetIdToGardenAssetId', () => { + it('maps native BTC to bitcoin:btc', () => { + expect(assetIdToGardenAssetId(btcAssetId)).toBe('bitcoin:btc') + }) + + it('maps the strkBTC ERC20 to starknet:strkbtc', () => { + expect(assetIdToGardenAssetId(strkbtcAssetId)).toBe('starknet:strkbtc') + }) + + it('returns undefined for unsupported assets', () => { + const ethAssetId = 'eip155:1/slip44:60' + expect(assetIdToGardenAssetId(ethAssetId)).toBeUndefined() + }) + + it('returns undefined for other Starknet tokens', () => { + const otherStarknetToken = `starknet:SN_MAIN/token:0x0000000000000000000000000000000000000001` + expect(assetIdToGardenAssetId(otherStarknetToken)).toBeUndefined() + }) + }) + + describe('isSupportedGardenPair', () => { + it('accepts BTC → strkBTC', () => { + expect(isSupportedGardenPair(btcAssetId, strkbtcAssetId)).toBe(true) + }) + + it('accepts strkBTC → BTC', () => { + expect(isSupportedGardenPair(strkbtcAssetId, btcAssetId)).toBe(true) + }) + + it('rejects same-asset pairs', () => { + expect(isSupportedGardenPair(btcAssetId, btcAssetId)).toBe(false) + expect(isSupportedGardenPair(strkbtcAssetId, strkbtcAssetId)).toBe(false) + }) + + it('rejects unsupported pairs', () => { + const eth = 'eip155:1/slip44:60' + expect(isSupportedGardenPair(btcAssetId, eth)).toBe(false) + expect(isSupportedGardenPair(eth, strkbtcAssetId)).toBe(false) + }) + + it('rejects Solana as the source chain even when the registry has the asset', () => { + const solanaSol = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501' + expect(isSupportedGardenPair(solanaSol, btcAssetId)).toBe(false) + }) + + it('rejects Tron as the source chain even when the registry has the asset', () => { + const tronUsdt = 'tron:0x2b6653dc/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t' + expect(isSupportedGardenPair(tronUsdt, btcAssetId)).toBe(false) + }) + + it('still accepts Solana as the destination chain', () => { + const solanaCbbtc = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:cbbtcf3aa214zXHbiAZQwf4122FBYbraNdFqgw4iMij' + expect(isSupportedGardenPair(btcAssetId, solanaCbbtc)).toBe(true) + }) + }) + + describe('mapGardenOrderToTxStatus', () => { + it('returns Confirmed with buyTxHash + actualBuyAmount when destination_swap.redeem_tx_hash is set', () => { + const order = buildOrder({}, { redeem_tx_hash: '0xredeem', filled_amount: '99500' }) + expect(mapGardenOrderToTxStatus(order)).toEqual({ + status: TxStatus.Confirmed, + buyTxHash: '0xredeem', + actualBuyAmountCryptoBaseUnit: '99500', + }) + }) + + it('returns Failed with "Swap refunded" when source has a refund_tx_hash', () => { + const order = buildOrder({ refund_tx_hash: '0xrefund' }, {}) + expect(mapGardenOrderToTxStatus(order)).toEqual({ + status: TxStatus.Failed, + message: 'Swap refunded', + }) + }) + + it('returns Failed with "Swap refunded" when destination has a refund_tx_hash', () => { + const order = buildOrder({}, { refund_tx_hash: '0xrefund' }) + expect(mapGardenOrderToTxStatus(order)).toEqual({ + status: TxStatus.Failed, + message: 'Swap refunded', + }) + }) + + it('returns Pending when nothing has settled yet', () => { + const order = buildOrder({}, {}) + expect(mapGardenOrderToTxStatus(order)).toEqual({ status: TxStatus.Pending }) + }) + + it('omits actualBuyAmountCryptoBaseUnit when filled_amount is "0" but redeem_tx_hash is set', () => { + const order = buildOrder({}, { redeem_tx_hash: '0xredeem', filled_amount: '0' }) + expect(mapGardenOrderToTxStatus(order)).toEqual({ + status: TxStatus.Confirmed, + buyTxHash: '0xredeem', + actualBuyAmountCryptoBaseUnit: undefined, + }) + }) + + it('omits actualBuyAmountCryptoBaseUnit when filled_amount is empty string', () => { + const order = buildOrder({}, { redeem_tx_hash: '0xredeem', filled_amount: '' }) + expect(mapGardenOrderToTxStatus(order)).toEqual({ + status: TxStatus.Confirmed, + buyTxHash: '0xredeem', + actualBuyAmountCryptoBaseUnit: undefined, + }) + }) + }) + + describe('error pattern matchers', () => { + it('isNoRouteFoundError detects the blacklist message', () => { + expect(isNoRouteFoundError('No order pair found : starknet:0x...:ethereum:0x...')).toBe(true) + expect(isNoRouteFoundError('some other error')).toBe(false) + expect(isNoRouteFoundError(undefined)).toBe(false) + }) + + it('isOutOfRangeError detects the min/max message', () => { + expect( + isOutOfRangeError( + 'Exact output quote error : expected amount to be within the range of 10000 to 500000000', + ), + ).toBe(true) + expect(isOutOfRangeError('some other error')).toBe(false) + expect(isOutOfRangeError(undefined)).toBe(false) + }) + + it('isInsufficientLiquidityError detects solver liquidity exhaustion', () => { + expect(isInsufficientLiquidityError('insufficient liquidity')).toBe(true) + expect(isInsufficientLiquidityError('Insufficient Liquidity for this route')).toBe(true) + expect(isInsufficientLiquidityError('No order pair found')).toBe(false) + expect(isInsufficientLiquidityError(undefined)).toBe(false) + }) + }) +}) diff --git a/packages/swapper/src/swappers/GardenSwapper/utils/helpers/helpers.ts b/packages/swapper/src/swappers/GardenSwapper/utils/helpers/helpers.ts new file mode 100644 index 00000000000..09d93df1061 --- /dev/null +++ b/packages/swapper/src/swappers/GardenSwapper/utils/helpers/helpers.ts @@ -0,0 +1,79 @@ +import type { AssetId, ChainId } from '@shapeshiftoss/caip' +import { + arbitrumChainId, + baseChainId, + bscChainId, + btcChainId, + ethChainId, + fromAssetId, + hyperEvmChainId, + ltcChainId, + megaethChainId, + monadChainId, + starknetChainId, +} from '@shapeshiftoss/caip' +import { TxStatus } from '@shapeshiftoss/unchained-client' + +import { lookupGardenAssetByAssetId } from '../../gardenAssetRegistry' +import type { GardenOrder } from '../../types' + +export const GardenSupportedSourceChainIds: readonly ChainId[] = [ + btcChainId, + ltcChainId, + ethChainId, + baseChainId, + bscChainId, + arbitrumChainId, + monadChainId, + hyperEvmChainId, + megaethChainId, + starknetChainId, +] + +const gardenSupportedSourceChainIdSet = new Set(GardenSupportedSourceChainIds) + +export const assetIdToGardenAssetId = (assetId: AssetId): string | undefined => + lookupGardenAssetByAssetId(assetId)?.id + +export const isSupportedGardenPair = (sellAssetId: AssetId, buyAssetId: AssetId): boolean => { + if (!gardenSupportedSourceChainIdSet.has(fromAssetId(sellAssetId).chainId)) return false + const sell = assetIdToGardenAssetId(sellAssetId) + const buy = assetIdToGardenAssetId(buyAssetId) + if (!sell || !buy) return false + if (sell === buy) return false + return true +} + +export const mapGardenOrderToTxStatus = ( + order: GardenOrder, +): { + status: TxStatus + buyTxHash?: string + message?: string + actualBuyAmountCryptoBaseUnit?: string +} => { + if (order.destination_swap.redeem_tx_hash) { + const filledAmount = order.destination_swap.filled_amount + return { + status: TxStatus.Confirmed, + buyTxHash: order.destination_swap.redeem_tx_hash, + actualBuyAmountCryptoBaseUnit: + filledAmount && filledAmount !== '0' ? filledAmount : undefined, + } + } + + if (order.source_swap.refund_tx_hash || order.destination_swap.refund_tx_hash) { + return { status: TxStatus.Failed, message: 'Swap refunded' } + } + + return { status: TxStatus.Pending } +} + +export const isNoRouteFoundError = (errorMessage: string | undefined): boolean => + errorMessage !== undefined && errorMessage.includes('No order pair found') + +export const isOutOfRangeError = (errorMessage: string | undefined): boolean => + errorMessage !== undefined && errorMessage.includes('expected amount to be within the range') + +export const isInsufficientLiquidityError = (errorMessage: string | undefined): boolean => + errorMessage !== undefined && errorMessage.toLowerCase().includes('insufficient liquidity') diff --git a/packages/swapper/src/types.ts b/packages/swapper/src/types.ts index c6e5288a76c..173562297cd 100644 --- a/packages/swapper/src/types.ts +++ b/packages/swapper/src/types.ts @@ -78,6 +78,7 @@ export type SwapperConfig = { VITE_RELAY_API_URL: string VITE_BEBOP_API_KEY: string VITE_NEAR_INTENTS_API_KEY: string + VITE_GARDEN_API_KEY: string VITE_TENDERLY_API_KEY: string VITE_TENDERLY_ACCOUNT_SLUG: string VITE_TENDERLY_PROJECT_SLUG: string @@ -106,6 +107,7 @@ export enum SwapperName { Stonfi = 'STON.fi', Across = 'Across', Debridge = 'deBridge', + Garden = 'Garden', } export type SwapSource = SwapperName | `${SwapperName} • ${string}` @@ -456,6 +458,17 @@ export type TradeQuoteStep = { timeEstimate: number deadline: string } + gardenSpecific?: { + orderId: string + bitcoinDepositAddress?: string + starknetCalls?: { to: string; selector: string; calldata: string[] }[] + evmInitiate?: { + to: string + data: string + value: string + allowanceContract: string + } + } thorchainSpecific?: { maxStreamingQuantity?: number } @@ -576,6 +589,17 @@ export type SwapperSpecificMetadata = { timeEstimate: number deadline: string } + gardenSpecific?: { + orderId: string + bitcoinDepositAddress?: string + starknetCalls?: { to: string; selector: string; calldata: string[] }[] + evmInitiate?: { + to: string + data: string + value: string + allowanceContract: string + } + } relayTransactionMetadata: RelayTransactionMetadata | undefined acrossTransactionMetadata: AcrossTransactionMetadata | undefined debridgeTransactionMetadata: DebridgeTransactionMetadata | undefined diff --git a/public/generated/asset-manifest.json b/public/generated/asset-manifest.json index 7dc107a3a40..07ba0e61c69 100644 --- a/public/generated/asset-manifest.json +++ b/public/generated/asset-manifest.json @@ -1,4 +1,4 @@ { - "assetData": "e3d85bfc", - "relatedAssetIndex": "4b9441d4" + "assetData": "6883b500", + "relatedAssetIndex": "046a1b38" } \ No newline at end of file diff --git a/public/generated/asset-manifest.json.br b/public/generated/asset-manifest.json.br index 337a3b5354d..410407596c1 100644 Binary files a/public/generated/asset-manifest.json.br and b/public/generated/asset-manifest.json.br differ diff --git a/public/generated/asset-manifest.json.gz b/public/generated/asset-manifest.json.gz index bc181fcaafd..d731e7986f5 100644 Binary files a/public/generated/asset-manifest.json.gz and b/public/generated/asset-manifest.json.gz differ diff --git a/public/generated/generatedAssetData.json b/public/generated/generatedAssetData.json index d2f3f53a185..38326768f38 100644 --- a/public/generated/generatedAssetData.json +++ b/public/generated/generatedAssetData.json @@ -340537,6 +340537,19 @@ "explorerAddressLink": "https://tronscan.org/#/address/", "explorerTxLink": "https://tronscan.org/#/transaction/", "relatedAssetKey": null + }, + "starknet:SN_MAIN/token:0x0787150e306e6eae6e3f79dea881770e8bbff2c1b8eb490f969669ee945b3135": { + "assetId": "starknet:SN_MAIN/token:0x0787150e306e6eae6e3f79dea881770e8bbff2c1b8eb490f969669ee945b3135", + "chainId": "starknet:SN_MAIN", + "name": "Starknet Bitcoin", + "precision": 8, + "color": "#F7931A", + "icon": "https://serve.garden.finance/chain_images/strkb.png", + "symbol": "strkBTC", + "explorer": "https://starkscan.co", + "explorerAddressLink": "https://starkscan.co/contract/", + "explorerTxLink": "https://starkscan.co/tx/", + "relatedAssetKey": "eip155:1/erc20:0x2260fac5e5542a773aa44fbcfedf7c193bc2c599" } }, "ids": [ @@ -365576,6 +365589,7 @@ "eip155:1/erc20:0x750c3a0a0ce9984eeb8c5d146dff024b584e5e33", "eip155:324/erc20:0x3355df6d4c9c3035724fd0e3914de96a5a83aaf4", "eip155:324/erc20:0xbbeb516fb02a01611cbbe0453fe3c580d7281011", - "eip155:8453/erc20:0x3054e8f8fba3055a42e5f5228a2a4e2ab1326933" + "eip155:8453/erc20:0x3054e8f8fba3055a42e5f5228a2a4e2ab1326933", + "starknet:SN_MAIN/token:0x0787150e306e6eae6e3f79dea881770e8bbff2c1b8eb490f969669ee945b3135" ] -} \ No newline at end of file +} diff --git a/public/generated/generatedAssetData.json.br b/public/generated/generatedAssetData.json.br index 931fee0ee57..cf4d8130080 100644 Binary files a/public/generated/generatedAssetData.json.br and b/public/generated/generatedAssetData.json.br differ diff --git a/public/generated/generatedAssetData.json.gz b/public/generated/generatedAssetData.json.gz index 494e8883d0b..49107cf25cf 100644 Binary files a/public/generated/generatedAssetData.json.gz and b/public/generated/generatedAssetData.json.gz differ diff --git a/public/generated/relatedAssetIndex.json b/public/generated/relatedAssetIndex.json index 6685d3cf61b..047498a4dc6 100644 --- a/public/generated/relatedAssetIndex.json +++ b/public/generated/relatedAssetIndex.json @@ -1396,7 +1396,8 @@ "eip155:42220/erc20:0xd629eb00deced2a080b7ec630ef6ac117e614f1b", "eip155:534352/erc20:0x3c1bca5a656e69edcd0d4e36bebb3fcdaca60cf1", "eip155:8453/erc20:0x0555e30da8f98308edb960aa94c0db47230d2b9c", - "starknet:SN_MAIN/token:0x03fe2b97c1fd336e750087d68b9b867997fd64a2661ff3ca5a7c771641e8e7ac" + "starknet:SN_MAIN/token:0x03fe2b97c1fd336e750087d68b9b867997fd64a2661ff3ca5a7c771641e8e7ac", + "starknet:SN_MAIN/token:0x0787150e306e6eae6e3f79dea881770e8bbff2c1b8eb490f969669ee945b3135" ], "eip155:8453/erc20:0xc5fed7c8ccc75d8a72b601a66dffd7a489073f0b": [ "eip155:56/erc20:0x6ef2ffb38d64afe18ce782da280b300e358cfeaf", diff --git a/public/generated/relatedAssetIndex.json.br b/public/generated/relatedAssetIndex.json.br index 154703ab776..c549affcd97 100644 Binary files a/public/generated/relatedAssetIndex.json.br and b/public/generated/relatedAssetIndex.json.br differ diff --git a/public/generated/relatedAssetIndex.json.gz b/public/generated/relatedAssetIndex.json.gz index b705e339f1f..3bb9b83cdba 100644 Binary files a/public/generated/relatedAssetIndex.json.gz and b/public/generated/relatedAssetIndex.json.gz differ diff --git a/scripts/generateAssetData/generateRelatedAssetIndex/generateRelatedAssetIndex.ts b/scripts/generateAssetData/generateRelatedAssetIndex/generateRelatedAssetIndex.ts index 8c6b98e686b..8c53daa4db9 100644 --- a/scripts/generateAssetData/generateRelatedAssetIndex/generateRelatedAssetIndex.ts +++ b/scripts/generateAssetData/generateRelatedAssetIndex/generateRelatedAssetIndex.ts @@ -33,6 +33,7 @@ import axios from 'axios' import axiosRetry from 'axios-retry' import fs from 'fs' import { isNull } from 'lodash' +import chunk from 'lodash/chunk' import isUndefined from 'lodash/isUndefined' import { ASSET_DATA_PATH, RELATED_ASSET_INDEX_PATH } from '../constants' @@ -93,6 +94,9 @@ const manualRelatedAssetIndex: Record = { 'eip155:1/erc20:0xdac17f958d2ee523a2206206994597c13d831ec7': [ 'eip155:146/erc20:0x6047828dc181963ba44974801ff68e538da5eaf9', ], + 'eip155:1/erc20:0x2260fac5e5542a773aa44fbcfedf7c193bc2c599': [ + 'starknet:SN_MAIN/token:0x0787150e306e6eae6e3f79dea881770e8bbff2c1b8eb490f969669ee945b3135', + ], } // Category → Canonical Asset mapping for bridged tokens @@ -160,15 +164,6 @@ const fetchBridgedCategoryMappings = async (): Promise> return categoryToCoinIds } -const chunkArray = (array: T[], chunkSize: number) => { - const result = [] - for (let i = 0; i < array.length; i += chunkSize) { - const chunk = array.slice(i, i + chunkSize) - result.push(chunk) - } - return result -} - const getZerionRelatedAssetIds = async ( assetId: AssetId, assetData: Record>, @@ -195,12 +190,6 @@ const getZerionRelatedAssetIds = async ( // exit if any request fails if (status !== 200) throw Error(`Zerion request failed: ${statusText}`) - try { - zerionFungiblesSchema.parse(res) - } catch (e) { - console.log(JSON.stringify(res, null, 2)) - } - const validationResult = zerionFungiblesSchema.parse(res) const firstEntry = validationResult.data[0] @@ -510,7 +499,7 @@ export const generateRelatedAssetIndex = async () => { intervalMs: 2000, }) - const chunks = chunkArray(Object.keys(generatedAssetData), BATCH_SIZE) + const chunks = chunk(Object.keys(generatedAssetData), BATCH_SIZE) for (const [i, batch] of chunks.entries()) { console.log(`Processing chunk: ${i} of ${chunks.length}`) await Promise.all( diff --git a/scripts/generateAssetData/starknet/index.ts b/scripts/generateAssetData/starknet/index.ts index 17c1ffeb86f..603393b858f 100644 --- a/scripts/generateAssetData/starknet/index.ts +++ b/scripts/generateAssetData/starknet/index.ts @@ -1,4 +1,10 @@ -import { starknetAssetId, starknetChainId } from '@shapeshiftoss/caip' +import { + ASSET_NAMESPACE, + ethChainId, + starknetAssetId, + starknetChainId, + toAssetId, +} from '@shapeshiftoss/caip' import type { Asset } from '@shapeshiftoss/types' import axios from 'axios' import chunk from 'lodash/chunk' @@ -23,6 +29,27 @@ const starknetBaseAsset: Asset = { relatedAssetKey: null, } +const wbtcEthAssetId = toAssetId({ + chainId: ethChainId, + assetNamespace: ASSET_NAMESPACE.erc20, + assetReference: '0x2260fac5e5542a773aa44fbcfedf7c193bc2c599', +}) + +const strkbtcAsset: Asset = { + assetId: + 'starknet:SN_MAIN/token:0x0787150e306e6eae6e3f79dea881770e8bbff2c1b8eb490f969669ee945b3135', + chainId: starknetChainId, + name: 'Starknet Bitcoin', + symbol: 'strkBTC', + precision: 8, + color: '#F7931A', + icon: 'https://serve.garden.finance/chain_images/strkb.png', + explorer: 'https://starkscan.co', + explorerAddressLink: 'https://starkscan.co/contract/', + explorerTxLink: 'https://starkscan.co/tx/', + relatedAssetKey: wbtcEthAssetId, +} + export const getAssets = async (): Promise => { const coingeckoTokens = await coingecko.getAssets(starknetChainId).catch(err => { console.error('Error fetching Starknet assets from CoinGecko:', err) @@ -59,7 +86,9 @@ export const getAssets = async (): Promise => { // This matches the Sui pattern for deduplicating native assets const nativeStrkTokenPattern = /^starknet:SN_MAIN\/token:0x0*4718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d$/i - const tokensOnly = modifiedAssets.filter(asset => !nativeStrkTokenPattern.test(asset.assetId)) + const tokensOnly = modifiedAssets.filter( + asset => !nativeStrkTokenPattern.test(asset.assetId) && asset.assetId !== strkbtcAsset.assetId, + ) - return [starknetBaseAsset, ...tokensOnly] + return [starknetBaseAsset, strkbtcAsset, ...tokensOnly] } diff --git a/src/components/MultiHopTrade/components/TradeConfirm/hooks/useTradeButtonProps.tsx b/src/components/MultiHopTrade/components/TradeConfirm/hooks/useTradeButtonProps.tsx index 29a0331b714..2f6cb752b37 100644 --- a/src/components/MultiHopTrade/components/TradeConfirm/hooks/useTradeButtonProps.tsx +++ b/src/components/MultiHopTrade/components/TradeConfirm/hooks/useTradeButtonProps.tsx @@ -119,6 +119,7 @@ export const useTradeButtonProps = ({ metadata: { chainflipSwapId: firstStep?.chainflipSpecific?.chainflipSwapId, nearIntentsSpecific: firstStep?.nearIntentsSpecific, + gardenSpecific: firstStep?.gardenSpecific, relayerExplorerTxLink, relayerTxHash, relayTransactionMetadata: firstStep?.relayTransactionMetadata, diff --git a/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/SwapperIcon.tsx b/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/SwapperIcon.tsx index 187578f40cb..53062fa631f 100644 --- a/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/SwapperIcon.tsx +++ b/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/SwapperIcon.tsx @@ -12,6 +12,7 @@ import CetusIcon from './cetus-icon.jpg' import ChainflipIcon from './chainflip-icon.png' import CowIcon from './cow-icon.png' import DebridgeIcon from './debridge-icon.svg' +import GardenIcon from './garden-icon.png' import MayachainIcon from './maya_logo.png' import NearIntentsIcon from './near-intents-icon.png' import PortalsIcon from './portals-icon.png' @@ -66,6 +67,8 @@ export const SwapperIcon = ({ return AcrossIcon case SwapperName.Debridge: return DebridgeIcon + case SwapperName.Garden: + return GardenIcon case SwapperName.Test: return '' default: diff --git a/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/garden-icon.png b/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/garden-icon.png new file mode 100644 index 00000000000..4d30748ef83 Binary files /dev/null and b/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/garden-icon.png differ diff --git a/src/config.ts b/src/config.ts index 662cb529474..e441545eef1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -279,6 +279,8 @@ 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_GARDEN_SWAP: bool({ default: false }), + VITE_GARDEN_API_KEY: str({ default: '' }), 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/lib/tradeExecution.ts b/src/lib/tradeExecution.ts index fc4ebda605e..1ccd12ee8a9 100644 --- a/src/lib/tradeExecution.ts +++ b/src/lib/tradeExecution.ts @@ -195,6 +195,7 @@ export class TradeExecution { ...swap.metadata, chainflipSwapId: tradeQuote.steps[0]?.chainflipSpecific?.chainflipSwapId, nearIntentsSpecific: tradeQuote.steps[0]?.nearIntentsSpecific, + gardenSpecific: tradeQuote.steps[0]?.gardenSpecific, relayTransactionMetadata: tradeQuote.steps[0]?.relayTransactionMetadata, quoteId: tradeQuote.steps[0]?.stonfiSpecific?.quoteId ?? swap.metadata.quoteId, stepIndex, diff --git a/src/state/helpers.ts b/src/state/helpers.ts index ab979b91d28..fd8a3be214d 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.Garden: return true case SwapperName.Zrx: case SwapperName.CowSwap: @@ -55,6 +56,7 @@ export const getEnabledSwappers = ( StonfiSwap, AcrossSwap, DebridgeSwap, + GardenSwap, }: 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.Garden]: + GardenSwap && (!isCrossAccountTrade || isCrossAccountTradeSupported(SwapperName.Garden)), [SwapperName.Test]: false, } } diff --git a/src/state/slices/portfolioSlice/selectors.ts b/src/state/slices/portfolioSlice/selectors.ts index 6672c2061ca..563a9803891 100644 --- a/src/state/slices/portfolioSlice/selectors.ts +++ b/src/state/slices/portfolioSlice/selectors.ts @@ -867,7 +867,12 @@ export const selectGroupedAssetsWithBalances = createCachedSelector( return row }) .filter(isSome) - .sort((a, b) => bnOrZero(b.fiatAmount).minus(bnOrZero(a.fiatAmount)).toNumber()) + .sort((a, b) => { + const aHas = bnOrZero(a.cryptoAmount).gt(0) ? 1 : 0 + const bHas = bnOrZero(b.cryptoAmount).gt(0) ? 1 : 0 + if (aHas !== bHas) return bHas - aHas + return bnOrZero(b.fiatAmount).minus(bnOrZero(a.fiatAmount)).toNumber() + }) if (relatedAssets.length === 0) return null diff --git a/src/state/slices/preferencesSlice/preferencesSlice.ts b/src/state/slices/preferencesSlice/preferencesSlice.ts index 898fad5e6f8..5769668d1b3 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 + GardenSwap: 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, + GardenSwap: getConfig().VITE_FEATURE_GARDEN_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..f8b9430ef00 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, + GardenSwap: false, LazyTxHistory: false, QuickBuy: false, NewWalletManager: false,