diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index fcfc34fb..94b6e14d 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -20,7 +20,7 @@ import fs, { existsSync, readFileSync } from 'fs'; import { getSsmParameter, SsmParameterReadError } from './ssm'; import { hexToBase58 } from './solana'; import { isTvmChain } from './tron'; -import { getRebalanceConfigFromS3 } from './s3'; +import { getRebalanceConfigFromS3, getThresholdRebalanceConfigFromS3 } from './s3'; import { stitchConfig, loadManifest, setValueByPath } from './shard'; config(); @@ -205,75 +205,110 @@ interface TokenRebalanceDefaults { maxAmount?: string; } +/** + * Convert empty strings to undefined so they don't short-circuit ?? fallback chains. + * Fee-admin S3 export should already convert empty strings to null, but this + * provides a defensive layer in mark to prevent empty string overrides. + */ +const nonEmpty = (value: string | undefined | null): string | undefined => + value === '' || value === null ? undefined : value; + async function loadTokenRebalanceConfig( + s3Config: TokenRebalanceConfig | undefined, // eslint-disable-next-line @typescript-eslint/no-explicit-any configJson: Record, configKey: string, envPrefix: string, defaults?: TokenRebalanceDefaults, ): Promise { + // Priority: S3 (fee-admin) > configJson (SSM) > env vars > defaults + const s3 = s3Config; const cfg = configJson[configKey]; return { - enabled: parseBooleanValue(cfg?.enabled) ?? parseBooleanValue(await fromEnv(`${envPrefix}_ENABLED`, true)) ?? false, + enabled: + s3?.enabled ?? + parseBooleanValue(cfg?.enabled) ?? + parseBooleanValue(await fromEnv(`${envPrefix}_ENABLED`, true)) ?? + false, marketMaker: { - address: cfg?.marketMaker?.address ?? (await fromEnv(`${envPrefix}_MARKET_MAKER_ADDRESS`, true)) ?? undefined, + address: + nonEmpty(s3?.marketMaker?.address) ?? + cfg?.marketMaker?.address ?? + (await fromEnv(`${envPrefix}_MARKET_MAKER_ADDRESS`, true)) ?? + undefined, onDemandEnabled: + s3?.marketMaker?.onDemandEnabled ?? parseBooleanValue(cfg?.marketMaker?.onDemandEnabled) ?? parseBooleanValue(await fromEnv(`${envPrefix}_MARKET_MAKER_ON_DEMAND_ENABLED`, true)) ?? false, thresholdEnabled: + s3?.marketMaker?.thresholdEnabled ?? parseBooleanValue(cfg?.marketMaker?.thresholdEnabled) ?? parseBooleanValue(await fromEnv(`${envPrefix}_MARKET_MAKER_THRESHOLD_ENABLED`, true)) ?? false, threshold: + nonEmpty(s3?.marketMaker?.threshold) ?? cfg?.marketMaker?.threshold ?? (await fromEnv(`${envPrefix}_MARKET_MAKER_THRESHOLD`, true)) ?? defaults?.mmThreshold ?? undefined, targetBalance: + nonEmpty(s3?.marketMaker?.targetBalance) ?? cfg?.marketMaker?.targetBalance ?? (await fromEnv(`${envPrefix}_MARKET_MAKER_TARGET_BALANCE`, true)) ?? defaults?.mmTarget ?? undefined, }, fillService: { - address: cfg?.fillService?.address ?? (await fromEnv(`${envPrefix}_FILL_SERVICE_ADDRESS`, true)) ?? undefined, + address: + nonEmpty(s3?.fillService?.address) ?? + cfg?.fillService?.address ?? + (await fromEnv(`${envPrefix}_FILL_SERVICE_ADDRESS`, true)) ?? + undefined, senderAddress: + nonEmpty(s3?.fillService?.senderAddress) ?? cfg?.fillService?.senderAddress ?? (await fromEnv(`${envPrefix}_FILL_SERVICE_SENDER_ADDRESS`, true)) ?? undefined, thresholdEnabled: + s3?.fillService?.thresholdEnabled ?? parseBooleanValue(cfg?.fillService?.thresholdEnabled) ?? parseBooleanValue(await fromEnv(`${envPrefix}_FILL_SERVICE_THRESHOLD_ENABLED`, true)) ?? false, threshold: + nonEmpty(s3?.fillService?.threshold) ?? cfg?.fillService?.threshold ?? (await fromEnv(`${envPrefix}_FILL_SERVICE_THRESHOLD`, true)) ?? defaults?.fsThreshold ?? undefined, targetBalance: + nonEmpty(s3?.fillService?.targetBalance) ?? cfg?.fillService?.targetBalance ?? (await fromEnv(`${envPrefix}_FILL_SERVICE_TARGET_BALANCE`, true)) ?? defaults?.fsTarget ?? undefined, allowCrossWalletRebalancing: + s3?.fillService?.allowCrossWalletRebalancing ?? parseBooleanValue(cfg?.fillService?.allowCrossWalletRebalancing) ?? parseBooleanValue(await fromEnv(`${envPrefix}_FILL_SERVICE_ALLOW_CROSS_WALLET`, true)) ?? false, }, bridge: { slippageDbps: + s3?.bridge?.slippageDbps ?? cfg?.bridge?.slippageDbps ?? parseInt( (await fromEnv(`${envPrefix}_BRIDGE_SLIPPAGE_DBPS`, true)) ?? String(defaults?.slippageDbps ?? 500), 10, ), minRebalanceAmount: + nonEmpty(s3?.bridge?.minRebalanceAmount) ?? cfg?.bridge?.minRebalanceAmount ?? (await fromEnv(`${envPrefix}_BRIDGE_MIN_REBALANCE_AMOUNT`, true)) ?? defaults?.minAmount ?? '100000000', // Safe default: 100 units (6-decimal tokens like USDC/USDT) maxRebalanceAmount: + nonEmpty(s3?.bridge?.maxRebalanceAmount) ?? cfg?.bridge?.maxRebalanceAmount ?? (await fromEnv(`${envPrefix}_BRIDGE_MAX_REBALANCE_AMOUNT`, true)) ?? defaults?.maxAmount ?? @@ -474,41 +509,71 @@ export async function loadConfiguration(): Promise { privateKey: configJson.solana?.privateKey ?? (await fromEnv('SOLANA_PRIVATE_KEY', true)) ?? undefined, rpcUrl: configJson.solana?.rpcUrl ?? (await fromEnv('SOLANA_RPC_URL', true)) ?? undefined, }, - tacRebalance: await loadTokenRebalanceConfig(configJson, 'tacRebalance', 'TAC_REBALANCE'), - methRebalance: await loadTokenRebalanceConfig(configJson, 'methRebalance', 'METH_REBALANCE'), - aManUsdeRebalance: await loadTokenRebalanceConfig(configJson, 'aManUsdeRebalance', 'AMANUSDE_REBALANCE'), - aMansyrupUsdtRebalance: await loadTokenRebalanceConfig( - configJson, - 'aMansyrupUsdtRebalance', - 'AMANSYRUPUSDT_REBALANCE', - ), - solanaPtusdeRebalance: { - enabled: - parseBooleanValue(configJson.solanaPtusdeRebalance?.enabled) ?? - parseBooleanValue(await fromEnv('SOLANA_PTUSDE_REBALANCE_ENABLED', true)) ?? - false, - ptUsdeThreshold: - configJson.solanaPtusdeRebalance?.ptUsdeThreshold ?? - (await fromEnv('SOLANA_PTUSDE_REBALANCE_THRESHOLD', true)) ?? - '100000000000', // 100 ptUSDe (9 decimals on Solana) - ptUsdeTarget: - configJson.solanaPtusdeRebalance?.ptUsdeTarget ?? - (await fromEnv('SOLANA_PTUSDE_REBALANCE_TARGET', true)) ?? - '500000000000', // 500 ptUSDe (9 decimals on Solana) - bridge: { - slippageDbps: - configJson.solanaPtusdeRebalance?.bridge?.slippageDbps ?? - parseInt((await fromEnv('SOLANA_PTUSDE_REBALANCE_BRIDGE_SLIPPAGE_DBPS', true)) ?? '50', 10), // 0.5% default - minRebalanceAmount: - configJson.solanaPtusdeRebalance?.bridge?.minRebalanceAmount ?? - (await fromEnv('SOLANA_PTUSDE_REBALANCE_BRIDGE_MIN_REBALANCE_AMOUNT', true)) ?? - '1000000', // 1 USDC minimum (6 decimals) - maxRebalanceAmount: - configJson.solanaPtusdeRebalance?.bridge?.maxRebalanceAmount ?? - (await fromEnv('SOLANA_PTUSDE_REBALANCE_BRIDGE_MAX_REBALANCE_AMOUNT', true)) ?? - '100000000', // 100 USDC max (6 decimals) - }, - }, + // Fetch threshold configs from S3 (fee-admin) - highest priority source + // Falls back gracefully to SSM/env if S3 is unavailable or empty + ...(await (async () => { + const thresholdS3 = await getThresholdRebalanceConfigFromS3(); + const solanaS3 = thresholdS3?.solanaPtusdeRebalance; + return { + tacRebalance: await loadTokenRebalanceConfig( + thresholdS3?.tacRebalance, + configJson, + 'tacRebalance', + 'TAC_REBALANCE', + ), + methRebalance: await loadTokenRebalanceConfig( + thresholdS3?.methRebalance, + configJson, + 'methRebalance', + 'METH_REBALANCE', + ), + aManUsdeRebalance: await loadTokenRebalanceConfig( + thresholdS3?.aManUsdeRebalance, + configJson, + 'aManUsdeRebalance', + 'AMANUSDE_REBALANCE', + ), + aMansyrupUsdtRebalance: await loadTokenRebalanceConfig( + thresholdS3?.aMansyrupUsdtRebalance, + configJson, + 'aMansyrupUsdtRebalance', + 'AMANSYRUPUSDT_REBALANCE', + ), + solanaPtusdeRebalance: { + enabled: + solanaS3?.enabled ?? + parseBooleanValue(configJson.solanaPtusdeRebalance?.enabled) ?? + parseBooleanValue(await fromEnv('SOLANA_PTUSDE_REBALANCE_ENABLED', true)) ?? + false, + ptUsdeThreshold: + nonEmpty(solanaS3?.ptUsdeThreshold) ?? + configJson.solanaPtusdeRebalance?.ptUsdeThreshold ?? + (await fromEnv('SOLANA_PTUSDE_REBALANCE_THRESHOLD', true)) ?? + '100000000000', // 100 ptUSDe (9 decimals on Solana) + ptUsdeTarget: + nonEmpty(solanaS3?.ptUsdeTarget) ?? + configJson.solanaPtusdeRebalance?.ptUsdeTarget ?? + (await fromEnv('SOLANA_PTUSDE_REBALANCE_TARGET', true)) ?? + '500000000000', // 500 ptUSDe (9 decimals on Solana) + bridge: { + slippageDbps: + solanaS3?.bridge?.slippageDbps ?? + configJson.solanaPtusdeRebalance?.bridge?.slippageDbps ?? + parseInt((await fromEnv('SOLANA_PTUSDE_REBALANCE_BRIDGE_SLIPPAGE_DBPS', true)) ?? '50', 10), // 0.5% default + minRebalanceAmount: + nonEmpty(solanaS3?.bridge?.minRebalanceAmount) ?? + configJson.solanaPtusdeRebalance?.bridge?.minRebalanceAmount ?? + (await fromEnv('SOLANA_PTUSDE_REBALANCE_BRIDGE_MIN_REBALANCE_AMOUNT', true)) ?? + '1000000', // 1 USDC minimum (6 decimals) + maxRebalanceAmount: + nonEmpty(solanaS3?.bridge?.maxRebalanceAmount) ?? + configJson.solanaPtusdeRebalance?.bridge?.maxRebalanceAmount ?? + (await fromEnv('SOLANA_PTUSDE_REBALANCE_BRIDGE_MAX_REBALANCE_AMOUNT', true)) ?? + '100000000', // 100 USDC max (6 decimals) + }, + }, + }; + })()), redis: configJson.redis ?? { host: await requireEnv('REDIS_HOST'), port: parseInt(await requireEnv('REDIS_PORT')), @@ -543,6 +608,7 @@ export async function loadConfiguration(): Promise { }; validateConfiguration(config); + logThresholdRebalancerConfigs(config); return config; } catch (_error: unknown) { const error = _error as Error; @@ -550,6 +616,57 @@ export async function loadConfiguration(): Promise { } } +/** + * Log loaded threshold rebalancer configs at startup. + * Only logs non-secret operational parameters: addresses, thresholds, + * targets, slippage, and amounts. No private keys, mnemonics, or API keys. + */ +function logThresholdRebalancerConfigs(config: MarkConfiguration): void { + const logTokenRebalancer = (name: string, cfg: TokenRebalanceConfig) => { + console.log(`[ThresholdConfig] ${name}:`, { + enabled: cfg.enabled, + marketMaker: { + address: cfg.marketMaker.address, + onDemandEnabled: cfg.marketMaker.onDemandEnabled, + thresholdEnabled: cfg.marketMaker.thresholdEnabled, + threshold: cfg.marketMaker.threshold, + targetBalance: cfg.marketMaker.targetBalance, + }, + fillService: { + address: cfg.fillService.address, + senderAddress: cfg.fillService.senderAddress, + thresholdEnabled: cfg.fillService.thresholdEnabled, + threshold: cfg.fillService.threshold, + targetBalance: cfg.fillService.targetBalance, + allowCrossWalletRebalancing: cfg.fillService.allowCrossWalletRebalancing, + }, + bridge: { + slippageDbps: cfg.bridge.slippageDbps, + minRebalanceAmount: cfg.bridge.minRebalanceAmount, + maxRebalanceAmount: cfg.bridge.maxRebalanceAmount, + }, + }); + }; + + if (config.tacRebalance) logTokenRebalancer('tacRebalance', config.tacRebalance); + if (config.methRebalance) logTokenRebalancer('methRebalance', config.methRebalance); + if (config.aManUsdeRebalance) logTokenRebalancer('aManUsdeRebalance', config.aManUsdeRebalance); + if (config.aMansyrupUsdtRebalance) logTokenRebalancer('aMansyrupUsdtRebalance', config.aMansyrupUsdtRebalance); + + if (config.solanaPtusdeRebalance) { + console.log('[ThresholdConfig] solanaPtusdeRebalance:', { + enabled: config.solanaPtusdeRebalance.enabled, + ptUsdeThreshold: config.solanaPtusdeRebalance.ptUsdeThreshold, + ptUsdeTarget: config.solanaPtusdeRebalance.ptUsdeTarget, + bridge: { + slippageDbps: config.solanaPtusdeRebalance.bridge.slippageDbps, + minRebalanceAmount: config.solanaPtusdeRebalance.bridge.minRebalanceAmount, + maxRebalanceAmount: config.solanaPtusdeRebalance.bridge.maxRebalanceAmount, + }, + }); + } +} + function validateConfiguration(config: MarkConfiguration): void { if (!config.web3SignerUrl) { throw new ConfigurationError('Signer address is required'); diff --git a/packages/core/src/s3.ts b/packages/core/src/s3.ts index 8672a3a3..1eeac0bf 100644 --- a/packages/core/src/s3.ts +++ b/packages/core/src/s3.ts @@ -1,5 +1,5 @@ import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3'; -import { RebalanceConfig } from './types/config'; +import { RebalanceConfig, ThresholdRebalanceS3Config } from './types/config'; // Singleton client to prevent race conditions let s3Client: S3Client | null = null; @@ -80,3 +80,54 @@ export const getRebalanceConfigFromS3 = async (): Promise SSM/configJson > env vars + */ +export const getThresholdRebalanceConfigFromS3 = async (): Promise => { + try { + const bucket = process.env.REBALANCE_CONFIG_S3_BUCKET; + const key = process.env.THRESHOLD_REBALANCE_CONFIG_S3_KEY || 'threshold-rebalance-config.json'; + const region = process.env.REBALANCE_CONFIG_S3_REGION; + + if (!bucket) { + return null; + } + + const client = getS3Client(region); + if (!client) { + return null; + } + + const command = new GetObjectCommand({ + Bucket: bucket, + Key: key, + }); + + const response = await client.send(command); + + if (!response.Body) { + return null; + } + + const bodyString = await response.Body.transformToString(); + const config = JSON.parse(bodyString) as ThresholdRebalanceS3Config; + + const configKeys = Object.keys(config); + console.log('Successfully loaded threshold rebalance config from S3', { + bucket, + key, + configKeys, + configCount: configKeys.length, + }); + + return config; + } catch (error) { + console.warn('Failed to fetch threshold rebalance config from S3:', error instanceof Error ? error.message : error); + return null; + } +}; diff --git a/packages/core/src/types/config.ts b/packages/core/src/types/config.ts index 7f56c46a..f171db9f 100644 --- a/packages/core/src/types/config.ts +++ b/packages/core/src/types/config.ts @@ -197,6 +197,19 @@ export const TOKEN_REBALANCER_KEYS: TokenRebalancerKey[] = [ 'aMansyrupUsdtRebalance', ]; +/** + * S3 threshold rebalance config shape exported by fee-admin. + * Each key maps to its corresponding TokenRebalanceConfig or SolanaRebalanceConfig. + * Loaded from threshold-rebalance-config.json in the solver's S3 bucket. + */ +export interface ThresholdRebalanceS3Config { + tacRebalance?: TokenRebalanceConfig; + methRebalance?: TokenRebalanceConfig; + aManUsdeRebalance?: TokenRebalanceConfig; + aMansyrupUsdtRebalance?: TokenRebalanceConfig; + solanaPtusdeRebalance?: SolanaRebalanceConfig; +} + export interface RedisConfig { host: string; port: number;