Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ KEY_ALCHEMY=
URL_ALCHEMY=
URL_OP_EXPLORER=https://sepolia-optimism.etherscan.io/
URL_COINGECKO=https://api.coingecko.com/api/v3/simple/price
MOONPAY_API_KEY=
MOONPAY_SECRET_KEY=
MOONPAY_WIDGET_URL=
MOONPAY_BASE_CURRENCY_CODE=twd
AWS_S3_ACCESS_ID=
AWS_S3_ACCESS_KEY=
AWS_S3_BUCKET=
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,7 @@

- Install dependencies: `pnpm add .`
- Setup environment variables: `cp .env.example .env`

## Operations

- MoonPay/Vercel setup and verification: [docs/moonpay-vercel-checklist.md](docs/moonpay-vercel-checklist.md)
83 changes: 83 additions & 0 deletions app/components/MoonPay/ApplePayButton/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import clsx from 'clsx'
import { useState } from 'react'
import { useAccount } from 'wagmi'

import GradButton from '@components/Button/Grad'
import WalletSvg from '@components/Svg/Wallet'
import useAlert from '@hooks/useAlert'
import useAppEnv from '@hooks/useAppEnv'
import { DATA_STATE } from '@constants'

type PropsType = {
amount: number
}

const MIN_ONRAMP_AMOUNT = 20

const ApplePayButton = ({ amount }: PropsType) => {
const { address } = useAccount()
const { moonPayReady } = useAppEnv()
const { makeAlert } = useAlert()
const [isOpening, setIsOpening] = useState(false)

if (!moonPayReady || !address || amount <= 0) {
return null
}

const onrampAmount = Math.ceil(Math.max(amount, MIN_ONRAMP_AMOUNT))

const openMoonPay = async () => {
if (isOpening) {
return
}

setIsOpening(true)
try {
const params = new URLSearchParams({
address,
amount: onrampAmount.toString(),
redirectURL: window.location.href,
})
const response = await fetch(`/api/moonpay?${params.toString()}`)
const data = await response.json()

if (data?.state !== DATA_STATE.successful || !data?.url) {
throw new Error(data?.error || 'Unable to open MoonPay')
}

window.open(data.url, '_blank', 'noopener,noreferrer')
} catch {
makeAlert('Unable to open Apple Pay. Please try again later.')
} finally {
setIsOpening(false)
}
}

const baseCss = clsx('mt-4 max-w-form mx-auto')
const hintCss = clsx('mb-3 text-xs text-gray-50 font-normal')
const buttonCss = clsx('f-row-cc gap-x-2 py-3 w-full')
const buttonOuterCss = clsx('w-full')

return (
<section className={baseCss}>
<p className={hintCss}>
Need more USDT? Add about {onrampAmount} Optimism USDT to your connected
wallet, then return to confirm the bid. MoonPay shows fees before
payment.
</p>
<GradButton
classes={buttonCss}
outerClasses={buttonOuterCss}
color="dim-green"
type="button"
disabled={isOpening}
onClick={openMoonPay}
>
<WalletSvg />
Add {onrampAmount} USDT with Apple Pay
</GradButton>
</section>
)
}

export default ApplePayButton
5 changes: 5 additions & 0 deletions app/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ export const ERROR = {

// Upload
REACH_SIZE_LIMIT: 'REACH_SIZE_LIMIT',

// MoonPay
MOONPAY_NOT_SET: 'MOONPAY_NOT_SET',
MOONPAY_AMOUNT_INVALID: 'MOONPAY_AMOUNT_INVALID',
MOONPAY_REDIRECT_INVALID: 'MOONPAY_REDIRECT_INVALID',
}

export const TX_STATE = {
Expand Down
101 changes: 101 additions & 0 deletions app/routes/api.moonpay/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type { LoaderFunctionArgs } from '@remix-run/node'

import crypto from 'node:crypto'
import { json } from '@remix-run/node'

import { DATA_STATE, ERROR } from '@constants'
import { readSecretEnv } from '@server/env.server'
import { sendError } from '@server/helper.server'

const MOONPAY_CURRENCY_CODE = 'usdt_optimism'
const MOONPAY_PAYMENT_METHOD = 'apple_pay'
const MOONPAY_DEFAULT_BASE_CURRENCY = 'twd'
const MOONPAY_PRODUCTION_URL = 'https://buy.moonpay.com/'
const MOONPAY_SANDBOX_URL = 'https://buy-sandbox.moonpay.com/'
const MOONPAY_MIN_QUOTE_CURRENCY_AMOUNT = 20

const addressRegex = /^(0x)[0-9a-fA-F]{40}$/

const normalizeAmount = (value: string | null) => {
const amount = Number(value)

if (!Number.isFinite(amount) || amount <= 0) {
throw new Error(ERROR.MOONPAY_AMOUNT_INVALID, {
cause: ERROR.MOONPAY_AMOUNT_INVALID,
})
}

return Math.ceil(Math.max(amount, MOONPAY_MIN_QUOTE_CURRENCY_AMOUNT))
}

const getRedirectURL = (value: string | null, requestURL: URL) => {
const fallback = new URL('/bid/1', requestURL)

if (!value) {
return fallback.toString()
}

const redirectURL = new URL(value, requestURL)
if (redirectURL.origin !== requestURL.origin) {
throw new Error(ERROR.MOONPAY_REDIRECT_INVALID, {
cause: ERROR.MOONPAY_REDIRECT_INVALID,
})
}

return redirectURL.toString()
}

export const loader = async ({ request }: LoaderFunctionArgs) => {
try {
const requestURL = new URL(request.url)
const address = requestURL.searchParams.get('address') || ''
const amount = normalizeAmount(requestURL.searchParams.get('amount'))
const redirectURL = getRedirectURL(
requestURL.searchParams.get('redirectURL'),
requestURL
)

if (!addressRegex.test(address)) {
throw new Error(ERROR.ADDRESS_INVALID, { cause: ERROR.ADDRESS_INVALID })
}

const {
env,
moonPayApiKey,
moonPaySecretKey,
moonPayWidgetUrl,
moonPayBaseCurrencyCode,
} = readSecretEnv()

if (!moonPayApiKey || !moonPaySecretKey) {
throw new Error(ERROR.MOONPAY_NOT_SET, { cause: ERROR.MOONPAY_NOT_SET })
}

const widgetUrl =
moonPayWidgetUrl ||
(env === 'production' ? MOONPAY_PRODUCTION_URL : MOONPAY_SANDBOX_URL)
const url = new URL(widgetUrl)

url.searchParams.set('apiKey', moonPayApiKey)
url.searchParams.set('currencyCode', MOONPAY_CURRENCY_CODE)
url.searchParams.set(
'baseCurrencyCode',
(moonPayBaseCurrencyCode || MOONPAY_DEFAULT_BASE_CURRENCY).toLowerCase()
)
url.searchParams.set('quoteCurrencyAmount', amount.toString())
url.searchParams.set('paymentMethod', MOONPAY_PAYMENT_METHOD)
url.searchParams.set('walletAddress', address)
url.searchParams.set('redirectURL', redirectURL)

const signature = crypto
.createHmac('sha256', moonPaySecretKey)
.update(url.search)
.digest('base64')

url.searchParams.set('signature', signature)

return json({ state: DATA_STATE.successful, url: url.toString() })
} catch (error) {
return sendError(error)
}
}
6 changes: 6 additions & 0 deletions app/routes/bid.$id/Setup/SetBid/SetPrice/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import clsx from 'clsx'
import { useAccount, useBalance } from 'wagmi'

import ApplePayButton from '@components/MoonPay/ApplePayButton'
import useAppEnv from '@hooks/useAppEnv'
import {
calTaxAsNumber,
Expand Down Expand Up @@ -53,6 +54,8 @@ const SetPrice = ({

// condition
const isSufficient = isNewBid ? balance >= totalAmount : balance >= totalDiff
const requiredAmount = isNewBid ? totalAmount : totalDiff
const topUpAmount = Math.max(requiredAmount - balance, 0)
const isUnderPrice =
priceChanged && price != prevBidPrice && price <= highestBidPrice
const canNext =
Expand Down Expand Up @@ -96,6 +99,9 @@ const SetPrice = ({
tax={tax}
totalAmount={totalAmount}
/>
{balanceData && !isSufficient && (
<ApplePayButton amount={topUpAmount} />
)}
</section>

<Controls disabled={!canNext} updateSetBidStep={updateSetBidStep} />
Expand Down
4 changes: 4 additions & 0 deletions app/server/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,9 @@ export const readSecretEnv = () => {
awsS3AccessId: env.AWS_S3_ACCESS_ID,
awsS3AccessKey: env.AWS_S3_ACCESS_KEY,
awsS3Bucket: env.AWS_S3_BUCKET,
moonPayApiKey: env.MOONPAY_API_KEY,
moonPaySecretKey: env.MOONPAY_SECRET_KEY,
moonPayWidgetUrl: env.MOONPAY_WIDGET_URL,
moonPayBaseCurrencyCode: env.MOONPAY_BASE_CURRENCY_CODE,
}
}
1 change: 1 addition & 0 deletions app/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type AppEnvType = {
tokenIdShowCase: string
idWalletConnect: string
urlOpExplorer: string
moonPayReady?: boolean
urlContract: string
gaId: string
}
Expand Down
1 change: 1 addition & 0 deletions app/utils/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const readEnv = () => {
idWalletConnect: env.ID_WALLET_CONNECT,
urlOpExplorer: env.URL_OP_EXPLORER,
urlCoinGecko: env.URL_COINGECKO,
moonPayReady: !!(env.MOONPAY_API_KEY && env.MOONPAY_SECRET_KEY),
urlContract,
gaId: env.GA_ID,
}
Expand Down
Loading