diff --git a/bun.lockb b/bun.lockb index e9ff3b77..e96daddb 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index c43f9098..370b64e7 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "dist" ], "devDependencies": { - "@solana/actions-spec": "^2.2.0", + "@solana/actions-spec": "~2.2.0", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@typescript-eslint/eslint-plugin": "^7.16.1", diff --git a/src/api/Action/Action.ts b/src/api/Action/Action.ts index c3dc2a46..dde3f81a 100644 --- a/src/api/Action/Action.ts +++ b/src/api/Action/Action.ts @@ -17,11 +17,18 @@ import { MultiValueActionComponent, SingleValueActionComponent, } from './action-components'; +import { + type ActionSupportStrategy, + BASELINE_ACTION_BLOCKCHAIN_IDS, + BASELINE_ACTION_VERSION, + defaultActionSupportStrategy, +} from './action-supportability.ts'; const MULTI_VALUE_TYPES: ActionParameterType[] = ['checkbox']; interface ActionMetadata { - blockchainIds: string[]; + blockchainIds?: string[]; + version?: string; } type ActionChainMetadata = @@ -40,6 +47,7 @@ export class Action { private readonly _url: string, private readonly _data: NextAction, private readonly _metadata: ActionMetadata, + private readonly _supportStrategy: ActionSupportStrategy, private _adapter?: ActionAdapter, private readonly _chainMetadata: ActionChainMetadata = { isChained: false }, ) { @@ -102,8 +110,17 @@ export class Action { return this._data.error?.message ?? null; } - public get metadata() { - return this._metadata; + public get metadata(): ActionMetadata { + // TODO: Remove fallback to baseline version after a few weeks after compatibility is adopted + return { + blockchainIds: + this._metadata.blockchainIds ?? BASELINE_ACTION_BLOCKCHAIN_IDS, + version: this._metadata.version ?? BASELINE_ACTION_VERSION, + }; + } + + public get adapterUnsafe() { + return this._adapter; } public get adapter() { @@ -118,15 +135,37 @@ export class Action { this._adapter = adapter; } + public async isSupported() { + try { + return await this._supportStrategy(this); + } catch (e) { + console.error( + `[@dialectlabs/blinks] Failed to check supportability for action ${this.url}`, + ); + return { + isSupported: false, + message: + 'Failed to check supportability, please contact your Blink client provider.', + }; + } + } + public async chain( next: N, chainData?: N extends PostNextActionLink ? NextActionPostRequest : never, ): Promise { if (next.type === 'inline') { - return new Action(this.url, next.action, this.metadata, this.adapter, { - isChained: true, - isInline: true, - }); + return new Action( + this.url, + next.action, + this.metadata, + this._supportStrategy, + this.adapter, + { + isChained: true, + isInline: true, + }, + ); } const baseUrlObj = new URL(this.url); @@ -161,10 +200,17 @@ export class Action { const data = (await response.json()) as NextAction; const metadata = getActionMetadata(response); - return new Action(href, data, metadata, this.adapter, { - isChained: true, - isInline: false, - }); + return new Action( + href, + data, + metadata, + this._supportStrategy, + this.adapter, + { + isChained: true, + isInline: false, + }, + ); } // be sure to use this only if the action is valid @@ -172,12 +218,17 @@ export class Action { url: string, data: NextAction, metadata: ActionMetadata, + supportStrategy: ActionSupportStrategy, adapter?: ActionAdapter, ) { - return new Action(url, data, metadata, adapter); + return new Action(url, data, metadata, supportStrategy, adapter); } - static async fetch(apiUrl: string, adapter?: ActionAdapter) { + static async fetch( + apiUrl: string, + adapter?: ActionAdapter, + supportStrategy: ActionSupportStrategy = defaultActionSupportStrategy, + ) { const proxyUrl = proxify(apiUrl); const response = await fetch(proxyUrl, { headers: { @@ -194,19 +245,27 @@ export class Action { const data = (await response.json()) as ActionGetResponse; const metadata = getActionMetadata(response); - return new Action(apiUrl, { ...data, type: 'action' }, metadata, adapter); + return new Action( + apiUrl, + { ...data, type: 'action' }, + metadata, + supportStrategy, + adapter, + ); } } const getActionMetadata = (response: Response): ActionMetadata => { - // for multi-chain x-blockchain-ids - const blockchainIds = ( - response?.headers?.get('x-blockchain-ids') || '' - ).split(','); + const blockchainIds = response.headers + .get('x-blockchain-ids') + ?.split(',') + .map((id) => id.trim()); + const version = response.headers.get('x-action-version')?.trim(); return { blockchainIds, - } satisfies ActionMetadata; + version, + }; }; const componentFactory = ( diff --git a/src/api/Action/action-supportability.ts b/src/api/Action/action-supportability.ts new file mode 100644 index 00000000..cebad8e5 --- /dev/null +++ b/src/api/Action/action-supportability.ts @@ -0,0 +1,183 @@ +import { BlockchainIds, getShortBlockchainName } from '../../utils/caip-2.ts'; +import { ACTIONS_SPEC_VERSION } from '../../utils/dependency-versions.ts'; +import type { Action } from './Action.ts'; + +/** + * Max spec version the Blink client supports. + */ +export const MAX_SUPPORTED_ACTION_VERSION = ACTIONS_SPEC_VERSION; + +export const DEFAULT_SUPPORTED_BLOCKCHAIN_IDS = [ + BlockchainIds.SOLANA_MAINNET, + BlockchainIds.SOLANA_DEVNET, +]; + +/** + * Baseline action version to be used when not set by action provider. + * Defaults to latest release that doesn't support versioning. + */ +export const BASELINE_ACTION_VERSION = '2.2'; +/** + * Baseline blockchain IDs to be used when not set by action provider. + * Defaults to Solana mainnet. + */ +export const BASELINE_ACTION_BLOCKCHAIN_IDS = [BlockchainIds.SOLANA_MAINNET]; + +type IsVersionSupportedParams = { + actionVersion: string; + supportedActionVersion: string; +}; + +type IsBlockchainIdSupportedParams = { + actionBlockchainIds: string[]; + supportedBlockchainIds: string[]; +}; + +export type ActionSupportability = + | { + isSupported: true; + } + | { + isSupported: false; + message: string; + }; + +export type ActionSupportStrategy = ( + action: Action, +) => Promise; + +/** + * Default implementation for checking if an action is supported. + * Checks if the action version and the action blockchain IDs are supported by blink. + * @param action Action. + * + * @see {isVersionSupported} + * @see {isBlockchainSupported} + */ +export const defaultActionSupportStrategy: ActionSupportStrategy = async ( + action, +) => { + const { version: actionVersion, blockchainIds: actionBlockchainIds } = + action.metadata; + + // Will be displayed in the future once we remove backward compatibility fallbacks for blockchains and version + if ( + !actionVersion || + !actionBlockchainIds || + actionBlockchainIds.length === 0 + ) { + return { + isSupported: false, + message: + 'Action compatibility metadata is not set. Please contact the action provider.', + }; + } + + const supportedActionVersion = MAX_SUPPORTED_ACTION_VERSION; + const supportedBlockchainIds = !action.adapterUnsafe + ? actionBlockchainIds // Assuming action is supported if adapter absent for optimistic compatibility + : action.adapterUnsafe.metadata.supportedBlockchainIds; + + const versionSupported = isVersionSupported({ + actionVersion, + supportedActionVersion, + }); + const blockchainSupported = isBlockchainSupported({ + actionBlockchainIds, + supportedBlockchainIds, + }); + + const notSupportedBlockchainIds = actionBlockchainIds.filter( + (id) => !supportedBlockchainIds.includes(id), + ); + + const notSupportedActionBlockchainNames = notSupportedBlockchainIds.map( + getShortBlockchainName, + ); + + if (!versionSupported && !blockchainSupported) { + const blockchainMessage = + notSupportedActionBlockchainNames.length === 1 + ? `blockchain ${notSupportedActionBlockchainNames[0]}` + : `blockchains ${notSupportedActionBlockchainNames.join(', ')}`; + return { + isSupported: false, + message: `Action version ${actionVersion} and ${blockchainMessage} are not supported by your Blink client.`, + }; + } + + if (!versionSupported) { + return { + isSupported: false, + message: `Action version ${actionVersion} is not supported by your Blink client.`, + }; + } + + if (!blockchainSupported) { + const blockchainMessage = + notSupportedActionBlockchainNames.length === 1 + ? `Action blockchain ${notSupportedActionBlockchainNames[0]} is not supported by your Blink client.` + : `Action blockchains ${notSupportedActionBlockchainNames.join(', ')} are not supported by your Blink client.`; + + return { + isSupported: false, + message: blockchainMessage, + }; + } + return { + isSupported: true, + }; +}; + +/** + * Check if the action version is supported by blink. + * @param supportedActionVersion The version the blink supports. + * @param actionVersion The version of the action. + * + * @returns `true` if the action version is less than or equal to the supported ignoring patch version, `false` otherwise. + */ +export function isVersionSupported({ + supportedActionVersion, + actionVersion, +}: IsVersionSupportedParams): boolean { + return compareSemverIgnoringPatch(actionVersion, supportedActionVersion) <= 0; +} + +function compareSemverIgnoringPatch(v1: string, v2: string): number { + const [major1, minor1] = v1.split('.').map(Number); + const [major2, minor2] = v2.split('.').map(Number); + if (major1 !== major2) { + return major1 - major2; + } else if (minor1 !== minor2) { + return minor1 - minor2; + } + return 0; +} + +/** + * Check if action blockchain IDs are supported by the blink. + * + * @param supportedBlockchainIds List of CAIP-2 blockchain IDs the client supports. + * @param actionBlockchainIds List of CAIP-2 blockchain IDs the action supports. + * + * @returns `true` if all action blockchain IDs are supported by blink, `false` otherwise. + * + * @see BlockchainIds + */ +export function isBlockchainSupported({ + supportedBlockchainIds, + actionBlockchainIds, +}: IsBlockchainIdSupportedParams): boolean { + if (actionBlockchainIds.length === 0 || supportedBlockchainIds.length === 0) { + return false; + } + const sanitizedSupportedBlockchainIds = supportedBlockchainIds.map((it) => + it.trim(), + ); + const sanitizedActionBlockchainIds = actionBlockchainIds.map((it) => + it.trim(), + ); + return sanitizedActionBlockchainIds.every((chain) => + sanitizedSupportedBlockchainIds.includes(chain), + ); +} diff --git a/src/api/Action/index.ts b/src/api/Action/index.ts index 3d6d2abe..71373a1a 100644 --- a/src/api/Action/index.ts +++ b/src/api/Action/index.ts @@ -1,2 +1,3 @@ export * from './action-components'; +export * from './action-supportability.ts'; export * from './Action.ts'; diff --git a/src/api/ActionConfig.ts b/src/api/ActionConfig.ts index 78ebf2b4..03dcdf06 100644 --- a/src/api/ActionConfig.ts +++ b/src/api/ActionConfig.ts @@ -1,6 +1,7 @@ import { Connection } from '@solana/web3.js'; import { type Action } from './Action'; import { AbstractActionComponent } from './Action/action-components'; +import { DEFAULT_SUPPORTED_BLOCKCHAIN_IDS } from './Action/action-supportability.ts'; export interface ActionContext { originalUrl: string; @@ -11,10 +12,26 @@ export interface ActionContext { export interface IncomingActionConfig { rpcUrl: string; - adapter: Pick; + adapter: Pick & + Partial>; +} + +/** + * Metadata for an action adapter. + * + * @property supportedBlockchainIds List of CAIP-2 blockchain IDs the adapter supports. + * + * @see {BlockchainIds} + */ +export interface ActionAdapterMetadata { + /** + * List of CAIP-2 blockchain IDs the adapter supports. + */ + supportedBlockchainIds: string[]; } export interface ActionAdapter { + metadata: ActionAdapterMetadata; connect: (context: ActionContext) => Promise; signTransaction: ( tx: string, @@ -24,13 +41,13 @@ export interface ActionAdapter { signature: string, context: ActionContext, ) => Promise; - isSupported?: ( - context: Omit, - ) => Promise; } export class ActionConfig implements ActionAdapter { private static readonly CONFIRM_TIMEOUT_MS = 60000 * 1.2; // 20% extra time + private static readonly DEFAULT_METADATA: ActionAdapterMetadata = { + supportedBlockchainIds: DEFAULT_SUPPORTED_BLOCKCHAIN_IDS, + }; private connection: Connection; constructor( @@ -47,12 +64,8 @@ export class ActionConfig implements ActionAdapter { : rpcUrlOrConnection; } - async connect(context: ActionContext) { - try { - return await this.adapter.connect(context); - } catch { - return null; - } + get metadata() { + return this.adapter.metadata ?? ActionConfig.DEFAULT_METADATA; } signTransaction(tx: string, context: ActionContext) { @@ -100,4 +113,12 @@ export class ActionConfig implements ActionAdapter { confirm(); }); } + + async connect(context: ActionContext) { + try { + return await this.adapter.connect(context); + } catch { + return null; + } + } } diff --git a/src/api/index.ts b/src/api/index.ts index c6f38ab6..a9e3c9c9 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,4 +1,5 @@ export * from './Action'; +export * from './Action/action-supportability.ts'; export * from './ActionCallbacks.ts'; export * from './ActionConfig'; export * from './actions-spec'; diff --git a/src/ext/twitter.tsx b/src/ext/twitter.tsx index 4ed66a48..8329e4e9 100644 --- a/src/ext/twitter.tsx +++ b/src/ext/twitter.tsx @@ -1,19 +1,21 @@ import { createRoot } from 'react-dom/client'; import { Action, + type ActionAdapter, + type ActionCallbacksConfig, ActionsRegistry, + type ActionSupportStrategy, + defaultActionSupportStrategy, getExtendedActionState, getExtendedInterstitialState, getExtendedWebsiteState, - type ActionAdapter, - type ActionCallbacksConfig, } from '../api'; import { checkSecurity, type SecurityLevel } from '../shared'; import { ActionContainer, type StylePreset } from '../ui'; import { noop } from '../utils/constants'; import { isInterstitial } from '../utils/interstitial-url.ts'; import { proxify } from '../utils/proxify.ts'; -import { ActionsURLMapper, type ActionsJsonConfig } from '../utils/url-mapper'; +import { type ActionsJsonConfig, ActionsURLMapper } from '../utils/url-mapper'; type ObserverSecurityLevel = SecurityLevel; @@ -22,6 +24,7 @@ export interface ObserverOptions { securityLevel: | ObserverSecurityLevel | Record<'websites' | 'interstitials' | 'actions', ObserverSecurityLevel>; + supportStrategy: ActionSupportStrategy; } interface NormalizedObserverOptions { @@ -29,10 +32,12 @@ interface NormalizedObserverOptions { 'websites' | 'interstitials' | 'actions', ObserverSecurityLevel >; + supportStrategy: ActionSupportStrategy; } const DEFAULT_OPTIONS: ObserverOptions = { securityLevel: 'only-trusted', + supportStrategy: defaultActionSupportStrategy, }; const normalizeOptions = ( @@ -100,6 +105,7 @@ export function setupTwitterObserver( observer.observe(twitterReactRoot, { childList: true, subtree: true }); }); } + async function handleNewNode( node: Element, config: ActionAdapter, @@ -180,23 +186,16 @@ async function handleNewNode( return; } - const action = await Action.fetch(actionApiUrl, config).catch(noop); + const action = await Action.fetch( + actionApiUrl, + config, + options.supportStrategy, + ).catch(noop); if (!action) { return; } - if (config.isSupported) { - const supported = await config.isSupported({ - originalUrl: actionUrl.toString(), - action, - actionType: state, - }); - if (!supported) { - return; - } - } - addMargin(container).replaceChildren( createAction({ originalUrl: actionUrl, @@ -291,6 +290,7 @@ function findLinkPreview(element: Element) { return anchor ? { anchor, card } : null; } + function findLastLinkInText(element: Element) { const tweetText = findElementByTestId(element, 'tweetText'); if (!tweetText) { diff --git a/src/hooks/solana/useActionSolanaWalletAdapter.ts b/src/hooks/solana/useActionSolanaWalletAdapter.ts index ff01818d..ca802393 100644 --- a/src/hooks/solana/useActionSolanaWalletAdapter.ts +++ b/src/hooks/solana/useActionSolanaWalletAdapter.ts @@ -13,7 +13,9 @@ import { ActionConfig } from '../../api'; * @param rpcUrlOrConnection * @see {Action} */ -export function useActionSolanaWalletAdapter(rpcUrlOrConnection: string | Connection) { +export function useActionSolanaWalletAdapter( + rpcUrlOrConnection: string | Connection, +) { const wallet = useWallet(); const walletModal = useWalletModal(); diff --git a/src/hooks/useAction.ts b/src/hooks/useAction.ts index a21fcd09..8715d30d 100644 --- a/src/hooks/useAction.ts +++ b/src/hooks/useAction.ts @@ -1,6 +1,11 @@ 'use client'; import { useEffect, useState } from 'react'; -import { Action, type ActionAdapter } from '../api'; +import { + Action, + type ActionAdapter, + type ActionSupportStrategy, + defaultActionSupportStrategy, +} from '../api'; import { unfurlUrlToActionApiUrl } from '../utils/url-mapper.ts'; import { useActionsRegistryInterval } from './useActionRegistryInterval.ts'; @@ -8,6 +13,7 @@ interface UseActionOptions { url: string | URL; adapter: ActionAdapter; securityRegistryRefreshInterval?: number; + supportStrategy?: ActionSupportStrategy; } function useActionApiUrl(url: string | URL) { @@ -36,7 +42,11 @@ function useActionApiUrl(url: string | URL) { return { actionApiUrl: apiUrl }; } -export function useAction({ url, adapter }: UseActionOptions) { +export function useAction({ + url, + adapter, + supportStrategy = defaultActionSupportStrategy, +}: UseActionOptions) { const { isRegistryLoaded } = useActionsRegistryInterval(); const { actionApiUrl } = useActionApiUrl(url); const [action, setAction] = useState(null); @@ -49,7 +59,7 @@ export function useAction({ url, adapter }: UseActionOptions) { } let ignore = false; - Action.fetch(actionApiUrl) + Action.fetch(actionApiUrl, undefined, supportStrategy) .then((action) => { if (ignore) { return; diff --git a/src/ui/ActionContainer.tsx b/src/ui/ActionContainer.tsx index 94e1aa65..19c99780 100644 --- a/src/ui/ActionContainer.tsx +++ b/src/ui/ActionContainer.tsx @@ -2,7 +2,12 @@ import { useEffect, useMemo, useReducer, useState } from 'react'; import { AbstractActionComponent, Action, + type ActionCallbacksConfig, + type ActionContext, + type ActionPostResponse, + type ActionSupportability, ButtonActionComponent, + type ExtendedActionState, FormActionComponent, getExtendedActionState, getExtendedInterstitialState, @@ -12,10 +17,6 @@ import { mergeActionStates, MultiValueActionComponent, SingleValueActionComponent, - type ActionCallbacksConfig, - type ActionContext, - type ActionPostResponse, - type ExtendedActionState, } from '../api'; import { checkSecurity, type SecurityLevel } from '../shared'; import { isInterstitial } from '../utils/interstitial-url.ts'; @@ -25,21 +26,29 @@ import { } from '../utils/type-guards.ts'; import { ActionLayout, - DisclaimerType, type Disclaimer, + DisclaimerType, type StylePreset, } from './ActionLayout'; -type ExecutionStatus = 'blocked' | 'idle' | 'executing' | 'success' | 'error'; +type ExecutionStatus = + | 'blocked' + | 'checking-supportability' + | 'idle' + | 'executing' + | 'success' + | 'error'; interface ExecutionState { status: ExecutionStatus; + checkingSupportability?: boolean; executingAction?: AbstractActionComponent | null; errorMessage?: string | null; successMessage?: string | null; } enum ExecutionType { + CHECK_SUPPORTABILITY = 'CHECK_SUPPORTABILITY', INITIATE = 'INITIATE', FINISH = 'FINISH', FAIL = 'FAIL', @@ -50,6 +59,9 @@ enum ExecutionType { } type ActionValue = + | { + type: ExecutionType.CHECK_SUPPORTABILITY; + } | { type: ExecutionType.INITIATE; executingAction: AbstractActionComponent; @@ -82,6 +94,11 @@ const executionReducer = ( action: ActionValue, ): ExecutionState => { switch (action.type) { + case ExecutionType.CHECK_SUPPORTABILITY: + return { + status: 'checking-supportability', + checkingSupportability: true, + }; case ExecutionType.INITIATE: return { status: 'executing', executingAction: action.executingAction }; case ExecutionType.FINISH: @@ -125,6 +142,7 @@ const buttonVariantMap: Record< ExecutionStatus, 'default' | 'error' | 'success' > = { + 'checking-supportability': 'default', blocked: 'default', idle: 'default', executing: 'default', @@ -133,6 +151,7 @@ const buttonVariantMap: Record< }; const buttonLabelMap: Record = { + 'checking-supportability': 'Loading', blocked: null, idle: null, executing: 'Executing', @@ -213,7 +232,6 @@ export const ActionContainer = ({ callbacks?: Partial; securityLevel?: SecurityLevel | NormalizedSecurityLevel; stylePreset?: StylePreset; - // please do not use it yet, better api is coming.. Experimental__ActionLayout?: typeof ActionLayout; }) => { @@ -233,6 +251,11 @@ export const ActionContainer = ({ const [actionState, setActionState] = useState( getOverallActionState(action, websiteUrl), ); + + const [supportability, setSupportability] = useState({ + isSupported: true, + }); + const overallState = useMemo( () => mergeActionStates( @@ -266,6 +289,23 @@ export const ActionContainer = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [callbacks, action, websiteUrl]); + useEffect(() => { + const checkSupportability = async (action: Action) => { + if (action.isChained) { + return; + } + try { + dispatch({ type: ExecutionType.CHECK_SUPPORTABILITY }); + const supportability = await action.isSupported(); + setSupportability(supportability); + } finally { + dispatch({ type: ExecutionType.RESET }); + } + }; + + checkSupportability(action); + }, [action]); + const buttons = useMemo( () => action?.actions @@ -488,7 +528,9 @@ export const ActionContainer = ({ return { type: DisclaimerType.BLOCKED, ignorable: isPassingSecurityCheck, - hidden: executionState.status !== 'blocked', + hidden: + executionState.status !== 'blocked' && + executionState.status !== 'checking-supportability', onSkip: () => dispatch({ type: ExecutionType.UNBLOCK }), }; } @@ -522,6 +564,7 @@ export const ActionContainer = ({ inputs={inputs.map((input) => asInputProps(input))} form={form ? asFormProps(form) : undefined} disclaimer={disclaimer} + supportability={supportability} /> ); }; diff --git a/src/ui/ActionLayout.tsx b/src/ui/ActionLayout.tsx index 1c60951d..bd6a657b 100644 --- a/src/ui/ActionLayout.tsx +++ b/src/ui/ActionLayout.tsx @@ -1,9 +1,10 @@ import clsx from 'clsx'; -import { useState, type ReactNode } from 'react'; -import type { ExtendedActionState } from '../api'; +import { type ReactNode, useState } from 'react'; +import type { ActionSupportability, ExtendedActionState } from '../api'; import { Badge } from './Badge.tsx'; import { Snackbar } from './Snackbar.tsx'; import { ExclamationShieldIcon, InfoShieldIcon, LinkIcon } from './icons'; +import ConfigIcon from './icons/ConfigIcon.tsx'; import { ActionButton, ActionDateInput, @@ -23,6 +24,7 @@ type ButtonProps = BaseButtonProps; type InputProps = BaseInputProps; export type StylePreset = 'default' | 'x-dark' | 'x-light' | 'custom'; + export enum DisclaimerType { BLOCKED = 'blocked', UNKNOWN = 'unknown', @@ -61,6 +63,7 @@ interface LayoutProps { buttons?: ButtonProps[]; inputs?: InputProps[]; form?: FormProps; + supportability: ActionSupportability; } export interface FormProps { @@ -90,6 +93,34 @@ const Linkable = ({
{children}
); +const NotSupportedBlock = ({ + message, + className, +}: { + message: string; + className?: string; +}) => { + return ( +
+
+
+
+ +
+ +
+
+
+ ); +}; + const DisclaimerBlock = ({ type, hidden, @@ -176,6 +207,7 @@ export const ActionLayout = ({ form, error, success, + supportability, }: LayoutProps) => { return (
@@ -248,33 +280,39 @@ export const ActionLayout = ({ {description} - {disclaimer && ( -
diff --git a/src/ui/Checkbox.tsx b/src/ui/Checkbox.tsx index c2b49977..4a42dfc2 100644 --- a/src/ui/Checkbox.tsx +++ b/src/ui/Checkbox.tsx @@ -45,10 +45,10 @@ export const Checkbox = ({ 'mt-0.5 flex aspect-square h-[16px] items-center justify-center rounded-lg border transition-colors motion-reduce:transition-none', { 'border-input-stroke bg-input-bg': !value && !disabled, - 'bg-input-bg-selected border-input-stroke-selected': + 'border-input-stroke-selected bg-input-bg-selected': value && !disabled, 'border-input-stroke-disabled bg-input-bg': !value && disabled, - 'bg-input-bg-disabled border-input-stroke-disabled': + 'border-input-stroke-disabled bg-input-bg-disabled': value && disabled, }, )} diff --git a/src/ui/icons/ConfigIcon.tsx b/src/ui/icons/ConfigIcon.tsx new file mode 100644 index 00000000..8719f2cb --- /dev/null +++ b/src/ui/icons/ConfigIcon.tsx @@ -0,0 +1,22 @@ +import type { SVGProps } from 'react'; +const SvgComponent = (props: SVGProps) => ( + + + + +); +export default SvgComponent; diff --git a/src/utils/caip-2.ts b/src/utils/caip-2.ts new file mode 100644 index 00000000..2be918cf --- /dev/null +++ b/src/utils/caip-2.ts @@ -0,0 +1,34 @@ +/** + * CAIP-2 Blockchain IDs. + */ +export const BlockchainIds = { + SOLANA_MAINNET: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + SOLANA_DEVNET: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', + SOLANA_TESTNET: 'solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3', + ETHEREUM_MAINNET: 'eip155:1', +}; +const BlockchainNames: Record = { + [BlockchainIds.SOLANA_MAINNET]: 'Solana Mainnet', + [BlockchainIds.SOLANA_DEVNET]: 'Solana Devnet', + [BlockchainIds.SOLANA_TESTNET]: 'Solana Testnet', + [BlockchainIds.ETHEREUM_MAINNET]: 'Ethereum Mainnet', +}; + +export function getShortBlockchainName(id: string) { + const blockchainName = BlockchainNames[id]; + if (!blockchainName) { + // https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-2.md + // chain_id: namespace + ":" + reference + const [chainId, reference] = id.split(':'); + if (chainId && reference) { + // If the ID is in CAIP-2 format, truncate the reference to 3 characters + const truncatedReference = + reference.length > 3 ? reference.slice(0, 3) + '...' : reference; + return `${chainId}:${truncatedReference}`; + } else { + // If the ID is not in CAIP-2 format, truncate the entire ID to 8 characters + return id.length > 8 ? id.slice(0, 8) + '...' : id; + } + } + return blockchainName; +} diff --git a/src/utils/dependency-versions.ts b/src/utils/dependency-versions.ts new file mode 100644 index 00000000..31bc6cb0 --- /dev/null +++ b/src/utils/dependency-versions.ts @@ -0,0 +1,10 @@ +import packageJson from '../../package.json'; + +const pkg = packageJson as unknown as { + devDependencies: { '@solana/actions-spec': string }; +}; + +// TODO: to be replaced with the actual version number exported from the actions-spec package +export const ACTIONS_SPEC_VERSION = pkg.devDependencies[ + '@solana/actions-spec' +].replace(/[^\d.]/g, ''); diff --git a/src/utils/index.ts b/src/utils/index.ts index 5493d870..50170126 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1 +1,2 @@ +export { BlockchainIds } from './caip-2.ts'; export { setProxyUrl } from './proxify'; diff --git a/test/api/Action/action-supportability.spec.ts b/test/api/Action/action-supportability.spec.ts new file mode 100644 index 00000000..b036c468 --- /dev/null +++ b/test/api/Action/action-supportability.spec.ts @@ -0,0 +1,159 @@ +import { describe, expect, test } from 'bun:test'; +import { isBlockchainSupported, isVersionSupported } from '../../../src'; + +describe('isVersionSupported', () => { + test('returns true when action version is less than client version', () => { + expect( + isVersionSupported({ + actionVersion: '2.1.0', + supportedActionVersion: '2.2.0', + }), + ).toBe(true); + expect( + isVersionSupported({ + actionVersion: '2.2.0', + supportedActionVersion: '2.3.0', + }), + ).toBe(true); + expect( + isVersionSupported({ + actionVersion: '1.0.0', + supportedActionVersion: '2.2.0', + }), + ).toBe(true); + }); + + test('returns true when action version is equal to client version', () => { + expect( + isVersionSupported({ + actionVersion: '2.2.0', + supportedActionVersion: '2.2.0', + }), + ).toBe(true); + }); + + test('returns false when action version is greater than client version', () => { + expect( + isVersionSupported({ + actionVersion: '2.3.0', + supportedActionVersion: '2.2.0', + }), + ).toBe(false); + expect( + isVersionSupported({ + actionVersion: '3.0.0', + supportedActionVersion: '2.2.0', + }), + ).toBe(false); + }); + + test('returns true ignoring patch version', () => { + expect( + isVersionSupported({ + actionVersion: '2.2.1', + supportedActionVersion: '2.2.2', + }), + ).toBe(true); + expect( + isVersionSupported({ + actionVersion: '2.2.1', + supportedActionVersion: '2.2.1', + }), + ).toBe(true); + expect( + isVersionSupported({ + actionVersion: '2.2.2', + supportedActionVersion: '2.2.1', + }), + ).toBe(true); + expect( + isVersionSupported({ + actionVersion: '2.2.2', + supportedActionVersion: '2.2', + }), + ).toBe(true); + }); + + test('returns false when action version is an incompatible string', () => { + expect( + isVersionSupported({ + actionVersion: 'invalidVersion', + supportedActionVersion: '2.2.0', + }), + ).toBe(false); + expect( + isVersionSupported({ + actionVersion: '2.2.0', + supportedActionVersion: 'invalidVersion', + }), + ).toBe(false); + }); + + test('returns false when acceptActionVersion is an incompatible string', () => { + expect( + isVersionSupported({ + actionVersion: '2.2.0', + supportedActionVersion: 'invalid.version', + }), + ).toBe(false); + }); + + test('returns false when both versions are incompatible strings', () => { + expect( + isVersionSupported({ + actionVersion: 'invalid.version', + supportedActionVersion: 'invalid.version', + }), + ).toBe(false); + }); +}); + +describe('isBlockchainSupported', () => { + test('returns true when all actionBlockchainIds are supported', () => { + expect( + isBlockchainSupported({ + supportedBlockchainIds: [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + 'ethereum:1', + ], + actionBlockchainIds: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + }), + ).toBe(true); + }); + + test('returns false when some actionBlockchainIds are not supported', () => { + expect( + isBlockchainSupported({ + supportedBlockchainIds: ['ethereum:1'], + actionBlockchainIds: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + }), + ).toBe(false); + }); + + test('returns false when both blockchainIds and actionBlockchainIds are empty', () => { + expect( + isBlockchainSupported({ + supportedBlockchainIds: [], + actionBlockchainIds: [], + }), + ).toBe(false); + }); + + test('returns false when supportedBlockchainIds is empty and actionBlockchainIds is not', () => { + expect( + isBlockchainSupported({ + supportedBlockchainIds: [], + actionBlockchainIds: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + }), + ).toBe(false); + }); + + test('returns false when actionBlockchainIds is empty and actionBlockchainIds is not', () => { + expect( + isBlockchainSupported({ + supportedBlockchainIds: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + actionBlockchainIds: [], + }), + ).toBe(false); + }); +}); diff --git a/test/utils/dependency-versions.spec.ts b/test/utils/dependency-versions.spec.ts new file mode 100644 index 00000000..98e781ed --- /dev/null +++ b/test/utils/dependency-versions.spec.ts @@ -0,0 +1,10 @@ +import { describe, expect, test } from 'bun:test'; +import { ACTIONS_SPEC_VERSION } from '../../src/utils/dependency-versions.ts'; + +describe('dependencyVersions', () => { + test('should extract the correct version numbe for actions spec', () => { + expect(ACTIONS_SPEC_VERSION).not.toBeNull(); + expect(ACTIONS_SPEC_VERSION).not.toBeUndefined(); + expect(ACTIONS_SPEC_VERSION).toMatch(/^\d+\.\d+\.\d+$/); + }); +});