import Mpc from '../../mpc' import { sdkLogger } from '../../logger' import { waitForEvmTxConfirmation } from '../../internal/waitForEvmTxConfirmation' import { stripStalePlanningNonceIfJsonObject } from '../../internal/stripStalePlanningNonce' import { isYieldEvmNetwork, resolveYieldNetworkToCaip2, } from '../../internal/yieldEvmNetwork' import type { YieldDepositParams, YieldDepositResult, YieldSubmitOptions, YieldSubmitProgress, YieldSubmitResultStatus, YieldWithdrawParams, YieldWithdrawResult, YieldXyzActionTransaction, YieldXyzEnterRequest, YieldXyzEnterYieldResponse, YieldXyzExitRequest, YieldXyzExitResponse, YieldXyzGetBalancesRequest, YieldXyzGetBalancesResponse, YieldXyzGetHistoricalActionsRequest, YieldXyzGetHistoricalActionsResponse, YieldXyzGetTransactionResponse, YieldXyzGetYieldDefaultsRequest, YieldXyzGetYieldDefaultsResponse, YieldXyzGetYieldsRequest, YieldXyzGetYieldsResponse, YieldXyzManageYieldRequest, YieldXyzManageYieldResponse, YieldXyzTrackTransactionRequest, YieldXyzTrackTransactionResponse, YieldXyzValidator, } from '../../shared/types' const LOG_PREFIX = '[YieldXyz]' /** CAIP-2 chain ID format: namespace:reference (e.g. eip155:1, solana:5eykt...) */ const CAIP2_CHAIN_ID_REGEX = /^[a-z0-9-]+:[a-zA-Z0-9]+$/ export default class YieldXyz { private mpc: Mpc private signAndSendTransactionFn?: ( transaction: unknown, network: string, ) => Promise private waitForConfirmationFn?: ( txHash: string, network: string, ) => Promise private evmRequestFn?: ( method: string, params: unknown[], network: string, ) => Promise private evmPollerDefaults?: { pollIntervalMs?: number; timeoutMs?: number } constructor({ mpc, waitForConfirmation, evmRequestFn, evmPollerOptions, }: { mpc: Mpc waitForConfirmation?: (txHash: string, network: string) => Promise evmRequestFn?: ( method: string, params: unknown[], network: string, ) => Promise evmPollerOptions?: { pollIntervalMs?: number; timeoutMs?: number } }) { this.mpc = mpc this.waitForConfirmationFn = waitForConfirmation this.evmRequestFn = evmRequestFn this.evmPollerDefaults = evmPollerOptions } /** * Configures the signer used by deposit() and withdraw() when no per-call * `signAndSendTransaction` option is provided. Typically called once after construction * (e.g. Portal wires in its MPC signer via this method instead of the constructor). * * Per-call `signAndSendTransaction` in {@link YieldSubmitOptions} always takes precedence. */ public setSignAndSendTransaction( fn: (transaction: unknown, network: string) => Promise, ): void { this.signAndSendTransactionFn = fn } /** * Retrieves yield balances for specified addresses and networks. * @param data - The parameters for the yield balances request. * @returns A `YieldXyzGetBalancesResponse` promise, resolving to balance information. * @throws An error if the operation fails. */ public async getBalances( data: YieldXyzGetBalancesRequest, ): Promise { return this.mpc?.getYieldXyzBalances(data) } /** * Retrieves historical yield actions with optional filtering. * @param data - The parameters for the historical yield actions request. * @returns A `YieldXyzGetHistoricalActionsResponse` promise, resolving to historical actions. * @throws An error if the operation fails. */ public async getHistoricalActions( data: YieldXyzGetHistoricalActionsRequest, ): Promise { return this.mpc?.getYieldXyzHistoricalActions(data) } /** * Manages a yield opportunity with the specified parameters. * @param data - The parameters for managing a yield opportunity. * @returns A `YieldXyzManageYieldResponse` promise, resolving to the action details. * @throws An error if the operation fails. */ public async manage( data: YieldXyzManageYieldRequest, ): Promise { return this.mpc?.manageYieldXyzYield(data) } /** * Enters a yield opportunity with the specified parameters. * @param data - The parameters for entering a yield opportunity. * @returns A `YieldXyzEnterYieldResponse` promise, resolving to the action details. * @throws An error if the operation fails. */ public async enter( data: YieldXyzEnterRequest, ): Promise { return this.mpc?.enterYieldXyzYield(data) } /** * Exits a yield opportunity with the specified parameters. * @param data - The parameters for exiting a yield opportunity. * @returns A `YieldXyzExitResponse` promise, resolving to the action details. * @throws An error if the operation fails. */ public async exit(data: YieldXyzExitRequest): Promise { return this.mpc?.exitYieldXyzYield(data) } /** * Discovers yield opportunities based on the provided parameters. * @param data - Optional parameters for yield discovery. If undefined, uses default parameters. * @returns A `YieldXyzGetYieldsResponse` promise, resolving to available yield opportunities. * @throws An error if the operation fails. */ public async discover( data: YieldXyzGetYieldsRequest, ): Promise { return this.mpc?.getYieldXyzYields(data) } /** * Tracks a transaction by submitting its hash to the Yield.xyz integration. * @param data - The parameters for tracking a transaction: * - transactionId: The ID of the transaction to track. * - txHash: The hash of the transaction to submit. * @returns A `YieldXyzTrackTransactionResponse` promise. * @throws An error if the operation fails. */ public async track( data: YieldXyzTrackTransactionRequest, ): Promise { return this.mpc?.trackYieldXyzTransaction(data) } /** * Retrieves a single yield action transaction by its ID. * @param transactionId - The ID of the transaction to retrieve. * @returns A `YieldXyzGetTransactionResponse` promise, resolving to transaction details. * @throws An error if the operation fails. */ public async getTransaction( transactionId: string, ): Promise { return this.mpc?.getYieldXyzTransaction(transactionId) } /** * Resolve suggested defaults (amount, validators) from Portal yield defaults map. * Calls the dedicated Yield.xyz defaults endpoint used in the deposit/withdraw flow. */ public async getYieldDefaults( req?: YieldXyzGetYieldDefaultsRequest, ): Promise { return this.mpc?.getYieldXyzDefaults(req) } public async getValidators(yieldId: string): Promise { const res = await this.mpc.getYieldXyzValidators(yieldId) sdkLogger.debug(`${LOG_PREFIX} getValidators raw response`, res) if (res.error) { sdkLogger.error(`${LOG_PREFIX} getValidators error`, res.error) throw new Error(`[YieldXyz] getValidators failed: ${res.error}`) } const d = res.data const validators = d?.validators ?? d?.rawResponse?.validators ?? d?.rawResponse?.items sdkLogger.debug(`${LOG_PREFIX} getValidators processed`, { validators }) if (!validators || !Array.isArray(validators)) { sdkLogger.error( `${LOG_PREFIX} No validators found for yieldId="${yieldId}"`, { data: d }, ) throw new Error( `[YieldXyz] No validators in response for yieldId="${yieldId}"`, ) } return validators } /** * @internal Validates and trims `chain` to a full CAIP-2 id (no aliases or numeric shortcuts). */ private requireFullCaip2Chain(chain: string): string { const c = chain.trim() if (!c) { throw new Error('[YieldXyz] chain is required.') } if (!CAIP2_CHAIN_ID_REGEX.test(c)) { throw new Error( `[YieldXyz] chain must be a full CAIP-2 id (e.g. eip155:11155111). Received: "${chain}"`, ) } return c } /** * @internal When `params` includes a non-empty `yieldId`, returns it. Otherwise requires * `chain` + `token`, validates CAIP-2, and resolves `yieldId` from Portal yield defaults. */ private async resolveYieldIdForHighLevelAction( params: YieldDepositParams, ): Promise<{ yieldId: string; chain?: string; token?: string }> { const fromYieldId = 'yieldId' in params && params.yieldId != null ? String(params.yieldId).trim() : '' if (fromYieldId !== '') { return { yieldId: fromYieldId } } if (!('chain' in params) || !('token' in params)) { throw new Error( '[YieldXyz] Provide either yieldId, or both chain (full CAIP-2) and token.', ) } const chainCaip2 = this.requireFullCaip2Chain(params.chain) const token = params.token.trim() const yieldId = await this.resolveYieldIdFromPortalDefaults( chainCaip2, token, ) return { yieldId, chain: chainCaip2, token } } /** * @internal Loads Portal yield defaults and reads `yieldId` for the exact map key `{caip2}:{token}`. */ private async resolveYieldIdFromPortalDefaults( chainCaip2: string, tokenSymbol: string, ): Promise { const token = tokenSymbol.trim() if (!token) { throw new Error( '[YieldXyz] token is required; use the exact symbol suffix from the yield defaults map.', ) } const key = `${chainCaip2}:${token}` const res = await this.getYieldDefaults({ includeOpportunities: false, }) if (res.error) { throw new Error(`[YieldXyz] Failed to get yield defaults: ${res.error}`) } if (!res.data) { throw new Error('[YieldXyz] No data returned from yield defaults endpoint') } const entry = res.data[key] const yieldId = entry?.yieldId if (yieldId == null || yieldId === '') { throw new Error( `[YieldXyz] No default yield for key "${key}". Use chain and token exactly as in the Portal yield defaults response.`, ) } return yieldId } /** * High-level deposit: build enter yield action, sign and submit each transaction, track hashes. * * The signer is resolved in priority order: * 1. `options.signAndSendTransaction` — per-call override. * 2. Instance setter — configured via {@link YieldXyz.setSignAndSendTransaction} (e.g. Portal). * 3. Error — thrown if neither is available. * * Supply either **`yieldId`** + `amount` (optional `address` / `arguments`), or full **CAIP-2** * **`chain`** + **`token`** + `amount` so the SDK can resolve `yieldId` from Portal yield defaults. * Optional {@link YieldSubmitOptions.onProgress} fires the same steps regardless of input mode. */ public async deposit( params: YieldDepositParams, options?: YieldSubmitOptions, ): Promise { const signAndSend = options?.signAndSendTransaction ?? this.signAndSendTransactionFn if (!signAndSend) { throw new Error( '[YieldXyz] No signer configured. Call setSignAndSendTransaction() on the instance or pass signAndSendTransaction in options.', ) } const { yieldId, chain: resolvedChain, token: resolvedToken } = await this.resolveYieldIdForHighLevelAction(params) sdkLogger.info(`${LOG_PREFIX} deposit: entry`, { amount: params.amount, yieldId, token: resolvedToken, chain: resolvedChain, usedExplicitYieldId: resolvedChain === undefined, hasAddress: Boolean(params.address), hasArguments: Boolean(params.arguments), }) sdkLogger.debug(`${LOG_PREFIX} deposit: resolved yieldId`, { yieldId }) const request: YieldXyzEnterRequest = { yieldId, address: params.address, arguments: { ...params.arguments, amount: params.amount, }, } try { const response = await this.enter(request) const effectiveWaitForConfirmation = this.resolveEffectiveWaitForConfirmation(options) const base = await this.executeAndTrack( response, signAndSend, options?.onProgress, effectiveWaitForConfirmation, 'deposit', ) const result: YieldDepositResult = { ...base, ...(resolvedChain !== undefined && resolvedToken !== undefined ? { chain: resolvedChain, token: resolvedToken } : {}), } sdkLogger.info(`${LOG_PREFIX} deposit: complete`, { hashes: result.hashes, yieldId: result.yieldId, status: result.status, yieldOpportunityDetails: result.yieldOpportunityDetails, }) return result } catch (error) { sdkLogger.debug(`${LOG_PREFIX} deposit: failed, re-throwing`, error) throw error } } /** * High-level withdraw: same dual input modes, `yieldId` resolution, and signer fallback * order as {@link YieldXyz.deposit}. */ public async withdraw( params: YieldWithdrawParams, options?: YieldSubmitOptions, ): Promise { const signAndSend = options?.signAndSendTransaction ?? this.signAndSendTransactionFn if (!signAndSend) { throw new Error( '[YieldXyz] No signer configured. Call setSignAndSendTransaction() on the instance or pass signAndSendTransaction in options.', ) } const { yieldId, chain: resolvedChain, token: resolvedToken } = await this.resolveYieldIdForHighLevelAction(params) sdkLogger.debug(`${LOG_PREFIX} withdraw: entry`, { amount: params.amount, yieldId, token: resolvedToken, chain: resolvedChain, usedExplicitYieldId: resolvedChain === undefined, hasAddress: Boolean(params.address), hasArguments: Boolean(params.arguments), }) sdkLogger.debug(`${LOG_PREFIX} withdraw: resolved yieldId`, { yieldId }) const request: YieldXyzExitRequest = { yieldId, address: params.address, arguments: { ...params.arguments, amount: params.amount, }, } try { const response = await this.exit(request) const effectiveWaitForConfirmation = this.resolveEffectiveWaitForConfirmation(options) const base = await this.executeAndTrack( response, signAndSend, options?.onProgress, effectiveWaitForConfirmation, 'withdraw', ) const result: YieldWithdrawResult = { ...base, ...(resolvedChain !== undefined && resolvedToken !== undefined ? { chain: resolvedChain, token: resolvedToken } : {}), } sdkLogger.info(`${LOG_PREFIX} withdraw: complete`, { hashes: result.hashes, yieldId: result.yieldId, status: result.status, yieldOpportunityDetails: result.yieldOpportunityDetails, }) return result } catch (error) { sdkLogger.debug(`${LOG_PREFIX} withdraw: failed, re-throwing`, error) throw error } } private extractTransactions( response: YieldXyzEnterYieldResponse | YieldXyzExitResponse, ): YieldXyzActionTransaction[] { const transactions = response.data?.rawResponse?.transactions if ( transactions && Array.isArray(transactions) && transactions.length > 0 ) { return transactions } return [] } private buildYieldOpportunityDetails( response: YieldXyzEnterYieldResponse | YieldXyzExitResponse, ): YieldDepositResult['yieldOpportunityDetails'] { const raw = response.data?.rawResponse if (!raw) { return { yieldId: '' } } return { yieldId: raw.yieldId, intent: raw.intent, type: raw.type, executionPattern: raw.executionPattern, status: raw.status, amount: raw.amount, amountUsd: raw.amountUsd, } } private parseUnsignedToObject( unsigned: string | Record | null, ): Record { if (unsigned == null) return {} if (typeof unsigned === 'object') return { ...unsigned } try { const parsed = JSON.parse(unsigned) // Validate parsed result is a plain object (not null, not array) if ( parsed != null && typeof parsed === 'object' && !Array.isArray(parsed) ) { return parsed as Record } sdkLogger.warn( `${LOG_PREFIX} JSON.parse returned non-object type: ${typeof parsed}`, ) return {} } catch { return {} } } private getTransactionPayloadSummary( payload: unknown, ): Record { const obj = this.parseUnsignedToObject( typeof payload === 'string' ? payload : (payload as Record), ) const data = obj.data ?? obj.input const dataStr = typeof data === 'string' ? data : JSON.stringify(data) return { from: obj.from, to: obj.to, dataLength: dataStr?.length ?? 0, value: obj.value, gasLimit: obj.gas ?? obj.gasLimit, nonce: obj.nonce, maxFeePerGas: obj.maxFeePerGas, maxPriorityFeePerGas: obj.maxPriorityFeePerGas, } } private logError(context: string, error: unknown): void { const err = error as Error & { response?: unknown status?: number data?: unknown } sdkLogger.error(`${LOG_PREFIX} ${context}:`, error) if (err.response !== undefined) { sdkLogger.debug(`${LOG_PREFIX} ${context} HTTP response:`, err.response) } if (err.status !== undefined) { sdkLogger.debug(`${LOG_PREFIX} ${context} HTTP status:`, err.status) } if (err.data !== undefined) { sdkLogger.debug(`${LOG_PREFIX} ${context} HTTP response data:`, err.data) } } private resolveEffectiveWaitForConfirmation( options?: YieldSubmitOptions, ): | ((txHash: string, network: string) => Promise) | undefined { if (options?.waitForConfirmation) { return options.waitForConfirmation } if (this.waitForConfirmationFn) { return this.waitForConfirmationFn } const evmFn = options?.evmRequestFn ?? this.evmRequestFn if (!evmFn) { return undefined } const pollerOpts = { ...this.evmPollerDefaults, ...options?.evmPollerOptions, } return async (txHash: string, network: string) => { if (!isYieldEvmNetwork(network)) { sdkLogger.warn( `${LOG_PREFIX} Cannot verify confirmation for non-EVM network ${network}. ` + 'Internal EVM poller fallback does not support non-EVM networks.', ) return false } const caip2 = network.startsWith('eip155:') ? network : resolveYieldNetworkToCaip2(network) ?? network return waitForEvmTxConfirmation(txHash, caip2, evmFn, { pollIntervalMs: pollerOpts.pollIntervalMs, timeoutMs: pollerOpts.timeoutMs, onTimeout: 'resolve_false', }) } } private async executeAndTrack( response: YieldXyzEnterYieldResponse | YieldXyzExitResponse, signAndSend: (transaction: unknown, network: string) => Promise, onProgress?: (event: YieldSubmitProgress) => void, waitForConfirmation?: ( txHash: string, network: string, ) => Promise, method: 'deposit' | 'withdraw' = 'deposit', ): Promise< Pick< YieldDepositResult, 'hashes' | 'yieldId' | 'yieldOpportunityDetails' | 'status' > > { const transactions = this.extractTransactions(response) sdkLogger.debug(`${LOG_PREFIX} executeAndTrack: transaction count`, { method, transactionCount: transactions.length, }) if (transactions.length === 0) { throw new Error('No transactions in yield action response.') } const total = transactions.length const hashes: string[] = [] let confirmationsReached = 0 let confirmationsRequired = waitForConfirmation ? total : 0 for (let index = 0; index < transactions.length; index++) { const tx = transactions[index] // Validate transaction exists (defensive check for sparse arrays) if (!tx) { throw new Error( `Transaction at index ${index} is undefined or null. This indicates a malformed API response.`, ) } try { if (!tx.id) { throw new Error( `Transaction at index ${index} is missing required field "id".`, ) } if (tx.network == null || tx.network === '') { throw new Error( `Transaction ${tx.id} is missing required field "network".`, ) } if (tx.unsignedTransaction == null) { throw new Error( `Transaction ${tx.id} has no unsignedTransaction to sign.`, ) } const rawUnsigned = tx.unsignedTransaction let payloadToSend: unknown = rawUnsigned const isEvm = typeof tx.network === 'string' && isYieldEvmNetwork(tx.network) const resolvedNetwork = isEvm ? (resolveYieldNetworkToCaip2(tx.network) ?? tx.network) : tx.network if (isEvm) { const canStripNonce = typeof rawUnsigned === 'string' || (typeof rawUnsigned === 'object' && rawUnsigned !== null && !Array.isArray(rawUnsigned)) if (canStripNonce) { payloadToSend = stripStalePlanningNonceIfJsonObject( rawUnsigned as string | Record, ) } sdkLogger.debug( `${LOG_PREFIX} executeAndTrack: nonce stripped for EVM tx`, { index, transactionId: tx.id, network: tx.network, payloadKind: typeof payloadToSend, }, ) } const payloadSummary = this.getTransactionPayloadSummary(payloadToSend) sdkLogger.debug( `${LOG_PREFIX} executeAndTrack: transaction preparation`, { method, index, total, transactionId: tx.id, network: tx.network, type: tx.type, payloadSummary, }, ) const progressSigning: YieldSubmitProgress = { step: 'signing', index, total, } sdkLogger.debug(`${LOG_PREFIX} executeAndTrack: signing start`, { method, index, total, transactionId: tx.id, }) onProgress?.(progressSigning) const hash = await signAndSend(payloadToSend, resolvedNetwork) if (typeof hash !== 'string' || hash.trim() === '') { throw new Error( `Transaction ${tx.id} signing returned an empty or invalid hash.`, ) } hashes.push(hash) sdkLogger.debug(`${LOG_PREFIX} executeAndTrack: signing complete`, { method, index, total, transactionId: tx.id, hash, }) sdkLogger.info(`${LOG_PREFIX} ${method}: submitted tx`, { index: index + 1, total, hash, }) const progressSubmitted: YieldSubmitProgress = { step: 'submitted', index, total, hash, } sdkLogger.debug(`${LOG_PREFIX} executeAndTrack: onProgress`, { step: progressSubmitted.step, index: progressSubmitted.index, total: progressSubmitted.total, hash: progressSubmitted.hash, }) onProgress?.(progressSubmitted) if (waitForConfirmation) { const progressConfirming: YieldSubmitProgress = { step: 'confirming', index, total, hash, } sdkLogger.info( `${LOG_PREFIX} executeAndTrack: waiting for confirmation`, { method, index, total, hash, network: tx.network, strategy: 'waitForConfirmation', }, ) onProgress?.(progressConfirming) const waiterResult = await waitForConfirmation(hash, resolvedNetwork) const isConfirmed = waiterResult === true if (isConfirmed) { confirmationsReached += 1 sdkLogger.debug( `${LOG_PREFIX} executeAndTrack: confirmation received`, { method, hash, confirmationsReached, total, }, ) onProgress?.({ step: 'confirmed', index, total, hash }) } else { sdkLogger.debug( `${LOG_PREFIX} executeAndTrack: confirmation not reached (waitForConfirmation did not return true)`, { method, hash, confirmationsReached, total, waiterResult, }, ) // Uncertain confirmation (timeout / false / no response): safe stop. // Track the hash with backend before stopping, then return partial result. sdkLogger.debug( `${LOG_PREFIX} executeAndTrack: track (submitTransactionHash) call before break`, { method, transactionId: tx.id, hash, }, ) await this.track({ transactionId: tx.id, hash, }) break } } sdkLogger.debug( `${LOG_PREFIX} executeAndTrack: track (submitTransactionHash) call`, { method, transactionId: tx.id, hash, }, ) const trackResponse = await this.track({ transactionId: tx.id, hash, }) sdkLogger.debug(`${LOG_PREFIX} executeAndTrack: track response`, { method, transactionId: tx.id, hash, trackStatus: trackResponse?.data?.rawResponse?.status, }) } catch (error) { this.logError( `executeAndTrack ${method} failed at index ${index}`, error, ) sdkLogger.debug(`${LOG_PREFIX} executeAndTrack: error context`, { method, index, total, transactionId: tx.id, network: tx.network, hashesSoFar: hashes.length, }) throw error } } const rawResponse = response.data?.rawResponse const yieldId = rawResponse?.yieldId ?? '' const yieldOpportunityDetails = this.buildYieldOpportunityDetails(response) // Determine status based on confirmation semantics from docs: // - If no confirmations required (no waitForConfirmation): SUCCESS // - If all required confirmations reached: SUCCESS // - If some confirmations reached but not all: PARTIAL_SUCCESS // - If confirmations required but zero reached: FAILED let status: YieldSubmitResultStatus if (confirmationsRequired === 0) { status = 'SUCCESS' } else if (confirmationsReached === confirmationsRequired) { status = 'SUCCESS' } else if (confirmationsReached > 0) { status = 'PARTIAL_SUCCESS' } else { status = 'FAILED' } sdkLogger.info(`${LOG_PREFIX} ${method}: executeAndTrack complete`, { yieldId, hashCount: hashes.length, confirmationsReached, confirmationsRequired, status, }) return { hashes, yieldId, yieldOpportunityDetails, status, } } }