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>pull/3911/head^2
parent
faf62ac8e7
commit
18d697436c
|
@ -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=""
|
||||
# *********************************************************************************************************
|
||||
|
|
|
@ -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;
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -27,6 +27,7 @@ export type BookingCreateBody = {
|
|||
hasHashedBookingLink: boolean;
|
||||
hashedLink?: string | null;
|
||||
smsReminderNumber?: string;
|
||||
ethSignature?: string;
|
||||
};
|
||||
|
||||
export type BookingResponse = Booking & {
|
||||
|
|
|
@ -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");
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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"),
|
||||
|
|
|
@ -18,8 +18,8 @@
|
|||
"@calcom/lib": "*",
|
||||
"@calcom/office365video": "*",
|
||||
"@calcom/trpc": "*",
|
||||
"@calcom/zoomvideo": "*",
|
||||
"@calcom/ui": "*",
|
||||
"@calcom/zoomvideo": "*",
|
||||
"lodash": "^4.17.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
@ -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.
|
|
@ -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>
|
|
@ -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;
|
|
@ -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;
|
|
@ -0,0 +1 @@
|
|||
export { default as add } from "./add";
|
|
@ -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;
|
|
@ -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'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;
|
|
@ -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
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
export * as api from "./api";
|
||||
export { metadata } from "./_metadata";
|
|
@ -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": "*"
|
||||
}
|
||||
}
|
|
@ -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 |
|
@ -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;
|
|
@ -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"
|
||||
}
|
||||
]
|
|
@ -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;
|
||||
};
|
|
@ -41,5 +41,11 @@
|
|||
"categories": ["video"],
|
||||
"slug": "campfire",
|
||||
"type": "campfire_video"
|
||||
},
|
||||
{
|
||||
"dirName": "rainbow",
|
||||
"categories": ["web3"],
|
||||
"slug": "rainbow",
|
||||
"type": "rainbow_web3"
|
||||
}
|
||||
]
|
||||
|
|
|
@ -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([
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -238,6 +238,8 @@
|
|||
"$VITAL_DEVELOPMENT_MODE",
|
||||
"$VITAL_REGION",
|
||||
"$ZAPIER_INVITE_LINK",
|
||||
"$ALCHEMY_API_KEY",
|
||||
"$INFURA_API_KEY",
|
||||
"yarn.lock"
|
||||
]
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue