From add17b7a7f30243e2bdda4cfaec501390f29deea Mon Sep 17 00:00:00 2001 From: sohrab <4444588+sohrab-@users.noreply.github.com> Date: Mon, 24 Apr 2023 18:48:06 +1000 Subject: [PATCH] feat: implement Clockwork integration --- package-lock.json | 19 + package.json | 1 + src/components/client/ClientHeader.tsx | 4 +- src/components/client/SendButton.tsx | 45 +++ .../client/integrations/ClockworkConfig.tsx | 355 ++++++++++++++++++ .../{ => integrations}/SquadsConfig.tsx | 18 +- src/hooks/clockwork.ts | 145 +++++++ src/types/state.ts | 26 +- src/utils/state.ts | 14 + 9 files changed, 624 insertions(+), 3 deletions(-) create mode 100644 src/components/client/integrations/ClockworkConfig.tsx rename src/components/client/{ => integrations}/SquadsConfig.tsx (89%) create mode 100644 src/hooks/clockwork.ts diff --git a/package-lock.json b/package-lock.json index 8a2b6cf..8c5e4b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@chakra-ui/icons": "^2.0.14", "@chakra-ui/react": "^2.4.4", + "@clockwork-xyz/sdk": "^0.3.0", "@dnd-kit/core": "^6.0.6", "@dnd-kit/modifiers": "^6.0.1", "@dnd-kit/sortable": "^7.0.1", @@ -3068,6 +3069,15 @@ "react": ">=18" } }, + "node_modules/@clockwork-xyz/sdk": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@clockwork-xyz/sdk/-/sdk-0.3.0.tgz", + "integrity": "sha512-RJwpLMWE0n+TiEGd4FpnZlOmsDIh6zbWMWNftZCHr3BCwGo4dCK91nlafP97fBy1nt4hepmMT8kii2El+wjHmg==", + "dependencies": { + "@coral-xyz/anchor": "^0.26.0", + "@solana/web3.js": "^1.73.2" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -26863,6 +26873,15 @@ "integrity": "sha512-sDEeeEjLfID333EC46NdCbhK2HyMXlpl5HzcJjuwWIpyVz4E1gKQ9hlwpq6grijvmzeSywQ5D3tTwUrvZck4KQ==", "requires": {} }, + "@clockwork-xyz/sdk": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@clockwork-xyz/sdk/-/sdk-0.3.0.tgz", + "integrity": "sha512-RJwpLMWE0n+TiEGd4FpnZlOmsDIh6zbWMWNftZCHr3BCwGo4dCK91nlafP97fBy1nt4hepmMT8kii2El+wjHmg==", + "requires": { + "@coral-xyz/anchor": "^0.26.0", + "@solana/web3.js": "^1.73.2" + } + }, "@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", diff --git a/package.json b/package.json index 64589e3..79ab946 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "dependencies": { "@chakra-ui/icons": "^2.0.14", "@chakra-ui/react": "^2.4.4", + "@clockwork-xyz/sdk": "^0.3.0", "@dnd-kit/core": "^6.0.6", "@dnd-kit/modifiers": "^6.0.1", "@dnd-kit/sortable": "^7.0.1", diff --git a/src/components/client/ClientHeader.tsx b/src/components/client/ClientHeader.tsx index 730bddb..9543ca4 100644 --- a/src/components/client/ClientHeader.tsx +++ b/src/components/client/ClientHeader.tsx @@ -17,7 +17,8 @@ import { Spacer, Tooltip, } from "@chakra-ui/react"; -import { SquadsConfig } from "components/client/SquadsConfig"; +import { ClockworkConfig } from "components/client/integrations/ClockworkConfig"; +import { SquadsConfig } from "components/client/integrations/SquadsConfig"; import { Description } from "components/common/Description"; import { EditableName } from "components/common/EditableName"; import { RpcEndpointMenu } from "components/common/RpcEndpointMenu"; @@ -195,6 +196,7 @@ export const ClientHeader: React.FC<{ sendButton: React.ReactNode }> = ({ {runType === "squads" && } + {runType === "clockwork" && } = (props) => ( ); +const ClockworkIcon: React.FC = (props) => ( + + + + + + +); + export const RUN_TYPES: { id: RunType; type: "standard" | "integration"; @@ -55,6 +83,12 @@ export const RUN_TYPES: { type: "integration", icon: , }, + { + id: "clockwork", + name: "Send to Clockwork", + type: "integration", + icon: , + }, ]; export const SendButton: React.FC<{ @@ -151,6 +185,13 @@ export const SendButton: React.FC<{ onError, }); + const { simulate: clockworkSimulate, send: clockworkSend } = + useSendToClockwork({ + onSimulated, + onSent, + onError, + }); + const onClick = () => { setUI((state) => { state.transactionRun = { @@ -167,6 +208,10 @@ export const SendButton: React.FC<{ squadsSimulate(transaction); } else if (runType === "squads" && !simulate) { squadsSend(transaction); + } else if (runType === "clockwork" && simulate) { + clockworkSimulate(transaction); + } else if (runType === "clockwork" && !simulate) { + clockworkSend(transaction); } }; diff --git a/src/components/client/integrations/ClockworkConfig.tsx b/src/components/client/integrations/ClockworkConfig.tsx new file mode 100644 index 0000000..2eab865 --- /dev/null +++ b/src/components/client/integrations/ClockworkConfig.tsx @@ -0,0 +1,355 @@ +import { InfoOutlineIcon } from "@chakra-ui/icons"; +import { + Box, + Flex, + FormLabel, + Grid, + GridItem, + Input, + Link, + NumberInput, + NumberInputField, + Select, + Spacer, + Switch, + useColorModeValue, +} from "@chakra-ui/react"; +import { ThreadProgramIdl } from "@clockwork-xyz/sdk"; +import { useWallet } from "@solana/wallet-adapter-react"; +import { PublicKey } from "@solana/web3.js"; +import { ExpandableSection } from "components/common/ExpandableSection"; +import { useSessionStoreWithUndo } from "hooks/useSessionStore"; +import { useMemo } from "react"; +import { ClockworkConfig as ClockworkConfigType } from "types/state"; + +// TODO a lot of repeated code for wiring the form to Zustand state +// TODO use a form state management framework? + +export const ClockworkConfig: React.FC = () => { + const [ + { + threadId, + amount, + fee, + rateLimit, + trigger, + accountTrigger, + cronTrigger, + epochTrigger, + slotTrigger, + }, + set, + ] = useSessionStoreWithUndo((state) => [state.clockworkConfig, state.set]); + const { publicKey: walletPublicKey } = useWallet(); + + const threadPda = useMemo(() => { + if (!walletPublicKey || !threadId) return; + + try { + return PublicKey.findProgramAddressSync( + [ + Buffer.from("thread"), + walletPublicKey.toBuffer(), + Buffer.from(threadId), + ], + new PublicKey(ThreadProgramIdl.metadata.address) + )[0].toBase58(); + } catch (e) { + return; // should only happen the seed is too long + } + }, [walletPublicKey, threadId]); + + return ( + + + + + + Clockwork documentation + + + + + Thread ID + + { + set((state) => { + state.clockworkConfig.threadId = event.target.value.trim(); + }); + }} + /> + + + Amount (in Lamports) + + + { + set((state) => { + state.clockworkConfig.amount = isNaN(value) ? 0 : value; + }); + }} + > + + + + + + Fee (in Lamports) + + + { + set((state) => { + state.clockworkConfig.fee = isNaN(value) ? "" : value; + }); + }} + > + + + + + + Rate limit (per slot) + + + { + set((state) => { + state.clockworkConfig.rateLimit = isNaN(value) ? "" : value; + }); + }} + > + + + + + + Trigger + + + + + + {trigger === "account" && ( + <> + + Address + + { + set((state) => { + state.clockworkConfig.accountTrigger.address = + event.target.value; + }); + }} + /> + + + Offset + + + { + set((state) => { + state.clockworkConfig.accountTrigger.offset = value; + }); + }} + > + + + + + + Size + + + { + set((state) => { + state.clockworkConfig.accountTrigger.size = value; + }); + }} + > + + + + + )} + + {trigger === "cron" && ( + <> + + Schedule + + { + set((state) => { + state.clockworkConfig.cronTrigger.schedule = + event.target.value.trim(); + }); + }} + /> + + + Skippable + + { + set((state) => { + state.clockworkConfig.cronTrigger.skippable = + event.target.checked; + }); + }} + /> + + )} + + {trigger === "slot" && ( + <> + + Slot + + + { + set((state) => { + state.clockworkConfig.slotTrigger.slot = value; + }); + }} + > + + + + + )} + + {trigger === "epoch" && ( + <> + + Epoch + + + { + set((state) => { + state.clockworkConfig.epochTrigger.epoch = value; + }); + }} + > + + + + + )} + + + Thread PDA:{" "} + + {threadPda || "N/A"} + + + + + ); +}; diff --git a/src/components/client/SquadsConfig.tsx b/src/components/client/integrations/SquadsConfig.tsx similarity index 89% rename from src/components/client/SquadsConfig.tsx rename to src/components/client/integrations/SquadsConfig.tsx index 98fbf85..d6993b2 100644 --- a/src/components/client/SquadsConfig.tsx +++ b/src/components/client/integrations/SquadsConfig.tsx @@ -1,4 +1,4 @@ -import { RepeatIcon } from "@chakra-ui/icons"; +import { InfoOutlineIcon, RepeatIcon } from "@chakra-ui/icons"; import { Box, Flex, @@ -7,11 +7,13 @@ import { GridItem, IconButton, Input, + Link, NumberDecrementStepper, NumberIncrementStepper, NumberInput, NumberInputField, NumberInputStepper, + Spacer, Spinner, Switch, Tooltip, @@ -43,6 +45,20 @@ export const SquadsConfig: React.FC = () => { return ( + + + + Squads V3 documentation + + + Squads program diff --git a/src/hooks/clockwork.ts b/src/hooks/clockwork.ts new file mode 100644 index 0000000..37e78ed --- /dev/null +++ b/src/hooks/clockwork.ts @@ -0,0 +1,145 @@ +import { + ClockworkProvider, + TriggerInput, + parseTransactionInstructions, +} from "@clockwork-xyz/sdk"; +import { AnchorProvider, BN } from "@project-serum/anchor"; +import { useAnchorWallet } from "@solana/wallet-adapter-react"; +import { TransactionInstruction } from "@solana/web3.js"; +import { useSessionStoreWithUndo } from "hooks/useSessionStore"; +import { useWeb3Connection } from "hooks/useWeb3Connection"; +import { + TransactionBuilderArgs, + useWeb3TransactionBuilder, +} from "hooks/useWeb3TranasctionBuilder"; +import { mapITransactionToWeb3Instructions } from "mappers/internal-to-web3js"; +import { ITransaction } from "types/internal"; +import { toPublicKey } from "utils/web3js"; + +export const useSendToClockwork = ( + args: TransactionBuilderArgs +): { + simulate: (transaction: ITransaction) => void; + send: (transaction: ITransaction) => void; +} => { + const { + threadId, + amount, + fee, + rateLimit, + trigger, + accountTrigger, + cronTrigger, + epochTrigger, + slotTrigger, + } = useSessionStoreWithUndo((state) => state.clockworkConfig); + + const connection = useWeb3Connection(); + const anchorWallet = useAnchorWallet(); + + const { + getAddressLookupTableAccounts, + buildTransactionFromWeb3Instructions, + withErrorHandler, + simulate: simulateWeb3Transation, + send: sendWeb3Transaction, + } = useWeb3TransactionBuilder(args); + + const buildTransaction = async (transaction: ITransaction) => { + if (!anchorWallet) { + throw new Error("Wallet not connected"); + } + if (!threadId) { + throw new Error("No Clockwork Thread ID has been defined"); + } + + const anchorProvider = new AnchorProvider(connection, anchorWallet, {}); + const provider = ClockworkProvider.fromAnchorProvider(anchorProvider); + + const triggerInput: TriggerInput = + trigger === "account" + ? { + account: { + address: toPublicKey( + accountTrigger.address, + "Clockwork account trigger's address" + ), + offset: new BN(accountTrigger.offset), + size: new BN(accountTrigger.size), + }, + } + : trigger === "cron" + ? { + cron: cronTrigger, + } + : trigger === "now" + ? { now: {} } + : trigger === "slot" + ? { slot: slotTrigger } + : { epoch: epochTrigger }; + + const threadInstructions = mapITransactionToWeb3Instructions(transaction); + const threadPda = provider.getThreadPDA( + anchorWallet.publicKey, + threadId + )[0]; + const account = await connection.getAccountInfo(threadPda); + + const instructions: TransactionInstruction[] = []; + if (account) { + instructions.push( + await provider.threadUpdate(anchorWallet.publicKey, threadPda, { + fee: fee === "" ? undefined : fee, + rateLimit: rateLimit === "" ? undefined : rateLimit, + instructions: parseTransactionInstructions(threadInstructions), + trigger: triggerInput, + }) + ); + } else { + instructions.push( + await provider.threadCreate( + anchorWallet.publicKey, + threadId, + threadInstructions, + triggerInput, + amount + ) + ); + + // not sure why these are not available on the create operation... + if (fee !== "" || rateLimit !== "") { + instructions.push( + await provider.threadUpdate(anchorWallet.publicKey, threadPda, { + fee: fee === "" ? undefined : fee, + rateLimit: rateLimit === "" ? undefined : rateLimit, + }) + ); + } + } + + return await buildTransactionFromWeb3Instructions( + instructions, + await getAddressLookupTableAccounts(transaction), + transaction.version + ); + }; + + const simulate = async (transaction: ITransaction) => { + const transactionWithContext = await buildTransaction(transaction); + if (!transactionWithContext) return; + + simulateWeb3Transation(transactionWithContext); + }; + + const send = async (transaction: ITransaction) => { + const transactionWithContext = await buildTransaction(transaction); + if (!transactionWithContext) return; + + sendWeb3Transaction(transactionWithContext); + }; + + return { + simulate: withErrorHandler(simulate), + send: withErrorHandler(send), + }; +}; diff --git a/src/types/state.ts b/src/types/state.ts index f3d4bd9..b3e18e2 100644 --- a/src/types/state.ts +++ b/src/types/state.ts @@ -17,7 +17,7 @@ export type Explorer = | "solscan" | "none"; -export type RunType = "send" | "squads"; +export type RunType = "send" | "squads" | "clockwork"; // We mutate state using immerjs. All state fields are set to readonly // so we don't by mistake try to mutate outside immerjs. @@ -47,6 +47,29 @@ export interface SquadsConfig { readonly activateTransaction: boolean; } +export interface ClockworkConfig { + readonly threadId: string; + readonly amount: number; + readonly fee: number | ""; + readonly rateLimit: number | ""; + readonly trigger: "account" | "cron" | "now" | "slot" | "epoch"; + readonly cronTrigger: { + schedule: string; + skippable: boolean; + }; + readonly accountTrigger: { + address: string; + offset: number; + size: number; + }; + readonly slotTrigger: { + slot: number; + }; + readonly epochTrigger: { + epoch: number; + }; +} + ////// State Stores ////// export interface PersistentState { @@ -61,6 +84,7 @@ export interface SessionStateWithUndo { readonly rpcEndpoint: IRpcEndpoint; readonly keypairs: Record; readonly squadsConfig: SquadsConfig; + readonly clockworkConfig: ClockworkConfig; set: (fn: (state: Draft) => void) => void; } diff --git a/src/utils/state.ts b/src/utils/state.ts index f239e72..cbd2688 100644 --- a/src/utils/state.ts +++ b/src/utils/state.ts @@ -8,6 +8,7 @@ import { } from "types/internal"; import { AppOptions, + ClockworkConfig, PersistentState, SessionStateWithUndo, SessionStateWithoutUndo, @@ -108,11 +109,24 @@ export const DEFAULT_SQUADS_CONFIG: SquadsConfig = { activateTransaction: false, }; +export const DEFAULT_CLOCKWORK_CONFIG: ClockworkConfig = { + threadId: "", + amount: 0, + fee: "", + rateLimit: "", + trigger: "now", + cronTrigger: { schedule: "", skippable: false }, + accountTrigger: { address: "", offset: 0, size: 0 }, + slotTrigger: { slot: 0 }, + epochTrigger: { epoch: 0 }, +}; + export const DEFAULT_SESSION_STATE_WITH_UNDO: SessionStateWithUndo = { transaction: DEFAULT_TRANSACTION, rpcEndpoint: DEFAULT_RPC_ENDPOINTS[0], keypairs: {}, squadsConfig: DEFAULT_SQUADS_CONFIG, + clockworkConfig: DEFAULT_CLOCKWORK_CONFIG, set: () => {}, // set by the hook };