DEV Community

AdamPay
AdamPay

Posted on

Building AdamPay: a non-custodial Solana payment PWA with Jupiter, Solana Pay and Phantom

AdamPay (adampay.app) is a small payment web app for Solana. You deposit SOL into your own Phantom wallet, swap it to USDC through Jupiter, and pay with Solana Pay QR codes. It never touches private keys: every transaction is built in the browser and signed in Phantom.

This post walks through how it's put together. There's no framework and no backend for money flows, just static files on a CDN, a handful of public APIs, and a service worker. The snippets below are taken from the actual code (lightly trimmed).

Architecture in one paragraph

The app is plain HTML, CSS and JavaScript, served as static files. It uses @solana/web3.js (bundled), a QR generator, jsQR as a scanner fallback, and supabase-js for accounts. Solana state comes from public JSON-RPC endpoints, and swaps go through Jupiter's API. The only "server" piece is Supabase Auth for email and password login. Money never passes through anything AdamPay controls.

1. Talking to Solana: plain JSON-RPC with fallback

Instead of a heavy client, balances and statuses come from raw JSON-RPC calls. Each call tries a list of public RPC URLs in order:

async function rpc(method, params) {
  let lastErr;
  for (const url of RPCS) {
    try {
      const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) });
      const j = await r.json();
      if (j.error) { lastErr = new Error(j.error.message); continue; }
      return j.result;
    } catch (e) { lastErr = e; }
  }
  throw lastErr || new Error('RPC unreachable');
}
Enter fullscreen mode Exit fullscreen mode

The USDC balance is read from the user's associated token account (ATA), which is derived client-side:

const ata = (owner, mint = USDC_MINT) => W3.PublicKey.findProgramAddressSync(
  [owner.toBuffer(), TOKEN_PROGRAM.toBuffer(), PK(mint).toBuffer()], ATA_PROGRAM)[0];
Enter fullscreen mode Exit fullscreen mode

2. Swapping SOL → USDC with Jupiter

The swap is two calls. First, a quote with 0.5% slippage (SLIPPAGE_BPS = 50):

const r = await fetch(`${JUP}/swap/v1/quote?inputMint=${SOL_MINT}&outputMint=${USDC_MINT}` +
  `&amount=${Math.floor(amountSol * 1e9)}&slippageBps=${SLIPPAGE_BPS}&restrictIntermediateTokens=true`);
Enter fullscreen mode Exit fullscreen mode

The UI shows the expected output, otherAmountThreshold (the minimum received), priceImpactPct, and the route labels from routePlan. Price impact above 1% is shown in red.

When the user taps Convert, the app fetches a fresh quote, so it never reuses a stale one. It then asks Jupiter to build the transaction for the user's public key:

const r = await fetch(`${JUP}/swap/v1/swap`, { method: 'POST', headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ quoteResponse: quote, userPublicKey: pubkey.toBase58(), wrapAndUnwrapSol: true,
    dynamicComputeUnitLimit: true,
    prioritizationFeeLamports: { priorityLevelWithMaxLamports: { maxLamports: 1000000, priorityLevel: 'high' } } }) });
const tx = W3.VersionedTransaction.deserialize(b64(j.swapTransaction));
const { signature } = await provider.signAndSendTransaction(tx);
Enter fullscreen mode Exit fullscreen mode

There's no platform fee parameter in the request, so AdamPay takes no cut. The user pays the Solana network fee plus a priority fee, capped at 0.001 SOL. The app also keeps 0.01 SOL in reserve (FEE_RESERVE), so the "Max" button never leaves the wallet unable to pay fees.

3. Phantom: connect, sign, never hold keys

The provider is detected from window.phantom.solana. On mobile without the extension, the app deep-links into Phantom's in-app browser:

const phantomDeeplink = () =>
  `https://phantom.app/ul/browse/${encodeURIComponent(location.href)}?ref=${encodeURIComponent(location.origin)}`;
Enter fullscreen mode Exit fullscreen mode

Every write goes through provider.signAndSendTransaction(tx). The app then polls getSignatureStatuses until the transaction is confirmed or finalized, and links the signature to Solscan. If the user rejects in Phantom, the error is caught and shown as "Cancelled in Phantom" instead of a scary stack trace.

4. Solana Pay: parsing, paying and requesting

Paying. A scanned or pasted value is either a bare address or a solana: URL. The parser pulls out the Solana Pay fields:

const u = new URL(text.replace(/^solana:/i, 'solana:'));
const recipient = decodeURIComponent(u.pathname);
const p = u.searchParams;
return { recipient, amount: p.get('amount'), splToken: p.get('spl-token'), label: p.get('label'),
         message: p.get('message'), memo: p.get('memo'), references: p.getAll('reference') };
Enter fullscreen mode Exit fullscreen mode

AdamPay only pays in USDC, so requests for other tokens or native SOL get a clear message instead of a failed transaction. Before enabling Continue, the app checks the recipient on-chain. It might be a USDC token account, a wallet whose USDC ATA already exists, or a wallet that needs one created. Off-curve (program) addresses get a warning.

The transfer itself is assembled by hand. It sets a compute budget, adds an idempotent ATA creation if needed, an optional memo, and a TransferChecked instruction. Solana Pay reference keys are appended as read-only accounts, so merchants can find the payment:

const data = Buf.alloc(10); data[0] = 12; // TransferChecked
let u = units; for (let i = 1; i <= 8; i++) { data[i] = Number(u & 0xffn); u >>= 8n; } data[9] = 6;
const keys = [{ pubkey: srcAta, isSigner: false, isWritable: true }, { pubkey: mint, isSigner: false, isWritable: false },
  { pubkey: destAta, isSigner: false, isWritable: true }, { pubkey: from, isSigner: true, isWritable: false }];
(references || []).forEach(r => { try { keys.push({ pubkey: PK(r), isSigner: false, isWritable: false }); } catch {} });
Enter fullscreen mode Exit fullscreen mode

Requesting. A payment request is just a Solana Pay URL rendered as a QR code:

q.set('spl-token', USDC_MINT); q.set('label', 'AdamPay'); if (msg) q.set('message', msg);
return `solana:${pubkey.toBase58()}?${q.toString().replace(/\+/g, '%20')}`;
Enter fullscreen mode Exit fullscreen mode

Scanning happens entirely on the device. The app uses the native BarcodeDetector where available and falls back to jsQR on a canvas frame. No images leave the phone.

5. The service worker: installable, but never caching money

The PWA has a manifest (display: standalone) and a deliberately conservative service worker. It caches only the app shell. Anything cross-origin, including RPC, Jupiter and Supabase, is never intercepted:

self.addEventListener('fetch', e => {
  const req = e.request;
  const url = new URL(req.url);
  if (req.method !== 'GET' || url.origin !== self.location.origin) return;
  if (url.pathname.startsWith('/admin')) return;
  // ...network-first for navigation and shell files, cache only as offline fallback
});
Enter fullscreen mode Exit fullscreen mode

Both navigation and static files are network-first, so users always get the latest code, and the cache is only an offline fallback. Navigations whose query string might carry auth codes are never written to the cache.

Installing differs by platform. On Android/Chrome the app captures beforeinstallprompt and shows its own Download AdamPay button. iOS has no install prompt, so the app explains Share → Add to Home Screen. It also sniffs in-app browsers (X, Instagram, Telegram, Phantom's browser…), which can't install PWAs at all, and offers "Open in Safari/Chrome" plus a copy-link fallback.

6. Accounts with Supabase Auth

Using the app requires an account (email + password) via Supabase Auth. Only the public, browser-safe key is shipped:

const sb = window.supabase.createClient(SB_URL, SB_PUBLISHABLE_KEY, {
  auth: { persistSession: true, autoRefreshToken: true, detectSessionInUrl: true, storageKey: 'adampay-auth' }
});
Enter fullscreen mode Exit fullscreen mode

onAuthStateChange drives a small router: app screens are gated in go(), and signed-out users land on a public landing page. Logging out also disconnects Phantom on that device. Password reset uses resetPasswordForEmail and updateUser. "Log in with Phantom" (Sign in with Solana via signInWithWeb3) is planned but not enabled yet.

What's deliberately not there

  • No custody, no keys, no seed phrases. The app can't move funds without a Phantom signature.
  • No automatic swaps. "Auto-convert" only suggests a conversion when SOL crosses a threshold you set.
  • No AdamPay fee in swaps or transfers. Users pay Solana network fees and market pricing through Jupiter.

Risk note

Crypto prices are volatile, and Solana payments are irreversible. With a non-custodial app you are responsible for your own wallet and seed phrase. Never share your seed phrase. Not financial advice.

Try it / follow along

The app is live at adampay.app, and updates are on X at @adampayapp. Questions about the implementation are welcome in the comments.

Top comments (0)