+
+ Need more USDT? Add about {onrampAmount} Optimism USDT to your connected
+ wallet, then return to confirm the bid. MoonPay shows fees before
+ payment.
+
+
+
+ Add {onrampAmount} USDT with Apple Pay
+
+
+ )
+}
+
+export default ApplePayButton
diff --git a/app/constants/index.ts b/app/constants/index.ts
index 1716854..6dc7005 100644
--- a/app/constants/index.ts
+++ b/app/constants/index.ts
@@ -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 = {
diff --git a/app/routes/api.moonpay/index.ts b/app/routes/api.moonpay/index.ts
new file mode 100644
index 0000000..4fabc0a
--- /dev/null
+++ b/app/routes/api.moonpay/index.ts
@@ -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)
+ }
+}
diff --git a/app/routes/bid.$id/Setup/SetBid/SetPrice/index.tsx b/app/routes/bid.$id/Setup/SetBid/SetPrice/index.tsx
index f358d3c..dd106c5 100644
--- a/app/routes/bid.$id/Setup/SetBid/SetPrice/index.tsx
+++ b/app/routes/bid.$id/Setup/SetBid/SetPrice/index.tsx
@@ -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,
@@ -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 =
@@ -96,6 +99,9 @@ const SetPrice = ({
tax={tax}
totalAmount={totalAmount}
/>
+ {balanceData && !isSufficient && (
+