Skip to content

fix(intent-engine)(#29): pre-flight balance check at create_intent - #32

Merged
vrogojin merged 2 commits into
masterfrom
fix/issue-29-preflight-balance-check
Jun 14, 2026
Merged

vrogojin merged 2 commits into
masterfrom
fix/issue-29-preflight-balance-check

Conversation

@vrogojin

Copy link
Copy Markdown
Owner

Summary

Closes #29.

IntentEngine.createIntent now rejects intents whose upper-bound exposure (rate_max × volume_max of quote for buy, volume_max of base for sell) exceeds ledger.getAvailable(asset), throwing INSUFFICIENT_PORTFOLIO_FOR_RATE_BAND before the intent reaches the market. Without this gate, an under-funded intent only fails 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. 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 in createIntent, sits right after validateIntentParams and before the max_active_intents gate. Emits a create_intent_insufficient_portfolio_for_rate_band warn log with the asset, required, available, and decimals.
  • src/trader/utils.ts — extract toSmallestUnitsBigInt so the pre-flight and the runtime reservation share one implementation (previously private in trader-main.ts).
  • src/trader/trader-main.ts — pass getDecimals: payments.getDecimals.bind(payments) into createIntentEngine.
  • src/trader/intent-engine.test.ts — 6 new tests:
    • buy rejected when rate_max × volume_max exceeds 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);
    • buy accepted at exact-cover (6 ETH = rate_max=0.12 × volume_max=50);
    • sell rejected when volume_max exceeds available base balance;
    • sell accepted at exact-cover;
    • reservation composition — same intent that passed before now fails after a 1 ETH reservation consumes available;
    • error-message contents include asset + smallest-unit values;
    • market is NOT posted to when the pre-flight rejects.
  • Three e2e harnesses (trader-intent-lifecycle, trader-matching, trader-multi-agent) plumb getDecimals and 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

  • Single-intent only. Two intents that EACH cover available but jointly exceed it are still accepted — the existing deal-time reservation catches that. Multi-intent reservation at create time is a larger protocol change; out of scope per the issue.
  • Counterparty-balance clamping in 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.
  • Project convention preserved: rates/volumes stay as decimal strings throughout intent + match + negotiate; smallest-unit conversion happens at exactly one place — the wallet-reservation boundary — which is now hit at create time as well as at deal time.

Test plan

  • npx tsc --noEmit (src + test) — clean
  • npx eslint . — clean
  • npx vitest run — 705/705 passing (44 existing + 6 new pre-flight tests in intent-engine.test.ts; three e2e harness updates)
  • npm run build — clean
  • Live verification via manual-test-trader-roundtrip.sh against the soak: bob's 4.5 ETH deposit + [0.08, 0.12] × 50 UCT should now refuse at create time instead of aborting at §8 with VOLUME_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.

vrogojin added 2 commits June 14, 2026 17:48
…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.
@vrogojin
vrogojin merged commit 7c16e00 into master Jun 14, 2026
1 check passed
@vrogojin
vrogojin deleted the fix/issue-29-preflight-balance-check branch June 14, 2026 19:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Trader posts intents it cannot honor — no pre-flight balance check vs max-rate exposure

1 participant