diff --git a/src/hooks.client.ts b/src/hooks.client.ts index 682a530..7d6013e 100644 --- a/src/hooks.client.ts +++ b/src/hooks.client.ts @@ -1,6 +1,7 @@ import * as Sentry from '@sentry/sveltekit'; import { handleErrorWithSentry } from '@sentry/sveltekit'; +import { CLAIM_PATH, redactClaimSecrets } from '#lib/claim.ts'; import { dev } from '$app/env'; Sentry.init({ @@ -16,10 +17,16 @@ Sentry.init({ replaysOnErrorSampleRate: 1.0, // If you don't want to use Session Replay, just remove the line below: - integrations: [Sentry.replayIntegration()], + // Replays record the page URL, which on the claim page holds the reward link's password. + // Claim links are always opened with a full page load, so no replay runs there. + integrations: window.location.pathname === CLAIM_PATH ? [] : [Sentry.replayIntegration()], + beforeBreadcrumb: (breadcrumb) => redactClaimSecrets(breadcrumb), environment: dev ? 'development' : 'production' }); +// Reward links carry their password in the URL fragment: keep it out of every event sent to Sentry. +Sentry.addEventProcessor((event) => redactClaimSecrets(event)); + // If you have a custom error handler, pass it to `handleErrorWithSentry` export const handleError = handleErrorWithSentry(); diff --git a/src/lib/claim.ts b/src/lib/claim.ts new file mode 100644 index 0000000..7a48d00 --- /dev/null +++ b/src/lib/claim.ts @@ -0,0 +1,15 @@ +/** Path of the page where rewards are claimed. Peanut's own claim page no longer opens SDK links. */ +export const CLAIM_PATH = '/claim'; + +// The password of a reward link lives in the URL fragment (`#p=…`). Whoever knows it can claim the +// funds, so it must never reach analytics or error reports. +const SECRET_RE = /([#&?]p=)[^&\s"'\\]+/g; + +export function redactClaimSecret(value: string): string { + return value.replace(SECRET_RE, '$1[redacted]'); +} + +/** Redacts claim secrets from every string of a JSON-serializable value, e.g. a Sentry event. */ +export function redactClaimSecrets(value: T): T { + return JSON.parse(redactClaimSecret(JSON.stringify(value))); +} diff --git a/src/lib/services/peanut.ts b/src/lib/services/peanut.ts index 98a43e7..f076a96 100644 --- a/src/lib/services/peanut.ts +++ b/src/lib/services/peanut.ts @@ -4,6 +4,7 @@ import type { ethers } from 'ethers'; import type { Balance } from '#lib/types.ts'; import { isNativeToken } from './balances.svelte'; +import { publicProvider } from './wallet.svelte'; peanut.toggleVerbose(import.meta.env.DEV); @@ -13,6 +14,8 @@ export async function createLinks(params: { amount: number; numberOfLinks: number; token: Balance; + /** URL of the claim page the links point to */ + baseUrl: string; }): Promise { // Values for tokenType are defined in SDK documentation: // https://docs.peanut.to/integrations/building-with-the-sdk/sdk-reference/common-types#epeanutlinktype @@ -24,7 +27,8 @@ export async function createLinks(params: { tokenAmount: params.amount, tokenType: tokenType, tokenAddress: tokenType == 1 ? params.token.address : undefined, - tokenDecimals: params.token.decimals + tokenDecimals: params.token.decimals, + baseUrl: params.baseUrl }; const passwords: string[] = []; @@ -58,3 +62,91 @@ export async function createLinks(params: { }); return links; } + +export interface LinkParams { + chainId: number; + contractVersion: string; + depositIdx: number; + password: string; +} + +/** + * Reads the deposit parameters of a reward link. Accepts links to our claim page as well as + * links generated for peanut.to (`?c=…&v=…&i=…#p=…` or the older `#?c=…&p=…` form). + */ +export function parseLink(link: string): LinkParams | undefined { + try { + const p = peanut.getParamsFromLink(link); + const chainId = Number(p.chainId); + if (!chainId || !p.contractVersion || !p.password || !Number.isInteger(p.depositIdx)) return; + return { + chainId, + contractVersion: p.contractVersion, + depositIdx: p.depositIdx, + password: p.password + }; + } catch { + return; + } +} + +/** Rewrites any reward link so it opens on our claim page. */ +export function toClaimUrl(link: string, baseUrl: string): string | undefined { + const p = parseLink(link); + if (!p) return; + return peanut.getLinkFromParams( + p.chainId.toString(), + p.contractVersion, + p.depositIdx, + p.password, + baseUrl + ); +} + +export interface LinkDetails { + chainId: number; + tokenSymbol: string; + tokenAmount: string; + tokenAddress: string; + senderAddress: string; + claimed: boolean; + depositDate: Date; +} + +/** Reads what a link holds directly from the chain, no wallet needed. */ +export async function getLinkDetails(link: string): Promise { + const params = parseLink(link); + if (!params) throw new Error('invalid reward link'); + const d = await peanut.getLinkDetails({ link, provider: publicProvider(params.chainId) }); + return { + chainId: params.chainId, + tokenSymbol: d.tokenSymbol, + tokenAmount: d.tokenAmount, + tokenAddress: d.tokenAddress, + senderAddress: d.senderAddress, + claimed: d.claimed, + depositDate: d.depositDate + }; +} + +/** + * Claims a link to `recipient`. The signer pays the gas and must be connected to the link's chain: + * Peanut's gasless relayer (api.peanut.to) has been shut down. + */ +export async function claimLink(params: { + signer: ethers.Signer; + link: string; + recipient: string; +}): Promise { + const linkParams = parseLink(params.link); + if (!linkParams) throw new Error('invalid reward link'); + + const unsignedTx = await peanut.prepareClaimTx({ + link: params.link, + recipientAddress: params.recipient, + provider: publicProvider(linkParams.chainId) + }); + const tx = await params.signer.sendTransaction(peanut.peanutToEthersV5Tx(unsignedTx)); + await tx.wait(); + return tx.hash; +} diff --git a/src/lib/services/reward.ts b/src/lib/services/reward.ts index 342f6e3..d60f605 100644 --- a/src/lib/services/reward.ts +++ b/src/lib/services/reward.ts @@ -1,5 +1,6 @@ import type { ethers } from 'ethers'; +import { CLAIM_PATH } from '#lib/claim.ts'; import type { Balance, Email } from '#lib/types.ts'; import { createLinks } from './peanut'; @@ -34,7 +35,8 @@ export async function createRewardLinks(params: { chainId: params.chainId, amount: params.rewardAmount, numberOfLinks: params.contributors.length, - token: params.selectedToken + token: params.selectedToken, + baseUrl: new URL(CLAIM_PATH, window.location.origin).href }); } diff --git a/src/lib/services/wallet.svelte.ts b/src/lib/services/wallet.svelte.ts index ce09853..ce6b605 100644 --- a/src/lib/services/wallet.svelte.ts +++ b/src/lib/services/wallet.svelte.ts @@ -29,6 +29,27 @@ export const networks: [AppKitNetwork, ...AppKitNetwork[]] = [ sepolia ]; +// Keyless public RPCs (CORS enabled), used to read a chain without a connected wallet. +// The Peanut SDK's own default RPCs rely on an Infura key that no longer works. +const rpcUrls: Record = { + [mainnet.id]: 'https://ethereum-rpc.publicnode.com', + [optimism.id]: 'https://optimism-rpc.publicnode.com', + [bsc.id]: 'https://bsc-rpc.publicnode.com', + [gnosis.id]: 'https://gnosis-rpc.publicnode.com', + [polygon.id]: 'https://polygon-bor-rpc.publicnode.com', + [base.id]: 'https://base-rpc.publicnode.com', + [arbitrum.id]: 'https://arbitrum-one-rpc.publicnode.com', + [avalanche.id]: 'https://avalanche-c-chain-rpc.publicnode.com/ext/bc/C/rpc', + [linea.id]: 'https://linea-rpc.publicnode.com', + [sepolia.id]: 'https://ethereum-sepolia-rpc.publicnode.com' +}; + +export function publicProvider(chainId: number) { + const url = rpcUrls[chainId]; + if (!url) throw new Error(`unsupported chain ${chainId}`); + return new ethers.providers.StaticJsonRpcProvider(url, chainId); +} + const projectId = 'f71066d156ed5402df3e3e516de81a96'; const metadata = { name: 'CommitKudos', diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index eefec0f..6cb1134 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -6,6 +6,7 @@ import { Toast } from '@skeletonlabs/skeleton-svelte'; import { inject } from '@vercel/analytics'; + import { redactClaimSecret } from '#lib/claim.ts'; import CurrencySwitch from '#lib/components/CurrencySwitch.svelte'; import LightSwitch from '#lib/components/LightSwitch.svelte'; import Web3Modal from '#lib/components/Web3Modal.svelte'; @@ -16,7 +17,11 @@ let { children } = $props(); - inject({ mode: dev ? 'development' : 'production' }); + inject({ + mode: dev ? 'development' : 'production', + // reward links carry their password in the URL fragment + beforeSend: (event) => ({ ...event, url: redactClaimSecret(event.url) }) + }); const nav = [ { href: '/', label: 'Reward' }, diff --git a/src/routes/api/mail/+server.ts b/src/routes/api/mail/+server.ts index 089fd71..8b21e18 100644 --- a/src/routes/api/mail/+server.ts +++ b/src/routes/api/mail/+server.ts @@ -1,5 +1,6 @@ import { json } from '@sveltejs/kit'; +import { CLAIM_PATH } from '#lib/claim.ts'; import { sendMail } from '#lib/services/mail.ts'; import type { Email } from '#lib/types.ts'; @@ -7,16 +8,17 @@ import type { RequestHandler } from './$types'; const EMAIL_RE = /^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/; -function isPeanutLink(link: string) { +/** Only links to this site's claim page are emailed. */ +function isClaimLink(link: string, origin: string) { try { const url = new URL(link); - return url.protocol === 'https:' && /(^|\.)peanut\.(to|me)$/.test(url.hostname); + return url.origin === origin && url.pathname === CLAIM_PATH && url.hash.startsWith('#p='); } catch { return false; } } -function validate(email: Partial): string | undefined { +function validate(email: Partial, origin: string): string | undefined { if (!email.name || !email.email || !email.repoName || !email.link) { return 'name, email, repoName and link are required'; } @@ -26,14 +28,14 @@ function validate(email: Partial): string | undefined { if (!/^[\w.-]+\/[\w.-]+$/.test(email.repoName)) { return 'invalid repository name'; } - if (!isPeanutLink(email.link)) { - return 'link must be a peanut link'; + if (!isClaimLink(email.link, origin)) { + return 'link must be a CommitKudos claim link'; } } -export const POST: RequestHandler = async ({ request }) => { +export const POST: RequestHandler = async ({ request, url }) => { const email: Partial = await request.json().catch(() => ({})); - const invalid = validate(email); + const invalid = validate(email, url.origin); if (invalid) { return json({ error: invalid }, { status: 400 }); } diff --git a/src/routes/claim/+page.svelte b/src/routes/claim/+page.svelte new file mode 100644 index 0000000..234a69e --- /dev/null +++ b/src/routes/claim/+page.svelte @@ -0,0 +1,215 @@ + + + + CommitKudos · Claim your reward + + +
+
+

Claim your reward

+

+ A maintainer thanked you for your open-source contributions. +

+
+ +
+ {#if !params} +
+ + + +

Links to peanut.to work too.

+
+ {:else if !network || loadError} +
+
+ {:else if !details} +
+
+
+
+
+ {:else} +
+
+
+

{details.tokenAmount} {details.tokenSymbol}

+

+ on {network.name}, sent by + {shortAddress(details.senderAddress)} + on {details.depositDate.toLocaleDateString()} +

+
+ +
+ {#if txHash} +

+

+ {#if txUrl} + + + {/if} + {:else if details.claimed} +

+

+ {:else} + +

+ Claiming is an on-chain transaction: you need a little {network.nativeCurrency.symbol} + on {network.name} to pay for gas. +

+ {/if} +
+ {/if} +
+