diff --git a/.env.example b/.env.example index 8dce1c9..2a847c7 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/README.md b/README.md index 14f0958..96974f0 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/app/components/MoonPay/ApplePayButton/index.tsx b/app/components/MoonPay/ApplePayButton/index.tsx new file mode 100644 index 0000000..a59ff47 --- /dev/null +++ b/app/components/MoonPay/ApplePayButton/index.tsx @@ -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 ( +
+

+ 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 && ( + + )} diff --git a/app/server/env.server.ts b/app/server/env.server.ts index 94c9e9f..f756fd7 100644 --- a/app/server/env.server.ts +++ b/app/server/env.server.ts @@ -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, } } diff --git a/app/types/index.d.ts b/app/types/index.d.ts index 567d9a0..ca16780 100644 --- a/app/types/index.d.ts +++ b/app/types/index.d.ts @@ -14,6 +14,7 @@ type AppEnvType = { tokenIdShowCase: string idWalletConnect: string urlOpExplorer: string + moonPayReady?: boolean urlContract: string gaId: string } diff --git a/app/utils/env.ts b/app/utils/env.ts index a5328fd..b6b53e6 100644 --- a/app/utils/env.ts +++ b/app/utils/env.ts @@ -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, } diff --git a/docs/moonpay-vercel-checklist.md b/docs/moonpay-vercel-checklist.md new file mode 100644 index 0000000..e9ae73a --- /dev/null +++ b/docs/moonpay-vercel-checklist.md @@ -0,0 +1,180 @@ +# MoonPay and Vercel Checklist + +This checklist covers the hosted MoonPay Apple Pay on-ramp used by the bid +flow. The integration is non-custodial: MoonPay sends Optimism USDT to the +user's connected wallet, then the user returns to Billboard and completes the +existing `Approve USDT` and `Confirm` transactions. + +## MoonPay setup + +1. Apply for MoonPay partner access and production API keys. +2. Request production support for: + - production domains: + - `https://billboard.matters-lab.io` + - `https://billboard-app.vercel.app` + - widget flow: buy/on-ramp + - asset: `usdt_optimism` + - payment method: Apple Pay + - fiat currencies: `twd` and `usd` +3. Confirm MoonPay's Optimism USDT asset matches the app's production USDT + contract: + `0x94b008aA00579c1307B0EF2c499aD98a8ce58e58` +4. Confirm the launch geography. MoonPay's public currencies API currently + marks `usdt_optimism` as unavailable in the US and Canada. +5. Confirm commercial terms before production approval: + - setup fee, if any + - monthly minimum, if any + - minimum volume commitment, if any + - whether any ecosystem or affiliate fee is enabled +6. Keep sandbox and production keys separate. Do not use test keys on the + production deployment. + +`MOONPAY_API_KEY` is included in the hosted MoonPay URL and can be treated as a +public integration identifier. `MOONPAY_SECRET_KEY` signs the wallet-address URL +server-side and must stay server-only. + +## Vercel setup + +Add the following environment variables to the Vercel project: + +```text +MOONPAY_API_KEY= +MOONPAY_SECRET_KEY= +MOONPAY_BASE_CURRENCY_CODE=twd +``` + +Optional: + +```text +MOONPAY_WIDGET_URL=https://buy.moonpay.com/ +``` + +If `MOONPAY_WIDGET_URL` is unset, production uses `https://buy.moonpay.com/` +and non-production uses `https://buy-sandbox.moonpay.com/`. + +## Cost policy + +The integration should launch without an added Matters ecosystem or affiliate +fee. MoonPay's public pricing disclosure says checkout can include network fees, +MoonPay fees, ecosystem fees if enabled, spreads, and bank/card provider fees. +For partner-referred flows, the MoonPay fee can be up to 4.5% with a minimum +fee for smaller transactions. MoonPay's partner FAQ also states that the usual +minimum buy amount is 20 USD or a fiat-currency equivalent. + +The app therefore opens MoonPay with at least 20 USDT. Smaller top-ups can make +the fixed minimum fee feel disproportionately expensive, so do not lower this +minimum unless MoonPay confirms a better production fee schedule for the active +key. + +Use the Vercel dashboard: + +1. Open the Billboard Vercel project. +2. Go to Settings -> Environment Variables. +3. Add the MoonPay variables for Production. +4. Add sandbox MoonPay variables for Preview if preview testing is needed. +5. Redeploy the active production deployment after saving the variables. + +## Verification + +After redeploying production, verify the integration in this order. + +1. Confirm the deployed bundle includes the MoonPay entrypoint: + + ```sh + tmp=$(mktemp -d) + curl -fsSL https://billboard.matters-lab.io/bid/1 > "$tmp/bid.html" + for p in $(rg -o '/assets/[^" ]+\.js' "$tmp/bid.html" | sort -u); do + curl -fsSL "https://billboard.matters-lab.io$p" > "$tmp/$(basename "$p")" + done + rg "api/moonpay|usdt_optimism|MoonPay|Add about" "$tmp" + rm -rf "$tmp" + ``` + +2. Confirm the target board allows open bidding, if that is expected for the + launch: + + ```sh + # Read from the Operator contract on Optimism: + # isBoardWhitelistDisabled(1) should return true. + ``` + + Contract page: + + +3. Open `https://billboard.matters-lab.io/bid/1` with a connected wallet that + has less Optimism USDT than the bid requires. +4. Enter a valid bid amount. The price step should show an Apple Pay top-up + button: + + ```text + Add USDT with Apple Pay + ``` + + The amount should be at least 20 USDT, even if the wallet shortfall is + smaller. + +5. Click the button. MoonPay should open in a new tab with: + - output asset: `usdt_optimism` + - payment method preference: Apple Pay + - destination address: the connected wallet address + - signed URL containing a `signature` parameter +6. Return to Billboard after MoonPay completes. The original bid flow should + still require the user to sign: + - `Approve USDT`, if allowance is insufficient + - `Confirm`, to place or update the bid + +## Matters release evaluation + +Follow the release evaluation SOP in +`/Users/mashbean/Documents/AI-Agent/docs/projects/matters-release-evaluation-agent/agent-sop.md`. + +Report these gates separately: + +- Repo/build status. +- Automated E2E status. +- Staging or preview acceptance. +- Production approval state. +- Production smoke-test state. + +For production, do read-only checks before human approval. Any production bid, +payment, moderation action, or contract write requires explicit human approval. + +## Cross-system acceptance + +MoonPay only changes how advertisers enter the bid flow. Before launch sign-off, +also verify the surfaces that consume Billboard outputs. + +1. Matters Web ad placement: + - Open a production Matters page that renders the Billboard placement. + - Confirm it still links to `https://billboard.matters-lab.io`. + - Confirm the winning ad image renders after the auction epoch changes. + - Confirm the production `NEXT_PUBLIC_BILLBOARD_IMAGE_URL` allows the S3 + image host returned by Billboard uploads, currently: + `https://.s3.ap-southeast-1.amazonaws.com/`. + - Confirm ad clicks still record and redirect through the existing Matters + Web Billboard component. +2. QF matching: + - Do not rely on the Lambda handler's historical default pool amount for a + production round. + - Pass the reviewed `amountTotal` for the round explicitly when running the + QF calculator. + - Validate `rounds.json` and the distribution files before finalizing and + sending notifications. +3. Observable matching dashboard: + - Verify the notebook still loads the current `rounds.json` and distribution + files after the next QF round. + - Treat notebook edits as blocked until the team has the notebook URL and + editor access. + +## Troubleshooting + +- If the Apple Pay button does not appear, check that `MOONPAY_API_KEY` is set + in the active Vercel environment and that the deployment was redeployed after + the variable was added. +- If the button appears but cannot open MoonPay, check `MOONPAY_SECRET_KEY` and + the `/api/moonpay` response. +- If MoonPay opens but does not allow the purchase, confirm the API key is + approved for `usdt_optimism`, Apple Pay, the user's country, and the active + domain. +- If the user can buy USDT but cannot bid, verify the board whitelist state and + the user's wallet network. Production bidding runs on Optimism.