Allow uncapped taker request inputs
Some checks failed
deploy / deploy (push) Failing after 33s

Proof: npm test (217/217), npm run operator-dashboard:build, and focused intent/trading-config tests cover DB-null request amount and slippage limits.

Assumptions: removing request amount and slippage caps is explicitly operator-approved for the current live-funds workflow; preflight remains side-effect-free and submit remains separate.

Still fake: request settlement truth still depends on inventory-delta attribution instead of venue-native terminal fill events.
This commit is contained in:
philipp 2026-05-18 14:13:07 +02:00
parent a73ea1c83f
commit b45d12d37c
9 changed files with 186 additions and 49 deletions

View file

@ -50,8 +50,10 @@ export function createIntentRequestController({
);
const slippageBps = Number(body.slippage_bps ?? requestPair.slippageBps ?? config.intentRequestDefaultSlippageBps ?? 200);
const minDeadlineMs = Number(body.min_deadline_ms || requestPair.minDeadlineMs || config.intentRequestMinDeadlineMs || 60_000);
const maxAmountUnits = parseDecimalToUnits(
String(requestPair.requestMaxNotional || config.intentRequestMaxAmountEure || 5),
const maxAmountUnits = requestPair.requestMaxNotional == null
? null
: parseDecimalToUnits(
String(requestPair.requestMaxNotional),
sourceAsset.decimals,
{ field: 'intent_request_max_notional' },
);
@ -80,22 +82,25 @@ export function createIntentRequestController({
);
}
sourceAmountUnits = parseDecimalToUnits(amountEure, sourceAsset.decimals, { field: 'amount_eure' });
if (BigInt(sourceAmountUnits) > BigInt(maxAmountUnits)) {
if (maxAmountUnits != null && BigInt(sourceAmountUnits) > BigInt(maxAmountUnits)) {
blockedBeforeQuote = true;
throw codedError(
'amount_exceeds_request_limit',
`Requested ${amountEure} ${sourceAsset.symbol} exceeds configured live request limit ${requestPair.requestMaxNotional || config.intentRequestMaxAmountEure || 5} ${sourceAsset.symbol}.`,
`Requested ${amountEure} ${sourceAsset.symbol} exceeds configured live request limit ${requestPair.requestMaxNotional} ${sourceAsset.symbol}.`,
);
}
if (!Number.isInteger(slippageBps) || slippageBps < 0) {
blockedBeforeQuote = true;
throw codedError('invalid_slippage', 'Slippage must be a non-negative integer in basis points.');
}
if (slippageBps > Number(requestPair.requestMaxSlippageBps ?? config.intentRequestMaxSlippageBps ?? 200)) {
if (
requestPair.requestMaxSlippageBps != null
&& slippageBps > Number(requestPair.requestMaxSlippageBps)
) {
blockedBeforeQuote = true;
throw codedError(
'slippage_exceeds_request_limit',
`Slippage ${slippageBps} bps exceeds configured limit ${requestPair.requestMaxSlippageBps ?? config.intentRequestMaxSlippageBps ?? 200} bps.`,
`Slippage ${slippageBps} bps exceeds configured limit ${requestPair.requestMaxSlippageBps} bps.`,
);
}
@ -560,11 +565,9 @@ async function resolveIntentRequestPair({ body, config, getTradingConfig }) {
priceRoute: pair.priceRoute,
requestDefaultNotional:
strategyConfig.requestDefaultNotional || config.intentRequestDefaultAmountEure,
requestMaxNotional:
strategyConfig.requestMaxNotional || config.intentRequestMaxAmountEure,
requestMaxNotional: strategyConfig.requestMaxNotional ?? null,
slippageBps: strategyConfig.slippageBps ?? config.intentRequestDefaultSlippageBps,
requestMaxSlippageBps:
strategyConfig.requestMaxSlippageBps ?? strategyConfig.slippageBps ?? config.intentRequestMaxSlippageBps,
requestMaxSlippageBps: strategyConfig.requestMaxSlippageBps ?? null,
minDeadlineMs: strategyConfig.minDeadlineMs ?? config.intentRequestMinDeadlineMs,
priceMaxAgeMs: strategyConfig.priceMaxAgeMs ?? config.intentRequestPriceMaxAgeMs,
inventoryMaxAgeMs: strategyConfig.inventoryMaxAgeMs ?? config.intentRequestInventoryMaxAgeMs,

View file

@ -900,14 +900,23 @@ function buildFundingSummary({ config, fundingObservations, recentDepositStatuse
}
function buildIntentRequestSummary({ config, intentRequests = [], executorState = {} } = {}) {
const defaultPair = config.defaultTakerPair || null;
const strategyConfig = defaultPair?.strategyConfig || null;
const usingDbPairConfig = Boolean(config.tradingConfigLoaded && strategyConfig);
const sourceAsset = defaultPair?.assetIn || config.tradingEure;
const destinationAsset = defaultPair?.assetOut || config.tradingBtc;
return {
defaults: {
source_symbol: config.tradingEure.symbol,
destination_symbol: config.tradingBtc.symbol,
amount_eure: String(config.intentRequestDefaultAmountEure || 5),
max_amount_eure: String(config.intentRequestMaxAmountEure || 5),
slippage_bps: Number(config.intentRequestDefaultSlippageBps ?? 200),
max_slippage_bps: Number(config.intentRequestMaxSlippageBps ?? 200),
source_symbol: sourceAsset?.symbol || 'Source',
destination_symbol: destinationAsset?.symbol || 'Destination',
amount_eure: String(strategyConfig?.requestDefaultNotional ?? config.intentRequestDefaultAmountEure ?? 5),
max_amount_eure: usingDbPairConfig
? strategyConfig.requestMaxNotional ?? null
: String(config.intentRequestMaxAmountEure || 5),
slippage_bps: Number(strategyConfig?.slippageBps ?? config.intentRequestDefaultSlippageBps ?? 200),
max_slippage_bps: usingDbPairConfig
? strategyConfig.requestMaxSlippageBps ?? null
: Number(config.intentRequestMaxSlippageBps ?? 200),
},
executor_armed: executorState.armed ?? null,
executor_paused: executorState.paused ?? null,

View file

@ -19,8 +19,9 @@ export const CURRENT_REVERSE_PAIR_KEY = pairKey(CURRENT_EURE_ASSET_ID, CURRENT_N
export const CURRENT_EDGE_BPS = 49;
export const CURRENT_STRATEGY_MAX_NOTIONAL = '150';
export const CURRENT_REQUEST_DEFAULT_NOTIONAL_EURE = '5';
export const CURRENT_REQUEST_MAX_NOTIONAL_EURE = '5';
export const CURRENT_SLIPPAGE_BPS = 200;
export const CURRENT_REQUEST_MAX_NOTIONAL_EURE = null;
export const CURRENT_REQUEST_MAX_SLIPPAGE_BPS = null;
export const CURRENT_MIN_DEADLINE_MS = 60_000;
export const CURRENT_PRICE_MAX_AGE_MS = 30_000;
export const CURRENT_INVENTORY_MAX_AGE_MS = 30_000;
@ -188,7 +189,7 @@ export function buildSeedStrategyConfig(pairId, {
inventoryMaxAgeMs: CURRENT_INVENTORY_MAX_AGE_MS,
requestDefaultNotional: CURRENT_REQUEST_DEFAULT_NOTIONAL_EURE,
requestMaxNotional: CURRENT_REQUEST_MAX_NOTIONAL_EURE,
requestMaxSlippageBps: CURRENT_SLIPPAGE_BPS,
requestMaxSlippageBps: CURRENT_REQUEST_MAX_SLIPPAGE_BPS,
createdBy,
reason,
};

View file

@ -719,9 +719,9 @@ export async function createPairStrategyConfigVersion(pool, {
minDeadlineMs = null,
priceMaxAgeMs = null,
inventoryMaxAgeMs = null,
requestDefaultNotional = null,
requestMaxNotional = null,
requestMaxSlippageBps = null,
requestDefaultNotional = undefined,
requestMaxNotional = undefined,
requestMaxSlippageBps = undefined,
changedBy = 'operator',
reason = 'operator config update',
} = {}) {
@ -763,17 +763,17 @@ export async function createPairStrategyConfigVersion(pool, {
inventoryMaxAgeMs:
inventoryMaxAgeMs == null ? Number(active.inventory_max_age_ms) : Number(inventoryMaxAgeMs),
requestDefaultNotional:
requestDefaultNotional == null
requestDefaultNotional === undefined
? active.request_default_notional == null ? null : String(active.request_default_notional)
: String(requestDefaultNotional),
: nullablePositiveNumberString(requestDefaultNotional, 'request_default_notional'),
requestMaxNotional:
requestMaxNotional == null
requestMaxNotional === undefined
? active.request_max_notional == null ? null : String(active.request_max_notional)
: String(requestMaxNotional),
: nullablePositiveNumberString(requestMaxNotional, 'request_max_notional'),
requestMaxSlippageBps:
requestMaxSlippageBps == null
requestMaxSlippageBps === undefined
? active.request_max_slippage_bps == null ? null : Number(active.request_max_slippage_bps)
: Number(requestMaxSlippageBps),
: nullableNonNegativeInteger(requestMaxSlippageBps, 'request_max_slippage_bps'),
createdBy: changedBy,
reason,
};
@ -874,9 +874,9 @@ export async function setTradingPairMode(pool, {
minDeadlineMs = null,
priceMaxAgeMs = null,
inventoryMaxAgeMs = null,
requestDefaultNotional = null,
requestMaxNotional = null,
requestMaxSlippageBps = null,
requestDefaultNotional = undefined,
requestMaxNotional = undefined,
requestMaxSlippageBps = undefined,
changedBy = 'operator',
reason = 'operator pair mode update',
} = {}) {
@ -1336,9 +1336,9 @@ function buildInitialPairStrategyConfig(pairId, {
minDeadlineMs = null,
priceMaxAgeMs = null,
inventoryMaxAgeMs = null,
requestDefaultNotional = null,
requestMaxNotional = null,
requestMaxSlippageBps = null,
requestDefaultNotional = undefined,
requestMaxNotional = undefined,
requestMaxSlippageBps = undefined,
changedBy = 'operator',
reason = 'operator pair strategy config initialization',
} = {}) {
@ -1396,11 +1396,6 @@ function nonNegativeIntegerOrDefault(value, fallback, field) {
return next;
}
function nullableNonNegativeIntegerOrDefault(value, fallback, field) {
if (!hasConfigOverride(value)) return fallback == null ? null : nonNegativeIntegerOrDefault(fallback, 0, field);
return nonNegativeIntegerOrDefault(value, 0, field);
}
function positiveNumberStringOrDefault(value, fallback, field) {
if (!hasConfigOverride(value)) return String(fallback);
const next = String(value).trim();
@ -1416,10 +1411,25 @@ function nonNegativeNumberStringOrDefault(value, fallback, field) {
}
function nullablePositiveNumberStringOrDefault(value, fallback, field) {
if (!hasConfigOverride(value)) return fallback == null ? null : positiveNumberStringOrDefault(fallback, '1', field);
if (value === undefined) return fallback == null ? null : positiveNumberStringOrDefault(fallback, '1', field);
return nullablePositiveNumberString(value, field);
}
function nullablePositiveNumberString(value, field) {
if (!hasConfigOverride(value)) return null;
return positiveNumberStringOrDefault(value, '1', field);
}
function nullableNonNegativeIntegerOrDefault(value, fallback, field) {
if (value === undefined) return fallback == null ? null : nonNegativeIntegerOrDefault(fallback, 0, field);
return nullableNonNegativeInteger(value, field);
}
function nullableNonNegativeInteger(value, field) {
if (!hasConfigOverride(value)) return null;
return nonNegativeIntegerOrDefault(value, 0, field);
}
function normalizeStrategyConfigRow(row) {
if (!row) return null;
return {

View file

@ -279,6 +279,10 @@ function IdentifierLine({ label, value }) {
function IntentRequestForm({ summary, onControl }) {
const defaults = summary?.defaults || {};
const sourceSymbol = defaults.source_symbol || 'EURe';
const destinationSymbol = defaults.destination_symbol || 'BTC';
const hasAmountCap = defaults.max_amount_eure != null && defaults.max_amount_eure !== '';
const hasSlippageCap = defaults.max_slippage_bps != null && defaults.max_slippage_bps !== '';
const [form, setForm] = useState({
amount_eure: defaults.amount_eure || '5',
slippage_bps: String(defaults.slippage_bps ?? 200),
@ -303,36 +307,40 @@ function IntentRequestForm({ summary, onControl }) {
<form onSubmit={handlePreflight}>
<div className="form-grid">
<div className="field">
<label htmlFor="intent-request-amount">Spend EURe</label>
<label htmlFor="intent-request-amount">{`Spend ${sourceSymbol}`}</label>
<input
id="intent-request-amount"
max={defaults.max_amount_eure || '5'}
min="0.01"
name="amount_eure"
onChange={(event) => setForm((current) => ({ ...current, amount_eure: event.target.value }))}
step="0.01"
type="number"
value={form.amount_eure}
{...(hasAmountCap ? { max: defaults.max_amount_eure } : {})}
/>
<div className="status-subtle">{`Max ${defaults.max_amount_eure || '5'} EURe per live test request`}</div>
<div className="status-subtle">
{hasAmountCap ? `Max ${defaults.max_amount_eure} ${sourceSymbol}` : 'No request amount cap'}
</div>
</div>
<div className="field">
<label htmlFor="intent-request-slippage">Max slippage bps</label>
<input
id="intent-request-slippage"
max={defaults.max_slippage_bps ?? 200}
min="0"
name="slippage_bps"
onChange={(event) => setForm((current) => ({ ...current, slippage_bps: event.target.value }))}
step="1"
type="number"
value={form.slippage_bps}
{...(hasSlippageCap ? { max: defaults.max_slippage_bps } : {})}
/>
<div className="status-subtle">{`Max ${defaults.max_slippage_bps ?? 200} bps / 2%`}</div>
<div className="status-subtle">
{hasSlippageCap ? `Max ${defaults.max_slippage_bps} bps` : 'No slippage cap'}
</div>
</div>
</div>
<div className="button-row">
<button className="button" type="submit">Preflight EURe to BTC</button>
<button className="button" type="submit">{`Preflight ${sourceSymbol} to ${destinationSymbol}`}</button>
<button
className="button secondary"
onClick={() => onControl('trade-executor', 'intent-request-refresh-outcomes')}

View file

@ -630,6 +630,16 @@ function PairConfigSection({ assetCatalog, pairConfig, onControl }) {
<td>{strategyConfig.edge_bps ?? 'Unavailable'} bps</td>
<td>
<div>{strategyConfig.max_notional || 'Unavailable'} max</div>
<div className="status-subtle">
{strategyConfig.request_max_notional == null
? 'No request cap'
: `${strategyConfig.request_max_notional} request max`}
</div>
<div className="status-subtle">
{strategyConfig.request_max_slippage_bps == null
? 'No slippage cap'
: `${strategyConfig.request_max_slippage_bps} bps slippage max`}
</div>
<div className="status-subtle">{strategyConfig.price_max_age_ms || 'Unavailable'} ms price max age</div>
</td>
<td>{route.source || 'Unavailable'}</td>

View file

@ -122,7 +122,14 @@ function buildRelay() {
};
}
function buildController({ store = buildStore(), relay = buildRelay(), armed = true, verifierRegistered = true, withMakerSuppressed = async (operation) => operation() } = {}) {
function buildController({
store = buildStore(),
relay = buildRelay(),
armed = true,
verifierRegistered = true,
withMakerSuppressed = async (operation) => operation(),
getTradingConfig = null,
} = {}) {
return {
store,
relay,
@ -137,6 +144,7 @@ function buildController({ store = buildStore(), relay = buildRelay(), armed = t
signer: KeyPair.fromRandom('ed25519'),
isArmed: () => armed,
isPaused: () => false,
getTradingConfig,
withMakerSuppressed,
now: () => Date.parse('2026-04-12T10:00:00.000Z'),
uuid: (() => {
@ -196,6 +204,60 @@ test('preflight is side-effect-free and does not publish a live intent', async (
assert.equal(relay.publishCalls, 0);
});
test('DB null request limits allow operator-chosen amount and slippage', async () => {
const store = buildStore({ inventoryUnits: '6000000000000000000' });
const relay = buildRelay();
relay.quote = async function quote() {
this.quoteCalls += 1;
return [{
quote_hash: 'uncapped-quote-hash',
amount_out: '12000',
expiration_time: '2026-04-12T10:01:00.000Z',
}];
};
const pairId = `${EURE.assetId}->${BTC.assetId}`;
const pair = {
key: pairId,
pairId,
takerEnabled: true,
canTrade: true,
assetIn: EURE,
assetOut: BTC,
strategyConfig: {
requestDefaultNotional: '5',
requestMaxNotional: null,
slippageBps: 200,
requestMaxSlippageBps: null,
minDeadlineMs: 60_000,
priceMaxAgeMs: 30_000,
inventoryMaxAgeMs: 30_000,
},
priceRoute: {
source: 'btc_eur_reference',
},
};
const { controller } = buildController({
store,
relay,
getTradingConfig: async () => ({
ok: true,
tradingEure: EURE,
tradingBtc: BTC,
pairByKey: new Map([[pairId, pair]]),
defaultTakerPair: pair,
}),
});
const preflight = await controller.preflight({ amount_eure: '6', slippage_bps: 250 });
assert.equal(preflight.state, 'draft');
assert.equal(preflight.reason_code, 'quote_available');
assert.equal(preflight.request_max_notional, null);
assert.equal(preflight.slippage_bps, 250);
assert.equal(preflight.live_submit_capable, true);
assert.equal(relay.quoteCalls, 1);
});
test('insufficient spendable EURe blocks before solver quote or signing', async () => {
const store = buildStore({ inventoryUnits: '0' });
const relay = buildRelay();

View file

@ -27,6 +27,8 @@ test('operator dashboard exposes DB-backed pair activation and pause controls',
assert.match(source, /control\.action === 'pause-pair'/);
assert.match(source, /edgeBps: body\.edge_bps/);
assert.match(source, /maxNotional: body\.max_notional/);
assert.match(source, /requestMaxNotional: bodyField\(body, 'request_max_notional', 'requestMaxNotional'\)/);
assert.match(source, /requestMaxSlippageBps: bodyField\(body, 'request_max_slippage_bps', 'requestMaxSlippageBps'\)/);
});
test('operator dashboard API auth failures are JSON for frontend fetches', () => {

View file

@ -213,6 +213,38 @@ test('edge update creates a new active strategy version', async () => {
assert.equal(versions.find((row) => row.version === 1).active, false);
});
test('strategy config update can clear request amount and slippage caps', async () => {
const pool = createMemoryPool();
await seedTradingConfig(pool);
const pairId = `${CURRENT_EURE_ASSET_ID}->${CURRENT_NBTC_ASSET_ID}`;
await createPairStrategyConfigVersion(pool, {
pairId,
edgeBps: 49,
maxNotional: '200',
requestMaxNotional: '5',
requestMaxSlippageBps: 200,
changedBy: 'test',
reason: 'operator capped request limits',
});
const next = await createPairStrategyConfigVersion(pool, {
pairId,
edgeBps: 49,
maxNotional: '200',
requestMaxNotional: null,
requestMaxSlippageBps: null,
changedBy: 'test',
reason: 'operator uncapped request limits',
});
const snapshot = await loadTradingConfig(pool);
const pair = snapshot.pairByKey.get(pairId);
assert.equal(next.requestMaxNotional, null);
assert.equal(next.requestMaxSlippageBps, null);
assert.equal(pair.strategyConfig.requestMaxNotional, null);
assert.equal(pair.strategyConfig.requestMaxSlippageBps, null);
});
test('strategy config update rejects invalid max notional', async () => {
const pool = createMemoryPool();
await seedTradingConfig(pool);