From ddc3643eb3d37acbb0b362ee990138d8bbe5f90e Mon Sep 17 00:00:00 2001 From: Xaroz Date: Wed, 18 Jun 2025 13:26:23 -0400 Subject: [PATCH 01/27] feat: initial create PR setup --- package.json | 1 + src/consts/config.server.ts | 40 +++++++++ src/features/deployment/hooks.ts | 2 +- src/flows/LandingCard.tsx | 40 +++++++++ src/pages/api/create-pr.ts | 67 ++++++++++++++ yarn.lock | 146 +++++++++++++++++++++++++++++++ 6 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 src/consts/config.server.ts create mode 100644 src/pages/api/create-pr.ts diff --git a/package.json b/package.json index 047c8a9..41509da 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", diff --git a/src/consts/config.server.ts b/src/consts/config.server.ts new file mode 100644 index 0000000..0d034c8 --- /dev/null +++ b/src/consts/config.server.ts @@ -0,0 +1,40 @@ +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'), +}); + +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 || ''; + +export const serverConfig: ServerConfig = Object.freeze({ + githubForkOwner, + githubRepoName, + githubUpstreamOwner, + githubBaseBranch, + githubToken, +}); diff --git a/src/features/deployment/hooks.ts b/src/features/deployment/hooks.ts index 7988b09..a15a221 100644 --- a/src/features/deployment/hooks.ts +++ b/src/features/deployment/hooks.ts @@ -40,7 +40,7 @@ export function useDeploymentHistory() { export function useLatestDeployment() { const { deployments, currentIndex } = useDeploymentHistory(); - return deployments[currentIndex]; + return deployments[2]; } export function usePastDeploymentChains() { diff --git a/src/flows/LandingCard.tsx b/src/flows/LandingCard.tsx index 0a17b3b..85814b1 100644 --- a/src/flows/LandingCard.tsx +++ b/src/flows/LandingCard.tsx @@ -1,7 +1,10 @@ import Image from 'next/image'; +import { useState } from 'react'; +import { stringify } from 'yaml'; import { SolidButton } from '../components/buttons/SolidButton'; import { AUnderline } from '../components/text/A'; import { links } from '../consts/links'; +import { useLatestDeployment } from '../features/deployment/hooks'; import BlueWave from '../images/illustrations/blue-wave.svg'; import SpaceCraft from '../images/illustrations/spacecraft.webp'; import { CardPage } from './CardPage'; @@ -9,6 +12,40 @@ import { useCardNav } from './hooks'; export function LandingPage() { const { setPage } = useCardNav(); + const [status, setStatus] = useState(''); + const [loading, setLoading] = useState(false); + const { result } = useLatestDeployment(); + + const createPr = async () => { + setLoading(true); + setStatus(''); + const yamlConfig = stringify(result?.result, { sortMapEntries: true }); + + const files = [ + { + path: 'docs/example.yaml', + content: yamlConfig, + }, + ]; + + try { + const res = await fetch('/api/create-pr', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ files }), + }); + + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Unknown error'); + + setStatus(`✅ PR opened: ${data.prUrl}`); + } catch (err: any) { + setStatus(`❌ Error: ${err.message}`); + console.log('error', err); + } finally { + setLoading(false); + } + }; return (
@@ -45,6 +82,9 @@ export function LandingPage() { > Deploy + + Create +
); diff --git a/src/pages/api/create-pr.ts b/src/pages/api/create-pr.ts new file mode 100644 index 0000000..f70b4ec --- /dev/null +++ b/src/pages/api/create-pr.ts @@ -0,0 +1,67 @@ +// pages/api/open-pr.ts +import { Octokit } from '@octokit/rest'; +import type { NextApiRequest, NextApiResponse } from 'next'; +import { serverConfig, ServerConfigSchema } from '../../consts/config.server'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const serverConfigParseResult = ServerConfigSchema.safeParse(serverConfig); + + if (!serverConfigParseResult.success) + return res.status(500).json({ + error: 'Missing Github configurations, check your environment variables', + }); + + const { githubBaseBranch, githubForkOwner, githubRepoName, githubToken, githubUpstreamOwner } = + serverConfigParseResult.data; + + const octokit = new Octokit({ auth: githubToken }); + + const { files } = req.body as { + files: Array<{ path: string; content: string }>; + }; + try { + // Step 1: 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; + const newBranch = `upload-${Date.now()}`; + + // Step 2: Create new branch + await octokit.git.createRef({ + owner: githubForkOwner, + repo: githubRepoName, + ref: `refs/heads/${newBranch}`, + sha: latestCommitSha, + }); + + // Step 3: Upload files to the new branch + for (const file of files) { + await octokit.repos.createOrUpdateFileContents({ + owner: githubForkOwner, + repo: githubRepoName, + path: file.path, + message: `Add ${file.path}`, + content: Buffer.from(file.content).toString('base64'), + branch: newBranch, + }); + } + + // Step 4: Create a PR from the fork branch to upstream main + const { data: pr } = await octokit.pulls.create({ + owner: githubUpstreamOwner, + repo: githubRepoName, + title: `Auto PR: ${newBranch}`, + head: `${githubForkOwner}:${newBranch}`, + base: githubBaseBranch, + body: `This PR was opened by the dummy bot.`, + }); + + return res.status(200).json({ success: true, prUrl: pr.html_url }); + } catch (err: any) { + return res.status(500).json({ error: err.message }); + } +} diff --git a/yarn.lock b/yarn.lock index 5952d3e..c071f6d 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" @@ -6221,6 +6222,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 +14115,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 +17261,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" @@ -25013,6 +25152,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" From 5631099db8658c2629ccaf2e7a8a4aec2fd51d6f Mon Sep 17 00:00:00 2001 From: Xaroz Date: Wed, 18 Jun 2025 18:21:21 -0400 Subject: [PATCH 02/27] chore: move to useMutation --- src/features/deployment/utils.ts | 16 +++++ .../deployment/warp/WarpDeploymentSuccess.tsx | 15 +++-- src/features/deployment/warp/github.ts | 60 +++++++++++++++++++ src/flows/LandingCard.tsx | 37 ------------ 4 files changed, 85 insertions(+), 43 deletions(-) create mode 100644 src/features/deployment/warp/github.ts diff --git a/src/features/deployment/utils.ts b/src/features/deployment/utils.ts index 98dc5bc..40cab39 100644 --- a/src/features/deployment/utils.ts +++ b/src/features/deployment/utils.ts @@ -1,3 +1,5 @@ +import { WarpCoreConfig, WarpRouteDeployConfig } from '@hyperlane-xyz/sdk'; +import { objKeys } from '@hyperlane-xyz/utils'; import { tryClipboardSet } from '@hyperlane-xyz/widgets'; import { toast } from 'react-toastify'; import { stringify } from 'yaml'; @@ -23,3 +25,17 @@ export function downloadYamlFile(config: unknown | undefined, filename: string) document.body.removeChild(a); URL.revokeObjectURL(url); } + +export function getDeployConfigFilename(config: WarpRouteDeployConfig) { + if (!config) return 'deploy.yaml'; + const chains = objKeys(config).sort(); + + return `${chains.join('-')}-deploy.yaml`; +} + +export function getWarpConfigFilename(config: WarpCoreConfig) { + if (!config) return 'config.yaml'; + + const chains = config.tokens.map((token) => token.chainName).sort(); + return `${chains.join('-')}-config.yaml`; +} diff --git a/src/features/deployment/warp/WarpDeploymentSuccess.tsx b/src/features/deployment/warp/WarpDeploymentSuccess.tsx index 8ac90e2..bae3936 100644 --- a/src/features/deployment/warp/WarpDeploymentSuccess.tsx +++ b/src/features/deployment/warp/WarpDeploymentSuccess.tsx @@ -1,5 +1,5 @@ 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'; @@ -12,7 +12,12 @@ import { Color } from '../../../styles/Color'; import { CoinGeckoConfirmationModal } from '../CoinGeckoConfirmationModal'; import { useDeploymentHistory, useLatestDeployment, useWarpDeploymentConfig } from '../hooks'; import { DeploymentType } from '../types'; -import { downloadYamlFile, tryCopyConfig } from '../utils'; +import { + downloadYamlFile, + getDeployConfigFilename, + getWarpConfigFilename, + tryCopyConfig, +} from '../utils'; import { CoinGeckoFormValues } from './types'; export function WarpDeploymentSuccess() { @@ -31,8 +36,7 @@ export function WarpDeploymentSuccess() { return; const deployConfigResult = deploymentContext.config.config; - const chains = objKeys(deployConfigResult).sort(); - const filename = `${chains.join('-')}-deploy.yaml`; + const filename = getDeployConfigFilename(deployConfigResult); downloadYamlFile(deployConfigResult, filename); }; @@ -41,8 +45,7 @@ export function WarpDeploymentSuccess() { return; const warpConfigResult = deploymentContext.result.result; - const chains = warpConfigResult.tokens.map((token) => token.chainName).sort(); - const filename = `${chains.join('-')}-config.yaml`; + const filename = getWarpConfigFilename(warpConfigResult); downloadYamlFile(warpConfigResult, filename); }; diff --git a/src/features/deployment/warp/github.ts b/src/features/deployment/warp/github.ts new file mode 100644 index 0000000..3f4f655 --- /dev/null +++ b/src/features/deployment/warp/github.ts @@ -0,0 +1,60 @@ +import { WarpCoreConfig, WarpRouteDeployConfig } from '@hyperlane-xyz/sdk'; +import { useMutation } from '@tanstack/react-query'; +import { stringify } from 'yaml'; +import { useLatestDeployment } from '../hooks'; +import { DeploymentType } from '../types'; +import { getDeployConfigFilename, getWarpConfigFilename } from '../utils'; + +const warpRoutesPath = 'deployments/warp_routes'; + +export function useCreateWarpRoutePR() { + const { config, result } = useLatestDeployment(); + + const { isPending, mutateAsync, error } = useMutation({ + mutationKey: ['createWarpRoutePr', config, result], + mutationFn: () => { + if (!config.config || config.type !== DeploymentType.Warp) return Promise.resolve(null); + if (!result?.result || result.type !== DeploymentType.Warp) return Promise.resolve(null); + + return createWarpRoutePR(config.config, result.result); + }, + retry: false, + }); + + return { + mutateAsync, + error, + isPending, + }; +} + +async function createWarpRoutePR(deployConfig: WarpRouteDeployConfig, warpConfig: WarpCoreConfig) { + const deployConfigFilename = getDeployConfigFilename(deployConfig); + const warpConfigFilename = getWarpConfigFilename(warpConfig); + const symbol = warpConfig.tokens[0].symbol; + + const yamlDeployConfig = stringify(deployConfig, { sortMapEntries: true }); + const yamlWarpConfig = stringify(warpConfig, { sortMapEntries: true }); + + const basePath = `${warpRoutesPath}/${symbol}`; + const files: DeployFile[] = [ + { content: yamlDeployConfig, path: `${basePath}/${deployConfigFilename}` }, + { content: yamlWarpConfig, path: `${basePath}/${warpConfigFilename}` }, + ]; + + const res = await fetch('/api/create-pr', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ files }), + }); + + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Unknown error'); + + return data; +} + +type DeployFile = { + path: string; + content: string; +}; diff --git a/src/flows/LandingCard.tsx b/src/flows/LandingCard.tsx index 85814b1..21b0a32 100644 --- a/src/flows/LandingCard.tsx +++ b/src/flows/LandingCard.tsx @@ -1,10 +1,7 @@ import Image from 'next/image'; -import { useState } from 'react'; -import { stringify } from 'yaml'; import { SolidButton } from '../components/buttons/SolidButton'; import { AUnderline } from '../components/text/A'; import { links } from '../consts/links'; -import { useLatestDeployment } from '../features/deployment/hooks'; import BlueWave from '../images/illustrations/blue-wave.svg'; import SpaceCraft from '../images/illustrations/spacecraft.webp'; import { CardPage } from './CardPage'; @@ -12,40 +9,6 @@ import { useCardNav } from './hooks'; export function LandingPage() { const { setPage } = useCardNav(); - const [status, setStatus] = useState(''); - const [loading, setLoading] = useState(false); - const { result } = useLatestDeployment(); - - const createPr = async () => { - setLoading(true); - setStatus(''); - const yamlConfig = stringify(result?.result, { sortMapEntries: true }); - - const files = [ - { - path: 'docs/example.yaml', - content: yamlConfig, - }, - ]; - - try { - const res = await fetch('/api/create-pr', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ files }), - }); - - const data = await res.json(); - if (!res.ok) throw new Error(data.error || 'Unknown error'); - - setStatus(`✅ PR opened: ${data.prUrl}`); - } catch (err: any) { - setStatus(`❌ Error: ${err.message}`); - console.log('error', err); - } finally { - setLoading(false); - } - }; return (
From a0cc2d49d65da875b7b0818dc6cf9523b5eac3fe Mon Sep 17 00:00:00 2001 From: Xaroz Date: Wed, 18 Jun 2025 18:21:56 -0400 Subject: [PATCH 03/27] chore: clean up --- src/flows/LandingCard.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/flows/LandingCard.tsx b/src/flows/LandingCard.tsx index 21b0a32..6515539 100644 --- a/src/flows/LandingCard.tsx +++ b/src/flows/LandingCard.tsx @@ -45,9 +45,9 @@ export function LandingPage() { > Deploy - + {/* Create - + */}
); From a66e9f430f1b7ed035fa72dbeaaf80c83f568325 Mon Sep 17 00:00:00 2001 From: Xaroz Date: Fri, 20 Jun 2025 10:44:56 -0400 Subject: [PATCH 04/27] chore: improve typing and symbol check --- src/features/deployment/warp/github.ts | 16 ++++++++++++++-- src/flows/LandingCard.tsx | 11 +++++++++-- src/pages/api/create-pr.ts | 6 +++++- src/types/api.ts | 9 +++++++++ 4 files changed, 37 insertions(+), 5 deletions(-) create mode 100644 src/types/api.ts diff --git a/src/features/deployment/warp/github.ts b/src/features/deployment/warp/github.ts index 3f4f655..cb015d7 100644 --- a/src/features/deployment/warp/github.ts +++ b/src/features/deployment/warp/github.ts @@ -1,9 +1,12 @@ import { WarpCoreConfig, WarpRouteDeployConfig } from '@hyperlane-xyz/sdk'; import { useMutation } from '@tanstack/react-query'; import { stringify } from 'yaml'; +import { useToastError } from '../../../components/toast/useToastError'; +import { CreatePrResponse } from '../../../types/api'; import { useLatestDeployment } from '../hooks'; import { DeploymentType } from '../types'; import { getDeployConfigFilename, getWarpConfigFilename } from '../utils'; +import { isSyntheticTokenType } from './utils'; const warpRoutesPath = 'deployments/warp_routes'; @@ -21,6 +24,8 @@ export function useCreateWarpRoutePR() { retry: false, }); + useToastError(error, 'Error creating PR for Github'); + return { mutateAsync, error, @@ -28,10 +33,17 @@ export function useCreateWarpRoutePR() { }; } -async function createWarpRoutePR(deployConfig: WarpRouteDeployConfig, warpConfig: WarpCoreConfig) { +async function createWarpRoutePR( + deployConfig: WarpRouteDeployConfig, + warpConfig: WarpCoreConfig, +): Promise { const deployConfigFilename = getDeployConfigFilename(deployConfig); const warpConfigFilename = getWarpConfigFilename(warpConfig); - const symbol = warpConfig.tokens[0].symbol; + const firstNonSythetic = Object.values(deployConfig).find((c) => !isSyntheticTokenType(c.type)); + + if (!firstNonSythetic) throw new Error('Token types cannot all be synthetic'); + + const symbol = firstNonSythetic?.symbol; const yamlDeployConfig = stringify(deployConfig, { sortMapEntries: true }); const yamlWarpConfig = stringify(warpConfig, { sortMapEntries: true }); diff --git a/src/flows/LandingCard.tsx b/src/flows/LandingCard.tsx index 6515539..812b446 100644 --- a/src/flows/LandingCard.tsx +++ b/src/flows/LandingCard.tsx @@ -2,6 +2,7 @@ import Image from 'next/image'; import { SolidButton } from '../components/buttons/SolidButton'; import { AUnderline } from '../components/text/A'; import { links } from '../consts/links'; +import { useCreateWarpRoutePR } from '../features/deployment/warp/github'; import BlueWave from '../images/illustrations/blue-wave.svg'; import SpaceCraft from '../images/illustrations/spacecraft.webp'; import { CardPage } from './CardPage'; @@ -9,6 +10,12 @@ import { useCardNav } from './hooks'; export function LandingPage() { const { setPage } = useCardNav(); + const { mutateAsync, isPending } = useCreateWarpRoutePR(); + + const handleCreatePr = async () => { + const data = await mutateAsync(); + if (data) console.log('data', data.prUrl); + }; return (
@@ -45,9 +52,9 @@ export function LandingPage() { > Deploy - {/* + Create - */} +
); diff --git a/src/pages/api/create-pr.ts b/src/pages/api/create-pr.ts index f70b4ec..5edd788 100644 --- a/src/pages/api/create-pr.ts +++ b/src/pages/api/create-pr.ts @@ -2,8 +2,12 @@ import { Octokit } from '@octokit/rest'; import type { NextApiRequest, NextApiResponse } from 'next'; import { serverConfig, ServerConfigSchema } from '../../consts/config.server'; +import { CreatePrError, CreatePrResponse } from '../../types/api'; -export default async function handler(req: NextApiRequest, res: NextApiResponse) { +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { const serverConfigParseResult = ServerConfigSchema.safeParse(serverConfig); if (!serverConfigParseResult.success) diff --git a/src/types/api.ts b/src/types/api.ts new file mode 100644 index 0000000..8402d50 --- /dev/null +++ b/src/types/api.ts @@ -0,0 +1,9 @@ +export interface CreatePrResponse { + success: true; + prUrl: string; +} + +export interface CreatePrError { + success?: false; + error: string; +} From 135e379c77e6e1504beab4b6edb773e83989c5ee Mon Sep 17 00:00:00 2001 From: Xaroz Date: Fri, 20 Jun 2025 12:03:42 -0400 Subject: [PATCH 05/27] chore: validate configs, clean up pr descriptions --- src/features/deployment/warp/github.ts | 23 +++++++++--------- src/pages/api/create-pr.ts | 32 +++++++++++++++++--------- src/types/api.ts | 10 ++++++++ src/utils/zod.ts | 13 ++++++++++- 4 files changed, 54 insertions(+), 24 deletions(-) diff --git a/src/features/deployment/warp/github.ts b/src/features/deployment/warp/github.ts index cb015d7..8b58fd7 100644 --- a/src/features/deployment/warp/github.ts +++ b/src/features/deployment/warp/github.ts @@ -2,7 +2,7 @@ import { WarpCoreConfig, WarpRouteDeployConfig } from '@hyperlane-xyz/sdk'; import { useMutation } from '@tanstack/react-query'; import { stringify } from 'yaml'; import { useToastError } from '../../../components/toast/useToastError'; -import { CreatePrResponse } from '../../../types/api'; +import { CreatePrBody, CreatePrResponse } from '../../../types/api'; import { useLatestDeployment } from '../hooks'; import { DeploymentType } from '../types'; import { getDeployConfigFilename, getWarpConfigFilename } from '../utils'; @@ -43,21 +43,25 @@ async function createWarpRoutePR( if (!firstNonSythetic) throw new Error('Token types cannot all be synthetic'); - const symbol = firstNonSythetic?.symbol; + const symbol = firstNonSythetic.symbol; const yamlDeployConfig = stringify(deployConfig, { sortMapEntries: true }); const yamlWarpConfig = stringify(warpConfig, { sortMapEntries: true }); const basePath = `${warpRoutesPath}/${symbol}`; - const files: DeployFile[] = [ - { content: yamlDeployConfig, path: `${basePath}/${deployConfigFilename}` }, - { content: yamlWarpConfig, path: `${basePath}/${warpConfigFilename}` }, - ]; + const files: CreatePrBody = { + deployConfig: { content: yamlDeployConfig, path: `${basePath}/${deployConfigFilename}` }, + warpConfig: { content: yamlWarpConfig, path: `${basePath}/${warpConfigFilename}` }, + }; const res = await fetch('/api/create-pr', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ files }), + body: JSON.stringify({ + deployConfig: files.deployConfig, + warpConfig: files.warpConfig, + symbol, + }), }); const data = await res.json(); @@ -65,8 +69,3 @@ async function createWarpRoutePR( return data; } - -type DeployFile = { - path: string; - content: string; -}; diff --git a/src/pages/api/create-pr.ts b/src/pages/api/create-pr.ts index 5edd788..26c5354 100644 --- a/src/pages/api/create-pr.ts +++ b/src/pages/api/create-pr.ts @@ -1,15 +1,16 @@ // pages/api/open-pr.ts +import { WarpCoreConfigSchema, WarpRouteDeployConfigSchema } from '@hyperlane-xyz/sdk'; import { Octokit } from '@octokit/rest'; import type { NextApiRequest, NextApiResponse } from 'next'; import { serverConfig, ServerConfigSchema } from '../../consts/config.server'; -import { CreatePrError, CreatePrResponse } from '../../types/api'; +import { CreatePrBody, CreatePrError, CreatePrResponse } from '../../types/api'; +import { validateStringToZodSchema } from '../../utils/zod'; export default async function handler( req: NextApiRequest, res: NextApiResponse, ) { const serverConfigParseResult = ServerConfigSchema.safeParse(serverConfig); - if (!serverConfigParseResult.success) return res.status(500).json({ error: 'Missing Github configurations, check your environment variables', @@ -17,12 +18,21 @@ export default async function handler( const { githubBaseBranch, githubForkOwner, githubRepoName, githubToken, githubUpstreamOwner } = serverConfigParseResult.data; - const octokit = new Octokit({ auth: githubToken }); - const { files } = req.body as { - files: Array<{ path: string; content: string }>; - }; + const { deployConfig, warpConfig, symbol } = req.body as CreatePrBody & { symbol: string }; + if (!deployConfig || !warpConfig || !symbol) + return res.status(400).json({ error: 'Missing config files to create PR' }); + + const deployConfigResult = validateStringToZodSchema( + deployConfig.content, + WarpRouteDeployConfigSchema, + ); + if (!deployConfigResult) return res.status(400).json({ error: 'Invalid deploy config' }); + + const warpConfigResult = validateStringToZodSchema(warpConfig.content, WarpCoreConfigSchema); + if (!warpConfigResult) return res.status(400).json({ error: 'Invalid warp config' }); + try { // Step 1: Get latest SHA of base branch in fork const { data: refData } = await octokit.git.getRef({ @@ -32,7 +42,7 @@ export default async function handler( }); const latestCommitSha = refData.object.sha; - const newBranch = `upload-${Date.now()}`; + const newBranch = `${Date.now()}-${symbol}-config`; // Step 2: Create new branch await octokit.git.createRef({ @@ -43,12 +53,12 @@ export default async function handler( }); // Step 3: Upload files to the new branch - for (const file of files) { + for (const file of [deployConfig, warpConfig]) { await octokit.repos.createOrUpdateFileContents({ owner: githubForkOwner, repo: githubRepoName, path: file.path, - message: `Add ${file.path}`, + message: `feat: add ${file.path}`, content: Buffer.from(file.content).toString('base64'), branch: newBranch, }); @@ -58,10 +68,10 @@ export default async function handler( const { data: pr } = await octokit.pulls.create({ owner: githubUpstreamOwner, repo: githubRepoName, - title: `Auto PR: ${newBranch}`, + title: `feat: add ${symbol} deploy artifacts`, head: `${githubForkOwner}:${newBranch}`, base: githubBaseBranch, - body: `This PR was opened by the dummy bot.`, + body: `This PR was created from the deploy app to add ${symbol} deploy artifacts`, }); return res.status(200).json({ success: true, prUrl: pr.html_url }); diff --git a/src/types/api.ts b/src/types/api.ts index 8402d50..839e989 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -7,3 +7,13 @@ export interface CreatePrError { success?: false; error: string; } + +export interface DeployFile { + path: string; + content: string; +} + +export interface CreatePrBody { + deployConfig: DeployFile; + warpConfig: DeployFile; +} diff --git a/src/utils/zod.ts b/src/utils/zod.ts index 7bc3dcb..d5dd122 100644 --- a/src/utils/zod.ts +++ b/src/utils/zod.ts @@ -1,7 +1,18 @@ -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 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; +} From 72bee3205e3824a7cc97085f12b1ec775c046b15 Mon Sep 17 00:00:00 2001 From: Xaroz Date: Fri, 20 Jun 2025 13:32:17 -0400 Subject: [PATCH 06/27] chore: include modal for adding PR, fix bug with modal close --- .../deployment/CoinGeckoConfirmationModal.tsx | 2 + .../deployment/CreateRegistryPrModal.tsx | 94 +++++++++++++++ src/features/deployment/{warp => }/github.ts | 18 +-- .../deployment/warp/WarpDeploymentSuccess.tsx | 1 + src/flows/LandingCard.tsx | 109 +++++++++++------- src/pages/api/create-pr.ts | 2 +- 6 files changed, 173 insertions(+), 53 deletions(-) create mode 100644 src/features/deployment/CreateRegistryPrModal.tsx rename src/features/deployment/{warp => }/github.ts (82%) 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..9261ea2 --- /dev/null +++ b/src/features/deployment/CreateRegistryPrModal.tsx @@ -0,0 +1,94 @@ +import { Modal } from '@hyperlane-xyz/widgets'; +import { SolidButton } from '../../components/buttons/SolidButton'; +import { A } from '../../components/text/A'; +import { H2 } from '../../components/text/Headers'; +import { links } from '../../consts/links'; +import { CreatePrResponse } from '../../types/api'; + +export function CreateRegistryPrModal({ + isOpen, + onCancel, + onConfirm, + confirmDisabled, + disabled, + data, +}: { + isOpen: boolean; + disabled: boolean; + confirmDisabled: boolean; + onCancel: () => void; + onConfirm: () => void; + data: 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! +

+ + {data && data.success && ( +
+ This is your the link to your PR + + {data.prUrl} + +
+ )} + + +
+ ); +} + +function ButtonsSection({ + onCancel, + onConfirm, + confirmDisabled, + disabled, +}: { + onCancel: () => void; + onConfirm: () => void; + confirmDisabled: boolean; + disabled: boolean; +}) { + return ( +
+ + Close + + + Confirm + +
+ ); +} diff --git a/src/features/deployment/warp/github.ts b/src/features/deployment/github.ts similarity index 82% rename from src/features/deployment/warp/github.ts rename to src/features/deployment/github.ts index 8b58fd7..aed76b4 100644 --- a/src/features/deployment/warp/github.ts +++ b/src/features/deployment/github.ts @@ -1,19 +1,19 @@ import { WarpCoreConfig, WarpRouteDeployConfig } from '@hyperlane-xyz/sdk'; import { useMutation } from '@tanstack/react-query'; import { stringify } from 'yaml'; -import { useToastError } from '../../../components/toast/useToastError'; -import { CreatePrBody, CreatePrResponse } from '../../../types/api'; -import { useLatestDeployment } from '../hooks'; -import { DeploymentType } from '../types'; -import { getDeployConfigFilename, getWarpConfigFilename } from '../utils'; -import { isSyntheticTokenType } from './utils'; +import { useToastError } from '../../components/toast/useToastError'; +import { CreatePrBody, CreatePrResponse } from '../../types/api'; +import { useLatestDeployment } from './hooks'; +import { DeploymentType } from './types'; +import { getDeployConfigFilename, getWarpConfigFilename } from './utils'; +import { isSyntheticTokenType } from './warp/utils'; const warpRoutesPath = 'deployments/warp_routes'; -export function useCreateWarpRoutePR() { +export function useCreateWarpRoutePR(onSuccess: () => void) { const { config, result } = useLatestDeployment(); - const { isPending, mutateAsync, error } = useMutation({ + const { isPending, mutateAsync, error, data } = useMutation({ mutationKey: ['createWarpRoutePr', config, result], mutationFn: () => { if (!config.config || config.type !== DeploymentType.Warp) return Promise.resolve(null); @@ -22,6 +22,7 @@ export function useCreateWarpRoutePR() { return createWarpRoutePR(config.config, result.result); }, retry: false, + onSuccess, }); useToastError(error, 'Error creating PR for Github'); @@ -30,6 +31,7 @@ export function useCreateWarpRoutePR() { mutateAsync, error, isPending, + data, }; } diff --git a/src/features/deployment/warp/WarpDeploymentSuccess.tsx b/src/features/deployment/warp/WarpDeploymentSuccess.tsx index bae3936..4fd74fb 100644 --- a/src/features/deployment/warp/WarpDeploymentSuccess.tsx +++ b/src/features/deployment/warp/WarpDeploymentSuccess.tsx @@ -162,6 +162,7 @@ export function WarpDeploymentSuccess() { isOpen={isOpen} onCancel={onCancelCoinGeckoId} onSubmit={onConfirmCoinGeckoId} + close={close} /> ); diff --git a/src/flows/LandingCard.tsx b/src/flows/LandingCard.tsx index 812b446..be1dec9 100644 --- a/src/flows/LandingCard.tsx +++ b/src/flows/LandingCard.tsx @@ -1,8 +1,11 @@ +import { useModal } from '@hyperlane-xyz/widgets'; import Image from 'next/image'; +import { useCallback, useState } from 'react'; import { SolidButton } from '../components/buttons/SolidButton'; import { AUnderline } from '../components/text/A'; import { links } from '../consts/links'; -import { useCreateWarpRoutePR } from '../features/deployment/warp/github'; +import { CreateRegistryPrModal } from '../features/deployment/CreateRegistryPrModal'; +import { useCreateWarpRoutePR } from '../features/deployment/github'; import BlueWave from '../images/illustrations/blue-wave.svg'; import SpaceCraft from '../images/illustrations/spacecraft.webp'; import { CardPage } from './CardPage'; @@ -10,52 +13,70 @@ import { useCardNav } from './hooks'; export function LandingPage() { const { setPage } = useCardNav(); - const { mutateAsync, isPending } = useCreateWarpRoutePR(); + const { close, isOpen, open } = useModal(); + const [hasSubmittedPr, setHasSubmittedPr] = useState(false); - const handleCreatePr = async () => { - const data = await mutateAsync(); - if (data) console.log('data', data.prUrl); - }; + const onPrCreationSuccess = useCallback(() => setHasSubmittedPr(true), []); + + const { mutateAsync, isPending, data } = useCreateWarpRoutePR(onPrCreationSuccess); + + const handleCreatePr = async () => await mutateAsync(); return ( -
-
- - -
-

Deploy a Warp Route

-

- Anyone can permissionlessly create an interchain token bridge by deploying Hyperlane Warp - Route contracts. -

-

- Follow three steps to create a new route: configure your options, deploy your contracts, and - set up a bridge UI. -

-

- To use more advanced settings, use the{' '} - Hyperlane CLI. -

-
- - Learn more - - setPage(CardPage.WarpForm)} - className="w-36 px-3 py-2" - > - Deploy - - - Create - + <> +
+
+ + +
+

Deploy a Warp Route

+

+ Anyone can permissionlessly create an interchain token bridge by deploying Hyperlane Warp + Route contracts. +

+

+ Follow three steps to create a new route: configure your options, deploy your contracts, + and set up a bridge UI. +

+

+ To use more advanced settings, use the{' '} + Hyperlane CLI. +

+
+ + Learn more + + setPage(CardPage.WarpForm)} + className="w-36 px-3 py-2" + > + Deploy + + + Create + +
-
+ + ); } diff --git a/src/pages/api/create-pr.ts b/src/pages/api/create-pr.ts index 26c5354..c09323a 100644 --- a/src/pages/api/create-pr.ts +++ b/src/pages/api/create-pr.ts @@ -42,7 +42,7 @@ export default async function handler( }); const latestCommitSha = refData.object.sha; - const newBranch = `${Date.now()}-${symbol}-config`; + const newBranch = `${symbol}-config-${Date.now()}`; // Step 2: Create new branch await octokit.git.createRef({ From 3ff2d3f7aaaec7ffe35fd94d6931e1d6643c1b8a Mon Sep 17 00:00:00 2001 From: Xaroz Date: Mon, 23 Jun 2025 11:14:00 -0400 Subject: [PATCH 07/27] feat: include username, org and clean up --- .../deployment/CreateRegistryPrModal.tsx | 127 ++++++++++++++---- src/features/deployment/github.ts | 18 ++- .../deployment/warp/WarpDeploymentSuccess.tsx | 31 ++++- src/flows/LandingCard.tsx | 6 +- src/images/icons/folder-code-icon.svg | 1 + src/pages/api/create-pr.ts | 30 ++++- src/types/api.ts | 20 +++ src/utils/string.ts | 8 ++ src/utils/zod.ts | 11 ++ 9 files changed, 207 insertions(+), 45 deletions(-) create mode 100644 src/images/icons/folder-code-icon.svg create mode 100644 src/utils/string.ts diff --git a/src/features/deployment/CreateRegistryPrModal.tsx b/src/features/deployment/CreateRegistryPrModal.tsx index 9261ea2..3f1982c 100644 --- a/src/features/deployment/CreateRegistryPrModal.tsx +++ b/src/features/deployment/CreateRegistryPrModal.tsx @@ -1,9 +1,14 @@ -import { Modal } from '@hyperlane-xyz/widgets'; +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 { CreatePrResponse } from '../../types/api'; +import { Color } from '../../styles/Color'; +import { CreatePrResponse, GithubIdentity, GitHubIdentitySchema } from '../../types/api'; +import { normalizeEmptyStrings } from '../../utils/string'; +import { zodErrorToFormikErrors } from '../../utils/zod'; export function CreateRegistryPrModal({ isOpen, @@ -17,57 +22,105 @@ export function CreateRegistryPrModal({ disabled: boolean; confirmDisabled: boolean; onCancel: () => void; - onConfirm: () => void; + onConfirm: (values: GithubIdentity) => void; data: 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!

- {data && data.success && ( -
- This is your the link to your PR - - {data.prUrl} - -
- )} +

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

- + + onSubmit={onConfirm} + validate={validateForm} + validateOnChange={false} + validateOnBlur={false} + initialValues={{ organization: undefined, username: undefined }} + > + {() => ( +
+ {data && data.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, - onConfirm, confirmDisabled, disabled, }: { onCancel: () => void; - onConfirm: () => void; confirmDisabled: boolean; disabled: boolean; }) { @@ -82,13 +135,29 @@ function ButtonsSection({ 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/github.ts b/src/features/deployment/github.ts index aed76b4..168c56a 100644 --- a/src/features/deployment/github.ts +++ b/src/features/deployment/github.ts @@ -2,7 +2,8 @@ import { WarpCoreConfig, WarpRouteDeployConfig } from '@hyperlane-xyz/sdk'; import { useMutation } from '@tanstack/react-query'; import { stringify } from 'yaml'; import { useToastError } from '../../components/toast/useToastError'; -import { CreatePrBody, CreatePrResponse } from '../../types/api'; +import { CreatePrBody, CreatePrResponse, GithubIdentity } from '../../types/api'; +import { normalizeEmptyStrings } from '../../utils/string'; import { useLatestDeployment } from './hooks'; import { DeploymentType } from './types'; import { getDeployConfigFilename, getWarpConfigFilename } from './utils'; @@ -13,13 +14,13 @@ const warpRoutesPath = 'deployments/warp_routes'; export function useCreateWarpRoutePR(onSuccess: () => void) { const { config, result } = useLatestDeployment(); - const { isPending, mutateAsync, error, data } = useMutation({ + const { isPending, mutate, mutateAsync, error, data } = useMutation({ mutationKey: ['createWarpRoutePr', config, result], - mutationFn: () => { + mutationFn: (githubInformation: GithubIdentity) => { if (!config.config || config.type !== DeploymentType.Warp) return Promise.resolve(null); if (!result?.result || result.type !== DeploymentType.Warp) return Promise.resolve(null); - return createWarpRoutePR(config.config, result.result); + return createWarpRoutePR(config.config, result.result, githubInformation); }, retry: false, onSuccess, @@ -28,6 +29,7 @@ export function useCreateWarpRoutePR(onSuccess: () => void) { useToastError(error, 'Error creating PR for Github'); return { + mutate, mutateAsync, error, isPending, @@ -38,14 +40,15 @@ export function useCreateWarpRoutePR(onSuccess: () => void) { async function createWarpRoutePR( deployConfig: WarpRouteDeployConfig, warpConfig: WarpCoreConfig, + githubInformation: GithubIdentity, ): Promise { const deployConfigFilename = getDeployConfigFilename(deployConfig); const warpConfigFilename = getWarpConfigFilename(warpConfig); - const firstNonSythetic = Object.values(deployConfig).find((c) => !isSyntheticTokenType(c.type)); + const firstNonSynthetic = Object.values(deployConfig).find((c) => !isSyntheticTokenType(c.type)); - if (!firstNonSythetic) throw new Error('Token types cannot all be synthetic'); + if (!firstNonSynthetic) throw new Error('Token types cannot all be synthetic'); - const symbol = firstNonSythetic.symbol; + const symbol = firstNonSynthetic.symbol; const yamlDeployConfig = stringify(deployConfig, { sortMapEntries: true }); const yamlWarpConfig = stringify(warpConfig, { sortMapEntries: true }); @@ -63,6 +66,7 @@ async function createWarpRoutePR( deployConfig: files.deployConfig, warpConfig: files.warpConfig, symbol, + ...normalizeEmptyStrings(githubInformation), }), }); diff --git a/src/features/deployment/warp/WarpDeploymentSuccess.tsx b/src/features/deployment/warp/WarpDeploymentSuccess.tsx index 4fd74fb..23a4294 100644 --- a/src/features/deployment/warp/WarpDeploymentSuccess.tsx +++ b/src/features/deployment/warp/WarpDeploymentSuccess.tsx @@ -3,13 +3,17 @@ import { shortenAddress } from '@hyperlane-xyz/utils'; import { CopyIcon, useModal } from '@hyperlane-xyz/widgets'; import clsx from 'clsx'; import Image from 'next/image'; +import { useCallback, 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 { @@ -24,6 +28,13 @@ 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)})` : ''; @@ -145,7 +156,15 @@ export function WarpDeploymentSuccess() { 4. Add your route to the{' '} Hyperlane Registry - + {' '} + or you can open a PR by clicking{' '} +
  • 5.{' '} @@ -164,6 +183,16 @@ export function WarpDeploymentSuccess() { onSubmit={onConfirmCoinGeckoId} close={close} /> + { + // + }} + confirmDisabled={hasSubmittedPr} + disabled={isPending} + data={createPrData} + /> ); } diff --git a/src/flows/LandingCard.tsx b/src/flows/LandingCard.tsx index be1dec9..6acaa59 100644 --- a/src/flows/LandingCard.tsx +++ b/src/flows/LandingCard.tsx @@ -18,9 +18,7 @@ export function LandingPage() { const onPrCreationSuccess = useCallback(() => setHasSubmittedPr(true), []); - const { mutateAsync, isPending, data } = useCreateWarpRoutePR(onPrCreationSuccess); - - const handleCreatePr = async () => await mutateAsync(); + const { mutate, isPending, data } = useCreateWarpRoutePR(onPrCreationSuccess); return ( <> @@ -72,7 +70,7 @@ export function LandingPage() { \ No newline at end of file diff --git a/src/pages/api/create-pr.ts b/src/pages/api/create-pr.ts index c09323a..1b353ac 100644 --- a/src/pages/api/create-pr.ts +++ b/src/pages/api/create-pr.ts @@ -3,8 +3,14 @@ import { WarpCoreConfigSchema, WarpRouteDeployConfigSchema } from '@hyperlane-xy import { Octokit } from '@octokit/rest'; import type { NextApiRequest, NextApiResponse } from 'next'; import { serverConfig, ServerConfigSchema } from '../../consts/config.server'; -import { CreatePrBody, CreatePrError, CreatePrResponse } from '../../types/api'; -import { validateStringToZodSchema } from '../../utils/zod'; +import { + CreatePrBody, + CreatePrError, + CreatePrResponse, + GithubIdentity, + GitHubIdentitySchema, +} from '../../types/api'; +import { validateStringToZodSchema, zodErrorToString } from '../../utils/zod'; export default async function handler( req: NextApiRequest, @@ -20,10 +26,19 @@ export default async function handler( serverConfigParseResult.data; const octokit = new Octokit({ auth: githubToken }); - const { deployConfig, warpConfig, symbol } = req.body as CreatePrBody & { symbol: string }; + const { deployConfig, warpConfig, symbol, organization, username } = req.body as CreatePrBody & + GithubIdentity & { symbol: string }; + if (!deployConfig || !warpConfig || !symbol) return res.status(400).json({ error: 'Missing config files to create PR' }); + const githubInformationResult = GitHubIdentitySchema.safeParse({ organization, username }); + + if (!githubInformationResult.success) { + const githubInfoError = zodErrorToString(githubInformationResult.error); + return res.status(400).json({ error: githubInfoError }); + } + const deployConfigResult = validateStringToZodSchema( deployConfig.content, WarpRouteDeployConfigSchema, @@ -64,6 +79,11 @@ export default async function handler( }); } + const { username, organization } = githubInformationResult.data; + const githubInfo = [username && `by ${username}`, organization && `from ${organization}`] + .filter(Boolean) + .join(' '); + // Step 4: Create a PR from the fork branch to upstream main const { data: pr } = await octokit.pulls.create({ owner: githubUpstreamOwner, @@ -71,7 +91,9 @@ export default async function handler( title: `feat: add ${symbol} deploy artifacts`, head: `${githubForkOwner}:${newBranch}`, base: githubBaseBranch, - body: `This PR was created from the deploy app to add ${symbol} deploy artifacts`, + body: `This PR was created from the deploy app to add ${symbol} deploy artifacts.${ + githubInfo ? `\n\nThis config was provided ${githubInfo}.` : '' + }`, }); return res.status(200).json({ success: true, prUrl: pr.html_url }); diff --git a/src/types/api.ts b/src/types/api.ts index 839e989..64591c4 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -1,3 +1,5 @@ +import { z } from 'zod'; + export interface CreatePrResponse { success: true; prUrl: string; @@ -17,3 +19,21 @@ export interface CreatePrBody { deployConfig: DeployFile; warpConfig: DeployFile; } + +const githubNameRegex = /^(?!-)(?!.*--)[a-zA-Z0-9-]{1,39}(?; 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 d5dd122..27890f5 100644 --- a/src/utils/zod.ts +++ b/src/utils/zod.ts @@ -7,6 +7,17 @@ export function zodErrorToString(err: ZodError) { 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; From d37bb8077c1fb281184d1fa3943af9347c133edb Mon Sep 17 00:00:00 2001 From: Xaroz Date: Mon, 23 Jun 2025 11:58:03 -0400 Subject: [PATCH 08/27] chore: move PR creation to success, clean up hard-coded index and landing --- src/features/deployment/hooks.ts | 2 +- .../deployment/warp/WarpDeploymentSuccess.tsx | 4 +- src/flows/LandingCard.tsx | 99 +++++++------------ 3 files changed, 37 insertions(+), 68 deletions(-) diff --git a/src/features/deployment/hooks.ts b/src/features/deployment/hooks.ts index a15a221..7988b09 100644 --- a/src/features/deployment/hooks.ts +++ b/src/features/deployment/hooks.ts @@ -40,7 +40,7 @@ export function useDeploymentHistory() { export function useLatestDeployment() { const { deployments, currentIndex } = useDeploymentHistory(); - return deployments[2]; + return deployments[currentIndex]; } export function usePastDeploymentChains() { diff --git a/src/features/deployment/warp/WarpDeploymentSuccess.tsx b/src/features/deployment/warp/WarpDeploymentSuccess.tsx index 23a4294..9f979fc 100644 --- a/src/features/deployment/warp/WarpDeploymentSuccess.tsx +++ b/src/features/deployment/warp/WarpDeploymentSuccess.tsx @@ -186,9 +186,7 @@ export function WarpDeploymentSuccess() { { - // - }} + onConfirm={mutate} confirmDisabled={hasSubmittedPr} disabled={isPending} data={createPrData} diff --git a/src/flows/LandingCard.tsx b/src/flows/LandingCard.tsx index 6acaa59..0a17b3b 100644 --- a/src/flows/LandingCard.tsx +++ b/src/flows/LandingCard.tsx @@ -1,11 +1,7 @@ -import { useModal } from '@hyperlane-xyz/widgets'; import Image from 'next/image'; -import { useCallback, useState } from 'react'; import { SolidButton } from '../components/buttons/SolidButton'; import { AUnderline } from '../components/text/A'; import { links } from '../consts/links'; -import { CreateRegistryPrModal } from '../features/deployment/CreateRegistryPrModal'; -import { useCreateWarpRoutePR } from '../features/deployment/github'; import BlueWave from '../images/illustrations/blue-wave.svg'; import SpaceCraft from '../images/illustrations/spacecraft.webp'; import { CardPage } from './CardPage'; @@ -13,68 +9,43 @@ import { useCardNav } from './hooks'; export function LandingPage() { const { setPage } = useCardNav(); - const { close, isOpen, open } = useModal(); - const [hasSubmittedPr, setHasSubmittedPr] = useState(false); - - const onPrCreationSuccess = useCallback(() => setHasSubmittedPr(true), []); - - const { mutate, isPending, data } = useCreateWarpRoutePR(onPrCreationSuccess); return ( - <> -
    -
    - - -
    -

    Deploy a Warp Route

    -

    - Anyone can permissionlessly create an interchain token bridge by deploying Hyperlane Warp - Route contracts. -

    -

    - Follow three steps to create a new route: configure your options, deploy your contracts, - and set up a bridge UI. -

    -

    - To use more advanced settings, use the{' '} - Hyperlane CLI. -

    -
    - - Learn more - - setPage(CardPage.WarpForm)} - className="w-36 px-3 py-2" - > - Deploy - - - Create - -
    +
    +
    + + +
    +

    Deploy a Warp Route

    +

    + Anyone can permissionlessly create an interchain token bridge by deploying Hyperlane Warp + Route contracts. +

    +

    + Follow three steps to create a new route: configure your options, deploy your contracts, and + set up a bridge UI. +

    +

    + To use more advanced settings, use the{' '} + Hyperlane CLI. +

    +
    + + Learn more + + setPage(CardPage.WarpForm)} + className="w-36 px-3 py-2" + > + Deploy +
    - - +
    ); } From e9db23612d208dc88ebd55498bd0aef7eee8fb20 Mon Sep 17 00:00:00 2001 From: Xaroz Date: Mon, 23 Jun 2025 12:46:07 -0400 Subject: [PATCH 09/27] feat: add changeset to commits --- package.json | 1 + src/pages/api/create-pr.ts | 30 +++++++++++++++++++++++++----- yarn.lock | 10 ++++++++++ 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 41509da..a3e32bb 100644 --- a/package.json +++ b/package.json @@ -45,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/pages/api/create-pr.ts b/src/pages/api/create-pr.ts index 1b353ac..2372fe1 100644 --- a/src/pages/api/create-pr.ts +++ b/src/pages/api/create-pr.ts @@ -1,12 +1,14 @@ // pages/api/open-pr.ts import { WarpCoreConfigSchema, WarpRouteDeployConfigSchema } from '@hyperlane-xyz/sdk'; import { Octokit } from '@octokit/rest'; +import humanId from 'human-id'; import type { NextApiRequest, NextApiResponse } from 'next'; import { serverConfig, ServerConfigSchema } from '../../consts/config.server'; import { CreatePrBody, CreatePrError, CreatePrResponse, + DeployFile, GithubIdentity, GitHubIdentitySchema, } from '../../types/api'; @@ -49,7 +51,7 @@ export default async function handler( if (!warpConfigResult) return res.status(400).json({ error: 'Invalid warp config' }); try { - // Step 1: Get latest SHA of base branch in fork + // Get latest SHA of base branch in fork const { data: refData } = await octokit.git.getRef({ owner: githubForkOwner, repo: githubRepoName, @@ -59,7 +61,7 @@ export default async function handler( const latestCommitSha = refData.object.sha; const newBranch = `${symbol}-config-${Date.now()}`; - // Step 2: Create new branch + // Create new branch await octokit.git.createRef({ owner: githubForkOwner, repo: githubRepoName, @@ -67,8 +69,10 @@ export default async function handler( sha: latestCommitSha, }); - // Step 3: Upload files to the new branch - for (const file of [deployConfig, warpConfig]) { + const changesetFile = writeChangeset(`Add ${symbol} deploy artifacts`); + + // Upload files to the new branch + for (const file of [deployConfig, warpConfig, changesetFile]) { await octokit.repos.createOrUpdateFileContents({ owner: githubForkOwner, repo: githubRepoName, @@ -84,7 +88,7 @@ export default async function handler( .filter(Boolean) .join(' '); - // Step 4: Create a PR from the fork branch to upstream main + // Create a PR from the fork branch to upstream main const { data: pr } = await octokit.pulls.create({ owner: githubUpstreamOwner, repo: githubRepoName, @@ -101,3 +105,19 @@ export default async function handler( return res.status(500).json({ error: err.message }); } } + +// 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: `.changset/${filename}`, content }; +} diff --git a/yarn.lock b/yarn.lock index c071f6d..eaf1bae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4511,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" @@ -18477,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" From 8a0fa4622c6e256b04e6feb53614659d75f5fa80 Mon Sep 17 00:00:00 2001 From: Xaroz Date: Mon, 23 Jun 2025 17:30:02 -0400 Subject: [PATCH 10/27] chore: sort warp core config --- src/features/deployment/utils.ts | 21 +++++++++++++++++++ .../deployment/warp/WarpDeploymentSuccess.tsx | 8 +++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/features/deployment/utils.ts b/src/features/deployment/utils.ts index 40cab39..057811c 100644 --- a/src/features/deployment/utils.ts +++ b/src/features/deployment/utils.ts @@ -39,3 +39,24 @@ export function getWarpConfigFilename(config: WarpCoreConfig) { const chains = config.tokens.map((token) => token.chainName).sort(); return `${chains.join('-')}-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 9f979fc..0279a5f 100644 --- a/src/features/deployment/warp/WarpDeploymentSuccess.tsx +++ b/src/features/deployment/warp/WarpDeploymentSuccess.tsx @@ -20,6 +20,7 @@ import { downloadYamlFile, getDeployConfigFilename, getWarpConfigFilename, + sortWarpCoreConfig, tryCopyConfig, } from '../utils'; import { CoinGeckoFormValues } from './types'; @@ -39,7 +40,8 @@ export function WarpDeploymentSuccess() { const firstOwnerDisplay = firstOwner ? ` (${shortenAddress(firstOwner)})` : ''; const deploymentContext = useLatestDeployment(); - const onClickCopyConfig = () => tryCopyConfig(deploymentContext?.result?.result); + const onClickCopyConfig = () => + tryCopyConfig(sortWarpCoreConfig(deploymentContext?.result?.result)); const onClickCopyDeployConfig = () => tryCopyConfig(deploymentContext?.config.config); const downloadDeployConfig = () => { @@ -55,7 +57,9 @@ export function WarpDeploymentSuccess() { if (!deploymentContext?.result?.result || deploymentContext.result.type !== DeploymentType.Warp) return; - const warpConfigResult = deploymentContext.result.result; + const warpConfigResult = sortWarpCoreConfig(deploymentContext.result.result); + if (!warpConfigResult) return; + const filename = getWarpConfigFilename(warpConfigResult); downloadYamlFile(warpConfigResult, filename); }; From 007be59215d848502b0fe924d4811c94b3746a30 Mon Sep 17 00:00:00 2001 From: Xaroz Date: Tue, 24 Jun 2025 12:09:11 -0400 Subject: [PATCH 11/27] chore: sort result in deployment details --- src/features/deployment/DeploymentDetailsModal.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 && }
    ); } From 1e000ced9f53ae6ae23933cd1ae98093ff331df2 Mon Sep 17 00:00:00 2001 From: Xaroz Date: Tue, 24 Jun 2025 17:04:42 -0400 Subject: [PATCH 12/27] chore: persist octokit instance, refactor json response and type --- eslint.config.mjs | 2 +- src/consts/config.server.ts | 6 ++ .../deployment/CreateRegistryPrModal.tsx | 10 +- .../deployment/warp/WarpDeploymentSuccess.tsx | 2 +- src/flows/LandingCard.tsx | 99 ++++++++++++------- src/libs/github.ts | 16 +++ src/pages/api/create-pr.ts | 45 +++++---- src/types/api.ts | 18 +++- src/utils/api.ts | 10 ++ 9 files changed, 142 insertions(+), 66 deletions(-) create mode 100644 src/libs/github.ts create mode 100644 src/utils/api.ts 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/src/consts/config.server.ts b/src/consts/config.server.ts index 0d034c8..f754535 100644 --- a/src/consts/config.server.ts +++ b/src/consts/config.server.ts @@ -21,6 +21,10 @@ export const ServerConfigSchema = z.object({ .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; @@ -30,6 +34,7 @@ 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, @@ -37,4 +42,5 @@ export const serverConfig: ServerConfig = Object.freeze({ githubUpstreamOwner, githubBaseBranch, githubToken, + serverEnvironment, }); diff --git a/src/features/deployment/CreateRegistryPrModal.tsx b/src/features/deployment/CreateRegistryPrModal.tsx index 3f1982c..66dbcf2 100644 --- a/src/features/deployment/CreateRegistryPrModal.tsx +++ b/src/features/deployment/CreateRegistryPrModal.tsx @@ -16,14 +16,14 @@ export function CreateRegistryPrModal({ onConfirm, confirmDisabled, disabled, - data, + response, }: { isOpen: boolean; disabled: boolean; confirmDisabled: boolean; onCancel: () => void; onConfirm: (values: GithubIdentity) => void; - data: CreatePrResponse | null | undefined; + response: CreatePrResponse | null | undefined; }) { return ( {() => (
    - {data && data.success ? ( + {response && response.success ? (

    This is the link to your PR:

    diff --git a/src/features/deployment/warp/WarpDeploymentSuccess.tsx b/src/features/deployment/warp/WarpDeploymentSuccess.tsx index 0279a5f..2d35a92 100644 --- a/src/features/deployment/warp/WarpDeploymentSuccess.tsx +++ b/src/features/deployment/warp/WarpDeploymentSuccess.tsx @@ -193,7 +193,7 @@ export function WarpDeploymentSuccess() { onConfirm={mutate} confirmDisabled={hasSubmittedPr} disabled={isPending} - data={createPrData} + response={createPrData} /> ); diff --git a/src/flows/LandingCard.tsx b/src/flows/LandingCard.tsx index 0a17b3b..8377ef3 100644 --- a/src/flows/LandingCard.tsx +++ b/src/flows/LandingCard.tsx @@ -1,7 +1,11 @@ +import { useModal } from '@hyperlane-xyz/widgets'; import Image from 'next/image'; +import { useCallback, useState } from 'react'; import { SolidButton } from '../components/buttons/SolidButton'; import { AUnderline } from '../components/text/A'; import { links } from '../consts/links'; +import { CreateRegistryPrModal } from '../features/deployment/CreateRegistryPrModal'; +import { useCreateWarpRoutePR } from '../features/deployment/github'; import BlueWave from '../images/illustrations/blue-wave.svg'; import SpaceCraft from '../images/illustrations/spacecraft.webp'; import { CardPage } from './CardPage'; @@ -9,43 +13,68 @@ import { useCardNav } from './hooks'; export function LandingPage() { const { setPage } = useCardNav(); + const { close, isOpen, open } = useModal(); + const [hasSubmittedPr, setHasSubmittedPr] = useState(false); + + const onPrCreationSuccess = useCallback(() => setHasSubmittedPr(true), []); + + const { mutate, isPending, data } = useCreateWarpRoutePR(onPrCreationSuccess); return ( -
    -
    - - -
    -

    Deploy a Warp Route

    -

    - Anyone can permissionlessly create an interchain token bridge by deploying Hyperlane Warp - Route contracts. -

    -

    - Follow three steps to create a new route: configure your options, deploy your contracts, and - set up a bridge UI. -

    -

    - To use more advanced settings, use the{' '} - Hyperlane CLI. -

    -
    - - Learn more - - setPage(CardPage.WarpForm)} - className="w-36 px-3 py-2" - > - Deploy - + <> +
    +
    + + +
    +

    Deploy a Warp Route

    +

    + Anyone can permissionlessly create an interchain token bridge by deploying Hyperlane Warp + Route contracts. +

    +

    + Follow three steps to create a new route: configure your options, deploy your contracts, + and set up a bridge UI. +

    +

    + To use more advanced settings, use the{' '} + Hyperlane CLI. +

    +
    + + Learn more + + setPage(CardPage.WarpSuccess)} + className="w-36 px-3 py-2" + > + Deploy + + + Create + +
    -
    + + ); } 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 index 2372fe1..f680bc5 100644 --- a/src/pages/api/create-pr.ts +++ b/src/pages/api/create-pr.ts @@ -1,54 +1,61 @@ -// pages/api/open-pr.ts import { WarpCoreConfigSchema, WarpRouteDeployConfigSchema } from '@hyperlane-xyz/sdk'; -import { Octokit } from '@octokit/rest'; import humanId from 'human-id'; import type { NextApiRequest, NextApiResponse } from 'next'; -import { serverConfig, ServerConfigSchema } from '../../consts/config.server'; +import { serverConfig } from '../../consts/config.server'; +import { getOctokitClient } from '../../libs/github'; import { + ApiError, CreatePrBody, - CreatePrError, CreatePrResponse, DeployFile, GithubIdentity, GitHubIdentitySchema, } from '../../types/api'; +import { sendJsonResponse } from '../../utils/api'; import { validateStringToZodSchema, zodErrorToString } from '../../utils/zod'; export default async function handler( req: NextApiRequest, - res: NextApiResponse, + res: NextApiResponse, ) { - const serverConfigParseResult = ServerConfigSchema.safeParse(serverConfig); - if (!serverConfigParseResult.success) - return res.status(500).json({ - error: 'Missing Github configurations, check your environment variables', + 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 { githubBaseBranch, githubForkOwner, githubRepoName, githubToken, githubUpstreamOwner } = - serverConfigParseResult.data; - const octokit = new Octokit({ auth: githubToken }); + } const { deployConfig, warpConfig, symbol, organization, username } = req.body as CreatePrBody & GithubIdentity & { symbol: string }; if (!deployConfig || !warpConfig || !symbol) - return res.status(400).json({ error: 'Missing config files to create PR' }); + return sendJsonResponse(res, 400, { error: 'Missing config files to create PR' }); const githubInformationResult = GitHubIdentitySchema.safeParse({ organization, username }); if (!githubInformationResult.success) { const githubInfoError = zodErrorToString(githubInformationResult.error); - return res.status(400).json({ error: githubInfoError }); + return sendJsonResponse(res, 400, { error: githubInfoError }); } const deployConfigResult = validateStringToZodSchema( deployConfig.content, WarpRouteDeployConfigSchema, ); - if (!deployConfigResult) return res.status(400).json({ error: 'Invalid deploy config' }); + if (!deployConfigResult) sendJsonResponse(res, 400, { error: 'Invalid deploy config' }); const warpConfigResult = validateStringToZodSchema(warpConfig.content, WarpCoreConfigSchema); - if (!warpConfigResult) return res.status(400).json({ error: 'Invalid warp config' }); + if (!warpConfigResult) return sendJsonResponse(res, 400, { error: 'Invalid warp config' }); try { // Get latest SHA of base branch in fork @@ -100,9 +107,9 @@ export default async function handler( }`, }); - return res.status(200).json({ success: true, prUrl: pr.html_url }); + return sendJsonResponse(res, 200, { data: { prUrl: pr.html_url }, success: true }); } catch (err: any) { - return res.status(500).json({ error: err.message }); + return sendJsonResponse(res, 500, { error: err.message }); } } diff --git a/src/types/api.ts b/src/types/api.ts index 64591c4..e456b43 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -1,15 +1,23 @@ import { z } from 'zod'; -export interface CreatePrResponse { +export interface ApiError { + success?: false; + error: string; +} + +export interface ApiSuccess { success: true; - prUrl: string; + data: T; } -export interface CreatePrError { - success?: false; - error: string; +export type ApiResponseBody = ApiSuccess | ApiError; + +export interface CreatePrData { + prUrl: string; } +export type CreatePrResponse = ApiResponseBody; + export interface DeployFile { path: string; content: string; diff --git a/src/utils/api.ts b/src/utils/api.ts new file mode 100644 index 0000000..690ff69 --- /dev/null +++ b/src/utils/api.ts @@ -0,0 +1,10 @@ +import { NextApiResponse } from 'next'; +import { ApiResponseBody } from '../types/api'; + +export function sendJsonResponse( + res: NextApiResponse>, + statusCode: number, + body?: ApiResponseBody, +) { + return res.status(statusCode).json(body as ApiResponseBody); +} From 6b5dc3802368ae597f01eeffd8ad864c4297393b Mon Sep 17 00:00:00 2001 From: Xaroz Date: Tue, 24 Jun 2025 17:40:48 -0400 Subject: [PATCH 13/27] chore: include warpRouteId to request --- src/features/deployment/github.ts | 14 +++-- src/flows/LandingCard.tsx | 99 +++++++++++-------------------- src/pages/api/create-pr.ts | 15 +++-- src/types/api.ts | 11 ++-- 4 files changed, 56 insertions(+), 83 deletions(-) diff --git a/src/features/deployment/github.ts b/src/features/deployment/github.ts index 168c56a..9977498 100644 --- a/src/features/deployment/github.ts +++ b/src/features/deployment/github.ts @@ -1,3 +1,4 @@ +import { BaseRegistry } from '@hyperlane-xyz/registry'; import { WarpCoreConfig, WarpRouteDeployConfig } from '@hyperlane-xyz/sdk'; import { useMutation } from '@tanstack/react-query'; import { stringify } from 'yaml'; @@ -46,27 +47,28 @@ async function createWarpRoutePR( const warpConfigFilename = getWarpConfigFilename(warpConfig); const firstNonSynthetic = Object.values(deployConfig).find((c) => !isSyntheticTokenType(c.type)); - if (!firstNonSynthetic) throw new Error('Token types cannot all be synthetic'); + if (!firstNonSynthetic || !firstNonSynthetic.symbol) + throw new Error('Token types cannot all be synthetic'); const symbol = firstNonSynthetic.symbol; + const warpRouteId = BaseRegistry.warpRouteConfigToId(warpConfig, { symbol }); const yamlDeployConfig = stringify(deployConfig, { sortMapEntries: true }); const yamlWarpConfig = stringify(warpConfig, { sortMapEntries: true }); const basePath = `${warpRoutesPath}/${symbol}`; - const files: CreatePrBody = { + const requestBody: CreatePrBody = { + ...normalizeEmptyStrings(githubInformation), deployConfig: { content: yamlDeployConfig, path: `${basePath}/${deployConfigFilename}` }, warpConfig: { content: yamlWarpConfig, path: `${basePath}/${warpConfigFilename}` }, + warpRouteId, }; const res = await fetch('/api/create-pr', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - deployConfig: files.deployConfig, - warpConfig: files.warpConfig, - symbol, - ...normalizeEmptyStrings(githubInformation), + ...requestBody, }), }); diff --git a/src/flows/LandingCard.tsx b/src/flows/LandingCard.tsx index 8377ef3..0a17b3b 100644 --- a/src/flows/LandingCard.tsx +++ b/src/flows/LandingCard.tsx @@ -1,11 +1,7 @@ -import { useModal } from '@hyperlane-xyz/widgets'; import Image from 'next/image'; -import { useCallback, useState } from 'react'; import { SolidButton } from '../components/buttons/SolidButton'; import { AUnderline } from '../components/text/A'; import { links } from '../consts/links'; -import { CreateRegistryPrModal } from '../features/deployment/CreateRegistryPrModal'; -import { useCreateWarpRoutePR } from '../features/deployment/github'; import BlueWave from '../images/illustrations/blue-wave.svg'; import SpaceCraft from '../images/illustrations/spacecraft.webp'; import { CardPage } from './CardPage'; @@ -13,68 +9,43 @@ import { useCardNav } from './hooks'; export function LandingPage() { const { setPage } = useCardNav(); - const { close, isOpen, open } = useModal(); - const [hasSubmittedPr, setHasSubmittedPr] = useState(false); - - const onPrCreationSuccess = useCallback(() => setHasSubmittedPr(true), []); - - const { mutate, isPending, data } = useCreateWarpRoutePR(onPrCreationSuccess); return ( - <> -
    -
    - - -
    -

    Deploy a Warp Route

    -

    - Anyone can permissionlessly create an interchain token bridge by deploying Hyperlane Warp - Route contracts. -

    -

    - Follow three steps to create a new route: configure your options, deploy your contracts, - and set up a bridge UI. -

    -

    - To use more advanced settings, use the{' '} - Hyperlane CLI. -

    -
    - - Learn more - - setPage(CardPage.WarpSuccess)} - className="w-36 px-3 py-2" - > - Deploy - - - Create - -
    +
    +
    + + +
    +

    Deploy a Warp Route

    +

    + Anyone can permissionlessly create an interchain token bridge by deploying Hyperlane Warp + Route contracts. +

    +

    + Follow three steps to create a new route: configure your options, deploy your contracts, and + set up a bridge UI. +

    +

    + To use more advanced settings, use the{' '} + Hyperlane CLI. +

    +
    + + Learn more + + setPage(CardPage.WarpForm)} + className="w-36 px-3 py-2" + > + Deploy +
    - - +
    ); } diff --git a/src/pages/api/create-pr.ts b/src/pages/api/create-pr.ts index f680bc5..4ec74a0 100644 --- a/src/pages/api/create-pr.ts +++ b/src/pages/api/create-pr.ts @@ -8,7 +8,6 @@ import { CreatePrBody, CreatePrResponse, DeployFile, - GithubIdentity, GitHubIdentitySchema, } from '../../types/api'; import { sendJsonResponse } from '../../utils/api'; @@ -35,10 +34,10 @@ export default async function handler( }); } - const { deployConfig, warpConfig, symbol, organization, username } = req.body as CreatePrBody & - GithubIdentity & { symbol: string }; + const { deployConfig, warpConfig, warpRouteId, organization, username } = + req.body as CreatePrBody; - if (!deployConfig || !warpConfig || !symbol) + if (!deployConfig || !warpConfig || !warpRouteId) return sendJsonResponse(res, 400, { error: 'Missing config files to create PR' }); const githubInformationResult = GitHubIdentitySchema.safeParse({ organization, username }); @@ -66,7 +65,7 @@ export default async function handler( }); const latestCommitSha = refData.object.sha; - const newBranch = `${symbol}-config-${Date.now()}`; + const newBranch = `${warpRouteId}-config-${Date.now()}`; // Create new branch await octokit.git.createRef({ @@ -76,7 +75,7 @@ export default async function handler( sha: latestCommitSha, }); - const changesetFile = writeChangeset(`Add ${symbol} deploy artifacts`); + const changesetFile = writeChangeset(`Add ${warpRouteId} warp route deploy artifacts`); // Upload files to the new branch for (const file of [deployConfig, warpConfig, changesetFile]) { @@ -99,10 +98,10 @@ export default async function handler( const { data: pr } = await octokit.pulls.create({ owner: githubUpstreamOwner, repo: githubRepoName, - title: `feat: add ${symbol} deploy artifacts`, + title: `feat: add ${warpRouteId} warp route deploy artifacts`, head: `${githubForkOwner}:${newBranch}`, base: githubBaseBranch, - body: `This PR was created from the deploy app to add ${symbol} deploy artifacts.${ + body: `This PR was created from the deploy app to add ${warpRouteId} warp route deploy artifacts.${ githubInfo ? `\n\nThis config was provided ${githubInfo}.` : '' }`, }); diff --git a/src/types/api.ts b/src/types/api.ts index e456b43..f694e93 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -23,11 +23,6 @@ export interface DeployFile { content: string; } -export interface CreatePrBody { - deployConfig: DeployFile; - warpConfig: DeployFile; -} - const githubNameRegex = /^(?!-)(?!.*--)[a-zA-Z0-9-]{1,39}(?; + +export type CreatePrBody = { + deployConfig: DeployFile; + warpConfig: DeployFile; + warpRouteId: string; +} & GithubIdentity; From 12d7a684a1bd6e956ae47b47fcc743595e9900f3 Mon Sep 17 00:00:00 2001 From: Xaroz Date: Tue, 24 Jun 2025 17:50:15 -0400 Subject: [PATCH 14/27] chore: move create PR types to its own file --- .../deployment/CreateRegistryPrModal.tsx | 2 +- src/features/deployment/github.ts | 2 +- src/pages/api/create-pr.ts | 4 +- src/types/api.ts | 37 ------------------- 4 files changed, 4 insertions(+), 41 deletions(-) diff --git a/src/features/deployment/CreateRegistryPrModal.tsx b/src/features/deployment/CreateRegistryPrModal.tsx index 66dbcf2..6978121 100644 --- a/src/features/deployment/CreateRegistryPrModal.tsx +++ b/src/features/deployment/CreateRegistryPrModal.tsx @@ -6,7 +6,7 @@ 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/api'; +import { CreatePrResponse, GithubIdentity, GitHubIdentitySchema } from '../../types/createPr'; import { normalizeEmptyStrings } from '../../utils/string'; import { zodErrorToFormikErrors } from '../../utils/zod'; diff --git a/src/features/deployment/github.ts b/src/features/deployment/github.ts index 9977498..c89cdfb 100644 --- a/src/features/deployment/github.ts +++ b/src/features/deployment/github.ts @@ -3,7 +3,7 @@ import { WarpCoreConfig, WarpRouteDeployConfig } from '@hyperlane-xyz/sdk'; import { useMutation } from '@tanstack/react-query'; import { stringify } from 'yaml'; import { useToastError } from '../../components/toast/useToastError'; -import { CreatePrBody, CreatePrResponse, GithubIdentity } from '../../types/api'; +import { CreatePrBody, CreatePrResponse, GithubIdentity } from '../../types/createPr'; import { normalizeEmptyStrings } from '../../utils/string'; import { useLatestDeployment } from './hooks'; import { DeploymentType } from './types'; diff --git a/src/pages/api/create-pr.ts b/src/pages/api/create-pr.ts index 4ec74a0..e67377c 100644 --- a/src/pages/api/create-pr.ts +++ b/src/pages/api/create-pr.ts @@ -3,13 +3,13 @@ import humanId from 'human-id'; import type { NextApiRequest, NextApiResponse } from 'next'; import { serverConfig } from '../../consts/config.server'; import { getOctokitClient } from '../../libs/github'; +import { ApiError } from '../../types/api'; import { - ApiError, CreatePrBody, CreatePrResponse, DeployFile, GitHubIdentitySchema, -} from '../../types/api'; +} from '../../types/createPr'; import { sendJsonResponse } from '../../utils/api'; import { validateStringToZodSchema, zodErrorToString } from '../../utils/zod'; diff --git a/src/types/api.ts b/src/types/api.ts index f694e93..698fbc1 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -1,5 +1,3 @@ -import { z } from 'zod'; - export interface ApiError { success?: false; error: string; @@ -11,38 +9,3 @@ export interface ApiSuccess { } export type ApiResponseBody = ApiSuccess | ApiError; - -export interface CreatePrData { - prUrl: string; -} - -export type CreatePrResponse = ApiResponseBody; - -export interface DeployFile { - path: string; - content: string; -} - -const githubNameRegex = /^(?!-)(?!.*--)[a-zA-Z0-9-]{1,39}(?; - -export type CreatePrBody = { - deployConfig: DeployFile; - warpConfig: DeployFile; - warpRouteId: string; -} & GithubIdentity; From 3ac08887268b2d43baf04870cd26ef7c3c991590 Mon Sep 17 00:00:00 2001 From: Xaroz Date: Tue, 24 Jun 2025 18:34:51 -0400 Subject: [PATCH 15/27] chore: refactor body validation into its own fn --- src/pages/api/create-pr.ts | 61 ++++++++++++++++++++++++-------------- 1 file changed, 38 insertions(+), 23 deletions(-) diff --git a/src/pages/api/create-pr.ts b/src/pages/api/create-pr.ts index e67377c..ba78d25 100644 --- a/src/pages/api/create-pr.ts +++ b/src/pages/api/create-pr.ts @@ -3,12 +3,12 @@ import humanId from 'human-id'; import type { NextApiRequest, NextApiResponse } from 'next'; import { serverConfig } from '../../consts/config.server'; import { getOctokitClient } from '../../libs/github'; -import { ApiError } from '../../types/api'; +import { ApiError, ApiSuccess } from '../../types/api'; import { CreatePrBody, + CreatePrBodySchema, CreatePrResponse, DeployFile, - GitHubIdentitySchema, } from '../../types/createPr'; import { sendJsonResponse } from '../../utils/api'; import { validateStringToZodSchema, zodErrorToString } from '../../utils/zod'; @@ -34,27 +34,12 @@ export default async function handler( }); } - const { deployConfig, warpConfig, warpRouteId, organization, username } = - req.body as CreatePrBody; + const requestBody = validateRequestBody(req.body); + if (!requestBody.success) return sendJsonResponse(res, 400, { error: requestBody.error }); - if (!deployConfig || !warpConfig || !warpRouteId) - return sendJsonResponse(res, 400, { error: 'Missing config files to create PR' }); - - const githubInformationResult = GitHubIdentitySchema.safeParse({ organization, username }); - - if (!githubInformationResult.success) { - const githubInfoError = zodErrorToString(githubInformationResult.error); - return sendJsonResponse(res, 400, { error: githubInfoError }); - } - - const deployConfigResult = validateStringToZodSchema( - deployConfig.content, - WarpRouteDeployConfigSchema, - ); - if (!deployConfigResult) sendJsonResponse(res, 400, { error: 'Invalid deploy config' }); - - const warpConfigResult = validateStringToZodSchema(warpConfig.content, WarpCoreConfigSchema); - if (!warpConfigResult) return sendJsonResponse(res, 400, { error: 'Invalid warp config' }); + const { + data: { deployConfig, warpConfig, warpRouteId, organization, username }, + } = requestBody; try { // Get latest SHA of base branch in fork @@ -89,7 +74,6 @@ export default async function handler( }); } - const { username, organization } = githubInformationResult.data; const githubInfo = [username && `by ${username}`, organization && `from ${organization}`] .filter(Boolean) .join(' '); @@ -112,6 +96,37 @@ export default async function handler( } } +export function validateRequestBody(body: unknown): ApiError | ApiSuccess { + 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, + }, + }; +} + // 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 { From 6cc9d0d27bbd8b9735dcd19135c25779f5e2a11c Mon Sep 17 00:00:00 2001 From: Xaroz Date: Tue, 24 Jun 2025 18:37:52 -0400 Subject: [PATCH 16/27] chore: move createPr types --- src/pages/api/create-pr.ts | 4 +--- src/types/createPr.ts | 46 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 src/types/createPr.ts diff --git a/src/pages/api/create-pr.ts b/src/pages/api/create-pr.ts index ba78d25..d78e391 100644 --- a/src/pages/api/create-pr.ts +++ b/src/pages/api/create-pr.ts @@ -100,9 +100,7 @@ export function validateRequestBody(body: unknown): ApiError | ApiSuccess; + +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; From e97838c6b5db8f66204d4e46947bb548387b5d2f Mon Sep 17 00:00:00 2001 From: Xaroz Date: Wed, 25 Jun 2025 11:14:05 -0400 Subject: [PATCH 17/27] chore: use warpRouteId for filenames --- src/features/deployment/github.ts | 7 ++-- src/features/deployment/utils.ts | 20 ++++------ .../deployment/warp/WarpDeploymentSuccess.tsx | 39 ++++++++++++------- 3 files changed, 35 insertions(+), 31 deletions(-) diff --git a/src/features/deployment/github.ts b/src/features/deployment/github.ts index c89cdfb..c51793a 100644 --- a/src/features/deployment/github.ts +++ b/src/features/deployment/github.ts @@ -7,7 +7,7 @@ import { CreatePrBody, CreatePrResponse, GithubIdentity } from '../../types/crea import { normalizeEmptyStrings } from '../../utils/string'; import { useLatestDeployment } from './hooks'; import { DeploymentType } from './types'; -import { getDeployConfigFilename, getWarpConfigFilename } from './utils'; +import { getConfigsFilename } from './utils'; import { isSyntheticTokenType } from './warp/utils'; const warpRoutesPath = 'deployments/warp_routes'; @@ -43,15 +43,14 @@ async function createWarpRoutePR( warpConfig: WarpCoreConfig, githubInformation: GithubIdentity, ): Promise { - const deployConfigFilename = getDeployConfigFilename(deployConfig); - const warpConfigFilename = getWarpConfigFilename(warpConfig); 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.warpRouteConfigToId(warpConfig, { symbol }); + const warpRouteId = BaseRegistry.warpDeployConfigToId(deployConfig, { symbol }); + const { deployConfigFilename, warpConfigFilename } = getConfigsFilename(warpRouteId); const yamlDeployConfig = stringify(deployConfig, { sortMapEntries: true }); const yamlWarpConfig = stringify(warpConfig, { sortMapEntries: true }); diff --git a/src/features/deployment/utils.ts b/src/features/deployment/utils.ts index 057811c..d209d24 100644 --- a/src/features/deployment/utils.ts +++ b/src/features/deployment/utils.ts @@ -1,5 +1,4 @@ -import { WarpCoreConfig, WarpRouteDeployConfig } from '@hyperlane-xyz/sdk'; -import { objKeys } from '@hyperlane-xyz/utils'; +import { WarpCoreConfig } from '@hyperlane-xyz/sdk'; import { tryClipboardSet } from '@hyperlane-xyz/widgets'; import { toast } from 'react-toastify'; import { stringify } from 'yaml'; @@ -26,18 +25,13 @@ export function downloadYamlFile(config: unknown | undefined, filename: string) URL.revokeObjectURL(url); } -export function getDeployConfigFilename(config: WarpRouteDeployConfig) { - if (!config) return 'deploy.yaml'; - const chains = objKeys(config).sort(); +export function getConfigsFilename(warpRouteId: string) { + const [_, label] = warpRouteId.split('/'); - return `${chains.join('-')}-deploy.yaml`; -} - -export function getWarpConfigFilename(config: WarpCoreConfig) { - if (!config) return 'config.yaml'; - - const chains = config.tokens.map((token) => token.chainName).sort(); - return `${chains.join('-')}-config.yaml`; + return { + deployConfigFilename: `${label}-deploy.yaml`, + warpConfigFilename: `${label}-config.yaml`, + }; } export function sortWarpCoreConfig(warpCoreConfig?: WarpCoreConfig): WarpCoreConfig | undefined { diff --git a/src/features/deployment/warp/WarpDeploymentSuccess.tsx b/src/features/deployment/warp/WarpDeploymentSuccess.tsx index 2d35a92..1a38ba9 100644 --- a/src/features/deployment/warp/WarpDeploymentSuccess.tsx +++ b/src/features/deployment/warp/WarpDeploymentSuccess.tsx @@ -1,9 +1,10 @@ +import { BaseRegistry } from '@hyperlane-xyz/registry'; import { TOKEN_COLLATERALIZED_STANDARDS } from '@hyperlane-xyz/sdk'; import { shortenAddress } from '@hyperlane-xyz/utils'; import { CopyIcon, useModal } from '@hyperlane-xyz/widgets'; import clsx from 'clsx'; import Image from 'next/image'; -import { useCallback, useState } from 'react'; +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'; @@ -16,14 +17,9 @@ import { CreateRegistryPrModal } from '../CreateRegistryPrModal'; import { useCreateWarpRoutePR } from '../github'; import { useDeploymentHistory, useLatestDeployment, useWarpDeploymentConfig } from '../hooks'; import { DeploymentType } from '../types'; -import { - downloadYamlFile, - getDeployConfigFilename, - getWarpConfigFilename, - sortWarpCoreConfig, - tryCopyConfig, -} from '../utils'; +import { downloadYamlFile, getConfigsFilename, sortWarpCoreConfig, tryCopyConfig } from '../utils'; import { CoinGeckoFormValues } from './types'; +import { isSyntheticTokenType } from './utils'; export function WarpDeploymentSuccess() { const { deploymentConfig } = useWarpDeploymentConfig(); @@ -40,28 +36,43 @@ export function WarpDeploymentSuccess() { const firstOwnerDisplay = firstOwner ? ` (${shortenAddress(firstOwner)})` : ''; const deploymentContext = useLatestDeployment(); + + 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 filename = getDeployConfigFilename(deployConfigResult); - 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 = sortWarpCoreConfig(deploymentContext.result.result); if (!warpConfigResult) return; - const filename = getWarpConfigFilename(warpConfigResult); - downloadYamlFile(warpConfigResult, filename); + const { warpConfigFilename } = getConfigsFilename(warpRouteId); + downloadYamlFile(warpConfigResult, warpConfigFilename); }; const onCancelCoinGeckoId = () => { From 18f5a7fb6a6de516624907b3b220874d556134fb Mon Sep 17 00:00:00 2001 From: Xaroz Date: Wed, 25 Jun 2025 11:34:10 -0400 Subject: [PATCH 18/27] chore: remove type assertion --- src/utils/api.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/utils/api.ts b/src/utils/api.ts index 690ff69..f0a31e5 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -6,5 +6,7 @@ export function sendJsonResponse( statusCode: number, body?: ApiResponseBody, ) { - return res.status(statusCode).json(body as ApiResponseBody); + if (body) return res.status(statusCode).json(body); + + return res.status(statusCode).end(); } From 0af1496dd57b33118515074ab240256687c86bb2 Mon Sep 17 00:00:00 2001 From: Jason Guo <33064781+Xaroz@users.noreply.github.com> Date: Tue, 8 Jul 2025 10:51:06 -0400 Subject: [PATCH 19/27] feat: verify create-pr request to prevent spamming (#31) fixes [ENG-1824](https://linear.app/hyperlane-xyz/issue/ENG-1824/rate-limiting-avoid-spamming-of-endpoints) This PR features different ways to prevent the `create-pr` endpoint from being spammed, the process goes like: 1. In the UI, the temporary deployer wallet will sign a message that contains a `timestamp` and `warpRouteId`. 2. The returned `signature` will be sent to the endpoint along with the `address` and `message` 3. The backend service will verify the signature using viem's `verifySignature` and also check if the `timestamp` has expired (its currently set to 2 minutes) 4. A `keccak256` hash will be created using the `warpRouteId`, `deployConfig` and `warpConfig`, this will be used for the branch name, which will be in `warpRouteId-keccak256hash` format 5. This branch name will be verified using `octokit` so no other branches exists with the same name 6. If every check pass then the PR is created Rate limiting will be implemented at a later PR ## Summary by CodeRabbit * **New Features** * Added a clear message in the PR creation modal informing users that wallet signature is required for verification, with no on-chain transaction involved. * Introduced wallet-based signature verification for PR creation, enhancing security and authenticity. * Implemented deterministic branch naming to prevent duplicate PRs for the same configuration. * **Bug Fixes** * Prevented duplicate pull requests by checking for existing branches with the same configuration. * **Refactor** * Improved internal structure for PR creation and signature verification processes to enhance maintainability. * **Chores** * Added utility for sorting object keys to ensure consistent configuration handling. --- src/features/deployment/github.ts | 67 +++++++++++-- src/flows/LandingCard.tsx | 2 +- src/pages/api/create-pr.ts | 154 ++++++++++++++++++++++++++++-- src/types/createPr.ts | 13 +++ src/utils/object.ts | 12 +++ 5 files changed, 229 insertions(+), 19 deletions(-) create mode 100644 src/utils/object.ts diff --git a/src/features/deployment/github.ts b/src/features/deployment/github.ts index c51793a..d928ed7 100644 --- a/src/features/deployment/github.ts +++ b/src/features/deployment/github.ts @@ -1,27 +1,58 @@ import { BaseRegistry } from '@hyperlane-xyz/registry'; -import { WarpCoreConfig, WarpRouteDeployConfig } from '@hyperlane-xyz/sdk'; +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, CreatePrResponse, GithubIdentity } from '../../types/createPr'; +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 } from './utils'; +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: (githubInformation: GithubIdentity) => { + mutationFn: async (githubInformation: GithubIdentity) => { if (!config.config || config.type !== DeploymentType.Warp) return Promise.resolve(null); if (!result?.result || result.type !== DeploymentType.Warp) return Promise.resolve(null); - return createWarpRoutePR(config.config, result.result, githubInformation); + 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: signature, + message, + signature, + }, + }); }, retry: false, onSuccess, @@ -38,11 +69,11 @@ export function useCreateWarpRoutePR(onSuccess: () => void) { }; } -async function createWarpRoutePR( +function getPrCreationBody( deployConfig: WarpRouteDeployConfig, warpConfig: WarpCoreConfig, githubInformation: GithubIdentity, -): Promise { +) { const firstNonSynthetic = Object.values(deployConfig).find((c) => !isSyntheticTokenType(c.type)); if (!firstNonSynthetic || !firstNonSynthetic.symbol) @@ -53,7 +84,7 @@ async function createWarpRoutePR( const { deployConfigFilename, warpConfigFilename } = getConfigsFilename(warpRouteId); const yamlDeployConfig = stringify(deployConfig, { sortMapEntries: true }); - const yamlWarpConfig = stringify(warpConfig, { sortMapEntries: true }); + const yamlWarpConfig = stringify(sortWarpCoreConfig(warpConfig), { sortMapEntries: true }); const basePath = `${warpRoutesPath}/${symbol}`; const requestBody: CreatePrBody = { @@ -63,6 +94,10 @@ async function createWarpRoutePR( warpRouteId, }; + return requestBody; +} + +async function createWarpRoutePR(requestBody: CreatePrRequestBody): Promise { const res = await fetch('/api/create-pr', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -76,3 +111,19 @@ async function createWarpRoutePR( 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/flows/LandingCard.tsx b/src/flows/LandingCard.tsx index 0a17b3b..b6edca4 100644 --- a/src/flows/LandingCard.tsx +++ b/src/flows/LandingCard.tsx @@ -40,7 +40,7 @@ export function LandingPage() { setPage(CardPage.WarpForm)} + onClick={() => setPage(CardPage.WarpSuccess)} className="w-36 px-3 py-2" > Deploy diff --git a/src/pages/api/create-pr.ts b/src/pages/api/create-pr.ts index d78e391..c36f43a 100644 --- a/src/pages/api/create-pr.ts +++ b/src/pages/api/create-pr.ts @@ -1,7 +1,17 @@ -import { WarpCoreConfigSchema, WarpRouteDeployConfigSchema } from '@hyperlane-xyz/sdk'; +import { + WarpCoreConfig, + WarpCoreConfigSchema, + WarpRouteDeployConfig, + WarpRouteDeployConfigSchema, +} from '@hyperlane-xyz/sdk'; +import { isValidAddressEvm } from '@hyperlane-xyz/utils'; +import { Octokit } from '@octokit/rest'; +import { solidityKeccak256, toUtf8Bytes } from 'ethers/lib/utils'; import humanId from 'human-id'; import type { NextApiRequest, NextApiResponse } from 'next'; +import { isHex, 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 { @@ -9,14 +19,19 @@ import { 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, @@ -34,12 +49,33 @@ export default async function handler( }); } - const requestBody = validateRequestBody(req.body); + 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 { - data: { deployConfig, warpConfig, warpRouteId, organization, username }, - } = requestBody; + 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 @@ -50,13 +86,12 @@ export default async function handler( }); const latestCommitSha = refData.object.sha; - const newBranch = `${warpRouteId}-config-${Date.now()}`; // Create new branch await octokit.git.createRef({ owner: githubForkOwner, repo: githubRepoName, - ref: `refs/heads/${newBranch}`, + ref: `refs/heads/${branchName}`, sha: latestCommitSha, }); @@ -70,7 +105,7 @@ export default async function handler( path: file.path, message: `feat: add ${file.path}`, content: Buffer.from(file.content).toString('base64'), - branch: newBranch, + branch: branchName, }); } @@ -83,7 +118,7 @@ export default async function handler( owner: githubUpstreamOwner, repo: githubRepoName, title: `feat: add ${warpRouteId} warp route deploy artifacts`, - head: `${githubForkOwner}:${newBranch}`, + 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}.` : '' @@ -96,7 +131,13 @@ export default async function handler( } } -export function validateRequestBody(body: unknown): ApiError | ApiSuccess { +export function validateRequestBody( + body: unknown, +): + | ApiError + | ApiSuccess< + CreatePrBody & { deployConfigResult: WarpRouteDeployConfig; warpConfigResult: WarpCoreConfig } + > { if (!body) return { error: 'Missing request body' }; const parsedBody = CreatePrBodySchema.safeParse(body); @@ -121,10 +162,57 @@ export function validateRequestBody(body: unknown): ApiError | ApiSuccess> { + 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) || !isValidAddressEvm(address)) + return { error: 'Address is not a valid EVM hex string' }; + if (!isHex(signature) || !isValidAddressEvm(address)) + 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 { @@ -138,5 +226,51 @@ function writeChangeset(description: string): DeployFile { ${description.trim()} `; - return { path: `.changset/${filename}`, content }; + 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 = toUtf8Bytes(JSON.stringify(sortedDeployConfig)); + const warpConfigBuffer = toUtf8Bytes(JSON.stringify(sortedWarpCoreConfig)); + + try { + const requestBodyHash = solidityKeccak256( + ['string', 'bytes', 'bytes'], + [warpRouteId, deployConfigBuffer, 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/createPr.ts b/src/types/createPr.ts index d5024ef..def57ee 100644 --- a/src/types/createPr.ts +++ b/src/types/createPr.ts @@ -44,3 +44,16 @@ export const CreatePrBodySchema = z 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/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; +} From cda7250bb10273fbd76278c1d632626a069eb636 Mon Sep 17 00:00:00 2001 From: Xaroz Date: Tue, 8 Jul 2025 11:10:49 -0400 Subject: [PATCH 20/27] fix: wrong address for verification and assertion for warpRouteId --- src/features/deployment/github.ts | 2 +- src/features/deployment/utils.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/features/deployment/github.ts b/src/features/deployment/github.ts index d928ed7..b1866dc 100644 --- a/src/features/deployment/github.ts +++ b/src/features/deployment/github.ts @@ -48,7 +48,7 @@ export function useCreateWarpRoutePR(onSuccess: () => void) { return createWarpRoutePR({ prBody, signatureVerification: { - address: signature, + address: deployer.address, message, signature, }, diff --git a/src/features/deployment/utils.ts b/src/features/deployment/utils.ts index d209d24..63b76d1 100644 --- a/src/features/deployment/utils.ts +++ b/src/features/deployment/utils.ts @@ -1,4 +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'; @@ -28,6 +29,8 @@ export function downloadYamlFile(config: unknown | undefined, filename: string) 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`, From 1f6a441a4dfa2f35b7da46b8f4760b6d27c5942a Mon Sep 17 00:00:00 2001 From: Xaroz Date: Tue, 8 Jul 2025 11:17:59 -0400 Subject: [PATCH 21/27] chore: change flow redirection --- src/flows/LandingCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flows/LandingCard.tsx b/src/flows/LandingCard.tsx index b6edca4..0a17b3b 100644 --- a/src/flows/LandingCard.tsx +++ b/src/flows/LandingCard.tsx @@ -40,7 +40,7 @@ export function LandingPage() { setPage(CardPage.WarpSuccess)} + onClick={() => setPage(CardPage.WarpForm)} className="w-36 px-3 py-2" > Deploy From add4bfd149dc5e065c0e92b2031dbafa310f7e21 Mon Sep 17 00:00:00 2001 From: Xaroz Date: Tue, 8 Jul 2025 16:09:27 -0400 Subject: [PATCH 22/27] chore: use viem for kekkach256 --- src/components/toast/useToastError.tsx | 2 +- src/features/deployment/github.ts | 6 ++++-- src/flows/LandingCard.tsx | 2 +- src/pages/api/create-pr.ts | 15 ++++++++------- 4 files changed, 14 insertions(+), 11 deletions(-) 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/features/deployment/github.ts b/src/features/deployment/github.ts index b1866dc..d432cb2 100644 --- a/src/features/deployment/github.ts +++ b/src/features/deployment/github.ts @@ -34,8 +34,10 @@ export function useCreateWarpRoutePR(onSuccess: () => void) { const { isPending, mutate, mutateAsync, error, data } = useMutation({ mutationKey: ['createWarpRoutePr', config, result], mutationFn: async (githubInformation: GithubIdentity) => { - if (!config.config || config.type !== DeploymentType.Warp) return Promise.resolve(null); - if (!result?.result || result.type !== DeploymentType.Warp) return Promise.resolve(null); + 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'); diff --git a/src/flows/LandingCard.tsx b/src/flows/LandingCard.tsx index 0a17b3b..b6edca4 100644 --- a/src/flows/LandingCard.tsx +++ b/src/flows/LandingCard.tsx @@ -40,7 +40,7 @@ export function LandingPage() { setPage(CardPage.WarpForm)} + onClick={() => setPage(CardPage.WarpSuccess)} className="w-36 px-3 py-2" > Deploy diff --git a/src/pages/api/create-pr.ts b/src/pages/api/create-pr.ts index c36f43a..a48e792 100644 --- a/src/pages/api/create-pr.ts +++ b/src/pages/api/create-pr.ts @@ -6,10 +6,9 @@ import { } from '@hyperlane-xyz/sdk'; import { isValidAddressEvm } from '@hyperlane-xyz/utils'; import { Octokit } from '@octokit/rest'; -import { solidityKeccak256, toUtf8Bytes } from 'ethers/lib/utils'; import humanId from 'human-id'; import type { NextApiRequest, NextApiResponse } from 'next'; -import { isHex, verifyMessage } from 'viem'; +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'; @@ -237,13 +236,15 @@ function getBranchName( const sortedDeployConfig = sortObjByKeys(deployConfig); const sortedWarpCoreConfig = sortObjByKeys(sortWarpCoreConfig(warpConfig)!); - const deployConfigBuffer = toUtf8Bytes(JSON.stringify(sortedDeployConfig)); - const warpConfigBuffer = toUtf8Bytes(JSON.stringify(sortedWarpCoreConfig)); + const deployConfigBuffer = toBytes(JSON.stringify(sortedDeployConfig)); + const warpConfigBuffer = toBytes(JSON.stringify(sortedWarpCoreConfig)); try { - const requestBodyHash = solidityKeccak256( - ['string', 'bytes', 'bytes'], - [warpRouteId, deployConfigBuffer, warpConfigBuffer], + const requestBodyHash = keccak256( + encodePacked( + ['string', 'bytes', 'bytes'], + [warpRouteId, toHex(deployConfigBuffer), toHex(warpConfigBuffer)], + ), ); return { success: true, data: `${warpRouteId}-${requestBodyHash}` }; } catch { From 23c8fdda44219d95813478bd5b369d5a13c040bf Mon Sep 17 00:00:00 2001 From: Xaroz Date: Tue, 8 Jul 2025 16:37:44 -0400 Subject: [PATCH 23/27] fix: attempt to fix build by excluding rainbow kit --- next.config.js | 42 +++++++++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/next.config.js b/next.config.js index d7ef0fd..5ea4b5a 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,20 @@ const nextConfig = { }, reactStrictMode: true, -} + outputFileTracingExcludes: { + '/api/create-pr': [ + './node_modules/@rainbow-me/rainbowkit/**/*', + './node_modules/@vanilla-extract/**/*', + ], + }, +}; 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 +107,4 @@ const sentryOptions = { }, }; -module.exports = withBundleAnalyzer(withSentryConfig(nextConfig, sentryOptions)); \ No newline at end of file +module.exports = withBundleAnalyzer(withSentryConfig(nextConfig, sentryOptions)); From f40f613fa0a4a8b086c7d52edb2c5c5bddc982b2 Mon Sep 17 00:00:00 2001 From: Xaroz Date: Tue, 8 Jul 2025 16:57:47 -0400 Subject: [PATCH 24/27] chore: attempt fix with standalone flag --- next.config.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/next.config.js b/next.config.js index 5ea4b5a..434161e 100755 --- a/next.config.js +++ b/next.config.js @@ -86,12 +86,7 @@ const nextConfig = { }, reactStrictMode: true, - outputFileTracingExcludes: { - '/api/create-pr': [ - './node_modules/@rainbow-me/rainbowkit/**/*', - './node_modules/@vanilla-extract/**/*', - ], - }, + output: 'standalone', }; const sentryOptions = { From d67a70017520d6dc0792d81d3531908b5a4c7be8 Mon Sep 17 00:00:00 2001 From: Xaroz Date: Tue, 8 Jul 2025 17:10:00 -0400 Subject: [PATCH 25/27] chore: temporary remove addressEvmCheck --- next.config.js | 1 - src/pages/api/create-pr.ts | 7 ++----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/next.config.js b/next.config.js index 434161e..2916873 100755 --- a/next.config.js +++ b/next.config.js @@ -86,7 +86,6 @@ const nextConfig = { }, reactStrictMode: true, - output: 'standalone', }; const sentryOptions = { diff --git a/src/pages/api/create-pr.ts b/src/pages/api/create-pr.ts index a48e792..5c2e676 100644 --- a/src/pages/api/create-pr.ts +++ b/src/pages/api/create-pr.ts @@ -4,7 +4,6 @@ import { WarpRouteDeployConfig, WarpRouteDeployConfigSchema, } from '@hyperlane-xyz/sdk'; -import { isValidAddressEvm } from '@hyperlane-xyz/utils'; import { Octokit } from '@octokit/rest'; import humanId from 'human-id'; import type { NextApiRequest, NextApiResponse } from 'next'; @@ -179,10 +178,8 @@ async function validateRequestSignature( const { address, message, signature } = parsedSignatureBody.data; - if (!isHex(address) || !isValidAddressEvm(address)) - return { error: 'Address is not a valid EVM hex string' }; - if (!isHex(signature) || !isValidAddressEvm(address)) - return { error: 'Signature is a not a valid EVM hex string' }; + 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({ From 25d8f0edbc74e4eaa457e4f46be12c4c581485af Mon Sep 17 00:00:00 2001 From: Xaroz Date: Mon, 14 Jul 2025 10:41:47 -0400 Subject: [PATCH 26/27] chore: return redirect flow --- src/flows/LandingCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flows/LandingCard.tsx b/src/flows/LandingCard.tsx index b6edca4..0a17b3b 100644 --- a/src/flows/LandingCard.tsx +++ b/src/flows/LandingCard.tsx @@ -40,7 +40,7 @@ export function LandingPage() { setPage(CardPage.WarpSuccess)} + onClick={() => setPage(CardPage.WarpForm)} className="w-36 px-3 py-2" > Deploy From 5ae6b0df07502a8915d38ac30064db7d7ec20ef5 Mon Sep 17 00:00:00 2001 From: Xaroz Date: Mon, 14 Jul 2025 12:22:32 -0400 Subject: [PATCH 27/27] chore: optimize imports --- next.config.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/next.config.js b/next.config.js index 2916873..6f3c164 100755 --- a/next.config.js +++ b/next.config.js @@ -86,6 +86,17 @@ 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 = {