Skip to content

docs(tip-1027): StablecoinDEX swap-and-send#3016

Open
decofe wants to merge 16 commits intomainfrom
tip/1027
Open

docs(tip-1027): StablecoinDEX swap-and-send#3016
decofe wants to merge 16 commits intomainfrom
tip/1027

Conversation

@decofe
Copy link
Copy Markdown
Member

@decofe decofe commented Mar 6, 2026

Adds TIP-1027: optional recipient parameter on swapExactAmountIn / swapExactAmountOut, so callers can swap and send output to a different address in one call.

Key design decisions:

  • New overloaded functions (swapExactAmountInTo, swapExactAmountOutTo) — existing functions unchanged
  • Caller is the logical sender for TIP-403 / TIP-1015 / TIP-1025 policy checks on the output transfer
  • recipient == DEX credits the caller's internal DEX balance (no TIP-20 transfer)
  • recipient == address(0) reverts

Compatible with TIP-1015 (compound policies) and TIP-1028 (address-level receive policies).

Prompted by: Dan

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>
decofe and others added 6 commits March 7, 2026 00:20
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>
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>
…fy 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>
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>
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>
- 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>
@github-actions
Copy link
Copy Markdown
Contributor

This PR has been marked stale due to 7 days of inactivity.

@github-actions github-actions Bot added the stale label Mar 17, 2026
decofe and others added 2 commits March 17, 2026 06:26
… 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
…ed 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
Comment thread tips/tip-1027.md Outdated

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

Choose a reason for hiding this comment

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

Suggested change
Since no TIP-20 transfer occurs on this path, the DEX manually enforces equivalent checks:
Since no transfer of the TIP-20 output token occurs on this path, the DEX manually enforces equivalent checks:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Applied — updated to "Since no transfer of the TIP-20 output token occurs on this path".

Co-authored-by: Daniel Robinson <1187252+danrobinson@users.noreply.github.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019cf74a-a0bd-70b8-9b3d-0a67f027b304
Comment thread tips/tip-1027.md Outdated

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

Choose a reason for hiding this comment

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

Should mention that, as usual, the TransferWithMemo event happens immediately after the Transfer event

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done — the memo section now specifies that both a Transfer event and a TransferWithMemo event are emitted, with TransferWithMemo immediately after Transfer. Test cases also updated.

Comment thread tips/tip-1027.md Outdated

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

Choose a reason for hiding this comment

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

Should mention the same about there being both a Transfer event and a TransferWithMemo event. Doesn't need to specify what function the DEX calls for this.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Updated — now describes both events (Transfer + TransferWithMemo) and ordering without specifying which DEX function call produces them.

Comment thread tips/tip-1027.md Outdated

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

Choose a reason for hiding this comment

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

Can remove the final sentence of this paragraph

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Removed.

decofe and others added 2 commits March 17, 2026 06:51
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
Co-authored-by: Daniel Robinson <1187252+danrobinson@users.noreply.github.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019cf74a-a0bd-70b8-9b3d-0a67f027b304
Comment thread tips/tip-1027.md
Comment thread tips/tip-1027.md Outdated

## Errors

One new error is added:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why does this add an event? Should it be just an error thrown by TIP-20, same as any time someone tries to transfer to address(0)?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good point — dropped the custom InvalidRecipient error. The DEX validates recipient != address(0) upfront and reverts with TIP-20's existing InvalidRecipient() error. No new error type needed.

decofe and others added 3 commits March 17, 2026 07:00
- 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
- 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
…-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
Comment thread tips/tip-1027.md

## New Functions

Four new swap functions are added to the `IStablecoinDEX` interface — two base functions and two `WithMemo` variants:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

+1, we should update this IMO.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

An alternative would just be overloading the function with 1-2 arguments and not changing the name. How do we feel about overloading?

Comment thread tips/tip-1027.md Outdated

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

Choose a reason for hiding this comment

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

If no transfer occurs, why do we need to enforce 403? We don't check 403 for other dex operations that might result in blocked funds getting stuck

Comment thread tips/tip-1027.md
Comment on lines +147 to +153
### DEX-Recipient (Internal Balance Credit)

When `recipient == address(StablecoinDEX)` and a memo is provided (via the `WithMemo` variants), the memo is attached to the **input** transfer instead, since no TIP-20 output transfer occurs on this path.

If the input is pulled (fully or partially) from the caller's external wallet, the external portion emits both a `Transfer` event and a `TransferWithMemo` event (with the `TransferWithMemo` immediately after). If the swap is funded entirely from the caller's internal DEX balance, the DEX emits a zero-value `Transfer` and `TransferWithMemo` event on `tokenIn` (with `from = msg.sender, to = StablecoinDEX, amount = 0`) to preserve the memo.

For the non-memo `To` variants with DEX-recipient, no `TransferWithMemo` is emitted regardless of funding source.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this is super wonky, just revert if you're trying to swap with a memo into the dex or something

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

i concur. do we have a clear usecase/user for why we're building these somewhat wonky semantics?

Copy link
Copy Markdown
Contributor

@danrobinson danrobinson Apr 28, 2026

Choose a reason for hiding this comment

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

You're right, let's just have this one revert; the special cases just kinda piled up a bit

@github-actions github-actions Bot removed the stale label Mar 18, 2026
Comment thread tips/tip-1027.md
/// @param amountIn Amount of tokenIn to sell
/// @param minAmountOut Minimum amount of tokenOut the recipient must receive
/// @param recipient Address to receive the output tokens
/// @param memo Arbitrary 32-byte memo attached to the output transfer
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

but it's input transfer if recipient = DEX.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Yep good point, should say that

Comment thread tips/tip-1027.md
/// @param amountOut Exact amount of tokenOut the recipient must receive
/// @param maxAmountIn Maximum amount of tokenIn to spend
/// @param recipient Address to receive the output tokens
/// @param memo Arbitrary 32-byte memo attached to the output transfer
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

but it's input transfer if recipient = DEX.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same here

Comment thread tips/tip-1027.md

14. **Exact-out memo on external recipient**: `swapExactAmountOutToWithMemo` emits a `TransferWithMemo` event on `tokenOut` immediately after the `Transfer` event.

15. **Exact-out memo on DEX-recipient**: `swapExactAmountOutToWithMemo(..., DEX_ADDRESS, memo)` follows the same memo rules as the exact-in variant (memo on input transfer if external funding occurs, no memo if fully internal-funded).
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

contradicts invariant 7 that says withMemo variants must always emit a TransferWithMemo?

Comment thread tips/tip-1027.md

## Motivation

Today, swapping and sending to another address requires two transactions: a swap (output goes to the caller) followed by a transfer to the intended recipient. This costs extra gas, increases latency, and prevents atomic swap-and-pay flows.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

dumb q: we do have native batching in tempo tx? why eat all this complexity instead of just doing that?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Comment thread tips/tip-1027.md

### External Recipient

For `WithMemo` variants with an external recipient, the output settlement emits both a `Transfer` event and a `TransferWithMemo` event on the output token, with `from = StablecoinDEX, to = recipient`. The `TransferWithMemo` event is emitted immediately after the `Transfer` event.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

won't this be confusing if the recipient expects to see a transfer from the sender but sees from from DEX?

Copy link
Copy Markdown
Member Author

@decofe decofe left a comment

Choose a reason for hiding this comment

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

TIP-1027 Review

protocolVersion → T5

Frontmatter says protocolVersion: TBD. Should be T5.

Security / Correctness Issues

  1. Paused-token bypass on DEX-recipient path is a meaningful weakening of pause semantics. The TIP explicitly allows swapExactAmountInTo(..., DEX_ADDRESS) to succeed when tokenOut is paused, with the rationale that pause is enforced on withdraw. But this means a user can acquire economic exposure to a paused token via internal DEX balance. If an issuer pauses a token expecting to freeze all new acquisition, this is a bypass. The TIP acknowledges this is intentional, but it deserves a more prominent callout in the spec — possibly a dedicated "Security Considerations" section — so issuers understand the trade-off.

  2. Synthetic zero-value Transfer + TransferWithMemo events are dangerous for indexers. When recipient == DEX and the swap is fully internally funded, the TIP requires emitting zero-value events on tokenIn to preserve the memo. Off-chain systems (explorers, accounting tools, compliance) will index these as real token movements. Consider:

    • Using a distinct event type (e.g., MemoAttached) instead of a zero-value Transfer
    • Or at minimum, documenting that indexers MUST ignore zero-value Transfer events from DEX-recipient paths
  3. Sender identity in TIP-403 checks on external-recipient path. The spec says output settlement uses ITIP20(tokenOut).transfer(recipient, amountOut) — a transfer from the DEX. The DEX is the sender for TIP-403 purposes. This means:

    • The DEX (not the caller) must be an authorized sender
    • If the DEX is blacklisted as sender on tokenOut, all swap-and-send to external recipients fails
    • This is probably correct, but should be stated explicitly so it is not misimplemented as a caller-is-sender check

Suggested changes

-protocolVersion: TBD
+protocolVersion: T5

…rsion 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
@decofe
Copy link
Copy Markdown
Member Author

decofe commented Apr 7, 2026

Addressed in a1f615c: added pause check on the DEX-recipient internal balance credit path, updated protocolVersion to T5. Paused tokenOut now reverts on the DEX-recipient path instead of allowing silent accumulation.

@github-actions
Copy link
Copy Markdown
Contributor

This PR has been marked stale due to 7 days of inactivity.

@github-actions github-actions Bot added the stale label Apr 25, 2026
Comment thread tips/tip-1027.md

When `recipient == address(StablecoinDEX)` and a memo is provided (via the `WithMemo` variants), the memo is attached to the **input** transfer instead, since no TIP-20 output transfer occurs on this path.

If the input is pulled (fully or partially) from the caller's external wallet, the external portion emits both a `Transfer` event and a `TransferWithMemo` event (with the `TransferWithMemo` immediately after). If the swap is funded entirely from the caller's internal DEX balance, the DEX emits a zero-value `Transfer` and `TransferWithMemo` event on `tokenIn` (with `from = msg.sender, to = StablecoinDEX, amount = 0`) to preserve the memo.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

when the swap is funded entirely from internal DEX balance, who actually emits the zero-value Transfer / TransferWithMemo "on tokenIn"? events are scoped to the emitting contract, so the DEX can't just log them on the token's address, either:

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

same question applies to invariant 7 and test case 12.

@fgimenez fgimenez added cyclops Trigger Cyclops PR audit and removed stale labels Apr 27, 2026
Copy link
Copy Markdown

@tempoxyz-bot tempoxyz-bot left a comment

Choose a reason for hiding this comment

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

👁️ Cyclops Review

PR #3016 adds a draft TIP for swap-and-send functions (target: T5). The diff is docs-only, but the spec makes security claims about the existing T2 StablecoinDEX that are demonstrably false. Three verified findings describe real pre-existing bugs in live T2 code that this spec's rationale relies upon.

🚨 [HIGH] StablecoinDEX swaps and orders bypass access-key spending limits

File: crates/precompiles/src/tip20/mod.rs:616-685

DEX input debits never enforce access-key spending caps. _transfer_from() does not call authorize_transfer(), and internal-balance debits have no keychain accounting. A compromised access key can drain any DEX-approved or internal balance far beyond its configured cap. TIP-1027's WithMemo design would widen this surface by explicitly using transferFromWithMemo on the input leg.

Fix: Charge access-key spending limits in _transfer_from() whenever from == tx_origin, and mirror in the DEX internal-balance debit path.

✅ Verified with test (test_access_key_limit_bypass_via_dex_swap).


Reviewer Callouts
  • Fee-token inference: crates/revm/src/common.rs:174-190 only decodes legacy swap selectors. New selectors must be added when TIP-1027 is implemented.
  • Zero-value memo emission mechanism: If implemented via transferFromWithMemo(msg.sender, DEX, 0, memo), it would trigger pause/policy checks on tokenIn, creating asymmetric behavior vs. non-memo variant.
  • TIP-1028 not yet in repo: Referenced multiple times but no tip-1028.md exists.
  • Check ordering: Spec checks policy before pause, but TIP-20 transfer checks pause first.

@jenpaff jenpaff added cyclops Trigger Cyclops PR audit and removed cyclops Trigger Cyclops PR audit labels Apr 27, 2026
Copy link
Copy Markdown

@tempoxyz-bot tempoxyz-bot left a comment

Choose a reason for hiding this comment

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

👁️ Cyclops Review

This PR adds a Draft TIP-1027 specification for swap-and-send. No runtime code is changed. The spec is internally consistent, but the audit surfaced pre-existing issues that this TIP either amplifies or relies on incorrectly.


🚨 [SECURITY] Sender-blacklisted makers can still trade through pre-escrowed orders

Severity: Medium | Status: Verified with passing test
File: crates/precompiles/src/stablecoin_dex/mod.rs:655-724

cancel_stale_order() correctly classifies an order as stale once the maker loses sender authorization, but fill_order() / partial_fill_order() never revalidate that condition. A sender-blacklisted maker's pre-escrowed orders can still be filled, crediting the maker with the opposite asset which they then withdraw. TIP-1027 lines 133–141 assume placement-time policy checks are sufficient for existing maker-fill credits — this is incorrect after post-placement policy changes.

Recommended Fix: Recompute the stale condition before matching any order. If the maker is no longer authorized as a sender on the escrowed token, skip or auto-cancel the order. Quote paths should also exclude stale liquidity.


⚠️ [ISSUE] Access-key spend limits bypassed on DEX external-funding path — TIP-1027 recipient amplifies to direct theft

Severity: High (once TIP-1027 is implemented)
File: crates/precompiles/src/tip20/mod.rs:616-685

TIP-1027 claims the new *To functions reuse the same input-debit logic (a "settlement-only change"). However, the current transfer_from / transfer_from_with_memo paths used by the DEX never consult AccountKeychain spend limits, while transfer() and system_transfer_from() do. Today this is an over-broad self-trade. With TIP-1027's recipient parameter, a compromised limited access key could spend the user's full DEX allowance and redirect output to an attacker address.

Recommended Fix: Add AccountKeychain::authorize_transfer() / check_and_update_spending_limit() to the transfer_from and transfer_from_with_memo code paths before TIP-1027 is implemented.


⚠️ [ISSUE] Post-pause bids create fresh paused-token positions through existing fill path

Severity: Medium
File: crates/precompiles/src/stablecoin_dex/mod.rs:421-438

TIP-1027 adds a pause check on the DEX-recipient path to prevent paused-token position buildup. However, existing place() never checks paused() on the base token, and fill_order() credits paused-token internal balances without a pause gate. The invariant TIP-1027 claims to establish does not hold for existing paths.

Recommended Fix: Extend the pause gate to place() for the base token and block swap input consumption from internal paused-token balances.


Reviewer Callouts
  • Stale-order matching: Confirm whether stale orders should be auto-cancelled on encounter or skipped, and ensure quote paths stop advertising unfillable liquidity.
  • Implementation must inherit check_not_paused() route validation: New external-recipient swap functions must run through validate_and_build_route() (per SIGP-203) so paused-token routing fails early.
  • TIP-1028 carve-out: When TIP-1028 lands, ensure withdraw-time enforcement is actually implemented for DEX-balance credits.
  • Access-key gap is pre-existing: The transfer_from spend-limit bypass exists today on legacy swaps; TIP-1027 just makes it exploitable for theft via recipient.
  • Fee-token inference and ABI updates: When implemented, update selector-sensitive consumers together (crates/revm/src/common.rs, dispatch, contracts ABI, Solidity reference interface).

Comment thread tips/tip-1027.md

Unlike the existing maker-fill path (where makers' internal balances are credited without checking pause, because the maker opted in before the pause by placing an order), the swap-and-send caller is newly acquiring a token position. Pause MUST be enforced to prevent users from building up positions in a paused token.

This is different from the existing maker-fill path (where `_fillOrder` credits `balances[maker]` without re-checking policies). In that case, the maker was already policy-checked when they placed the order. Here, the caller is newly acquiring a token position via swap, so we must verify they are authorized to hold it.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 [SUGGESTION] Spec references wrong error name

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

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

Comment thread tips/tip-1027.md

TIP-1028 address-level receive policies do not apply to the DEX-credit path, since no TIP-20 transfer occurs. Address-level receive policies govern actual token transfers, not internal DEX balance accounting.

## Output Settlement (External Recipient)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

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

The invariant this paragraph claims to establish does not hold for existing paths.

Comment thread tips/tip-1027.md

## Output Settlement (External Recipient)

For external recipients, the DEX calls `ITIP20(tokenOut).transfer(recipient, amountOut)` (or `transferWithMemo(recipient, amountOut, memo)` for the `WithMemo` variants) — a normal TIP-20 transfer from the DEX. This is identical to how existing swap functions settle output, just with `recipient` instead of `msg.sender`. All standard TIP-20 checks (TIP-403 token-level policies, TIP-1028 address-level receive policies, pause state, recipient validity) are enforced automatically by the TIP-20 transfer.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚨 [SECURITY] Placement-time policy check assumption is incorrect for stale orders (verified)

This paragraph assumes the maker "was already policy-checked when they placed the order" so re-checking is unnecessary. However, cancel_stale_order() recognizes that sender authorization can change after placement, while fill_order() / partial_fill_order() never revalidate. A sender-blacklisted maker's pre-escrowed orders can still be matched, crediting the maker with the opposite asset which they then withdraw.

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

Recommended Fix: Recompute the stale condition before matching any order.

Comment thread tips/tip-1027.md
- Otherwise: settle via `ITIP20(tokenOut).transfer(recipient, amountOut)` for base variants, or `ITIP20(tokenOut).transferWithMemo(recipient, amountOut, memo)` for `WithMemo` variants.

The existing `swapExactAmountIn` and `swapExactAmountOut` remain unchanged — they are not refactored to delegate to the new functions, preserving identical behavior for existing callers.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

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

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

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

Copy link
Copy Markdown
Contributor

@0xKitsune 0xKitsune left a comment

Choose a reason for hiding this comment

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

IMO the "credit to internal balance" functionality should be its own function (maybe swapAndDeposit?) instead of a special case where you pass the DEX address as the recipient.

Right now, depending on whether you pass the DEX address or a regular address, the function does two different things with different policy checks, no actual token transfer, the memo gets attached to a different leg, and TIP-1028 doesn't apply.

Splitting it into two functions means each does one thing clearly. swapAndSend always transfers to the recipient, and swapAndDeposit doesn't need a memo since nothing is moving externally.

If swapAndDeposit is useful, I think we could consider it for another TIP.

Comment thread tips/tip-1027.md

## New Functions

Four new swap functions are added to the `IStablecoinDEX` interface — two base functions and two `WithMemo` variants:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

+1, we should update this IMO.

Comment thread tips/tip-1027.md
Comment on lines +252 to +254
21. **Recipient address-level receive policy**: If `recipient` has a whitelist receive policy that does not include the DEX address, the swap-and-send reverts (enforced by TIP-20 `transfer`).

22. **Recipient token set**: If `recipient` has a token set that does not include `tokenOut`, the swap-and-send reverts.
Copy link
Copy Markdown
Contributor

@0xalpharush 0xalpharush May 5, 2026

Choose a reason for hiding this comment

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

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cyclops Trigger Cyclops PR audit

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants