import { CAIP2_CHAIN_ID_REGEX, sdkLogger } from '@portal-hq/utils' import type { YieldDepositParams, YieldDepositResult, YieldSubmitOptions, YieldSubmitProgress, YieldSubmitResultStatus, YieldWithdrawParams, YieldWithdrawResult, YieldXyzActionTransaction, YieldXyzEnterRequest, YieldXyzEnterYieldResponse, YieldXyzExitRequest, YieldXyzExitResponse, YieldXyzGetBalancesRequest, YieldXyzGetBalancesResponse, YieldXyzGetHistoricalActionsRequest, YieldXyzGetHistoricalActionsResponse, YieldXyzGetTransactionResponse, YieldXyzGetYieldsRequest, YieldXyzGetYieldsResponse, YieldXyzManageYieldRequest, YieldXyzManageYieldResponse, YieldXyzTrackTransactionRequest, YieldXyzTrackTransactionResponse, YieldXyzValidator, } from '../../types' import type { IPortalYieldXyzApi } from './PortalYieldXyzApi' import type { EvmReceiptPollerOptions, EvmRequestFn } from './evmReceiptPoller' import { pollForEvmReceipt } from './evmReceiptPoller' import { isYieldEvmNetwork } from './yieldEvmNetwork' const LOG_PREFIX = '[YieldXyz]' export interface IYieldXyz { discover( request?: YieldXyzGetYieldsRequest, ): Promise enter(request: YieldXyzEnterRequest): Promise track( transactionId: string, txHash: string, ): Promise getBalances( request: YieldXyzGetBalancesRequest, ): Promise getTransaction(transactionId: string): Promise getHistoricalActions( request: YieldXyzGetHistoricalActionsRequest, ): Promise manage( request: YieldXyzManageYieldRequest, ): Promise exit(request: YieldXyzExitRequest): Promise /** * Configures the signer used by deposit() and withdraw() when no per-call * `signAndSendTransaction` option is provided. Call this once after construction * (e.g. when Portal wires in its MPC signer) instead of passing it to the constructor. * * Per-call `signAndSendTransaction` in {@link YieldSubmitOptions} always takes precedence * over the instance-level setter. */ setSignAndSendTransaction( fn: (transaction: unknown, network: string) => Promise, ): void /** * High-level deposit: {@link YieldXyz.deposit} — pass either `yieldId` + `amount`, or full * CAIP-2 `chain` + `token` + `amount` + `address` (plus optional `arguments`). When `yieldId` is * provided (non-empty after trim), it is used directly; otherwise the SDK resolves it from * Portal yield defaults. */ deposit( params: YieldDepositParams, options?: YieldSubmitOptions, ): Promise /** * High-level withdraw; same parameters as {@link IYieldXyz.deposit}. */ withdraw( params: YieldWithdrawParams, options?: YieldSubmitOptions, ): Promise getValidators(yieldId: string): Promise } export interface YieldXyzOptions { api: IPortalYieldXyzApi /** * Default confirmation function for all deposit/withdraw calls on this instance. * Called for **every** transaction on **any** chain (EVM and non-EVM). * Takes priority over the fallback EVM poller (`evmRequestFn`). * * When constructed via `Portal`, this is set to `portal.waitForConfirmation` * (EVM receipt polling via gateway RPC). Per-call overrides can be passed via * `waitForConfirmation` in the deposit/withdraw options. * * Use this when you need custom confirmation logic for standalone `YieldXyz`, e.g. * a specific number of block confirmations, Solana finality checks, or your own RPC. */ waitForConfirmation?: ( txHash: string, network: string, ) => Promise /** * Raw RPC function for the **standalone** EVM receipt poller fallback. * Not used when `waitForConfirmation` is set (including Portal's default). */ evmRequestFn?: EvmRequestFn /** Tuning options when using `evmRequestFn` as the confirmation fallback. */ evmPollerOptions?: EvmReceiptPollerOptions } export class YieldXyz implements IYieldXyz { private readonly api: IPortalYieldXyzApi private readonly options: YieldXyzOptions private signAndSendTransactionFn?: ( transaction: unknown, network: string, ) => Promise constructor(options: YieldXyzOptions) { this.api = options.api this.options = options } /** * 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 } public async discover( request?: YieldXyzGetYieldsRequest, ): Promise { return this.api.getYields(request) } public async enter( request: YieldXyzEnterRequest, ): Promise { return this.api.enterYield(request) } public async track( transactionId: string, txHash: string, ): Promise { const request: YieldXyzTrackTransactionRequest = { transactionId, hash: txHash, } return this.api.submitTransactionHash(request) } public async getBalances( request: YieldXyzGetBalancesRequest, ): Promise { return this.api.getYieldBalances(request) } public async getTransaction( transactionId: string, ): Promise { return this.api.getYieldTransaction(transactionId) } public async getHistoricalActions( request: YieldXyzGetHistoricalActionsRequest, ): Promise { return this.api.getHistoricalYieldActions(request) } public async manage( request: YieldXyzManageYieldRequest, ): Promise { return this.api.manageYield(request) } public async exit( request: YieldXyzExitRequest, ): Promise { return this.api.exitYield(request) } /** * Returns validators for a yield. */ public async getValidators(yieldId: string): Promise { if (typeof this.api.getYieldValidators !== 'function') { throw new Error( '[YieldXyz] getYieldValidators is missing on the API. Use a single consistent @portal-hq/core build (restart Metro with --reset-cache; from packages/core run `yarn prepare` if imports resolve to `lib/`).', ) } const res = await this.api.getYieldValidators(yieldId) const d = res.data const validators = d?.validators ?? d?.rawResponse?.validators ?? d?.rawResponse?.items if (!validators || !Array.isArray(validators)) { 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.api.getYieldDefaults({ includeOpportunities: false, }) 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` + `address` (optional `arguments`), or full **CAIP-2** * **`chain`** + **`token`** + `amount` + `address`, 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.debug(`${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, amount: params.amount, arguments: params.arguments, } try { const response = await this.api.enterYield(request) const effectiveWaitForConfirmation = options?.waitForConfirmation ?? this.options.waitForConfirmation const base = await this.executeAndTrack( response, signAndSend, options?.onProgress, effectiveWaitForConfirmation, 'deposit', options?.evmPollerOptions, ) 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) { // Inner executeAndTrack already logged at ERROR; log context only at DEBUG here. 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, amount: params.amount, arguments: params.arguments, } try { const response = await this.api.exitYield(request) const effectiveWaitForConfirmation = options?.waitForConfirmation ?? this.options.waitForConfirmation const base = await this.executeAndTrack( response, signAndSend, options?.onProgress, effectiveWaitForConfirmation, 'withdraw', options?.evmPollerOptions, ) 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) { // Inner executeAndTrack already logged at ERROR; log context only at DEBUG here. 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 { return JSON.parse(unsigned) as Record } catch { return {} } } /** * Same unwrap rule as `normalizeEvmTransactionParams` in Portal (single nested object). */ private unwrapSingleKeyTransactionObject( obj: Record, ): Record { const keys = Object.keys(obj) if ( keys.length === 1 && typeof obj[keys[0]] === 'object' && obj[keys[0]] !== null && !Array.isArray(obj[keys[0]]) ) { return obj[keys[0]] as Record } return obj } /** * Strip Yield planning-time stale nonce on JSON EVM payloads; preserve raw strings if not JSON. */ private stripStalePlanningNonceIfJsonObject( unsigned: string | Record, ): unknown { if ( typeof unsigned === 'object' && unsigned !== null && !Array.isArray(unsigned) ) { const spread = { ...unsigned } as Record const inner = this.unwrapSingleKeyTransactionObject(spread) const { nonce: _staleNonce, ...rest } = inner return rest } if (typeof unsigned === 'string') { try { const parsed = JSON.parse(unsigned) as unknown if ( typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ) { const inner = this.unwrapSingleKeyTransactionObject( parsed as Record, ) const { nonce: _staleNonce, ...rest } = inner return rest } } catch { return unsigned } } return unsigned } /** Safe summary of tx payload for debug logs (avoids logging full data at INFO). */ 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 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', evmPollerOptionsOverride?: EvmReceiptPollerOptions, ): 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.') } // Determine whether the built-in EVM poller is available as a fallback. const evmRequestFn = this.options.evmRequestFn const hasInternalEvmPoller = Boolean(evmRequestFn) sdkLogger.debug(`${LOG_PREFIX} executeAndTrack: confirmation strategy`, { method, waitForConfirmation: Boolean(waitForConfirmation), evmRequestFnFallback: hasInternalEvmPoller, }) const total = transactions.length const hashes: string[] = [] let confirmationsReached = 0 const confirmationsRequired = waitForConfirmation ? total : 0 let failedEarly = false for (let index = 0; index < transactions.length; index++) { const tx = transactions[index] 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.`, ) } let payloadToSend: unknown = tx.unsignedTransaction const isEvm = typeof tx.network === 'string' && isYieldEvmNetwork(tx.network) if (isEvm) { // Yield XYZ assigns nonces at planning time (before any tx is sent), so every // tx in a multi-step action carries the same stale nonce. Instead of trying to // patch nonces ourselves, we strip the nonce entirely so Portal's MPC signer // auto-fetches the correct pending nonce at sign time — identical to the manual // sign flow and guaranteed correct as long as waitForConfirmation is used between txs. payloadToSend = this.stripStalePlanningNonceIfJsonObject( tx.unsignedTransaction 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, tx.network) 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) // Confirmation strategy (priority order): // 1. waitForConfirmation — integrator or Portal.waitForConfirmation (any chain). // 2. evmRequestFn + pollForEvmReceipt — same shared EVM receipt poller, soft timeout. // 3. No confirmation — non-EVM when no waiter and no evmRequestFn. const effectiveWaiter: | ((hash: string, network: string) => Promise) | undefined = waitForConfirmation ?? (isEvm && evmRequestFn ? (h: string, n: string) => pollForEvmReceipt( h, n, evmRequestFn, evmPollerOptionsOverride ?? this.options.evmPollerOptions, ) : undefined) if (effectiveWaiter) { const progressConfirming: YieldSubmitProgress = { step: 'confirming', index, total, hash, } sdkLogger.debug( `${LOG_PREFIX} executeAndTrack: waiting for confirmation`, { method, index, total, hash, network: tx.network, strategy: waitForConfirmation ? 'waitForConfirmation' : 'evmRequestFn-poller', }, ) onProgress?.(progressConfirming) const waiterResult = await effectiveWaiter(hash, tx.network) const isConfirmed = waiterResult === true const isFailed = waiterResult === false sdkLogger.info(`[YIELD RN] confirmation result`, { hash, result: waiterResult, isConfirmed, isFailed, }) if (isConfirmed) { confirmationsReached += 1 sdkLogger.debug( `${LOG_PREFIX} executeAndTrack: confirmation received`, { method, hash, confirmationsReached, total, }, ) onProgress?.({ step: 'confirmed', index, total, hash }) } else if (isFailed) { // Transaction failed on-chain (status 0x0) - STOP IMMEDIATELY sdkLogger.error( `${LOG_PREFIX} executeAndTrack: transaction FAILED on-chain`, { method, hash, index, total, network: tx.network, }, ) failedEarly = true // Track the hash before breaking so backend knows about the failure sdkLogger.debug( `${LOG_PREFIX} executeAndTrack: track (submitTransactionHash) call before break (FAILED)`, { method, transactionId: tx.id, hash, }, ) await this.api.submitTransactionHash({ transactionId: tx.id, hash, }) break } else { // Uncertain confirmation (timeout / void / no response): safe stop. // Track the hash with backend before stopping, then return partial result. sdkLogger.debug( `${LOG_PREFIX} executeAndTrack: confirmation not reached (waiter did not return true)`, { method, hash, confirmationsReached, total, waiterResult, }, ) sdkLogger.debug( `${LOG_PREFIX} executeAndTrack: track (submitTransactionHash) call before break`, { method, transactionId: tx.id, hash, }, ) await this.api.submitTransactionHash({ transactionId: tx.id, hash, }) break } } // Track the hash with Yield backend (happens for all confirmed txs and when no waiter). sdkLogger.debug( `${LOG_PREFIX} executeAndTrack: track (submitTransactionHash) call`, { method, transactionId: tx.id, hash, }, ) const trackResponse = await this.api.submitTransactionHash({ transactionId: tx.id, hash, }) sdkLogger.debug(`${LOG_PREFIX} executeAndTrack: track response`, { method, transactionId: tx.id, hash, trackSuccess: trackResponse?.data?.rawResponse?.success, 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) // Compute final status based on confirmation results let status: YieldSubmitResultStatus if (failedEarly) { status = 'FAILED' } else if (confirmationsRequired === 0) { // No waitForConfirmation configured - optimistic success status = 'SUCCESS' } else if (confirmationsReached === confirmationsRequired) { // All confirmations reached status = 'SUCCESS' } else { // Some confirmations reached but not all status = 'PARTIAL_SUCCESS' } sdkLogger.info(`[YIELD RN] final status`, { status, confirmationsReached, confirmationsRequired, }) sdkLogger.info(`${LOG_PREFIX} ${method}: executeAndTrack complete`, { yieldId, hashCount: hashes.length, confirmationsReached: confirmationsReached > 0 ? confirmationsReached : undefined, status, }) return { hashes, yieldId, yieldOpportunityDetails, status, } } } export default YieldXyz