SIG-605: add SignalsRouter for atomic swap-and-bet#65
Conversation
There was a problem hiding this comment.
Summary
This PR adds SignalsRouter, a non-upgradeable peripheral contract that atomically swaps input tokens to ctUSD via Algebra V2 (Satsuma DEX) and executes the corresponding Core operation in a single transaction. The router supports open, increase, decrease, close, and claim operations with token swap. It uses balance-delta accounting for ctUSD input flows and return-value accounting for output swaps. All entry points are nonReentrant, and the token allowlist is owner-gated with ctUSD excluded.
Cross-PR Context
Sibling PRs (SIG-605)
No sibling PRs found. The PR description correctly identifies downstream integration needs:
v1-sdk: NewSignalsRouterABI export needed (ABI is already generated inabis/SignalsRouter.json)signals-app: Betting flow integration for non-ctUSD token paths
Since this is a new additive contract (no existing interface changes), downstream repos continue to function without modification. Sibling PRs can follow independently.
Issues
No issues found.
Suggestions
-
🟡 [Robustness — dangling ctUSD approval to Core after swap operations]: After
openPositionWithSwapandincreasePositionWithSwap, the router callsctUSD.forceApprove(address(core), ctUSDReceived)but Core only pullstotalCostwhich may be less thanctUSDReceived. The remaining approval persists until the nextforceApprovecall. (contracts/router/SignalsRouter.sol:718)Scenario: User calls
openPositionWithSwapwithinputAmount = 100_000, swap returns 100_000 ctUSD, butmaxCostmatches actual cost of 80_000. The router approves Core for 100_000, Core pulls 80_000, leaving 20_000 ctUSD approval. The leftover ctUSD balance is correctly returned to the user via the delta refund, and the approval is harmless because Core is a trusted immutable proxy. However, resetting the approval to 0 after the Core call (ctUSD.forceApprove(address(core), 0)) follows the principle of least privilege and eliminates even theoretical risk from a future Core upgrade.
Evidence: Line 718 approvesctUSDReceived, line 720 callscore.openPositionwhich pulls onlytotalCost <= maxCost.forceApproveon line 747 (increasePosition path) exhibits the same pattern. The refund on lines 722-725 correctly handles the balance delta, so no funds are at risk — this is a defense-in-depth suggestion. -
🟡 [Robustness —
_deliverOutputuses swap return value instead of balance-delta for output token transfer]: The_deliverOutputfunction transfersoutputAmount(the return value from_swapExactInput) directly to the recipient, while input-side swaps use balance-delta accounting. (contracts/router/SignalsRouter.sol:827-828)Scenario: If the Algebra V2 swap router's
exactInputSinglereturn valueamountOutdoes not exactly match the actual token transfer amount (e.g., a fee-on-transfer output token is added to the allowlist in the future), thesafeTransfer(recipient, outputAmount)would revert due to insufficient balance. The input-side already uses balance-delta (ctUSD.balanceOf(address(this)) - ctUSDBefore), which is immune to this class of bug.
Evidence: Lines 716-717 use balance-delta for ctUSD received from swap. Lines 827-828 use return value for output token. The current allowlisted tokens (USDC.e, WCBTC) are standard ERC20s without transfer fees, so this is not an active bug — but applying the same balance-delta pattern for output tokens would make the router robust against future allowlist additions of fee-on-transfer tokens.
Verdict
REQUEST_CHANGES: Two suggestions remain unresolved.
There was a problem hiding this comment.
Summary
This PR adds SignalsRouter, a non-upgradeable peripheral contract that atomically swaps input tokens to ctUSD via Algebra V2 (Satsuma DEX) and executes the corresponding Core operation in a single transaction. The router supports open, increase, decrease, close, and claim operations with token swap. It uses balance-delta accounting for ctUSD input flows and return-value accounting for output swaps. All entry points are nonReentrant, and the token allowlist is owner-gated with ctUSD excluded.
Cross-PR Context
Sibling PRs (SIG-605)
No sibling PRs found. The PR description correctly identifies downstream integration needs:
v1-sdk: NewSignalsRouterABI export needed (ABI is already generated inabis/SignalsRouter.json)signals-app: Betting flow integration for non-ctUSD token paths
Since this is a new additive contract (no existing interface changes), downstream repos continue to function without modification. Sibling PRs can follow independently.
Issues
No issues found.
Suggestions
-
🟡 [Robustness — dangling ctUSD approval to Core after swap operations]: After
openPositionWithSwapandincreasePositionWithSwap, the router callsctUSD.forceApprove(address(core), ctUSDReceived)but Core only pullstotalCostwhich may be less thanctUSDReceived. The remaining approval persists until the nextforceApprovecall. (contracts/router/SignalsRouter.sol:718)Scenario: User calls
openPositionWithSwapwithinputAmount = 100_000, swap returns 100_000 ctUSD, butmaxCostmatches actual cost of 80_000. The router approves Core for 100_000, Core pulls 80_000, leaving 20_000 ctUSD approval. The leftover ctUSD balance is correctly returned to the user via the delta refund, and the approval is harmless because Core is a trusted immutable proxy. However, resetting the approval to 0 after the Core call (ctUSD.forceApprove(address(core), 0)) follows the principle of least privilege and eliminates even theoretical risk from a future Core upgrade.
Evidence: Line 718 approvesctUSDReceived, line 720 callscore.openPositionwhich pulls onlytotalCost <= maxCost.forceApproveon line 747 (increasePosition path) exhibits the same pattern. The refund on lines 722-725 correctly handles the balance delta, so no funds are at risk — this is a defense-in-depth suggestion. -
🟡 [Robustness —
_deliverOutputuses swap return value instead of balance-delta for output token transfer]: The_deliverOutputfunction transfersoutputAmount(the return value from_swapExactInput) directly to the recipient, while input-side swaps use balance-delta accounting. (contracts/router/SignalsRouter.sol:827-828)Scenario: If the Algebra V2 swap router's
exactInputSinglereturn valueamountOutdoes not exactly match the actual token transfer amount (e.g., a fee-on-transfer output token is added to the allowlist in the future), thesafeTransfer(recipient, outputAmount)would revert due to insufficient balance. The input-side already uses balance-delta (ctUSD.balanceOf(address(this)) - ctUSDBefore), which is immune to this class of bug.
Evidence: Lines 716-717 use balance-delta for ctUSD received from swap. Lines 827-828 use return value for output token. The current allowlisted tokens (USDC.e, WCBTC) are standard ERC20s without transfer fees, so this is not an active bug — but applying the same balance-delta pattern for output tokens would make the router robust against future allowlist additions of fee-on-transfer tokens.
Verdict
REQUEST_CHANGES: Two suggestions remain unresolved.
There was a problem hiding this comment.
Summary
This PR adds SignalsRouter, a non-upgradeable peripheral contract that atomically swaps input tokens to ctUSD via Algebra V2 (Satsuma DEX) and executes the corresponding Core operation in a single transaction. The router supports open, increase, decrease, close, and claim operations with token swap. It uses balance-delta accounting for ctUSD input flows and return-value accounting for output swaps. All entry points are nonReentrant, and the token allowlist is owner-gated with ctUSD excluded.
Cross-PR Context
Sibling PRs (SIG-605)
No sibling PRs found. The PR description correctly identifies downstream integration needs:
v1-sdk: NewSignalsRouterABI export needed (ABI is already generated inabis/SignalsRouter.json)signals-app: Betting flow integration for non-ctUSD token paths
Since this is a new additive contract (no existing interface changes), downstream repos continue to function without modification. Sibling PRs can follow independently.
Issues
No issues found.
Suggestions
-
🟡 [Robustness — dangling ctUSD approval to Core after swap operations]: After
openPositionWithSwapandincreasePositionWithSwap, the router callsctUSD.forceApprove(address(core), ctUSDReceived)but Core only pullstotalCostwhich may be less thanctUSDReceived. The remaining approval persists until the nextforceApprovecall. (contracts/router/SignalsRouter.sol:718)Scenario: User calls
openPositionWithSwapwithinputAmount = 100_000, swap returns 100_000 ctUSD, butmaxCostmatches actual cost of 80_000. The router approves Core for 100_000, Core pulls 80_000, leaving 20_000 ctUSD approval. The leftover ctUSD balance is correctly returned to the user via the delta refund, and the approval is harmless because Core is a trusted immutable proxy. However, resetting the approval to 0 after the Core call (ctUSD.forceApprove(address(core), 0)) follows the principle of least privilege and eliminates even theoretical risk from a future Core upgrade.
Evidence: Line 718 approvesctUSDReceived, line 720 callscore.openPositionwhich pulls onlytotalCost <= maxCost.forceApproveon line 747 (increasePosition path) exhibits the same pattern. The refund on lines 722-725 correctly handles the balance delta, so no funds are at risk — this is a defense-in-depth suggestion. -
🟡 [Robustness —
_deliverOutputuses swap return value instead of balance-delta for output token transfer]: The_deliverOutputfunction transfersoutputAmount(the return value from_swapExactInput) directly to the recipient, while input-side swaps use balance-delta accounting. (contracts/router/SignalsRouter.sol:827-828)Scenario: If the Algebra V2 swap router's
exactInputSinglereturn valueamountOutdoes not exactly match the actual token transfer amount (e.g., a fee-on-transfer output token is added to the allowlist in the future), thesafeTransfer(recipient, outputAmount)would revert due to insufficient balance. The input-side already uses balance-delta (ctUSD.balanceOf(address(this)) - ctUSDBefore), which is immune to this class of bug.
Evidence: Lines 716-717 use balance-delta for ctUSD received from swap. Lines 827-828 use return value for output token. The current allowlisted tokens (USDC.e, WCBTC) are standard ERC20s without transfer fees, so this is not an active bug — but applying the same balance-delta pattern for output tokens would make the router robust against future allowlist additions of fee-on-transfer tokens.
Verdict
REQUEST_CHANGES: Two suggestions remain unresolved.
There was a problem hiding this comment.
Summary
This PR adds SignalsRouter, a non-upgradeable peripheral contract that atomically swaps input tokens to ctUSD via Algebra V2 (Satsuma DEX) and executes the corresponding Core operation in a single transaction. The router supports open, increase, decrease, close, and claim operations with token swap. It uses balance-delta accounting for ctUSD input flows and return-value accounting for output swaps. All entry points are nonReentrant, and the token allowlist is owner-gated with ctUSD excluded.
Cross-PR Context
Sibling PRs (SIG-605)
No sibling PRs found. The PR description correctly identifies downstream integration needs:
v1-sdk: NewSignalsRouterABI export needed (ABI is already generated inabis/SignalsRouter.json)signals-app: Betting flow integration for non-ctUSD token paths
Since this is a new additive contract (no existing interface changes), downstream repos continue to function without modification. Sibling PRs can follow independently.
Issues
No issues found.
Suggestions
-
🟡 [Robustness — dangling ctUSD approval to Core after swap operations]: After
openPositionWithSwapandincreasePositionWithSwap, the router callsctUSD.forceApprove(address(core), ctUSDReceived)but Core only pullstotalCostwhich may be less thanctUSDReceived. The remaining approval persists until the nextforceApprovecall. (contracts/router/SignalsRouter.sol:718)Scenario: User calls
openPositionWithSwapwithinputAmount = 100_000, swap returns 100_000 ctUSD, butmaxCostmatches actual cost of 80_000. The router approves Core for 100_000, Core pulls 80_000, leaving 20_000 ctUSD approval. The leftover ctUSD balance is correctly returned to the user via the delta refund, and the approval is harmless because Core is a trusted immutable proxy. However, resetting the approval to 0 after the Core call (ctUSD.forceApprove(address(core), 0)) follows the principle of least privilege and eliminates even theoretical risk from a future Core upgrade.
Evidence: Line 718 approvesctUSDReceived, line 720 callscore.openPositionwhich pulls onlytotalCost <= maxCost.forceApproveon line 747 (increasePosition path) exhibits the same pattern. The refund on lines 722-725 correctly handles the balance delta, so no funds are at risk — this is a defense-in-depth suggestion. -
🟡 [Robustness —
_deliverOutputuses swap return value instead of balance-delta for output token transfer]: The_deliverOutputfunction transfersoutputAmount(the return value from_swapExactInput) directly to the recipient, while input-side swaps use balance-delta accounting. (contracts/router/SignalsRouter.sol:827-828)Scenario: If the Algebra V2 swap router's
exactInputSinglereturn valueamountOutdoes not exactly match the actual token transfer amount (e.g., a fee-on-transfer output token is added to the allowlist in the future), thesafeTransfer(recipient, outputAmount)would revert due to insufficient balance. The input-side already uses balance-delta (ctUSD.balanceOf(address(this)) - ctUSDBefore), which is immune to this class of bug.
Evidence: Lines 716-717 use balance-delta for ctUSD received from swap. Lines 827-828 use return value for output token. The current allowlisted tokens (USDC.e, WCBTC) are standard ERC20s without transfer fees, so this is not an active bug — but applying the same balance-delta pattern for output tokens would make the router robust against future allowlist additions of fee-on-transfer tokens.
Verdict
REQUEST_CHANGES: Two suggestions remain unresolved.
There was a problem hiding this comment.
Summary
This PR adds SignalsRouter, a non-upgradeable peripheral contract that atomically swaps input tokens to ctUSD via Algebra V2 (Satsuma DEX) and executes the corresponding Core operation in a single transaction. The router supports open, increase, decrease, close, and claim operations with token swap. It uses balance-delta accounting for ctUSD input flows and return-value accounting for output swaps. All entry points are nonReentrant, and the token allowlist is owner-gated with ctUSD excluded.
Cross-PR Context
Sibling PRs (SIG-605)
No sibling PRs found. The PR description correctly identifies downstream integration needs:
v1-sdk: NewSignalsRouterABI export needed (ABI is already generated inabis/SignalsRouter.json)signals-app: Betting flow integration for non-ctUSD token paths
Since this is a new additive contract (no existing interface changes), downstream repos continue to function without modification. Sibling PRs can follow independently.
Issues
No issues found.
Suggestions
-
🟡 [Robustness — dangling ctUSD approval to Core after swap operations]: After
openPositionWithSwapandincreasePositionWithSwap, the router callsctUSD.forceApprove(address(core), ctUSDReceived)but Core only pullstotalCostwhich may be less thanctUSDReceived. The remaining approval persists until the nextforceApprovecall. (contracts/router/SignalsRouter.sol:718)Scenario: User calls
openPositionWithSwapwithinputAmount = 100_000, swap returns 100_000 ctUSD, butmaxCostmatches actual cost of 80_000. The router approves Core for 100_000, Core pulls 80_000, leaving 20_000 ctUSD approval. The leftover ctUSD balance is correctly returned to the user via the delta refund, and the approval is harmless because Core is a trusted immutable proxy. However, resetting the approval to 0 after the Core call (ctUSD.forceApprove(address(core), 0)) follows the principle of least privilege and eliminates even theoretical risk from a future Core upgrade.
Evidence: Line 718 approvesctUSDReceived, line 720 callscore.openPositionwhich pulls onlytotalCost <= maxCost.forceApproveon line 747 (increasePosition path) exhibits the same pattern. The refund on lines 722-725 correctly handles the balance delta, so no funds are at risk — this is a defense-in-depth suggestion. -
🟡 [Robustness —
_deliverOutputuses swap return value instead of balance-delta for output token transfer]: The_deliverOutputfunction transfersoutputAmount(the return value from_swapExactInput) directly to the recipient, while input-side swaps use balance-delta accounting. (contracts/router/SignalsRouter.sol:827-828)Scenario: If the Algebra V2 swap router's
exactInputSinglereturn valueamountOutdoes not exactly match the actual token transfer amount (e.g., a fee-on-transfer output token is added to the allowlist in the future), thesafeTransfer(recipient, outputAmount)would revert due to insufficient balance. The input-side already uses balance-delta (ctUSD.balanceOf(address(this)) - ctUSDBefore), which is immune to this class of bug.
Evidence: Lines 716-717 use balance-delta for ctUSD received from swap. Lines 827-828 use return value for output token. The current allowlisted tokens (USDC.e, WCBTC) are standard ERC20s without transfer fees, so this is not an active bug — but applying the same balance-delta pattern for output tokens would make the router robust against future allowlist additions of fee-on-transfer tokens.
Verdict
REQUEST_CHANGES: Two suggestions remain unresolved.
Apply reviewer suggestion: use balanceOf delta instead of swap return value for output token transfer in _deliverOutput. This matches input-side accounting and guards against future fee-on-transfer tokens in the allowlist.
Review ResponseAppliedOutput token balance-delta — RebuttedctUSD approval reset after Core call — Core is an immutable UUPS proxy. The only entity that can pull from the Router's ctUSD approval is |
There was a problem hiding this comment.
Re-review Summary
Re-reviewing after 2 new commits since last review: f2d5b7e (ABI regen) and 648b4bc (balance-delta fix for output delivery). 1 file changed in the substantive fix commit.
Previous Items
-
✅ [Resolved]:
_deliverOutputuses swap return value instead of balance-delta — resolved by commit648b4bc. The function now readsoutToken.balanceOf(address(this))before and after the swap, transferring only the delta. This matches input-side accounting and protects against fee-on-transfer tokens. -
🔄 [Accepted]: Dangling ctUSD approval to Core after swap operations — author's rebuttal is technically sound: Core is a UUPS proxy whose upgrades require the same Safe multisig governance that controls the Router's
Ownable. The Router holds zero ctUSD between transactions (excess is refunded via balance-delta), so the dangling approval allows pulling 0 tokens. AddingforceApprove(core, 0)costs ~5,200 gas per tx. The threat model (compromised governance) makes this defense moot since governance controls both the Router and Core upgrade path. The cost-benefit analysis is concrete and evidence-based.
New Issues
No new issues found. Fresh review of the full contract confirms:
- All entry points are
nonReentrantwith correct allowlist enforcement - Balance-delta accounting is consistent across both input (ctUSD from swap) and output (output token from swap) paths
- NFT lifecycle is correct:
_takePositionusestransferFrom(requires prior approval),_returnPositionIfNeededchecksexists()before attempting return,onERC721Receivedcorrectly rejects non-mint transfers - ctUSD shortcut in
_deliverOutputcorrectly bypasses allowlist check (ctUSD is always valid output) - Dust protection: pre-existing ctUSD balance in the router is never leaked to users (ctUSDBefore snapshot isolates the transaction's delta)
- Test coverage is comprehensive: happy paths, slippage reverts, rollback scenarios, access control, dust preservation, paused-state claim, and losing position claim
Verdict
APPROVE: All previous items are either Resolved (1) or Accepted (1). No new issues found.
### Context SIG-605 added the `SignalsRouter` for atomic swap-and-bet operations (PR #65). This PR adds comprehensive fork tests that exercise the Router against a live Citrea mainnet fork — real Satsuma swaps, real Core contract state. ### Decisions - Tests run against **prod fork** (`FORK_ENV=prod`) so they validate against actual deployed contracts and pool liquidity, not mock state - Each Router entry point gets its own full-flow test: `openPositionWithSwap`, `increasePositionWithSwap`, `decreasePositionWithSwap`, `closePositionWithSwap`, `claimPayoutWithSwap` - `claimPayoutWithSwap` test drives the settlement path (warp → markSettlementFailed → finalizeSecondarySettlement → requestSettlementChunks → claim) to cover the payout flow end-to-end - `_findActiveMarket` helper searches backward from `nextMarketId` for an active, seeded, in-window market — tests skip gracefully if none exists - `_openRealPosition` places the position at center ticks (not edge) so the cost/payout is meaningful rather than near-zero - Existing swap-only test renamed to `test_satsuma_swap_usdcE_to_ctUSD` for clarity and simplified (removed redundant local variables) ### Risk Areas - `claimPayoutWithSwap` test manipulates settlement state via `ownerSafe` — the settlement value calculation (`winningTick * 1e6`) assumes 8-decimal Redstone feed and tick = price * 100 convention - `_findActiveMarket` iterates up to `nextMarketId + 5` — relies on there being an active market during test execution ### Sibling PRs - `v1-contract` PR #65: SIG-605: add SignalsRouter for atomic swap-and-bet (CLOSED/MERGED)
Context
MarketCore's
openPositiononly accepts ctUSD. Users holding other tokens (USDC.e, WCBTC) must manually swap → approve → open in 3 separate transactions, with price slippage risk between the swap and bet. SIG-605This PR adds
SignalsRouter, a non-upgradeable peripheral contract that atomically swaps input tokens to ctUSD via Algebra V2 (Satsuma DEX) and executes the corresponding Core operation in a single transaction.Decisions
increasePositionWithSwap,decreasePositionWithSwap,closePositionWithSwap,claimPayoutWithSwaptemporarily transfer the position NFT to the router (viatransferFrom) so the router becomesownerOffor the Core ownership check, then return it post-operation.onERC721Receivedrejects non-mint transfers to prevent accidental NFT deposits.balanceOf(this)before/after pattern rather than trusting return values, ensuring pre-existing ctUSD dust in the router is never drained to users.poolDeployerset toaddress(0)in deploy script: The Algebra V2 pool deployer address on Citrea is not yet registered in environment configs. The deploy script passesaddress(0)— this needs to be updated before mainnet deployment.rescueTokenandrescueNFTallow the owner to recover stuck assets — defensive measure since the router should never hold assets between transactions.Impact
This is a new contract with a new ABI. No existing contract interfaces or events are modified.
v1-sdk: NewSignalsRouterABI needs to be added for frontend integration (ABI exported viaexport-abis.js)signals-app: Betting flow needs to integrate router calls for non-ctUSD token paths (Satsuma swap infra already exists insrc/core/satsuma/)Risk Areas
maxCost, the Core call reverts and the entire transaction rolls back. TheminCtUSDparameter controls this boundary but requires correct off-chain quoting._takePositionusestransferFrom(notsafeTransferFrom) — caller must have approved the router for the position NFT. Missing approval reverts at ERC721 level.msg.senderfor all Core calls — fee policies and risk gates see the router address as the trader, not the end user. The NatSpec documents this, but any future per-user policy logic must account for router-mediated calls.onERC721Receivedallowsfrom == address(0)(mint) only — this is correct foropenPositionflow where Core mints to the router, but means the router cannot receive position NFTs viasafeTransferFromfrom users (they must usetransferFromorsetApprovalForAll+ router's_takePosition).