fix(intent-engine)(#29): pre-flight balance check at create_intent - #32
Merged
Merged
Conversation
…over rate-band exposure An intent is an unconditional promise to honor any rate in [rate_min, rate_max] for any volume in [volume_min, volume_max]. A counterparty with a narrow band that pins the rate at our rate_max forces us to settle at our upper-bound exposure (`rate_max × volume_max` of the quote asset for buy, `volume_max` of the base asset for sell). Without a pre-flight check the trader posts intents it cannot fund, and the failure surfaces only at deal-acceptance time as a terminal VOLUME_RESERVATION_FAILED — visible to the counterparty as an aborted swap, with no actionable signal to the operator. Add a Fail-Closed-Early gate in IntentEngine.createIntent that compares the upper-bound exposure (smallest units) against `ledger.getAvailable(coin)` and throws `INSUFFICIENT_PORTFOLIO_FOR_RATE_BAND` before the intent is posted to the market. The conversion path matches the existing deal-time reservation math in trader-main.ts (Number × Number → toSmallestUnitsBigInt at the SDK-registered decimals), so the gate honors the same arithmetic the runtime reservation will eventually apply. Plumbing: - Extract `toSmallestUnitsBigInt` from trader-main.ts to utils.ts so both the pre-flight and the runtime reservation share one implementation. - Add required `getDecimals: (coinId) => number` to IntentEngineDeps and wire it through trader-main and the three e2e harnesses. - Bump e2e trader-matching/trader-multi-agent test balances above the default buy-intent worst-case (500 × 1000 = 500_000 USDC) so existing matching scenarios still post; update three hardcoded balance assertions accordingly. - Six new unit tests cover buy/sell accept+reject cases, reservation composition (an intent that passed before now fails after a reservation consumes available), error message contents, and market-quiet on rejection. Scope notes: - Concurrent multi-intent over-promise (two solvable intents that jointly exceed available balance) is intentionally NOT addressed — the pre-flight catches single-intent miscalibration only, matching the issue's stated scope. Multi-intent reservation is a larger protocol change tracked separately. - Counterparty-balance clamping during negotiation is also out of scope (the counterparty's balance isn't part of NP-0 today). The pre-flight here is local-only and orthogonal. Closes #29
Default was bumped to 1_000_000_000n in the previous commit to clear the default-params worst-case exposure; the param's JSDoc still said 10_000.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #29.
IntentEngine.createIntentnow rejects intents whose upper-bound exposure (rate_max × volume_maxof quote for buy,volume_maxof base for sell) exceedsledger.getAvailable(asset), throwingINSUFFICIENT_PORTFOLIO_FOR_RATE_BANDbefore the intent reaches the market. Without this gate, an under-funded intent only fails at deal-acceptance time as a terminalVOLUME_RESERVATION_FAILED— visible to the counterparty as an aborted swap, with no actionable signal to the operator. The pre-flight converts runtime drift into a configuration error at create time.The conversion path matches the existing deal-time reservation math in
trader-main.ts(Number × Number → toSmallestUnitsBigInt(decimals)), so the gate honors exactly the arithmetic the runtime reservation will apply later.Changes
src/trader/intent-engine.ts— pre-flight portfolio gate increateIntent, sits right aftervalidateIntentParamsand before themax_active_intentsgate. Emits acreate_intent_insufficient_portfolio_for_rate_bandwarn log with the asset, required, available, and decimals.src/trader/utils.ts— extracttoSmallestUnitsBigIntso the pre-flight and the runtime reservation share one implementation (previously private intrader-main.ts).src/trader/trader-main.ts— passgetDecimals: payments.getDecimals.bind(payments)intocreateIntentEngine.src/trader/intent-engine.test.ts— 6 new tests:rate_max × volume_maxexceeds available quote balance (bob's 4.5 ETH vs 6 ETH worst-case from Trader posts intents it cannot honor — no pre-flight balance check vs max-rate exposure #29's repro);volume_maxexceeds available base balance;trader-intent-lifecycle,trader-matching,trader-multi-agent) plumbgetDecimalsand bump balances above the default-buy worst case (500_000 USDC) so existing matching scenarios still post; three hardcoded balance assertions updated accordingly.Scope notes
Test plan
npx tsc --noEmit(src + test) — cleannpx eslint .— cleannpx vitest run— 705/705 passing (44 existing + 6 new pre-flight tests inintent-engine.test.ts; three e2e harness updates)npm run build— cleanmanual-test-trader-roundtrip.shagainst the soak: bob's 4.5 ETH deposit +[0.08, 0.12] × 50 UCTshould now refuse at create time instead of aborting at §8 withVOLUME_RESERVATION_FAILED. The soak workaround (bumping bob's deposit to 6 ETH on the sphere-sdk side, commit 60be0dcf on fix/trader-soak-rate-band-coverage) keeps the soak running independently; this PR makes that workaround unnecessary going forward.