diff --git a/e2e/buy-process.spec.ts b/e2e/buy-process.spec.ts index 60a54f8ea..6cdf63ee6 100644 --- a/e2e/buy-process.spec.ts +++ b/e2e/buy-process.spec.ts @@ -1,8 +1,6 @@ import { test, expect } from '@playwright/test'; import { BlockchainType, getCachedAuth } from './helpers/auth-cache'; -// Note: API Integration tests have been moved to Jest (src/__tests__/api/buy-api.test.ts) -// This file now contains only UI Flow tests that require browser interaction test.describe('Buy Process - UI Flow', () => { async function getToken( diff --git a/e2e/login-process.spec.ts b/e2e/login-process.spec.ts index fd33f6489..befb84d65 100644 --- a/e2e/login-process.spec.ts +++ b/e2e/login-process.spec.ts @@ -1,8 +1,6 @@ import { test, expect } from '@playwright/test'; import { getCachedAuth } from './helpers/auth-cache'; -// Note: API Integration tests for authentication have been moved to Jest (src/__tests__/api/auth-api.test.ts) -// This file now contains only UI Flow tests that require browser interaction test.describe('Login Process - UI Flow', () => { let token: string; diff --git a/e2e/sell-process.spec.ts b/e2e/sell-process.spec.ts index e29a35ae3..e02cf0428 100644 --- a/e2e/sell-process.spec.ts +++ b/e2e/sell-process.spec.ts @@ -1,8 +1,6 @@ import { test, expect } from '@playwright/test'; import { getCachedAuth } from './helpers/auth-cache'; -// Note: API Integration tests have been moved to Jest (src/__tests__/api/sell-api.test.ts) -// This file now contains only UI Flow tests that require browser interaction test.describe('Sell Process - UI Flow', () => { let token: string; diff --git a/e2e/swap-process.spec.ts b/e2e/swap-process.spec.ts index e6fba6fc7..8edf1858f 100644 --- a/e2e/swap-process.spec.ts +++ b/e2e/swap-process.spec.ts @@ -1,8 +1,6 @@ import { test, expect } from '@playwright/test'; import { getCachedAuth } from './helpers/auth-cache'; -// Note: API Integration tests have been moved to Jest (src/__tests__/api/swap-api.test.ts) -// This file now contains only UI Flow tests that require browser interaction test.describe('Swap Process - UI Flow', () => { let token: string; diff --git a/package-lock.json b/package-lock.json index 83ea91d10..dc8b0f1f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,8 @@ "version": "1.0.4", "license": "MIT", "dependencies": { - "@dfx.swiss/react": "^1.3.0-beta.236", - "@dfx.swiss/react-components": "^1.3.0-beta.236", + "@dfx.swiss/react": "^1.3.0-beta.237", + "@dfx.swiss/react-components": "^1.3.0-beta.237", "@ledgerhq/hw-app-btc": "^6.24.1", "@ledgerhq/hw-app-eth": "^6.33.7", "@ledgerhq/hw-transport-webhid": "^6.27.19", @@ -2631,9 +2631,9 @@ } }, "node_modules/@dfx.swiss/react": { - "version": "1.3.0-beta.236", - "resolved": "https://registry.npmjs.org/@dfx.swiss/react/-/react-1.3.0-beta.236.tgz", - "integrity": "sha512-aNGss+yuu3kQBGnm7ifQ3LqEuXl4ScI2tFIeCltgP2P4nIs0BeioF02N8Myben4tGxgpayF6hiGLcvAy1pF5CQ==", + "version": "1.3.0-beta.237", + "resolved": "https://registry.npmjs.org/@dfx.swiss/react/-/react-1.3.0-beta.237.tgz", + "integrity": "sha512-VDVYw7wnUHxSy3DGN6rGS9UsYkn10GOienYcI5MGubKVGtkWY8GFRVQ9NeQBjIHFPHi9EJaDmM7GI0ZYDRfEew==", "license": "MIT", "dependencies": { "ibantools": "^4.2.1", @@ -2644,9 +2644,9 @@ } }, "node_modules/@dfx.swiss/react-components": { - "version": "1.3.0-beta.236", - "resolved": "https://registry.npmjs.org/@dfx.swiss/react-components/-/react-components-1.3.0-beta.236.tgz", - "integrity": "sha512-LTAE+YBKhNudxfAVuf+PlAGITlxJbtCIbP+67a4A/P5dm7IaZqUSDB96xbWLfl4o6qqOUGUizSPB1o0hk/fs/Q==", + "version": "1.3.0-beta.237", + "resolved": "https://registry.npmjs.org/@dfx.swiss/react-components/-/react-components-1.3.0-beta.237.tgz", + "integrity": "sha512-1xlqEB20rAfnAyuv4I+dQu1lsUyW4jXXo5Qxxn2hRAVaeBKddvv/vOWrQghSACxudwabn9aeLmicQQAQ67TXog==", "license": "MIT", "dependencies": { "@floating-ui/react": "^0.18.1", diff --git a/package.json b/package.json index 32e4733eb..5cde4ccbe 100644 --- a/package.json +++ b/package.json @@ -12,12 +12,14 @@ "access": "public" }, "dependencies": { - "@dfx.swiss/react": "^1.3.0-beta.236", - "@dfx.swiss/react-components": "^1.3.0-beta.236", + "@dfx.swiss/react": "^1.3.0-beta.237", + "@dfx.swiss/react-components": "^1.3.0-beta.237", "@ledgerhq/hw-app-btc": "^6.24.1", "@ledgerhq/hw-app-eth": "^6.33.7", "@ledgerhq/hw-transport-webhid": "^6.27.19", "@r2wc/react-to-web-component": "^2.0.2", + "@scure/bip32": "^2.0.1", + "@scure/bip39": "^2.0.1", "@solana/spl-token": "^0.4.13", "@solana/wallet-adapter-phantom": "^0.9.27", "@solana/wallet-adapter-trust": "^0.1.16", @@ -34,6 +36,8 @@ "apexcharts": "^4.7.0", "bech32": "^2.0.0", "bitbox-api": "^0.2.1", + "bitcoinjs-lib": "^7.0.0", + "bitcoinjs-message": "^2.2.0", "browser-lang": "^0.2.1", "buffer": "^6.0.3", "copy-to-clipboard": "^3.3.3", @@ -57,18 +61,14 @@ "react-scripts": "5.0.1", "stream-browserify": "^3.0.0", "stream-http": "^3.2.0", + "tronweb": "^6.1.1", + "tweetnacl": "^1.0.3", "typescript": "^5.4.5", "url": "^0.11.1", "viem": "^2.13.3", "web-vitals": "^2.1.4", "web3": "^1.8.1", - "webln": "^0.3.2", - "@scure/bip32": "^2.0.1", - "@scure/bip39": "^2.0.1", - "bitcoinjs-lib": "^7.0.0", - "bitcoinjs-message": "^2.2.0", - "tronweb": "^6.1.1", - "tweetnacl": "^1.0.3" + "webln": "^0.3.2" }, "scripts": { "start": "react-app-rewired start", @@ -83,14 +83,13 @@ "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --no-fix", "lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "test": "react-app-rewired test --watchAll=false --passWithNoTests", - "test:api": "react-app-rewired test --watchAll=false --testPathPattern=__tests__/api", "test:e2e": "./scripts/e2e-test.sh", "test:e2e:local": "npx playwright test", "test:e2e:metamask": "npx playwright test --config=playwright.synpress.config.ts", "synpress:install-chrome": "npx @puppeteer/browsers install chrome@126.0.6478.0", "synpress:download-metamask": "mkdir -p .cache-synpress && curl -L https://github.com/MetaMask/metamask-extension/releases/download/v11.9.1/metamask-chrome-11.9.1.zip -o .cache-synpress/metamask.zip && unzip -o .cache-synpress/metamask.zip -d .cache-synpress/metamask-chrome-11.9.1", "synpress:setup": "npm run synpress:install-chrome && npm run synpress:download-metamask", - "test:all": "npm run test:api && npm run test:e2e", + "test:all": "npm run test && npm run test:e2e", "eject": "react-scripts eject", "serve": "serve build -l 4000", "analyze": "source-map-explorer 'build/static/js/*.js'", @@ -180,8 +179,7 @@ "node_modules/(?!(@dfx.swiss|@scure|@noble|@solana|bitcoinjs-lib|bitcoinjs-message|tronweb|tweetnacl)/)" ], "testPathIgnorePatterns": [ - "/node_modules/", - "__tests__/api/helpers/" + "/node_modules/" ], "setupFilesAfterEnv": [ "/src/setupTests.ts" diff --git a/src/App.tsx b/src/App.tsx index 1efceba9e..dd977871d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -60,6 +60,11 @@ const ComplianceKycFilesDetailsScreen = lazy(() => import('./screens/compliance- const ComplianceKycStatsScreen = lazy(() => import('./screens/compliance-kyc-stats.screen')); const ComplianceTransactionListScreen = lazy(() => import('./screens/compliance-transaction-list.screen')); const RealunitScreen = lazy(() => import('./screens/realunit.screen')); +const RealunitHoldersScreen = lazy(() => import('./screens/realunit-holders.screen')); +const RealunitQuotesScreen = lazy(() => import('./screens/realunit-quotes.screen')); +const RealunitTransactionsScreen = lazy(() => import('./screens/realunit-transactions.screen')); +const RealunitQuoteDetailScreen = lazy(() => import('./screens/realunit-quote-detail.screen')); +const RealunitTransactionDetailScreen = lazy(() => import('./screens/realunit-transaction-detail.screen')); const RealunitUserScreen = lazy(() => import('./screens/realunit-user.screen')); const PersonalIbanScreen = lazy(() => import('./screens/personal-iban.screen')); const BuyCryptoUpdateScreen = lazy(() => import('./screens/buy-crypto-update.screen')); @@ -360,6 +365,26 @@ export const Routes = [ index: true, element: withSuspense(), }, + { + path: 'holders', + element: withSuspense(), + }, + { + path: 'quotes', + element: withSuspense(), + }, + { + path: 'quotes/:id', + element: withSuspense(), + }, + { + path: 'transactions', + element: withSuspense(), + }, + { + path: 'transactions/:id', + element: withSuspense(), + }, { path: 'user/:address', element: withSuspense(), diff --git a/src/__tests__/api/auth-api.test.ts b/src/__tests__/api/auth-api.test.ts deleted file mode 100644 index 9bcf384a0..000000000 --- a/src/__tests__/api/auth-api.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { createTestCredentials, TestCredentials } from './helpers/test-wallet'; - -const API_URL = 'https://dev.api.dfx.swiss/v1'; - -interface AuthResponse { - accessToken: string; -} - -interface SignInfoResponse { - message: string; - blockchains: string[]; -} - -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function authenticate( - credentials: TestCredentials, -): Promise<{ success: boolean; token?: string; error?: string }> { - try { - const response = await fetch(`${API_URL}/auth`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(credentials), - }); - - if (response.ok) { - const data: AuthResponse = await response.json(); - return { success: true, token: data.accessToken }; - } - - const errorBody = await response.json().catch(() => ({})); - return { success: false, error: (errorBody as { message?: string }).message || `HTTP ${response.status}` }; - } catch (e) { - return { success: false, error: String(e) }; - } -} - -async function getSignInfo( - address: string, - retries = 3, -): Promise { - for (let i = 0; i < retries; i++) { - try { - const response = await fetch(`${API_URL}/auth/signMessage?address=${address}`); - if (response.ok) { - return response.json(); - } - if (response.status === 429) { - await delay(1000 * (i + 1)); - continue; - } - return null; - } catch { - if (i < retries - 1) { - await delay(1000 * (i + 1)); - continue; - } - return null; - } - } - return null; -} - -// API Integration tests for Authentication (EVM only) -describe('Authentication - API Integration', () => { - describe('EVM Authentication', () => { - let evmCredentials: TestCredentials; - - beforeAll(async () => { - evmCredentials = await createTestCredentials(); - console.log(`EVM test address: ${evmCredentials.address}`); - }, 30000); - - test('should authenticate with Ethereum address', async () => { - const result = await authenticate(evmCredentials); - expect(result.success).toBeTruthy(); - expect(result.token).toBeTruthy(); - console.log('Ethereum login successful'); - }); - - test('should get correct sign info for EVM address', async () => { - const signInfo = await getSignInfo(evmCredentials.address); - expect(signInfo).toBeTruthy(); - if (!signInfo) return; - expect(signInfo.blockchains).toContain('Ethereum'); - expect(signInfo.blockchains).toContain('Polygon'); - expect(signInfo.blockchains).toContain('Arbitrum'); - expect(signInfo.blockchains).toContain('Optimism'); - expect(signInfo.blockchains).toContain('Base'); - expect(signInfo.blockchains).toContain('BinanceSmartChain'); - console.log(`EVM blockchains: ${signInfo.blockchains.join(', ')}`); - }); - - test('should reject invalid EVM signature', async () => { - const invalidCredentials = { - address: evmCredentials.address, - signature: '0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', - }; - - const result = await authenticate(invalidCredentials); - expect(result.success).toBeFalsy(); - console.log(`Invalid signature rejected: ${result.error}`); - }); - }); - - describe('Address Format Verification', () => { - test('should generate valid EVM address format', async () => { - const evmCreds = await createTestCredentials(); - expect(evmCreds.address).toMatch(/^0x[a-fA-F0-9]{40}$/); - console.log(`Generated EVM address: ${evmCreds.address}`); - }); - - test('should verify API recognizes EVM address', async () => { - const evmCreds = await createTestCredentials(); - const evmInfo = await getSignInfo(evmCreds.address); - expect(evmInfo?.blockchains).toContain('Ethereum'); - }); - }); -}); diff --git a/src/__tests__/api/buy-api.test.ts b/src/__tests__/api/buy-api.test.ts deleted file mode 100644 index b8872a80e..000000000 --- a/src/__tests__/api/buy-api.test.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { ApiClient, createApiClient } from './helpers/api-client'; -import { TestCredentials } from './helpers/test-wallet'; - -const API_URL = 'https://dev.api.dfx.swiss/v1'; - -// EVM-compatible blockchains for this test address -const EVM_BLOCKCHAINS = ['Ethereum', 'Arbitrum', 'Optimism', 'Polygon', 'Base', 'BinanceSmartChain', 'Gnosis']; - -interface Asset { - id: number; - name: string; - uniqueName: string; - blockchain: string; - buyable: boolean; -} - -interface Fiat { - id: number; - name: string; - sellable: boolean; -} - -interface BuyPaymentInfo { - id: number; - routeId: number; - amount: number; - currency: { id: number; name: string }; - asset: { id: number; name: string }; - estimatedAmount: number; - rate: number; - exchangeRate: number; - minVolume: number; - maxVolume: number; - fees: { total: number }; - isValid: boolean; - error?: string; - iban?: string; - remittanceInfo?: string; -} - -interface BuyQuote { - amount: number; - estimatedAmount: number; - rate: number; - exchangeRate: number; - minVolume: number; - maxVolume: number; - feeAmount: number; - isValid: boolean; - error?: string; -} - -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function getAssets(client: ApiClient): Promise { - const result = await client.get('/asset'); - expect(result.data).toBeTruthy(); - return result.data ?? []; -} - -async function getFiats(): Promise { - const response = await fetch(`${API_URL}/fiat`); - expect(response.ok).toBeTruthy(); - return response.json(); -} - -async function getBuyQuote( - params: { currency: { id: number }; asset: { id: number }; amount: number }, -): Promise { - const response = await fetch(`${API_URL}/buy/quote`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - currency: params.currency, - asset: params.asset, - amount: params.amount, - paymentMethod: 'Bank', - }), - }); - - expect(response.ok).toBeTruthy(); - return response.json(); -} - -async function createBuyPaymentInfo( - client: ApiClient, - params: { currency: { id: number }; asset: { id: number }; amount: number }, -): Promise<{ data: BuyPaymentInfo | null; error: string | null; status: number }> { - return client.put('/buy/paymentInfos', { - currency: params.currency, - asset: params.asset, - amount: params.amount, - paymentMethod: 'Bank', - }); -} - -// API Integration tests for Buy Process (EVM only) -describe('Buy Process - API Integration', () => { - let client: ApiClient; - let credentials: TestCredentials; - let buyableAssets: Asset[]; - let sellableFiats: Fiat[]; - - beforeAll(async () => { - const auth = await createApiClient(); - client = auth.client; - credentials = auth.credentials; - console.log(`Using EVM test address: ${credentials.address}`); - - const [assets, fiats] = await Promise.all([getAssets(client), getFiats()]); - - buyableAssets = assets.filter((a) => a.buyable && EVM_BLOCKCHAINS.includes(a.blockchain)); - sellableFiats = fiats.filter((f) => f.sellable); - - expect(buyableAssets.length).toBeGreaterThan(0); - expect(sellableFiats.length).toBeGreaterThan(0); - }, 60000); - - test('should authenticate with EVM credentials', async () => { - const response = await fetch(`${API_URL}/auth`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(credentials), - }); - - expect(response.ok).toBeTruthy(); - const data = await response.json(); - expect(data.accessToken).toBeTruthy(); - }); - - test('should fetch buyable assets', async () => { - const assets = await getAssets(client); - const buyable = assets.filter((a) => a.buyable); - - expect(buyable.length).toBeGreaterThan(0); - console.log(`Found ${buyable.length} buyable assets`); - }); - - test('should fetch sellable fiats', async () => { - const fiats = await getFiats(); - const sellable = fiats.filter((f) => f.sellable); - - expect(sellable.length).toBeGreaterThan(0); - console.log(`Found ${sellable.length} sellable fiats`); - - const eurExists = sellable.some((f) => f.name === 'EUR'); - const chfExists = sellable.some((f) => f.name === 'CHF'); - expect(eurExists || chfExists).toBeTruthy(); - }); - - test('should get buy quote for EUR -> ETH', async () => { - const eur = sellableFiats.find((f) => f.name === 'EUR'); - const eth = buyableAssets.find((a) => a.name === 'ETH' && EVM_BLOCKCHAINS.includes(a.blockchain)); - - if (!eur || !eth) { - console.log('Skipping: EUR or ETH not available'); - return; - } - - const quote = await getBuyQuote({ - currency: { id: eur.id }, - asset: { id: eth.id }, - amount: 100, - }); - - expect(quote.amount).toBe(100); - expect(quote.estimatedAmount).toBeGreaterThan(0); - expect(quote.rate).toBeGreaterThan(0); - expect(quote.minVolume).toBeGreaterThan(0); - expect(quote.maxVolume).toBeGreaterThan(0); - - console.log(`Quote: 100 EUR -> ${quote.estimatedAmount} ETH (rate: ${quote.rate})`); - }); - - test('should get buy quote for CHF -> WBTC', async () => { - const chf = sellableFiats.find((f) => f.name === 'CHF'); - const wbtc = buyableAssets.find((a) => a.name === 'WBTC' && EVM_BLOCKCHAINS.includes(a.blockchain)); - - if (!chf || !wbtc) { - console.log('Skipping: CHF or WBTC not available'); - return; - } - - const quote = await getBuyQuote({ - currency: { id: chf.id }, - asset: { id: wbtc.id }, - amount: 200, - }); - - expect(quote.amount).toBe(200); - expect(quote.estimatedAmount).toBeGreaterThan(0); - expect(quote.rate).toBeGreaterThan(0); - - console.log(`Quote: 200 CHF -> ${quote.estimatedAmount} WBTC (rate: ${quote.rate})`); - }); - - test('should reject amount below minimum', async () => { - const eur = sellableFiats.find((f) => f.name === 'EUR'); - const eth = buyableAssets.find((a) => a.name === 'ETH' && EVM_BLOCKCHAINS.includes(a.blockchain)); - - if (!eur || !eth) { - console.log('Skipping: EUR or ETH not available'); - return; - } - - const quote = await getBuyQuote({ - currency: { id: eur.id }, - asset: { id: eth.id }, - amount: 1, - }); - - if (!quote.isValid) { - expect(quote.error).toBeTruthy(); - console.log(`Amount too low error: ${quote.error}`); - } - }); - - test('should create buy payment info for EUR -> ETH', async () => { - const eur = sellableFiats.find((f) => f.name === 'EUR'); - const eth = buyableAssets.find((a) => a.name === 'ETH' && EVM_BLOCKCHAINS.includes(a.blockchain)); - - if (!eur || !eth) { - console.log('Skipping: EUR or ETH not available'); - return; - } - - const result = await createBuyPaymentInfo(client, { - currency: { id: eur.id }, - asset: { id: eth.id }, - amount: 100, - }); - - if (result.error) { - console.log(`Payment info creation returned error: ${result.error} (status: ${result.status})`); - const expectedErrors = ['Trading not allowed', 'RecommendationRequired', 'EmailRequired', 'KYC required', 'KycRequired', 'User not found', 'Ident data incomplete']; - const isExpectedError = expectedErrors.some((e) => result.error?.includes(e)); - if (isExpectedError) { - console.log('Skipping test - account restriction'); - return; - } - expect(result.data).toBeTruthy(); - return; - } - - const paymentInfo = result.data; - if (!paymentInfo) return; - expect(paymentInfo.id).toBeGreaterThan(0); - expect(paymentInfo.amount).toBe(100); - expect(paymentInfo.currency.name).toBe('EUR'); - expect(paymentInfo.estimatedAmount).toBeGreaterThan(0); - expect(paymentInfo.rate).toBeGreaterThan(0); - - console.log(`Created payment info ID: ${paymentInfo.id}, Amount: ${paymentInfo.estimatedAmount} ETH`); - }); - - test('should handle multiple currencies', async () => { - const eth = buyableAssets.find((a) => a.name === 'ETH' && EVM_BLOCKCHAINS.includes(a.blockchain)); - - if (!eth) { - console.log('Skipping: ETH not available'); - return; - } - - const currencies = ['EUR', 'CHF', 'USD'].map((name) => sellableFiats.find((f) => f.name === name)).filter(Boolean); - - for (const currency of currencies) { - if (!currency) continue; - - const quote = await getBuyQuote({ - currency: { id: currency.id }, - asset: { id: eth.id }, - amount: 100, - }); - - expect(quote.estimatedAmount).toBeGreaterThan(0); - console.log(`${currency.name} 100 -> ${quote.estimatedAmount} ETH`); - - await delay(500); - } - }, 15000); - - test('should handle multiple assets', async () => { - const eur = sellableFiats.find((f) => f.name === 'EUR'); - - if (!eur) { - console.log('Skipping: EUR not available'); - return; - } - - const assets = ['ETH', 'USDT', 'USDC'] - .map((name) => buyableAssets.find((a) => a.name === name)) - .filter(Boolean); - - for (const asset of assets) { - if (!asset) continue; - - const quote = await getBuyQuote({ - currency: { id: eur.id }, - asset: { id: asset.id }, - amount: 100, - }); - - expect(quote.estimatedAmount).toBeGreaterThan(0); - console.log(`EUR 100 -> ${quote.estimatedAmount} ${asset.name}`); - - await delay(500); - } - }, 15000); -}); diff --git a/src/__tests__/api/helpers/api-client.ts b/src/__tests__/api/helpers/api-client.ts deleted file mode 100644 index d41c8dafd..000000000 --- a/src/__tests__/api/helpers/api-client.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { createTestCredentials, TestCredentials } from './test-wallet'; - -const API_URL = 'https://dev.api.dfx.swiss/v1'; - -// Global cache for auth tokens to avoid rate limiting -const tokenCache: Map = new Map(); - -// Cache for credentials -let cachedCredentials: TestCredentials | null = null; - -// Mutex for serializing auth requests -let authMutex: Promise = Promise.resolve(); - -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function generateCredentials(): Promise { - if (cachedCredentials) { - return cachedCredentials; - } - - cachedCredentials = await createTestCredentials(); - return cachedCredentials; -} - -async function authenticateWithRetry( - credentials: TestCredentials, - maxRetries = 5, -): Promise { - let lastError: Error | null = null; - - for (let attempt = 0; attempt < maxRetries; attempt++) { - if (attempt > 0) { - const backoffMs = Math.pow(2, attempt) * 1000; - console.log(`Auth retry ${attempt}/${maxRetries}, waiting ${backoffMs}ms...`); - await delay(backoffMs); - } - - try { - const response = await fetch(`${API_URL}/auth`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(credentials), - }); - - if (response.ok) { - const data = await response.json(); - return data.accessToken; - } - - const status = response.status; - if (status === 429) { - console.log(`Rate limited (429), will retry...`); - lastError = new Error(`Rate limited: ${status}`); - continue; - } - - const body = await response.text().catch(() => 'unknown'); - throw new Error(`Auth failed with status ${status}: ${body}`); - } catch (e) { - if (e instanceof Error && e.message.includes('Rate limited')) { - lastError = e; - continue; - } - throw e; - } - } - - throw lastError || new Error('Authentication failed after retries'); -} - -async function getCachedAuth(): Promise<{ token: string; credentials: TestCredentials }> { - const credentials = await generateCredentials(); - const cacheKey = `evm:${credentials.address}`; - - const cached = tokenCache.get(cacheKey); - if (cached && cached.expiry > Date.now()) { - console.log(`Using cached token for evm`); - return { token: cached.token, credentials }; - } - - const currentMutex = authMutex; - let resolve: () => void = () => { /* noop */ }; - authMutex = new Promise((r) => (resolve = r)); - - try { - await currentMutex; - - const cachedAgain = tokenCache.get(cacheKey); - if (cachedAgain && cachedAgain.expiry > Date.now()) { - console.log(`Using cached token for evm (after mutex)`); - return { token: cachedAgain.token, credentials }; - } - - await delay(1000); - - console.log(`Authenticating evm address: ${credentials.address}`); - const token = await authenticateWithRetry(credentials); - - tokenCache.set(cacheKey, { - token, - expiry: Date.now() + 2 * 60 * 60 * 1000, - }); - - return { token, credentials }; - } finally { - resolve(); - } -} - -export { getTestIban } from './test-wallet'; - -export class ApiClient { - private token: string; - - constructor(token: string) { - this.token = token; - } - - private async request( - method: string, - path: string, - data?: unknown, - requireAuth = true, - ): Promise<{ data: T | null; error: string | null; status: number }> { - const headers: Record = { - 'Content-Type': 'application/json', - }; - - if (requireAuth) { - headers['Authorization'] = `Bearer ${this.token}`; - } - - const response = await fetch(`${API_URL}${path}`, { - method, - headers, - body: data ? JSON.stringify(data) : undefined, - }); - - if (response.ok) { - const json = await response.json(); - return { data: json, error: null, status: response.status }; - } - - const errorBody = await response.json().catch(() => ({})); - return { - data: null, - error: (errorBody as { message?: string }).message || 'Unknown error', - status: response.status, - }; - } - - async get(path: string, requireAuth = true): Promise<{ data: T | null; error: string | null; status: number }> { - return this.request('GET', path, undefined, requireAuth); - } - - async post(path: string, data?: unknown, requireAuth = true): Promise<{ data: T | null; error: string | null; status: number }> { - return this.request('POST', path, data, requireAuth); - } - - async put(path: string, data?: unknown, requireAuth = true): Promise<{ data: T | null; error: string | null; status: number }> { - return this.request('PUT', path, data, requireAuth); - } - - async delete(path: string, requireAuth = true): Promise<{ data: T | null; error: string | null; status: number }> { - return this.request('DELETE', path, undefined, requireAuth); - } -} - -export async function createApiClient(): Promise<{ client: ApiClient; credentials: TestCredentials }> { - const { token, credentials } = await getCachedAuth(); - return { client: new ApiClient(token), credentials }; -} diff --git a/src/__tests__/api/helpers/test-wallet.ts b/src/__tests__/api/helpers/test-wallet.ts deleted file mode 100644 index e772d4208..000000000 --- a/src/__tests__/api/helpers/test-wallet.ts +++ /dev/null @@ -1,27 +0,0 @@ -// Pre-computed test credentials -// Generated from mnemonic: "below debris olive author enhance ankle drum angle buyer cruel school milk" -// WARNING: This is for testing only! Never use for real funds! -const PRECOMPUTED_EVM_ADDRESS = '0x4B33B90cFC38341Db2b9EC5cF3B737508801c617'; -const PRECOMPUTED_EVM_SIGNATURE = - '0x18e4049227f7006f6820233b5dd4ff9e76af0b2125d2d927efc5e6934db1837313ffa8ed80556c3bc558e44d6c6971bec8db12d3db9d99364ec2992d3e4a1f511c'; -const TEST_IBAN_DEFAULT = 'CH9300762011623852957'; - -export interface TestCredentials { - address: string; - signature: string; -} - -export function getTestIban(): string { - return TEST_IBAN_DEFAULT; -} - -/** - * Returns pre-computed EVM test credentials. - * Using pre-computed values to avoid webpack Buffer polyfill conflicts with ethers.js. - */ -export async function createTestCredentials(): Promise { - return { - address: PRECOMPUTED_EVM_ADDRESS, - signature: PRECOMPUTED_EVM_SIGNATURE, - }; -} diff --git a/src/__tests__/api/sell-api.test.ts b/src/__tests__/api/sell-api.test.ts deleted file mode 100644 index 7e8f2e571..000000000 --- a/src/__tests__/api/sell-api.test.ts +++ /dev/null @@ -1,313 +0,0 @@ -import { ApiClient, createApiClient, getTestIban } from './helpers/api-client'; -import { TestCredentials } from './helpers/test-wallet'; - -const API_URL = 'https://dev.api.dfx.swiss/v1'; - -const EVM_BLOCKCHAINS = ['Ethereum', 'Arbitrum', 'Optimism', 'Polygon', 'Base', 'BinanceSmartChain', 'Gnosis']; - -interface Asset { - id: number; - name: string; - uniqueName: string; - blockchain: string; - sellable: boolean; -} - -interface Fiat { - id: number; - name: string; - buyable: boolean; -} - -interface SellPaymentInfo { - id: number; - routeId: number; - amount: number; - currency: { id: number; name: string }; - asset: { id: number; name: string }; - estimatedAmount: number; - rate: number; - exchangeRate: number; - minVolume: number; - maxVolume: number; - fees: { total: number }; - feesTarget: { total: number }; - isValid: boolean; - error?: string; - depositAddress?: string; -} - -interface SellQuote { - amount: number; - estimatedAmount: number; - rate: number; - exchangeRate: number; - minVolume: number; - maxVolume: number; - feeAmount: number; - isValid: boolean; - error?: string; -} - -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function getAssets(client: ApiClient): Promise { - const result = await client.get('/asset'); - expect(result.data).toBeTruthy(); - return result.data ?? []; -} - -async function getFiats(): Promise { - const response = await fetch(`${API_URL}/fiat`); - expect(response.ok).toBeTruthy(); - return response.json(); -} - -async function getSellQuote( - params: { asset: { id: number }; currency: { id: number }; amount: number }, -): Promise { - const response = await fetch(`${API_URL}/sell/quote`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - asset: params.asset, - currency: params.currency, - amount: params.amount, - }), - }); - - expect(response.ok).toBeTruthy(); - return response.json(); -} - -async function createSellPaymentInfo( - client: ApiClient, - params: { asset: { id: number }; currency: { id: number }; amount: number; iban: string }, -): Promise<{ data: SellPaymentInfo | null; error: string | null; status: number }> { - return client.put('/sell/paymentInfos', { - asset: params.asset, - currency: params.currency, - amount: params.amount, - iban: params.iban, - }); -} - -// API Integration tests for Sell Process (EVM only) -describe('Sell Process - API Integration', () => { - let client: ApiClient; - let credentials: TestCredentials; - let sellableAssets: Asset[]; - let buyableFiats: Fiat[]; - let testIban: string; - - beforeAll(async () => { - const auth = await createApiClient(); - client = auth.client; - credentials = auth.credentials; - testIban = getTestIban(); - console.log(`Using EVM test address: ${credentials.address}`); - - const [assets, fiats] = await Promise.all([getAssets(client), getFiats()]); - - sellableAssets = assets.filter((a) => a.sellable && EVM_BLOCKCHAINS.includes(a.blockchain)); - buyableFiats = fiats.filter((f) => f.buyable); - - expect(sellableAssets.length).toBeGreaterThan(0); - expect(buyableFiats.length).toBeGreaterThan(0); - }, 60000); - - test('should authenticate with EVM credentials', async () => { - const response = await fetch(`${API_URL}/auth`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(credentials), - }); - - expect(response.ok).toBeTruthy(); - const data = await response.json(); - expect(data.accessToken).toBeTruthy(); - }); - - test('should fetch sellable assets', async () => { - const assets = await getAssets(client); - const sellable = assets.filter((a) => a.sellable && EVM_BLOCKCHAINS.includes(a.blockchain)); - - expect(sellable.length).toBeGreaterThan(0); - console.log(`Found ${sellable.length} sellable EVM assets`); - }); - - test('should fetch buyable fiats', async () => { - const fiats = await getFiats(); - const buyable = fiats.filter((f) => f.buyable); - - expect(buyable.length).toBeGreaterThan(0); - console.log(`Found ${buyable.length} buyable fiats`); - - const eurExists = buyable.some((f) => f.name === 'EUR'); - const chfExists = buyable.some((f) => f.name === 'CHF'); - expect(eurExists || chfExists).toBeTruthy(); - }); - - test('should get sell quote for ETH -> EUR', async () => { - const eth = sellableAssets.find((a) => a.name === 'ETH' && EVM_BLOCKCHAINS.includes(a.blockchain)); - const eur = buyableFiats.find((f) => f.name === 'EUR'); - - if (!eth || !eur) { - console.log('Skipping: ETH or EUR not available'); - return; - } - - const quote = await getSellQuote({ - asset: { id: eth.id }, - currency: { id: eur.id }, - amount: 0.1, - }); - - expect(quote.amount).toBe(0.1); - expect(quote.estimatedAmount).toBeGreaterThan(0); - expect(quote.rate).toBeGreaterThan(0); - expect(quote.minVolume).toBeGreaterThan(0); - expect(quote.maxVolume).toBeGreaterThan(0); - - console.log(`Quote: 0.1 ETH -> ${quote.estimatedAmount} EUR (rate: ${quote.rate})`); - }); - - test('should get sell quote for USDC -> CHF', async () => { - const usdc = sellableAssets.find((a) => a.name === 'USDC' && EVM_BLOCKCHAINS.includes(a.blockchain)); - const chf = buyableFiats.find((f) => f.name === 'CHF'); - - if (!usdc || !chf) { - console.log('Skipping: USDC or CHF not available'); - return; - } - - const quote = await getSellQuote({ - asset: { id: usdc.id }, - currency: { id: chf.id }, - amount: 100, - }); - - expect(quote.amount).toBe(100); - expect(quote.estimatedAmount).toBeGreaterThan(0); - expect(quote.rate).toBeGreaterThan(0); - - console.log(`Quote: 100 USDC -> ${quote.estimatedAmount} CHF (rate: ${quote.rate})`); - }); - - test('should reject amount below minimum', async () => { - const eth = sellableAssets.find((a) => a.name === 'ETH' && EVM_BLOCKCHAINS.includes(a.blockchain)); - const eur = buyableFiats.find((f) => f.name === 'EUR'); - - if (!eth || !eur) { - console.log('Skipping: ETH or EUR not available'); - return; - } - - const quote = await getSellQuote({ - asset: { id: eth.id }, - currency: { id: eur.id }, - amount: 0.0001, - }); - - if (!quote.isValid) { - expect(quote.error).toBeTruthy(); - console.log(`Amount too low error: ${quote.error}`); - } - }); - - test('should create sell payment info for ETH -> EUR', async () => { - const eth = sellableAssets.find((a) => a.name === 'ETH' && EVM_BLOCKCHAINS.includes(a.blockchain)); - const eur = buyableFiats.find((f) => f.name === 'EUR'); - - if (!eth || !eur) { - console.log('Skipping: ETH or EUR not available'); - return; - } - - const result = await createSellPaymentInfo(client, { - asset: { id: eth.id }, - currency: { id: eur.id }, - amount: 0.1, - iban: testIban, - }); - - if (result.error) { - console.log(`Payment info creation returned error: ${result.error} (status: ${result.status})`); - const expectedErrors = ['Trading not allowed', 'RecommendationRequired', 'EmailRequired', 'KYC required', 'KycRequired', 'User not found', 'Ident data incomplete']; - const isExpectedError = expectedErrors.some((e) => result.error?.includes(e)); - if (isExpectedError) { - console.log('Skipping test - account restriction'); - return; - } - expect(result.data).toBeTruthy(); - return; - } - - const paymentInfo = result.data; - if (!paymentInfo) return; - expect(paymentInfo.id).toBeGreaterThan(0); - expect(paymentInfo.amount).toBe(0.1); - expect(paymentInfo.estimatedAmount).toBeGreaterThan(0); - expect(paymentInfo.rate).toBeGreaterThan(0); - - console.log(`Created sell payment info ID: ${paymentInfo.id}, Amount: ${paymentInfo.estimatedAmount} EUR`); - }); - - test('should handle multiple fiat currencies', async () => { - const eth = sellableAssets.find((a) => a.name === 'ETH' && EVM_BLOCKCHAINS.includes(a.blockchain)); - - if (!eth) { - console.log('Skipping: ETH not available'); - return; - } - - const currencies = ['EUR', 'CHF', 'USD'].map((name) => buyableFiats.find((f) => f.name === name)).filter(Boolean); - - for (const currency of currencies) { - if (!currency) continue; - - const quote = await getSellQuote({ - asset: { id: eth.id }, - currency: { id: currency.id }, - amount: 0.1, - }); - - expect(quote.estimatedAmount).toBeGreaterThan(0); - console.log(`0.1 ETH -> ${quote.estimatedAmount} ${currency.name}`); - - await delay(500); - } - }, 15000); - - test('should handle multiple assets', async () => { - const eur = buyableFiats.find((f) => f.name === 'EUR'); - - if (!eur) { - console.log('Skipping: EUR not available'); - return; - } - - const assets = ['ETH', 'USDT', 'USDC'] - .map((name) => sellableAssets.find((a) => a.name === name)) - .filter(Boolean); - - for (const asset of assets) { - if (!asset) continue; - - const amount = asset.name === 'ETH' ? 0.1 : 100; - - const quote = await getSellQuote({ - asset: { id: asset.id }, - currency: { id: eur.id }, - amount, - }); - - expect(quote.estimatedAmount).toBeGreaterThan(0); - console.log(`${amount} ${asset.name} -> ${quote.estimatedAmount} EUR`); - - await delay(500); - } - }, 15000); -}); diff --git a/src/__tests__/api/swap-api.test.ts b/src/__tests__/api/swap-api.test.ts deleted file mode 100644 index 7910d97be..000000000 --- a/src/__tests__/api/swap-api.test.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { ApiClient, createApiClient } from './helpers/api-client'; -import { TestCredentials } from './helpers/test-wallet'; - -const API_URL = 'https://dev.api.dfx.swiss/v1'; - -const EVM_BLOCKCHAINS = ['Ethereum', 'Arbitrum', 'Optimism', 'Polygon', 'Base', 'BinanceSmartChain', 'Gnosis']; - -interface Asset { - id: number; - name: string; - uniqueName: string; - blockchain: string; - buyable: boolean; - sellable: boolean; -} - -interface SwapQuote { - amount: number; - estimatedAmount: number; - exchangeRate: number; - minVolume: number; - maxVolume: number; - feeAmount: number; - fees: { total: number }; - feesTarget: { total: number }; - isValid: boolean; - error?: string; -} - -interface SwapPaymentInfo { - id: number; - routeId: number; - amount: number; - sourceAsset: { id: number; name: string }; - targetAsset: { id: number; name: string }; - estimatedAmount: number; - rate: number; - exchangeRate: number; - minVolume: number; - maxVolume: number; - fees: { total: number }; - feesTarget: { total: number }; - isValid: boolean; - error?: string; - depositAddress?: string; -} - -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function getAssets(client: ApiClient): Promise { - const result = await client.get('/asset'); - expect(result.data).toBeTruthy(); - return result.data ?? []; -} - -async function getSwapQuote( - params: { sourceAsset: { id: number }; targetAsset: { id: number }; amount: number }, -): Promise { - const response = await fetch(`${API_URL}/swap/quote`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sourceAsset: params.sourceAsset, - targetAsset: params.targetAsset, - amount: params.amount, - }), - }); - - expect(response.ok).toBeTruthy(); - return response.json(); -} - -async function createSwapPaymentInfo( - client: ApiClient, - params: { sourceAsset: { id: number }; targetAsset: { id: number }; amount: number }, -): Promise<{ data: SwapPaymentInfo | null; error: string | null; status: number }> { - return client.put('/swap/paymentInfos', { - sourceAsset: params.sourceAsset, - targetAsset: params.targetAsset, - amount: params.amount, - }); -} - -// API Integration tests for Swap Process (EVM only) -describe('Swap Process - API Integration', () => { - let client: ApiClient; - let credentials: TestCredentials; - let swappableAssets: Asset[]; - - beforeAll(async () => { - const auth = await createApiClient(); - client = auth.client; - credentials = auth.credentials; - console.log(`Using test address: ${credentials.address}`); - - const assets = await getAssets(client); - - swappableAssets = assets.filter( - (a) => a.buyable && a.sellable && EVM_BLOCKCHAINS.includes(a.blockchain), - ); - - expect(swappableAssets.length).toBeGreaterThan(0); - console.log(`Found ${swappableAssets.length} swappable EVM assets`); - }, 60000); - - test('should authenticate with test credentials', async () => { - const response = await fetch(`${API_URL}/auth`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(credentials), - }); - - expect(response.ok).toBeTruthy(); - const data = await response.json(); - expect(data.accessToken).toBeTruthy(); - }); - - test('should fetch swappable assets', async () => { - const assets = await getAssets(client); - const swappable = assets.filter( - (a) => a.buyable && a.sellable && EVM_BLOCKCHAINS.includes(a.blockchain), - ); - - expect(swappable.length).toBeGreaterThan(0); - console.log(`Found ${swappable.length} swappable EVM assets`); - - const hasEth = swappable.some((a) => a.name === 'ETH'); - const hasUsdt = swappable.some((a) => a.name === 'USDT'); - const hasUsdc = swappable.some((a) => a.name === 'USDC'); - - expect(hasEth || hasUsdt || hasUsdc).toBeTruthy(); - }); - - test('should get swap quote for ETH -> USDT', async () => { - const eth = swappableAssets.find((a) => a.name === 'ETH' && EVM_BLOCKCHAINS.includes(a.blockchain)); - const usdt = swappableAssets.find((a) => a.name === 'USDT' && EVM_BLOCKCHAINS.includes(a.blockchain)); - - if (!eth || !usdt) { - console.log('Skipping: ETH or USDT not available'); - return; - } - - const quote = await getSwapQuote({ - sourceAsset: { id: eth.id }, - targetAsset: { id: usdt.id }, - amount: 0.1, - }); - - expect(quote.amount).toBe(0.1); - expect(quote.estimatedAmount).toBeGreaterThan(0); - expect(quote.exchangeRate).toBeGreaterThan(0); - expect(quote.minVolume).toBeGreaterThan(0); - expect(quote.maxVolume).toBeGreaterThan(0); - - console.log(`Quote: 0.1 ETH -> ${quote.estimatedAmount} USDT (rate: ${quote.exchangeRate})`); - }); - - test('should get swap quote for USDC -> ETH', async () => { - const usdc = swappableAssets.find((a) => a.name === 'USDC' && EVM_BLOCKCHAINS.includes(a.blockchain)); - const eth = swappableAssets.find((a) => a.name === 'ETH' && EVM_BLOCKCHAINS.includes(a.blockchain)); - - if (!usdc || !eth) { - console.log('Skipping: USDC or ETH not available'); - return; - } - - const quote = await getSwapQuote({ - sourceAsset: { id: usdc.id }, - targetAsset: { id: eth.id }, - amount: 100, - }); - - expect(quote.amount).toBe(100); - expect(quote.estimatedAmount).toBeGreaterThan(0); - expect(quote.exchangeRate).toBeGreaterThan(0); - - console.log(`Quote: 100 USDC -> ${quote.estimatedAmount} ETH (rate: ${quote.exchangeRate})`); - }); - - test('should get swap quote for USDT -> USDC (stablecoin swap)', async () => { - const usdt = swappableAssets.find((a) => a.name === 'USDT' && EVM_BLOCKCHAINS.includes(a.blockchain)); - const usdc = swappableAssets.find((a) => a.name === 'USDC' && EVM_BLOCKCHAINS.includes(a.blockchain)); - - if (!usdt || !usdc) { - console.log('Skipping: USDT or USDC not available'); - return; - } - - const quote = await getSwapQuote({ - sourceAsset: { id: usdt.id }, - targetAsset: { id: usdc.id }, - amount: 100, - }); - - expect(quote.amount).toBe(100); - expect(quote.estimatedAmount).toBeGreaterThan(0); - expect(quote.exchangeRate).toBeGreaterThan(0.9); - expect(quote.exchangeRate).toBeLessThan(1.1); - - console.log(`Quote: 100 USDT -> ${quote.estimatedAmount} USDC (rate: ${quote.exchangeRate})`); - }); - - test('should reject amount below minimum', async () => { - const eth = swappableAssets.find((a) => a.name === 'ETH' && EVM_BLOCKCHAINS.includes(a.blockchain)); - const usdt = swappableAssets.find((a) => a.name === 'USDT' && EVM_BLOCKCHAINS.includes(a.blockchain)); - - if (!eth || !usdt) { - console.log('Skipping: ETH or USDT not available'); - return; - } - - const quote = await getSwapQuote({ - sourceAsset: { id: eth.id }, - targetAsset: { id: usdt.id }, - amount: 0.00001, - }); - - if (!quote.isValid) { - expect(quote.error).toBeTruthy(); - console.log(`Amount too low error: ${quote.error}`); - } - }); - - test.skip('should create swap payment info for ETH -> USDT', async () => { - const eth = swappableAssets.find((a) => a.name === 'ETH' && EVM_BLOCKCHAINS.includes(a.blockchain)); - const usdt = swappableAssets.find((a) => a.name === 'USDT' && EVM_BLOCKCHAINS.includes(a.blockchain)); - - if (!eth || !usdt) { - console.log('Skipping: ETH or USDT not available'); - return; - } - - const result = await createSwapPaymentInfo(client, { - sourceAsset: { id: eth.id }, - targetAsset: { id: usdt.id }, - amount: 0.1, - }); - - if (result.error) { - console.log(`Payment info creation returned error: ${result.error} (status: ${result.status})`); - const expectedErrors = ['Trading not allowed', 'RecommendationRequired', 'EmailRequired', 'KYC required', 'KycRequired', 'User not found', 'Ident data incomplete']; - const isExpectedError = expectedErrors.some((e) => result.error?.includes(e)); - if (isExpectedError) { - console.log('Skipping test - account restriction'); - return; - } - expect(result.data).toBeTruthy(); - return; - } - - const paymentInfo = result.data; - if (!paymentInfo) return; - expect(paymentInfo.id).toBeGreaterThan(0); - expect(paymentInfo.amount).toBe(0.1); - expect(paymentInfo.estimatedAmount).toBeGreaterThan(0); - expect(paymentInfo.rate).toBeGreaterThan(0); - - console.log(`Created swap payment info ID: ${paymentInfo.id}, Amount: ${paymentInfo.estimatedAmount} USDT`); - }); - - test('should handle multiple swap pairs', async () => { - const eth = swappableAssets.find((a) => a.name === 'ETH' && EVM_BLOCKCHAINS.includes(a.blockchain)); - - if (!eth) { - console.log('Skipping: ETH not available'); - return; - } - - const targetAssets = ['USDT', 'USDC'] - .map((name) => swappableAssets.find((a) => a.name === name && EVM_BLOCKCHAINS.includes(a.blockchain))) - .filter(Boolean); - - for (const targetAsset of targetAssets) { - if (!targetAsset) continue; - - const quote = await getSwapQuote({ - sourceAsset: { id: eth.id }, - targetAsset: { id: targetAsset.id }, - amount: 0.1, - }); - - expect(quote.estimatedAmount).toBeGreaterThan(0); - console.log(`0.1 ETH -> ${quote.estimatedAmount} ${targetAsset.name}`); - - await delay(500); - } - }); - - test('should handle reverse swap pairs', async () => { - const eth = swappableAssets.find((a) => a.name === 'ETH' && EVM_BLOCKCHAINS.includes(a.blockchain)); - const usdt = swappableAssets.find((a) => a.name === 'USDT' && EVM_BLOCKCHAINS.includes(a.blockchain)); - - if (!eth || !usdt) { - console.log('Skipping: ETH or USDT not available'); - return; - } - - const quote1 = await getSwapQuote({ - sourceAsset: { id: eth.id }, - targetAsset: { id: usdt.id }, - amount: 0.1, - }); - - await delay(500); - - const quote2 = await getSwapQuote({ - sourceAsset: { id: usdt.id }, - targetAsset: { id: eth.id }, - amount: 100, - }); - - expect(quote1.estimatedAmount).toBeGreaterThan(0); - expect(quote2.estimatedAmount).toBeGreaterThan(0); - - console.log(`0.1 ETH -> ${quote1.estimatedAmount} USDT`); - console.log(`100 USDT -> ${quote2.estimatedAmount} ETH`); - - const rate1 = quote1.estimatedAmount / 0.1; - const rate2 = 100 / quote2.estimatedAmount; - - expect(Math.abs(rate1 - rate2) / rate1).toBeLessThan(0.05); - }); -}); diff --git a/src/components/navigation.tsx b/src/components/navigation.tsx index b15677b76..5b92ab699 100644 --- a/src/components/navigation.tsx +++ b/src/components/navigation.tsx @@ -206,7 +206,7 @@ function NavigationMenu({ setIsNavigationOpen, small = false }: NavigationMenuCo onClose={() => setIsNavigationOpen(false)} /> )} - {session?.role && [UserRole.ADMIN].includes(session.role) && ( + {session?.role && [UserRole.ADMIN, UserRole.REALUNIT].includes(session.role) && ( (); const [priceHistory, setPriceHistory] = useState([]); const [timeframe, setTimeframe] = useState(Timeframe.ALL); + const [quotes, setQuotes] = useState([]); + const [transactions, setTransactions] = useState([]); + const [quotesLoading, setQuotesLoading] = useState(false); + const [transactionsLoading, setTransactionsLoading] = useState(false); - const { getAccountSummary, getAccountHistory, getHolders, getPriceHistory, getTokenInfo, getTokenPrice } = - useRealunitApi(); + const { + getAccountSummary, + getAccountHistory, + getHolders, + getPriceHistory, + getTokenInfo, + getTokenPrice, + getAdminQuotes, + getAdminTransactions, + confirmPayment, + } = useRealunitApi(); const fetchAccountSummary = useCallback( (address: string) => { @@ -46,6 +61,9 @@ export function RealunitContextProvider({ children }: PropsWithChildren): JSX.El .then((accountData) => { setAccountSummary(accountData); }) + .catch(() => { + setAccountSummary(undefined); + }) .finally(() => setIsLoading(false)); }, [setAccountSummary, setIsLoading], @@ -98,6 +116,28 @@ export function RealunitContextProvider({ children }: PropsWithChildren): JSX.El }); }, [setTokenPrice]); + const fetchQuotes = useCallback(() => { + setQuotesLoading(true); + getAdminQuotes(50, quotes.length) + .then((data) => { + setQuotes((prev) => [...prev, ...data]); + }) + .finally(() => setQuotesLoading(false)); + }, [quotes.length]); + + const resetQuotes = useCallback(() => { + setQuotes([]); + }, []); + + const fetchTransactions = useCallback(() => { + setTransactionsLoading(true); + getAdminTransactions(50, transactions.length) + .then((data) => { + setTransactions((prev) => [...prev, ...data]); + }) + .finally(() => setTransactionsLoading(false)); + }, [transactions.length]); + const context = useMemo( () => ({ accountSummary, @@ -110,12 +150,20 @@ export function RealunitContextProvider({ children }: PropsWithChildren): JSX.El tokenPrice, priceHistory, timeframe, + quotes, + transactions, + quotesLoading, + transactionsLoading, fetchAccountSummary, fetchAccountHistory, fetchHolders, fetchTokenInfo, fetchPriceHistory, fetchTokenPrice, + fetchQuotes, + resetQuotes, + fetchTransactions, + confirmPayment, }), [ accountSummary, @@ -128,12 +176,20 @@ export function RealunitContextProvider({ children }: PropsWithChildren): JSX.El tokenPrice, priceHistory, timeframe, + quotes, + transactions, + quotesLoading, + transactionsLoading, fetchAccountSummary, fetchAccountHistory, fetchHolders, fetchTokenInfo, fetchTokenPrice, fetchPriceHistory, + fetchQuotes, + resetQuotes, + fetchTransactions, + confirmPayment, ], ); diff --git a/src/dto/realunit.dto.ts b/src/dto/realunit.dto.ts index 03c61a3b6..9bae24d43 100644 --- a/src/dto/realunit.dto.ts +++ b/src/dto/realunit.dto.ts @@ -94,6 +94,28 @@ export enum PaginationDirection { PREV = 'prev', } +export interface RealUnitQuote { + id: number; + uid: string; + type: string; + status: string; + amount: number; + estimatedAmount: number; + created: string; + userAddress?: string; +} + +export interface RealUnitTransaction { + id: number; + uid: string; + type: string; + amountInChf: number; + assets: string; + created: string; + outputDate?: string; + userAddress?: string; +} + export interface RealunitContextInterface { accountSummary?: AccountSummary; history?: AccountHistory; @@ -105,10 +127,18 @@ export interface RealunitContextInterface { tokenPrice?: TokenPrice; priceHistory: PriceHistoryEntry[]; timeframe: Timeframe; + quotes: RealUnitQuote[]; + transactions: RealUnitTransaction[]; + quotesLoading: boolean; + transactionsLoading: boolean; fetchAccountSummary: (address: string) => void; fetchAccountHistory: (address: string, cursor?: string, direction?: PaginationDirection) => void; fetchHolders: (cursor?: string, direction?: PaginationDirection) => void; fetchTokenInfo: () => void; fetchPriceHistory: (timeframe?: Timeframe) => void; fetchTokenPrice: () => void; + fetchQuotes: () => void; + resetQuotes: () => void; + fetchTransactions: () => void; + confirmPayment: (id: number) => Promise; } diff --git a/src/hooks/guard.hook.ts b/src/hooks/guard.hook.ts index 9b284bf99..ee94a2342 100644 --- a/src/hooks/guard.hook.ts +++ b/src/hooks/guard.hook.ts @@ -15,6 +15,10 @@ export function useAdminGuard(redirectPath = '/', isActive = true) { useUserRoleGuard([UserRole.ADMIN], redirectPath, isActive); } +export function useRealunitGuard(redirectPath = '/', isActive = true) { + useUserRoleGuard([UserRole.ADMIN, UserRole.REALUNIT], redirectPath, isActive); +} + export function useComplianceGuard(redirectPath = '/', isActive = true) { useUserRoleGuard([UserRole.ADMIN, UserRole.COMPLIANCE], redirectPath, isActive); } diff --git a/src/hooks/realunit-api.hook.ts b/src/hooks/realunit-api.hook.ts index 147ec9ebe..bbc2b51f1 100644 --- a/src/hooks/realunit-api.hook.ts +++ b/src/hooks/realunit-api.hook.ts @@ -6,6 +6,8 @@ import { HoldersResponse, PaginationDirection, PriceHistoryEntry, + RealUnitQuote, + RealUnitTransaction, TokenInfo, TokenPrice, } from 'src/dto/realunit.dto'; @@ -70,6 +72,35 @@ export function useRealunitApi() { }); } + async function getAdminQuotes(limit?: number, offset?: number): Promise { + const params = new URLSearchParams(); + if (limit != null) params.set('limit', String(limit)); + if (offset != null) params.set('offset', String(offset)); + + return call({ + url: relativeUrl({ path: 'realunit/admin/quotes', params }), + method: 'GET', + }); + } + + async function getAdminTransactions(limit?: number, offset?: number): Promise { + const params = new URLSearchParams(); + if (limit != null) params.set('limit', String(limit)); + if (offset != null) params.set('offset', String(offset)); + + return call({ + url: relativeUrl({ path: 'realunit/admin/transactions', params }), + method: 'GET', + }); + } + + async function confirmPayment(id: number): Promise { + return call({ + url: `realunit/admin/quotes/${id}/confirm-payment`, + method: 'PUT', + }); + } + return useMemo( () => ({ getAccountSummary, @@ -78,6 +109,9 @@ export function useRealunitApi() { getTokenInfo, getTokenPrice, getPriceHistory, + getAdminQuotes, + getAdminTransactions, + confirmPayment, }), [call], ); diff --git a/src/screens/realunit-holders.screen.tsx b/src/screens/realunit-holders.screen.tsx new file mode 100644 index 000000000..c243d5c70 --- /dev/null +++ b/src/screens/realunit-holders.screen.tsx @@ -0,0 +1,116 @@ +import { + CopyButton, + IconColor, + SpinnerSize, + StyledButton, + StyledButtonWidth, + StyledLoadingSpinner, +} from '@dfx.swiss/react-components'; +import { useEffect } from 'react'; +import { useRealunitContext } from 'src/contexts/realunit.context'; +import { useSettingsContext } from 'src/contexts/settings.context'; +import { PaginationDirection } from 'src/dto/realunit.dto'; +import { useClipboard } from 'src/hooks/clipboard.hook'; +import { useRealunitGuard } from 'src/hooks/guard.hook'; +import { useLayoutOptions } from 'src/hooks/layout-config.hook'; +import { useNavigation } from 'src/hooks/navigation.hook'; +import { blankedAddress } from 'src/util/utils'; + +export default function RealunitHoldersScreen(): JSX.Element { + useRealunitGuard(); + + const { translate } = useSettingsContext(); + const { navigate } = useNavigation(); + const { copy } = useClipboard(); + + const { holders, totalCount, pageInfo, isLoading, fetchHolders } = useRealunitContext(); + + useLayoutOptions({ title: translate('screens/realunit', 'All Holders'), backButton: true }); + + useEffect(() => { + if (!holders.length) fetchHolders(); + }, [fetchHolders]); + + const handleAddressClick = (address: string) => { + const encodedAddress = encodeURIComponent(address); + navigate(`/realunit/user/${encodedAddress}`); + }; + + const changePage = (dir: PaginationDirection) => + fetchHolders(dir === PaginationDirection.NEXT ? pageInfo.endCursor : pageInfo.startCursor, dir); + + return ( + <> + {isLoading && !holders.length ? ( + + ) : ( +
+
+

+ {translate('screens/realunit', 'All Holders')} ({totalCount?.toLocaleString() ?? '0'}) +

+ + + + + + + + + + + {holders.map((holder) => ( + + + + + + ))} + +
+ {translate('screens/realunit', 'Address')} + + {translate('screens/realunit', 'Balance')} + + {translate('screens/realunit', 'Percentage')} +
+
+ + copy(holder.address)} /> +
+
{holder.balance}{holder.percentage.toFixed(2)}%
+
+ +
+
+ changePage(PaginationDirection.PREV)} + disabled={!pageInfo.hasPreviousPage} + width={StyledButtonWidth.MIN} + /> +
+ +
+ changePage(PaginationDirection.NEXT)} + disabled={!pageInfo.hasNextPage} + width={StyledButtonWidth.MIN} + /> +
+
+
+ )} + + ); +} diff --git a/src/screens/realunit-quote-detail.screen.tsx b/src/screens/realunit-quote-detail.screen.tsx new file mode 100644 index 000000000..e74744eb3 --- /dev/null +++ b/src/screens/realunit-quote-detail.screen.tsx @@ -0,0 +1,147 @@ +import { SpinnerSize, StyledButton, StyledButtonWidth, StyledLoadingSpinner } from '@dfx.swiss/react-components'; +import { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { ConfirmationOverlay } from 'src/components/overlay/confirmation-overlay'; +import { useRealunitContext } from 'src/contexts/realunit.context'; +import { useSettingsContext } from 'src/contexts/settings.context'; +import { useRealunitGuard } from 'src/hooks/guard.hook'; +import { useLayoutOptions } from 'src/hooks/layout-config.hook'; +import { useNavigation } from 'src/hooks/navigation.hook'; +import { blankedAddress } from 'src/util/utils'; + +export default function RealunitQuoteDetailScreen(): JSX.Element { + useRealunitGuard(); + + const { translate } = useSettingsContext(); + const { navigate } = useNavigation(); + const { id } = useParams<{ id: string }>(); + const { quotes, quotesLoading, fetchQuotes, resetQuotes, confirmPayment } = useRealunitContext(); + const [showConfirmation, setShowConfirmation] = useState(false); + + useLayoutOptions({ title: translate('screens/realunit', 'Quote Detail'), backButton: true }); + + useEffect(() => { + if (!quotes.length) fetchQuotes(); + }, [fetchQuotes]); + + const quote = quotes.find((q) => q.id === Number(id)); + + const displayType = (type: string): string => { + switch (type) { + case 'BuyFiat': + return 'Sell'; + case 'BuyCrypto': + return 'Buy'; + default: + return type; + } + }; + + if (quotesLoading && !quotes.length) { + return ; + } + + if (!quote) { + return

{translate('screens/realunit', 'Quote not found')}

; + } + + return ( +
+

{translate('screens/realunit', 'Quote Detail')}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ {translate('screens/realunit', 'Key')} + + {translate('screens/realunit', 'Value')} +
+ {translate('screens/realunit', 'Type')} + {displayType(quote.type)}
+ {translate('screens/realunit', 'Status')} + {quote.status}
+ {translate('screens/realunit', 'Amount')} + {quote.amount?.toLocaleString()}
+ {translate('screens/realunit', 'Estimated Amount')} + + {quote.estimatedAmount?.toLocaleString()} +
+ {translate('screens/realunit', 'User')} + + {quote.userAddress ? ( + + ) : ( + '-' + )} +
+ {translate('screens/realunit', 'Created')} + + {new Date(quote.created).toLocaleString()} +
+ + {quote.status === 'WaitingForPayment' && ( +
+ setShowConfirmation(true)} + width={StyledButtonWidth.FULL} + /> +
+ )} + + {showConfirmation && ( + setShowConfirmation(false)} + onConfirm={async () => { + await confirmPayment(quote.id); + resetQuotes(); + setShowConfirmation(false); + navigate(-1); + }} + /> + )} +
+ ); +} diff --git a/src/screens/realunit-quotes.screen.tsx b/src/screens/realunit-quotes.screen.tsx new file mode 100644 index 000000000..ba2c80870 --- /dev/null +++ b/src/screens/realunit-quotes.screen.tsx @@ -0,0 +1,106 @@ +import { SpinnerSize, StyledButton, StyledButtonWidth, StyledLoadingSpinner } from '@dfx.swiss/react-components'; +import { useEffect } from 'react'; +import { useRealunitContext } from 'src/contexts/realunit.context'; +import { useSettingsContext } from 'src/contexts/settings.context'; +import { useRealunitGuard } from 'src/hooks/guard.hook'; +import { useLayoutOptions } from 'src/hooks/layout-config.hook'; +import { useNavigation } from 'src/hooks/navigation.hook'; +import { blankedAddress } from 'src/util/utils'; + +export default function RealunitQuotesScreen(): JSX.Element { + useRealunitGuard(); + + const { translate } = useSettingsContext(); + const { navigate } = useNavigation(); + const { quotes, quotesLoading, fetchQuotes } = useRealunitContext(); + + useLayoutOptions({ title: translate('screens/realunit', 'Pending Transactions'), backButton: true }); + + const displayType = (type: string): string => { + switch (type) { + case 'BuyFiat': + return 'Sell'; + case 'BuyCrypto': + return 'Buy'; + default: + return type; + } + }; + + useEffect(() => { + if (!quotes.length) fetchQuotes(); + }, [fetchQuotes]); + + return ( + <> + {quotesLoading && !quotes.length ? ( + + ) : ( +
+
+

{translate('screens/realunit', 'Pending Transactions')}

+ + + + + + + + + + + {quotes.map((quote) => ( + navigate(`/realunit/quotes/${quote.id}`)} + > + + + + + + ))} + {!quotes.length && !quotesLoading && ( + + + + )} + +
+ {translate('screens/realunit', 'Type')} + + {translate('screens/realunit', 'Amount')} + + {translate('screens/realunit', 'User')} + + {translate('screens/realunit', 'Created')} +
{displayType(quote.type)}{quote.amount?.toLocaleString()} + {quote.userAddress ? blankedAddress(quote.userAddress, { displayLength: 12 }) : '-'} + + {new Date(quote.created).toLocaleString()} +
+ {translate('screens/realunit', 'No pending transactions found')} +
+
+ + {quotes.length > 0 && ( +
+ +
+ )} + {quotesLoading && quotes.length > 0 && ( +
+ +
+ )} +
+ )} + + ); +} diff --git a/src/screens/realunit-transaction-detail.screen.tsx b/src/screens/realunit-transaction-detail.screen.tsx new file mode 100644 index 000000000..1c786a28c --- /dev/null +++ b/src/screens/realunit-transaction-detail.screen.tsx @@ -0,0 +1,111 @@ +import { SpinnerSize, StyledLoadingSpinner } from '@dfx.swiss/react-components'; +import { useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import { useRealunitContext } from 'src/contexts/realunit.context'; +import { useSettingsContext } from 'src/contexts/settings.context'; +import { useRealunitGuard } from 'src/hooks/guard.hook'; +import { useLayoutOptions } from 'src/hooks/layout-config.hook'; +import { useNavigation } from 'src/hooks/navigation.hook'; +import { blankedAddress } from 'src/util/utils'; + +export default function RealunitTransactionDetailScreen(): JSX.Element { + useRealunitGuard(); + + const { translate } = useSettingsContext(); + const { navigate } = useNavigation(); + const { id } = useParams<{ id: string }>(); + const { transactions, transactionsLoading, fetchTransactions } = useRealunitContext(); + + useLayoutOptions({ title: translate('screens/realunit', 'Transaction Detail'), backButton: true }); + + useEffect(() => { + if (!transactions.length) fetchTransactions(); + }, [fetchTransactions]); + + const transaction = transactions.find((t) => t.id === Number(id)); + + const displayType = (type: string): string => { + switch (type) { + case 'BuyFiat': + return 'Sell'; + case 'BuyCrypto': + return 'Buy'; + default: + return type; + } + }; + + if (transactionsLoading && !transactions.length) { + return ; + } + + if (!transaction) { + return

{translate('screens/realunit', 'Transaction not found')}

; + } + + return ( +
+

{translate('screens/realunit', 'Transaction Detail')}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ {translate('screens/realunit', 'Key')} + + {translate('screens/realunit', 'Value')} +
+ {translate('screens/realunit', 'Type')} + {displayType(transaction.type)}
+ {translate('screens/realunit', 'Amount CHF')} + + {transaction.amountInChf?.toLocaleString()} +
+ {translate('screens/realunit', 'Assets')} + {transaction.assets}
+ {translate('screens/realunit', 'User')} + + {transaction.userAddress ? ( + + ) : ( + '-' + )} +
+ {translate('screens/realunit', 'Date')} + + {new Date(transaction.outputDate ?? transaction.created).toLocaleString()} +
+
+ ); +} diff --git a/src/screens/realunit-transactions.screen.tsx b/src/screens/realunit-transactions.screen.tsx new file mode 100644 index 000000000..30cf5ff1a --- /dev/null +++ b/src/screens/realunit-transactions.screen.tsx @@ -0,0 +1,108 @@ +import { SpinnerSize, StyledButton, StyledButtonWidth, StyledLoadingSpinner } from '@dfx.swiss/react-components'; +import { useEffect } from 'react'; +import { useRealunitContext } from 'src/contexts/realunit.context'; +import { useSettingsContext } from 'src/contexts/settings.context'; +import { useRealunitGuard } from 'src/hooks/guard.hook'; +import { useLayoutOptions } from 'src/hooks/layout-config.hook'; +import { useNavigation } from 'src/hooks/navigation.hook'; +import { blankedAddress } from 'src/util/utils'; + +export default function RealunitTransactionsScreen(): JSX.Element { + useRealunitGuard(); + + const { translate } = useSettingsContext(); + const { navigate } = useNavigation(); + const { transactions, transactionsLoading, fetchTransactions } = useRealunitContext(); + + useLayoutOptions({ title: translate('screens/realunit', 'Received Transactions'), backButton: true }); + + const displayType = (type: string): string => { + switch (type) { + case 'BuyFiat': + return 'Sell'; + case 'BuyCrypto': + return 'Buy'; + default: + return type; + } + }; + + useEffect(() => { + if (!transactions.length) fetchTransactions(); + }, [fetchTransactions]); + + return ( + <> + {transactionsLoading && !transactions.length ? ( + + ) : ( +
+
+

{translate('screens/realunit', 'Received Transactions')}

+ + + + + + + + + + + {transactions.map((tx) => ( + navigate(`/realunit/transactions/${tx.id}`)} + > + + + + + + ))} + {!transactions.length && !transactionsLoading && ( + + + + )} + +
+ {translate('screens/realunit', 'Type')} + + {translate('screens/realunit', 'Amount CHF')} + + {translate('screens/realunit', 'User')} + + {translate('screens/realunit', 'Date')} +
{displayType(tx.type)} + {tx.amountInChf?.toLocaleString()} + + {tx.userAddress ? blankedAddress(tx.userAddress, { displayLength: 12 }) : '-'} + + {new Date(tx.outputDate ?? tx.created).toLocaleString()} +
+ {translate('screens/realunit', 'No received transactions found')} +
+
+ + {transactions.length > 0 && ( +
+ +
+ )} + {transactionsLoading && transactions.length > 0 && ( +
+ +
+ )} +
+ )} + + ); +} diff --git a/src/screens/realunit-user.screen.tsx b/src/screens/realunit-user.screen.tsx index be71406a3..f7e3933f7 100644 --- a/src/screens/realunit-user.screen.tsx +++ b/src/screens/realunit-user.screen.tsx @@ -14,12 +14,12 @@ import { useRealunitContext } from 'src/contexts/realunit.context'; import { useSettingsContext } from 'src/contexts/settings.context'; import { PaginationDirection } from 'src/dto/realunit.dto'; import { useClipboard } from 'src/hooks/clipboard.hook'; -import { useAdminGuard } from 'src/hooks/guard.hook'; +import { useRealunitGuard } from 'src/hooks/guard.hook'; import { useLayoutOptions } from 'src/hooks/layout-config.hook'; import { blankedAddress, formatCurrency } from 'src/util/utils'; export default function RealunitUserScreen(): JSX.Element { - useAdminGuard(); + useRealunitGuard(); const { translate } = useSettingsContext(); const { copy } = useClipboard(); @@ -61,7 +61,7 @@ export default function RealunitUserScreen(): JSX.Element { return ( <> - {!accountSummary ? ( + {isLoading && !accountSummary ? ( ) : !accountSummary ? (

{translate('screens/realunit', 'No data available')}

diff --git a/src/screens/realunit.screen.tsx b/src/screens/realunit.screen.tsx index 47e7dfd80..95b278988 100644 --- a/src/screens/realunit.screen.tsx +++ b/src/screens/realunit.screen.tsx @@ -3,6 +3,7 @@ import { IconColor, SpinnerSize, StyledButton, + StyledButtonColor, StyledButtonWidth, StyledLoadingSpinner, } from '@dfx.swiss/react-components'; @@ -10,14 +11,13 @@ import { useEffect } from 'react'; import { PriceHistoryChart } from 'src/components/realunit/price-history-chart'; import { useRealunitContext } from 'src/contexts/realunit.context'; import { useSettingsContext } from 'src/contexts/settings.context'; -import { PaginationDirection } from 'src/dto/realunit.dto'; import { useClipboard } from 'src/hooks/clipboard.hook'; -import { useAdminGuard } from 'src/hooks/guard.hook'; +import { useRealunitGuard } from 'src/hooks/guard.hook'; import { useLayoutOptions } from 'src/hooks/layout-config.hook'; import { useNavigation } from 'src/hooks/navigation.hook'; import { blankedAddress } from 'src/util/utils'; export default function RealunitScreen(): JSX.Element { - useAdminGuard(); + useRealunitGuard(); const { translate } = useSettingsContext(); const { navigate } = useNavigation(); @@ -26,14 +26,19 @@ export default function RealunitScreen(): JSX.Element { const { holders, totalCount, - pageInfo, tokenInfo, isLoading, priceHistory, timeframe, + quotes, + transactions, + quotesLoading, + transactionsLoading, fetchHolders, fetchPriceHistory, fetchTokenInfo, + fetchQuotes, + fetchTransactions, } = useRealunitContext(); useLayoutOptions({ backButton: true }); @@ -42,16 +47,30 @@ export default function RealunitScreen(): JSX.Element { if (!holders.length) fetchHolders(); if (!tokenInfo) fetchTokenInfo(); if (!priceHistory.length) fetchPriceHistory(); - }, [fetchHolders, fetchTokenInfo]); + if (!quotes.length) fetchQuotes(); + if (!transactions.length) fetchTransactions(); + }, [fetchHolders, fetchTokenInfo, fetchQuotes, fetchTransactions]); + + const topHolders = holders.slice(0, 3); + const topQuotes = quotes.slice(0, 3); + const topTransactions = transactions.slice(0, 3); + + const displayType = (type: string): string => { + switch (type) { + case 'BuyFiat': + return 'Sell'; + case 'BuyCrypto': + return 'Buy'; + default: + return type; + } + }; const handleAddressClick = (address: string) => { const encodedAddress = encodeURIComponent(address); navigate(`/realunit/user/${encodedAddress}`); }; - const changePage = (dir: PaginationDirection) => - fetchHolders(dir === PaginationDirection.NEXT ? pageInfo.endCursor : pageInfo.startCursor, dir); - return ( <> {!holders.length && !tokenInfo ? ( @@ -59,65 +78,6 @@ export default function RealunitScreen(): JSX.Element { ) : (
- {isLoading ? ( -
- -
- ) : ( - tokenInfo && ( -
-

{translate('screens/realunit', 'RealUnit ')}

- - - - - - - - - - - - - - - - - - - - - - - - - - -
- {translate('screens/realunit', 'Overview')} - - {translate('screens/realunit', '')} -
- {translate('screens/realunit', 'Holders')} - - {totalCount?.toLocaleString() ?? '0'} -
- {translate('screens/realunit', 'Shares')} - - {Number(tokenInfo.totalShares.total).toLocaleString()} -
- {translate('screens/realunit', 'Total Supply')} - - {Number(tokenInfo.totalSupply.value).toLocaleString()} REALU -
- {translate('screens/realunit', 'Timestamp')} - - {new Date(tokenInfo.totalSupply.timestamp).toLocaleString()} -
-
- ) - )} -

{translate('screens/realunit', 'Price History')}

+ {isLoading ? ( +
+ +
+ ) : ( + tokenInfo && ( +
+

{translate('screens/realunit', 'RealUnit ')}

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ {translate('screens/realunit', 'Overview')} + + {translate('screens/realunit', '')} +
+ {translate('screens/realunit', 'Holders')} + + {totalCount?.toLocaleString() ?? '0'} +
+ {translate('screens/realunit', 'Shares')} + + {Number(tokenInfo.totalShares.total).toLocaleString()} +
+ {translate('screens/realunit', 'Total Supply')} + + {Number(tokenInfo.totalSupply.value).toLocaleString()} REALU +
+ {translate('screens/realunit', 'Timestamp')} + + {new Date(tokenInfo.totalSupply.timestamp).toLocaleString()} +
+
+ ) + )} +

{translate('screens/realunit', 'Top Holders')}

@@ -144,7 +163,7 @@ export default function RealunitScreen(): JSX.Element { - {holders.map((holder) => ( + {topHolders.map((holder) => ( -
-
+ {holders.length > 3 && ( +
changePage(PaginationDirection.PREV)} - disabled={!pageInfo.hasPreviousPage} - width={StyledButtonWidth.MIN} + label={translate('general/actions', 'More')} + onClick={() => navigate('/realunit/holders')} + width={StyledButtonWidth.FULL} + color={StyledButtonColor.STURDY_WHITE} />
+ )} + +
+

{translate('screens/realunit', 'Pending Transactions')}

+
+ + + + + + + + + + {topQuotes.map((quote) => ( + navigate(`/realunit/quotes/${quote.id}`)} + > + + + + + + ))} + {!quotes.length && !quotesLoading && ( + + + + )} + +
+ {translate('screens/realunit', 'Type')} + + {translate('screens/realunit', 'Amount')} + + {translate('screens/realunit', 'User')} + + {translate('screens/realunit', 'Created')} +
{displayType(quote.type)}{quote.amount?.toLocaleString()} + {quote.userAddress ? blankedAddress(quote.userAddress, { displayLength: 12 }) : '-'} + + {new Date(quote.created).toLocaleString()} +
+ {translate('screens/realunit', 'No pending transactions found')} +
+ {quotesLoading && !quotes.length && ( +
+ +
+ )} +
-
+ {quotes.length > 3 && ( +
changePage(PaginationDirection.NEXT)} - disabled={!pageInfo.hasNextPage} - width={StyledButtonWidth.MIN} + label={translate('general/actions', 'More')} + onClick={() => navigate('/realunit/quotes')} + width={StyledButtonWidth.FULL} + color={StyledButtonColor.STURDY_WHITE} />
+ )} + +
+

{translate('screens/realunit', 'Received Transactions')}

+ + + + + + + + + + + {topTransactions.map((tx) => ( + navigate(`/realunit/transactions/${tx.id}`)} + > + + + + + + ))} + {!transactions.length && !transactionsLoading && ( + + + + )} + +
+ {translate('screens/realunit', 'Type')} + + {translate('screens/realunit', 'Amount CHF')} + + {translate('screens/realunit', 'User')} + + {translate('screens/realunit', 'Date')} +
{displayType(tx.type)} + {tx.amountInChf?.toLocaleString()} + + {tx.userAddress ? blankedAddress(tx.userAddress, { displayLength: 12 }) : '-'} + + {new Date(tx.outputDate ?? tx.created).toLocaleString()} +
+ {translate('screens/realunit', 'No received transactions found')} +
+ {transactionsLoading && !transactions.length && ( +
+ +
+ )}
+ + {transactions.length > 3 && ( +
+ navigate('/realunit/transactions')} + width={StyledButtonWidth.FULL} + color={StyledButtonColor.STURDY_WHITE} + /> +
+ )}
)} diff --git a/src/translations/languages/de.json b/src/translations/languages/de.json index ab393b439..ddad6bbfd 100644 --- a/src/translations/languages/de.json +++ b/src/translations/languages/de.json @@ -5,6 +5,7 @@ "Start": "Starten", "Previous": "Zurück", "Next": "Weiter", + "More": "Mehr", "Continue": "Weiter", "Close": "Schliessen", "Ok": "Ok", @@ -1014,7 +1015,28 @@ "Details": "Details", "Tx Hash": "Tx Hash", "No transactions found": "Keine Transaktionen gefunden", - "Percentage": "Prozentsatz" + "Percentage": "Prozentsatz", + "RealUnit ": "RealUnit ", + "Overview": "Übersicht", + "Holders": "Inhaber", + "Shares": "Aktien", + "Total Supply": "Gesamtangebot", + "Price History": "Preisverlauf", + "Top Holders": "Top Inhaber", + "All Holders": "Alle Inhaber", + "Pending Transactions": "Pendente Transaktionen", + "No pending transactions found": "Keine pendenten Transaktionen gefunden", + "Received Transactions": "Erhaltene Transaktionen", + "No received transactions found": "Keine erhaltenen Transaktionen gefunden", + "Type": "Typ", + "Amount": "Betrag", + "User": "Benutzer", + "Created": "Erstellt", + "Amount CHF": "Betrag CHF", + "Date": "Datum", + "Confirm Payment Received": "Zahlungseingang bestätigen", + "Are you sure you want to confirm the payment receipt?": "Möchten Sie den Zahlungseingang wirklich bestätigen?", + "Payment confirmed successfully": "Zahlungseingang erfolgreich bestätigt" }, "screens/blockchain": { "Transaction signing": "Transaktionssignierung", diff --git a/src/translations/languages/fr.json b/src/translations/languages/fr.json index 08a851553..9740d4712 100644 --- a/src/translations/languages/fr.json +++ b/src/translations/languages/fr.json @@ -5,6 +5,7 @@ "Start": "Lancer", "Previous": "Précédent", "Next": "Suivant", + "More": "Plus", "Continue": "Continuer", "Close": "Fermer", "Ok": "Ok", @@ -1013,7 +1014,28 @@ "Details": "Détails", "Tx Hash": "Tx Hash", "No transactions found": "Aucune transaction trouvée", - "Percentage": "Pourcentage" + "Percentage": "Pourcentage", + "RealUnit ": "RealUnit ", + "Overview": "Aperçu", + "Holders": "Détenteurs", + "Shares": "Actions", + "Total Supply": "Offre totale", + "Price History": "Historique des prix", + "Top Holders": "Principaux détenteurs", + "All Holders": "Tous les détenteurs", + "Pending Transactions": "Transactions en attente", + "No pending transactions found": "Aucune transaction en attente trouvée", + "Received Transactions": "Transactions reçues", + "No received transactions found": "Aucune transaction reçue trouvée", + "Type": "Type", + "Amount": "Montant", + "User": "Utilisateur", + "Created": "Créé", + "Amount CHF": "Montant CHF", + "Date": "Date", + "Confirm Payment Received": "Confirmer la réception du paiement", + "Are you sure you want to confirm the payment receipt?": "Êtes-vous sûr de vouloir confirmer la réception du paiement?", + "Payment confirmed successfully": "Réception du paiement confirmée avec succès" }, "screens/blockchain": { "Transaction signing": "Signature de la transaction", diff --git a/src/translations/languages/it.json b/src/translations/languages/it.json index c6fb0de23..606765cef 100644 --- a/src/translations/languages/it.json +++ b/src/translations/languages/it.json @@ -5,6 +5,7 @@ "Start": "Iniziare", "Previous": "Precedente", "Next": "Avanti", + "More": "Di più", "Continue": "Continua", "Close": "Chiudere", "Ok": "Ok", @@ -1013,7 +1014,28 @@ "Details": "Dettagli", "Tx Hash": "Tx Hash", "No transactions found": "Nessuna transazione trovata", - "Percentage": "Percentuale" + "Percentage": "Percentuale", + "RealUnit ": "RealUnit ", + "Overview": "Panoramica", + "Holders": "Titolari", + "Shares": "Azioni", + "Total Supply": "Offerta totale", + "Price History": "Storico dei prezzi", + "Top Holders": "Principali titolari", + "All Holders": "Tutti i titolari", + "Pending Transactions": "Transazioni in sospeso", + "No pending transactions found": "Nessuna transazione in sospeso trovata", + "Received Transactions": "Transazioni ricevute", + "No received transactions found": "Nessuna transazione ricevuta trovata", + "Type": "Tipo", + "Amount": "Importo", + "User": "Utente", + "Created": "Creato", + "Amount CHF": "Importo CHF", + "Date": "Data", + "Confirm Payment Received": "Conferma ricezione pagamento", + "Are you sure you want to confirm the payment receipt?": "Sei sicuro di voler confermare la ricezione del pagamento?", + "Payment confirmed successfully": "Ricezione del pagamento confermata con successo" }, "screens/blockchain": { "Transaction signing": "Firma della transazione",