Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions apps/cowswap-frontend/.env
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,6 @@ REACT_APP_NETWORK_URL_11155111=https://ethereum-sepolia-rpc.publicnode.com
# CMS base URL
# REACT_APP_CMS_BASE_URL=http://localhost:1337/api

# Notifications Telegram bot ID
# REACT_APP_TG_BOT_ID=3713371337

#######################################
# Crowdin translations
#######################################
Expand Down
3 changes: 1 addition & 2 deletions apps/cowswap-frontend/src/api/gasPrices/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { GAS_FEE_ENDPOINTS } from '@cowprotocol/common-const'
import { fetchWithRateLimit } from '@cowprotocol/common-utils'
import { isEvmChain, SupportedChainId as ChainId } from '@cowprotocol/cow-sdk'
import { wagmiAdapter } from '@cowprotocol/wallet'

import { getGasPrice } from '@wagmi/core'

import { fetchWithRateLimit } from 'common/utils/fetch'

const fetchRateLimited = fetchWithRateLimit({
rateLimit: {
tokensPerInterval: 3,
Expand Down
36 changes: 36 additions & 0 deletions apps/cowswap-frontend/src/locales/en-US.po
Original file line number Diff line number Diff line change
Expand Up @@ -1997,6 +1997,10 @@ msgstr "MEV-Protected Slippage"
msgid "Closed"
msgstr "Closed"

#: apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/ConnectTelegramModal.tsx
msgid "Connect Telegram"
msgstr "Connect Telegram"

#: apps/cowswap-frontend/src/pages/error/AnySwapAffectedUsers/index.tsx
msgid "CoW Swap 404 not found"
msgstr "CoW Swap 404 not found"
Expand Down Expand Up @@ -3001,6 +3005,10 @@ msgstr "Insufficient allowance granted for"
#~ msgid "Lock this code to your wallet?"
#~ msgstr "Lock this code to your wallet?"

#: apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/ConnectTelegramModal.tsx
msgid "Try again"
msgstr "Try again"

#: apps/cowswap-frontend/src/modules/hooksStore/pure/AddCustomHookForm/CustomDappLoader/index.tsx
msgid "Failed to fetch manifest from {manifestUrl}. Please verify the URL and try again."
msgstr "Failed to fetch manifest from {manifestUrl}. Please verify the URL and try again."
Expand Down Expand Up @@ -3410,6 +3418,10 @@ msgstr "TWAP"
msgid "Insufficient liquidity for this trade"
msgstr "Insufficient liquidity for this trade"

#: apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/ConnectTelegramModal.tsx
msgid "Get a new link"
msgstr "Get a new link"

#: apps/cowswap-frontend/src/modules/affiliate/containers/AffiliateTraderCodeInfo.tsx
#: apps/cowswap-frontend/src/modules/affiliate/containers/AffiliateTraderCodeInfo.tsx
msgid "Will be updated {approxNextUpdateTimeAgo}"
Expand Down Expand Up @@ -3611,6 +3623,10 @@ msgstr "Signed: <0>{numConfirmations} out of {gnosisSafeThreshold} signers</0>"
msgid "This order can be partially filled"
msgstr "This order can be partially filled"

#: apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/ConnectTelegramModal.tsx
msgid "This link expired before you tapped Start in Telegram."
msgstr "This link expired before you tapped Start in Telegram."

#: libs/hook-dapp-lib/src/hookDappsRegistry.ts
#~ msgid "Build your own hook"
#~ msgstr "Build your own hook"
Expand Down Expand Up @@ -5612,6 +5628,10 @@ msgstr "Recreate limit order"
msgid "TWAP order split in <0>{numOfParts} equal parts</0>"
msgstr "TWAP order split in <0>{numOfParts} equal parts</0>"

#: apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/ConnectTelegramModal.tsx
msgid "Scan this code, or open the link on this device, then tap “Start” in the chat."
msgstr "Scan this code, or open the link on this device, then tap “Start” in the chat."

#: apps/cowswap-frontend/src/modules/account/containers/AccountDetails/SurplusCard.tsx
msgid "Your total surplus"
msgstr "Your total surplus"
Expand Down Expand Up @@ -5643,6 +5663,10 @@ msgstr "before that time."
msgid "View transaction"
msgstr "View transaction"

#: apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/ConnectTelegramModal.tsx
msgid "Preparing your connect link…"
msgstr "Preparing your connect link…"

#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
#~ msgid "This list requires consent before importing."
#~ msgstr "This list requires consent before importing."
Expand Down Expand Up @@ -5838,6 +5862,10 @@ msgstr "of your sell amount! Therefore, your order is unlikely to execute."
#~ msgid "This hook allows you to easily set up vesting contracts with LlamaPay. Enter the recipient’s address or ENS name, then choose how much to transfer: the token buy will be automatically detected by the hook and the contracts will be linked to your LlamaPay dashboard for seamless tracking."
#~ msgstr "This hook allows you to easily set up vesting contracts with LlamaPay. Enter the recipient’s address or ENS name, then choose how much to transfer: the token buy will be automatically detected by the hook and the contracts will be linked to your LlamaPay dashboard for seamless tracking."

#: apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/ConnectTelegramModal.tsx
msgid "Something went wrong. Please try again."
msgstr "Something went wrong. Please try again."

#: apps/cowswap-frontend/src/modules/ordersTable/pure/ContextMenu/OrderContextMenu.pure.tsx
#: apps/cowswap-frontend/src/modules/ordersTable/pure/ReceiptModal/ReceiptModal.modal.tsx
msgid "Recreate"
Expand Down Expand Up @@ -6833,6 +6861,10 @@ msgstr "Executes at"
#~ msgid "From (incl. costs)"
#~ msgstr "From (incl. costs)"

#: apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/ConnectTelegramModal.tsx
msgid "Open in Telegram"
msgstr "Open in Telegram"

#: apps/cowswap-frontend/src/legacy/components/Header/NotificationAlertPopover/NotificationAlertPopover.pure.tsx
#: apps/cowswap-frontend/src/modules/account/containers/AccountDetails/index.tsx
msgid "Get trade alerts"
Expand Down Expand Up @@ -8189,6 +8221,10 @@ msgstr "Quote ID"
msgid "Bridge costs"
msgstr "Bridge costs"

#: apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/ConnectTelegramModal.tsx
msgid "Waiting for you to tap Start…"
msgstr "Waiting for you to tap Start…"

#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
#: apps/cowswap-frontend/src/pages/Account/MyRewards.tsx
#~ msgid "Claimable rewards"
Expand Down
97 changes: 17 additions & 80 deletions apps/cowswap-frontend/src/modules/affiliate/api/bffAffiliateApi.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,7 @@
import { BFF_BASE_URL } from '@cowprotocol/common-const'
import {
ApiError,
fetchWithTimeout,
JSON_HEADERS,
parseJsonResponse,
RetryableResponseError,
STATUS_CODES_TO_RETRY,
stripTrailingSlash,
unwrapOk,
} from '@cowprotocol/common-utils'
import type { ApiErrorPayload, FetchJsonResponse } from '@cowprotocol/common-utils'

import { fetchWithRateLimit } from 'common/utils/fetch'
import { ApiError, BffApiClient, unwrapOk } from '@cowprotocol/common-utils'
import type { ApiErrorPayload } from '@cowprotocol/common-utils'

import { wait } from 'common/utils/wait'

import {
Expand All @@ -25,73 +15,25 @@ import {

import {
AFFILIATE_API_TIMEOUT_MS,
BACKOFF_MAX_ATTEMPTS,
BACKOFF_START_DELAY_MS,
BACKOFF_TIME_MULTIPLE,
RATE_LIMIT_INTERVAL_MS,
VERIFICATION_MIN_RESPONSE_DELAY_MS,
VERIFICATION_RETRY_DELAY_MS,
} from '../config/affiliateProgram.const'

class BffAffiliateApi {
private readonly baseUrl: string
private readonly timeoutMs: number
private readonly fetchRateLimited: ReturnType<typeof fetchWithRateLimit>

/**
* Configuration
*/

class BffAffiliateApi extends BffApiClient {
constructor(baseUrl: string, timeoutMs: number = AFFILIATE_API_TIMEOUT_MS) {
this.baseUrl = stripTrailingSlash(baseUrl)
this.timeoutMs = timeoutMs
this.fetchRateLimited = fetchWithRateLimit({
rateLimit: {
tokensPerInterval: 1,
interval: RATE_LIMIT_INTERVAL_MS,
},
backoff: {
numOfAttempts: BACKOFF_MAX_ATTEMPTS,
startingDelay: BACKOFF_START_DELAY_MS,
timeMultiple: BACKOFF_TIME_MULTIPLE,
},
})
}

private buildUrl(path: string): string {
const normalizedPath = path.replace(/^\//, '')
return `${this.baseUrl}/${normalizedPath}`
}

private async fetchJsonResponse<T>(input: string, init?: RequestInit): Promise<FetchJsonResponse<T>> {
const response = await this.fetchRateLimited(async () => {
const requestInit = {
method: 'GET',
headers: JSON_HEADERS,
...init,
}
const response = await fetchWithTimeout(input, {
...requestInit,
timeout: this.timeoutMs,
timeoutMessage: 'Unable to reach referral service',
})
if (STATUS_CODES_TO_RETRY.has(response.status)) {
throw new RetryableResponseError(response.status)
}
return response
})
return parseJsonResponse<T>(response)
// Rate-limit/retry-on-retryable-status uses BffApiClient's default tuning.
super(baseUrl, timeoutMs, 'Unable to reach referral service')
}

/**
* API
*/

async verifyCode(code: string): Promise<TraderInfoResponse> {
const url = this.buildUrl(`ref-codes/${encodeURIComponent(code)}`)
const path = `ref-codes/${encodeURIComponent(code)}`
try {
const [result] = await Promise.all([
this.fetchJsonResponse<TraderInfoResponse>(url),
this.fetchJson<TraderInfoResponse>(path),
wait(VERIFICATION_MIN_RESPONSE_DELAY_MS),
])
return unwrapOk(result, 'Trader response missing')
Expand All @@ -102,8 +44,7 @@ class BffAffiliateApi {
}

async verifyCodeAvailability(code: string): Promise<boolean> {
const url = this.buildUrl(`ref-codes/${encodeURIComponent(code)}`)
const { response, text } = await this.fetchJsonResponse<TraderInfoResponse>(url)
const { response, text } = await this.fetchJson<TraderInfoResponse>(`ref-codes/${encodeURIComponent(code)}`)

if (response.status === 404) return true
if (response.ok || response.status === 403) return false
Expand All @@ -112,49 +53,45 @@ class BffAffiliateApi {
}

async createCode(request: PartnerCreateRequest): Promise<PartnerInfoResponse> {
const url = this.buildUrl(`affiliate/${request.walletAddress}`)
const result = await this.fetchJsonResponse<PartnerInfoResponse>(url, {
const result = await this.fetchJson<PartnerInfoResponse>(`affiliate/${request.walletAddress}`, {
method: 'POST',
body: JSON.stringify(request),
})
return unwrapOk(result, 'Affiliate response missing')
}

async getTraderInfo(code: string): Promise<TraderInfoResponse | null> {
const url = this.buildUrl(`ref-codes/${encodeURIComponent(code)}`)
const { response, data, text } = await this.fetchJsonResponse<TraderInfoResponse>(url)
const { response, data, text } = await this.fetchJson<TraderInfoResponse>(`ref-codes/${encodeURIComponent(code)}`)
if (response.status === 404) return null
if (response.ok) return data ?? null
throw new ApiError(response.status, text, data as ApiErrorPayload)
}

async getPartnerInfo(account: string): Promise<PartnerInfoResponse | null> {
const url = this.buildUrl(`affiliate/${account}`)
const { response, data, text } = await this.fetchJsonResponse<PartnerInfoResponse>(url)
const { response, data, text } = await this.fetchJson<PartnerInfoResponse>(`affiliate/${account}`)
if (response.status === 404) return null
if (response.ok) return data ?? null
throw new ApiError(response.status, text, data as ApiErrorPayload)
}

async getTraderStats(account: string): Promise<TraderStatsResponse | null> {
const url = this.buildUrl(`affiliate/trader-stats/${account}`)
const { response, data, text } = await this.fetchJsonResponse<TraderStatsResponse>(url)
const { response, data, text } = await this.fetchJson<TraderStatsResponse>(`affiliate/trader-stats/${account}`)
if (response.status === 404) return null
if (response.ok) return data ?? null
throw new ApiError(response.status, text, data as ApiErrorPayload)
}

async getAffiliateStats(account: string): Promise<PartnerStatsResponse | null> {
const url = this.buildUrl(`affiliate/affiliate-stats/${account}`)
const { response, data, text } = await this.fetchJsonResponse<PartnerStatsResponse>(url)
const { response, data, text } = await this.fetchJson<PartnerStatsResponse>(`affiliate/affiliate-stats/${account}`)
if (response.status === 404) return null
if (response.ok) return data ?? null
throw new ApiError(response.status, text, data as ApiErrorPayload)
}

async getTraderActivity(account: string): Promise<TraderActivityResponse | null> {
const url = this.buildUrl(`affiliate/trader-activity/${account}`)
const { response, data, text } = await this.fetchJsonResponse<TraderActivityResponse>(url)
const { response, data, text } = await this.fetchJson<TraderActivityResponse>(
`affiliate/trader-activity/${account}`,
)
if (response.status === 404) return null
if (response.ok) return data ?? null
throw new ApiError(response.status, text, data as ApiErrorPayload)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,6 @@ export const AFFILIATE_HIDE_REWARDS_ROW_IF_INELIGIBLE = true

export const AFFILIATE_ELIGIBILITY_LOADING_WARNING_MS = ms`30s`

export const RATE_LIMIT_INTERVAL_MS = 200
export const BACKOFF_START_DELAY_MS = ms`1s`
export const BACKOFF_TIME_MULTIPLE = 3
export const BACKOFF_MAX_ATTEMPTS = 3

export const REF_CODE_PATTERN = /^[A-Z0-9_-]{5,20}$/
export const REF_CODE_MIN_LENGTH = 5
export const REF_CODE_MAX_LENGTH = 20
Expand Down
12 changes: 5 additions & 7 deletions apps/cowswap-frontend/src/modules/notifications/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,9 @@

## Local development

To debug/develop Telegram subscriptions locally you need to do:
1. Create your own telegram bot (https://core.telegram.org/bots/tutorial)
2. Put the bot id to `REACT_APP_TG_BOT_ID` in `.env.local`. You can get it from BotFather. Id is the fist digital part of API Token
3. Uncomment `REACT_APP_CMS_BASE_URL` in `.env.local` or add `REACT_APP_CMS_BASE_URL=http://localhost:1337/api` to `.env`
4. Run the CMS locally and add you bot API token to CMS `.env`: `TELEGRAM_SECRET=XXX`
5. In CMS settings, go to `Settings -> Users & Permissions plugin -> Roles -> Public -> Telegram subscription` and enable `addSubscription`, `removeSubscription`, and `checkSubscription`
6. Launch CoW Swap app as `pnpm run start --host=127.0.0.1`, because Telegram only allows using this domain locally
Telegram notifications are connected via a bot deep-link (`/start <token>`), not the Telegram Login Widget — the frontend only talks to the bff, never to `oauth.telegram.org` or the CMS directly.

To debug/develop Telegram subscriptions locally:
1. Point `REACT_APP_BFF_BASE_URL` at your local `bff` `apps/api` instance in `.env.local`.
2. Follow the `bff` repo's own local-dev instructions for `apps/api` and `apps/telegram` (these need `TELEGRAM_SECRET`, `CMS_BASE_URL`, `CMS_API_KEY`, and a Redis instance shared between the two apps).
3. Launch CoW Swap as usual (`pnpm run start`) — no bot id or Telegram-specific env var is needed on the frontend anymore.
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { BFF_BASE_URL } from '@cowprotocol/common-const'
import { BffApiClient, NO_RATE_LIMIT, unwrapOk } from '@cowprotocol/common-utils'

export interface TelegramConnectStatusResponse {
connected: boolean
username?: string
// Static link to the bot chat - unsubscribing only happens there (see the bot's
// "Unsubscribe" button), so the frontend never posts a disconnect request itself.
botDeepLink: string
}

export interface TelegramConnectTokenResponse {
token: string
deepLink: string
}

const TELEGRAM_API_TIMEOUT_MS = 10_000

class BffTelegramApi extends BffApiClient {
constructor(baseUrl: string) {
// Each poll tick already retries on its own schedule (see useTelegramConnect.ts), so this
// opts out of BffApiClient's default rate-limit/backoff instead of stacking both.
super(baseUrl, TELEGRAM_API_TIMEOUT_MS, 'Unable to reach notifications service', NO_RATE_LIMIT)
}

async getConnectToken(account: string): Promise<TelegramConnectTokenResponse> {
const result = await this.fetchJson<TelegramConnectTokenResponse>(`accounts/${account}/telegram/connect-token`, {
method: 'POST',
// Fastify (the real bff) 400s a POST that carries `Content-Type: application/json`
// with no body at all (FST_ERR_CTP_EMPTY_JSON_BODY) - send an explicit empty object.
body: '{}',
})
return unwrapOk(result, 'Telegram connect-token response missing')
}

async getConnectStatus(account: string): Promise<TelegramConnectStatusResponse> {
const result = await this.fetchJson<TelegramConnectStatusResponse>(`accounts/${account}/telegram/connect-status`)
return unwrapOk(result, 'Telegram connect-status response missing')
}
}

export const bffTelegramApi = new BffTelegramApi(BFF_BASE_URL)
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { atom } from 'jotai'

export interface TelegramSubscriptionState {
isSubscribed: boolean
username?: string
// Static link to the bot chat (no connect-token) - unsubscribing only happens there.
botDeepLink?: string
}

/**
* Shared across every useTelegramConnect() instance so header/sidebar/settings
* agree on subscription state instead of each holding its own stale copy, and
* so a hook instance that remounts (e.g. the sidebar, which unmounts when closed)
* doesn't flash "not subscribed" before its own fetch resolves.
*/
export const telegramSubscriptionAtom = atom<Record<string, TelegramSubscriptionState>>({})

This file was deleted.

Loading
Loading