Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 156 additions & 39 deletions packages/core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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<string, any>,
configKey: string,
envPrefix: string,
defaults?: TokenRebalanceDefaults,
): Promise<TokenRebalanceConfig> {
// 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 ??
Expand Down Expand Up @@ -474,41 +509,71 @@ export async function loadConfiguration(): Promise<MarkConfiguration> {
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')),
Expand Down Expand Up @@ -543,13 +608,65 @@ export async function loadConfiguration(): Promise<MarkConfiguration> {
};

validateConfiguration(config);
logThresholdRebalancerConfigs(config);
return config;
} catch (_error: unknown) {
const error = _error as Error;
throw new ConfigurationError('Failed to load configuration: ' + error.message, { error: JSON.stringify(error) });
}
}

/**
* 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');
Expand Down
53 changes: 52 additions & 1 deletion packages/core/src/s3.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -80,3 +80,54 @@ export const getRebalanceConfigFromS3 = async (): Promise<RebalanceConfig | null
return null;
}
};

/**
* Fetch threshold-based rebalancer configs from S3.
* These are exported by fee-admin as threshold-rebalance-config.json.
* Uses the same S3 bucket as regular rebalance config.
*
* Priority chain: S3 (fee-admin) > SSM/configJson > env vars
*/
export const getThresholdRebalanceConfigFromS3 = async (): Promise<ThresholdRebalanceS3Config | null> => {
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;
}
};
13 changes: 13 additions & 0 deletions packages/core/src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading