diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9c6c16eb..e5dfcb22 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -28,6 +28,7 @@ The returned `NexusClient` includes: - `swapWithExactOut(params, options?)` - `swapAndExecute(params, options?)` - `calculateMaxForSwap(params)` +- `calculateMaxForBridge(params)` - Simulation variants: `simulateBridge`, `simulateBridgeAndTransfer`, `simulateBridgeAndExecute`, `simulateExecute` - `getBalancesForBridge()` - `getBalancesForSwap()` diff --git a/src/analytics/events.ts b/src/analytics/events.ts index 9f895d74..09ca0a1c 100644 --- a/src/analytics/events.ts +++ b/src/analytics/events.ts @@ -317,6 +317,14 @@ export const NexusAnalyticsEvents = { CALCULATE_MAX_FOR_SWAP_SUCCESS: 'nexus_v2_calculate_max_for_swap_success', /** Fires when calculateMaxForSwap() throws. */ CALCULATE_MAX_FOR_SWAP_FAILED: 'nexus_v2_calculate_max_for_swap_failed', + + // Calculate Max For Bridge Operations + /** Fires when calculateMaxForBridge() is called. */ + CALCULATE_MAX_FOR_BRIDGE_INITIATED: 'nexus_v2_calculate_max_for_bridge_initiated', + /** Fires when calculateMaxForBridge() returns. */ + CALCULATE_MAX_FOR_BRIDGE_SUCCESS: 'nexus_v2_calculate_max_for_bridge_success', + /** Fires when calculateMaxForBridge() throws. */ + CALCULATE_MAX_FOR_BRIDGE_FAILED: 'nexus_v2_calculate_max_for_bridge_failed', } as const; export type NexusAnalyticsEvent = (typeof NexusAnalyticsEvents)[keyof typeof NexusAnalyticsEvents]; @@ -340,6 +348,7 @@ export const NexusOperationNames = { BALANCES_FETCH_SWAP: 'balances_fetch_swap', LIST_INTENTS: 'list_intents', CALCULATE_MAX_FOR_SWAP: 'calculate_max_for_swap', + CALCULATE_MAX_FOR_BRIDGE: 'calculate_max_for_bridge', WALLET_CONNECT: 'wallet_connect', INITIALIZE: 'initialize', } as const; diff --git a/src/core/sdk/client.ts b/src/core/sdk/client.ts index 958b94e5..5a3d59ff 100644 --- a/src/core/sdk/client.ts +++ b/src/core/sdk/client.ts @@ -1,4 +1,5 @@ import { AnalyticsManager } from '../../analytics/AnalyticsManager'; +import type { BridgeMaxParams, BridgeMaxResult } from '../../bridge/types'; import type { AnalyticsConfig, BridgeAndExecuteParams, @@ -49,6 +50,7 @@ import { trackBridgeAndExecute, trackBridgeAndExecuteSim, trackBridgeSim, + trackCalculateMaxForBridge, trackCalculateMaxForSwap, trackExecute, trackExecuteSim, @@ -192,6 +194,9 @@ export const createNexusClient = (config?: { const calculateMaxForSwapPublic = (input: SwapMaxParams): Promise => trackCalculateMaxForSwap(analytics, input, () => base.calculateMaxForSwap(input)); + const calculateMaxForBridgePublic = (input: BridgeMaxParams): Promise => + trackCalculateMaxForBridge(analytics, input, () => base.calculateMaxForBridge(input)); + const setEVMProvider = (provider: EthereumProvider) => base.setEvmProvider(provider); const convertTokenReadableAmountToBigInt = ( @@ -229,7 +234,7 @@ export const createNexusClient = (config?: { swapWithExactOut, swapAndExecute: swapAndExecutePublic, calculateMaxForSwap: calculateMaxForSwapPublic, - calculateMaxForBridge: (input) => base.calculateMaxForBridge(input), + calculateMaxForBridge: calculateMaxForBridgePublic, setEVMProvider, convertTokenReadableAmountToBigInt, getSupportedChains: () => getSupportedChainsFromChainList(base.getChainList()), diff --git a/src/core/sdk/operation-boundary.ts b/src/core/sdk/operation-boundary.ts index 9b30b7a0..2132fb4c 100644 --- a/src/core/sdk/operation-boundary.ts +++ b/src/core/sdk/operation-boundary.ts @@ -23,6 +23,7 @@ import { translateTransferEvent, } from '../../analytics/lifecycle-translator'; import { buildEconomics, extractBridgeProperties, getWalletType } from '../../analytics/utils'; +import type { BridgeMaxParams, BridgeMaxResult } from '../../bridge/types'; import type { BridgeAndExecuteEvent, BridgeAndExecuteParams, @@ -617,6 +618,30 @@ export function trackCalculateMaxForSwap( }); } +export function trackCalculateMaxForBridge( + analytics: AnalyticsManager, + params: BridgeMaxParams, + run: (opId: string) => Promise +): Promise { + const initiatedProps = { + toChainId: params.toChainId, + tokenSymbol: params.toTokenSymbol, + sourceChains: params.sources, + }; + return analytics.runOp({ + events: { + initiated: NexusAnalyticsEvents.CALCULATE_MAX_FOR_BRIDGE_INITIATED, + success: NexusAnalyticsEvents.CALCULATE_MAX_FOR_BRIDGE_SUCCESS, + failed: NexusAnalyticsEvents.CALCULATE_MAX_FOR_BRIDGE_FAILED, + }, + opName: NexusOperationNames.CALCULATE_MAX_FOR_BRIDGE, + operation: 'calculateMaxForBridge', + initiatedProps, + params, + run, + }); +} + export function trackWalletConnect( analytics: AnalyticsManager, provider: EthereumProvider, diff --git a/src/domain/errors.ts b/src/domain/errors.ts index 494e9460..d3301ce4 100644 --- a/src/domain/errors.ts +++ b/src/domain/errors.ts @@ -1,4 +1,4 @@ -import { BaseError as ViemBaseError, type Hex } from 'viem'; +import { type Hex, BaseError as ViemBaseError } from 'viem'; /** * Categories for hierarchical errors. Drives subclass identity and `error.category` @@ -42,6 +42,7 @@ export type OperationName = | 'swapWithExactOut' | 'swapAndExecute' | 'calculateMaxForSwap' + | 'calculateMaxForBridge' | 'setEVMProvider' // exported utility helpers (rev 10) | 'getCoinbaseRates' diff --git a/tests/core/sdk-calculate-max-for-bridge.test.ts b/tests/core/sdk-calculate-max-for-bridge.test.ts new file mode 100644 index 00000000..3e92837e --- /dev/null +++ b/tests/core/sdk-calculate-max-for-bridge.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { BridgeMaxParams, BridgeMaxResult } from '../../src'; +import { createNexusClient } from '../../src'; + +const hoisted = vi.hoisted(() => ({ + calculateMaxForBridge: vi.fn(), + peekChainList: vi.fn(), + reportOperationError: vi.fn(), + setAnalytics: vi.fn(), +})); + +vi.mock('../../src/core/sdk/base', () => ({ + createBase: vi.fn(() => ({ + calculateMaxForBridge: hoisted.calculateMaxForBridge, + peekChainList: hoisted.peekChainList, + setAnalytics: hoisted.setAnalytics, + })), +})); + +vi.mock('../../src/services/error-telemetry', () => ({ + reportOperationError: hoisted.reportOperationError, +})); + +const input: BridgeMaxParams = { + toChainId: 8453, + toTokenSymbol: 'USDC', + sources: [10, 42161], +}; + +const maxResult: BridgeMaxResult = { + toChainId: 8453, + toTokenSymbol: 'USDC', + provider: 'nexus', + maxAmount: '2.5', + maxAmountRaw: 2_500_000n, + symbol: 'USDC', + decimals: 6, + sources: [ + { + chainId: 10, + tokenAddress: '0x0000000000000000000000000000000000000010', + symbol: 'USDC', + decimals: 6, + amount: '2.5', + }, + ], +}; + +const makeClient = () => { + const client = createNexusClient({ + network: 'testnet', + analytics: { enabled: true }, + }); + client.analytics.enable(); + return client; +}; + +describe('createNexusClient calculateMaxForBridge analytics', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('emits the operation lifecycle and preserves the max result', async () => { + hoisted.calculateMaxForBridge.mockResolvedValue(maxResult); + const client = makeClient(); + const trackSpy = vi.spyOn(client.analytics, 'track'); + trackSpy.mockClear(); + + await expect(client.calculateMaxForBridge(input)).resolves.toBe(maxResult); + + expect(hoisted.calculateMaxForBridge).toHaveBeenCalledWith(input); + expect(trackSpy).toHaveBeenCalledWith('nexus_v2_calculate_max_for_bridge_initiated', { + toChainId: 8453, + tokenSymbol: 'USDC', + sourceChains: [10, 42161], + }); + expect(trackSpy).toHaveBeenCalledWith('nexus_v2_calculate_max_for_bridge_success', { + toChainId: 8453, + tokenSymbol: 'USDC', + sourceChains: [10, 42161], + }); + expect(trackSpy).toHaveBeenCalledWith( + 'nexus_v2_operation_performance', + expect.objectContaining({ + operation: 'calculate_max_for_bridge', + success: true, + }) + ); + }); + + it('emits failure analytics and rethrows the original error', async () => { + const failure = new Error('bridge max unavailable'); + hoisted.calculateMaxForBridge.mockRejectedValue(failure); + const client = makeClient(); + const trackSpy = vi.spyOn(client.analytics, 'track'); + trackSpy.mockClear(); + + await expect(client.calculateMaxForBridge(input)).rejects.toBe(failure); + + expect(trackSpy).toHaveBeenCalledWith('nexus_v2_calculate_max_for_bridge_failed', { + toChainId: 8453, + tokenSymbol: 'USDC', + sourceChains: [10, 42161], + }); + expect(trackSpy).not.toHaveBeenCalledWith( + 'nexus_v2_calculate_max_for_bridge_success', + expect.anything() + ); + expect(hoisted.reportOperationError).toHaveBeenCalledWith({ + operation: 'calculateMaxForBridge', + operationId: expect.any(String), + params: input, + options: undefined, + error: failure, + }); + expect(trackSpy).toHaveBeenCalledWith( + 'nexus_v2_operation_performance', + expect.objectContaining({ + operation: 'calculate_max_for_bridge', + success: false, + }) + ); + }); +}); diff --git a/tests/public-api.test.ts b/tests/public-api.test.ts index fd26659c..f6e04142 100644 --- a/tests/public-api.test.ts +++ b/tests/public-api.test.ts @@ -9,6 +9,7 @@ import type { IntentRecord, ListIntentsParams, ListIntentsResult, + OperationName, SwapAndExecuteResult, SwapMaxResult, SwapResult as SwapResultType, @@ -23,6 +24,8 @@ describe('public api exports', () => { const bridgeSimulation = {} as BridgeSimulationResult; const swapResult = {} as SwapResult; const swapMaxResult = {} as SwapMaxResult; + const calculateMaxForBridgeOperation = + 'calculateMaxForBridge' as const satisfies OperationName; const txResult = {} as TxResult; const bridgeAndExecuteResult = {} as BridgeAndExecuteResult; const swapAndExecuteResult = {} as SwapAndExecuteResult; @@ -33,6 +36,7 @@ describe('public api exports', () => { expectTypeOf(bridgeSimulation).toMatchTypeOf(); expectTypeOf(swapResult).toMatchTypeOf(); expectTypeOf(swapMaxResult).toMatchTypeOf(); + expect(calculateMaxForBridgeOperation).toBe('calculateMaxForBridge'); expectTypeOf(txResult).toMatchTypeOf(); expectTypeOf(bridgeAndExecuteResult).toMatchTypeOf(); expectTypeOf(swapAndExecuteResult).toMatchTypeOf(); @@ -83,6 +87,15 @@ describe('public api exports', () => { expect(NexusAnalyticsEvents.CALCULATE_MAX_FOR_SWAP_FAILED).toBe( 'nexus_v2_calculate_max_for_swap_failed' ); + expect(NexusAnalyticsEvents.CALCULATE_MAX_FOR_BRIDGE_INITIATED).toBe( + 'nexus_v2_calculate_max_for_bridge_initiated' + ); + expect(NexusAnalyticsEvents.CALCULATE_MAX_FOR_BRIDGE_SUCCESS).toBe( + 'nexus_v2_calculate_max_for_bridge_success' + ); + expect(NexusAnalyticsEvents.CALCULATE_MAX_FOR_BRIDGE_FAILED).toBe( + 'nexus_v2_calculate_max_for_bridge_failed' + ); }); it('locks the AnalyticsManager public surface after boundary cleanup', () => { @@ -119,6 +132,7 @@ describe('public api exports', () => { 'trackBalanceFetch', 'trackInit', 'trackListIntents', + 'trackCalculateMaxForBridge', 'trackCalculateMaxForSwap', 'trackWalletConnect', ]) {