Skip to content

feat(cli): fund RFQ collateral from any held token with --pay-with (cli 0.4.0) - #59

Merged
Shawnchee merged 8 commits into
mainfrom
feat/cli-swapAndCall
Aug 20, 2026
Merged

Shawnchee merged 8 commits into
mainfrom
feat/cli-swapAndCall

Conversation

@Shawnchee

@Shawnchee Shawnchee commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds rfq request --pay-with, which funds an RFQ's collateral from an asset you already hold — in the same transaction — via OptionFactory.swapAndCall. Native ETH wraps to WETH 1:1 inside the factory; an ERC-20 is swapped through KyberSwap. Cuts CLI 0.4.0.

The collateral token an RFQ takes is dictated by the structure, not the user (single-strike ETH CALL is WETH, everything else USDC), so a user holding the other asset previously had to go acquire the right one first.

What's here

  • feat(cli) — the --pay-with flow: swapAndCall.ts (contract rules + aggregator client + router calldata decoder), payWith.ts (planning, rails, preview), and the rfq request wiring.
  • docs(rfq)docs/rfq/pay-with.md, cross-linked from the RFQ overview, create-rfq, SUMMARY, and the CLI README.
  • fix(cli) — unrelated one-liner: the id column pushed position list past terminal width.
  • chore — gitignore for local working notes; 0.4.0 release prep.

Safety properties

The approval targets the OptionFactory, not the router — the factory is what executes the swap. Beyond that:

  • The minimum shown at the prompt is the minimum enforced at broadcast. The route is re-quoted after confirmation, so the confirmed floor is carried across and can be raised but never lowered. Covering the required deposit is not treated as consent to the rate — the deposit can be a fraction of the swap output, with the excess refunded, so a much worse rate would otherwise clear that bar silently. A 0.5% re-quote allowance is priced into the displayed figure so ordinary price movement doesn't abort the run.
  • The aggregator's executable calldata is decoded and bound before signing — source/destination token, amount, destination receiver, and the minReturnAmount the router actually enforces. That decoded minimum, not the API's plaintext amountOut, is what the floor is measured against, so a response cannot report one price and encode another. Routes carrying a router-level fee, or using an entrypoint the CLI can't decode, are refused. Selectors are pinned against the verified Base MetaAggregationRouterV2 (swap 0xe21fd0e9, swapGeneric 0x59e50fed, swapSimpleMode 0x8af033fb).
  • Chain is asserted against the RPC before the approval and the swap. --pay-with signs directly rather than through an SDK write method, which is where that check normally lives.
  • Price impact, slippage caps, router authorizedRouters status, and wallet balance are all checked before any approval is broadcast, so a rejected route costs no gas. A route with no USD pricing is refused rather than treated as zero impact.

Scope notes

  • BUY-side and Base only. A SELL request escrows nothing at request time — the factory pulls collateral at settlement — so there's no deposit for a swap to fund.
  • No SDK change. The CLI builds calldata with the existing encodeSwapAndCall and sends it with the wallet's own signer.
  • One known pre-existing SDK bug is deliberately not fixed here: optionFactory.swapAndCall() drops params.value, breaking the direct native-wrap path. It has no CLI impact (the CLI uses encodeSwapAndCall, which preserves it) and fails closed. Worth a separate patch.
  • The 0.4.0 release prep also backfills the 0.3.0/0.3.1 changelog headings — both releases bumped package.json but never cut their entries, leaving shipped work under [Unreleased].

Testing

  • npm test in cli14/14 pass, including two new suites covering the calldata decoder, every binding rejection (redirected recipient, wrong tokens, wrong size, smuggled fee, unknown selector, inflated plaintext minimum), the NaN/Infinity price-impact fail-open, and the confirmed-floor invariant.
  • npm run typecheck + npm run build clean in both cli and the SDK root.
  • The calldata binding was run against live KyberSwap Base routes for 4 real pairs (USDC↔WETH, USDC→cbBTC, WETH→cbBTC) — all decode and pass, with the decoded minimum landing ~50bps above the plan floor, confirming the drift budget is sized correctly and the new checks don't reject legitimate routes.

Verified end-to-end on Base mainnet (RFQ 123, ETH-21AUG26-2400-C, WETH-collateralized INVERSE_CALL, funded from USDC):

Step Tx Result
swapAndCall — USDC pulled, swapped via Kyber, WETH deposited, RFQ created atomically 0xa099be… success, 737,152 gas
settleQuotationEarly — maker offer accepted 0x474e9a… success, 654,881 gas

The decoded-calldata binding and the confirmed floor both ran on the live route and passed. The resulting position reports contracts 0.016273772577950114 — matching the request's numContracts exactly — at a premium of 0.000095459806942795 WETH, matching the accepted offer to the wei. Total cost ~$0.02 in gas.

Expect occasional aborts on fast-moving pairs when drift exceeds 50bps between prompt and broadcast — clear error, nothing broadcast, re-run. That's the floor working as designed.

- Ignores CLI PRD/templates, agent tooling dirs (.claude, .superstack), and
  local handoff notes that were never meant to be tracked.
- Adds codex_bugs.md alongside fable_5_fix.md: both are security-triage logs
  that reference unfixed vulnerabilities and must not be published.
- `id` is optionAddress + "-" + side, so printing it alongside optionAddress
  pushed the table past terminal width and wrapped pnl under the first column.
- JSON output still carries `id`, so scripts keying on it are unaffected.
- `rfq request --pay-with <eth|token>` routes the request through
  `OptionFactory.swapAndCall`, wrapping native ETH 1:1 or swapping an ERC-20
  through KyberSwap so the collateral is funded in the same transaction. The
  approval targets the OptionFactory, not the router, because the factory is
  what executes the swap.
- The minimum shown at the confirmation prompt is the minimum enforced at
  broadcast: the re-quote can raise it but never lower it, and the aggregator's
  calldata is decoded and bound to the quoted trade (tokens, amount, recipient,
  and the router's own `minReturnAmount`) before anything is signed.
- BUY-side and Base only — a SELL request escrows nothing at request time, so
  there is no deposit for a swap to fund.
- Adds docs/rfq/pay-with.md covering both funding paths, sizing, the rails,
  and why the approval goes to the OptionFactory rather than the swap router.
- Cross-links it from the RFQ overview, create-rfq, SUMMARY, and the CLI README.
- Cuts a 0.4.0 section for `--pay-with` and updates the README version banner.
- Backfills the 0.3.0 and 0.3.1 headings: both releases bumped package.json but
  never cut their changelog entries, leaving shipped work under [Unreleased].
- Bumps cli to 0.4.0. Publishing to npm remains a separate decision.
- `--pay-with` refused a zero-deposit request by suggesting the flag be dropped,
  but a BUY with reservePrice 0 escrows nothing and cannot be filled either way,
  so that advice led nowhere. It now names the cause and the two real fixes.
- Documents why it happens: the MM ask is only fetched when sizing with
  --collateral-amount, so --contracts leaves the reserve at 0 unless set.
@Shawnchee
Shawnchee merged commit 325a086 into main Aug 20, 2026
6 checks passed
@Shawnchee
Shawnchee deleted the feat/cli-swapAndCall branch August 20, 2026 17:45
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.

1 participant