diff --git a/tips/tip-1027.md b/tips/tip-1027.md new file mode 100644 index 0000000000..08b5242ce4 --- /dev/null +++ b/tips/tip-1027.md @@ -0,0 +1,256 @@ +--- +id: TIP-1027 +title: StablecoinDEX Swap-and-Send +description: Adds an optional recipient parameter to StablecoinDEX swap functions, allowing callers to send swap output directly to another address. +authors: Dan Robinson +status: Draft +related: TIP-1015, TIP-1028 +protocolVersion: T5 +--- + +# TIP-1027: StablecoinDEX Swap-and-Send + +## Abstract + +This TIP adds new swap functions to the StablecoinDEX that accept a `recipient` parameter, allowing the caller to direct swap output to a different address in a single call. + +## Motivation + +Today, swapping and sending to another address requires two transactions: a swap (output goes to the caller) followed by a transfer to the intended recipient. This costs extra gas, increases latency, and prevents atomic swap-and-pay flows. + +Common use cases: + +- **Payments**: Swap token A for token B and pay a merchant in one call +- **DEX balance deposits**: Swap and credit the caller's internal DEX balance for immediate limit order placement, avoiding a round-trip transfer + +--- + +# Specification + +## New Functions + +Four new swap functions are added to the `IStablecoinDEX` interface — two base functions and two `WithMemo` variants: + +```solidity +/// @notice Swap exact input amount and send output to a specified recipient +/// @param tokenIn Token to sell +/// @param tokenOut Token to receive +/// @param amountIn Amount of tokenIn to sell +/// @param minAmountOut Minimum amount of tokenOut the recipient must receive +/// @param recipient Address to receive the output tokens +/// @return amountOut Actual amount of tokenOut sent to recipient +function swapExactAmountInTo( + address tokenIn, + address tokenOut, + uint128 amountIn, + uint128 minAmountOut, + address recipient +) external returns (uint128 amountOut); + +/// @notice Swap exact input amount and send output to a specified recipient, with memo +/// @param tokenIn Token to sell +/// @param tokenOut Token to receive +/// @param amountIn Amount of tokenIn to sell +/// @param minAmountOut Minimum amount of tokenOut the recipient must receive +/// @param recipient Address to receive the output tokens +/// @param memo Arbitrary 32-byte memo attached to the output transfer +/// @return amountOut Actual amount of tokenOut sent to recipient +function swapExactAmountInToWithMemo( + address tokenIn, + address tokenOut, + uint128 amountIn, + uint128 minAmountOut, + address recipient, + bytes32 memo +) external returns (uint128 amountOut); + +/// @notice Swap for exact output amount and send to a specified recipient +/// @param tokenIn Token to sell +/// @param tokenOut Token to receive +/// @param amountOut Exact amount of tokenOut the recipient must receive +/// @param maxAmountIn Maximum amount of tokenIn to spend +/// @param recipient Address to receive the output tokens +/// @return amountIn Actual amount of tokenIn spent +function swapExactAmountOutTo( + address tokenIn, + address tokenOut, + uint128 amountOut, + uint128 maxAmountIn, + address recipient +) external returns (uint128 amountIn); + +/// @notice Swap for exact output amount and send to a specified recipient, with memo +/// @param tokenIn Token to sell +/// @param tokenOut Token to receive +/// @param amountOut Exact amount of tokenOut the recipient must receive +/// @param maxAmountIn Maximum amount of tokenIn to spend +/// @param recipient Address to receive the output tokens +/// @param memo Arbitrary 32-byte memo attached to the output transfer +/// @return amountIn Actual amount of tokenIn spent +function swapExactAmountOutToWithMemo( + address tokenIn, + address tokenOut, + uint128 amountOut, + uint128 maxAmountIn, + address recipient, + bytes32 memo +) external returns (uint128 amountIn); +``` + +The existing `swapExactAmountIn` and `swapExactAmountOut` functions are unchanged. + +The `WithMemo` variants are separate functions rather than overloads with a default — `bytes32(0)` is a valid memo value and must be distinguishable from "no memo". This follows the existing TIP-20 pattern where `transfer` / `transferWithMemo`, `mint` / `mintWithMemo`, etc. are separate functions. + +## Recipient Validation + +Recipient validation MUST occur **before** routing, order fills, or any state mutation. + +- `recipient == address(0)` or any TIP-20 token address MUST revert with `InvalidRecipient()` (mirroring TIP-20's `check_recipient` logic). +- `recipient == msg.sender` is allowed and follows the same settlement path as the existing no-recipient functions. + +### Special Case: `recipient == address(StablecoinDEX)` + +When the recipient is the DEX itself, the output tokens are credited to the caller's internal DEX balance (`balances[msg.sender][tokenOut] += amountOut`) instead of performing a TIP-20 transfer. This enables atomic swap-into-balance flows — for example, swapping and immediately placing a limit order without a round-trip transfer. + +Since no transfer of the TIP-20 output token occurs on this path, the DEX manually enforces policy and pause checks: + +```solidity +uint64 policyId = ITIP20(tokenOut).transferPolicyId(); +// DEX must be authorized sender (issuer can freeze all DEX trading) +if (!TIP403_REGISTRY.isAuthorizedSender(policyId, address(this))) { + revert ITIP20.PolicyForbids(); +} +// Caller must be authorized to receive the token +if (!TIP403_REGISTRY.isAuthorizedRecipient(policyId, msg.sender)) { + revert ITIP20.PolicyForbids(); +} +// Output token must not be paused +if (ITIP20(tokenOut).paused()) { + revert ITIP20.TokenPaused(); +} +``` + +Unlike the existing maker-fill path (where makers' internal balances are credited without checking pause, because the maker opted in before the pause by placing an order), the swap-and-send caller is newly acquiring a token position. Pause MUST be enforced to prevent users from building up positions in a paused token. + +This is different from the existing maker-fill path (where `_fillOrder` credits `balances[maker]` without re-checking policies). In that case, the maker was already policy-checked when they placed the order. Here, the caller is newly acquiring a token position via swap, so we must verify they are authorized to hold it. + +TIP-1028 address-level receive policies do not apply to the DEX-credit path, since no TIP-20 transfer occurs. Address-level receive policies govern actual token transfers, not internal DEX balance accounting. + +## Output Settlement (External Recipient) + +For external recipients, the DEX calls `ITIP20(tokenOut).transfer(recipient, amountOut)` (or `transferWithMemo(recipient, amountOut, memo)` for the `WithMemo` variants) — a normal TIP-20 transfer from the DEX. This is identical to how existing swap functions settle output, just with `recipient` instead of `msg.sender`. All standard TIP-20 checks (TIP-403 token-level policies, TIP-1028 address-level receive policies, pause state, recipient validity) are enforced automatically by the TIP-20 transfer. + +## Memo Behavior + +### External Recipient + +For `WithMemo` variants with an external recipient, the output settlement emits both a `Transfer` event and a `TransferWithMemo` event on the output token, with `from = StablecoinDEX, to = recipient`. The `TransferWithMemo` event is emitted immediately after the `Transfer` event. + +For the non-memo variants, the DEX calls `ITIP20(tokenOut).transfer(recipient, amountOut)` and no `TransferWithMemo` event is emitted. + +### DEX-Recipient (Internal Balance Credit) + +When `recipient == address(StablecoinDEX)` and a memo is provided (via the `WithMemo` variants), the memo is attached to the **input** transfer instead, since no TIP-20 output transfer occurs on this path. + +If the input is pulled (fully or partially) from the caller's external wallet, the external portion emits both a `Transfer` event and a `TransferWithMemo` event (with the `TransferWithMemo` immediately after). If the swap is funded entirely from the caller's internal DEX balance, the DEX emits a zero-value `Transfer` and `TransferWithMemo` event on `tokenIn` (with `from = msg.sender, to = StablecoinDEX, amount = 0`) to preserve the memo. + +For the non-memo `To` variants with DEX-recipient, no `TransferWithMemo` is emitted regardless of funding source. + +## Event Emission + +The `OrderFilled` event's `taker` field remains `msg.sender` — the taker is the party that initiated the trade, not the recipient of the output. The TIP-20 `Transfer` event shows `from = StablecoinDEX, to = recipient`, which accurately reflects the on-chain token movement. + +## Errors + +No new errors are added. Invalid recipients (`address(0)` or TIP-20 token addresses) revert with the existing TIP-20 `InvalidRecipient()` error. + +## Atomicity + +If output settlement fails for any reason (policy denial, paused token, invalid recipient, etc.), the entire swap MUST revert — including all prior order fills, orderbook mutations, and balance changes. + +## Implementation Outline + +All four new functions MUST execute the same routing, pricing, fill, and input-debit logic as the corresponding existing swap function. The only semantic differences are the output settlement target and memo handling: + +- If `recipient == address(StablecoinDEX)`: check policies, credit `balances[msg.sender][tokenOut]`. For `WithMemo` variants, if the input is pulled from the caller's external wallet (fully or partially), use `transferFromWithMemo` for the external portion so the memo is attached to the input leg. If the swap is funded entirely from internal DEX balance, emit a zero-value `Transfer` and `TransferWithMemo` on `tokenIn` to preserve the memo. +- Otherwise: settle via `ITIP20(tokenOut).transfer(recipient, amountOut)` for base variants, or `ITIP20(tokenOut).transferWithMemo(recipient, amountOut, memo)` for `WithMemo` variants. + +The existing `swapExactAmountIn` and `swapExactAmountOut` remain unchanged — they are not refactored to delegate to the new functions, preserving identical behavior for existing callers. + +--- + +# Invariants + +1. **Caller-only input**: The DEX MUST only pull input tokens from `msg.sender`. The `recipient` parameter MUST NOT affect which address input tokens are pulled from. + +2. **DEX-balance credit for DEX-recipient**: When `recipient == address(StablecoinDEX)`, the output MUST be credited to `balances[msg.sender][tokenOut]`, not transferred via TIP-20. Policy checks MUST still be enforced. + +3. **Upfront recipient validation**: All four new functions MUST validate the recipient **before** any routing, fills, or state mutation. `recipient == address(0)` or any TIP-20 token address MUST revert with `InvalidRecipient()`. + +4. **Existing function preservation**: `swapExactAmountIn` and `swapExactAmountOut` MUST behave identically to before this TIP. Their semantics, policy checks, and event emission MUST NOT change. + +5. **Settlement-only change**: The `recipient` parameter MUST affect only final `tokenOut` settlement. It MUST NOT change routing, price computation, fill order selection, or `tokenIn` source. + +6. **Taker identity**: The `OrderFilled` event's `taker` field MUST be `msg.sender` regardless of the `recipient` parameter. + +7. **Memo presence**: The `WithMemo` variants MUST always emit a `TransferWithMemo` event. When `recipient == address(StablecoinDEX)` and the swap is funded entirely from internal DEX balance, a zero-value `Transfer` and `TransferWithMemo` on `tokenIn` MUST be emitted to preserve the memo. The non-memo variants MUST NOT emit a `TransferWithMemo` event. `bytes32(0)` is a valid memo. + +8. **Memo placement — external recipient**: For `WithMemo` variants with an external recipient, the memo MUST be attached to the output settlement (the `transferWithMemo` call on `tokenOut`). + +9. **Memo placement — DEX-recipient**: For `WithMemo` variants where `recipient == address(StablecoinDEX)`, the memo MUST be attached to the input transfer (the `tokenIn` transfer from caller to DEX), since no TIP-20 output transfer occurs. + +## Test Cases + +### Core Swap-and-Send + +1. **Basic swap-and-send**: `swapExactAmountInTo` sends output to a third-party address. Verify recipient receives the tokens and caller's balance decreases by `amountIn`. + +2. **Swap-and-send exact out**: `swapExactAmountOutTo` sends exact output to recipient. Verify `amountIn <= maxAmountIn`. + +3. **Self-recipient**: `swapExactAmountInTo(..., msg.sender)` behaves identically to `swapExactAmountIn(...)`. + +4. **DEX-recipient credits balance**: `swapExactAmountInTo(..., DEX_ADDRESS)` credits `balances[msg.sender][tokenOut]` and emits no TIP-20 `Transfer` event for the output. + +5. **Invalid recipient reverts early**: All four new functions revert with `InvalidRecipient()` when `recipient == address(0)` or a TIP-20 token address, **before** any order fills or state mutation. + +6. **Multi-hop swap-and-send**: A swap that routes through an intermediate pair delivers the final output to the recipient. + +7. **Existing functions unchanged**: `swapExactAmountIn` and `swapExactAmountOut` continue to work identically (gas, events, policy checks) before and after this TIP. + +### Memo + +8. **Memo on external recipient**: `swapExactAmountInToWithMemo` emits a `TransferWithMemo` event on `tokenOut` with the provided memo, `from = StablecoinDEX, to = recipient`. The `TransferWithMemo` event MUST be emitted immediately after the corresponding `Transfer` event. + +9. **Memo with bytes32(0)**: `swapExactAmountInToWithMemo(..., bytes32(0))` emits a `TransferWithMemo` event with `memo = bytes32(0)` immediately after the `Transfer` event. This is a valid memo, not "no memo". + +10. **No memo on non-memo variants**: `swapExactAmountInTo` and `swapExactAmountOutTo` do NOT emit a `TransferWithMemo` event. + +11. **Memo on DEX-recipient (external funding)**: `swapExactAmountInToWithMemo(..., DEX_ADDRESS, memo)` where input is pulled from the caller's external wallet attaches the memo to the input transfer. The `TransferWithMemo` event MUST be emitted immediately after the corresponding input `Transfer` event. + +12. **Memo on DEX-recipient (full internal funding)**: `swapExactAmountInToWithMemo(..., DEX_ADDRESS, memo)` where the caller's internal DEX balance fully covers the input emits a zero-value `Transfer` and `TransferWithMemo` on `tokenIn` (`from = msg.sender, to = StablecoinDEX, amount = 0`) to preserve the memo. + +13. **No memo on DEX-recipient non-memo variant**: `swapExactAmountInTo(..., DEX_ADDRESS)` does not emit any `TransferWithMemo` event regardless of funding source. + +14. **Exact-out memo on external recipient**: `swapExactAmountOutToWithMemo` emits a `TransferWithMemo` event on `tokenOut` immediately after the `Transfer` event. + +15. **Exact-out memo on DEX-recipient**: `swapExactAmountOutToWithMemo(..., DEX_ADDRESS, memo)` follows the same memo rules as the exact-in variant (memo on input transfer if external funding occurs, no memo if fully internal-funded). + +### Policy (TIP-403 / TIP-1015) + +16. **Policy: blocked recipient**: If `recipient` is blocked by `tokenOut`'s recipient policy, the swap-and-send reverts. + +17. **DEX frozen as sender**: If `tokenOut`'s policy blacklists the DEX as a sender, both `swapExactAmountInTo` (external recipient) and `swapExactAmountInTo` (DEX-balance credit) revert. + +18. **DEX-recipient + blocked caller**: `swapExactAmountInTo(..., DEX_ADDRESS)` reverts if `msg.sender` is blocked by `tokenOut`'s recipient policy. + +19. **Paused token reverts (external recipient)**: If `tokenOut` is paused, `swapExactAmountInTo` to an external recipient reverts during settlement (enforced by TIP-20 `transfer`). + +20. **Paused token reverts (DEX-recipient)**: If `tokenOut` is paused, `swapExactAmountInTo(..., DEX_ADDRESS)` reverts. The pause check is enforced by the DEX before crediting internal balance. + +### Address-Level Policies (TIP-1028) + +21. **Recipient address-level receive policy**: If `recipient` has a whitelist receive policy that does not include the DEX address, the swap-and-send reverts (enforced by TIP-20 `transfer`). + +22. **Recipient token set**: If `recipient` has a token set that does not include `tokenOut`, the swap-and-send reverts. + +23. **DEX-recipient skips address-level checks**: `swapExactAmountInTo(..., DEX_ADDRESS)` does NOT check TIP-1028 address-level controls, since no TIP-20 transfer occurs. Controls are enforced when the caller later calls `withdraw`.