From 5121bf07c7e3dd92447e845bca3de9ba5e26931f Mon Sep 17 00:00:00 2001 From: Christophe de Carvalho Date: Sun, 4 Oct 2026 16:52:45 +0100 Subject: [PATCH] feat: claim rewards on a CommitKudos page instead of peanut.to Peanut moved to peanut.me and its claim page no longer opens links made with the SDK: peanut.to redirects to the homepage, dropping the link, and peanut.me only knows links registered through its own backend. The gasless relayer (api.peanut.to) is suspended and the SDK's default RPCs rely on a dead Infura key. The deposits themselves are still claimable on-chain, so reward links now point to a /claim page that reads the link through public RPCs and claims it with the recipient's wallet. Old peanut.to links can be pasted there. The link password lives in the URL fragment, so it is redacted from Sentry events, breadcrumbs and Vercel Analytics, and Session Replay is not started on the claim page. Co-Authored-By: Claude Opus 5.5 --- src/hooks.client.ts | 9 +- src/lib/claim.ts | 15 +++ src/lib/services/peanut.ts | 94 ++++++++++++- src/lib/services/reward.ts | 4 +- src/lib/services/wallet.svelte.ts | 21 +++ src/routes/+layout.svelte | 7 +- src/routes/api/mail/+server.ts | 16 ++- src/routes/claim/+page.svelte | 215 ++++++++++++++++++++++++++++++ 8 files changed, 370 insertions(+), 11 deletions(-) create mode 100644 src/lib/claim.ts create mode 100644 src/routes/claim/+page.svelte 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} +
+