diff --git a/.sandbox/Dockerfile b/.sandbox/Dockerfile index 0d7a0ca..e865ddd 100644 --- a/.sandbox/Dockerfile +++ b/.sandbox/Dockerfile @@ -6,7 +6,7 @@ RUN apt-get update -y \ && apt-get install -y --no-install-recommends ca-certificates curl \ && rm -rf /var/lib/apt/lists/* -RUN bun install --global @usherlabs/cex-broker@0.2.4 +RUN bun install --global @usherlabs/cex-broker@0.2.5 COPY --chmod=0755 ./.sandbox/entrypoint.sh /entrypoint.sh ENTRYPOINT ["/entrypoint.sh"] diff --git a/package.json b/package.json index 94a0305..c6d6b63 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@usherlabs/cex-broker", - "version": "0.2.4", + "version": "0.2.5", "description": "Unified gRPC API to CEXs by Usher Labs.", "repository": { "type": "git", diff --git a/src/schemas/action-payloads.ts b/src/schemas/action-payloads.ts index 552ec32..5ad7196 100644 --- a/src/schemas/action-payloads.ts +++ b/src/schemas/action-payloads.ts @@ -65,6 +65,25 @@ export const CancelOrderPayloadSchema = z.object({ params: z.preprocess(parseJsonString, stringNumberRecordSchema).default({}), }); +const booleanLikeSchema = z.preprocess((value: unknown) => { + if (typeof value !== "string") { + return value; + } + const normalized = value.trim().toLowerCase(); + if (["true", "1", "yes"].includes(normalized)) { + return true; + } + if (["false", "0", "no"].includes(normalized)) { + return false; + } + return value; +}, z.boolean()); + +export const FetchFeesPayloadSchema = z.object({ + includeAllFees: booleanLikeSchema.optional().default(false), + includeFundingFees: booleanLikeSchema.optional(), +}); + export type DepositPayload = z.infer; export type CallPayload = z.infer; export type FetchDepositAddressesPayload = z.infer< @@ -76,3 +95,4 @@ export type GetOrderDetailsPayload = z.infer< typeof GetOrderDetailsPayloadSchema >; export type CancelOrderPayload = z.infer; +export type FetchFeesPayload = z.infer; diff --git a/src/server.ts b/src/server.ts index b514db1..ed79e6b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -27,6 +27,7 @@ import { CreateOrderPayloadSchema, DepositPayloadSchema, FetchDepositAddressesPayloadSchema, + FetchFeesPayloadSchema, GetOrderDetailsPayloadSchema, WithdrawPayloadSchema, } from "./schemas/action-payloads"; @@ -338,21 +339,160 @@ export function getServer( null, ); } + const parsedPayload = parsePayload( + FetchFeesPayloadSchema, + call.request.payload, + ); + if (!parsedPayload.success) { + return wrappedCallback( + { + code: grpc.status.INVALID_ARGUMENT, + message: parsedPayload.message, + }, + null, + ); + } + const includeAllFees = + parsedPayload.data.includeAllFees || + parsedPayload.data.includeFundingFees === true; try { await broker.loadMarkets(); - const market = await broker.market(symbol); + const fetchFundingFees = async (currencyCodes: string[]) => { + let fundingFeeSource: + | "fetchDepositWithdrawFees" + | "currencies" + | "unavailable" = "unavailable"; + const fundingFeesByCurrency: Record = {}; + + if (broker.has.fetchDepositWithdrawFees) { + try { + const feeMap = (await broker.fetchDepositWithdrawFees( + currencyCodes, + )) as unknown as Record< + string, + { + deposit?: unknown; + withdraw?: unknown; + networks?: unknown; + fee?: number; + percentage?: boolean; + } + >; + for (const code of currencyCodes) { + const feeInfo = feeMap[code]; + if (!feeInfo) { + continue; + } + const fallbackFee = + feeInfo.fee !== undefined || + feeInfo.percentage !== undefined + ? { + fee: feeInfo.fee ?? null, + percentage: feeInfo.percentage ?? null, + } + : null; + fundingFeesByCurrency[code] = { + deposit: feeInfo.deposit ?? fallbackFee, + withdraw: feeInfo.withdraw ?? fallbackFee, + networks: feeInfo.networks ?? {}, + }; + } + if (Object.keys(fundingFeesByCurrency).length > 0) { + fundingFeeSource = "fetchDepositWithdrawFees"; + } + } catch (error) { + safeLogError( + `Error fetching deposit/withdraw fee map for ${symbol} from ${cex}`, + error, + ); + } + } - // Address CodeRabbit's concern: explicit handling for missing fees - const generalFee = broker.fees ?? null; - const feeStatus = broker.fees ? "available" : "unknown"; + if (fundingFeeSource === "unavailable") { + try { + const currencies = await broker.fetchCurrencies(); + for (const code of currencyCodes) { + const currency = currencies[code]; + if (!currency) { + continue; + } + fundingFeesByCurrency[code] = { + deposit: { + enabled: currency.deposit ?? null, + }, + withdraw: { + enabled: currency.withdraw ?? null, + fee: currency.fee ?? null, + limits: currency.limits?.withdraw ?? null, + }, + networks: currency.networks ?? {}, + }; + } + if (Object.keys(fundingFeesByCurrency).length > 0) { + fundingFeeSource = "currencies"; + } + } catch (error) { + safeLogError( + `Error fetching currency metadata for fees for ${symbol} from ${cex}`, + error, + ); + } + } + + return { fundingFeeSource, fundingFeesByCurrency }; + }; + + const isMarketSymbol = symbol.includes("/"); + if (isMarketSymbol) { + const market = await broker.market(symbol); + const generalFee = broker.fees ?? null; + const feeStatus = broker.fees ? "available" : "unknown"; + + if (!broker.fees) { + log.warn(`Fee metadata unavailable for ${cex}`, { symbol }); + } + + if (!includeAllFees) { + return wrappedCallback(null, { + proof: verityProof, + result: JSON.stringify({ + feeScope: "market", + generalFee, + feeStatus, + market, + }), + }); + } - if (!broker.fees) { - log.warn(`Fee metadata unavailable for ${cex}`, { symbol }); + const currencyCodes = Array.from( + new Set([market.base, market.quote]), + ); + const { fundingFeeSource, fundingFeesByCurrency } = + await fetchFundingFees(currencyCodes); + return wrappedCallback(null, { + proof: verityProof, + result: JSON.stringify({ + feeScope: "market+funding", + generalFee, + feeStatus, + market, + fundingFeeSource, + fundingFeesByCurrency, + }), + }); } + const tokenCode = symbol.toUpperCase(); + const { fundingFeeSource, fundingFeesByCurrency } = + await fetchFundingFees([tokenCode]); return wrappedCallback(null, { proof: verityProof, - result: JSON.stringify({ generalFee, feeStatus, market }), + result: JSON.stringify({ + feeScope: "token", + symbol: tokenCode, + fundingFeeSource, + fundingFeesByCurrency, + }), }); } catch (error) { safeLogError(