// The page a client opens. It reads the invoice from the chain and talks to 1Click// directly, so Duly's servers are not in the path of the money.import { h, icon, mount, action, copy, field, fmtDate, fmtDateTime, qr, short, statusChip, toast } from './ui.js';import { NET, apiGet, apiPost, base, contractVerified, isTest, txUrl, view } from './api.js';import { ONE_CLICK } from './lib/config.js';import { openDocument, fromUrlKey } from './lib/doc.js';import { formatAmount, invoiceTotals, parseAmount } from './lib/money.js';import { assertPaymentQuote, paymentMessage, paymentQuoteBody, postQuote } from './lib/quote.js';import { EVM_CHAIN_IDS, addressOk, chainName, groupTokens, refundTag } from './lib/chains.js';const usd = (u) => `$${formatAmount(u)}`;/** The public mainnet sample invoice judges and visitors can pay with about 10 cents. */const SAMPLE = { id: '1-4', routes: 'USDC on Arbitrum, Optimism, Polygon, Gnosis or NEAR' };export async function loadDoc(id, invoice, keyText) { if (!keyText) return { doc: null, state: 'locked' }; try { const stored = await apiGet(`/api/doc?id=${encodeURIComponent(id)}`); return { doc: await openDocument(stored.ct, fromUrlKey(keyText), invoice.doc_hash), state: 'ok' }; } catch (e) { return { doc: null, state: /does not match/.test(e.message) ? 'tampered' : 'missing' }; }}export function invoiceSheet(inv, doc) { const paid = ['paid', 'partially_refunded'].includes(inv.status); const t = doc?.items?.length ? invoiceTotals(doc) : null; return h( 'section', { class: 'card invoice' }, h( 'div', { class: 'inv-head' }, h('div', {}, h('h2', {}, doc?.from?.name || 'Invoice'), doc?.from?.email && h('p', { class: 'muted small' }, doc.from.email), h('p', { class: 'muted small mono' }, `Account ${short(inv.merchant, 10, 6)}`)), h('div', { class: 'inv-no' }, h('p', { class: 'num' }, `Invoice ${String(inv.number).padStart(4, '0')}`), paid ? h('div', { class: 'stamp' }, 'PAID') : statusChip(inv.status)), ), h( 'div', { class: 'parties' }, h('div', {}, h('p', { class: 'muted small' }, 'Billed to'), h('p', {}, doc?.to?.name || 'Private'), doc?.to?.email && h('p', { class: 'muted small' }, doc.to.email)), h('div', {}, h('p', { class: 'muted small' }, 'Issued'), h('p', {}, fmtDate(inv.issued_at_ms))), h('div', {}, h('p', { class: 'muted small' }, 'Due'), h('p', {}, fmtDate(inv.due_at_ms))), ), t && h( 'table', { class: 'lines' }, h('thead', {}, h('tr', {}, h('th', {}, 'Item'), h('th', { class: 'amt' }, 'Qty'), h('th', { class: 'amt' }, 'Amount'))), h('tbody', {}, doc.items.map((it, i) => h('tr', {}, h('td', {}, it.d), h('td', { class: 'amt' }, it.q), h('td', { class: 'amt' }, formatAmount(t.lines[i]))))), ), h( 'div', { class: 'totals' }, t && h('div', {}, h('span', {}, 'Subtotal'), h('span', {}, formatAmount(t.subtotal))), t && t.tax > 0n && h('div', {}, h('span', {}, `Tax ${(doc.taxBps / 100).toString()}%`), h('span', {}, formatAmount(t.tax))), h('div', { class: 'grand' }, h('span', {}, 'Total'), h('span', {}, `${formatAmount(inv.amount)} USDC`)), doc?.ccy && doc.ccy !== 'USD' && doc.fx && h('div', { class: 'muted small' }, h('span', {}, `About, at ${doc.fx}`), h('span', {}, `${(Number(inv.amount) / 1e6 * Number(doc.fx)).toFixed(2)} ${doc.ccy}`)), BigInt(inv.paid) > 0n && h('div', { class: 'small' }, h('span', {}, 'Received'), h('span', {}, formatAmount(inv.paid))), BigInt(inv.refunded) > 0n && h('div', { class: 'small' }, h('span', {}, 'Refunded'), h('span', {}, formatAmount(inv.refunded))), ), doc?.note && h('p', { class: 'muted small' }, doc.note), t && t.total !== BigInt(inv.amount) && h('div', { class: 'banner bad' }, 'The line items do not add up to the amount on-chain. Check with the sender before paying.'), );}function proofCard(inv, docState) { const lines = [ `Amount, dates and payments are read live from contract ${NET.contract} on NEAR, not from a Duly server.`, docState === 'ok' ? 'The invoice details match the fingerprint recorded on-chain when it was issued, so they have not been changed.' : null, `Issued by account ${short(inv.merchant, 8, 6)}.`, ].filter(Boolean); return h('details', { class: 'card small no-print' }, h('summary', {}, 'How to check this invoice yourself'), h('div', { class: 'stack' }, lines.map((l) => h('p', { class: 'muted' }, l)), h('a', { href: `${NET.explorer}/address/${NET.contract}` }, 'Open the contract on the NEAR explorer')));}async function receipts(id) { const { payments } = await apiGet(`/api/watch?id=${encodeURIComponent(id)}`).catch(() => ({ payments: [] })); return payments.filter((p) => p.near_tx);}function receiptCard(inv, txs) { return h( 'section', { class: 'card stack no-print-border' }, h('div', { class: 'row between' }, h('h3', {}, 'Payment record'), h('button', { class: 'no-print', onClick: () => window.print() }, 'Save as PDF')), h( 'div', { class: 'list' }, inv.payments.map((p) => h('div', { class: 'item' }, h('div', {}, h('p', {}, `${formatAmount(p.amount)} USDC`), h('p', { class: 'muted small' }, fmtDateTime(p.at_ms))), h('p', { class: 'muted small mono right' }, short(p.refund_to.split(':').pop() || '', 8, 6))), ), ), txs.length > 0 && h( 'ul', { class: 'txs flush' }, txs.map((t) => h( 'li', { class: 'tx' }, h('span', { class: 'net near' }, 'NEAR'), h('span', { class: 'h' }, h('a', { class: 'mono', href: txUrl(t.near_tx), title: t.near_tx }, short(t.near_tx, 8, 6)), h('span', { class: 'what' }, 'settlement')), h('span', { class: 'acts' }, h('button', { type: 'button', class: 'copy', 'aria-label': 'Copy transaction hash', onClick: () => copy(t.near_tx, 'Copied') }, icon('copy'))), ), ), ), BigInt(inv.excess) > 0n && h('div', { class: 'banner warn' }, `${formatAmount(inv.excess)} USDC more than the invoice arrived. The sender can refund it.`), );}// -------------------------------------------------- payingconst STEP_ORDER = ['PENDING_DEPOSIT', 'KNOWN_DEPOSIT_TX', 'PROCESSING', 'SUCCESS'];const STEP_WORDS = ['Waiting for your payment', 'Payment seen', 'Converting to USDC', 'Invoice paid'];function steps(status) { const at = Math.max(0, STEP_ORDER.indexOf(status)); return h('ol', { class: 'steps' }, STEP_WORDS.map((w, i) => h('li', { class: i < at || status === 'SUCCESS' ? 'done' : i === at ? 'now' : '' }, w)));}async function payFromBrowserWallet(token, quote) { const eth = window.ethereum; const chainId = EVM_CHAIN_IDS[token.blockchain]; const [from] = await eth.request({ method: 'eth_requestAccounts' }); await eth.request({ method: 'wallet_switchEthereumChain', params: [{ chainId: `0x${chainId.toString(16)}` }] }); const amount = BigInt(quote.quote.amountIn); const to = quote.quote.depositAddress; const tx = token.contractAddress ? { from, to: token.contractAddress, data: `0xa9059cbb${to.slice(2).toLowerCase().padStart(64, '0')}${amount.toString(16).padStart(64, '0')}`, } : { from, to, value: `0x${amount.toString(16)}` }; const hash = await eth.request({ method: 'eth_sendTransaction', params: [tx] }); await fetch(`${ONE_CLICK}/deposit/submit`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ depositAddress: to, txHash: hash }), }).catch(() => null);}function trackPayment(box, id, quote, onPaid) { const address = quote.quote.depositAddress; const render = (status) => { if (status === 'REFUNDED' || status === 'FAILED') { box.replaceChildren(h('div', { class: 'banner warn' }, 'This payment did not go through. Your money was sent back to your refund address.')); return true; } if (status === 'INCOMPLETE_DEPOSIT') { box.replaceChildren(h('div', { class: 'banner warn' }, 'Less than the required amount arrived. It will go back to your refund address at the deadline.')); return false; } box.replaceChildren(steps(status)); return false; }; render('PENDING_DEPOSIT'); const timer = setInterval(async () => { const res = await fetch(`${ONE_CLICK}/status?depositAddress=${encodeURIComponent(address)}`).catch(() => null); const s = res?.ok ? await res.json().catch(() => null) : null; if (s?.status && render(s.status)) clearInterval(timer); const inv = await view('get_invoice', { id }).catch(() => null); if (inv && BigInt(inv.paid) > BigInt(quote.paidBefore)) { clearInterval(timer); onPaid(); } }, 6000);}function addressPanel(id, inv, token, quote, onPaid) { const address = quote.quote.depositAddress; const amount = quote.quote.amountInFormatted; const box = h('div', {}); const canWallet = window.ethereum && token.blockchain in EVM_CHAIN_IDS; const panel = h( 'section', { class: 'card stack' }, h('h3', {}, `Send ${amount} ${token.symbol}`), h('p', { class: 'muted small' }, `On ${chainName(token.blockchain)}. Send the exact amount, from your wallet or an exchange.`), qr(address), h('p', { class: 'mono' }, address), h('div', { class: 'row' }, h('button', { onClick: () => copy(address, 'Address copied') }, 'Copy address'), h('button', { onClick: () => copy(amount, 'Amount copied') }, 'Copy amount')), quote.quote.depositMemo && h('div', { class: 'banner warn' }, `You must also include this memo: ${quote.quote.depositMemo}`), canWallet && h('button', { class: 'primary wide', onClick: action(() => payFromBrowserWallet(token, quote), 'Check your wallet') }, 'Pay with my wallet'), h('p', { class: 'muted small' }, `Price held until ${fmtDateTime(Date.parse(quote.quote.deadline))}. Any extra comes back to your refund address, less a small network fee, and so does the payment if it cannot complete.`), h('div', { class: 'banner good small' }, `Checked: this address is signed by NEAR Intents and pays invoice ${inv.number} from account ${short(inv.merchant, 10, 6)}. Only pay if you expected an invoice from this sender.`), box, ); trackPayment(box, id, { ...quote, paidBefore: inv.paid }, onPaid); apiPost('/api/watch', { id, deposit: address }).catch(() => null); return panel;}async function payCard(id, inv, onPaid) { const groups = groupTokens(await (await fetch(`${ONE_CLICK}/tokens`)).json()); const symbolSel = h('select', { 'aria-label': 'Token' }, groups.map((g) => h('option', { value: g.symbol }, g.symbol))); const chainSel = h('select', { 'aria-label': 'Network' }); const refund = h('input', { placeholder: 'Your wallet address', autocomplete: 'off', spellcheck: 'false' }); const partAmount = h('input', { inputmode: 'decimal', value: formatAmount(inv.due_now).replace(/,/g, '') }); const result = h('div', { class: 'stack' }); const token = () => groups.find((g) => g.symbol === symbolSel.value).options.find((o) => o.assetId === chainSel.value); const fillChains = () => { const g = groups.find((x) => x.symbol === symbolSel.value); chainSel.replaceChildren(...g.options.map((o) => h('option', { value: o.assetId }, chainName(o.blockchain)))); }; fillChains(); symbolSel.addEventListener('change', fillChains); const buildBody = (dry) => { const t = token(); const address = refund.value.trim(); if (!addressOk(t.blockchain, address)) throw new Error(`Enter your ${chainName(t.blockchain)} address, so a failed payment can come back to you`); const amount = parseAmount(partAmount.value); if (amount <= 0n || amount > BigInt(inv.due_now)) throw new Error(`Enter an amount up to ${formatAmount(inv.due_now)}`); const msg = paymentMessage(id, refundTag(t.blockchain, t.symbol, address)); return { amount: amount.toString(), msg, refundTo: address, body: paymentQuoteBody({ dry, originAsset: t.assetId, amount: amount.toString(), refundTo: address, contract: NET.contract, msg, deadlineMs: Date.now() + 60 * 60 * 1000, stable: ['USDC', 'USDT'].includes(t.symbol) }), }; }; const getPrice = action(async () => { const dry = buildBody(true); const res = await postQuote(dry.body); await assertPaymentQuote(res, { dry: true, originAsset: token().assetId, contract: NET.contract, msg: dry.msg, amount: dry.amount, refundTo: dry.refundTo }); const t = token(); result.replaceChildren( h('div', { class: 'banner info' }, `You send about ${res.quote.amountInFormatted} ${t.symbol} (${res.quote.amountInUsd ? `$${Number(res.quote.amountInUsd).toFixed(2)}` : 'price varies'}). The seller receives exactly ${formatAmount(dry.amount)} USDC. Any extra comes back to you, less a small network fee.`), h('button', { class: 'primary wide', onClick: action(async () => { const real = buildBody(false); const q = await postQuote(real.body); await assertPaymentQuote(q, { dry: false, originAsset: token().assetId, contract: NET.contract, msg: real.msg, amount: real.amount, refundTo: real.refundTo }); card.replaceWith(addressPanel(id, inv, token(), q, onPaid)); }, 'Getting your address'), }, 'Get payment address'), ); }, 'Checking the price'); const card = h( 'section', { class: 'card stack no-print' }, h('h3', {}, 'Pay this invoice'), h('p', { class: 'muted small' }, 'Use what you already hold. You do not need an account.'), h('div', { class: 'grid' }, field('Pay with', symbolSel), field('On', chainSel)), field('Your address on that network', refund, 'Only used to send money back if the payment cannot complete.'), field('Amount to pay (USDC)', partAmount, 'You can pay part now and the rest later.'), h('button', { class: 'primary wide', onClick: getPrice }, 'See the price'), result, ); return card;}function testPayCard(id, inv, onPaid) { const amount = h('input', { inputmode: 'decimal', value: formatAmount(inv.due_now).replace(/,/g, '') }); return h( 'section', { class: 'card stack no-print' }, h('h3', {}, 'Pay this invoice'), h('div', { class: 'banner warn' }, 'Test mode: this sends worthless test USDC through the real contract on NEAR testnet. Try a smaller amount for a part payment, or a larger one for an overpayment.'), field('Amount (test USDC)', amount), h('button', { class: 'primary wide', onClick: action(async () => { const units = parseAmount(amount.value); await apiPost('/api/test/pay', { id, amount: units.toString(), refundTo: 'base:USDC:0x1111111111111111111111111111111111111111' }); toast('Payment received on-chain'); onPaid(); }, 'Paying on testnet'), }, 'Simulate the client paying'), );}function topBar() { return [isTest && h('div', { class: 'testbar' }, 'Test mode. No real money.'), h('header', { class: 'top no-print' }, h('a', { class: 'brand', href: `${base}/` }, 'Duly'))];}export async function payPage(id) { const keyText = location.hash.slice(1); const inv = await view('get_invoice', { id }).catch(() => null); if (!inv) { mount(h('div', {}, topBar(), h('main', { class: 'wrap narrow' }, h('div', { class: 'card' }, h('h2', {}, 'Invoice not found'), h('p', { class: 'muted' }, 'Check the link with whoever sent it.'))))); return; } const { doc, state } = await loadDoc(id, inv, keyText); const rerender = () => payPage(id); const verified = await contractVerified(); const open = BigInt(inv.due_now) > 0n && verified; const txs = BigInt(inv.paid) > 0n ? await receipts(id) : []; const stillOpenOffer = inv.discount_bps > 0 && Number(inv.discount_until_ms) > Date.now() && open; mount( h( 'div', {}, topBar(), h( 'main', { class: 'wrap narrow stack' }, !verified && h('div', { class: 'banner bad' }, 'Payment is switched off: the contract on-chain is not the one this site expects. Do not pay this invoice.'), state === 'tampered' && h('div', { class: 'banner bad' }, 'Warning: the details served for this invoice do not match what was issued on-chain. Do not pay it. Contact the sender.'), state === 'locked' && h('div', { class: 'banner info' }, 'This link has no key, so the private details stay hidden. The amount and status below come from the chain.'), state === 'missing' && h('div', { class: 'banner warn' }, 'The invoice details could not be opened. The amount and status below come from the chain.'), !isTest && id === SAMPLE.id && open && h('div', { class: 'banner info' }, `This is Duly's public sample invoice, for 0.10 USDC on NEAR mainnet. Pay it with ${SAMPLE.routes}; on Base and Ethereum 0.10 is below the route minimum. The first payment closes it on-chain; anything paid after that is recorded as extra and stays refundable.`), stillOpenOffer && h('div', { class: 'banner good' }, `Pay by ${fmtDateTime(inv.discount_until_ms)} and save ${inv.discount_bps / 100}%. You owe ${usd(inv.due_now)} if you pay by then.`), invoiceSheet(inv, doc), open && state !== 'tampered' && (isTest ? testPayCard(id, inv, rerender) : await payCard(id, inv, rerender)), inv.payments.length > 0 && receiptCard(inv, txs), proofCard(inv, state), ), ), );}