Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/hooks.client.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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();
15 changes: 15 additions & 0 deletions src/lib/claim.ts
Original file line number Diff line number Diff line change
@@ -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<T>(value: T): T {
return JSON.parse(redactClaimSecret(JSON.stringify(value)));
}
94 changes: 93 additions & 1 deletion src/lib/services/peanut.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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<string[]> {
// Values for tokenType are defined in SDK documentation:
// https://docs.peanut.to/integrations/building-with-the-sdk/sdk-reference/common-types#epeanutlinktype
Expand All @@ -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[] = [];
Expand Down Expand Up @@ -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<LinkDetails> {
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<string> {
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;
}
4 changes: 3 additions & 1 deletion src/lib/services/reward.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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
});
}

Expand Down
21 changes: 21 additions & 0 deletions src/lib/services/wallet.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, string> = {
[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',
Expand Down
7 changes: 6 additions & 1 deletion src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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' },
Expand Down
16 changes: 9 additions & 7 deletions src/routes/api/mail/+server.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
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';

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<Email>): string | undefined {
function validate(email: Partial<Email>, origin: string): string | undefined {
if (!email.name || !email.email || !email.repoName || !email.link) {
return 'name, email, repoName and link are required';
}
Expand All @@ -26,14 +28,14 @@ function validate(email: Partial<Email>): 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<Email> = await request.json().catch(() => ({}));
const invalid = validate(email);
const invalid = validate(email, url.origin);
if (invalid) {
return json({ error: invalid }, { status: 400 });
}
Expand Down
Loading
Loading