const DEFAULT_GIVETH_PROJECTS = [ { slug: 'Save-the-Children-Philippines', name: 'Save the Children Philippines', url: 'https://giveth.io/project/Save-the-Children-Philippines', network: 'Polygon', currency: 'POL' }, { slug: 'Save-the-Children-International', name: 'Save the Children International', url: 'https://giveth.io/project/Save-the-Children-International', network: 'Polygon', currency: 'POL' }, { slug: 'scholarship-programme-for-underprivileged-children', name: 'Scholarship Programme For Underprivileged Children', url: 'https://giveth.io/project/scholarship-programme-for-underprivileged-children', network: 'Polygon', currency: 'POL', recipientAddress: '0x37aB975aB79eAB2d6849e28339E33963143aae6E' } ]; const DEFAULT_GIVETH_SUBGRAPH_URL = 'https://api.thegraph.com/subgraphs/name/giveth/giveth-polygon'; const DEFAULT_POLYGON_BLOCKSCOUT_URL = 'https://polygon.blockscout.com/api/v2'; function normalizeSlug(value) { return String(value || '').trim(); } function normalizeTxHash(value) { const txHash = String(value || '').trim(); return /^0x[a-fA-F0-9]{64}$/.test(txHash) ? txHash.toLowerCase() : ''; } function normalizeAddress(value) { const address = String(value || '').trim(); return /^0x[a-fA-F0-9]{40}$/.test(address) ? address.toLowerCase() : ''; } function parseProjectsJson(value) { if (!value) return []; try { const parsed = JSON.parse(value); return Array.isArray(parsed) ? parsed : []; } catch (error) { return []; } } function normalizeProject(project) { const slug = normalizeSlug(project.slug); const url = project.url || `https://giveth.io/project/${encodeURIComponent(slug)}`; return { provider: 'giveth', id: slug, slug, name: project.name || slug.replace(/-/g, ' '), description: project.description || 'Official Giveth project. Open the project page, donate on Polygon, then paste the transaction hash for verification.', url, donateUrl: project.donateUrl || `${url}#donate`, logoUrl: project.logoUrl || project.imageUrl || '', network: project.network || 'Polygon', currency: project.currency || 'POL', recipientAddress: normalizeAddress(project.recipientAddress || project.recipient || project.walletAddress || '') }; } function getGivethProjects(env) { const envProjects = parseProjectsJson(env.GIVETH_PROJECTS_JSON); const projects = envProjects.length ? envProjects : DEFAULT_GIVETH_PROJECTS; return projects.map(normalizeProject).filter((project) => project.slug); } function getGivethProject(env, slug) { const normalized = normalizeSlug(slug); return getGivethProjects(env).find((project) => project.slug === normalized) || getGivethProjects(env).find((project) => project.slug.toLowerCase() === normalized.toLowerCase()) || null; } function findProjectBySlug(projects, slug) { const normalized = normalizeSlug(slug); if (!normalized) return null; return projects.find((project) => project.slug === normalized) || projects.find((project) => project.slug.toLowerCase() === normalized.toLowerCase()) || null; } function getMetaContent(html, property) { const escaped = property.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const pattern = new RegExp(`'); } async function withGivethPageMeta(project) { try { const response = await fetch(project.url, { headers: { accept: 'text/html' } }); if (!response.ok) return project; const html = await response.text(); const title = decodeHtmlEntities(getMetaContent(html, 'og:title')); const description = decodeHtmlEntities(getMetaContent(html, 'og:description')); const image = decodeHtmlEntities(getMetaContent(html, 'og:image')); return { ...project, name: title || project.name, description: description || project.description, logoUrl: image || project.logoUrl }; } catch (error) { return project; } } function getGivethSubgraphUrl(env) { return env.GIVETH_SUBGRAPH_URL || DEFAULT_GIVETH_SUBGRAPH_URL; } function getPolygonBlockscoutUrl(env) { return String(env.POLYGON_BLOCKSCOUT_URL || DEFAULT_POLYGON_BLOCKSCOUT_URL).replace(/\/$/, ''); } function getStatus(value) { return String(value || '').trim().toLowerCase(); } function isConfirmedStatus(value) { const status = getStatus(value); return !status || ['confirmed', 'success', 'succeeded', 'completed', 'done'].includes(status); } function getDonationProjectSlug(donation) { return normalizeSlug( donation.projectSlug || donation.project_slug || donation.project?.slug || donation.project?.projectSlug || donation.project?.project_slug || donation.project?.id || '' ); } function getDonationAmountUsd(donation) { const candidates = [ donation.amountUsd, donation.amountUSD, donation.usdValue, donation.valueUsd, donation.amountInUsd, donation.priceUsd, donation.amount_in_usd ]; for (const candidate of candidates) { const number = Number(candidate); if (Number.isFinite(number) && number > 0) return number; } return 0; } function weiToPol(weiValue) { try { const wei = BigInt(String(weiValue || '0')); const whole = wei / 1000000000000000000n; const fraction = wei % 1000000000000000000n; return Number(`${whole}.${fraction.toString().padStart(18, '0').slice(0, 8)}`); } catch (error) { return 0; } } function normalizeDonationRows(payload) { const data = payload?.data || payload || {}; const roots = [ data.donations, data.donationByTransactionHash, data.donation, data.payments, data.transactions ]; return roots.flatMap((root) => { if (!root) return []; return Array.isArray(root) ? root : [root]; }); } async function queryGivethSubgraph(env, query, variables) { const response = await fetch(getGivethSubgraphUrl(env), { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify({ query, variables }) }); const payload = await response.json().catch(() => ({})); if (!response.ok || payload.errors?.length) { const detail = payload.errors?.[0]?.message || `Giveth subgraph failed: ${response.status}`; throw new Error(detail); } return normalizeDonationRows(payload); } async function fetchGivethDonationRows(env, txHash) { const queries = [ `query DonationByTransactionHash($txHash: String!) { donations(where: { transactionHash: $txHash }) { id transactionHash status amountUsd amountUSD usdValue projectSlug project { slug title } } }`, `query DonationByTxHash($txHash: String!) { donations(where: { txHash: $txHash }) { id txHash status amountUsd amountUSD usdValue projectSlug project { slug title } } }`, `query DonationByHash($txHash: String!) { donations(where: { transactionId: $txHash }) { id transactionId status amountUsd amountUSD usdValue projectSlug project { slug title } } }` ]; const errors = []; for (const query of queries) { try { const rows = await queryGivethSubgraph(env, query, { txHash }); if (rows.length) return rows; } catch (error) { errors.push(error.message); } } return { rows: [], errors }; } async function verifyPolygonNativeTransfer(env, { txHash, project }) { if (!project.recipientAddress) { return { ok: false, error: 'project_recipient_not_configured', message: 'The transaction may be confirmed, but this project needs a configured recipient address before chain fallback can prove it.' }; } try { const response = await fetch(`${getPolygonBlockscoutUrl(env)}/transactions/${txHash}`, { headers: { accept: 'application/json' } }); const payload = await response.json().catch(() => ({})); if (!response.ok) { return { ok: false, error: 'polygon_receipt_unavailable', message: `Polygon receipt lookup failed: ${response.status}` }; } const status = String(payload.status || payload.result || '').toLowerCase(); const toAddress = normalizeAddress(payload.to?.hash || payload.to || ''); const valuePol = weiToPol(payload.value); const exchangeRate = Number(payload.historic_exchange_rate || payload.exchange_rate || 0); const amountUsd = Number.isFinite(exchangeRate) && exchangeRate > 0 ? Number((valuePol * exchangeRate).toFixed(2)) : 0; if (!['ok', 'success', 'succeeded'].includes(status)) { return { ok: false, error: 'polygon_tx_not_successful', message: 'The Polygon transaction is visible, but it is not marked successful yet.' }; } if (toAddress !== project.recipientAddress) { return { ok: false, error: 'polygon_recipient_mismatch', message: 'The Polygon transaction is confirmed, but it does not match the selected charity project recipient.', details: [`expected ${project.recipientAddress}`, `received ${toAddress || 'unknown'}`] }; } if (valuePol <= 0) { return { ok: false, error: 'polygon_empty_transfer', message: 'The Polygon transaction is confirmed, but no POL transfer value was found.' }; } return { ok: true, project, donation: { id: txHash, source: 'polygon-blockscout', status, amountPol: valuePol, amountUsd, projectSlug: project.slug, recipientAddress: project.recipientAddress }, amountPol: valuePol, amountUsd, txHash }; } catch (error) { return { ok: false, error: 'polygon_receipt_lookup_failed', message: error.message || 'Polygon receipt lookup failed.' }; } } async function verifyDonationRowsForProject(env, { hash, project }) { const result = await fetchGivethDonationRows(env, hash); const rows = Array.isArray(result) ? result : result.rows; const errors = Array.isArray(result) ? [] : result.errors; for (const donation of rows) { const slug = getDonationProjectSlug(donation); const amountUsd = getDonationAmountUsd(donation); const status = donation.status || donation.state || donation.confirmationStatus || ''; if (slug === project.slug && isConfirmedStatus(status)) { return { ok: true, project, donation, amountUsd, txHash: hash }; } } const chainFallback = await verifyPolygonNativeTransfer(env, { txHash: hash, project }); if (chainFallback.ok) return chainFallback; return { ok: false, error: 'giveth_donation_not_confirmed', message: chainFallback.message || 'No confirmed Giveth donation matched this transaction hash and project slug yet.', details: [ ...errors.slice(0, 3), chainFallback.error ].filter(Boolean) }; } async function verifyDonationRowsAgainstConfiguredProjects(env, hash) { const projects = getGivethProjects(env); const result = await fetchGivethDonationRows(env, hash); const rows = Array.isArray(result) ? result : result.rows; const errors = Array.isArray(result) ? [] : result.errors; const unmatchedSlugs = []; for (const donation of rows) { const slug = getDonationProjectSlug(donation); const project = findProjectBySlug(projects, slug); const amountUsd = getDonationAmountUsd(donation); const status = donation.status || donation.state || donation.confirmationStatus || ''; if (slug && !project) unmatchedSlugs.push(slug); if (project && isConfirmedStatus(status)) { return { ok: true, inferred: true, project, donation, amountUsd, txHash: hash }; } } for (const project of projects) { if (!project.recipientAddress) continue; const chainFallback = await verifyPolygonNativeTransfer(env, { txHash: hash, project }); if (chainFallback.ok) { return { ...chainFallback, inferred: true }; } } return { ok: false, error: 'giveth_project_not_inferred', message: unmatchedSlugs.length ? 'This donation appears to target a Giveth project that is not in the SoulWall charity card list.' : 'SoulWall could not match this transaction hash to one of the configured charity cards yet.', details: [ ...errors.slice(0, 3), ...Array.from(new Set(unmatchedSlugs)).slice(0, 3).map((slug) => `unconfigured project ${slug}`) ].filter(Boolean) }; } async function verifyGivethDonation(env, { txHash, projectSlug }) { const hash = normalizeTxHash(txHash); if (!hash) return { ok: false, error: 'invalid_tx_hash' }; const slug = normalizeSlug(projectSlug); if (!slug) return verifyDonationRowsAgainstConfiguredProjects(env, hash); const project = getGivethProject(env, slug); if (!project) return { ok: false, error: 'unknown_giveth_project' }; return verifyDonationRowsForProject(env, { hash, project }); } export { getGivethProject, getGivethProjects, normalizeTxHash, verifyGivethDonation, withGivethPageMeta };