Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
256 changes: 256 additions & 0 deletions tips/tip-1027.md
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.
Copy link
Copy Markdown
Contributor

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?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Not everyone can use Tempo txes.
  2. There's a cost to initialize the temporary balance (250k gas)—if we eventually get refunds for storage that is cleared in the same slot that could help, but it's still worse.
  3. The caller may not be authorized to receive the token. I think this is an interesting edge case that adds a nice capability to TIP-1015.


Common use cases:
Comment thread
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:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

feel like this should be WithRecipient and WithMemo.. the To suffix is really confusing

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1, we should update this IMO.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah interesting idea, but WithRecipient and WithMemo sounds like the one with the memo doesn't have a recipient, but it does.

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but it's input transfer if recipient = DEX.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but it's input transfer if recipient = DEX.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 [SUGGESTION] Spec references wrong error name

The actual interface at interfaces/ITIP20.sol:9 defines ContractPaused, not TokenPaused. This pseudocode would not compile as written.

Recommended Fix: Change ITIP20.TokenPaused() to ITIP20.ContractPaused().


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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [ISSUE] Post-pause bids invalidate this invariant claim

This paragraph claims the DEX-recipient pause check prevents users from "building up positions in a paused token." However, existing place() never checks paused() on the base token, and fill_order() credits paused-token internal balances without a pause gate. A fresh post-pause bidder can still acquire paused-token positions through the existing matching engine (crates/precompiles/src/stablecoin_dex/mod.rs:421-438, 667-724).

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.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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, cancel_stale_order() recognizes that sender authorization can change after placement, while fill_order() / partial_fill_order() never revalidate. A sender-blacklisted maker's pre-escrowed orders can still be matched, crediting the maker with the opposite asset which they then withdraw.

This was reproduced with a passing test. See crates/precompiles/src/stablecoin_dex/mod.rs:655-724 and :1096-1123.

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.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 Transfer / TransferWithMemo "on tokenIn"? events are scoped to the emitting contract, so the DEX can't just log them on the token's address, either:

  • the DEX calls tokenIn.transferWithMemo(self, 0, memo) (which means TIP-20 has to allow zero-value memo transfers
  • something else I'm missing

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Contributor

@danrobinson danrobinson Apr 28, 2026

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [ISSUE] "Settlement-only change" assumption fails for access-key transactions

Reusing the current input-debit logic is not safe once recipient can be arbitrary. The DEX external-funding path uses TIP20::transfer_from(), which does not consult AccountKeychain spend limits (unlike transfer() and system_transfer_from() which do). Today this is bounded because output returns to msg.sender. With recipient, a compromised limited access key could spend the user's full pre-approved DEX allowance and redirect swap output to an attacker.

See crates/precompiles/src/tip20/mod.rs:616-685 vs :603-613 and :642-657.

Recommended Fix: Add AccountKeychain::authorize_transfer() to the transfer_from path before implementing TIP-1027.

---

# 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).
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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
Copy link
Copy Markdown
Contributor

@0xalpharush 0xalpharush May 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this purposeful or the use of the ESCROW_ADDRESS in #3791 was added later and the tokens should be redirected there?


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`.
Loading