From 8ec6286ff7fe69594a7b4431b07c1ef068d45b53 Mon Sep 17 00:00:00 2001 From: martbost Date: Tue, 8 Sep 2026 06:26:15 -0500 Subject: [PATCH] Add MoonPay card on-ramp (same underpinning as RM Circle) Signed /api/moonpay-url endpoint: public key from MOONPAY_PUBLIC_KEY env or site config, SECRET from MOONPAY_SECRET_KEY env ONLY (never site config, since /api/config exposes siteConfig). Wallet-prefilled signed MoonPay URL when keys are set, else a generic buy page. "Buy POL with a card" button under the Buy packages tiles. Zero custody: MoonPay is merchant of record; crypto goes straight to the buyer's wallet. Co-Authored-By: Claude Opus 4.8 --- public/assets/my.js | 26 ++++++++++++++++++++++++++ public/my.html | 2 +- server.js | 21 +++++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/public/assets/my.js b/public/assets/my.js index b4f9f1c..a03273e 100644 --- a/public/assets/my.js +++ b/public/assets/my.js @@ -1182,6 +1182,23 @@ // ── in-dashboard package buying ── const PKG = { 1: 'Micro', 2: 'Activation', 3: 'Builder', 4: 'Growth', 5: 'Leader' }; + // Card on-ramp: buy POL with a card via MoonPay, delivered to the buyer's own + // wallet. Signed + wallet-prefilled once MoonPay keys are set; generic page + // otherwise. The site never touches funds — MoonPay is merchant of record. + async function openMoonpay(pol) { + try { + let addr = ''; + try { const me = await (await fetch('/api/me')).json(); addr = me.address || ''; } catch (e) {} + const q = '/api/moonpay-url?pol=' + encodeURIComponent(pol || '') + (addr ? '&address=' + encodeURIComponent(addr) : ''); + const r = await (await fetch(q)).json(); + if (r && r.url) { + window.open(r.url, '_blank', 'noopener'); + IAP.status(r.signed + ? 'MoonPay opened in a new tab with your wallet address pre-filled. Choose POL on Polygon, finish the purchase, then come back and buy your package.' + : 'MoonPay opened in a new tab. Choose POL on the Polygon network and paste your own wallet address as the destination, then come back.', 'ok'); + } + } catch (e) { IAP.status('Could not open MoonPay: ' + ((e && e.message) || e), 'bad'); } + } async function loadBuyTiles() { try { const { products } = await (await fetch('/api/catalog')).json(); @@ -1221,6 +1238,15 @@ } catch (e) { IAP.status('Purchase failed: ' + ((e && e.message) || e), 'bad'); } finally { b.disabled = false; } })); + // brand new to crypto? buy POL with a card, sent straight to the wallet + const builder = products.find(p => p.priceCents === 5000) || products[products.length - 1]; + const needPol = (builder && builder.costWei) ? Math.max(30, Math.ceil(Number(builder.costWei) / 1e18) + 3) : 30; + const cta = document.createElement('div'); + cta.style.cssText = 'grid-column:1/-1;margin-top:10px;text-align:center'; + cta.innerHTML = '

New to crypto? Buy POL with a debit or credit card, Apple Pay, or Google Pay. It lands straight in your own wallet, and this site never touches your money.

' + + ''; + wrap.appendChild(cta); + const mb = $('moonpayBtn'); if (mb) mb.addEventListener('click', () => openMoonpay(needPol)); } catch (e) {} } loadBuyTiles(); diff --git a/public/my.html b/public/my.html index 6404742..163ec0d 100644 --- a/public/my.html +++ b/public/my.html @@ -687,7 +687,7 @@ - + diff --git a/server.js b/server.js index 8134f3a..512dc9f 100644 --- a/server.js +++ b/server.js @@ -316,6 +316,27 @@ const server = http.createServer(async (req, res) => { chainName: c.chainName, explorer: c.explorer, rpc: c.rpcs[0], emailAuth: mailer.hasKey() || !IS_PROD }, siteConfig())); } + if (p === '/api/moonpay-url' && req.method === 'GET') { + // Card on-ramp deep link. With MoonPay keys set — PUBLIC key via + // MOONPAY_PUBLIC_KEY env or site config, SECRET key via MOONPAY_SECRET_KEY + // env ONLY (never site config, since /api/config exposes siteConfig) — + // returns a SIGNED checkout URL prefilled with the buyer's own wallet and + // a POL amount; otherwise a generic MoonPay buy page. Zero custody either + // way: MoonPay is merchant of record and the crypto goes straight to the + // buyer's wallet — this site never touches or holds anyone's money. + const addr = (u.searchParams.get('address') || '').trim(); + let pol = Math.round(Number(u.searchParams.get('pol')) || 0); + if (!pol || pol < 30) pol = 30; + if (pol > 100000) pol = 100000; + const pk = (process.env.MOONPAY_PUBLIC_KEY || siteConfig().moonpayPublicKey || '').trim(); + const sk = (process.env.MOONPAY_SECRET_KEY || '').trim(); + if (pk && sk && /^0x[0-9a-fA-F]{40}$/.test(addr)) { + const qs = '?apiKey=' + encodeURIComponent(pk) + '¤cyCode=pol_polygon&walletAddress=' + encodeURIComponent(addr) + '"eCurrencyAmount=' + pol; + const sig = crypto.createHmac('sha256', sk).update(qs).digest('base64'); + return json(res, 200, { url: 'https://buy.moonpay.com/' + qs + '&signature=' + encodeURIComponent(sig), signed: true, pol }); + } + return json(res, 200, { url: 'https://www.moonpay.com/buy/pol', signed: false, pol }); + } if (p === '/api/catalog' && req.method === 'GET') { return json(res, 200, { products: await chain.catalog() }); }