mirror of
https://github.com/luxfi/safe-wallet.git
synced 2026-10-30 15:19:21 +00:00
* feat(mobile): implement send/transfer flow Add a complete send flow for the mobile app with four screens: Select Recipient, Select Token, Enter Amount, and Review Send. Includes token transfer encoding (native + ERC-20), transaction signing/proposing, recipient validation with address book lookup, fiat conversion, and amount input with decimal validation. Gated behind SEND_TRANSFERS feature flag with entry point on the Assets header. * fix(mobile): update send flow UI to match Figma designs - RecipientInput: add "To:" prefix, dark background, improved placeholder - RecipientSections: add 40px identicon circles in list rows - SelectToken: add recipient summary bar at top with identicon - AmountDisplay: increase font to 44px semibold per Figma - TokenPill: larger 28px icon, show balance, inline MAX text - EnterAmount: add fiat/crypto toggle button, hidden input for keyboard - ReviewSend: card-based layout with token icon and recipient identicon - AddToAddressBookModal: add identicon for address - AssetsHeader: improve send button sizing and centering - Restore SEND_TRANSFERS feature flag gate * fix(mobile): hide root header for send flow stack The (send) group was missing from the root layout Stack.Screen definitions, causing a double navigation header — one from the root Stack and one from the (send) nested Stack layout. * fix(mobile): improve select recipient screen with dedup and Figma alignment - Replace QR button in input with Paste button using @react-native-clipboard - Add "Scan QR code" row as separate element below input field - Deduplicate addresses across sections: safes > signers > address book - Use address book names for safes and signers when available * fix(mobile): add selected recipient state to send flow When a user selects a contact from the list, the input field now shows the contact's identicon, name, and address with an X button to clear. The QR code row and address sections hide, and the label updates to reflect the validation state (e.g. "Known recipient" with checkmark). * refactor(mobile): use SafeShield useAddressBookCheck in recipient validation Replace hand-rolled address book and owned safe checks with the shared SafeShield useAddressBookCheck hook from packages/utils. This reuses the same isInAddressBook pattern used by the mobile counterparty analysis, keeping signer and self-send checks as mobile-specific logic. * fix: ui improvements * fix(mobile): propose send tx * fix(web): add current progress * refactor(mobile): reduce cyclomatic complexity across send flow Extract helper functions, custom hooks, and sub-components to reduce cyclomatic complexity flagged by CodeScene across 16 files in the mobile send flow. * fix(mobile): prettier * refactor(mobile): address code review findings across send flow - Fix stale closure in handleContinue (missing displayName dep) - Wire SEND_TRANSFERS feature flag in AssetsHeader - Fix unsafe Operation type cast in proposeNewTransaction - Simplify useFiatConversion (180->110 lines, consolidated memos) - Fix fiat-to-token precision (truncate instead of round) - Fix lossy balance comparison (direct wei comparison) - Add nonce validation bounds in CustomNonceModal - Fix stale defaultNonce on modal re-open - Extract shared DialogModal component - Convert isSubmitting from useRef to useState - Replace hardcoded colors with theme tokens - Fix module-level memory leak in ScanQrSend - Add QR code chain prefix validation - Fix inverted RecipientValidationBadge condition - Fix unsafe decimals type assertions - Reorder SDK chain verification before tx creation - Extract shared RecipientDisplay component - Extract TokenListHeader component - Remove redundant handleScrollBeginDrag - Extract stable ItemSeparator component - Remove unused exported types (YAGNI) - Memoize RecipientRow with stable onPress - Add useFiatConversion test suite (26 tests) * fix: prettier * refactor(mobile): reduce cyclomatic complexity across send flow Extract helper functions and simplify conditionals in: - EnterAmount.container (20 → <10) - useRecipientValidation (10 → <9, 5 args → 2) - useNonce (9 → <9) - useFiatConversion (14 → <9) - ScanQrSend.container (12 → <10) - CustomNonceModal, useSendTransaction, useTokenBalance * fix(mobile): token data didn’t load * feat(mobile): add suspicious address check * fix(mobile): improve UI * fix(mobile): new navbar elements * fix(mobile): lint * fix(mobile): show loading spinner on review and confirm * fix(mobile): address PR review feedback for send flow - Fix scientific notation in fiatToToken by using toFixed() instead of toString() to ensure plain decimal output for safeParseUnits - Refactor buildFiatDisplay and buildTokenDisplay to use object params instead of 6 positional arguments - Add ref-based guard in useSendTransaction to prevent concurrent transaction proposals from rapid double-taps * fix(mobile): prettier * fix(mobile): block send submission until token decimals are confirmed Prevent incorrect transfer amounts when token data is loading or unavailable. Previously, decimals fell back to 18, which would encode wrong amounts for tokens like USDC (6 decimals). Now submission is gated on isTokenDataReady from useTokenBalance. --------- Co-authored-by: Hanzo Dev <[email protected]>
471 lines
17 KiB
TypeScript
471 lines
17 KiB
TypeScript
import { http, HttpResponse } from 'msw'
|
|
import type { FiatCurrencies } from '@safe-global/store/gateway/types'
|
|
import { Balances } from '@safe-global/store/gateway/AUTO_GENERATED/balances'
|
|
import { CollectiblePage } from '@safe-global/store/gateway/AUTO_GENERATED/collectibles'
|
|
import type { RelaysRemaining } from '@safe-global/store/gateway/AUTO_GENERATED/relay'
|
|
import type { MasterCopy } from '@safe-global/store/gateway/AUTO_GENERATED/chains'
|
|
import type { TransactionDetails, QueuedItemPage } from '@safe-global/store/gateway/AUTO_GENERATED/transactions'
|
|
import { defaultMockSafeApps } from './mockSafeApps'
|
|
|
|
const iso4217Currencies = ['USD', 'EUR', 'GBP']
|
|
|
|
const defaultMasterCopies: MasterCopy[] = [
|
|
{
|
|
address: '0xd9Db270c1B5E3Bd161E8c8503c55cEFDDe8E6766',
|
|
version: '1.3.0',
|
|
},
|
|
{
|
|
address: '0x6851D6fDFAfD08c0EF60ac1b9c90E5dE6247cEAC',
|
|
version: '1.4.1',
|
|
},
|
|
]
|
|
|
|
const chainsConfig = {
|
|
count: 3,
|
|
next: null,
|
|
previous: null,
|
|
results: [
|
|
{
|
|
chainId: '1',
|
|
chainName: 'Ethereum',
|
|
shortName: 'eth',
|
|
description: 'Ethereum Mainnet',
|
|
l2: false,
|
|
isTestnet: false,
|
|
zk: false,
|
|
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18, logoUri: '' },
|
|
transactionService: 'https://safe-transaction-mainnet.safe.global',
|
|
blockExplorerUriTemplate: {
|
|
address: 'https://etherscan.io/address/{{address}}',
|
|
txHash: 'https://etherscan.io/tx/{{txHash}}',
|
|
api: 'https://api.etherscan.io/api',
|
|
},
|
|
beaconChainExplorerUriTemplate: {},
|
|
disabledWallets: [],
|
|
balancesProvider: { chainName: 'ethereum', enabled: true },
|
|
contractAddresses: { safeSingletonAddress: '0x', safeProxyFactoryAddress: '0x' },
|
|
features: [],
|
|
gasPrice: [],
|
|
publicRpcUri: { authentication: 'NO_AUTHENTICATION', value: 'https://ethereum.publicnode.com' },
|
|
rpcUri: { authentication: 'NO_AUTHENTICATION', value: 'https://ethereum.publicnode.com' },
|
|
safeAppsRpcUri: { authentication: 'NO_AUTHENTICATION', value: 'https://ethereum.publicnode.com' },
|
|
theme: { backgroundColor: '#E8E7E6', textColor: '#001428' },
|
|
},
|
|
{
|
|
chainId: '137',
|
|
chainName: 'Polygon',
|
|
shortName: 'matic',
|
|
description: 'Polygon Mainnet',
|
|
l2: true,
|
|
isTestnet: false,
|
|
zk: false,
|
|
nativeCurrency: { name: 'MATIC', symbol: 'MATIC', decimals: 18, logoUri: '' },
|
|
transactionService: 'https://safe-transaction-polygon.safe.global',
|
|
blockExplorerUriTemplate: {
|
|
address: 'https://polygonscan.com/address/{{address}}',
|
|
txHash: 'https://polygonscan.com/tx/{{txHash}}',
|
|
api: 'https://api.polygonscan.com/api',
|
|
},
|
|
beaconChainExplorerUriTemplate: {},
|
|
disabledWallets: [],
|
|
balancesProvider: { chainName: 'polygon', enabled: true },
|
|
contractAddresses: { safeSingletonAddress: '0x', safeProxyFactoryAddress: '0x' },
|
|
features: [],
|
|
gasPrice: [],
|
|
publicRpcUri: { authentication: 'NO_AUTHENTICATION', value: 'https://polygon-rpc.com' },
|
|
rpcUri: { authentication: 'NO_AUTHENTICATION', value: 'https://polygon-rpc.com' },
|
|
safeAppsRpcUri: { authentication: 'NO_AUTHENTICATION', value: 'https://polygon-rpc.com' },
|
|
theme: { backgroundColor: '#8B5CF6', textColor: '#FFFFFF' },
|
|
},
|
|
{
|
|
chainId: '42161',
|
|
chainName: 'Arbitrum One',
|
|
shortName: 'arb1',
|
|
description: 'Arbitrum One',
|
|
l2: true,
|
|
isTestnet: false,
|
|
zk: false,
|
|
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18, logoUri: '' },
|
|
transactionService: 'https://safe-transaction-arbitrum.safe.global',
|
|
blockExplorerUriTemplate: {
|
|
address: 'https://arbiscan.io/address/{{address}}',
|
|
txHash: 'https://arbiscan.io/tx/{{txHash}}',
|
|
api: 'https://api.arbiscan.io/api',
|
|
},
|
|
beaconChainExplorerUriTemplate: {},
|
|
disabledWallets: [],
|
|
balancesProvider: { chainName: 'arbitrum', enabled: true },
|
|
contractAddresses: { safeSingletonAddress: '0x', safeProxyFactoryAddress: '0x' },
|
|
features: [],
|
|
gasPrice: [],
|
|
publicRpcUri: { authentication: 'NO_AUTHENTICATION', value: 'https://arbitrum-one.publicnode.com' },
|
|
rpcUri: { authentication: 'NO_AUTHENTICATION', value: 'https://arbitrum-one.publicnode.com' },
|
|
safeAppsRpcUri: { authentication: 'NO_AUTHENTICATION', value: 'https://arbitrum-one.publicnode.com' },
|
|
theme: { backgroundColor: '#12AAFF', textColor: '#FFFFFF' },
|
|
},
|
|
],
|
|
}
|
|
|
|
export const handlers = (GATEWAY_URL: string) => [
|
|
http.get(`${GATEWAY_URL}/v1/auth/nonce`, () => {
|
|
return HttpResponse.json({
|
|
nonce: 'mock-nonce-for-testing-12345',
|
|
timestamp: new Date().toISOString(),
|
|
expirationTime: new Date(Date.now() + 300000).toISOString(),
|
|
})
|
|
}),
|
|
|
|
http.get<never, never, Balances>(`${GATEWAY_URL}/v1/chains/1/safes/0x123/balances/USD`, () => {
|
|
return HttpResponse.json({
|
|
items: [
|
|
{
|
|
tokenInfo: {
|
|
name: 'Ethereum',
|
|
symbol: 'ETH',
|
|
decimals: 18,
|
|
address: '0x',
|
|
type: 'ERC20',
|
|
logoUri: 'https://safe-transaction-assets.safe.global/chains/1/chain_logo.png',
|
|
},
|
|
balance: '1000000000000000000',
|
|
fiatBalance: '2000',
|
|
fiatConversion: '2000',
|
|
},
|
|
],
|
|
fiatTotal: '2000',
|
|
})
|
|
}),
|
|
http.get<never, never, CollectiblePage>(`${GATEWAY_URL}/v2/chains/:chainId/safes/:safeAddress/collectibles`, () => {
|
|
return HttpResponse.json({
|
|
count: 2,
|
|
next: null,
|
|
previous: null,
|
|
results: [
|
|
{
|
|
id: '1',
|
|
address: '0x123',
|
|
tokenName: 'Cool NFT',
|
|
tokenSymbol: 'CNFT',
|
|
logoUri: 'https://example.com/nft1.png',
|
|
name: 'NFT #1',
|
|
description: 'A cool NFT',
|
|
uri: 'https://example.com/nft1.json',
|
|
imageUri: 'https://example.com/nft1.png',
|
|
},
|
|
{
|
|
id: '2',
|
|
address: '0x456',
|
|
tokenName: 'Another NFT',
|
|
tokenSymbol: 'ANFT',
|
|
logoUri: 'https://example.com/nft2.png',
|
|
name: 'NFT #2',
|
|
description: 'Another cool NFT',
|
|
uri: 'https://example.com/nft2.json',
|
|
imageUri: 'https://example.com/nft2.png',
|
|
},
|
|
],
|
|
})
|
|
}),
|
|
http.get<never, never, FiatCurrencies>(`${GATEWAY_URL}/v1/balances/supported-fiat-codes`, () => {
|
|
return HttpResponse.json(iso4217Currencies)
|
|
}),
|
|
|
|
http.get(`${GATEWAY_URL}/v1/chains/:chainId/safes/:safeAddress`, () => {
|
|
return HttpResponse.json({
|
|
address: '0x123',
|
|
nonce: 0,
|
|
threshold: 1,
|
|
owners: ['0x1234567890123456789012345678901234567890'],
|
|
masterCopy: '0x',
|
|
modules: [],
|
|
fallbackHandler: '0x',
|
|
guard: '0x',
|
|
version: '1.3.0',
|
|
})
|
|
}),
|
|
|
|
// Relay endpoint for remaining relays
|
|
http.get<{ chainId: string; safeAddress: string }, never, RelaysRemaining>(
|
|
`${GATEWAY_URL}/v1/chains/:chainId/relay/:safeAddress`,
|
|
({ params }) => {
|
|
// Default mock response; can be customized per test using MSW request handlers
|
|
return HttpResponse.json({
|
|
remaining: 5,
|
|
limit: 5,
|
|
})
|
|
},
|
|
),
|
|
|
|
// Master copies endpoint for master copy contracts
|
|
http.get<{ chainId: string }, never, MasterCopy[]>(`${GATEWAY_URL}/v1/chains/:chainId/about/master-copies`, () => {
|
|
return HttpResponse.json(defaultMasterCopies)
|
|
}),
|
|
|
|
// Chains config endpoint for RTK Query initialization (v1 - used by mobile)
|
|
http.get(`${GATEWAY_URL}/v1/chains`, () => {
|
|
return HttpResponse.json(chainsConfig)
|
|
}),
|
|
|
|
// Chains config endpoint for RTK Query initialization (v2 - used by web)
|
|
http.get(`${GATEWAY_URL}/v2/chains`, () => {
|
|
return HttpResponse.json(chainsConfig)
|
|
}),
|
|
|
|
// Individual chain endpoint
|
|
http.get<{ chainId: string }>(`${GATEWAY_URL}/v2/chains/:chainId`, ({ params }) => {
|
|
const { chainId } = params
|
|
|
|
// Mock data for common chains
|
|
const chainMocks: Record<string, any> = {
|
|
'1': {
|
|
chainId: '1',
|
|
chainName: 'Ethereum',
|
|
shortName: 'eth',
|
|
description: 'Ethereum Mainnet',
|
|
l2: false,
|
|
isTestnet: false,
|
|
zk: false,
|
|
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18, logoUri: '' },
|
|
transactionService: 'https://safe-transaction-mainnet.safe.global',
|
|
blockExplorerUriTemplate: {
|
|
address: 'https://etherscan.io/address/{{address}}',
|
|
txHash: 'https://etherscan.io/tx/{{txHash}}',
|
|
api: 'https://api.etherscan.io/api',
|
|
},
|
|
beaconChainExplorerUriTemplate: {},
|
|
disabledWallets: [],
|
|
balancesProvider: { chainName: 1, enabled: true },
|
|
contractAddresses: { safeSingletonAddress: '0x', safeProxyFactoryAddress: '0x' },
|
|
features: [],
|
|
gasPrice: [],
|
|
publicRpcUri: { authentication: 'NO_AUTHENTICATION', value: 'https://ethereum.publicnode.com' },
|
|
rpcUri: { authentication: 'NO_AUTHENTICATION', value: 'https://ethereum.publicnode.com' },
|
|
safeAppsRpcUri: { authentication: 'NO_AUTHENTICATION', value: 'https://ethereum.publicnode.com' },
|
|
theme: { backgroundColor: '#E8E7E6', textColor: '#001428' },
|
|
},
|
|
'137': {
|
|
chainId: '137',
|
|
chainName: 'Polygon',
|
|
shortName: 'matic',
|
|
description: 'Polygon Mainnet',
|
|
l2: true,
|
|
isTestnet: false,
|
|
zk: false,
|
|
nativeCurrency: { name: 'MATIC', symbol: 'MATIC', decimals: 18, logoUri: '' },
|
|
transactionService: 'https://safe-transaction-polygon.safe.global',
|
|
blockExplorerUriTemplate: {
|
|
address: 'https://polygonscan.com/address/{{address}}',
|
|
txHash: 'https://polygonscan.com/tx/{{txHash}}',
|
|
api: 'https://api.polygonscan.com/api',
|
|
},
|
|
beaconChainExplorerUriTemplate: {},
|
|
disabledWallets: [],
|
|
balancesProvider: { chainName: 137, enabled: true },
|
|
contractAddresses: { safeSingletonAddress: '0x', safeProxyFactoryAddress: '0x' },
|
|
features: [],
|
|
gasPrice: [],
|
|
publicRpcUri: { authentication: 'NO_AUTHENTICATION', value: 'https://polygon-rpc.com' },
|
|
rpcUri: { authentication: 'NO_AUTHENTICATION', value: 'https://polygon-rpc.com' },
|
|
safeAppsRpcUri: { authentication: 'NO_AUTHENTICATION', value: 'https://polygon-rpc.com' },
|
|
theme: { backgroundColor: '#8B5CF6', textColor: '#FFFFFF' },
|
|
},
|
|
}
|
|
|
|
const chain = chainMocks[chainId]
|
|
if (chain) {
|
|
return HttpResponse.json(chain)
|
|
}
|
|
|
|
// Return 404 for unknown chains
|
|
return new HttpResponse(null, { status: 404 })
|
|
}),
|
|
|
|
// Safe Apps endpoint
|
|
http.get(`${GATEWAY_URL}/v1/chains/:chainId/safe-apps`, ({ request }) => {
|
|
const url = new URL(request.url)
|
|
const appUrl = url.searchParams.get('url')
|
|
|
|
// If filtering by URL, return matching apps (with trailing slash handling)
|
|
if (appUrl) {
|
|
const matchingApp = defaultMockSafeApps.find(
|
|
(app) => app.url === appUrl || app.url === appUrl.replace(/\/$/, '') || `${app.url}/` === appUrl,
|
|
)
|
|
return HttpResponse.json(matchingApp ? [matchingApp] : [])
|
|
}
|
|
|
|
// Return all apps by default
|
|
return HttpResponse.json(defaultMockSafeApps)
|
|
}),
|
|
|
|
// Transaction endpoint for retrieving transaction details
|
|
http.get<{ chainId: string; id: string }, never, TransactionDetails>(
|
|
`${GATEWAY_URL}/v1/chains/:chainId/transactions/:id`,
|
|
() => {
|
|
// Default mock response; can be customized per test using MSW request handlers
|
|
return HttpResponse.json({
|
|
txInfo: {
|
|
type: 'Custom',
|
|
to: {
|
|
value: '0x123',
|
|
name: 'Test',
|
|
logoUri: null,
|
|
},
|
|
dataSize: '100',
|
|
value: null,
|
|
isCancellation: false,
|
|
methodName: 'test',
|
|
},
|
|
safeAddress: '0x456',
|
|
txId: '0x345',
|
|
txStatus: 'AWAITING_CONFIRMATIONS' as const,
|
|
})
|
|
},
|
|
),
|
|
|
|
// Messages endpoint for retrieving safe messages
|
|
http.get(`${GATEWAY_URL}/v1/chains/:chainId/safes/:safeAddress/messages`, () => {
|
|
return HttpResponse.json({
|
|
count: 0,
|
|
next: null,
|
|
previous: null,
|
|
results: [],
|
|
})
|
|
}),
|
|
|
|
// Message by hash endpoint
|
|
http.get(`${GATEWAY_URL}/v1/chains/:chainId/messages/:messageHash`, () => {
|
|
return HttpResponse.json({
|
|
messageHash: '0x0',
|
|
status: 'NEEDS_CONFIRMATION',
|
|
message: '',
|
|
creationTimestamp: Date.now(),
|
|
modifiedTimestamp: Date.now(),
|
|
confirmationsSubmitted: 0,
|
|
confirmationsRequired: 1,
|
|
proposedBy: {
|
|
value: '0x0',
|
|
},
|
|
confirmations: [],
|
|
})
|
|
}),
|
|
|
|
// Transaction queue endpoint for paginated transaction queue
|
|
http.get<{ chainId: string; safeAddress: string }, never, QueuedItemPage>(
|
|
`${GATEWAY_URL}/v1/chains/:chainId/safes/:safeAddress/transactions/queued`,
|
|
() => {
|
|
return HttpResponse.json({
|
|
count: 2,
|
|
next: null,
|
|
previous: null,
|
|
results: [],
|
|
})
|
|
},
|
|
),
|
|
|
|
// Notification registration endpoints
|
|
http.post(`${GATEWAY_URL}/v1/register/notifications`, () => {
|
|
return HttpResponse.json({})
|
|
}),
|
|
|
|
http.delete(`${GATEWAY_URL}/v1/chains/:chainId/notifications/devices/:uuid`, () => {
|
|
return HttpResponse.json({})
|
|
}),
|
|
|
|
http.delete(`${GATEWAY_URL}/v1/chains/:chainId/notifications/devices/:uuid/safes/:safeAddress`, () => {
|
|
return HttpResponse.json({})
|
|
}),
|
|
|
|
// Transaction confirmation endpoint for signing
|
|
http.post<{ chainId: string; safeTxHash: string }, { signature: string }>(
|
|
`${GATEWAY_URL}/v1/chains/:chainId/transactions/:safeTxHash/confirmations`,
|
|
async ({ request }) => {
|
|
const body = await request.json()
|
|
// Success case - echo back the signature
|
|
return HttpResponse.json({ signature: body.signature }, { status: 201 })
|
|
},
|
|
),
|
|
|
|
// Mock targeted-messaging endpoint for Hypernative (outreachId: 11)
|
|
http.get<{ outreachId: string; chainId: string; safeAddress: string }>(
|
|
`${GATEWAY_URL}/v1/targeted-messaging/outreaches/:outreachId/chains/:chainId/safes/:safeAddress`,
|
|
({ params }) => {
|
|
const { outreachId, chainId, safeAddress } = params
|
|
|
|
// List of Safe addresses that should be considered "targeted" for Hypernative
|
|
// Add your test Safe addresses here (use lowercase for comparison)
|
|
const targetedSafes = [
|
|
'0x1234567890123456789012345678901234567890',
|
|
'0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef',
|
|
'0x8f02c3d4a63b2fe436762c807eff182d35df721f',
|
|
]
|
|
|
|
const isTargeted = targetedSafes.some((addr) => addr.toLowerCase() === safeAddress.toLowerCase())
|
|
|
|
if (isTargeted && outreachId === '11') {
|
|
return HttpResponse.json({
|
|
outreachId: Number(outreachId),
|
|
address: safeAddress,
|
|
})
|
|
}
|
|
|
|
// Return 404 for non-targeted Safes (matches backend behavior)
|
|
return HttpResponse.json({ detail: 'Not found' }, { status: 404 })
|
|
},
|
|
),
|
|
|
|
// Mock Hypernative OAuth token exchange endpoint
|
|
// This handles the OAuth authorization code exchange for access tokens
|
|
// Used in development and testing when NEXT_PUBLIC_HYPERNATIVE_TOKEN_URL is set to mock URL
|
|
// Per Hypernative API spec: accepts JSON body, returns 600s expiry, read-only scope
|
|
http.post('https://mock-hn-auth.example.com/oauth/token', async ({ request }) => {
|
|
const body = (await request.json()) as {
|
|
grant_type?: string
|
|
code?: string
|
|
code_verifier?: string
|
|
redirect_uri?: string
|
|
client_id?: string
|
|
}
|
|
|
|
const grantType = body?.grant_type
|
|
const code = body?.code
|
|
const codeVerifier = body?.code_verifier
|
|
const redirectUri = body?.redirect_uri
|
|
const clientId = body?.client_id
|
|
|
|
// Validate required OAuth parameters
|
|
if (!grantType || grantType !== 'authorization_code') {
|
|
return HttpResponse.json({ error: 'invalid_grant', error_description: 'Invalid grant type' }, { status: 400 })
|
|
}
|
|
|
|
if (!code) {
|
|
return HttpResponse.json({ error: 'invalid_request', error_description: 'Missing code' }, { status: 400 })
|
|
}
|
|
|
|
if (!codeVerifier) {
|
|
return HttpResponse.json(
|
|
{ error: 'invalid_request', error_description: 'Missing PKCE code_verifier' },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
|
|
if (!redirectUri) {
|
|
return HttpResponse.json({ error: 'invalid_request', error_description: 'Missing redirect_uri' }, { status: 400 })
|
|
}
|
|
|
|
if (!clientId || clientId !== 'SAFE_WALLET_WEB') {
|
|
return HttpResponse.json({ error: 'invalid_client', error_description: 'Invalid client_id' }, { status: 401 })
|
|
}
|
|
|
|
// Return successful token response per Hypernative spec
|
|
// Hypernative API wraps the OAuth token response in a `data` object
|
|
return HttpResponse.json({
|
|
data: {
|
|
access_token: `mock-hn-token-${Date.now()}`,
|
|
token_type: 'Bearer',
|
|
expires_in: 600,
|
|
scope: 'read',
|
|
},
|
|
})
|
|
}),
|
|
]
|