diff --git a/apps/iframe/src/components/requests/WalletSwitchEthereumChain.tsx b/apps/iframe/src/components/requests/WalletSwitchEthereumChain.tsx index 1b3d6553df..21e307da99 100644 --- a/apps/iframe/src/components/requests/WalletSwitchEthereumChain.tsx +++ b/apps/iframe/src/components/requests/WalletSwitchEthereumChain.tsx @@ -1,6 +1,7 @@ import { useAtomValue } from "jotai" import { RequestDisabled } from "#src/components/requests/common/RequestDisabled" -import { chainsAtom, currentChainAtom } from "#src/state/chains" +import { currentChainAtom } from "#src/state/chains" +import { getChains } from "#src/state/chains/index" import { Layout } from "./common/Layout" import type { RequestConfirmationProps } from "./props" @@ -10,7 +11,7 @@ export const WalletSwitchEthereumChain = ({ reject, accept, }: RequestConfirmationProps<"wallet_switchEthereumChain">) => { - const chains = useAtomValue(chainsAtom) + const chains = getChains() const currentChain = useAtomValue(currentChainAtom) const chain = chains[params[0].chainId] const headline = "Switch chain" diff --git a/apps/iframe/src/connections/GoogleConnector.ts b/apps/iframe/src/connections/GoogleConnector.ts index bfed61901f..e434d9e918 100644 --- a/apps/iframe/src/connections/GoogleConnector.ts +++ b/apps/iframe/src/connections/GoogleConnector.ts @@ -3,7 +3,7 @@ import { type AuthProvider, GoogleAuthProvider } from "firebase/auth" import type { EIP1193Provider } from "viem" import { setUserWithProvider } from "#src/actions/setUserWithProvider" import { StorageKey, storage } from "#src/services/storage" -import { getChains } from "#src/state/chains" +import { getChains } from "#src/state/chains/index" import { grantPermissions } from "#src/state/permissions" import { getUser } from "#src/state/user" import { getAppURL } from "#src/utils/appURL" diff --git a/apps/iframe/src/requests/checkIfRequestRequiresConfirmation.ts b/apps/iframe/src/requests/checkIfRequestRequiresConfirmation.ts index 6a3985f71e..22731cff99 100644 --- a/apps/iframe/src/requests/checkIfRequestRequiresConfirmation.ts +++ b/apps/iframe/src/requests/checkIfRequestRequiresConfirmation.ts @@ -4,7 +4,8 @@ import { requiresApproval } from "@happy.tech/wallet-common" import { PermissionName } from "#src/constants/permissions" import { checkAndChecksumAddress, hasNonZeroValue } from "#src/requests/utils/checks" import { type SessionKeysByHappyUser, StorageKey, storage } from "#src/services/storage" -import { getChains, getCurrentChain } from "#src/state/chains" +import { getChains } from "#src/state/chains/index" +import { getCurrentChain } from "#src/state/chains" import { hasPermissions } from "#src/state/permissions" import { getUser } from "#src/state/user" import type { AppURL } from "#src/utils/appURL" diff --git a/apps/iframe/src/requests/handlers/approved.ts b/apps/iframe/src/requests/handlers/approved.ts index 5d36840080..b186be1b94 100644 --- a/apps/iframe/src/requests/handlers/approved.ts +++ b/apps/iframe/src/requests/handlers/approved.ts @@ -5,7 +5,8 @@ import { checkAndChecksumAddress, checkedTx, checkedWatchedAsset } from "#src/re import { sendToWalletClient } from "#src/requests/utils/sendToClient" import { installNewSessionKey } from "#src/requests/utils/sessionKeys" import { eoaSigner } from "#src/requests/utils/signers" -import { getChains, getCurrentChain, setChains, setCurrentChain } from "#src/state/chains" +import { getChains, setChain } from "#src/state/chains/index" +import { getCurrentChain, setCurrentChain } from "#src/state/chains" import { loadAbiForUser } from "#src/state/loadedAbis" import { grantPermissions } from "#src/state/permissions" import { checkUser, getUser } from "#src/state/user" @@ -54,7 +55,7 @@ export async function dispatchApprovedRequest(request: PopupMsgs[Msgs.PopupAppro const response = await sendToWalletClient(request.payload) // Only add chain if the request is successful. - setChains((prev) => ({ ...prev, [params.chainId]: params })) + setChain(params) return response } diff --git a/apps/iframe/src/requests/handlers/injected.ts b/apps/iframe/src/requests/handlers/injected.ts index 34b233c459..8ef6b9a1a4 100644 --- a/apps/iframe/src/requests/handlers/injected.ts +++ b/apps/iframe/src/requests/handlers/injected.ts @@ -26,7 +26,8 @@ import { getTransactionReceipt, } from "#src/requests/utils/shared" import { eoaSigner, sessionKeySigner } from "#src/requests/utils/signers" -import { getChains, setChains, setCurrentChain } from "#src/state/chains" +import { setCurrentChain } from "#src/state/chains" +import { getChains, setChain } from "#src/state/chains/index" import { revokedSessionKeys } from "#src/state/interfaceState" import { loadAbiForUser } from "#src/state/loadedAbis" import { getPermissions, grantPermissions, revokePermissions } from "#src/state/permissions" @@ -193,7 +194,7 @@ export async function dispatchInjectedRequest(request: ProviderMsgsFromApp[Msgs. const resp = await sendToWalletClient(request.payload) - setChains((prev) => ({ ...prev, [params.chainId]: params })) + setChain(params) // Some wallets (Metamask, Rabby, ...) automatically switch to the newly-added chain. // Normalize behavior by always switching. diff --git a/apps/iframe/src/state/chains.ts b/apps/iframe/src/state/chains.ts index aea45b9c7d..57ac0c799a 100644 --- a/apps/iframe/src/state/chains.ts +++ b/apps/iframe/src/state/chains.ts @@ -1,41 +1,11 @@ import { accessorsFromAtom } from "@happy.tech/common" -import { chainDefinitions as defaultChains } from "@happy.tech/wallet-common" import type { ChainParameters } from "@happy.tech/wallet-common" import { type WritableAtom, atom } from "jotai" -import { atomWithStorage } from "jotai/utils" import type { AddEthereumChainParameter } from "viem" -import { StorageKey } from "../services/storage" +import { getChainFromSearchParams } from "./chains/index" // NOTE: If `HAPPY_RPC_OVERRIDE` is set, the RPC URL of all default chains will be set to that RPC server. -export function getChainFromSearchParams(): ChainParameters { - const chainId = new URLSearchParams(window.location.search).get("chainId") - const chainKey = chainId && `0x${BigInt(chainId).toString(16)}` - const chains = getChains() - return chainKey && chainKey in chains // - ? chains[chainKey] - : defaultChains.defaultChain -} - -function getDefaultChainsRecord() { - return Object.fromEntries(Object.entries(defaultChains).map(([_, chain]) => [chain.chainId, chain])) -} - -/** - * This atom maps chain IDs to their respective chain parameters. Initialized with the officially - * supported chains. - */ -export const chainsAtom = atomWithStorage< - Record // ->(StorageKey.Chains, getDefaultChainsRecord(), undefined, { getOnInit: true }) - -export const { - /** See {@link chainsAtom} */ - getValue: getChains, - /** See {@link chainsAtom} */ - setValue: setChains, -} = accessorsFromAtom(chainsAtom) - /** * This atom stores the current configuration of the chain that the iframe is connected to. * diff --git a/apps/iframe/src/state/chains/index.ts b/apps/iframe/src/state/chains/index.ts new file mode 100644 index 0000000000..b9d9c0af5b --- /dev/null +++ b/apps/iframe/src/state/chains/index.ts @@ -0,0 +1,20 @@ +import { chainDefinitions as defaultChains, type ChainParameters } from "@happy.tech/wallet-common" +import { chainsLegend } from "./observable" + + +export function getChainFromSearchParams(): ChainParameters { + const chainId = new URLSearchParams(window.location.search).get("chainId") + const chainKey = chainId && `0x${BigInt(chainId).toString(16)}` + const chains = chainsLegend.get() + return chainKey && chainKey in chains // + ? chains[chainKey] + : defaultChains.defaultChain +} + +export function getChains() { + return chainsLegend.get() +} + +export function setChain(chain: ChainParameters) { + chainsLegend[chain.chainId].set(chain) +} \ No newline at end of file diff --git a/apps/iframe/src/state/chains/observable.ts b/apps/iframe/src/state/chains/observable.ts new file mode 100644 index 0000000000..3a155c01b2 --- /dev/null +++ b/apps/iframe/src/state/chains/observable.ts @@ -0,0 +1,108 @@ +import { observable } from "@legendapp/state" +import { ObservablePersistLocalStorage } from "@legendapp/state/persist-plugins/local-storage" +import { chainDefinitions as defaultChains } from "@happy.tech/wallet-common" +import { syncedCrud } from "@legendapp/state/sync-plugins/crud" +import { deploymentVar } from "#src/env.ts" +import { getUser } from "../user" +import type { AddEthereumChainParameter } from "viem" + +const SYNC_SERVICE_URL = deploymentVar("VITE_SYNC_SERVICE_URL") + +function getDefaultChainsRecord() { + return Object.fromEntries(Object.entries(defaultChains).map(([_, chain]) => [chain.chainId, chain])) +} + +export const chainsLegend = observable( + syncedCrud({ + list: async ({ lastSync }) => { + const user = getUser() + if (!user) return [] + + const response = await fetch( + `${SYNC_SERVICE_URL}/api/v1/settings/list?type=Chain&user=${user.address}${lastSync ? `&lastUpdated=${lastSync}` : ""}`, + ) + const data = await response.json() + + return data.data as AddEthereumChainParameter[] + }, + create: async (data: AddEthereumChainParameter) => { + const user = getUser() + if (!user) return + + const response = await fetch(`${SYNC_SERVICE_URL}/api/v1/settings/create`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + ...data, + id: `${user.address}-${data.chainId}`, + type: "Chain", + user: user.address, + createdAt: Date.now(), + updatedAt: Date.now(), + deleted: false, + }), + }) + await response.json() + }, + update: async (data: AddEthereumChainParameter) => { + const user = getUser() + if (!user) return + + const response = await fetch(`${SYNC_SERVICE_URL}/api/v1/settings/update`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + ...data, + id: `${user.address}-${data.chainId}`, + type: "Chain", + user: user.address, + createdAt: Date.now(), + updatedAt: Date.now(), + deleted: false, + }), + }) + await response.json() + }, + subscribe: ({ refresh }) => { + const user = getUser() + if (!user) return + const eventSource = new EventSource(`${SYNC_SERVICE_URL}/api/v1/settings/subscribe?user=${user.address}`) + eventSource.addEventListener("config.changed", (event) => { + const data = JSON.parse(event.data) + console.log("Received update", data) + refresh() + }) + + return () => eventSource.close() + }, + delete: async ({ chainId }) => { + const user = getUser() + if (!user) return + + const response = await fetch(`${SYNC_SERVICE_URL}/api/v1/settings/delete`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ id: `${user.address}-${chainId}` }), + }) + await response.json() + }, + persist: { + plugin: ObservablePersistLocalStorage, + name: "chains-legend", + retrySync: true, // Retry sync after reload + }, + initial: getDefaultChainsRecord(), + fieldCreatedAt: "createdAt", + fieldUpdatedAt: "updatedAt", + fieldDeleted: "deleted", + changesSince: "last-sync", + updatePartial: true, + }), +) + diff --git a/apps/sync-service/src/db/migrations/Migration20250626143000.ts b/apps/sync-service/src/db/migrations/Migration20250626143000.ts new file mode 100644 index 0000000000..d573a6b5f4 --- /dev/null +++ b/apps/sync-service/src/db/migrations/Migration20250626143000.ts @@ -0,0 +1,22 @@ +import type { Kysely } from "kysely" +import type { Database } from "../types" + +export async function up(db: Kysely) { + await db.schema + .createTable("chains") + .addColumn("user", "text", (col) => col.notNull()) + .addColumn("chainId", "text", (col) => col.notNull()) + .addColumn("chainName", "text", (col) => col.notNull()) + .addColumn("rpcUrls", "text", (col) => col.notNull()) + .addColumn("nativeCurrency", "json") + .addColumn("blockExplorerUrls", "json") + .addColumn("iconUrls", "json") + .addColumn("opStack", "boolean") + .addColumn("id", "text", (col) => col.notNull().primaryKey()) + .addColumn("updatedAt", "integer", (col) => col.notNull()) + .addColumn("createdAt", "integer", (col) => col.notNull()) + .addColumn("deleted", "boolean", (col) => col.notNull()) + .execute() +} + +export const migration20250626143000 = { up } diff --git a/apps/sync-service/src/db/migrations/index.ts b/apps/sync-service/src/db/migrations/index.ts index fbb32073b9..906a1dd1ac 100644 --- a/apps/sync-service/src/db/migrations/index.ts +++ b/apps/sync-service/src/db/migrations/index.ts @@ -1,7 +1,9 @@ import { migration20250515123000 } from "./Migration20250515123000" import { migration20250623143000 } from "./Migration20250623143000" +import { migration20250626143000 } from "./Migration20250626143000" export const migrations = { "20250515123000": migration20250515123000, "20250623143000": migration20250623143000, + "20250626143000": migration20250626143000, } diff --git a/apps/sync-service/src/db/types.ts b/apps/sync-service/src/db/types.ts index 7024ee3f6b..b3d681d143 100644 --- a/apps/sync-service/src/db/types.ts +++ b/apps/sync-service/src/db/types.ts @@ -46,7 +46,27 @@ export type WatchAssetRow = { deleted: ColumnType } +export type ChainRow = { + user: Hex + chainId: string + chainName: string + rpcUrls: ColumnType; + nativeCurrency?: ColumnType<{ + symbol: string; + decimals: number; + name: string; + }, string, string> + blockExplorerUrls?: ColumnType | undefined; + iconUrls?: ColumnType | undefined; + opStack?: ColumnType + id: string + updatedAt: number + createdAt: number + deleted: ColumnType +} + export interface Database { walletPermissions: WalletPermissionRow watchedAssets: WatchAssetRow + chains: ChainRow } diff --git a/apps/sync-service/src/dtos.ts b/apps/sync-service/src/dtos.ts index 1384af5f0a..14a9e1a269 100644 --- a/apps/sync-service/src/dtos.ts +++ b/apps/sync-service/src/dtos.ts @@ -97,3 +97,45 @@ export const watchAssetUpdate = watchAsset.partial().extend({ }) export type WatchAssetUpdate = z.infer + +export const chain = z.object({ + chainId: z.string().openapi({ example: "0xd8" }), + chainName: z.string().openapi({ example: "HappyChain Sepolia" }), + nativeCurrency: z.object({ + name: z.string().openapi({ example: "HappyChain" }), + symbol: z.string().openapi({ example: "HAPPY" }), + decimals: z.number().openapi({ example: 18 }) + }).optional(), + rpcUrls: z.array(z.string()), + blockExplorerUrls: z.array(z.string()).optional().openapi({ example: ["https://explorer.testnet.happy.tech"] }), + iconUrls: z.array(z.string()).optional(), + opStack: z.boolean().optional().openapi({ example: true }), + type: z.literal("Chain").openapi({ + example: "Chain", + type: "string", + }), + user: z.string().refine(isAddress).transform(checksum).openapi({ + example: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + type: "string", + }), + id: z.string().openapi({ example: "78b7d642-e851-4f0f-9cd6-a47c6c2a572a" }), + updatedAt: z.number().openapi({ example: 1715702400 }), + createdAt: z.number().openapi({ example: 1715702400 }), + deleted: z.boolean().openapi({ example: false }), +}) + +export type Chain = z.infer + +export const chainUpdate = chain.partial().extend({ + type: z.literal("Chain").openapi({ + example: "Chain", + type: "string", + }), + user: z.string().refine(isAddress).transform(checksum).openapi({ + example: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + type: "string", + }), + id: z.string().openapi({ example: "78b7d642-e851-4f0f-9cd6-a47c6c2a572a" }), +}) + +export type ChainUpdate = z.infer \ No newline at end of file diff --git a/apps/sync-service/src/handlers/createConfig/createConfig.ts b/apps/sync-service/src/handlers/createConfig/createConfig.ts index 83e00d6828..10aa7493a0 100644 --- a/apps/sync-service/src/handlers/createConfig/createConfig.ts +++ b/apps/sync-service/src/handlers/createConfig/createConfig.ts @@ -4,12 +4,15 @@ import { savePermission } from "../../repositories/permissionsRepository" import { saveWatchedAsset } from "../../repositories/watchAssetsRepository" import { notifyUpdates } from "../../services/notifyUpdates" import type { CreateConfigInput } from "./types" +import { saveChain } from "../../repositories/chainRepository" export async function createConfig(input: CreateConfigInput): Promise> { if (input.type === "WalletPermissions") { await savePermission(input) } else if (input.type === "ERC20") { await saveWatchedAsset(input) + } else if (input.type === "Chain") { + await saveChain(input) } notifyUpdates({ diff --git a/apps/sync-service/src/handlers/createConfig/validation.ts b/apps/sync-service/src/handlers/createConfig/validation.ts index 344fe75c6c..fe248720fd 100644 --- a/apps/sync-service/src/handlers/createConfig/validation.ts +++ b/apps/sync-service/src/handlers/createConfig/validation.ts @@ -2,11 +2,11 @@ import { describeRoute } from "hono-openapi" import { resolver } from "hono-openapi/zod" import { validator as zv } from "hono-openapi/zod" import { z } from "zod" -import { walletPermission, watchAsset } from "../../dtos" +import { chain, walletPermission, watchAsset } from "../../dtos" import { isProduction } from "../../utils/isProduction" -export const inputSchema: z.ZodDiscriminatedUnion<"type", [typeof walletPermission, typeof watchAsset]> = - z.discriminatedUnion("type", [walletPermission, watchAsset]) +export const inputSchema: z.ZodDiscriminatedUnion<"type", [typeof walletPermission, typeof watchAsset, typeof chain]> = + z.discriminatedUnion("type", [walletPermission, watchAsset, chain]) export const outputSchema = z.object({ success: z.boolean(), diff --git a/apps/sync-service/src/handlers/deleteConfig/deleteConfig.ts b/apps/sync-service/src/handlers/deleteConfig/deleteConfig.ts index 66e35800e3..ae33973599 100644 --- a/apps/sync-service/src/handlers/deleteConfig/deleteConfig.ts +++ b/apps/sync-service/src/handlers/deleteConfig/deleteConfig.ts @@ -4,11 +4,13 @@ import { deletePermission, getPermission } from "../../repositories/permissionsR import { deleteWatchedAsset, getWatchedAsset } from "../../repositories/watchAssetsRepository" import { notifyUpdates } from "../../services/notifyUpdates" import type { DeleteConfigInput } from "./types" +import { deleteChain, getChain } from "../../repositories/chainRepository" export async function deleteConfig(input: DeleteConfigInput): Promise> { const permission = await getPermission(input.id) const watchedAsset = await getWatchedAsset(input.id) - + const chain = await getChain(input.id) + let user: Address if (permission) { await deletePermission(input.id) @@ -16,6 +18,9 @@ export async function deleteConfig(input: DeleteConfigInput): Promise> { - const config: (WalletPermission | WatchAsset)[] = [] +export async function listConfig(input: ListConfigInput): Promise> { + const config: (WalletPermission | WatchAsset | Chain)[] = [] if (input.type === "WalletPermissions" || input.type === undefined) { const permissions = await listPermissions(input.user, input.lastUpdated) config.push(...permissions) @@ -14,6 +15,9 @@ export async function listConfig(input: ListConfigInput): Promise> { if (input.type === "WalletPermissions") { await savePermission(input) } else if (input.type === "ERC20") { await saveWatchedAsset(input) + } else if (input.type === "Chain") { + await saveChain(input) } notifyUpdates({ diff --git a/apps/sync-service/src/handlers/updateConfig/validation.ts b/apps/sync-service/src/handlers/updateConfig/validation.ts index 971ddab2d8..0c3069cfc8 100644 --- a/apps/sync-service/src/handlers/updateConfig/validation.ts +++ b/apps/sync-service/src/handlers/updateConfig/validation.ts @@ -2,11 +2,11 @@ import { describeRoute } from "hono-openapi" import { resolver } from "hono-openapi/zod" import { validator as zv } from "hono-openapi/zod" import { z } from "zod" -import { walletPermissionUpdate, watchAssetUpdate } from "../../dtos" +import { walletPermissionUpdate, watchAssetUpdate, chainUpdate } from "../../dtos" import { isProduction } from "../../utils/isProduction" -export const inputSchema: z.ZodDiscriminatedUnion<"type", [typeof walletPermissionUpdate, typeof watchAssetUpdate]> = - z.discriminatedUnion("type", [walletPermissionUpdate, watchAssetUpdate]) +export const inputSchema: z.ZodDiscriminatedUnion<"type", [typeof walletPermissionUpdate, typeof watchAssetUpdate, typeof chainUpdate]> = + z.discriminatedUnion("type", [walletPermissionUpdate, watchAssetUpdate, chainUpdate]) export const outputSchema = z.object({ success: z.boolean(), diff --git a/apps/sync-service/src/repositories/chainRepository.ts b/apps/sync-service/src/repositories/chainRepository.ts new file mode 100644 index 0000000000..8a10a06e9e --- /dev/null +++ b/apps/sync-service/src/repositories/chainRepository.ts @@ -0,0 +1,65 @@ +import type { Hex } from "@happy.tech/common" +import type { Insertable, Selectable } from "kysely" +import { db } from "../db/driver" +import type { ChainRow } from "../db/types" +import type { Chain, ChainUpdate } from "../dtos" +import { nullToUndefined } from "../utils/nullToUndefined" + +function fromDtoToDbUpdate(chain: ChainUpdate): Partial> { + const { nativeCurrency, blockExplorerUrls, iconUrls, rpcUrls, type, ...rest } = chain + return { + ...rest, + ...(nativeCurrency && { nativeCurrency: JSON.stringify(nativeCurrency) }), + ...(blockExplorerUrls && { blockExplorerUrls: JSON.stringify(blockExplorerUrls) }), + ...(iconUrls && { iconUrls: JSON.stringify(iconUrls) }), + ...(rpcUrls && { rpcUrls: JSON.stringify(rpcUrls) }), + updatedAt: Date.now(), + } +} + +function fromDbToDto(chain: Selectable): Chain { + return nullToUndefined({ + type: "Chain", + ...chain, + opStack: chain.opStack === 1, + deleted: chain.deleted === 1, + }) +} + +export function getChain(id: string) { + return db.selectFrom("chains").where("id", "=", id).selectAll().executeTakeFirst() +} + +export async function listChains(user: Hex, lastUpdated?: number): Promise { + const result = await db + .selectFrom("chains") + .where("user", "=", user) + .$if(lastUpdated !== undefined, (qb) => qb.where("updatedAt", ">", lastUpdated as number)) + .selectAll() + .execute() + return result.map(fromDbToDto) +} + +export async function saveChain(chain: ChainUpdate) { + const existing = await getChain(chain.id) + if (existing) { + return await db + .updateTable("chains") + .set(fromDtoToDbUpdate(chain)) + .where("id", "=", chain.id) + .execute() + } + + return await db + .insertInto("chains") + .values(fromDtoToDbUpdate(chain) as Insertable) + .execute() +} + +export async function deleteChain(id: string) { + return await db + .updateTable("chains") + .set({ deleted: true, updatedAt: Date.now() }) + .where("id", "=", id) + .execute() +} diff --git a/apps/sync-service/src/utils/nullToUndefined.ts b/apps/sync-service/src/utils/nullToUndefined.ts new file mode 100644 index 0000000000..9f01f517cb --- /dev/null +++ b/apps/sync-service/src/utils/nullToUndefined.ts @@ -0,0 +1,5 @@ +export function nullToUndefined>(value: T): T { + return Object.fromEntries( + Object.entries(value).map(([key, value]) => [key, value === null ? undefined : value]) + ) as T +} \ No newline at end of file