From 94b12d3943c61ae29bc87e200d69eb6a157d2572 Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Fri, 6 Mar 2026 19:31:51 +0000 Subject: [PATCH 01/16] docs(tip-1027): StablecoinDEX swap-and-send Adds TIP-1027 spec for optional recipient parameter on StablecoinDEX swap functions. Caller is treated as logical sender for TIP-403/TIP-1025 policy checks. Includes DEX-balance credit special case for recipient == DEX. Co-Authored-By: Daniel Robinson <1187252+danrobinson@users.noreply.github.com> --- tips/tip-1027.md | 207 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 tips/tip-1027.md diff --git a/tips/tip-1027.md b/tips/tip-1027.md new file mode 100644 index 0000000000..297a1c00ba --- /dev/null +++ b/tips/tip-1027.md @@ -0,0 +1,207 @@ +--- +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-1025 +protocolVersion: TBD +--- + +# TIP-1027: StablecoinDEX Swap-and-Send + +## Abstract + +This TIP adds new swap functions to the StablecoinDEX that accept an optional `recipient` parameter, allowing the caller to direct swap output to a different address in a single call. Transfer policy checks treat the caller as the logical sender (not the DEX), so the recipient's TIP-403 and address-level receive policies see the original initiator. + +## 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 +- **Gifting / payroll**: Swap and distribute to multiple recipients (via a batch contract) +- **Protocol integrations**: Contracts that swap on behalf of users and route output to the correct destination +- **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 + +Two new swap functions are added to the `IStablecoinDEX` interface: + +```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 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); +``` + +The existing `swapExactAmountIn` and `swapExactAmountOut` functions are unchanged. They continue to use normal `ITIP20.transfer()` for output settlement, where the DEX is the TIP-20 `msg.sender`. The new `...To` functions use caller-as-logical-sender semantics for policy checks, which means `swapExactAmountInTo(..., msg.sender)` is **not** identical to `swapExactAmountIn(...)` — they may produce different policy outcomes under TIP-1015 compound policies or TIP-1025 address-level receive policies. + +## Recipient Validation + +- `recipient == address(0)` MUST revert with `InvalidRecipient()`. +- `recipient` MUST satisfy normal TIP-20 recipient-validity rules (e.g., not a token address or invalid precompile). If it does not, the function MUST revert with `InvalidRecipient()`. +- `recipient == msg.sender` is allowed. The economic result is the same as the legacy functions, but policy enforcement differs (caller-as-sender vs DEX-as-sender). + +### 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. + +No output-side TIP-403 or TIP-1025 policy check is required for the DEX-balance credit path, since no TIP-20 transfer to the caller or recipient occurs — it is an internal DEX accounting credit only. The input-side policy check (caller → DEX) still applies as usual. + +Note: this means a caller can receive an internal DEX credit for `tokenOut` even if a direct on-chain transfer of `tokenOut` to them would be policy-blocked. This is intentional — the tokens remain in the DEX's custody and only leave when the caller later withdraws or places an order. + +## Transfer Policy Enforcement + +For the output transfer to an external recipient, the DEX MUST NOT use `ITIP20.transfer(recipient, amountOut)`, because that would check policies with the DEX as the sender. Instead, the DEX manually enforces policies treating the **caller** as the logical sender, then executes a system-level transfer. + +### Token-Level Policy (TIP-403 / TIP-1015) + +```solidity +uint64 policyId = ITIP20(tokenOut).transferPolicyId(); + +// Check: caller is authorized sender under tokenOut's policy +if (!TIP403_REGISTRY.isAuthorizedSender(policyId, msg.sender)) { + revert ITIP20.PolicyForbids(); +} + +// Check: recipient is authorized recipient under tokenOut's policy +if (!TIP403_REGISTRY.isAuthorizedRecipient(policyId, recipient)) { + revert ITIP20.PolicyForbids(); +} +``` + +This uses the TIP-1015 compound-aware `isAuthorizedSender` / `isAuthorizedRecipient` functions, so compound policies with asymmetric sender/recipient rules work correctly. + +### Address-Level Receive Policy (TIP-1025) + +If TIP-1025 (address-level receive policies) is active, the DEX additionally checks: + +```solidity +if (!TIP403_REGISTRY.isAddressTransferAuthorized(msg.sender, recipient, tokenOut)) { + revert ITIP20.PolicyForbids(); +} +``` + +This checks the **recipient's** address-level controls against the **caller** (not the DEX). If the recipient has whitelisted specific counterparties, the caller must be on that list — they don't need to separately whitelist the DEX address. + +### Settlement + +After policy checks pass, the DEX executes a privileged transfer from itself to the recipient that: + +1. Moves `amountOut` of `tokenOut` from the DEX to `recipient` (standard `_transfer` bookkeeping) +2. Emits the standard TIP-20 `Transfer(DEX, recipient, amountOut)` event +3. Reverts if `tokenOut` is paused +4. Preserves all normal transfer side effects (reward accounting, etc.) +5. Does **not** re-check TIP-403 or TIP-1025 authorization (already enforced above) +6. Does **not** check or consume TIP-20 allowances + +This is the same privilege level the DEX already uses for output settlement today (calling `transfer` from the DEX address). The only difference is that authorization is checked against the caller rather than the DEX. + +## 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 emitted by the settlement will show `from = StablecoinDEX, to = recipient`, which accurately reflects the on-chain token movement. + +## Errors + +One new error is added: + +```solidity +error InvalidRecipient(); +``` + +## Implementation Outline + +The new functions share the existing swap routing and order-fill logic. The only difference is the final settlement step: + +``` +swapExactAmountInTo: + 1. findTradePath(tokenIn, tokenOut) + 2. Fill orders (identical to swapExactAmountIn) + 3. Pull input tokens from caller: _decrementBalanceOrTransferFrom(msg.sender, tokenIn, amountIn) + 4. Settle output: + a. If recipient == DEX: balances[msg.sender][tokenOut] += amountOut + b. Else: enforce policies (caller as sender), then systemTransfer to recipient +``` + +The existing `swapExactAmountIn` and `swapExactAmountOut` remain unchanged — they are not refactored to delegate to the new functions, preserving identical gas costs and 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. **Caller-as-sender policy check**: On the output transfer, TIP-403 sender authorization MUST be checked against `msg.sender`, not the DEX address. + +3. **Recipient policy check**: TIP-403 recipient authorization and TIP-1025 address-level receive policies MUST be checked against the `recipient` address. + +4. **DEX-balance credit for self-recipient**: When `recipient == address(StablecoinDEX)`, the output MUST be credited to `balances[msg.sender][tokenOut]`, not transferred via TIP-20. + +5. **No zero-address recipient**: `swapExactAmountInTo` and `swapExactAmountOutTo` MUST revert with `InvalidRecipient()` when `recipient == address(0)`. + +6. **Existing function preservation**: `swapExactAmountIn` and `swapExactAmountOut` MUST behave identically to before this TIP. Their gas cost, policy checks (DEX as TIP-20 msg.sender), and event emission MUST NOT change. + +7. **Taker identity**: The `OrderFilled` event's `taker` field MUST be `msg.sender` regardless of the `recipient` parameter. + +## Test Cases + +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 differs from legacy**: `swapExactAmountInTo(..., msg.sender)` produces the same economic result as `swapExactAmountIn(...)`, but under a TIP-1015 compound policy where the DEX is an authorized sender but `msg.sender` is not, the legacy function succeeds and the `...To` function reverts. + +4. **DEX-recipient credits balance**: `swapExactAmountInTo(..., DEX_ADDRESS)` credits `balances[msg.sender][tokenOut]` and emits no TIP-20 `Transfer` event for the output. + +5. **DEX-recipient skips output policy**: `swapExactAmountInTo(..., DEX_ADDRESS)` succeeds even if `msg.sender` would be blocked by `tokenOut`'s recipient policy (no output transfer occurs). + +6. **Zero-address reverts**: `swapExactAmountInTo(..., address(0))` reverts with `InvalidRecipient()`. + +7. **Invalid recipient reverts**: `swapExactAmountInTo(..., invalidPrecompile)` reverts with `InvalidRecipient()`. + +8. **Policy: blocked caller**: If `msg.sender` is blocked by `tokenOut`'s sender policy, the swap-and-send reverts even though the recipient is authorized. + +9. **Policy: blocked recipient**: If `recipient` is blocked by `tokenOut`'s recipient policy, the swap-and-send reverts. + +10. **TIP-1025 address-level receive**: If the recipient has an address-level receive policy that does not include `msg.sender`, the swap-and-send reverts. + +11. **TIP-1025 caller treated as sender**: Recipient whitelists `msg.sender` (not the DEX) in their address-level receive policy. The swap-and-send succeeds. + +12. **Compound policy (TIP-1015)**: Token has a compound policy with different sender/recipient sub-policies. Verify `swapExactAmountInTo` checks the sender sub-policy against `msg.sender` and the recipient sub-policy against `recipient`. + +13. **Multi-hop swap-and-send**: A swap that routes through an intermediate pair delivers the final output to the recipient. + +14. **Paused token reverts**: If `tokenOut` is paused, `swapExactAmountInTo` reverts during settlement. + +15. **Existing functions unchanged**: `swapExactAmountIn` and `swapExactAmountOut` continue to work identically (gas, events, policy checks) before and after this TIP. From 3ffc5a11f173659f7628225d0ccb3fa6a0f315ba Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Sat, 7 Mar 2026 00:20:04 +0000 Subject: [PATCH 02/16] docs(tip-1027): enforce policy on DEX-balance credit path Caller acquiring a new token via swap-to-DEX-balance must be policy-checked as both sender and recipient, unlike maker fills which were pre-checked at order placement. Co-Authored-By: Daniel Robinson <1187252+danrobinson@users.noreply.github.com> --- tips/tip-1027.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/tips/tip-1027.md b/tips/tip-1027.md index 297a1c00ba..03ef60a219 100644 --- a/tips/tip-1027.md +++ b/tips/tip-1027.md @@ -77,9 +77,19 @@ The existing `swapExactAmountIn` and `swapExactAmountOut` functions are unchange 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. -No output-side TIP-403 or TIP-1025 policy check is required for the DEX-balance credit path, since no TIP-20 transfer to the caller or recipient occurs — it is an internal DEX accounting credit only. The input-side policy check (caller → DEX) still applies as usual. +Even though no TIP-20 transfer occurs, the DEX MUST still enforce output-side policy checks treating the caller as both sender and recipient: -Note: this means a caller can receive an internal DEX credit for `tokenOut` even if a direct on-chain transfer of `tokenOut` to them would be policy-blocked. This is intentional — the tokens remain in the DEX's custody and only leave when the caller later withdraws or places an order. +```solidity +uint64 policyId = ITIP20(tokenOut).transferPolicyId(); +if (!TIP403_REGISTRY.isAuthorizedSender(policyId, msg.sender)) { + revert ITIP20.PolicyForbids(); +} +if (!TIP403_REGISTRY.isAuthorizedRecipient(policyId, msg.sender)) { + revert ITIP20.PolicyForbids(); +} +``` + +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. ## Transfer Policy Enforcement @@ -166,7 +176,7 @@ The existing `swapExactAmountIn` and `swapExactAmountOut` remain unchanged — t 3. **Recipient policy check**: TIP-403 recipient authorization and TIP-1025 address-level receive policies MUST be checked against the `recipient` address. -4. **DEX-balance credit for self-recipient**: When `recipient == address(StablecoinDEX)`, the output MUST be credited to `balances[msg.sender][tokenOut]`, not transferred via TIP-20. +4. **DEX-balance credit for self-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 treating `msg.sender` as both sender and recipient. 5. **No zero-address recipient**: `swapExactAmountInTo` and `swapExactAmountOutTo` MUST revert with `InvalidRecipient()` when `recipient == address(0)`. @@ -184,7 +194,7 @@ The existing `swapExactAmountIn` and `swapExactAmountOut` remain unchanged — t 4. **DEX-recipient credits balance**: `swapExactAmountInTo(..., DEX_ADDRESS)` credits `balances[msg.sender][tokenOut]` and emits no TIP-20 `Transfer` event for the output. -5. **DEX-recipient skips output policy**: `swapExactAmountInTo(..., DEX_ADDRESS)` succeeds even if `msg.sender` would be blocked by `tokenOut`'s recipient policy (no output transfer occurs). +5. **DEX-recipient checks caller as sender+recipient**: `swapExactAmountInTo(..., DEX_ADDRESS)` reverts if `msg.sender` is blocked by `tokenOut`'s sender or recipient policy, even though no TIP-20 transfer occurs. 6. **Zero-address reverts**: `swapExactAmountInTo(..., address(0))` reverts with `InvalidRecipient()`. From c726afec8b9e4f6bf7abdad36a4a678dfa26f5d7 Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Sat, 7 Mar 2026 00:26:35 +0000 Subject: [PATCH 03/16] docs(tip-1027): add DEX sender authorization check on tokenOut Issuers must be able to freeze all DEX trading of their token by blacklisting the DEX address. The privileged settlement path must check isAuthorizedSender(policyId, DEX) on both the external-recipient and DEX-balance-credit paths. Co-Authored-By: Daniel Robinson <1187252+danrobinson@users.noreply.github.com> --- tips/tip-1027.md | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/tips/tip-1027.md b/tips/tip-1027.md index 03ef60a219..8bc8ca20d7 100644 --- a/tips/tip-1027.md +++ b/tips/tip-1027.md @@ -81,6 +81,11 @@ Even though no TIP-20 transfer occurs, the DEX MUST still enforce output-side po ```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 as both sender and recipient if (!TIP403_REGISTRY.isAuthorizedSender(policyId, msg.sender)) { revert ITIP20.PolicyForbids(); } @@ -89,7 +94,7 @@ if (!TIP403_REGISTRY.isAuthorizedRecipient(policyId, msg.sender)) { } ``` -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. +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. The DEX sender check is also required to preserve the ability for issuers to freeze all DEX trading. ## Transfer Policy Enforcement @@ -100,6 +105,12 @@ For the output transfer to an external recipient, the DEX MUST NOT use `ITIP20.t ```solidity uint64 policyId = ITIP20(tokenOut).transferPolicyId(); +// Check: DEX is authorized sender under tokenOut's policy +// (preserves the ability for issuers to freeze all DEX trading of their token) +if (!TIP403_REGISTRY.isAuthorizedSender(policyId, address(this))) { + revert ITIP20.PolicyForbids(); +} + // Check: caller is authorized sender under tokenOut's policy if (!TIP403_REGISTRY.isAuthorizedSender(policyId, msg.sender)) { revert ITIP20.PolicyForbids(); @@ -111,7 +122,7 @@ if (!TIP403_REGISTRY.isAuthorizedRecipient(policyId, recipient)) { } ``` -This uses the TIP-1015 compound-aware `isAuthorizedSender` / `isAuthorizedRecipient` functions, so compound policies with asymmetric sender/recipient rules work correctly. +This uses the TIP-1015 compound-aware `isAuthorizedSender` / `isAuthorizedRecipient` functions, so compound policies with asymmetric sender/recipient rules work correctly. The DEX sender check ensures that a token issuer can freeze all DEX trading by blacklisting the DEX address. ### Address-Level Receive Policy (TIP-1025) @@ -214,4 +225,6 @@ The existing `swapExactAmountIn` and `swapExactAmountOut` remain unchanged — t 14. **Paused token reverts**: If `tokenOut` is paused, `swapExactAmountInTo` reverts during settlement. -15. **Existing functions unchanged**: `swapExactAmountIn` and `swapExactAmountOut` continue to work identically (gas, events, policy checks) before and after this TIP. +15. **DEX frozen as sender**: If `tokenOut`'s policy blacklists the DEX as a sender, both `swapExactAmountInTo` (external recipient) and `swapExactAmountInTo` (DEX-balance credit) revert. + +16. **Existing functions unchanged**: `swapExactAmountIn` and `swapExactAmountOut` continue to work identically (gas, events, policy checks) before and after this TIP. From 47fab82f025937ed3576f2702fc4aa211e8388d3 Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Sat, 7 Mar 2026 00:36:21 +0000 Subject: [PATCH 04/16] docs(tip-1027): add pause + TIP-1025 checks on DEX-credit path, clarify msg.sender semantics - Pause check on DEX-balance credit path (can't acquire paused tokens) - TIP-1025 isAddressTransferAuthorized(caller, caller, tokenOut) on DEX-credit path - Clarify that policies see msg.sender, not end-user-through-router - Add tokenIn-recipient invariant - Add test cases for TIP-1025 self/self, pause, and router semantics Co-Authored-By: Daniel Robinson <1187252+danrobinson@users.noreply.github.com> --- tips/tip-1027.md | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/tips/tip-1027.md b/tips/tip-1027.md index 8bc8ca20d7..77afee553c 100644 --- a/tips/tip-1027.md +++ b/tips/tip-1027.md @@ -12,7 +12,7 @@ protocolVersion: TBD ## Abstract -This TIP adds new swap functions to the StablecoinDEX that accept an optional `recipient` parameter, allowing the caller to direct swap output to a different address in a single call. Transfer policy checks treat the caller as the logical sender (not the DEX), so the recipient's TIP-403 and address-level receive policies see the original initiator. +This TIP adds new swap functions to the StablecoinDEX that accept an optional `recipient` parameter, allowing the caller to direct swap output to a different address in a single call. Transfer policy checks treat the direct caller (`msg.sender`) as the logical sender (not the DEX), so the recipient's TIP-403 and address-level receive policies see the caller. For protocol integrations where a router or batcher contract calls the DEX, policies will see the router contract as the sender, not the end user behind it. ## Motivation @@ -77,9 +77,12 @@ The existing `swapExactAmountIn` and `swapExactAmountOut` functions are unchange 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. -Even though no TIP-20 transfer occurs, the DEX MUST still enforce output-side policy checks treating the caller as both sender and recipient: +Even though no TIP-20 transfer occurs, the DEX MUST still enforce output-side checks: ```solidity +// Paused tokens cannot be acquired, even into DEX balance +if (ITIP20(tokenOut).paused()) revert ITIP20.ContractPaused(); + uint64 policyId = ITIP20(tokenOut).transferPolicyId(); // DEX must be authorized sender (issuer can freeze all DEX trading) if (!TIP403_REGISTRY.isAuthorizedSender(policyId, address(this))) { @@ -92,9 +95,13 @@ if (!TIP403_REGISTRY.isAuthorizedSender(policyId, msg.sender)) { if (!TIP403_REGISTRY.isAuthorizedRecipient(policyId, msg.sender)) { revert ITIP20.PolicyForbids(); } +// TIP-1025 address-level receive (caller sending to self) +if (!TIP403_REGISTRY.isAddressTransferAuthorized(msg.sender, msg.sender, tokenOut)) { + revert ITIP20.PolicyForbids(); +} ``` -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. The DEX sender check is also required to preserve the ability for issuers to freeze all DEX trading. +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. The DEX sender check preserves the ability for issuers to freeze all DEX trading. The pause check prevents acquiring positions in frozen tokens even via internal balance. ## Transfer Policy Enforcement @@ -195,6 +202,8 @@ The existing `swapExactAmountIn` and `swapExactAmountOut` remain unchanged — t 7. **Taker identity**: The `OrderFilled` event's `taker` field MUST be `msg.sender` regardless of the `recipient` parameter. +8. **No tokenIn-recipient checks**: `tokenIn` policy checks apply only to the caller → DEX transfer. The `recipient` parameter MUST NOT be checked against `tokenIn`'s policy. + ## Test Cases 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`. @@ -227,4 +236,10 @@ The existing `swapExactAmountIn` and `swapExactAmountOut` remain unchanged — t 15. **DEX frozen as sender**: If `tokenOut`'s policy blacklists the DEX as a sender, both `swapExactAmountInTo` (external recipient) and `swapExactAmountInTo` (DEX-balance credit) revert. -16. **Existing functions unchanged**: `swapExactAmountIn` and `swapExactAmountOut` continue to work identically (gas, events, policy checks) before and after this TIP. +16. **DEX-recipient + TIP-1025 self/self deny**: Caller has address-level receive policy that rejects self-sends for `tokenOut`. `swapExactAmountInTo(..., DEX_ADDRESS)` reverts. + +17. **DEX-recipient + paused tokenOut**: `swapExactAmountInTo(..., DEX_ADDRESS)` reverts if `tokenOut` is paused. + +18. **Router contract semantics**: A router contract calls `swapExactAmountInTo`. Recipient has TIP-1025 whitelist that includes the end user but not the router. Swap reverts — policies see `msg.sender` (the router), not the end user. + +19. **Existing functions unchanged**: `swapExactAmountIn` and `swapExactAmountOut` continue to work identically (gas, events, policy checks) before and after this TIP. From a6873f022224cdea9c932bb73933edfee40c7009 Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Sat, 7 Mar 2026 00:43:48 +0000 Subject: [PATCH 05/16] docs(tip-1027): DEX-credit TIP-1025 check uses DEX as sender, not self MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DEX-balance credit path checks isAddressTransferAuthorized(DEX, caller) not (caller, caller) — tokens come from DEX custody. Co-Authored-By: Daniel Robinson <1187252+danrobinson@users.noreply.github.com> --- tips/tip-1027.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tips/tip-1027.md b/tips/tip-1027.md index 77afee553c..1a60a35827 100644 --- a/tips/tip-1027.md +++ b/tips/tip-1027.md @@ -95,8 +95,8 @@ if (!TIP403_REGISTRY.isAuthorizedSender(policyId, msg.sender)) { if (!TIP403_REGISTRY.isAuthorizedRecipient(policyId, msg.sender)) { revert ITIP20.PolicyForbids(); } -// TIP-1025 address-level receive (caller sending to self) -if (!TIP403_REGISTRY.isAddressTransferAuthorized(msg.sender, msg.sender, tokenOut)) { +// TIP-1025 address-level receive (DEX sending to caller) +if (!TIP403_REGISTRY.isAddressTransferAuthorized(address(this), msg.sender, tokenOut)) { revert ITIP20.PolicyForbids(); } ``` @@ -236,7 +236,7 @@ The existing `swapExactAmountIn` and `swapExactAmountOut` remain unchanged — t 15. **DEX frozen as sender**: If `tokenOut`'s policy blacklists the DEX as a sender, both `swapExactAmountInTo` (external recipient) and `swapExactAmountInTo` (DEX-balance credit) revert. -16. **DEX-recipient + TIP-1025 self/self deny**: Caller has address-level receive policy that rejects self-sends for `tokenOut`. `swapExactAmountInTo(..., DEX_ADDRESS)` reverts. +16. **DEX-recipient + TIP-1025 deny**: Caller has address-level receive policy that does not authorize the DEX as sender. `swapExactAmountInTo(..., DEX_ADDRESS)` reverts. 17. **DEX-recipient + paused tokenOut**: `swapExactAmountInTo(..., DEX_ADDRESS)` reverts if `tokenOut` is paused. From 3eb1a4923f209b1beba6d3d3e8836b7aee954ecc Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Sun, 8 Mar 2026 23:39:26 +0000 Subject: [PATCH 06/16] docs(tip-1027): simplify policy checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External recipient path uses normal ITIP20.transfer(recipient) — no manual policy enforcement or privileged settlement needed. DEX-credit path checks pause, DEX-as-sender (issuer freeze), and caller-as-recipient. Co-Authored-By: Daniel Robinson <1187252+danrobinson@users.noreply.github.com> --- tips/tip-1027.md | 125 +++++++++-------------------------------------- 1 file changed, 24 insertions(+), 101 deletions(-) diff --git a/tips/tip-1027.md b/tips/tip-1027.md index 1a60a35827..0c08e60417 100644 --- a/tips/tip-1027.md +++ b/tips/tip-1027.md @@ -12,7 +12,7 @@ protocolVersion: TBD ## Abstract -This TIP adds new swap functions to the StablecoinDEX that accept an optional `recipient` parameter, allowing the caller to direct swap output to a different address in a single call. Transfer policy checks treat the direct caller (`msg.sender`) as the logical sender (not the DEX), so the recipient's TIP-403 and address-level receive policies see the caller. For protocol integrations where a router or batcher contract calls the DEX, policies will see the router contract as the sender, not the end user behind it. +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 @@ -65,19 +65,18 @@ function swapExactAmountOutTo( ) external returns (uint128 amountIn); ``` -The existing `swapExactAmountIn` and `swapExactAmountOut` functions are unchanged. They continue to use normal `ITIP20.transfer()` for output settlement, where the DEX is the TIP-20 `msg.sender`. The new `...To` functions use caller-as-logical-sender semantics for policy checks, which means `swapExactAmountInTo(..., msg.sender)` is **not** identical to `swapExactAmountIn(...)` — they may produce different policy outcomes under TIP-1015 compound policies or TIP-1025 address-level receive policies. +The existing `swapExactAmountIn` and `swapExactAmountOut` functions are unchanged. ## Recipient Validation - `recipient == address(0)` MUST revert with `InvalidRecipient()`. -- `recipient` MUST satisfy normal TIP-20 recipient-validity rules (e.g., not a token address or invalid precompile). If it does not, the function MUST revert with `InvalidRecipient()`. -- `recipient == msg.sender` is allowed. The economic result is the same as the legacy functions, but policy enforcement differs (caller-as-sender vs DEX-as-sender). +- `recipient == msg.sender` is allowed and behaves identically to 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. -Even though no TIP-20 transfer occurs, the DEX MUST still enforce output-side checks: +Since no TIP-20 transfer occurs on this path, the DEX manually enforces equivalent checks: ```solidity // Paused tokens cannot be acquired, even into DEX balance @@ -88,77 +87,21 @@ uint64 policyId = ITIP20(tokenOut).transferPolicyId(); if (!TIP403_REGISTRY.isAuthorizedSender(policyId, address(this))) { revert ITIP20.PolicyForbids(); } -// Caller as both sender and recipient -if (!TIP403_REGISTRY.isAuthorizedSender(policyId, msg.sender)) { - revert ITIP20.PolicyForbids(); -} +// Caller must be authorized to receive the token if (!TIP403_REGISTRY.isAuthorizedRecipient(policyId, msg.sender)) { revert ITIP20.PolicyForbids(); } -// TIP-1025 address-level receive (DEX sending to caller) -if (!TIP403_REGISTRY.isAddressTransferAuthorized(address(this), msg.sender, tokenOut)) { - revert ITIP20.PolicyForbids(); -} -``` - -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. The DEX sender check preserves the ability for issuers to freeze all DEX trading. The pause check prevents acquiring positions in frozen tokens even via internal balance. - -## Transfer Policy Enforcement - -For the output transfer to an external recipient, the DEX MUST NOT use `ITIP20.transfer(recipient, amountOut)`, because that would check policies with the DEX as the sender. Instead, the DEX manually enforces policies treating the **caller** as the logical sender, then executes a system-level transfer. - -### Token-Level Policy (TIP-403 / TIP-1015) - -```solidity -uint64 policyId = ITIP20(tokenOut).transferPolicyId(); - -// Check: DEX is authorized sender under tokenOut's policy -// (preserves the ability for issuers to freeze all DEX trading of their token) -if (!TIP403_REGISTRY.isAuthorizedSender(policyId, address(this))) { - revert ITIP20.PolicyForbids(); -} - -// Check: caller is authorized sender under tokenOut's policy -if (!TIP403_REGISTRY.isAuthorizedSender(policyId, msg.sender)) { - revert ITIP20.PolicyForbids(); -} - -// Check: recipient is authorized recipient under tokenOut's policy -if (!TIP403_REGISTRY.isAuthorizedRecipient(policyId, recipient)) { - revert ITIP20.PolicyForbids(); -} ``` -This uses the TIP-1015 compound-aware `isAuthorizedSender` / `isAuthorizedRecipient` functions, so compound policies with asymmetric sender/recipient rules work correctly. The DEX sender check ensures that a token issuer can freeze all DEX trading by blacklisting the DEX address. - -### Address-Level Receive Policy (TIP-1025) - -If TIP-1025 (address-level receive policies) is active, the DEX additionally checks: - -```solidity -if (!TIP403_REGISTRY.isAddressTransferAuthorized(msg.sender, recipient, tokenOut)) { - revert ITIP20.PolicyForbids(); -} -``` - -This checks the **recipient's** address-level controls against the **caller** (not the DEX). If the recipient has whitelisted specific counterparties, the caller must be on that list — they don't need to separately whitelist the DEX address. - -### Settlement - -After policy checks pass, the DEX executes a privileged transfer from itself to the recipient that: +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. -1. Moves `amountOut` of `tokenOut` from the DEX to `recipient` (standard `_transfer` bookkeeping) -2. Emits the standard TIP-20 `Transfer(DEX, recipient, amountOut)` event -3. Reverts if `tokenOut` is paused -4. Preserves all normal transfer side effects (reward accounting, etc.) -5. Does **not** re-check TIP-403 or TIP-1025 authorization (already enforced above) -6. Does **not** check or consume TIP-20 allowances +## Output Settlement (External Recipient) -This is the same privilege level the DEX already uses for output settlement today (calling `transfer` from the DEX address). The only difference is that authorization is checked against the caller rather than the DEX. +For external recipients, the DEX calls `ITIP20(tokenOut).transfer(recipient, amountOut)` — 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-1025 address-level receive policies, pause state, recipient validity) are enforced automatically by the TIP-20 transfer. ## 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 emitted by the settlement will show `from = StablecoinDEX, to = recipient`, which accurately reflects the on-chain token movement. +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 @@ -178,8 +121,8 @@ swapExactAmountInTo: 2. Fill orders (identical to swapExactAmountIn) 3. Pull input tokens from caller: _decrementBalanceOrTransferFrom(msg.sender, tokenIn, amountIn) 4. Settle output: - a. If recipient == DEX: balances[msg.sender][tokenOut] += amountOut - b. Else: enforce policies (caller as sender), then systemTransfer to recipient + a. If recipient == DEX: check pause + policies, then balances[msg.sender][tokenOut] += amountOut + b. Else: ITIP20(tokenOut).transfer(recipient, amountOut) ``` The existing `swapExactAmountIn` and `swapExactAmountOut` remain unchanged — they are not refactored to delegate to the new functions, preserving identical gas costs and behavior for existing callers. @@ -190,19 +133,13 @@ The existing `swapExactAmountIn` and `swapExactAmountOut` remain unchanged — t 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. **Caller-as-sender policy check**: On the output transfer, TIP-403 sender authorization MUST be checked against `msg.sender`, not the DEX address. +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. Pause and policy checks MUST still be enforced. -3. **Recipient policy check**: TIP-403 recipient authorization and TIP-1025 address-level receive policies MUST be checked against the `recipient` address. +3. **No zero-address recipient**: `swapExactAmountInTo` and `swapExactAmountOutTo` MUST revert with `InvalidRecipient()` when `recipient == address(0)`. -4. **DEX-balance credit for self-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 treating `msg.sender` as both sender and recipient. +4. **Existing function preservation**: `swapExactAmountIn` and `swapExactAmountOut` MUST behave identically to before this TIP. Their gas cost, policy checks, and event emission MUST NOT change. -5. **No zero-address recipient**: `swapExactAmountInTo` and `swapExactAmountOutTo` MUST revert with `InvalidRecipient()` when `recipient == address(0)`. - -6. **Existing function preservation**: `swapExactAmountIn` and `swapExactAmountOut` MUST behave identically to before this TIP. Their gas cost, policy checks (DEX as TIP-20 msg.sender), and event emission MUST NOT change. - -7. **Taker identity**: The `OrderFilled` event's `taker` field MUST be `msg.sender` regardless of the `recipient` parameter. - -8. **No tokenIn-recipient checks**: `tokenIn` policy checks apply only to the caller → DEX transfer. The `recipient` parameter MUST NOT be checked against `tokenIn`'s policy. +5. **Taker identity**: The `OrderFilled` event's `taker` field MUST be `msg.sender` regardless of the `recipient` parameter. ## Test Cases @@ -210,36 +147,22 @@ The existing `swapExactAmountIn` and `swapExactAmountOut` remain unchanged — t 2. **Swap-and-send exact out**: `swapExactAmountOutTo` sends exact output to recipient. Verify `amountIn <= maxAmountIn`. -3. **Self-recipient differs from legacy**: `swapExactAmountInTo(..., msg.sender)` produces the same economic result as `swapExactAmountIn(...)`, but under a TIP-1015 compound policy where the DEX is an authorized sender but `msg.sender` is not, the legacy function succeeds and the `...To` function reverts. +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. **DEX-recipient checks caller as sender+recipient**: `swapExactAmountInTo(..., DEX_ADDRESS)` reverts if `msg.sender` is blocked by `tokenOut`'s sender or recipient policy, even though no TIP-20 transfer occurs. - -6. **Zero-address reverts**: `swapExactAmountInTo(..., address(0))` reverts with `InvalidRecipient()`. - -7. **Invalid recipient reverts**: `swapExactAmountInTo(..., invalidPrecompile)` reverts with `InvalidRecipient()`. - -8. **Policy: blocked caller**: If `msg.sender` is blocked by `tokenOut`'s sender policy, the swap-and-send reverts even though the recipient is authorized. - -9. **Policy: blocked recipient**: If `recipient` is blocked by `tokenOut`'s recipient policy, the swap-and-send reverts. - -10. **TIP-1025 address-level receive**: If the recipient has an address-level receive policy that does not include `msg.sender`, the swap-and-send reverts. - -11. **TIP-1025 caller treated as sender**: Recipient whitelists `msg.sender` (not the DEX) in their address-level receive policy. The swap-and-send succeeds. - -12. **Compound policy (TIP-1015)**: Token has a compound policy with different sender/recipient sub-policies. Verify `swapExactAmountInTo` checks the sender sub-policy against `msg.sender` and the recipient sub-policy against `recipient`. +5. **Zero-address reverts**: `swapExactAmountInTo(..., address(0))` reverts with `InvalidRecipient()`. -13. **Multi-hop swap-and-send**: A swap that routes through an intermediate pair delivers the final output to the recipient. +6. **Policy: blocked recipient**: If `recipient` is blocked by `tokenOut`'s recipient policy, the swap-and-send reverts. -14. **Paused token reverts**: If `tokenOut` is paused, `swapExactAmountInTo` reverts during settlement. +7. **DEX frozen as sender**: If `tokenOut`'s policy blacklists the DEX as a sender, both `swapExactAmountInTo` (external recipient) and `swapExactAmountInTo` (DEX-balance credit) revert. -15. **DEX frozen as sender**: If `tokenOut`'s policy blacklists the DEX as a sender, both `swapExactAmountInTo` (external recipient) and `swapExactAmountInTo` (DEX-balance credit) revert. +8. **DEX-recipient + blocked caller**: `swapExactAmountInTo(..., DEX_ADDRESS)` reverts if `msg.sender` is blocked by `tokenOut`'s recipient policy. -16. **DEX-recipient + TIP-1025 deny**: Caller has address-level receive policy that does not authorize the DEX as sender. `swapExactAmountInTo(..., DEX_ADDRESS)` reverts. +9. **DEX-recipient + paused tokenOut**: `swapExactAmountInTo(..., DEX_ADDRESS)` reverts if `tokenOut` is paused. -17. **DEX-recipient + paused tokenOut**: `swapExactAmountInTo(..., DEX_ADDRESS)` reverts if `tokenOut` is paused. +10. **Multi-hop swap-and-send**: A swap that routes through an intermediate pair delivers the final output to the recipient. -18. **Router contract semantics**: A router contract calls `swapExactAmountInTo`. Recipient has TIP-1025 whitelist that includes the end user but not the router. Swap reverts — policies see `msg.sender` (the router), not the end user. +11. **Paused token reverts**: If `tokenOut` is paused, `swapExactAmountInTo` reverts during settlement (enforced by TIP-20 `transfer`). -19. **Existing functions unchanged**: `swapExactAmountIn` and `swapExactAmountOut` continue to work identically (gas, events, policy checks) before and after this TIP. +12. **Existing functions unchanged**: `swapExactAmountIn` and `swapExactAmountOut` continue to work identically (gas, events, policy checks) before and after this TIP. From edd48d3b6f4c1df61c4e07737c17cdaa20ff05b7 Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:26:35 +0000 Subject: [PATCH 07/16] docs(tip-1027): address oracle review feedback - Explicitly scope out TIP-1025 from DEX-credit path - Add atomicity requirement (settlement failure reverts entire swap) - Narrow recipient==msg.sender equivalence claim - Soften gas-cost invariant to semantics-only - Add settlement-only-change invariant Co-Authored-By: Daniel Robinson <1187252+danrobinson@users.noreply.github.com> --- tips/tip-1027.md | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/tips/tip-1027.md b/tips/tip-1027.md index 0c08e60417..e0626d1687 100644 --- a/tips/tip-1027.md +++ b/tips/tip-1027.md @@ -70,7 +70,7 @@ The existing `swapExactAmountIn` and `swapExactAmountOut` functions are unchange ## Recipient Validation - `recipient == address(0)` MUST revert with `InvalidRecipient()`. -- `recipient == msg.sender` is allowed and behaves identically to the existing no-recipient functions. +- `recipient == msg.sender` is allowed and follows the same `ITIP20.transfer(msg.sender, amountOut)` settlement path as the existing no-recipient functions. ### Special Case: `recipient == address(StablecoinDEX)` @@ -95,6 +95,8 @@ if (!TIP403_REGISTRY.isAuthorizedRecipient(policyId, msg.sender)) { 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-1025 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)` — 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-1025 address-level receive policies, pause state, recipient validity) are enforced automatically by the TIP-20 transfer. @@ -111,21 +113,18 @@ One new error is added: error InvalidRecipient(); ``` +## 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 -The new functions share the existing swap routing and order-fill logic. The only difference is the final settlement step: +`swapExactAmountInTo` and `swapExactAmountOutTo` MUST execute the same routing, pricing, fill, and input-debit logic as the corresponding existing swap function. The only semantic difference is the final output settlement target: -``` -swapExactAmountInTo: - 1. findTradePath(tokenIn, tokenOut) - 2. Fill orders (identical to swapExactAmountIn) - 3. Pull input tokens from caller: _decrementBalanceOrTransferFrom(msg.sender, tokenIn, amountIn) - 4. Settle output: - a. If recipient == DEX: check pause + policies, then balances[msg.sender][tokenOut] += amountOut - b. Else: ITIP20(tokenOut).transfer(recipient, amountOut) -``` +- If `recipient == address(StablecoinDEX)`: check pause + policies, then credit `balances[msg.sender][tokenOut]` +- Otherwise: `ITIP20(tokenOut).transfer(recipient, amountOut)` -The existing `swapExactAmountIn` and `swapExactAmountOut` remain unchanged — they are not refactored to delegate to the new functions, preserving identical gas costs and behavior for existing callers. +The existing `swapExactAmountIn` and `swapExactAmountOut` remain unchanged — they are not refactored to delegate to the new functions, preserving identical behavior for existing callers. --- @@ -137,9 +136,11 @@ The existing `swapExactAmountIn` and `swapExactAmountOut` remain unchanged — t 3. **No zero-address recipient**: `swapExactAmountInTo` and `swapExactAmountOutTo` MUST revert with `InvalidRecipient()` when `recipient == address(0)`. -4. **Existing function preservation**: `swapExactAmountIn` and `swapExactAmountOut` MUST behave identically to before this TIP. Their gas cost, policy checks, and event emission MUST NOT change. +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. -5. **Taker identity**: The `OrderFilled` event's `taker` field MUST be `msg.sender` regardless of the `recipient` parameter. +6. **Taker identity**: The `OrderFilled` event's `taker` field MUST be `msg.sender` regardless of the `recipient` parameter. ## Test Cases From be05e63d79b82cc324c7f57f1d08b5df3d90e59a Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Tue, 17 Mar 2026 06:26:29 +0000 Subject: [PATCH 08/16] docs(tip-1027): add WithMemo variants, memo placement, caller receive policy, TIP-1028 tests Co-authored-by: Daniel Robinson <1187252+danrobinson@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019cf74a-a0bd-70b8-9b3d-0a67f027b304 --- tips/tip-1027.md | 130 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 113 insertions(+), 17 deletions(-) diff --git a/tips/tip-1027.md b/tips/tip-1027.md index e0626d1687..589367f037 100644 --- a/tips/tip-1027.md +++ b/tips/tip-1027.md @@ -4,7 +4,7 @@ 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-1025 +related: TIP-1015, TIP-1025, TIP-1028 protocolVersion: TBD --- @@ -31,7 +31,7 @@ Common use cases: ## New Functions -Two new swap functions are added to the `IStablecoinDEX` interface: +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 @@ -49,6 +49,23 @@ function swapExactAmountInTo( 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 @@ -63,10 +80,29 @@ function swapExactAmountOutTo( 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 == address(0)` MUST revert with `InvalidRecipient()`. @@ -95,15 +131,39 @@ if (!TIP403_REGISTRY.isAuthorizedRecipient(policyId, msg.sender)) { 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-1025 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. +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)` — 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-1025 address-level receive policies, pause state, recipient validity) are enforced automatically by the TIP-20 transfer. +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. + +### Caller Receive Authorization + +The caller (`msg.sender`) is NOT checked as a recipient of `tokenOut`. The caller never holds the output token — the DEX transfers it directly to the recipient. This is intentional: it allows a caller to pay someone in a token they cannot receive themselves, enabling use cases like a restricted entity facilitating payments in tokens it cannot hold. + +The caller IS checked as: +- **Sender of `tokenIn`**: via `decrement_balance_or_transfer_from`, which calls `ensure_transfer_authorized(msg.sender, DEX)` on the input token. + +The recipient IS checked as: +- **Recipient of `tokenOut`**: via the standard TIP-20 `transfer` / `transferWithMemo` path, which enforces token-level policy (TIP-403/TIP-1015), address-level receive policy, and token set (TIP-1028). + +## Memo Behavior + +### External Recipient + +For `WithMemo` variants with an external recipient, the DEX calls `ITIP20(tokenOut).transferWithMemo(recipient, amountOut, memo)`. This emits the standard TIP-20 `TransferWithMemo` event on the output token with `from = StablecoinDEX, to = recipient`. The memo is attached to the output settlement — the transfer that delivers value to the recipient. + +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 — the TIP-20 transfer from the caller to the DEX for `tokenIn`. The DEX uses `transferFromWithMemo` (or the equivalent internal path) to pull input tokens with the memo. This is because no TIP-20 output transfer occurs on this path, so the memo annotates the caller's deposit action. + +For the non-memo `To` variants with DEX-recipient, no memo is emitted on either leg. ## 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. +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. Attribution of who initiated the swap is available via the `OrderFilled` event's `taker` field. ## Errors @@ -119,10 +179,10 @@ If output settlement fails for any reason (policy denial, paused token, invalid ## Implementation Outline -`swapExactAmountInTo` and `swapExactAmountOutTo` MUST execute the same routing, pricing, fill, and input-debit logic as the corresponding existing swap function. The only semantic difference is the final output settlement target: +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 pause + policies, then credit `balances[msg.sender][tokenOut]` -- Otherwise: `ITIP20(tokenOut).transfer(recipient, amountOut)` +- If `recipient == address(StablecoinDEX)`: check pause + policies, credit `balances[msg.sender][tokenOut]`. For `WithMemo` variants, pull input tokens using `transferFromWithMemo` so the memo is attached to the input leg. +- 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. @@ -134,7 +194,7 @@ The existing `swapExactAmountIn` and `swapExactAmountOut` remain unchanged — t 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. Pause and policy checks MUST still be enforced. -3. **No zero-address recipient**: `swapExactAmountInTo` and `swapExactAmountOutTo` MUST revert with `InvalidRecipient()` when `recipient == address(0)`. +3. **No zero-address recipient**: All four new functions MUST revert with `InvalidRecipient()` when `recipient == address(0)`. 4. **Existing function preservation**: `swapExactAmountIn` and `swapExactAmountOut` MUST behave identically to before this TIP. Their semantics, policy checks, and event emission MUST NOT change. @@ -142,8 +202,18 @@ The existing `swapExactAmountIn` and `swapExactAmountOut` remain unchanged — t 6. **Taker identity**: The `OrderFilled` event's `taker` field MUST be `msg.sender` regardless of the `recipient` parameter. +7. **No caller receive check on output**: The caller MUST NOT be checked as a recipient of `tokenOut`. The caller never holds the output token — the DEX transfers directly to the recipient. This allows callers to facilitate payments in tokens they cannot receive themselves. + +8. **Memo presence**: The `WithMemo` variants MUST emit a `TransferWithMemo` event. The non-memo variants MUST NOT emit a `TransferWithMemo` event. `bytes32(0)` is a valid memo. + +9. **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`). + +10. **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`. @@ -152,18 +222,44 @@ The existing `swapExactAmountIn` and `swapExactAmountOut` remain unchanged — t 4. **DEX-recipient credits balance**: `swapExactAmountInTo(..., DEX_ADDRESS)` credits `balances[msg.sender][tokenOut]` and emits no TIP-20 `Transfer` event for the output. -5. **Zero-address reverts**: `swapExactAmountInTo(..., address(0))` reverts with `InvalidRecipient()`. +5. **Zero-address reverts**: All four new functions revert with `InvalidRecipient()` when `recipient == address(0)`. + +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`. + +9. **Memo with bytes32(0)**: `swapExactAmountInToWithMemo(..., bytes32(0))` emits a `TransferWithMemo` event with `memo = bytes32(0)`. This is a valid memo, not "no memo". + +10. **No memo on non-memo variants**: `swapExactAmountInTo` does NOT emit a `TransferWithMemo` event. + +11. **Memo on DEX-recipient**: `swapExactAmountInToWithMemo(..., DEX_ADDRESS, memo)` attaches the memo to the input transfer (`tokenIn` from caller to DEX) via `transferFromWithMemo`. + +12. **No memo on DEX-recipient non-memo variant**: `swapExactAmountInTo(..., DEX_ADDRESS)` does not emit any `TransferWithMemo` event on either leg. + +### Policy (TIP-403 / TIP-1015) + +13. **Policy: blocked recipient**: If `recipient` is blocked by `tokenOut`'s recipient policy, the swap-and-send reverts. + +14. **DEX frozen as sender**: If `tokenOut`'s policy blacklists the DEX as a sender, both `swapExactAmountInTo` (external recipient) and `swapExactAmountInTo` (DEX-balance credit) revert. + +15. **DEX-recipient + blocked caller**: `swapExactAmountInTo(..., DEX_ADDRESS)` reverts if `msg.sender` is blocked by `tokenOut`'s recipient policy. + +16. **DEX-recipient + paused tokenOut**: `swapExactAmountInTo(..., DEX_ADDRESS)` reverts if `tokenOut` is paused. -6. **Policy: blocked recipient**: If `recipient` is blocked by `tokenOut`'s recipient policy, the swap-and-send reverts. +17. **Paused token reverts**: If `tokenOut` is paused, `swapExactAmountInTo` reverts during settlement (enforced by TIP-20 `transfer`). -7. **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. **Caller not checked as output recipient**: If `msg.sender` is blocked from receiving `tokenOut` (via token-level recipient policy), `swapExactAmountInTo` to an external recipient MUST still succeed — the caller never holds the output token. -8. **DEX-recipient + blocked caller**: `swapExactAmountInTo(..., DEX_ADDRESS)` reverts if `msg.sender` is blocked by `tokenOut`'s recipient policy. +### Address-Level Policies (TIP-1028) -9. **DEX-recipient + paused tokenOut**: `swapExactAmountInTo(..., DEX_ADDRESS)` reverts if `tokenOut` is paused. +19. **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`). -10. **Multi-hop swap-and-send**: A swap that routes through an intermediate pair delivers the final output to the recipient. +20. **Recipient token set**: If `recipient` has a token set that does not include `tokenOut`, the swap-and-send reverts. -11. **Paused token reverts**: If `tokenOut` is paused, `swapExactAmountInTo` reverts during settlement (enforced by TIP-20 `transfer`). +21. **Caller address-level policy irrelevant**: If `msg.sender` has address-level receive controls that would reject `tokenOut`, the swap-and-send to an external recipient MUST still succeed — the caller is not the receiver. -12. **Existing functions unchanged**: `swapExactAmountIn` and `swapExactAmountOut` continue to work identically (gas, events, policy checks) before and after this TIP. +22. **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`. From 910b69e34bd1c367373c11696de125611ce68d99 Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Tue, 17 Mar 2026 06:28:09 +0000 Subject: [PATCH 09/16] docs(tip-1027): remove caller receive authorization section and related tests Co-authored-by: Daniel Robinson <1187252+danrobinson@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019cf74a-a0bd-70b8-9b3d-0a67f027b304 --- tips/tip-1027.md | 28 ++++++---------------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/tips/tip-1027.md b/tips/tip-1027.md index 589367f037..1d39ee6c4b 100644 --- a/tips/tip-1027.md +++ b/tips/tip-1027.md @@ -137,16 +137,6 @@ TIP-1028 address-level receive policies do not apply to the DEX-credit path, sin 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. -### Caller Receive Authorization - -The caller (`msg.sender`) is NOT checked as a recipient of `tokenOut`. The caller never holds the output token — the DEX transfers it directly to the recipient. This is intentional: it allows a caller to pay someone in a token they cannot receive themselves, enabling use cases like a restricted entity facilitating payments in tokens it cannot hold. - -The caller IS checked as: -- **Sender of `tokenIn`**: via `decrement_balance_or_transfer_from`, which calls `ensure_transfer_authorized(msg.sender, DEX)` on the input token. - -The recipient IS checked as: -- **Recipient of `tokenOut`**: via the standard TIP-20 `transfer` / `transferWithMemo` path, which enforces token-level policy (TIP-403/TIP-1015), address-level receive policy, and token set (TIP-1028). - ## Memo Behavior ### External Recipient @@ -202,13 +192,11 @@ The existing `swapExactAmountIn` and `swapExactAmountOut` remain unchanged — t 6. **Taker identity**: The `OrderFilled` event's `taker` field MUST be `msg.sender` regardless of the `recipient` parameter. -7. **No caller receive check on output**: The caller MUST NOT be checked as a recipient of `tokenOut`. The caller never holds the output token — the DEX transfers directly to the recipient. This allows callers to facilitate payments in tokens they cannot receive themselves. +7. **Memo presence**: The `WithMemo` variants MUST emit a `TransferWithMemo` event. The non-memo variants MUST NOT emit a `TransferWithMemo` event. `bytes32(0)` is a valid memo. -8. **Memo presence**: The `WithMemo` variants MUST emit a `TransferWithMemo` event. 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 — external recipient**: For `WithMemo` variants with an external recipient, the memo MUST be attached to the output settlement (the `transferWithMemo` call on `tokenOut`). - -10. **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. +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 @@ -252,14 +240,10 @@ The existing `swapExactAmountIn` and `swapExactAmountOut` remain unchanged — t 17. **Paused token reverts**: If `tokenOut` is paused, `swapExactAmountInTo` reverts during settlement (enforced by TIP-20 `transfer`). -18. **Caller not checked as output recipient**: If `msg.sender` is blocked from receiving `tokenOut` (via token-level recipient policy), `swapExactAmountInTo` to an external recipient MUST still succeed — the caller never holds the output token. - ### Address-Level Policies (TIP-1028) -19. **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`). - -20. **Recipient token set**: If `recipient` has a token set that does not include `tokenOut`, the swap-and-send reverts. +18. **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`). -21. **Caller address-level policy irrelevant**: If `msg.sender` has address-level receive controls that would reject `tokenOut`, the swap-and-send to an external recipient MUST still succeed — the caller is not the receiver. +19. **Recipient token set**: If `recipient` has a token set that does not include `tokenOut`, the swap-and-send reverts. -22. **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`. +20. **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`. From 2abc55069f9b84d3f85b5a18582333dab59da023 Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Tue, 17 Mar 2026 06:31:21 +0000 Subject: [PATCH 10/16] docs(tip-1027): drop stale TIP-1025 reference from frontmatter Co-authored-by: Daniel Robinson <1187252+danrobinson@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019cf74a-a0bd-70b8-9b3d-0a67f027b304 --- tips/tip-1027.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tips/tip-1027.md b/tips/tip-1027.md index 1d39ee6c4b..95f1e0107e 100644 --- a/tips/tip-1027.md +++ b/tips/tip-1027.md @@ -4,7 +4,7 @@ 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-1025, TIP-1028 +related: TIP-1015, TIP-1028 protocolVersion: TBD --- From 768afcde26d79680f5bbe528f001915c1aca9f9d Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Tue, 17 Mar 2026 06:51:49 +0000 Subject: [PATCH 11/16] docs(tip-1027): drop pause check from DEX-recipient path Consistent with multi-hop swaps and order fills, which don't pause-check intermediate/internal balance credits. Pause is enforced on withdraw. Co-authored-by: Daniel Robinson <1187252+danrobinson@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019cf74a-a0bd-70b8-9b3d-0a67f027b304 --- tips/tip-1027.md | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/tips/tip-1027.md b/tips/tip-1027.md index 95f1e0107e..81d33d8f87 100644 --- a/tips/tip-1027.md +++ b/tips/tip-1027.md @@ -112,12 +112,9 @@ The `WithMemo` variants are separate functions rather than overloads with a defa 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 TIP-20 transfer occurs on this path, the DEX manually enforces equivalent checks: +Since no TIP-20 transfer occurs on this path, the DEX manually enforces policy checks: ```solidity -// Paused tokens cannot be acquired, even into DEX balance -if (ITIP20(tokenOut).paused()) revert ITIP20.ContractPaused(); - uint64 policyId = ITIP20(tokenOut).transferPolicyId(); // DEX must be authorized sender (issuer can freeze all DEX trading) if (!TIP403_REGISTRY.isAuthorizedSender(policyId, address(this))) { @@ -129,6 +126,8 @@ if (!TIP403_REGISTRY.isAuthorizedRecipient(policyId, msg.sender)) { } ``` +Pause state is NOT checked on this path. This is consistent with multi-hop swaps, where intermediate tokens are transitory and not pause-checked, and with order fills, where makers' internal balances are credited without checking pause. A paused token can still accumulate in internal DEX balance — the pause is enforced when the caller later calls `withdraw` (which goes through `TIP20.transfer`). + 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. @@ -236,14 +235,12 @@ The existing `swapExactAmountIn` and `swapExactAmountOut` remain unchanged — t 15. **DEX-recipient + blocked caller**: `swapExactAmountInTo(..., DEX_ADDRESS)` reverts if `msg.sender` is blocked by `tokenOut`'s recipient policy. -16. **DEX-recipient + paused tokenOut**: `swapExactAmountInTo(..., DEX_ADDRESS)` reverts if `tokenOut` is paused. - -17. **Paused token reverts**: If `tokenOut` is paused, `swapExactAmountInTo` reverts during settlement (enforced by TIP-20 `transfer`). +16. **Paused token reverts**: If `tokenOut` is paused, `swapExactAmountInTo` reverts during settlement (enforced by TIP-20 `transfer`). ### Address-Level Policies (TIP-1028) -18. **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`). +17. **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`). -19. **Recipient token set**: If `recipient` has a token set that does not include `tokenOut`, the swap-and-send reverts. +18. **Recipient token set**: If `recipient` has a token set that does not include `tokenOut`, the swap-and-send reverts. -20. **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`. +19. **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`. From ccb5f1dc53cd6dfccb3ee9fd67ccaf1998438ef8 Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Tue, 17 Mar 2026 06:55:12 +0000 Subject: [PATCH 12/16] docs(tip-1027): specify TransferWithMemo event ordering in test cases Co-authored-by: Daniel Robinson <1187252+danrobinson@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019cf74a-a0bd-70b8-9b3d-0a67f027b304 --- tips/tip-1027.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tips/tip-1027.md b/tips/tip-1027.md index 81d33d8f87..554e6dc15a 100644 --- a/tips/tip-1027.md +++ b/tips/tip-1027.md @@ -217,13 +217,13 @@ The existing `swapExactAmountIn` and `swapExactAmountOut` remain unchanged — t ### Memo -8. **Memo on external recipient**: `swapExactAmountInToWithMemo` emits a `TransferWithMemo` event on `tokenOut` with the provided memo, `from = StablecoinDEX`, `to = recipient`. +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)`. This is a valid memo, not "no memo". +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` does NOT emit a `TransferWithMemo` event. -11. **Memo on DEX-recipient**: `swapExactAmountInToWithMemo(..., DEX_ADDRESS, memo)` attaches the memo to the input transfer (`tokenIn` from caller to DEX) via `transferFromWithMemo`. +11. **Memo on DEX-recipient**: `swapExactAmountInToWithMemo(..., DEX_ADDRESS, memo)` attaches the memo to the input transfer (`tokenIn` from caller to DEX). The `TransferWithMemo` event MUST be emitted immediately after the corresponding input `Transfer` event. 12. **No memo on DEX-recipient non-memo variant**: `swapExactAmountInTo(..., DEX_ADDRESS)` does not emit any `TransferWithMemo` event on either leg. From 1c7321f93bb6e278642bbd5376ea7dd20f8bca47 Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Tue, 17 Mar 2026 07:00:21 +0000 Subject: [PATCH 13/16] docs(tip-1027): address review comments - Use Dan's suggested wording for DEX-recipient intro - Specify Transfer + TransferWithMemo event pairs and ordering - Remove implementation details (function names) from memo section - Remove redundant sentence from Event Emission - Trim motivation to payments + DEX balance deposits - Drop custom InvalidRecipient error, reuse TIP-20's - Fix invariant 2 (pause check was already removed) Co-authored-by: Daniel Robinson <1187252+danrobinson@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019cf74a-a0bd-70b8-9b3d-0a67f027b304 --- tips/tip-1027.md | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/tips/tip-1027.md b/tips/tip-1027.md index 554e6dc15a..00107895e8 100644 --- a/tips/tip-1027.md +++ b/tips/tip-1027.md @@ -21,8 +21,6 @@ Today, swapping and sending to another address requires two transactions: a swap Common use cases: - **Payments**: Swap token A for token B and pay a merchant in one call -- **Gifting / payroll**: Swap and distribute to multiple recipients (via a batch contract) -- **Protocol integrations**: Contracts that swap on behalf of users and route output to the correct destination - **DEX balance deposits**: Swap and credit the caller's internal DEX balance for immediate limit order placement, avoiding a round-trip transfer --- @@ -105,14 +103,14 @@ The `WithMemo` variants are separate functions rather than overloads with a defa ## Recipient Validation -- `recipient == address(0)` MUST revert with `InvalidRecipient()`. +- `recipient == address(0)` MUST revert with the TIP-20 `InvalidRecipient()` error. - `recipient == msg.sender` is allowed and follows the same `ITIP20.transfer(msg.sender, amountOut)` 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 TIP-20 transfer occurs on this path, the DEX manually enforces policy checks: +Since no transfer of the TIP-20 output token occurs on this path, the DEX manually enforces policy checks: ```solidity uint64 policyId = ITIP20(tokenOut).transferPolicyId(); @@ -140,27 +138,23 @@ For external recipients, the DEX calls `ITIP20(tokenOut).transfer(recipient, amo ### External Recipient -For `WithMemo` variants with an external recipient, the DEX calls `ITIP20(tokenOut).transferWithMemo(recipient, amountOut, memo)`. This emits the standard TIP-20 `TransferWithMemo` event on the output token with `from = StablecoinDEX, to = recipient`. The memo is attached to the output settlement — the transfer that delivers value to the 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 — the TIP-20 transfer from the caller to the DEX for `tokenIn`. The DEX uses `transferFromWithMemo` (or the equivalent internal path) to pull input tokens with the memo. This is because no TIP-20 output transfer occurs on this path, so the memo annotates the caller's deposit action. +When `recipient == address(StablecoinDEX)` and a memo is provided (via the `WithMemo` variants), the memo is attached to the **input** transfer instead. The input transfer emits both a `Transfer` event and a `TransferWithMemo` event (with the `TransferWithMemo` immediately after), since no TIP-20 output transfer occurs on this path. For the non-memo `To` variants with DEX-recipient, no memo is emitted on either leg. ## 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. Attribution of who initiated the swap is available via the `OrderFilled` event's `taker` field. +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 -One new error is added: - -```solidity -error InvalidRecipient(); -``` +No new errors are added. When `recipient == address(0)`, the function reverts with the existing TIP-20 `InvalidRecipient()` error. ## Atomicity @@ -181,9 +175,9 @@ The existing `swapExactAmountIn` and `swapExactAmountOut` remain unchanged — t 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. Pause and policy checks MUST still be enforced. +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. **No zero-address recipient**: All four new functions MUST revert with `InvalidRecipient()` when `recipient == address(0)`. +3. **No zero-address recipient**: All four new functions MUST revert with the TIP-20 `InvalidRecipient()` error when `recipient == address(0)`. 4. **Existing function preservation**: `swapExactAmountIn` and `swapExactAmountOut` MUST behave identically to before this TIP. Their semantics, policy checks, and event emission MUST NOT change. @@ -209,7 +203,7 @@ The existing `swapExactAmountIn` and `swapExactAmountOut` remain unchanged — t 4. **DEX-recipient credits balance**: `swapExactAmountInTo(..., DEX_ADDRESS)` credits `balances[msg.sender][tokenOut]` and emits no TIP-20 `Transfer` event for the output. -5. **Zero-address reverts**: All four new functions revert with `InvalidRecipient()` when `recipient == address(0)`. +5. **Zero-address reverts**: All four new functions revert with the TIP-20 `InvalidRecipient()` error when `recipient == address(0)`. 6. **Multi-hop swap-and-send**: A swap that routes through an intermediate pair delivers the final output to the recipient. From 107b6ed8384d67668011544dd45c7e6afd46014b Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Tue, 17 Mar 2026 07:14:28 +0000 Subject: [PATCH 14/16] docs(tip-1027): fix oracle-identified issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix contradiction: implementation outline said 'check pause' for DEX-recipient but detailed spec says no pause check - Handle WithMemo + DEX-recipient when swap is funded from internal balance (no TIP-20 transfer → no TransferWithMemo emitted) - Require upfront recipient validation before any fills/mutations - Mirror TIP-20 check_recipient: reject address(0) and TIP-20 prefixes - Add test cases for swapExactAmountOutTo{WithMemo} variants - Add test for paused token succeeding on DEX-recipient path - Add test for DEX-recipient memo with full internal funding - Split pause test into external-recipient (reverts) vs DEX-recipient (succeeds) Co-authored-by: Daniel Robinson <1187252+danrobinson@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019cf74a-a0bd-70b8-9b3d-0a67f027b304 --- tips/tip-1027.md | 52 +++++++++++++++++++++++++++++------------------- 1 file changed, 32 insertions(+), 20 deletions(-) diff --git a/tips/tip-1027.md b/tips/tip-1027.md index 00107895e8..0511d351d1 100644 --- a/tips/tip-1027.md +++ b/tips/tip-1027.md @@ -103,8 +103,10 @@ The `WithMemo` variants are separate functions rather than overloads with a defa ## Recipient Validation -- `recipient == address(0)` MUST revert with the TIP-20 `InvalidRecipient()` error. -- `recipient == msg.sender` is allowed and follows the same `ITIP20.transfer(msg.sender, amountOut)` settlement path as the existing no-recipient functions. +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)` @@ -144,9 +146,11 @@ For the non-memo variants, the DEX calls `ITIP20(tokenOut).transfer(recipient, a ### 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. The input transfer emits both a `Transfer` event and a `TransferWithMemo` event (with the `TransferWithMemo` immediately after), since no TIP-20 output transfer occurs on this path. +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, no TIP-20 input transfer occurs and no `TransferWithMemo` event is emitted. -For the non-memo `To` variants with DEX-recipient, no memo is emitted on either leg. +For the non-memo `To` variants with DEX-recipient, no `TransferWithMemo` is emitted regardless of funding source. ## Event Emission @@ -154,7 +158,7 @@ The `OrderFilled` event's `taker` field remains `msg.sender` — the taker is th ## Errors -No new errors are added. When `recipient == address(0)`, the function reverts with the existing TIP-20 `InvalidRecipient()` error. +No new errors are added. Invalid recipients (`address(0)` or TIP-20 token addresses) revert with the existing TIP-20 `InvalidRecipient()` error. ## Atomicity @@ -164,7 +168,7 @@ If output settlement fails for any reason (policy denial, paused token, invalid 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 pause + policies, credit `balances[msg.sender][tokenOut]`. For `WithMemo` variants, pull input tokens using `transferFromWithMemo` so the memo is attached to the input leg. +- 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, no `TransferWithMemo` event is emitted. - 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. @@ -177,7 +181,7 @@ The existing `swapExactAmountIn` and `swapExactAmountOut` remain unchanged — t 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. **No zero-address recipient**: All four new functions MUST revert with the TIP-20 `InvalidRecipient()` error when `recipient == address(0)`. +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. @@ -185,7 +189,7 @@ The existing `swapExactAmountIn` and `swapExactAmountOut` remain unchanged — t 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 emit a `TransferWithMemo` event. The non-memo variants MUST NOT emit a `TransferWithMemo` event. `bytes32(0)` is a valid memo. +7. **Memo presence**: The `WithMemo` variants MUST emit a `TransferWithMemo` event when a TIP-20 transfer occurs on the memo-bearing leg. When `recipient == address(StablecoinDEX)` and the swap is funded entirely from internal DEX balance, no `TransferWithMemo` is emitted (there is no TIP-20 transfer to attach it to). 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`). @@ -203,7 +207,7 @@ The existing `swapExactAmountIn` and `swapExactAmountOut` remain unchanged — t 4. **DEX-recipient credits balance**: `swapExactAmountInTo(..., DEX_ADDRESS)` credits `balances[msg.sender][tokenOut]` and emits no TIP-20 `Transfer` event for the output. -5. **Zero-address reverts**: All four new functions revert with the TIP-20 `InvalidRecipient()` error when `recipient == address(0)`. +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. @@ -211,30 +215,38 @@ The existing `swapExactAmountIn` and `swapExactAmountOut` remain unchanged — t ### 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. +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` does NOT emit a `TransferWithMemo` event. +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. -11. **Memo on DEX-recipient**: `swapExactAmountInToWithMemo(..., DEX_ADDRESS, memo)` attaches the memo to the input transfer (`tokenIn` from caller to DEX). 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 does NOT emit a `TransferWithMemo` event (no TIP-20 transfer occurs). -12. **No memo on DEX-recipient non-memo variant**: `swapExactAmountInTo(..., DEX_ADDRESS)` does not emit any `TransferWithMemo` event on either leg. +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) -13. **Policy: blocked recipient**: If `recipient` is blocked by `tokenOut`'s recipient policy, the swap-and-send reverts. +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. -14. **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. -15. **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`). -16. **Paused token reverts**: If `tokenOut` is paused, `swapExactAmountInTo` reverts during settlement (enforced by TIP-20 `transfer`). +20. **Paused token succeeds (DEX-recipient)**: If `tokenOut` is paused, `swapExactAmountInTo(..., DEX_ADDRESS)` succeeds and credits the caller's internal balance. A subsequent `withdraw` reverts. ### Address-Level Policies (TIP-1028) -17. **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`). +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`). -18. **Recipient token set**: If `recipient` has a token set that does not include `tokenOut`, the swap-and-send reverts. +22. **Recipient token set**: If `recipient` has a token set that does not include `tokenOut`, the swap-and-send reverts. -19. **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`. +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`. From 41a543a58ce8fca57bfe273d67cafb79a25ef1d8 Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Tue, 17 Mar 2026 07:17:49 +0000 Subject: [PATCH 15/16] docs(tip-1027): emit zero-value transfer with memo for fully internal-funded DEX-recipient swaps Ensures WithMemo variants always emit a TransferWithMemo event, even when the swap is fully funded from internal DEX balance. Co-authored-by: Daniel Robinson <1187252+danrobinson@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019cf74a-a0bd-70b8-9b3d-0a67f027b304 --- tips/tip-1027.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tips/tip-1027.md b/tips/tip-1027.md index 0511d351d1..6940c238a1 100644 --- a/tips/tip-1027.md +++ b/tips/tip-1027.md @@ -148,7 +148,7 @@ For the non-memo variants, the DEX calls `ITIP20(tokenOut).transfer(recipient, a 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, no TIP-20 input transfer occurs and no `TransferWithMemo` event is emitted. +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. @@ -168,7 +168,7 @@ If output settlement fails for any reason (policy denial, paused token, invalid 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, no `TransferWithMemo` event is emitted. +- 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. @@ -189,7 +189,7 @@ The existing `swapExactAmountIn` and `swapExactAmountOut` remain unchanged — t 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 emit a `TransferWithMemo` event when a TIP-20 transfer occurs on the memo-bearing leg. When `recipient == address(StablecoinDEX)` and the swap is funded entirely from internal DEX balance, no `TransferWithMemo` is emitted (there is no TIP-20 transfer to attach it to). The non-memo variants MUST NOT emit a `TransferWithMemo` event. `bytes32(0)` is a valid memo. +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`). @@ -223,7 +223,7 @@ The existing `swapExactAmountIn` and `swapExactAmountOut` remain unchanged — t 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 does NOT emit a `TransferWithMemo` event (no TIP-20 transfer occurs). +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. From a1f615cc1ca06481fbfec631a93c52d61a06d05d Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Tue, 7 Apr 2026 23:07:40 +0000 Subject: [PATCH 16/16] docs(tip-1027): add pause check on DEX-recipient path, set protocolVersion T5 Co-Authored-By: Daniel Robinson <1187252+danrobinson@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019d68f2-72e9-746f-aa40-1ab00dc19763 --- tips/tip-1027.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tips/tip-1027.md b/tips/tip-1027.md index 6940c238a1..08b5242ce4 100644 --- a/tips/tip-1027.md +++ b/tips/tip-1027.md @@ -5,7 +5,7 @@ description: Adds an optional recipient parameter to StablecoinDEX swap function authors: Dan Robinson status: Draft related: TIP-1015, TIP-1028 -protocolVersion: TBD +protocolVersion: T5 --- # TIP-1027: StablecoinDEX Swap-and-Send @@ -112,7 +112,7 @@ Recipient validation MUST occur **before** routing, order fills, or any state mu 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 checks: +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(); @@ -124,9 +124,13 @@ if (!TIP403_REGISTRY.isAuthorizedSender(policyId, address(this))) { if (!TIP403_REGISTRY.isAuthorizedRecipient(policyId, msg.sender)) { revert ITIP20.PolicyForbids(); } +// Output token must not be paused +if (ITIP20(tokenOut).paused()) { + revert ITIP20.TokenPaused(); +} ``` -Pause state is NOT checked on this path. This is consistent with multi-hop swaps, where intermediate tokens are transitory and not pause-checked, and with order fills, where makers' internal balances are credited without checking pause. A paused token can still accumulate in internal DEX balance — the pause is enforced when the caller later calls `withdraw` (which goes through `TIP20.transfer`). +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. @@ -241,7 +245,7 @@ The existing `swapExactAmountIn` and `swapExactAmountOut` remain unchanged — t 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 succeeds (DEX-recipient)**: If `tokenOut` is paused, `swapExactAmountInTo(..., DEX_ADDRESS)` succeeds and credits the caller's internal balance. A subsequent `withdraw` reverts. +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)