-
Notifications
You must be signed in to change notification settings - Fork 279
docs(tip-1027): StablecoinDEX swap-and-send #3016
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
94b12d3
3ffc5a1
c726afe
47fab82
a6873f0
3eb1a49
edd48d3
be05e63
910b69e
2abc550
768afcd
ccb5f1d
1c7321f
107b6ed
41a543a
a1f615c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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: | ||
|
0xKitsune marked this conversation as resolved.
|
||
|
|
||
| - **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: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. feel like this should be
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. +1, we should update this IMO.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah interesting idea, but An alternative would just be overloading the function with 1-2 arguments and not changing the name. How do we feel about overloading? |
||
|
|
||
| ```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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. but it's input transfer if recipient = DEX.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yep good point, should say that |
||
| /// @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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. but it's input transfer if recipient = DEX.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same here |
||
| /// @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. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 [SUGGESTION] Spec references wrong error name The actual interface at Recommended Fix: Change |
||
|
|
||
| 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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This paragraph claims the DEX-recipient pause check prevents users from "building up positions in a paused token." However, existing The invariant this paragraph claims to establish does not hold for existing paths. |
||
|
|
||
| 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. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚨 [SECURITY] Placement-time policy check assumption is incorrect for stale orders (verified) This paragraph assumes the maker "was already policy-checked when they placed the order" so re-checking is unnecessary. However, This was reproduced with a passing test. See Recommended Fix: Recompute the stale condition before matching any order. |
||
|
|
||
| ## 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. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. won't this be confusing if the recipient expects to see a transfer from the sender but sees from from DEX? |
||
|
|
||
| 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. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. when the swap is funded entirely from internal DEX balance, who actually emits the zero-value
same question applies to invariant 7 and test case 12. |
||
|
|
||
| For the non-memo `To` variants with DEX-recipient, no `TransferWithMemo` is emitted regardless of funding source. | ||
|
Comment on lines
+151
to
+157
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is super wonky, just revert if you're trying to swap with a memo into the dex or something
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i concur. do we have a clear usecase/user for why we're building these somewhat wonky semantics?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You're right, let's just have this one revert; the special cases just kinda piled up a bit |
||
|
|
||
| ## 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. | ||
|
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reusing the current input-debit logic is not safe once See Recommended Fix: Add |
||
| --- | ||
|
|
||
| # 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). | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. contradicts invariant 7 that says withMemo variants must always emit a TransferWithMemo? |
||
|
|
||
| ### 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. | ||
|
Comment on lines
+252
to
+254
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this purposeful or the use of the |
||
|
|
||
| 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`. | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
dumb q: we do have native batching in tempo tx? why eat all this complexity instead of just doing that?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.