Security[SEC-I01 H07]: swap signing modal does not display pair contract address[SEC-I02] #449

Closed
opened 2026-06-30 15:51:01 +00:00 by totdking · 16 comments
totdking commented 2026-06-30 15:51:01 +00:00 (Migrated from gitlab.com)
No description provided.
totdking commented 2026-06-30 15:58:55 +00:00 (Migrated from gitlab.com)

Summary

The swap signing modal shows the user the offer token symbol, receive token symbol, amount, max_spread, and min_return but does not show the pair contract address. The frontend derives which pair contract to call from the indexer's router_operations response. A malicious or compromised indexer (via misconfigured VITE_INDEXER_URL, DNS hijack, or compromised build env) can return the correct token symbols but route the swap through an attacker-controlled pair contract. The user has no way to verify the actual pair contract address before signing.

Impact is bounded by min_return: if the attacker pair returns fewer tokens than min_return, the transaction reverts. However, an attacker pair can skim an amount within slippage tolerance and the user sees a successful swap below expected return with no indication that a substitute pair was used.


What Was Checked

  • frontend-dapp/src/components/swap/SwapPreSubmitSummary.tsx lines 48, 70: signing modal renders token symbols only (${offerSymbol} -> ${receiveSymbol}). No pair contract address field rendered anywhere in the summary.
  • frontend-dapp/src/pages/SwapPage.tsx line ~531: frontend validates token_in == fromToken and token_out == toToken from indexer response only. Pair contract addresses in router_operations are not validated against a known-good factory pair registry before submission.
  • docs/operator-secrets.md: notes VITE_INDEXER_URL must be HTTPS-only and operator-controlled but does not describe a UI-level mitigation.

Expected (per checklist)

The signing modal displays the pair contract address (or at minimum the truncated address with a copy/explorer link) alongside the token symbols so the user can verify the actual contract before signing. Maps to SEC-E07 (route display aligns with actual submit operations).


Actual

Pair contract address is absent from the signing modal. Only token symbols are shown.


Suggested Fix

Add a pair contract address row to SwapPreSubmitSummary.tsx. For multihop routes, show the first and last pair addresses or a collapsed "N hops via [addr1]...[addrN]" indicator. Add a factory registry validation step in SwapPage.tsx that cross-checks each pair address in router_operations against the factory's registered pairs before enriching and submitting, rejecting the route if any pair is not factory-registered.


Verification Checklist

  • Signing modal displays pair contract address (or truncated address with explorer link) for direct swaps
  • Signing modal shows hop pair addresses for multihop routes
  • Factory pair registry validation added before swap submission rejects unregistered pair addresses from indexer response
  • Frontend test asserts signing modal renders pair address field

Cc: @PlasticDigits

### Summary The swap signing modal shows the user the offer token symbol, receive token symbol, amount, max_spread, and min_return but does not show the pair contract address. The frontend derives which pair contract to call from the indexer's router_operations response. A malicious or compromised indexer (via misconfigured VITE_INDEXER_URL, DNS hijack, or compromised build env) can return the correct token symbols but route the swap through an attacker-controlled pair contract. The user has no way to verify the actual pair contract address before signing. Impact is bounded by min_return: if the attacker pair returns fewer tokens than min_return, the transaction reverts. However, an attacker pair can skim an amount within slippage tolerance and the user sees a successful swap below expected return with no indication that a substitute pair was used. --- ### What Was Checked - `frontend-dapp/src/components/swap/SwapPreSubmitSummary.tsx` lines 48, 70: signing modal renders token symbols only (`${offerSymbol} -> ${receiveSymbol}`). No pair contract address field rendered anywhere in the summary. - `frontend-dapp/src/pages/SwapPage.tsx` line \~531: frontend validates token_in == fromToken and token_out == toToken from indexer response only. Pair contract addresses in router_operations are not validated against a known-good factory pair registry before submission. - `docs/operator-secrets.md`: notes VITE_INDEXER_URL must be HTTPS-only and operator-controlled but does not describe a UI-level mitigation. --- ### Expected (per checklist) The signing modal displays the pair contract address (or at minimum the truncated address with a copy/explorer link) alongside the token symbols so the user can verify the actual contract before signing. Maps to SEC-E07 (route display aligns with actual submit operations). --- ### Actual Pair contract address is absent from the signing modal. Only token symbols are shown. --- ### Suggested Fix Add a pair contract address row to SwapPreSubmitSummary.tsx. For multihop routes, show the first and last pair addresses or a collapsed "N hops via \[addr1\]...\[addrN\]" indicator. Add a factory registry validation step in SwapPage.tsx that cross-checks each pair address in router_operations against the factory's registered pairs before enriching and submitting, rejecting the route if any pair is not factory-registered. --- ### Verification Checklist - [ ] Signing modal displays pair contract address (or truncated address with explorer link) for direct swaps - [ ] Signing modal shows hop pair addresses for multihop routes - [ ] Factory pair registry validation added before swap submission rejects unregistered pair addresses from indexer response - [ ] Frontend test asserts signing modal renders pair address field Cc: @PlasticDigits
totdking commented 2026-06-30 16:08:55 +00:00 (Migrated from gitlab.com)

mentioned in issue #450

mentioned in issue #450
totdking commented 2026-06-30 18:37:55 +00:00 (Migrated from gitlab.com)

mentioned in issue #381

mentioned in issue #381
Brouie commented 2026-06-30 19:13:34 +00:00 (Migrated from gitlab.com)

mentioned in merge request !987

mentioned in merge request !987
Brouie commented 2026-06-30 19:15:31 +00:00 (Migrated from gitlab.com)

Dug into this one at the source level before changing anything, and the stated threat model doesn't actually hold for our code — the frontend never submits to an indexer-provided pair contract address. Walking the real data flow:

  • Direct swaps submit a CW20 Send to directPair.contract_addr. directPair is pairs.find(...) over the pairs list (SwapPage.tsx:237), and that list comes from getAllPairsPaginated() -> queryContract(FACTORY_CONTRACT_ADDRESS, {pairs:{...}}) (factory.ts:32-54), which hits the chain LCD (queries.ts -> TERRA_LCD_URL/cosmwasm/wasm/v1/...), not the indexer. The factory address is VITE_FACTORY_ADDRESS (constants.ts:3). So the pair we sign against is the on-chain factory registry, already the trust anchor.
  • Multihop swaps Send to ROUTER_CONTRACT_ADDRESS (VITE_ROUTER_ADDRESS, constants.ts:4) via executeMultiHopSwap (router.ts:96-98). The router resolves each pair from the factory on-chain; the frontend never supplies a pair address.
  • The indexer's router_operations carry only token offer_asset_info / ask_asset_info — no pair contract addresses for the frontend to be tricked into.

So a malicious indexer can't substitute the pair contract we submit to: the submit target is either the factory-sourced direct pair (LCD) or the fixed router env address. The one thing the indexer does control on the route — the intermediate token path — I closed under #450 (swapRouteIntermediateTokensAligned now cross-checks the displayed path against the submitted ops).

What's left of this issue is genuine but lower-stakes: a UI transparency improvement — show the pair contract address (direct) or the router address + resolved hop pairs (multihop) in the signing modal so a user can eyeball it. Since the address is already chain-sourced, that's defense-in-depth/legibility, not a validation gap, and it's a browser/UI change that I'd verify on the laptop pass.

Leaving this open. @PlasticDigits — want me to add the pair-address row to the signing modal as a transparency thing, or is the chain-sourced guarantee + #450 cross-check enough to downgrade/close this? Not closing it from my side either way.

Dug into this one at the source level before changing anything, and the stated threat model doesn't actually hold for our code — the frontend never submits to an indexer-provided pair contract address. Walking the real data flow: - **Direct swaps** submit a CW20 Send to `directPair.contract_addr`. `directPair` is `pairs.find(...)` over the `pairs` list (SwapPage.tsx:237), and that list comes from `getAllPairsPaginated()` -> `queryContract(FACTORY_CONTRACT_ADDRESS, {pairs:{...}})` (factory.ts:32-54), which hits the **chain LCD** (`queries.ts` -> `TERRA_LCD_URL/cosmwasm/wasm/v1/...`), not the indexer. The factory address is `VITE_FACTORY_ADDRESS` (constants.ts:3). So the pair we sign against is the on-chain factory registry, already the trust anchor. - **Multihop swaps** Send to `ROUTER_CONTRACT_ADDRESS` (`VITE_ROUTER_ADDRESS`, constants.ts:4) via `executeMultiHopSwap` (router.ts:96-98). The router resolves each pair from the factory on-chain; the frontend never supplies a pair address. - The indexer's `router_operations` carry only token `offer_asset_info` / `ask_asset_info` — no pair contract addresses for the frontend to be tricked into. So a malicious indexer can't substitute the pair contract we submit to: the submit target is either the factory-sourced direct pair (LCD) or the fixed router env address. The one thing the indexer *does* control on the route — the intermediate token path — I closed under #450 (`swapRouteIntermediateTokensAligned` now cross-checks the displayed path against the submitted ops). What's left of this issue is genuine but lower-stakes: a **UI transparency** improvement — show the pair contract address (direct) or the router address + resolved hop pairs (multihop) in the signing modal so a user can eyeball it. Since the address is already chain-sourced, that's defense-in-depth/legibility, not a validation gap, and it's a browser/UI change that I'd verify on the laptop pass. Leaving this open. @PlasticDigits — want me to add the pair-address row to the signing modal as a transparency thing, or is the chain-sourced guarantee + #450 cross-check enough to downgrade/close this? Not closing it from my side either way.
PlasticDigits commented 2026-06-30 22:12:23 +00:00 (Migrated from gitlab.com)

Must minimize user cognitive overload

Must minimize user cognitive overload
PlasticDigits commented 2026-06-30 22:17:57 +00:00 (Migrated from gitlab.com)

mentioned in commit 2585a19362

mentioned in commit 2585a1936223829fedc4cf7b447795d9cc63e121
PlasticDigits commented 2026-06-30 22:18:25 +00:00 (Migrated from gitlab.com)

mentioned in merge request !991

mentioned in merge request !991
PlasticDigits commented 2026-07-01 00:12:35 +00:00 (Migrated from gitlab.com)

mentioned in commit 633628a491

mentioned in commit 633628a491aec140d811b9cf0862c3cd32f76eb2
PlasticDigits commented 2026-07-01 00:33:34 +00:00 (Migrated from gitlab.com)

mentioned in commit 4dad91d80d

mentioned in commit 4dad91d80d80e5a609e91124efb4903f021706d5
PlasticDigits commented 2026-07-01 00:39:41 +00:00 (Migrated from gitlab.com)

mentioned in commit 37b005bd6a

mentioned in commit 37b005bd6a9fe9a53e5174a6353be63e678066a6
PlasticDigits commented 2026-07-01 00:44:01 +00:00 (Migrated from gitlab.com)

Verification — #449 (SEC-I01 H07 / SEC-I02)

Verified on main @ 772a7dec (includes merge 37b005bd / fix/449-swap-signing-pair-contract).

Checklist

Item Result How verified
Signing modal displays pair contract address (truncated + copy/explorer) for direct swaps PASS SwapPreSubmitSummary renders swap-confirm-pair-contracts / swap-confirm-pair-contract via AddressRow (truncate, copy, explorer). Unit test: SwapPreSubmitSummary.test.tsx — renders factory-sourced pair contract address for direct swaps (#449 / SEC-I02). Wired on /swap (SwapPage.tsx → pairContractAddresses={swapBlacklistProbe.pairAddresses}) and Trade market tab (TradeMarketOrderPanel.tsx).
Signing modal shows hop pair addresses for multihop routes PASS Multihop uses swap-confirm-hop-pair-{n} rows with → separators. Unit test: SwapPreSubmitSummary.test.tsx — renders hop pair contract addresses for multihop routes (#449). Resolution: resolveSwapRoutePairAddresses.ts maps each router_operations hop to a factory LCD pairs entry.
Factory pair registry validation rejects unregistered indexer pair addresses before submit N/A (by design) Code review + issue thread (Brouie 2026-06-30): the frontend never submits to an indexer-supplied pair address. Direct swaps CW20-Send to directPair.contract_addr from LCD factory getAllPairsPaginated(); multihop sends to fixed ROUTER_CONTRACT_ADDRESS with token-only ops — router resolves pairs on-chain. docs/security-model.md documents this trust boundary. Optional LCD getPair() preflight exists in swapRoutePreflight.ts for spread simulation, not a separate submit gate. Displayed pair addresses are factory-sourced for transparency only.
Frontend test asserts signing modal renders pair address field PASS npm run test:run -- src/components/swap/__tests__/SwapPreSubmitSummary.test.tsx src/utils/__tests__/resolveSwapRoutePairAddresses.test.ts → 8/8 passed. Broader signing regression: SwapPage.test.tsx + TradeMarketOrderPanel.submitSnapshot.test.tsx → 43/43 passed.

Commands

npm run test:run -- \
  src/components/swap/__tests__/SwapPreSubmitSummary.test.tsx \
  src/utils/__tests__/resolveSwapRoutePairAddresses.test.ts \
  src/pages/SwapPage.test.tsx \
  src/components/trade/__tests__/TradeMarketOrderPanel.submitSnapshot.test.tsx
# Test Files 4 passed (4), Tests 43 passed (43)

Notes

  • Original threat model (malicious indexer substituting pair contract at sign time) does not apply to current submit paths; shipped fix is UI transparency (factory-sourced pair row in pre-sign summary), aligned with PlasticDigits guidance to minimize cognitive overload (single labeled row; multihop collapsed hop list).
  • Docs/skills updated in the merged MR: docs/frontend.md, docs/security-model.md, skills/AGENTS_FRONTEND_SWAP_SIGNING_CONFIRMATION.md.

Closing as verified on main.

## Verification — #449 (SEC-I01 H07 / SEC-I02) Verified on `main` @ `772a7dec` (includes merge `37b005bd` / `fix/449-swap-signing-pair-contract`). ### Checklist | Item | Result | How verified | |------|--------|--------------| | Signing modal displays pair contract address (truncated + copy/explorer) for direct swaps | **PASS** | `SwapPreSubmitSummary` renders `swap-confirm-pair-contracts` / `swap-confirm-pair-contract` via `AddressRow` (truncate, copy, explorer). Unit test: `SwapPreSubmitSummary.test.tsx` — *renders factory-sourced pair contract address for direct swaps (#449 / SEC-I02)*. Wired on `/swap` (`SwapPage.tsx` → `pairContractAddresses={swapBlacklistProbe.pairAddresses}`) and Trade market tab (`TradeMarketOrderPanel.tsx`). | | Signing modal shows hop pair addresses for multihop routes | **PASS** | Multihop uses `swap-confirm-hop-pair-{n}` rows with `→` separators. Unit test: `SwapPreSubmitSummary.test.tsx` — *renders hop pair contract addresses for multihop routes (#449)*. Resolution: `resolveSwapRoutePairAddresses.ts` maps each `router_operations` hop to a factory LCD `pairs` entry. | | Factory pair registry validation rejects unregistered indexer pair addresses before submit | **N/A (by design)** | Code review + issue thread (Brouie 2026-06-30): the frontend **never submits to an indexer-supplied pair address**. Direct swaps CW20-Send to `directPair.contract_addr` from LCD factory `getAllPairsPaginated()`; multihop sends to fixed `ROUTER_CONTRACT_ADDRESS` with token-only ops — router resolves pairs on-chain. `docs/security-model.md` documents this trust boundary. Optional LCD `getPair()` preflight exists in `swapRoutePreflight.ts` for spread simulation, not a separate submit gate. Displayed pair addresses are factory-sourced for transparency only. | | Frontend test asserts signing modal renders pair address field | **PASS** | `npm run test:run -- src/components/swap/__tests__/SwapPreSubmitSummary.test.tsx src/utils/__tests__/resolveSwapRoutePairAddresses.test.ts` → **8/8 passed**. Broader signing regression: `SwapPage.test.tsx` + `TradeMarketOrderPanel.submitSnapshot.test.tsx` → **43/43 passed**. | ### Commands ```bash npm run test:run -- \ src/components/swap/__tests__/SwapPreSubmitSummary.test.tsx \ src/utils/__tests__/resolveSwapRoutePairAddresses.test.ts \ src/pages/SwapPage.test.tsx \ src/components/trade/__tests__/TradeMarketOrderPanel.submitSnapshot.test.tsx # Test Files 4 passed (4), Tests 43 passed (43) ``` ### Notes - Original threat model (malicious indexer substituting pair contract at sign time) does not apply to current submit paths; shipped fix is **UI transparency** (factory-sourced pair row in pre-sign summary), aligned with PlasticDigits guidance to minimize cognitive overload (single labeled row; multihop collapsed hop list). - Docs/skills updated in the merged MR: `docs/frontend.md`, `docs/security-model.md`, `skills/AGENTS_FRONTEND_SWAP_SIGNING_CONFIRMATION.md`. Closing as verified on `main`.
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-07-01 00:44:02 +00:00
Brouie commented 2026-07-01 12:57:10 +00:00 (Migrated from gitlab.com)

mentioned in issue #471

mentioned in issue #471
totdking commented 2026-07-02 16:13:11 +00:00 (Migrated from gitlab.com)

mentioned in commit 9ff4ea7b60

mentioned in commit 9ff4ea7b6091af242260e3566c86b126f590411c
totdking commented 2026-07-02 16:14:08 +00:00 (Migrated from gitlab.com)

mentioned in merge request !1007

mentioned in merge request !1007
PlasticDigits commented 2026-08-17 03:45:39 +00:00 (Migrated from gitlab.com)

mentioned in issue #541

mentioned in issue #541
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
code/cl8y-dex-terraclassic#449
No description provided.