Feat: Web3 Rainbowkit Integration (#4019)

* add new rainbow app and metadata

* add rainbowkit components

* add rainbow to event-type form

* create wallet connection ui

* verify signature when event is booked

* extract rainbow logic to app-store

* fix issues, dynamic import, theming

* skeleton, better api logic

* add gate logic to /[user]/book

* Fixes package.json

* Update yarn.lock

* Type fixes

Co-authored-by: zomars <zomars@me.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
hexcowboy 2022-09-05 14:10:58 -07:00 committed by GitHub
parent faf62ac8e7
commit 18d697436c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 2305 additions and 279 deletions

View File

@ -87,7 +87,6 @@ VITAL_REGION="us"
# Used for the Zapier integration
# @see https://github.com/calcom/cal.com/blob/main/packages/app-store/zapier/README.md
ZAPIER_INVITE_LINK=""
# *********************************************************************************************************
# - LARK
# Needed to enable Lark Calendar integration and Login with Lark
@ -95,4 +94,10 @@ ZAPIER_INVITE_LINK=""
LARK_OPEN_APP_ID=""
LARK_OPEN_APP_SECRET=""
LARK_OPEN_VERIFICATION_TOKEN=""
# - WEB3
# Used for the Web3 plugin
# @see https://github.com/calcom/cal.com/blob/main/packages/app-store/web3/README.md
ALCHEMY_API_KEY=""
INFURA_API_KEY=""
# *********************************************************************************************************

View File

@ -0,0 +1,49 @@
import { Dispatch, useState, useEffect } from "react";
import { JSONObject } from "superjson/dist/types";
import RainbowGate from "@calcom/app-store/rainbow/components/RainbowKit";
export type Gate = undefined | "rainbow"; // Add more like ` | "geolocation" | "payment"`
export type GateState = {
rainbowToken?: string;
};
type GateProps = {
children: React.ReactNode;
gates: Gate[];
metadata: JSONObject;
dispatch: Dispatch<Partial<GateState>>;
};
// To add a new Gate just add the gate logic to the switch statement
const Gates: React.FC<GateProps> = ({ children, gates, metadata, dispatch }) => {
const [rainbowToken, setRainbowToken] = useState<string>();
useEffect(() => {
dispatch({ rainbowToken });
}, [rainbowToken, dispatch]);
let gateWrappers = <>{children}</>;
// Recursively wraps the `gateWrappers` with new gates allowing for multiple gates
for (const gate of gates) {
switch (gate) {
case "rainbow":
if (metadata.blockchainId && metadata.smartContractAddress && !rainbowToken) {
gateWrappers = (
<RainbowGate
setToken={setRainbowToken}
chainId={metadata.blockchainId as number}
tokenAddress={metadata.smartContractAddress as string}>
{gateWrappers}
</RainbowGate>
);
}
}
}
return gateWrappers;
};
export default Gates;

View File

@ -20,6 +20,7 @@ type AvailableTimesProps = {
seatsPerTimeSlot?: number | null;
slots?: Slot[];
isLoading: boolean;
ethSignature?: string;
};
const AvailableTimes: FC<AvailableTimesProps> = ({
@ -31,6 +32,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
recurringCount,
timeFormat,
seatsPerTimeSlot,
ethSignature,
}) => {
const { t, i18n } = useLocale();
const router = useRouter();
@ -69,6 +71,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
slug: eventTypeSlug,
/** Treat as recurring only when a count exist and it's not a rescheduling workflow */
count: recurringCount && !rescheduleUid ? recurringCount : undefined,
ethSignature,
},
};

View File

@ -4,7 +4,8 @@ import { SchedulingType } from "@prisma/client";
import * as Collapsible from "@radix-ui/react-collapsible";
import { TFunction } from "next-i18next";
import { useRouter } from "next/router";
import { useEffect, useMemo, useState } from "react";
import { useReducer, useEffect, useMemo, useState } from "react";
import { Toaster } from "react-hot-toast";
import { FormattedNumber, IntlProvider } from "react-intl";
import { z } from "zod";
@ -34,6 +35,7 @@ import { timeZone as localStorageTimeZone } from "@lib/clock";
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
import { isBrandingHidden } from "@lib/isBrandingHidden";
import Gates, { Gate, GateState } from "@components/Gates";
import AvailableTimes from "@components/booking/AvailableTimes";
import TimeOptions from "@components/booking/TimeOptions";
import { UserAvatars } from "@components/booking/UserAvatars";
@ -46,8 +48,6 @@ import type { DynamicAvailabilityPageProps } from "../../../pages/d/[link]/[slug
import type { AvailabilityTeamPageProps } from "../../../pages/team/[slug]/[type]";
import { AvailableEventLocations } from "../AvailableEventLocations";
export type Props = AvailabilityTeamPageProps | AvailabilityPageProps | DynamicAvailabilityPageProps;
const GoBackToPreviousPage = ({ t }: { t: TFunction }) => {
const router = useRouter();
const path = router.asPath.split("/");
@ -113,6 +113,7 @@ const SlotPicker = ({
users,
seatsPerTimeSlot,
weekStart = 0,
ethSignature,
}: {
eventType: Pick<EventType, "id" | "schedulingType" | "slug">;
timeFormat: string;
@ -121,6 +122,7 @@ const SlotPicker = ({
recurringEventCount?: number;
users: string[];
weekStart?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
ethSignature?: string;
}) => {
const [selectedDate, setSelectedDate] = useState<Dayjs>();
const [browsingDate, setBrowsingDate] = useState<Dayjs>();
@ -202,6 +204,7 @@ const SlotPicker = ({
eventTypeSlug={eventType.slug}
seatsPerTimeSlot={seatsPerTimeSlot}
recurringCount={recurringEventCount}
ethSignature={ethSignature}
/>
)}
</>
@ -284,6 +287,8 @@ const useRouterQuery = <T extends string>(name: T) => {
} & { setQuery: typeof setQuery };
};
export type Props = AvailabilityTeamPageProps | AvailabilityPageProps | DynamicAvailabilityPageProps;
const AvailabilityPage = ({ profile, eventType }: Props) => {
const router = useRouter();
const isEmbed = useIsEmbed();
@ -299,6 +304,13 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
const [timeZone, setTimeZone] = useState<string>();
const [timeFormat, setTimeFormat] = useState(detectBrowserTimeFormat);
const [isAvailableTimesVisible, setIsAvailableTimesVisible] = useState<boolean>();
const [gateState, gateDispatcher] = useReducer(
(state: GateState, newState: Partial<GateState>) => ({
...state,
...newState,
}),
{}
);
useEffect(() => {
setTimeZone(localStorageTimeZone() || dayjs.tz.guess());
@ -325,7 +337,7 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
}, [telemetry]);
// get dynamic user list here
const userList = eventType.users.map((user) => user.username).filter(notEmpty);
const userList = eventType.users ? eventType.users.map((user) => user.username).filter(notEmpty) : [];
// Recurring event sidebar requires more space
const maxWidth = isAvailableTimesVisible
? recurringEventCount
@ -349,8 +361,16 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
if (rawSlug.length > 1) rawSlug.pop(); //team events have team name as slug, but user events have [user]/[type] as slug.
const slug = rawSlug.join("/");
// Define conditional gates here
const gates = [
// Rainbow gate is only added if the event has both a `blockchainId` and a `smartContractAddress`
eventType.metadata && eventType.metadata.blockchainId && eventType.metadata.smartContractAddress
? ("rainbow" as Gate)
: undefined,
];
return (
<>
<Gates gates={gates} metadata={eventType.metadata} dispatch={gateDispatcher}>
<HeadSeo
title={`${rescheduleUid ? t("reschedule") : ""} ${eventType.title} | ${profile.name}`}
description={`${rescheduleUid ? t("reschedule") : ""} ${eventType.title}`}
@ -587,13 +607,15 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
users={userList}
seatsPerTimeSlot={eventType.seatsPerTimeSlot || undefined}
recurringEventCount={recurringEventCount}
ethSignature={gateState.rainbowToken}
/>
</div>
</div>
{(!eventType.users[0] || !isBrandingHidden(eventType.users[0])) && !isEmbed && <PoweredByCal />}
</main>
</div>
</>
<Toaster position="bottom-right" />
</Gates>
);
};

View File

@ -5,7 +5,7 @@ import { isValidPhoneNumber } from "libphonenumber-js";
import { useSession } from "next-auth/react";
import Head from "next/head";
import { useRouter } from "next/router";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState, useReducer } from "react";
import { Controller, useForm, useWatch } from "react-hook-form";
import { FormattedNumber, IntlProvider } from "react-intl";
import { ReactMultiEmail } from "react-multi-email";
@ -48,6 +48,7 @@ import createRecurringBooking from "@lib/mutations/bookings/create-recurring-boo
import { parseDate, parseRecurringDates } from "@lib/parseDate";
import slugify from "@lib/slugify";
import Gates, { Gate, GateState } from "@components/Gates";
import { UserAvatars } from "@components/booking/UserAvatars";
import EventTypeDescriptionSafeHTML from "@components/eventtype/EventTypeDescriptionSafeHTML";
@ -55,15 +56,6 @@ import { BookPageProps } from "../../../pages/[user]/book";
import { HashLinkPageProps } from "../../../pages/d/[link]/book";
import { TeamBookingPageProps } from "../../../pages/team/[slug]/book";
declare global {
// eslint-disable-next-line no-var
var web3: {
currentProvider: {
selectedAddress: string;
};
};
}
type BookingPageProps = BookPageProps | TeamBookingPageProps | HashLinkPageProps;
type BookingFormValues = {
@ -98,6 +90,13 @@ const BookingPage = ({
const { data: session } = useSession();
const isBackgroundTransparent = useIsBackgroundTransparent();
const telemetry = useTelemetry();
const [gateState, gateDispatcher] = useReducer(
(state: GateState, newState: Partial<GateState>) => ({
...state,
...newState,
}),
{}
);
useEffect(() => {
if (top !== window) {
@ -359,6 +358,7 @@ const BookingPage = ({
hashedLink,
smsReminderNumber:
selectedLocationType === LocationType.Phone ? booking.phone : booking.smsReminderNumber,
ethSignature: gateState.rainbowToken,
}));
recurringMutation.mutate(recurringBookings);
} else {
@ -386,6 +386,7 @@ const BookingPage = ({
hashedLink,
smsReminderNumber:
selectedLocationType === LocationType.Phone ? booking.phone : booking.smsReminderNumber,
ethSignature: gateState.rainbowToken,
});
}
};
@ -412,8 +413,16 @@ const BookingPage = ({
});
}
// Define conditional gates here
const gates = [
// Rainbow gate is only added if the event has both a `blockchainId` and a `smartContractAddress`
eventType.metadata && eventType.metadata.blockchainId && eventType.metadata.smartContractAddress
? ("rainbow" as Gate)
: undefined,
];
return (
<div>
<Gates gates={gates} metadata={eventType.metadata} dispatch={gateDispatcher}>
<Head>
<title>
{rescheduleUid
@ -873,7 +882,7 @@ const BookingPage = ({
</div>
</div>
</main>
</div>
</Gates>
);
};

View File

@ -16,6 +16,10 @@ type RecurringEventControllerProps = {
onRecurringEventDefined: (value: boolean) => void;
};
/**
* @deprecated
* use component from `/apps/web/components/v2/eventtype/RecurringEventController` instead
**/
export default function RecurringEventController({
recurringEvent,
formMethods,

View File

@ -2,13 +2,12 @@ import { EventTypeSetupInfered, FormValues } from "pages/v2/event-types/[type]";
import { useState } from "react";
import { useFormContext } from "react-hook-form";
import RecurringEventController from "@components/v2/eventtype/RecurringEventController";
import RecurringEventController from "./RecurringEventController";
export const EventRecurringTab = ({
eventType,
hasPaymentIntegration,
}: Pick<EventTypeSetupInfered, "eventType" | "hasPaymentIntegration">) => {
const formMethods = useFormContext<FormValues>();
const requirePayment = eventType.price > 0;
const [recurringEventDefined, setRecurringEventDefined] = useState(
eventType.recurringEvent?.count !== undefined
@ -20,7 +19,6 @@ export const EventRecurringTab = ({
paymentEnabled={hasPaymentIntegration && requirePayment}
onRecurringEventDefined={setRecurringEventDefined}
recurringEvent={eventType.recurringEvent}
formMethods={formMethods}
/>
</div>
);

View File

@ -1,28 +1,27 @@
import type { FormValues } from "pages/event-types/[type]";
import { useState } from "react";
import { UseFormReturn } from "react-hook-form";
import { useFormContext } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Frequency } from "@calcom/prisma/zod-utils";
import type { RecurringEvent } from "@calcom/types/Calendar";
import { Alert } from "@calcom/ui/Alert";
import { Label, Switch, Select } from "@calcom/ui/v2";
import { Label, Select, Switch } from "@calcom/ui/v2";
type RecurringEventControllerProps = {
recurringEvent: RecurringEvent | null;
formMethods: UseFormReturn<FormValues>;
paymentEnabled: boolean;
onRecurringEventDefined: (value: boolean) => void;
};
export default function RecurringEventController({
recurringEvent,
formMethods,
paymentEnabled,
onRecurringEventDefined,
}: RecurringEventControllerProps) {
const { t } = useLocale();
const [recurringEventState, setRecurringEventState] = useState<RecurringEvent | null>(recurringEvent);
const formMethods = useFormContext<FormValues>();
/* Just yearly-0, monthly-1 and weekly-2 */
const recurringEventFreqOptions = Object.entries(Frequency)

View File

@ -27,6 +27,7 @@ export type BookingCreateBody = {
hasHashedBookingLink: boolean;
hashedLink?: string | null;
smsReminderNumber?: string;
ethSignature?: string;
};
export type BookingResponse = Booking & {

View File

@ -6,6 +6,7 @@ import short from "short-uuid";
import { v5 as uuidv5 } from "uuid";
import { getLocationValueForDB, LocationObject } from "@calcom/app-store/locations";
import { handleEthSignature } from "@calcom/app-store/rainbow/utils/ethereum";
import { handlePayment } from "@calcom/app-store/stripepayment/lib/server";
import { cancelScheduledJobs, scheduleTrigger } from "@calcom/app-store/zapier/lib/nodeScheduler";
import EventManager from "@calcom/core/EventManager";
@ -350,6 +351,10 @@ async function handler(req: NextApiRequest) {
console.log("available users", users);
// @TODO: use the returned address somewhere in booking creation?
// const address: string | undefined = await ...
await handleEthSignature(eventType.metadata, reqBody.ethSignature);
const [organizerUser] = users;
const tOrganizer = await getTranslation(organizerUser.locale ?? "en", "common");

View File

@ -154,6 +154,7 @@ export default function IntegrationsPage() {
<CalendarListContainer />
<IntegrationsContainer variant="payment" className="mt-8" />
<IntegrationsContainer variant="other" className="mt-8" />
<IntegrationsContainer variant="web3" className="mt-8" />
</>
) : (
<EmptyScreen

View File

@ -5,6 +5,7 @@ import * as RadioGroup from "@radix-ui/react-radio-group";
import classNames from "classnames";
import { isValidPhoneNumber } from "libphonenumber-js";
import { GetServerSidePropsContext } from "next";
import dynamic from "next/dynamic";
import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
import { Controller, Noop, useForm, UseFormReturn } from "react-hook-form";
@ -67,6 +68,10 @@ import WebhookListContainer from "@components/webhook/WebhookListContainer";
import { getTranslation } from "@server/lib/i18n";
import { TRPCClientError } from "@trpc/client";
const RainbowInstallForm = dynamic(() => import("@calcom/rainbow/components/RainbowInstallForm"), {
suspense: true,
});
type OptionTypeBase = {
label: string;
value: EventLocationType["type"];
@ -115,6 +120,8 @@ export type FormValues = {
};
successRedirectUrl: string;
giphyThankYouPage: string;
blockchainId: number;
smartContractAddress: string;
};
const SuccessRedirectEdit = <T extends UseFormReturn<FormValues>>({
@ -236,6 +243,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
hasPaymentIntegration,
currency,
hasGiphyIntegration,
hasRainbowIntegration,
} = props;
const router = useRouter();
@ -558,6 +566,8 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
const {
periodDates,
periodCountCalendarDays,
smartContractAddress,
blockchainId,
giphyThankYouPage,
beforeBufferTime,
afterBufferTime,
@ -579,6 +589,8 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
afterEventBuffer: afterBufferTime,
seatsPerTimeSlot: Number.isNaN(seatsPerTimeSlot) ? null : seatsPerTimeSlot,
metadata: {
...(smartContractAddress ? { smartContractAddress } : {}),
...(blockchainId ? { blockchainId } : { blockchainId: 1 }),
...(giphyThankYouPage ? { giphyThankYouPage } : {}),
},
});
@ -1056,6 +1068,14 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
)}
/>
{hasRainbowIntegration && (
<RainbowInstallForm
formMethods={formMethods}
blockchainId={(eventType.metadata.blockchainId as number) || 1}
smartContractAddress={(eventType.metadata.smartContractAddress as string) || ""}
/>
)}
<hr className="my-2 border-neutral-200" />
<Controller
name="minimumBookingNotice"
@ -1884,6 +1904,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
};
const hasGiphyIntegration = !!credentials.find((credential) => credential.type === "giphy_other");
const hasRainbowIntegration = !!credentials.find((credential) => credential.type === "rainbow_web3");
// backwards compat
if (eventType.users.length === 0 && !eventType.team) {
@ -1947,6 +1968,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
teamMembers,
hasPaymentIntegration,
hasGiphyIntegration,
hasRainbowIntegration,
currency,
currentUserMembership,
},

View File

@ -1093,6 +1093,14 @@
"customize_your_brand_colors": "Customize your own brand colour into your booking page.",
"pro": "Pro",
"removes_cal_branding": "Removes any Cal related brandings, i.e. 'Powered by Cal.'",
"web3": "Web3",
"rainbow_token_gated": "This event type is token gated.",
"rainbow_connect_wallet_gate": "Connect your wallet if you own <1>{{name}}</1> (<3>{{symbol}}</3>).",
"rainbow_insufficient_balance": "Your connected wallet doesn't contain enough <1>{{symbol}}</1>.",
"rainbow_sign_message_request": "Sign the message request on your wallet.",
"rainbow_signature_error": "Error requesting signature from your wallet.",
"token_address": "Token Address",
"blockchain": "Blockchain",
"old_password": "Old password",
"secure_password": "Your new super secure password",
"error_updating_password": "Error updating password",

View File

@ -23,6 +23,7 @@ import { metadata as larkcalendar_meta } from "./larkcalendar/_metadata";
import { metadata as office365calendar_meta } from "./office365calendar/_metadata";
import { metadata as office365video_meta } from "./office365video/_metadata";
import { metadata as ping_meta } from "./ping/_metadata";
import { metadata as rainbow_meta } from "./rainbow/_metadata";
import { metadata as riverside_meta } from "./riverside/_metadata";
import { metadata as slackmessaging_meta } from "./slackmessaging/_metadata";
import { metadata as stripepayment_meta } from "./stripepayment/_metadata";
@ -54,6 +55,7 @@ export const appStoreMetadata = {
office365calendar: office365calendar_meta,
office365video: office365video_meta,
ping: ping_meta,
rainbow: rainbow_meta,
riverside: riverside_meta,
slackmessaging: slackmessaging_meta,
stripepayment: stripepayment_meta,

View File

@ -20,6 +20,7 @@ export const apiHandlers = {
office365calendar: import("./office365calendar/api"),
office365video: import("./office365video/api"),
ping: import("./ping/api"),
rainbow: import("./rainbow/api"),
riverside: import("./riverside/api"),
slackmessaging: import("./slackmessaging/api"),
stripepayment: import("./stripepayment/api"),

View File

@ -18,8 +18,8 @@
"@calcom/lib": "*",
"@calcom/office365video": "*",
"@calcom/trpc": "*",
"@calcom/zoomvideo": "*",
"@calcom/ui": "*",
"@calcom/zoomvideo": "*",
"lodash": "^4.17.21"
},
"devDependencies": {

View File

@ -0,0 +1,12 @@
# Rainbow App
> Intended for developer information
The web3 app uses [RainbowKit](https://www.rainbowkit.com/) to authenticate Ethereum users.
When deploying, the app requires either a `ALCHEMY_API_KEY` or `INFURA_API_KEY` (or both) which can be obtained by creating an Alchemy or Infura project respectively.
<img width="901" alt="Find your Alchemy API key" src="https://user-images.githubusercontent.com/8162609/187499278-e5f03c3f-b4a5-430a-9121-f30207802d4c.png">
<img width="932" alt="Find your Infura API key" src="https://user-images.githubusercontent.com/8162609/187499759-425b4fc4-621b-4753-b77f-257b5055408a.png">
Available blockchains are Ethereum mainnet, Arbitrum, Optimism, and Polygon mainnet.

View File

@ -0,0 +1,16 @@
---
description: Token gate bookings based on NFTs, DAO tokens, and ERC-20 tokens.
---
{/* Feel free to edit description or add other frontmatter. Frontmatter would be available in the components here as variables by same name */}
<div>
{description}
</div>
<hr />
<div>
<p>Token gate your bookings. Rainbow supports dozens of trusted Ethereum wallet apps to verify token ownership.</p>
<strong>Available blockchains are Ethereum mainnet, Arbitrum, Optimism, and Polygon mainnet.</strong>
</div>

View File

@ -0,0 +1,10 @@
import type { AppMeta } from "@calcom/types/App";
import config from "./config.json";
export const metadata = {
category: "other",
...config,
} as AppMeta;
export default metadata;

View File

@ -0,0 +1,17 @@
import { AppDeclarativeHandler } from "@calcom/types/AppHandler";
import { createDefaultInstallation } from "../../_utils/installation";
import appConfig from "../config.json";
const handler: AppDeclarativeHandler = {
// Instead of passing appType and slug from here, api/integrations/[..args] should be able to derive and pass these directly to createCredential
appType: appConfig.type,
slug: appConfig.slug,
supportsMultipleInstalls: false,
handlerType: "add",
redirectUrl: "/apps/installed",
createCredential: ({ appType, user, slug }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {} }),
};
export default handler;

View File

@ -0,0 +1 @@
export { default as add } from "./add";

View File

@ -0,0 +1,69 @@
import type { UseFormReturn } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { SUPPORTED_CHAINS_FOR_FORM } from "@calcom/rainbow/utils/ethereum";
import type { FormValues } from "@calcom/web/pages/event-types/[type]";
import Select from "@components/ui/form/Select";
type RainbowInstallFormProps = {
formMethods: UseFormReturn<FormValues>;
blockchainId: number;
smartContractAddress: string;
};
const RainbowInstallForm: React.FC<RainbowInstallFormProps> = ({
formMethods,
blockchainId,
smartContractAddress,
}) => {
const { t } = useLocale();
return (
<>
<hr className="my-2 border-neutral-200" />
<div className="block items-center sm:flex">
<div className="min-w-48 mb-4 sm:mb-0">
<label htmlFor="blockchainId" className="flex text-sm font-medium text-neutral-700">
{t("Blockchain")}
</label>
</div>
<Select
isSearchable={false}
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
onChange={(e) => {
formMethods.setValue("blockchainId", (e && e.value) || 1);
}}
defaultValue={
SUPPORTED_CHAINS_FOR_FORM.find((e) => e.value === blockchainId) || {
value: 1,
label: "Ethereum",
}
}
options={SUPPORTED_CHAINS_FOR_FORM || [{ value: 1, label: "Ethereum" }]}
/>
</div>
<div className="block items-center sm:flex">
<div className="min-w-48 mb-4 sm:mb-0">
<label htmlFor="smartContractAddress" className="flex text-sm font-medium text-neutral-700">
{t("token_address")}
</label>
</div>
<div className="w-full">
<div className="relative mt-1 rounded-sm">
<input
type="text"
className="block w-full rounded-sm border-gray-300 text-sm "
placeholder={t("Example: 0x71c7656ec7ab88b098defb751b7401b5f6d8976f")}
defaultValue={(smartContractAddress || "") as string}
{...formMethods.register("smartContractAddress")}
/>
</div>
</div>
</div>
</>
);
};
export default RainbowInstallForm;

View File

@ -0,0 +1,177 @@
import {
ConnectButton,
getDefaultWallets,
RainbowKitProvider,
darkTheme,
lightTheme,
} from "@rainbow-me/rainbowkit";
import "@rainbow-me/rainbowkit/styles.css";
import { useTheme } from "next-themes";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { Trans } from "react-i18next";
import { configureChains, createClient, WagmiConfig } from "wagmi";
import { useAccount, useSignMessage } from "wagmi";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import { trpc } from "@calcom/trpc/react";
import { SkeletonText } from "@calcom/ui";
import { Icon } from "@calcom/ui/Icon";
import { getProviders, ETH_MESSAGE, SUPPORTED_CHAINS } from "../utils/ethereum";
const { chains, provider } = configureChains(SUPPORTED_CHAINS, getProviders());
const { connectors } = getDefaultWallets({
appName: "Cal.com",
chains,
});
const wagmiClient = createClient({
autoConnect: true,
connectors,
provider,
});
type RainbowGateProps = {
children: React.ReactNode;
setToken: (_: string) => void;
chainId: number;
tokenAddress: string;
};
const RainbowGate: React.FC<RainbowGateProps> = (props) => {
const { resolvedTheme: theme } = useTheme();
const [rainbowTheme, setRainbowTheme] = useState(theme === "dark" ? darkTheme() : lightTheme());
useEffect(() => {
theme === "dark" ? setRainbowTheme(darkTheme()) : setRainbowTheme(lightTheme());
}, [theme]);
return (
<WagmiConfig client={wagmiClient}>
<RainbowKitProvider chains={chains.filter((chain) => chain.id === props.chainId)} theme={rainbowTheme}>
<BalanceCheck {...props} />
</RainbowKitProvider>
</WagmiConfig>
);
};
// The word "token" is used for two differenct concepts here: `setToken` is the token for
// the Gate while `useToken` is a hook used to retrieve the Ethereum token.
const BalanceCheck: React.FC<RainbowGateProps> = ({ chainId, setToken, tokenAddress }) => {
const { t } = useLocale();
const { address } = useAccount();
const {
data: signedMessage,
isLoading: isSignatureLoading,
isError: isSignatureError,
signMessage,
} = useSignMessage({
message: ETH_MESSAGE,
});
const { data: contractData, isLoading: isContractLoading } = trpc.useQuery([
"viewer.eth.contract",
{ address: tokenAddress, chainId },
]);
const { data: balanceData, isLoading: isBalanceLoading } = trpc.useQuery(
["viewer.eth.balance", { address: address || "", tokenAddress, chainId }],
{
enabled: !!address,
}
);
// The token may have already been set in the query params, so we can extract it here
const router = useRouter();
const { ethSignature, ...routerQuery } = router.query;
const isLoading = isContractLoading || isBalanceLoading;
// Any logic here will unlock the gate by setting the token to the user's wallet signature
useEffect(() => {
// If the `ethSignature` is found, remove it from the URL bar and propogate back up
if (ethSignature !== undefined) {
// Remove the `ethSignature` param but keep all others
router.replace({ query: { ...routerQuery } });
setToken(ethSignature as string);
}
if (balanceData && balanceData.data) {
if (balanceData.data.hasBalance) {
if (signedMessage) {
showToast("Wallet verified.", "success");
setToken(signedMessage);
} else if (router.isReady && !ethSignature) {
signMessage();
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router.isReady, balanceData, setToken, signedMessage, signMessage]);
return (
<main className="mx-auto max-w-3xl py-24 px-4">
<div className="rounded-md border border-neutral-200 dark:border-neutral-700 dark:hover:border-neutral-600">
<div className="hover:border-brand dark:bg-darkgray-100 flex min-h-[120px] grow border-b border-neutral-200 bg-white p-4 text-center first:rounded-t-md last:rounded-b-md last:border-b-0 hover:bg-white dark:border-neutral-700 dark:hover:border-neutral-600 md:flex-row md:text-left ">
<span className="mb-4 grow md:mb-0">
<h2 className="mb-2 grow font-semibold text-neutral-900 dark:text-white">Token Gate</h2>
{isLoading && (
<>
<SkeletonText width="[100%]" height="5" className="mb-3" />
<SkeletonText width="[100%]" height="5" />
</>
)}
{!isLoading && contractData && contractData.data && (
<>
<p className="text-neutral-300 dark:text-white">
<Trans i18nKey="rainbow_connect_wallet_gate" t={t}>
Connect your wallet if you own {contractData.data.name} ({contractData.data.symbol}) .
</Trans>
</p>
{balanceData && balanceData.data && (
<>
{!balanceData.data.hasBalance && (
<div className="mt-2 flex flex-row items-center">
<Icon.FiAlertTriangle className="h-5 w-5 text-red-600" />
<p className="ml-2 text-red-600">
<Trans i18nKey="rainbow_insufficient_balance" t={t}>
Your connected wallet doesn&apos;t contain enough {contractData.data.symbol}.
</Trans>
</p>
</div>
)}
{balanceData.data.hasBalance && isSignatureLoading && (
<div className="mt-2 flex flex-row items-center">
<Icon.FiLoader className="h-5 w-5 text-green-600" />
<p className="ml-2 text-green-600">{t("rainbow_sign_message_request")}</p>
</div>
)}
</>
)}
{isSignatureError && (
<div className="mt-2 flex flex-row items-center">
<Icon.FiAlertTriangle className="h-5 w-5 text-red-600" />
<p className="ml-2 text-red-600">
<Trans i18nKey="rainbow_signature_error" t={t}>
{t("rainbow_signature_error")}
</Trans>
</p>
</div>
)}
</>
)}
</span>
<span className="ml-10 min-w-[170px] self-center">
<ConnectButton chainStatus="icon" showBalance={false} />
</span>
</div>
</div>
</main>
);
};
export default RainbowGate;

View File

@ -0,0 +1,15 @@
{
"/*": "Don't modify slug - If required, do it using cli edit command",
"name": "Rainbow",
"slug": "rainbow",
"type": "rainbow_web3",
"imageSrc": "/api/app-store/rainbow/icon.svg",
"logo": "/api/app-store/rainbow/icon.svg",
"url": "https://cal.com/apps/rainbow",
"variant": "web3",
"categories": ["web3"],
"publisher": "hexcowboy",
"email": "",
"description": "Web3 integration for token gating on Fungible Tokens, NFTs, and DAOs.",
"__createdUsingCli": true
}

View File

@ -0,0 +1,2 @@
export * as api from "./api";
export { metadata } from "./_metadata";

View File

@ -0,0 +1,17 @@
{
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"name": "@calcom/rainbow",
"version": "0.0.0",
"main": "./index.ts",
"description": "Web3 integration for token gating on Fungible Tokens, NFTs, and DAOs.",
"dependencies": {
"@calcom/lib": "*",
"@rainbow-me/rainbowkit": "^0.5.0",
"ethers": "^5.7.0",
"wagmi": "^0.6.4"
},
"devDependencies": {
"@calcom/types": "*"
}
}

View File

@ -0,0 +1,54 @@
<svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="120" height="120" fill="url(#paint0_linear_62_329)"/>
<path d="M20 38H26C56.9279 38 82 63.0721 82 94V100H94C97.3137 100 100 97.3137 100 94C100 53.1309 66.8691 20 26 20C22.6863 20 20 22.6863 20 26V38Z" fill="url(#paint1_radial_62_329)"/>
<path d="M84 94H100C100 97.3137 97.3137 100 94 100H84V94Z" fill="url(#paint2_linear_62_329)"/>
<path d="M26 20L26 36H20L20 26C20 22.6863 22.6863 20 26 20Z" fill="url(#paint3_linear_62_329)"/>
<path d="M20 36H26C58.0325 36 84 61.9675 84 94V100H66V94C66 71.9086 48.0914 54 26 54H20V36Z" fill="url(#paint4_radial_62_329)"/>
<path d="M68 94H84V100H68V94Z" fill="url(#paint5_linear_62_329)"/>
<path d="M20 52L20 36L26 36L26 52H20Z" fill="url(#paint6_linear_62_329)"/>
<path d="M20 62C20 65.3137 22.6863 68 26 68C40.3594 68 52 79.6406 52 94C52 97.3137 54.6863 100 58 100H68V94C68 70.804 49.196 52 26 52H20V62Z" fill="url(#paint7_radial_62_329)"/>
<path d="M52 94H68V100H58C54.6863 100 52 97.3137 52 94Z" fill="url(#paint8_radial_62_329)"/>
<path d="M26 68C22.6863 68 20 65.3137 20 62L20 52L26 52L26 68Z" fill="url(#paint9_radial_62_329)"/>
<defs>
<linearGradient id="paint0_linear_62_329" x1="60" y1="0" x2="60" y2="120" gradientUnits="userSpaceOnUse">
<stop stop-color="#174299"/>
<stop offset="1" stop-color="#001E59"/>
</linearGradient>
<radialGradient id="paint1_radial_62_329" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(26 94) rotate(-90) scale(74)">
<stop offset="0.770277" stop-color="#FF4000"/>
<stop offset="1" stop-color="#8754C9"/>
</radialGradient>
<linearGradient id="paint2_linear_62_329" x1="83" y1="97" x2="100" y2="97" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF4000"/>
<stop offset="1" stop-color="#8754C9"/>
</linearGradient>
<linearGradient id="paint3_linear_62_329" x1="23" y1="20" x2="23" y2="37" gradientUnits="userSpaceOnUse">
<stop stop-color="#8754C9"/>
<stop offset="1" stop-color="#FF4000"/>
</linearGradient>
<radialGradient id="paint4_radial_62_329" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(26 94) rotate(-90) scale(58)">
<stop offset="0.723929" stop-color="#FFF700"/>
<stop offset="1" stop-color="#FF9901"/>
</radialGradient>
<linearGradient id="paint5_linear_62_329" x1="68" y1="97" x2="84" y2="97" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFF700"/>
<stop offset="1" stop-color="#FF9901"/>
</linearGradient>
<linearGradient id="paint6_linear_62_329" x1="23" y1="52" x2="23" y2="36" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFF700"/>
<stop offset="1" stop-color="#FF9901"/>
</linearGradient>
<radialGradient id="paint7_radial_62_329" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(26 94) rotate(-90) scale(42)">
<stop offset="0.59513" stop-color="#00AAFF"/>
<stop offset="1" stop-color="#01DA40"/>
</radialGradient>
<radialGradient id="paint8_radial_62_329" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(51 97) scale(17 45.3333)">
<stop stop-color="#00AAFF"/>
<stop offset="1" stop-color="#01DA40"/>
</radialGradient>
<radialGradient id="paint9_radial_62_329" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(23 69) rotate(-90) scale(17 322.37)">
<stop stop-color="#00AAFF"/>
<stop offset="1" stop-color="#01DA40"/>
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -0,0 +1,92 @@
import { ethers } from "ethers";
import { configureChains, createClient } from "wagmi";
import { z } from "zod";
import { createRouter } from "@calcom/trpc/server/createRouter";
import abi from "../utils/abi.json";
import { checkBalance, getProviders, SUPPORTED_CHAINS } from "../utils/ethereum";
const ethRouter = createRouter()
// Fetch contract `name` and `symbol` or error
.query("contract", {
input: z.object({
address: z.string(),
chainId: z.number(),
}),
output: z.object({
data: z
.object({
name: z.string(),
symbol: z.string(),
})
.nullish(),
error: z.string().nullish(),
}),
async resolve({ input: { address, chainId } }) {
const { provider } = configureChains(
SUPPORTED_CHAINS.filter((chain) => chain.id === chainId),
getProviders()
);
const client = createClient({
provider,
});
const contract = new ethers.Contract(address, abi, client.provider);
try {
const name = await contract.name();
const symbol = await contract.symbol();
return {
data: {
name,
symbol: `$${symbol}`,
},
};
} catch (e) {
return {
data: {
name: address,
symbol: "$UNKNOWN",
},
};
}
},
})
// Fetch user's `balance` of either ERC-20 or ERC-721 compliant token or error
.query("balance", {
input: z.object({
address: z.string(),
tokenAddress: z.string(),
chainId: z.number(),
}),
output: z.object({
data: z
.object({
hasBalance: z.boolean(),
})
.nullish(),
error: z.string().nullish(),
}),
async resolve({ input: { address, tokenAddress, chainId } }) {
try {
const hasBalance = await checkBalance(address, tokenAddress, chainId);
return {
data: {
hasBalance,
},
};
} catch (e) {
return {
data: {
hasBalance: false,
},
};
}
},
});
export default ethRouter;

View File

@ -0,0 +1,49 @@
[
{
"constant": true,
"inputs": [],
"name": "name",
"outputs": [
{
"name": "",
"type": "string"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": true,
"inputs": [],
"name": "symbol",
"outputs": [
{
"name": "",
"type": "string"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": true,
"inputs": [
{
"name": "owner",
"type": "address"
}
],
"name": "balanceOf",
"outputs": [
{
"name": "balance",
"type": "uint256"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
}
]

View File

@ -0,0 +1,116 @@
import type { Prisma } from "@prisma/client";
import { utils, Contract } from "ethers";
import { chain, configureChains, createClient } from "wagmi";
import { alchemyProvider } from "wagmi/providers/alchemy";
import { infuraProvider } from "wagmi/providers/infura";
import { publicProvider } from "wagmi/providers/public";
import { HttpError } from "@calcom/lib/http-error";
import abi from "./abi.json";
export const ETH_MESSAGE = "Connect to Cal.com";
export const SUPPORTED_CHAINS = [chain.mainnet, chain.polygon, chain.optimism, chain.arbitrum];
export const SUPPORTED_CHAINS_FOR_FORM = SUPPORTED_CHAINS.map((chain) => {
return { value: chain.id, label: chain.name };
});
// Optionally grabs Alchemy, Infura, in addition to public providers
export const getProviders = () => {
let providers = []; // eslint-disable-line prefer-const
if (process.env.ALCHEMY_API_KEY) {
providers.push(alchemyProvider({ apiKey: process.env.ALCHEMY_API_KEY }));
}
if (process.env.INFURA_API_KEY) {
providers.push(infuraProvider({ apiKey: process.env.INFURA_API_KEY }));
}
// Public provider will always be available as fallback, but having at least
// on of either Infura or Alchemy providers is highly recommended
providers.push(publicProvider());
return providers;
};
type VerifyResult = {
hasBalance: boolean;
address: string;
};
// Checks balance for any contract that implements the abi (NFT, ERC20, etc)
export const checkBalance = async (
walletAddress: string,
tokenAddress: string,
chainId: number
): Promise<boolean> => {
const { provider } = configureChains(
SUPPORTED_CHAINS.filter((chain) => chain.id === chainId),
getProviders()
);
const client = createClient({
provider,
});
const contract = new Contract(tokenAddress, abi, client.provider);
const userAddress = utils.getAddress(walletAddress);
const balance = await contract.balanceOf(userAddress);
return !balance.isZero();
};
// Extracts wallet address from a signed message and checks balance
export const verifyEthSig = async (
sig: string,
tokenAddress: string,
chainId: number
): Promise<VerifyResult> => {
const address = utils.verifyMessage(ETH_MESSAGE, sig);
const hasBalance = await checkBalance(address, tokenAddress, chainId);
return {
address,
hasBalance,
};
};
type HandleEthSignatureInput = {
smartContractAddress?: string;
blockchainId?: number;
};
// Handler used in `/book/event` API
export const handleEthSignature = async (
_metadata: Prisma.JsonValue,
ethSignature?: string
): Promise<string | undefined> => {
if (!_metadata) {
return;
}
const metadata = _metadata as HandleEthSignatureInput;
if (metadata) {
if (metadata.blockchainId && metadata.smartContractAddress) {
if (!ethSignature) {
throw new HttpError({ statusCode: 400, message: "Ethereum signature required." });
}
const { address, hasBalance } = await verifyEthSig(
ethSignature,
metadata.smartContractAddress as string,
metadata.blockchainId as number
);
if (!hasBalance) {
throw new HttpError({ statusCode: 400, message: "The wallet doesn't contain enough tokens." });
} else {
return address;
}
}
}
return undefined;
};

View File

@ -41,5 +41,11 @@
"categories": ["video"],
"slug": "campfire",
"type": "campfire_video"
},
{
"dirName": "rainbow",
"categories": ["web3"],
"slug": "rainbow",
"type": "rainbow_web3"
}
]

View File

@ -79,6 +79,7 @@ export const bookingCreateBodySchema = z.object({
metadata: z.record(z.string()),
hasHashedBookingLink: z.boolean().optional(),
hashedLink: z.string().nullish(),
ethSignature: z.string().optional(),
});
export const requiredCustomInputSchema = z.union([

View File

@ -5,6 +5,7 @@ import { JSONObject } from "superjson/dist/types";
import { z } from "zod";
import app_RoutingForms from "@calcom/app-store/ee/routing_forms/trpc-router";
import ethRouter from "@calcom/app-store/rainbow/trpc/router";
import { deleteStripeCustomer } from "@calcom/app-store/stripepayment/lib/customer";
import stripe, { closePayments } from "@calcom/app-store/stripepayment/lib/server";
import getApps, { getLocationOptions } from "@calcom/app-store/utils";
@ -1296,4 +1297,5 @@ export const viewerRouter = createRouter()
// NOTE: Add all app related routes in the bottom till the problem described in @calcom/app-store/trpc-routers.ts is solved.
// After that there would just one merge call here for all the apps.
.merge("app_routing_forms.", app_RoutingForms);
.merge("app_routing_forms.", app_RoutingForms)
.merge("eth.", ethRouter);

View File

@ -66,7 +66,7 @@ export interface App {
*/
imageSrc?: string;
/** TODO determine if we should use this instead of category */
variant: "calendar" | "payment" | "conferencing" | "video" | "other" | "other_calendar";
variant: "calendar" | "payment" | "conferencing" | "video" | "other" | "other_calendar" | "web3";
/** The slug for the app store public page inside `/apps/[slug] */
slug: string;

View File

@ -238,6 +238,8 @@
"$VITAL_DEVELOPMENT_MODE",
"$VITAL_REGION",
"$ZAPIER_INVITE_LINK",
"$ALCHEMY_API_KEY",
"$INFURA_API_KEY",
"yarn.lock"
]
}

1739
yarn.lock

File diff suppressed because it is too large Load Diff