diff --git a/eslint.config.mjs b/eslint.config.mjs index 54c8e85..164218e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -56,7 +56,7 @@ export default [ }, rules: { - 'camelcase': ['error'], + camelcase: ['error'], 'guard-for-in': ['error'], 'import/no-cycle': ['error'], 'import/no-self-import': ['error'], diff --git a/next.config.js b/next.config.js index d7ef0fd..6f3c164 100755 --- a/next.config.js +++ b/next.config.js @@ -1,17 +1,21 @@ /** @type {import('next').NextConfig} */ -const { version } = require('./package.json') -const { withSentryConfig } = require("@sentry/nextjs"); +const { version } = require('./package.json'); +const { withSentryConfig } = require('@sentry/nextjs'); const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true', -}) +}); -const isDev = process.env.NODE_ENV !== 'production' +const isDev = process.env.NODE_ENV !== 'production'; // Sometimes useful to disable this during development const ENABLE_CSP_HEADER = true; -const FRAME_SRC_HOSTS = ['https://*.walletconnect.com', 'https://*.walletconnect.org','https://*.solflare.com']; -const STYLE_SRC_HOSTS = [] +const FRAME_SRC_HOSTS = [ + 'https://*.walletconnect.com', + 'https://*.walletconnect.org', + 'https://*.solflare.com', +]; +const STYLE_SRC_HOSTS = []; const IMG_SRC_HOSTS = ['https://*.walletconnect.com', 'https://*.githubusercontent.com']; const cspHeader = ` default-src 'self'; @@ -27,7 +31,9 @@ const cspHeader = ` frame-ancestors 'none'; ${!isDev ? 'block-all-mixed-content;' : ''} ${!isDev ? 'upgrade-insecure-requests;' : ''} -`.replace(/\s{2,}/g, ' ').trim(); +` + .replace(/\s{2,}/g, ' ') + .trim(); const securityHeaders = [ { @@ -54,8 +60,8 @@ const securityHeaders = [ value: cspHeader, }, ] - : []) -] + : []), +]; const nextConfig = { webpack(config) { @@ -72,7 +78,7 @@ const nextConfig = { source: '/(.*)', headers: securityHeaders, }, - ] + ]; }, env: { @@ -80,14 +86,25 @@ const nextConfig = { }, reactStrictMode: true, -} + + experimental: { + optimizePackageImports: [ + '@hyperlane-xyz/registry', + '@hyperlane-xyz/sdk', + '@hyperlane-xyz/utils', + '@hyperlane-xyz/widgets', + '@rainbow-me/rainbowkit', + '@solana/spl-token', + ], + }, +}; const sentryOptions = { - org: "hyperlane", - project: "warp-ui", + org: 'hyperlane', + project: 'warp-ui', authToken: process.env.SENTRY_AUTH_TOKEN, hideSourceMaps: true, - tunnelRoute: "/monitoring-tunnel", + tunnelRoute: '/monitoring-tunnel', bundleSizeOptimizations: { excludeDebugStatements: true, excludeReplayIframe: true, @@ -95,4 +112,4 @@ const sentryOptions = { }, }; -module.exports = withBundleAnalyzer(withSentryConfig(nextConfig, sentryOptions)); \ No newline at end of file +module.exports = withBundleAnalyzer(withSentryConfig(nextConfig, sentryOptions)); diff --git a/package.json b/package.json index 047c8a9..a3e32bb 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@interchain-ui/react": "^1.23.28", "@metamask/post-message-stream": "6.1.2", "@metamask/providers": "10.2.1", + "@octokit/rest": "^22.0.0", "@rainbow-me/rainbowkit": "^2.2.0", "@sentry/nextjs": "^8.38.0", "@solana/spl-token": "^0.4.9", @@ -44,6 +45,7 @@ "ethers": "^5.8.0", "formik": "^2.4.6", "framer-motion": "^11.13.1", + "human-id": "^4.1.1", "next": "^15.0.3", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/src/components/toast/useToastError.tsx b/src/components/toast/useToastError.tsx index 346d5da..b845332 100644 --- a/src/components/toast/useToastError.tsx +++ b/src/components/toast/useToastError.tsx @@ -7,7 +7,7 @@ export function useToastError(error: any, context: string, errorLength = 120) { useEffect(() => { if (!error) return; logger.error(context, error); - const errorMsg = errorToString(error, errorLength); + const errorMsg = error?.error?.message || errorToString(error, errorLength); toast.error(`${context}: ${errorMsg}`); }, [error, context, errorLength]); } diff --git a/src/consts/config.server.ts b/src/consts/config.server.ts new file mode 100644 index 0000000..f754535 --- /dev/null +++ b/src/consts/config.server.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; + +export const ServerConfigSchema = z.object({ + githubForkOwner: z + .string() + .min(1, 'GITHUB_FORK_OWNER is required') + .describe('Username of the forked repository owner'), + githubRepoName: z + .string() + .min(1, 'GITHUB_REPO is required') + .describe('Name of the repository (should match both upstream and fork)'), + githubUpstreamOwner: z + .string() + .min(1, 'GITHUB_UPSTREAM_OWNER is required') + .describe('Username of the base (upstream) repository owner'), + githubBaseBranch: z + .string() + .min(1, 'GITHUB_BASE_BRANCH is required') + .describe('Branch of the repositories (e.g., main or master)'), + githubToken: z + .string() + .min(1, 'GITHUB_TOKEN is required') + .describe('GitHub token of the fork owner with repo/pull access'), + serverEnvironment: z + .string() + .optional() + .describe('The environment currently running on the server'), +}); + +export type ServerConfig = z.infer; + +const githubForkOwner = process.env.GITHUB_FORK_OWNER || ''; +const githubRepoName = process.env.GITHUB_REPO || ''; +const githubUpstreamOwner = process.env.GITHUB_UPSTREAM_OWNER || ''; +const githubBaseBranch = 'main'; +const githubToken = process.env.GITHUB_TOKEN || ''; +const serverEnvironment = process.env.SERVER_ENVIRONMENT || 'production'; + +export const serverConfig: ServerConfig = Object.freeze({ + githubForkOwner, + githubRepoName, + githubUpstreamOwner, + githubBaseBranch, + githubToken, + serverEnvironment, +}); diff --git a/src/features/deployment/CoinGeckoConfirmationModal.tsx b/src/features/deployment/CoinGeckoConfirmationModal.tsx index 2cd29bf..9e40d3d 100644 --- a/src/features/deployment/CoinGeckoConfirmationModal.tsx +++ b/src/features/deployment/CoinGeckoConfirmationModal.tsx @@ -15,10 +15,12 @@ export function CoinGeckoConfirmationModal({ isOpen, onCancel, onSubmit, + close, }: { isOpen: boolean; onCancel: () => void; onSubmit: (values: CoinGeckoFormValues) => void; + close: () => void; }) { const { result } = useLatestDeployment(); const initialCoinGeckoId = getInitialCoinGeckoId(result); diff --git a/src/features/deployment/CreateRegistryPrModal.tsx b/src/features/deployment/CreateRegistryPrModal.tsx new file mode 100644 index 0000000..6978121 --- /dev/null +++ b/src/features/deployment/CreateRegistryPrModal.tsx @@ -0,0 +1,163 @@ +import { ErrorIcon, Modal } from '@hyperlane-xyz/widgets'; +import { Form, Formik, useFormikContext } from 'formik'; +import { SolidButton } from '../../components/buttons/SolidButton'; +import { TextInput } from '../../components/input/TextField'; +import { A } from '../../components/text/A'; +import { H2 } from '../../components/text/Headers'; +import { links } from '../../consts/links'; +import { Color } from '../../styles/Color'; +import { CreatePrResponse, GithubIdentity, GitHubIdentitySchema } from '../../types/createPr'; +import { normalizeEmptyStrings } from '../../utils/string'; +import { zodErrorToFormikErrors } from '../../utils/zod'; + +export function CreateRegistryPrModal({ + isOpen, + onCancel, + onConfirm, + confirmDisabled, + disabled, + response, +}: { + isOpen: boolean; + disabled: boolean; + confirmDisabled: boolean; + onCancel: () => void; + onConfirm: (values: GithubIdentity) => void; + response: CreatePrResponse | null | undefined; +}) { + return ( + +

Add this deployment to the Hyperlane Registry

+

+ Would you like to create a Pull Request on Github to include this deployment to the{' '} + + Hyperlane Registry + + ? Once your PR is merged, your artifacts will become available for the community to use! +

+ +

+ Optionally, you can include your Github username and organization! +

+ + + onSubmit={onConfirm} + validate={validateForm} + validateOnChange={false} + validateOnBlur={false} + initialValues={{ organization: undefined, username: undefined }} + > + {() => ( +
+ {response && response.success ? ( +
+

This is the link to your PR:

+ +
+ ) : ( + + )} + + + + )} + +
+ ); +} + +function InputSection() { + const { setFieldValue, values, errors } = useFormikContext(); + + return ( +
+
+ setFieldValue('username', v)} + placeholder="Github Username" + /> + {errors.username && ( +
+ + {errors.username} +
+ )} +
+
+ setFieldValue('organization', v)} + placeholder="Organization" + /> + {errors.organization && ( +
+ + {errors.organization} +
+ )} +
+
+ ); +} + +function ButtonsSection({ + onCancel, + confirmDisabled, + disabled, +}: { + onCancel: () => void; + confirmDisabled: boolean; + disabled: boolean; +}) { + return ( +
+ + Close + + + Confirm + +
+ ); +} + +const styles = { + text: 'text-center text-sm text-gray-700', + link: 'underline underline-offset-2 hover:opacity-80 active:opacity-70', +}; + +function validateForm(values: GithubIdentity) { + const normalizedValues = normalizeEmptyStrings(values); + const parsedResult = GitHubIdentitySchema.safeParse(normalizedValues); + + if (!parsedResult.success) { + return zodErrorToFormikErrors(parsedResult.error); + } + + return undefined; +} diff --git a/src/features/deployment/DeploymentDetailsModal.tsx b/src/features/deployment/DeploymentDetailsModal.tsx index 43caf93..25b509f 100644 --- a/src/features/deployment/DeploymentDetailsModal.tsx +++ b/src/features/deployment/DeploymentDetailsModal.tsx @@ -7,6 +7,7 @@ import { useMultiProvider } from '../chains/hooks'; import { getChainDisplayName } from '../chains/utils'; import { DeploymentStatusIcon } from './DeploymentStatusIcon'; import { DeploymentContext } from './types'; +import { sortWarpCoreConfig } from './utils'; export function DeploymentsDetailsModal({ isOpen, @@ -23,6 +24,8 @@ export function DeploymentsDetailsModal({ const multiProvider = useMultiProvider(); const chainNames = chains.map((c) => getChainDisplayName(multiProvider, c)).join(', '); + const warpCoreResult = sortWarpCoreConfig(result?.result); + return (
@@ -37,7 +40,7 @@ export function DeploymentsDetailsModal({
- {result && } + {result && }
); } diff --git a/src/features/deployment/github.ts b/src/features/deployment/github.ts new file mode 100644 index 0000000..d432cb2 --- /dev/null +++ b/src/features/deployment/github.ts @@ -0,0 +1,131 @@ +import { BaseRegistry } from '@hyperlane-xyz/registry'; +import { + MultiProtocolProvider, + ProviderType, + WarpCoreConfig, + WarpRouteDeployConfig, +} from '@hyperlane-xyz/sdk'; +import { assert, ProtocolType } from '@hyperlane-xyz/utils'; +import { useMutation } from '@tanstack/react-query'; +import { stringify } from 'yaml'; +import { useToastError } from '../../components/toast/useToastError'; +import { + CreatePrBody, + CreatePrRequestBody, + CreatePrResponse, + GithubIdentity, +} from '../../types/createPr'; +import { normalizeEmptyStrings } from '../../utils/string'; +import { useMultiProvider } from '../chains/hooks'; +import { TypedWallet } from '../deployerWallet/types'; +import { useDeployerWallets } from '../deployerWallet/wallets'; +import { useLatestDeployment } from './hooks'; +import { DeploymentType } from './types'; +import { getConfigsFilename, sortWarpCoreConfig } from './utils'; +import { isSyntheticTokenType } from './warp/utils'; + +const warpRoutesPath = 'deployments/warp_routes'; + +export function useCreateWarpRoutePR(onSuccess: () => void) { + const { config, result } = useLatestDeployment(); + const multiProvider = useMultiProvider(); + const { wallets } = useDeployerWallets(); + + const { isPending, mutate, mutateAsync, error, data } = useMutation({ + mutationKey: ['createWarpRoutePr', config, result], + mutationFn: async (githubInformation: GithubIdentity) => { + if (!config.config || config.type !== DeploymentType.Warp) + throw new Error('Deployment config not found'); + if (!result?.result || result.type !== DeploymentType.Warp) + throw new Error('Deployment result not found'); + + const deployer = wallets[ProtocolType.Ethereum]; + assert(deployer, 'Deployer wallet not found'); + + const prBody = getPrCreationBody(config.config, result.result, githubInformation); + const timestamp = `timestamp: ${new Date().toISOString()}`; + const message = `Verify PR creation for: ${prBody.warpRouteId} ${timestamp}`; + const signature = await createSignatureFromWallet(deployer, message, multiProvider); + + return createWarpRoutePR({ + prBody, + signatureVerification: { + address: deployer.address, + message, + signature, + }, + }); + }, + retry: false, + onSuccess, + }); + + useToastError(error, 'Error creating PR for Github'); + + return { + mutate, + mutateAsync, + error, + isPending, + data, + }; +} + +function getPrCreationBody( + deployConfig: WarpRouteDeployConfig, + warpConfig: WarpCoreConfig, + githubInformation: GithubIdentity, +) { + const firstNonSynthetic = Object.values(deployConfig).find((c) => !isSyntheticTokenType(c.type)); + + if (!firstNonSynthetic || !firstNonSynthetic.symbol) + throw new Error('Token types cannot all be synthetic'); + + const symbol = firstNonSynthetic.symbol; + const warpRouteId = BaseRegistry.warpDeployConfigToId(deployConfig, { symbol }); + const { deployConfigFilename, warpConfigFilename } = getConfigsFilename(warpRouteId); + + const yamlDeployConfig = stringify(deployConfig, { sortMapEntries: true }); + const yamlWarpConfig = stringify(sortWarpCoreConfig(warpConfig), { sortMapEntries: true }); + + const basePath = `${warpRoutesPath}/${symbol}`; + const requestBody: CreatePrBody = { + ...normalizeEmptyStrings(githubInformation), + deployConfig: { content: yamlDeployConfig, path: `${basePath}/${deployConfigFilename}` }, + warpConfig: { content: yamlWarpConfig, path: `${basePath}/${warpConfigFilename}` }, + warpRouteId, + }; + + return requestBody; +} + +async function createWarpRoutePR(requestBody: CreatePrRequestBody): Promise { + const res = await fetch('/api/create-pr', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...requestBody, + }), + }); + + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Unknown error'); + + return data; +} + +// TODO multi-protocol support +export async function createSignatureFromWallet( + typedWallet: TypedWallet, + message: string, + multiProvider: MultiProtocolProvider, +): Promise { + if (typedWallet.type === ProviderType.EthersV5) { + // any chain will do but we are using mainnet for ease + const provider = multiProvider.getEthersV5Provider('ethereum'); + const signature = await typedWallet.wallet.connect(provider).signMessage(message); + return signature; + } else { + throw new Error(`Unsupported provider type for sending txs: ${typedWallet.type}`); + } +} diff --git a/src/features/deployment/utils.ts b/src/features/deployment/utils.ts index 98dc5bc..63b76d1 100644 --- a/src/features/deployment/utils.ts +++ b/src/features/deployment/utils.ts @@ -1,3 +1,5 @@ +import { WarpCoreConfig } from '@hyperlane-xyz/sdk'; +import { assert } from '@hyperlane-xyz/utils'; import { tryClipboardSet } from '@hyperlane-xyz/widgets'; import { toast } from 'react-toastify'; import { stringify } from 'yaml'; @@ -23,3 +25,35 @@ export function downloadYamlFile(config: unknown | undefined, filename: string) document.body.removeChild(a); URL.revokeObjectURL(url); } + +export function getConfigsFilename(warpRouteId: string) { + const [_, label] = warpRouteId.split('/'); + + assert(label, `Invalid warpRouteId format: ${warpRouteId}. Expected format: "prefix/label`); + + return { + deployConfigFilename: `${label}-deploy.yaml`, + warpConfigFilename: `${label}-config.yaml`, + }; +} + +export function sortWarpCoreConfig(warpCoreConfig?: WarpCoreConfig): WarpCoreConfig | undefined { + if (!warpCoreConfig) return undefined; + + const tokens = warpCoreConfig.tokens; + + const sortedTokens = [...tokens] + .sort((a, b) => a.chainName.localeCompare(b.chainName)) + .map((token) => ({ + ...token, + connections: token.connections + ? [...token.connections].sort((a, b) => { + const chainA = a.token.split('|')[1] || ''; + const chainB = b.token.split('|')[1] || ''; + return chainA.localeCompare(chainB); + }) + : undefined, + })); + + return { ...warpCoreConfig, tokens: sortedTokens }; +} diff --git a/src/features/deployment/warp/WarpDeploymentSuccess.tsx b/src/features/deployment/warp/WarpDeploymentSuccess.tsx index 8ac90e2..1a38ba9 100644 --- a/src/features/deployment/warp/WarpDeploymentSuccess.tsx +++ b/src/features/deployment/warp/WarpDeploymentSuccess.tsx @@ -1,49 +1,78 @@ +import { BaseRegistry } from '@hyperlane-xyz/registry'; import { TOKEN_COLLATERALIZED_STANDARDS } from '@hyperlane-xyz/sdk'; -import { objKeys, shortenAddress } from '@hyperlane-xyz/utils'; +import { shortenAddress } from '@hyperlane-xyz/utils'; import { CopyIcon, useModal } from '@hyperlane-xyz/widgets'; import clsx from 'clsx'; import Image from 'next/image'; +import { useCallback, useMemo, useState } from 'react'; import { RestartButton } from '../../../components/buttons/RestartButton'; import { A } from '../../../components/text/A'; import { H1, H2 } from '../../../components/text/Headers'; import { links } from '../../../consts/links'; import DownloadIcon from '../../../images/icons/download-icon.svg'; +import FolderCodeIcon from '../../../images/icons/folder-code-icon.svg'; import { Color } from '../../../styles/Color'; import { CoinGeckoConfirmationModal } from '../CoinGeckoConfirmationModal'; +import { CreateRegistryPrModal } from '../CreateRegistryPrModal'; +import { useCreateWarpRoutePR } from '../github'; import { useDeploymentHistory, useLatestDeployment, useWarpDeploymentConfig } from '../hooks'; import { DeploymentType } from '../types'; -import { downloadYamlFile, tryCopyConfig } from '../utils'; +import { downloadYamlFile, getConfigsFilename, sortWarpCoreConfig, tryCopyConfig } from '../utils'; import { CoinGeckoFormValues } from './types'; +import { isSyntheticTokenType } from './utils'; export function WarpDeploymentSuccess() { const { deploymentConfig } = useWarpDeploymentConfig(); const { updateDeployment, currentIndex } = useDeploymentHistory(); const { close, isOpen, open } = useModal(); + const { close: closeCreatePr, isOpen: isCreatePrOpen, open: openCreatePr } = useModal(); + const [hasSubmittedPr, setHasSubmittedPr] = useState(false); + + const onPrCreationSuccess = useCallback(() => setHasSubmittedPr(true), []); + + const { mutate, isPending, data: createPrData } = useCreateWarpRoutePR(onPrCreationSuccess); + const firstOwner = Object.values(deploymentConfig?.config || {})[0]?.owner; const firstOwnerDisplay = firstOwner ? ` (${shortenAddress(firstOwner)})` : ''; const deploymentContext = useLatestDeployment(); - const onClickCopyConfig = () => tryCopyConfig(deploymentContext?.result?.result); + + const warpRouteId = useMemo(() => { + if (!deploymentContext.config?.config || deploymentContext.config.type !== DeploymentType.Warp) + return undefined; + const deployConfig = deploymentContext.config.config; + const firstNonSynthetic = Object.values(deployConfig).find( + (c) => !isSyntheticTokenType(c.type), + ); + + if (!firstNonSynthetic || !firstNonSynthetic.symbol) return undefined; + const symbol = firstNonSynthetic.symbol; + + return BaseRegistry.warpDeployConfigToId(deployConfig, { symbol }); + }, [deploymentContext]); + + const onClickCopyConfig = () => + tryCopyConfig(sortWarpCoreConfig(deploymentContext?.result?.result)); const onClickCopyDeployConfig = () => tryCopyConfig(deploymentContext?.config.config); const downloadDeployConfig = () => { - if (!deploymentContext?.config.config || deploymentContext.config.type !== DeploymentType.Warp) - return; + if (!warpRouteId) return; const deployConfigResult = deploymentContext.config.config; - const chains = objKeys(deployConfigResult).sort(); - const filename = `${chains.join('-')}-deploy.yaml`; - downloadYamlFile(deployConfigResult, filename); + const { deployConfigFilename } = getConfigsFilename(warpRouteId); + downloadYamlFile(deployConfigResult, deployConfigFilename); }; const downloadWarpConfig = () => { + if (!warpRouteId) return; if (!deploymentContext?.result?.result || deploymentContext.result.type !== DeploymentType.Warp) return; - const warpConfigResult = deploymentContext.result.result; - const chains = warpConfigResult.tokens.map((token) => token.chainName).sort(); - const filename = `${chains.join('-')}-config.yaml`; - downloadYamlFile(warpConfigResult, filename); + const warpConfigResult = sortWarpCoreConfig(deploymentContext.result.result); + if (!warpConfigResult) return; + + const { warpConfigFilename } = getConfigsFilename(warpRouteId); + downloadYamlFile(warpConfigResult, warpConfigFilename); }; const onCancelCoinGeckoId = () => { @@ -142,7 +171,15 @@ export function WarpDeploymentSuccess() { 4. Add your route to the{' '} Hyperlane Registry - + {' '} + or you can open a PR by clicking{' '} +
  • 5.{' '} @@ -159,6 +196,15 @@ export function WarpDeploymentSuccess() { isOpen={isOpen} onCancel={onCancelCoinGeckoId} onSubmit={onConfirmCoinGeckoId} + close={close} + /> + ); diff --git a/src/images/icons/folder-code-icon.svg b/src/images/icons/folder-code-icon.svg new file mode 100644 index 0000000..5d3c3f4 --- /dev/null +++ b/src/images/icons/folder-code-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/libs/github.ts b/src/libs/github.ts new file mode 100644 index 0000000..06bfbcb --- /dev/null +++ b/src/libs/github.ts @@ -0,0 +1,16 @@ +// this is meant to be used server side only! +import { Octokit } from '@octokit/rest'; +import { serverConfig, ServerConfigSchema } from '../consts/config.server'; + +let cachedOctokitClient: Octokit | null = null; + +export function getOctokitClient(): Octokit | null { + if (!cachedOctokitClient) { + const serverConfigParseResult = ServerConfigSchema.safeParse(serverConfig); + if (!serverConfigParseResult.success) return null; + + cachedOctokitClient = new Octokit({ auth: serverConfigParseResult.data.githubToken }); + } + + return cachedOctokitClient; +} diff --git a/src/pages/api/create-pr.ts b/src/pages/api/create-pr.ts new file mode 100644 index 0000000..5c2e676 --- /dev/null +++ b/src/pages/api/create-pr.ts @@ -0,0 +1,274 @@ +import { + WarpCoreConfig, + WarpCoreConfigSchema, + WarpRouteDeployConfig, + WarpRouteDeployConfigSchema, +} from '@hyperlane-xyz/sdk'; +import { Octokit } from '@octokit/rest'; +import humanId from 'human-id'; +import type { NextApiRequest, NextApiResponse } from 'next'; +import { encodePacked, isHex, keccak256, toBytes, toHex, verifyMessage } from 'viem'; +import { serverConfig } from '../../consts/config.server'; +import { sortWarpCoreConfig } from '../../features/deployment/utils'; +import { getOctokitClient } from '../../libs/github'; +import { ApiError, ApiSuccess } from '../../types/api'; +import { + CreatePrBody, + CreatePrBodySchema, + CreatePrResponse, + DeployFile, + VerifyPrSignature, + VerifyPrSignatureSchema, +} from '../../types/createPr'; +import { sendJsonResponse } from '../../utils/api'; +import { sortObjByKeys } from '../../utils/object'; +import { validateStringToZodSchema, zodErrorToString } from '../../utils/zod'; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + if (req.method !== 'POST') return sendJsonResponse(res, 405, { error: 'Method not allowed' }); + + const { + githubBaseBranch, + githubForkOwner, + githubRepoName, + githubUpstreamOwner, + serverEnvironment, + } = serverConfig; + const octokit = getOctokitClient(); + if (!octokit) { + return sendJsonResponse(res, 500, { + error: + serverEnvironment === 'development' + ? 'Missing Github configurations, check your environment variables' + : 'Internal Server Error', + }); + } + + const { prBody, signatureVerification } = req.body; + + const requestBody = validateRequestBody(prBody); + if (!requestBody.success) return sendJsonResponse(res, 400, { error: requestBody.error }); + + const signatureVerificationResponse = await validateRequestSignature(signatureVerification); + if (!signatureVerificationResponse.success) + return sendJsonResponse(res, 400, { error: signatureVerificationResponse.error }); + + const { + deployConfig, + warpConfig, + warpRouteId, + organization, + username, + deployConfigResult, + warpConfigResult, + } = requestBody.data; + + const branch = getBranchName(warpRouteId, deployConfigResult, warpConfigResult); + if (!branch.success) return sendJsonResponse(res, 400, { error: branch.error }); + + const branchName = branch.data; + const validBranch = await isValidBranchName(octokit, githubForkOwner, githubRepoName, branchName); + + if (!validBranch) + return sendJsonResponse(res, 400, { error: 'A PR already exists with these config!' }); + + try { + // Get latest SHA of base branch in fork + const { data: refData } = await octokit.git.getRef({ + owner: githubForkOwner, + repo: githubRepoName, + ref: `heads/${githubBaseBranch}`, + }); + + const latestCommitSha = refData.object.sha; + + // Create new branch + await octokit.git.createRef({ + owner: githubForkOwner, + repo: githubRepoName, + ref: `refs/heads/${branchName}`, + sha: latestCommitSha, + }); + + const changesetFile = writeChangeset(`Add ${warpRouteId} warp route deploy artifacts`); + + // Upload files to the new branch + for (const file of [deployConfig, warpConfig, changesetFile]) { + await octokit.repos.createOrUpdateFileContents({ + owner: githubForkOwner, + repo: githubRepoName, + path: file.path, + message: `feat: add ${file.path}`, + content: Buffer.from(file.content).toString('base64'), + branch: branchName, + }); + } + + const githubInfo = [username && `by ${username}`, organization && `from ${organization}`] + .filter(Boolean) + .join(' '); + + // Create a PR from the fork branch to upstream main + const { data: pr } = await octokit.pulls.create({ + owner: githubUpstreamOwner, + repo: githubRepoName, + title: `feat: add ${warpRouteId} warp route deploy artifacts`, + head: `${githubForkOwner}:${branchName}`, + base: githubBaseBranch, + body: `This PR was created from the deploy app to add ${warpRouteId} warp route deploy artifacts.${ + githubInfo ? `\n\nThis config was provided ${githubInfo}.` : '' + }`, + }); + + return sendJsonResponse(res, 200, { data: { prUrl: pr.html_url }, success: true }); + } catch (err: any) { + return sendJsonResponse(res, 500, { error: err.message }); + } +} + +export function validateRequestBody( + body: unknown, +): + | ApiError + | ApiSuccess< + CreatePrBody & { deployConfigResult: WarpRouteDeployConfig; warpConfigResult: WarpCoreConfig } + > { + if (!body) return { error: 'Missing request body' }; + + const parsedBody = CreatePrBodySchema.safeParse(body); + if (!parsedBody.success) return { error: zodErrorToString(parsedBody.error) }; + + const { deployConfig, warpConfig, warpRouteId, organization, username } = parsedBody.data; + + const deployConfigResult = validateStringToZodSchema( + deployConfig.content, + WarpRouteDeployConfigSchema, + ); + if (!deployConfigResult) return { error: 'Invalid deploy config content' }; + + const warpConfigResult = validateStringToZodSchema(warpConfig.content, WarpCoreConfigSchema); + if (!warpConfigResult) return { error: 'Invalid warp config content' }; + + return { + success: true, + data: { + deployConfig, + warpConfig, + warpRouteId, + organization, + username, + deployConfigResult, + warpConfigResult, + }, + }; +} + +const MAX_TIMESTAMP_DURATION = 2 * 60 * 1000; // 2 minutes + +async function validateRequestSignature( + signatureVerification: unknown, +): Promise> { + if (!signatureVerification) return { error: 'Missing signatureVerification' }; + + const parsedSignatureBody = VerifyPrSignatureSchema.safeParse(signatureVerification); + if (!parsedSignatureBody.success) return { error: zodErrorToString(parsedSignatureBody.error) }; + + const { address, message, signature } = parsedSignatureBody.data; + + if (!isHex(address)) return { error: 'Address is not a valid EVM hex string' }; + if (!isHex(signature)) return { error: 'Signature is a not a valid EVM hex string' }; + + try { + const isValidSignature = await verifyMessage({ + address, + message, + signature, + }); + if (!isValidSignature) return { error: 'Invalid signature' }; + } catch { + return { error: 'Invalid signature' }; + } + + // validate that signature is not older than `MAX_TIMESTAMP_DURATION` + const splitMessage = message.split('timestamp:'); + if (splitMessage.length !== 2) return { error: 'Timestamp not found in message' }; + + const isoString = splitMessage[1].trim(); + const timestamp = new Date(isoString); + if (isNaN(timestamp.getTime())) { + return { error: 'Invalid timestamp format' }; + } + + const currentTimestamp = new Date(); + const diffInMs = currentTimestamp.getTime() - timestamp.getTime(); + if (diffInMs > MAX_TIMESTAMP_DURATION) return { error: 'Expired signature' }; + + return { success: true, data: { address, message, signature } }; +} + +// adapted from https://github.com/changesets/changesets/blob/main/packages/write/src/index.ts +// so that it could be used in the deploy app +function writeChangeset(description: string): DeployFile { + const id = humanId({ separator: '-', capitalize: false }); + const filename = `${id}.md`; + + const content = `--- +'@hyperlane-xyz/registry': minor +--- + +${description.trim()} +`; + + return { path: `.changeset/${filename}`, content }; +} + +function getBranchName( + warpRouteId: string, + deployConfig: WarpRouteDeployConfig, + warpConfig: WarpCoreConfig, +): ApiError | ApiSuccess { + const sortedDeployConfig = sortObjByKeys(deployConfig); + const sortedWarpCoreConfig = sortObjByKeys(sortWarpCoreConfig(warpConfig)!); + + const deployConfigBuffer = toBytes(JSON.stringify(sortedDeployConfig)); + const warpConfigBuffer = toBytes(JSON.stringify(sortedWarpCoreConfig)); + + try { + const requestBodyHash = keccak256( + encodePacked( + ['string', 'bytes', 'bytes'], + [warpRouteId, toHex(deployConfigBuffer), toHex(warpConfigBuffer)], + ), + ); + return { success: true, data: `${warpRouteId}-${requestBodyHash}` }; + } catch { + return { error: 'Failed to create branch name' }; + } +} + +async function isValidBranchName( + octokit: Octokit, + owner: string, + repo: string, + branchName: string, +) { + try { + await octokit.git.getRef({ + owner, + repo, + ref: `heads/${branchName}`, + }); + + // If no error is thrown, the branch exists + return false; + } catch (error: any) { + // branch does not exist + if (error.status === 404) return true; + + // Something else went wrong + throw new Error(`Failed to check branch existence: ${error.message}`); + } +} diff --git a/src/types/api.ts b/src/types/api.ts new file mode 100644 index 0000000..698fbc1 --- /dev/null +++ b/src/types/api.ts @@ -0,0 +1,11 @@ +export interface ApiError { + success?: false; + error: string; +} + +export interface ApiSuccess { + success: true; + data: T; +} + +export type ApiResponseBody = ApiSuccess | ApiError; diff --git a/src/types/createPr.ts b/src/types/createPr.ts new file mode 100644 index 0000000..def57ee --- /dev/null +++ b/src/types/createPr.ts @@ -0,0 +1,59 @@ +import { z } from 'zod'; +import { ApiResponseBody } from './api'; + +const githubNameRegex = /^(?!-)(?!.*--)[a-zA-Z0-9-]{1,39}(?; + +export const DeployFileSchema = z.object({ + path: z.string().min(1, 'File path is required').describe('Location for the file'), + content: z + .string() + .min(1, 'File content is required') + .describe('Stringified content of the file'), +}); + +export type DeployFile = z.infer; + +export interface CreatePrData { + prUrl: string; +} + +export const CreatePrBodySchema = z + .object({ + deployConfig: DeployFileSchema, + warpConfig: DeployFileSchema, + warpRouteId: z.string().min(1, 'Warp Route ID is required'), + }) + .merge(GitHubIdentitySchema); + +export type CreatePrBody = z.infer; + +export type CreatePrResponse = ApiResponseBody; + +export const VerifyPrSignatureSchema = z.object({ + message: z.string().min(1).describe('Message to be signed'), + address: z.string().min(1).describe('Owner of the signed message'), + signature: z.string().min(1).describe('The signed message'), +}); + +export type VerifyPrSignature = z.infer; + +export type CreatePrRequestBody = { + prBody: CreatePrBody; + signatureVerification: VerifyPrSignature; +}; diff --git a/src/utils/api.ts b/src/utils/api.ts new file mode 100644 index 0000000..f0a31e5 --- /dev/null +++ b/src/utils/api.ts @@ -0,0 +1,12 @@ +import { NextApiResponse } from 'next'; +import { ApiResponseBody } from '../types/api'; + +export function sendJsonResponse( + res: NextApiResponse>, + statusCode: number, + body?: ApiResponseBody, +) { + if (body) return res.status(statusCode).json(body); + + return res.status(statusCode).end(); +} diff --git a/src/utils/object.ts b/src/utils/object.ts new file mode 100644 index 0000000..f751b90 --- /dev/null +++ b/src/utils/object.ts @@ -0,0 +1,12 @@ +import { isObject } from '@hyperlane-xyz/utils'; + +export function sortObjByKeys>(obj: T): T { + if (!isObject(obj)) return obj; + + return Object.keys(obj) + .sort() + .reduce((acc, key) => { + acc[key] = obj[key]; + return acc; + }, {}) as T; +} diff --git a/src/utils/string.ts b/src/utils/string.ts new file mode 100644 index 0000000..2472a89 --- /dev/null +++ b/src/utils/string.ts @@ -0,0 +1,8 @@ +export function normalizeEmptyStrings>(obj: T): T { + return Object.fromEntries( + Object.entries(obj).map(([k, v]) => [ + k, + typeof v === 'string' && v.trim() === '' ? undefined : v, + ]), + ) as T; +} diff --git a/src/utils/zod.ts b/src/utils/zod.ts index 7bc3dcb..27890f5 100644 --- a/src/utils/zod.ts +++ b/src/utils/zod.ts @@ -1,7 +1,29 @@ -import { ZodError } from 'zod'; +import { tryParseJsonOrYaml } from '@hyperlane-xyz/utils'; +import { ZodError, ZodType } from 'zod'; import { fromError } from 'zod-validation-error'; export function zodErrorToString(err: ZodError) { const errorMsg = fromError(err).toString(); return errorMsg.replace(/^Validation error: /, ''); } + +export function zodErrorToFormikErrors(error: ZodError): Record { + const formikErrors: Record = {}; + + for (const issue of error.errors) { + const path = issue.path.join('.'); + formikErrors[path] = issue.message; + } + + return formikErrors; +} + +export function validateStringToZodSchema(input: string, schema: ZodType) { + const parsedInput = tryParseJsonOrYaml(input); + if (!parsedInput.success) return null; + + const parsedResult = schema.safeParse(parsedInput.data); + if (!parsedResult.success) return null; + + return parsedResult.data; +} diff --git a/yarn.lock b/yarn.lock index 5952d3e..eaf1bae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4480,6 +4480,7 @@ __metadata: "@metamask/post-message-stream": "npm:6.1.2" "@metamask/providers": "npm:10.2.1" "@next/bundle-analyzer": "npm:^15.0.2" + "@octokit/rest": "npm:^22.0.0" "@rainbow-me/rainbowkit": "npm:^2.2.0" "@sentry/nextjs": "npm:^8.38.0" "@solana/spl-token": "npm:^0.4.9" @@ -4510,6 +4511,7 @@ __metadata: ethers: "npm:^5.8.0" formik: "npm:^2.4.6" framer-motion: "npm:^11.13.1" + human-id: "npm:^4.1.1" next: "npm:^15.0.3" postcss: "npm:^8.4.47" prettier: "npm:^3.2.5" @@ -6221,6 +6223,130 @@ __metadata: languageName: node linkType: hard +"@octokit/auth-token@npm:^6.0.0": + version: 6.0.0 + resolution: "@octokit/auth-token@npm:6.0.0" + checksum: 10/a30f5c4c984964b57193de5b6f67169f74e4779fedbe716157dd3558dd9de3ca5c105cae521b7bd8ce1ae180773a2ef01afe2306ad5a329f4fd291eba2b7c7d1 + languageName: node + linkType: hard + +"@octokit/core@npm:^7.0.2": + version: 7.0.2 + resolution: "@octokit/core@npm:7.0.2" + dependencies: + "@octokit/auth-token": "npm:^6.0.0" + "@octokit/graphql": "npm:^9.0.1" + "@octokit/request": "npm:^10.0.2" + "@octokit/request-error": "npm:^7.0.0" + "@octokit/types": "npm:^14.0.0" + before-after-hook: "npm:^4.0.0" + universal-user-agent: "npm:^7.0.0" + checksum: 10/bef39511f3653b9dec239a7e8e8bdb4f17eb43f95d4f69b14eda44a4e2d22ab0239e2a4b0a445f474afd85169928b60420d0be5b316165505851b8a69b3ab596 + languageName: node + linkType: hard + +"@octokit/endpoint@npm:^11.0.0": + version: 11.0.0 + resolution: "@octokit/endpoint@npm:11.0.0" + dependencies: + "@octokit/types": "npm:^14.0.0" + universal-user-agent: "npm:^7.0.2" + checksum: 10/d7583a44f8560343b0fbd191aa9d2653e563cdd78f550c83cf7440a66edbe47bab6d0d6c52ae271bcbd35703356154ed590b22881aa8ee690f0d8f249ce6bde0 + languageName: node + linkType: hard + +"@octokit/graphql@npm:^9.0.1": + version: 9.0.1 + resolution: "@octokit/graphql@npm:9.0.1" + dependencies: + "@octokit/request": "npm:^10.0.2" + "@octokit/types": "npm:^14.0.0" + universal-user-agent: "npm:^7.0.0" + checksum: 10/02d7ea4e2c17a4d4b7311150d0326318c756aff6cf955d9ba443a4bf26b32784832060379fc74f4537657415b262c10adb7f4a1655e15b143d19c2f099b87f16 + languageName: node + linkType: hard + +"@octokit/openapi-types@npm:^25.1.0": + version: 25.1.0 + resolution: "@octokit/openapi-types@npm:25.1.0" + checksum: 10/91989a4cec12250e6b3226e9aa931c05c27d46a946725d01e6a831af3890f157210a7032f07641a156c608cc6bf6cf55a28f07179910b644966358d6d559dec6 + languageName: node + linkType: hard + +"@octokit/plugin-paginate-rest@npm:^13.0.1": + version: 13.1.0 + resolution: "@octokit/plugin-paginate-rest@npm:13.1.0" + dependencies: + "@octokit/types": "npm:^14.1.0" + peerDependencies: + "@octokit/core": ">=6" + checksum: 10/1e34afb0fa619462bbfc1dda65774df25762c05f61f4e2967124ce92e765e08d1bd3f7534e681038128b686e70aea19a41922835f9028ca39c49ecc82e82ed0b + languageName: node + linkType: hard + +"@octokit/plugin-request-log@npm:^6.0.0": + version: 6.0.0 + resolution: "@octokit/plugin-request-log@npm:6.0.0" + peerDependencies: + "@octokit/core": ">=6" + checksum: 10/8a79973b1429bfead9113c4117f418aaef5ff368795daded3415ba14623d97d5fc08d1e822dbd566ecc9f041119e1a48a11853a9c48d9eb1caa62baa79c17f83 + languageName: node + linkType: hard + +"@octokit/plugin-rest-endpoint-methods@npm:^16.0.0": + version: 16.0.0 + resolution: "@octokit/plugin-rest-endpoint-methods@npm:16.0.0" + dependencies: + "@octokit/types": "npm:^14.1.0" + peerDependencies: + "@octokit/core": ">=6" + checksum: 10/17a299d2cda214fbc3a9d741746abb181845375b8094d1086e3810ec3796547754fa5a2d83aee410821d0d67c1f168343b38e6573813552482afdb6ebbb08189 + languageName: node + linkType: hard + +"@octokit/request-error@npm:^7.0.0": + version: 7.0.0 + resolution: "@octokit/request-error@npm:7.0.0" + dependencies: + "@octokit/types": "npm:^14.0.0" + checksum: 10/c4370d2c31f599c1f366c480d5a02bc93442e5a0e151ec5caf0d5a5b0f0f91b50ecedc945aa6ea61b4c9ed1e89153dc7727daf4317680d33e916f829da7d141b + languageName: node + linkType: hard + +"@octokit/request@npm:^10.0.2": + version: 10.0.2 + resolution: "@octokit/request@npm:10.0.2" + dependencies: + "@octokit/endpoint": "npm:^11.0.0" + "@octokit/request-error": "npm:^7.0.0" + "@octokit/types": "npm:^14.0.0" + fast-content-type-parse: "npm:^3.0.0" + universal-user-agent: "npm:^7.0.2" + checksum: 10/eaddfd49787e8caad664a80c7c665d69bd303f90b5e6be822d571b684a4cd42bdfee29119f838fdfaed2946bc09f38219e1d7a0923388436bff0bfdd0202acca + languageName: node + linkType: hard + +"@octokit/rest@npm:^22.0.0": + version: 22.0.0 + resolution: "@octokit/rest@npm:22.0.0" + dependencies: + "@octokit/core": "npm:^7.0.2" + "@octokit/plugin-paginate-rest": "npm:^13.0.1" + "@octokit/plugin-request-log": "npm:^6.0.0" + "@octokit/plugin-rest-endpoint-methods": "npm:^16.0.0" + checksum: 10/d2b80fefd6aed307cb728980cb1d94cb484d48fabf0055198664287a7fb50544d312b005e4fb8dec2a6e97a153ec0ad7654d62f59898e1077a4cfba64e6d5c3e + languageName: node + linkType: hard + +"@octokit/types@npm:^14.0.0, @octokit/types@npm:^14.1.0": + version: 14.1.0 + resolution: "@octokit/types@npm:14.1.0" + dependencies: + "@octokit/openapi-types": "npm:^25.1.0" + checksum: 10/ea5549ca6176bd1184427141a77bca88c68f07d252d3ea1db7f9b58ec16b66391218a75a99927efb1e36a2cb00e8ed37a79b71fdc95a1117a9982516156fd997 + languageName: node + linkType: hard + "@offchainlabs/upgrade-executor@npm:1.1.0-beta.0": version: 1.1.0-beta.0 resolution: "@offchainlabs/upgrade-executor@npm:1.1.0-beta.0" @@ -13990,6 +14116,13 @@ __metadata: languageName: node linkType: hard +"before-after-hook@npm:^4.0.0": + version: 4.0.0 + resolution: "before-after-hook@npm:4.0.0" + checksum: 10/9fd52bc0c3cca0fb115e04dacbeeaacff38fa23e1af725d62392254c31ef433b15da60efcba61552e44d64e26f25ea259f72dba005115924389e88d2fd56e19f + languageName: node + linkType: hard + "better-path-resolve@npm:1.0.0": version: 1.0.0 resolution: "better-path-resolve@npm:1.0.0" @@ -17129,6 +17262,13 @@ __metadata: languageName: node linkType: hard +"fast-content-type-parse@npm:^3.0.0": + version: 3.0.0 + resolution: "fast-content-type-parse@npm:3.0.0" + checksum: 10/8616a8aa6c9b4f8f4f3c90eaa4e7bfc2240cfa6f41f0eef5b5aa2b2c8b38bd9ad435f1488b6d817ffd725c54651e2777b882ae9dd59366e71e7896f1ec11d473 + languageName: node + linkType: hard + "fast-deep-equal@npm:^2.0.1": version: 2.0.1 resolution: "fast-deep-equal@npm:2.0.1" @@ -18338,6 +18478,15 @@ __metadata: languageName: node linkType: hard +"human-id@npm:^4.1.1": + version: 4.1.1 + resolution: "human-id@npm:4.1.1" + bin: + human-id: dist/cli.js + checksum: 10/84fef1edd470fc155a34161107beed8baf77bafd20bf515c3fadfbce3690ecc9aa0bacf3fcf4cf9add3c274772ead3ef64aa6531374538ffebe8129fccfb0015 + languageName: node + linkType: hard + "human-signals@npm:^2.1.0": version: 2.1.0 resolution: "human-signals@npm:2.1.0" @@ -25013,6 +25162,13 @@ __metadata: languageName: node linkType: hard +"universal-user-agent@npm:^7.0.0, universal-user-agent@npm:^7.0.2": + version: 7.0.3 + resolution: "universal-user-agent@npm:7.0.3" + checksum: 10/c497e85f8b11eb8fa4dce584d7a39cc98710164959f494cafc3c269b51abb20fff269951838efd7424d15f6b3d001507f3cb8b52bb5676fdb642019dfd17e63e + languageName: node + linkType: hard + "universalify@npm:^0.1.0": version: 0.1.2 resolution: "universalify@npm:0.1.2"