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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .env
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,6 @@ VITE_MTPELERIN_INTEGRATION_KEY=954139b2-ef3e-4914-82ea-33192d3f43d3
VITE_ONRAMPER_WIDGET_URL=https://buy.onramper.com/
VITE_ONRAMPER_API_URL=https://api.onramper.com/
VITE_ONRAMPER_API_KEY=pk_prod_01HWMA66BYRB2271G08XDZVVCX
VITE_ONRAMPER_SIGNING_KEY=01JQZZQ5HM3R6X58EWFFWDQTYV

# banxa
VITE_BANXA_API_URL=https://api.banxa.com/shapeshift/
Expand Down
6 changes: 5 additions & 1 deletion headers/csps/fiatRamps/onRamper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,9 @@ const mode = process.env.MODE ?? process.env.NODE_ENV ?? 'development'
const env = loadEnv(mode, process.cwd(), '')

export const csp: Csp = {
'connect-src': [env.VITE_ONRAMPER_API_URL, env.VITE_ONRAMPER_WIDGET_URL],
'connect-src': [
env.VITE_ONRAMPER_API_URL,
env.VITE_ONRAMPER_WIDGET_URL,
...(env.VITE_ONRAMPER_SIGNING_URL ? [env.VITE_ONRAMPER_SIGNING_URL] : []),
],
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@
"translations:count-words-invalidate-since": "pnpm exec tsx ./scripts/translations/countWordsInvalidateSince.ts",
"translations:update-path": "pnpm exec tsx ./scripts/translations/updateTranslationPath.ts",
"type-check": "NODE_OPTIONS=--max-old-space-size=8192 pnpm exec tsc --noEmit",
"type-check-watch": "pnpm exec tsc --noEmit --watch"
"type-check-watch": "pnpm exec tsc --noEmit --watch",
"onramper:local": "VITE_ONRAMPER_SIGNING_KEY= VITE_ONRAMPER_API_KEY=local-test-only VITE_ONRAMPER_SIGNING_URL=http://localhost:3002 VITE_ONRAMPER_API_URL=http://localhost:3999/ VITE_ONRAMPER_WIDGET_URL=http://localhost:3999/ vite --host 127.0.0.1 --port 5173 --strictPort --mode development"
},
"browserslist": {
"production": [
Expand Down
7 changes: 6 additions & 1 deletion src/assets/translations/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -526,7 +526,12 @@
"noProvidersBody": "Looks like we don't have any providers for that asset yet.",
"notSupported": "%{asset} is not supported by %{wallet}",
"previewPurchase": "Preview Purchase",
"previewSale": "Preview Sale"
"previewSale": "Preview Sale",
"onramperWalletVerification": "A wallet signature is required to authorize checkout. It does not send a transaction.",
"onramperWalletChanged": "Your wallet changed. Start checkout again with the selected wallet.",
"onramperWalletUnsupported": "Onramper checkout currently requires an EVM wallet that supports message signing.",
"onramperWalletMismatch": "Use a checkout destination controlled by your selected wallet.",
"onramperCheckoutFailed": "Onramper checkout could not be authorized. Check that your wallet is connected and try again."
},
"connectWalletPage": {
"shapeshift": "ShapeShift",
Expand Down
11 changes: 2 additions & 9 deletions src/components/Modals/FiatRamps/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export const supportedFiatRamps: SupportedFiatRamp = {
OnRamper: {
id: 'OnRamper',
label: 'fiatRamps.onRamper',
info: 'fiatRamps.onramperWalletVerification',
tags: ['Aggregator'],
logo: OnRamperLogo,
isActive: () => true,
Expand All @@ -111,15 +112,7 @@ export const supportedFiatRamps: SupportedFiatRamp = {
},
getSupportedFiatList: () => SUPPORTED_ONRAMPER_FIAT_CURRENCIES,
getQuotes: getOnramperQuote,
onSubmit: async props => {
try {
const onRamperCheckoutUrl = await createOnRamperUrl(props)
return onRamperCheckoutUrl
} catch (err) {
console.error(err)
return Promise.resolve(undefined)
}
},
onSubmit: createOnRamperUrl,
},
Banxa: {
id: 'Banxa',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { createSiweMessage } from 'viem/siwe'
import { beforeEach, describe, expect, it, vi } from 'vitest'

import { FiatRampAction } from '../../FiatRampsCommon'
import { createOnRamperUrl } from './index'

const { post, config } = vi.hoisted(() => ({
post: vi.fn(),
config: {
VITE_ONRAMPER_SIGNING_URL: 'https://checkout.example',
VITE_ONRAMPER_WIDGET_URL: 'https://buy.onramper.com/',
},
}))
vi.mock('axios', () => ({
default: {
create: () => ({ post }),
isAxiosError: (error: unknown) =>
Boolean(error && typeof error === 'object' && 'response' in error),
},
}))
vi.mock('@/config', () => ({ getConfig: () => config }))
vi.mock('./utils', () => ({
getSupportedOnramperCurrencies: () => Promise.resolve({ message: { crypto: [], fiat: [] } }),
findAssetIdByOnramperCrypto: vi.fn(),
findOnramperTokenIdByAssetId: () => 'eth',
}))
const props = {
action: FiatRampAction.Buy,
assetId: 'eip155:1/slip44:60',
address: '0x' + '11'.repeat(20),
fiatCurrency: 'USD',
fiatAmount: '100',
options: { language: 'en', mode: 'dark' as const },
}
const sessionToken = 'aa'.repeat(32)
const challengeId = '00000000-0000-4000-8000-000000000000'
const makeAuth = () => ({
signMessage: vi.fn().mockResolvedValue('0x' + '22'.repeat(65)),
assertActive: vi.fn(),
})
function challenge(address = props.address, chainId = 1, domain = window.location.host) {
return {
id: challengeId,
message: createSiweMessage({
address: address as `0x${string}`,
chainId,
domain,
scheme: window.location.protocol.slice(0, -1),
uri: `${window.location.origin}/onramper`,
nonce: '1234567890abcdef',
requestId: challengeId,
issuedAt: new Date(),
expirationTime: new Date(Date.now() + 5 * 60_000),
version: '1',
statement:
'Authorize ShapeShift Onramper checkout to this wallet. This does not authorize a blockchain transaction.',
}),
}
}
function responses(url = 'https://buy.onramper.com/?sigV2=test') {
post
.mockResolvedValueOnce({ data: challenge() })
.mockResolvedValueOnce({
data: {
token: sessionToken,
address: props.address,
chainId: 1,
expiresAt: new Date(Date.now() + 15 * 60_000).toISOString(),
},
})
.mockResolvedValueOnce({ data: { id: 'intent-id', token: 'one-use-token' } })
.mockResolvedValueOnce({ data: { url } })
}
beforeEach(() => {
post.mockReset()
})

describe('Onramper wallet-authorized checkout', () => {
it('verifies a wallet proof and authenticates both intent requests without sending a destination override', async () => {
const walletAuth = makeAuth()
responses()
expect(await createOnRamperUrl({ ...props, walletAuth })).toContain('sigV2=test')
expect(walletAuth.signMessage).toHaveBeenCalledWith(
expect.stringContaining('wants you to sign in'),
props.address,
'eip155:1',
)
expect(post).toHaveBeenNthCalledWith(
3,
'/onramper/intents',
expect.objectContaining({ chainId: 1, action: 'buy', fiat: 'USD', amount: '100' }),
{ headers: { Authorization: `Bearer ${sessionToken}` } },
)
expect(post.mock.calls[2][1]).not.toHaveProperty('address')
expect(post).toHaveBeenNthCalledWith(
4,
'/onramper/intents/intent-id/checkout',
{ token: 'one-use-token' },
{ headers: { Authorization: `Bearer ${sessionToken}` } },
)
})
it('preserves sell amounts', async () => {
responses()
await createOnRamperUrl({
...props,
walletAuth: makeAuth(),
action: FiatRampAction.Sell,
amountCryptoPrecision: '0.02',
})
expect(post.mock.calls[2][1]).toMatchObject({ action: 'sell', amount: '0.02' })
})
it('reuses an in-memory session only for the same wallet, chain, origin and backend', async () => {
const walletAuth = makeAuth()
responses()
await createOnRamperUrl({ ...props, walletAuth })
post
.mockClear()
.mockResolvedValueOnce({ data: { id: 'next', token: 'token' } })
.mockResolvedValueOnce({ data: { url: 'https://buy.onramper.com/' } })
await createOnRamperUrl({ ...props, walletAuth })
expect(walletAuth.signMessage).toHaveBeenCalledTimes(1)
expect(post.mock.calls[0][0]).toBe('/onramper/intents')
})
it.each([
challenge('0x' + '33'.repeat(20)),
challenge(props.address, 10),
challenge(props.address, 1, 'evil.example'),
])('refuses a challenge with the wrong wallet, network or domain', async value => {
const walletAuth = makeAuth()
post.mockResolvedValueOnce({ data: value })
await expect(createOnRamperUrl({ ...props, walletAuth })).rejects.toThrow(
'fiatRamps.onramperCheckoutFailed',
)
expect(walletAuth.signMessage).not.toHaveBeenCalled()
expect(post).toHaveBeenCalledTimes(1)
})
it('wallet rejection does not issue an intent', async () => {
const walletAuth = makeAuth()
walletAuth.signMessage.mockRejectedValueOnce(new Error('Rejected'))
post.mockResolvedValueOnce({ data: challenge() })
await expect(createOnRamperUrl({ ...props, walletAuth })).rejects.toThrow('Rejected')
expect(post).toHaveBeenCalledTimes(1)
})
it('refuses checkout when the wallet changes during authorization', async () => {
const walletAuth = makeAuth()
walletAuth.assertActive
.mockImplementationOnce(() => {})
.mockImplementationOnce(() => {
throw new Error('Wallet changed')
})
responses()
await expect(createOnRamperUrl({ ...props, walletAuth })).rejects.toThrow('Wallet changed')
expect(post).toHaveBeenCalledTimes(1)
})
it('rejects non-EVM and missing-wallet flows before contacting the signer', async () => {
await expect(createOnRamperUrl(props)).rejects.toThrow('fiatRamps.onramperWalletUnsupported')
await expect(
createOnRamperUrl({
...props,
assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0',
walletAuth: makeAuth(),
}),
).rejects.toThrow('fiatRamps.onramperWalletUnsupported')
expect(post).not.toHaveBeenCalled()
})
it('rejects a checkout URL pointing to another origin', async () => {
responses('https://evil.example/')
await expect(createOnRamperUrl({ ...props, walletAuth: makeAuth() })).rejects.toThrow(
'Invalid checkout URL',
)
})
it('drops a rejected session so the next attempt requires fresh wallet verification', async () => {
const walletAuth = makeAuth()
responses()
await createOnRamperUrl({ ...props, walletAuth })
post.mockRejectedValueOnce({ response: { status: 401 } })
await expect(createOnRamperUrl({ ...props, walletAuth })).rejects.toMatchObject({
response: { status: 401 },
})
responses()
await createOnRamperUrl({ ...props, walletAuth })
expect(walletAuth.signMessage).toHaveBeenCalledTimes(2)
})
})
Loading
Loading