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
};