diff --git a/src/FormoAnalytics.ts b/src/FormoAnalytics.ts index 1cfab4c..6af1ea5 100644 --- a/src/FormoAnalytics.ts +++ b/src/FormoAnalytics.ts @@ -35,7 +35,7 @@ import { TrackingOptions, TransactionStatus, } from "./types"; -import { toChecksumAddress, getValidAddress } from "./utils"; +import { validateAddress } from "./utils"; import { parseTrafficSource, updateStoredTrafficSource } from "./utils/trafficSource"; import { captureInstallReferrer } from "./lib/installReferrer"; import { Linking, EmitterSubscription } from "react-native"; @@ -306,8 +306,8 @@ export class FormoAnalytics implements IFormoAnalytics { return; } - const checksummedAddress = this.validateAndChecksumAddress(address); - if (!checksummedAddress) { + const validatedAddress = this.validateAndChecksumAddress(address, chainId); + if (!validatedAddress) { logger.warn(`Connect: Invalid address provided ("${address}")`); return; } @@ -315,14 +315,14 @@ export class FormoAnalytics implements IFormoAnalytics { // Track event before updating state so connect events TO excluded chains are tracked await this.trackEvent( EventType.CONNECT, - { chainId, address: checksummedAddress }, + { chainId, address: validatedAddress }, properties, context, callback ); this.currentChainId = chainId; - this.currentAddress = checksummedAddress; + this.currentAddress = validatedAddress; } /** @@ -514,6 +514,8 @@ export class FormoAnalytics implements IFormoAnalytics { return; } this.currentAddress = validAddress; + // Note: validateAddress returns Solana addresses unchanged (Base58, case-sensitive) + // and EVM addresses checksummed. } else { this.currentAddress = undefined; } @@ -760,11 +762,17 @@ export class FormoAnalytics implements IFormoAnalytics { } /** - * Validate and checksum address + * Validate and normalize an address for the given chain. + * + * EVM addresses are returned in EIP-55 checksum format. + * Solana addresses are returned as-is (Base58 is case-sensitive). + * When chainId is omitted, EVM is tried first with Solana as fallback. */ - private validateAndChecksumAddress(address: string): Address | undefined { - const validAddress = getValidAddress(address); - return validAddress ? toChecksumAddress(validAddress) : undefined; + private validateAndChecksumAddress( + address: string, + chainId?: ChainID + ): Address | undefined { + return validateAddress(address, chainId); } /** diff --git a/src/__tests__/solana.test.ts b/src/__tests__/solana.test.ts new file mode 100644 index 0000000..52c2e72 --- /dev/null +++ b/src/__tests__/solana.test.ts @@ -0,0 +1,179 @@ +import { + isSolanaAddress, + getValidSolanaAddress, + isBlockedSolanaAddress, + isSolanaSystemAddress, + SOLANA_SYSTEM_ADDRESSES, +} from '../solana/address'; +import { + isSolanaChainId, + SOLANA_CHAIN_IDS, + DEFAULT_SOLANA_CHAIN_ID, +} from '../solana/types'; +import { + validateAddress, + isBlockedAddress, +} from '../utils/address'; + +const VITALIK_EVM = '0xd8da6bf26964af9d7eed9e03e53415d37aa96045'; +const VITALIK_EVM_CHECKSUM = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; + +// A real Solana address (Vitalik example replaced with valid Base58 32-byte key) +const VALID_SOLANA = 'FDKJvWcJNe6wecbgDYDFPCfgs14aJnVsUfWQRYWLn4Tn'; +const ANOTHER_SOLANA = '7v91N7iZ9eyTktonbRy7sTjKBNMWFM8jShaeP4S2t9NA'; + +describe('Solana address utilities', () => { + describe('isSolanaAddress()', () => { + it('returns true for a valid Base58 32-byte key', () => { + expect(isSolanaAddress(VALID_SOLANA)).toBe(true); + expect(isSolanaAddress(ANOTHER_SOLANA)).toBe(true); + }); + + it('returns true for system addresses', () => { + expect(isSolanaAddress(SOLANA_SYSTEM_ADDRESSES.SYSTEM_PROGRAM)).toBe(true); + expect(isSolanaAddress(SOLANA_SYSTEM_ADDRESSES.TOKEN_PROGRAM)).toBe(true); + }); + + it('returns false for too-short strings', () => { + expect(isSolanaAddress('abc')).toBe(false); + }); + + it('returns false for too-long strings', () => { + expect(isSolanaAddress('1'.repeat(45))).toBe(false); + }); + + it('returns false for strings containing non-Base58 characters', () => { + // 0 (zero), O (capital o), I (capital i), l (lower L) are not in Base58 + expect(isSolanaAddress('0OIl' + '1'.repeat(28))).toBe(false); + }); + + it('returns false for EVM addresses', () => { + expect(isSolanaAddress(VITALIK_EVM)).toBe(false); + }); + + it('returns false for non-string values', () => { + expect(isSolanaAddress(null)).toBe(false); + expect(isSolanaAddress(undefined)).toBe(false); + expect(isSolanaAddress(123)).toBe(false); + expect(isSolanaAddress({})).toBe(false); + }); + }); + + describe('getValidSolanaAddress()', () => { + it('trims whitespace and returns the address', () => { + expect(getValidSolanaAddress(` ${VALID_SOLANA} `)).toBe(VALID_SOLANA); + }); + + it('returns null for invalid addresses', () => { + expect(getValidSolanaAddress('invalid')).toBeNull(); + expect(getValidSolanaAddress('')).toBeNull(); + expect(getValidSolanaAddress(null)).toBeNull(); + expect(getValidSolanaAddress(undefined)).toBeNull(); + }); + + it('handles PublicKey-like objects with toBase58', () => { + const pk = { + toBase58: () => VALID_SOLANA, + toString: () => VALID_SOLANA, + toBytes: () => new Uint8Array(), + equals: () => false, + }; + expect(getValidSolanaAddress(pk)).toBe(VALID_SOLANA); + }); + + it('returns null when toBase58 throws', () => { + const pk = { + toBase58: () => { + throw new Error('boom'); + }, + toString: () => '', + toBytes: () => new Uint8Array(), + equals: () => false, + }; + expect(getValidSolanaAddress(pk)).toBeNull(); + }); + }); + + describe('isSolanaSystemAddress() / isBlockedSolanaAddress()', () => { + it('returns true for system program', () => { + expect(isSolanaSystemAddress(SOLANA_SYSTEM_ADDRESSES.SYSTEM_PROGRAM)).toBe(true); + expect(isBlockedSolanaAddress(SOLANA_SYSTEM_ADDRESSES.SYSTEM_PROGRAM)).toBe(true); + }); + + it('returns false for normal user addresses', () => { + expect(isSolanaSystemAddress(VALID_SOLANA)).toBe(false); + expect(isBlockedSolanaAddress(VALID_SOLANA)).toBe(false); + }); + }); + + describe('isSolanaChainId()', () => { + it('returns true for known Solana chain IDs', () => { + expect(isSolanaChainId(SOLANA_CHAIN_IDS['mainnet-beta'])).toBe(true); + expect(isSolanaChainId(SOLANA_CHAIN_IDS.devnet)).toBe(true); + expect(isSolanaChainId(DEFAULT_SOLANA_CHAIN_ID)).toBe(true); + }); + + it('returns false for EVM chain IDs', () => { + expect(isSolanaChainId(1)).toBe(false); + expect(isSolanaChainId(137)).toBe(false); + }); + + it('returns false for null/undefined', () => { + expect(isSolanaChainId(null)).toBe(false); + expect(isSolanaChainId(undefined)).toBe(false); + }); + }); +}); + +describe('validateAddress()', () => { + it('returns checksummed EVM address when no chainId is provided', () => { + expect(validateAddress(VITALIK_EVM)).toBe(VITALIK_EVM_CHECKSUM); + }); + + it('returns Solana address as-is when no chainId is provided', () => { + expect(validateAddress(VALID_SOLANA)).toBe(VALID_SOLANA); + }); + + it('returns checksummed EVM address for EVM chainId', () => { + expect(validateAddress(VITALIK_EVM, 1)).toBe(VITALIK_EVM_CHECKSUM); + }); + + it('returns undefined for Solana address with EVM chainId', () => { + expect(validateAddress(VALID_SOLANA, 1)).toBeUndefined(); + }); + + it('returns Solana address for Solana chainId', () => { + expect(validateAddress(VALID_SOLANA, DEFAULT_SOLANA_CHAIN_ID)).toBe( + VALID_SOLANA + ); + }); + + it('returns undefined for EVM address with Solana chainId', () => { + expect(validateAddress(VITALIK_EVM, DEFAULT_SOLANA_CHAIN_ID)).toBeUndefined(); + }); + + it('returns undefined for invalid input', () => { + expect(validateAddress('not-an-address')).toBeUndefined(); + expect(validateAddress('')).toBeUndefined(); + }); +}); + +describe('isBlockedAddress() with Solana', () => { + it('returns false for normal Solana addresses', () => { + expect(isBlockedAddress(VALID_SOLANA)).toBe(false); + }); + + it('returns true for Solana system program', () => { + expect(isBlockedAddress(SOLANA_SYSTEM_ADDRESSES.SYSTEM_PROGRAM)).toBe(true); + expect(isBlockedAddress(SOLANA_SYSTEM_ADDRESSES.TOKEN_PROGRAM)).toBe(true); + }); + + it('still returns true for EVM zero/dead addresses', () => { + expect( + isBlockedAddress('0x0000000000000000000000000000000000000000') + ).toBe(true); + expect( + isBlockedAddress('0x000000000000000000000000000000000000dead') + ).toBe(true); + }); +}); diff --git a/src/lib/event/EventFactory.ts b/src/lib/event/EventFactory.ts index 3deb769..5109c5b 100644 --- a/src/lib/event/EventFactory.ts +++ b/src/lib/event/EventFactory.ts @@ -39,8 +39,7 @@ import { TransactionStatus, } from "../../types"; import { - toChecksumAddress, - getValidAddress, + validateAddress, toSnakeCase, mergeDeepRight, getStoredTrafficSource, @@ -337,12 +336,11 @@ class EventFactory implements IEventFactory { commonEventData.anonymous_id = generateAnonymousId(LOCAL_ANONYMOUS_ID_KEY); // Handle address - convert undefined to null for consistency - const validAddress = getValidAddress(formoEvent.address); - if (validAddress) { - commonEventData.address = toChecksumAddress(validAddress); - } else { - commonEventData.address = null; - } + // Try EVM first, then Solana fallback (chainId is not always present here). + const validAddress = formoEvent.address + ? validateAddress(formoEvent.address) + : undefined; + commonEventData.address = validAddress ?? null; const processedEvent = mergeDeepRight( formoEvent as Record, @@ -680,10 +678,7 @@ class EventFactory implements IEventFactory { // Set address if not already set by the specific event generator if (formoEvent.address === undefined || formoEvent.address === null) { - const validAddress = getValidAddress(address); - formoEvent.address = validAddress - ? toChecksumAddress(validAddress) - : null; + formoEvent.address = address ? validateAddress(address) ?? null : null; } formoEvent.user_id = userId || null; diff --git a/src/solana/address.ts b/src/solana/address.ts new file mode 100644 index 0000000..4fd7271 --- /dev/null +++ b/src/solana/address.ts @@ -0,0 +1,122 @@ +/** + * Solana address validation utilities + * + * Solana uses Base58 encoded 32-byte public keys as addresses. + * Format: FDKJvWcJNe6wecbgDYDFPCfgs14aJnVsUfWQRYWLn4Tn (32-44 characters) + * + * @see https://solana.com/developers/courses/intro-to-solana/interact-with-wallets + */ + +import { SolanaPublicKey } from "./types"; + +/** + * Base58 alphabet used by Solana (Bitcoin alphabet) + */ +const BASE58_ALPHABET = + "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + +const BASE58_CHAR_SET = new Set(BASE58_ALPHABET); + +const MIN_SOLANA_ADDRESS_LENGTH = 32; +const MAX_SOLANA_ADDRESS_LENGTH = 44; + +/** + * System program addresses and other special Solana addresses + * These are valid addresses but may not represent user wallets + */ +export const SOLANA_SYSTEM_ADDRESSES = { + SYSTEM_PROGRAM: "11111111111111111111111111111111", + TOKEN_PROGRAM: "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + TOKEN_2022_PROGRAM: "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb", + ASSOCIATED_TOKEN_PROGRAM: "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL", + RENT_SYSVAR: "SysvarRent111111111111111111111111111111111", + CLOCK_SYSVAR: "SysvarC1ock11111111111111111111111111111111", +} as const; + +function isValidBase58String(str: string): boolean { + for (const ch of str) { + if (!BASE58_CHAR_SET.has(ch)) { + return false; + } + } + return true; +} + +/** + * Check if a string is a valid Solana address format + * + * Performs format validation only (length and character set). Does not + * verify that the address is a valid point on the Ed25519 curve. + */ +export function isSolanaAddress(value: unknown): value is string { + if (typeof value !== "string") { + return false; + } + + const trimmed = value.trim(); + + if ( + trimmed.length < MIN_SOLANA_ADDRESS_LENGTH || + trimmed.length > MAX_SOLANA_ADDRESS_LENGTH + ) { + return false; + } + + return isValidBase58String(trimmed); +} + +/** + * Get a valid Solana address from a string or PublicKey + */ +export function getValidSolanaAddress( + address: string | SolanaPublicKey | null | undefined +): string | null { + if (!address) { + return null; + } + + if (typeof address === "object" && "toBase58" in address) { + try { + const base58 = address.toBase58(); + return isSolanaAddress(base58) ? base58 : null; + } catch { + return null; + } + } + + if (typeof address === "string") { + const trimmed = address.trim(); + return isSolanaAddress(trimmed) ? trimmed : null; + } + + return null; +} + +/** + * Check if a Solana address is a system program or well-known program address + */ +export function isSolanaSystemAddress(address: string): boolean { + const validAddress = getValidSolanaAddress(address); + if (!validAddress) { + return false; + } + + return Object.values(SOLANA_SYSTEM_ADDRESSES).includes( + validAddress as (typeof SOLANA_SYSTEM_ADDRESSES)[keyof typeof SOLANA_SYSTEM_ADDRESSES] + ); +} + +/** + * Check if a Solana address is blocked (should not emit events). + * Blocks system program addresses since they don't represent user wallets. + */ +export function isBlockedSolanaAddress( + address: string | SolanaPublicKey | null | undefined +): boolean { + const validAddress = getValidSolanaAddress(address); + if (!validAddress) { + return false; + } + + return isSolanaSystemAddress(validAddress); +} diff --git a/src/solana/index.ts b/src/solana/index.ts new file mode 100644 index 0000000..27b599d --- /dev/null +++ b/src/solana/index.ts @@ -0,0 +1,2 @@ +export * from "./address"; +export * from "./types"; diff --git a/src/solana/types.ts b/src/solana/types.ts new file mode 100644 index 0000000..f23ae52 --- /dev/null +++ b/src/solana/types.ts @@ -0,0 +1,46 @@ +/** + * Solana-specific type definitions + */ + +/** + * Solana cluster/network types + * Solana doesn't use chainId like EVM, instead it uses cluster names + */ +export type SolanaCluster = "mainnet-beta" | "testnet" | "devnet" | "localnet"; + +/** + * Mapping of Solana clusters to numeric chain IDs for consistency with EVM events + * These IDs are non-standard but provide a way to identify Solana networks in our analytics + * + * Using high numbers (900000+) to avoid collision with EVM chain IDs + */ +export const SOLANA_CHAIN_IDS: Record = { + "mainnet-beta": 900001, + testnet: 900002, + devnet: 900003, + localnet: 900004, +} as const; + +/** + * Default Solana chain ID (mainnet-beta) + */ +export const DEFAULT_SOLANA_CHAIN_ID = SOLANA_CHAIN_IDS["mainnet-beta"]; + +/** + * Check if a chain ID belongs to a Solana network. + */ +export function isSolanaChainId(chainId: number | undefined | null): boolean { + if (chainId === undefined || chainId === null) return false; + return Object.values(SOLANA_CHAIN_IDS).includes(chainId); +} + +/** + * Solana PublicKey interface + * Used by address validation utilities. + */ +export interface SolanaPublicKey { + toBase58(): string; + toString(): string; + toBytes(): Uint8Array; + equals(other: SolanaPublicKey): boolean; +} diff --git a/src/utils/address.ts b/src/utils/address.ts index 42c5219..6c1e326 100644 --- a/src/utils/address.ts +++ b/src/utils/address.ts @@ -1,11 +1,19 @@ /** * Address validation and checksum utilities * - * Uses ethereum-cryptography for proper EIP-55 checksum computation + * Supports both EVM and Solana addresses. + * + * Uses ethereum-cryptography for proper EIP-55 checksum computation. */ import { keccak256 } from "ethereum-cryptography/keccak.js"; import { utf8ToBytes } from "ethereum-cryptography/utils.js"; +import { + isSolanaAddress, + getValidSolanaAddress, + isBlockedSolanaAddress, +} from "../solana/address"; +import { isSolanaChainId } from "../solana/types"; /** * Convert Uint8Array to hex string @@ -17,7 +25,7 @@ function toHex(bytes: Uint8Array): string { } /** - * Check if a string is a valid Ethereum address + * Check if a string is a valid Ethereum (EVM) address */ export function isValidAddress(address: string): boolean { if (!address) return false; @@ -56,7 +64,7 @@ export function toChecksumAddress(address: string): string { } /** - * Get valid address or null + * Get a valid (trimmed) EVM address, or null if invalid. */ export function getValidAddress( address: string | undefined | null @@ -68,7 +76,52 @@ export function getValidAddress( } /** - * Blocked addresses that should not emit events + * Validates an EVM address and returns it in checksummed format. + */ +export function validateAndChecksumAddress( + address: string +): string | undefined { + const validAddress = getValidAddress(address); + return validAddress ? toChecksumAddress(validAddress) : undefined; +} + +/** + * Validates an address for both EVM and Solana chains. + * + * For EVM addresses, returns checksummed format. + * For Solana addresses, returns the Base58 address as-is. + * + * When chainId is explicitly provided, validation is strict: + * - Solana chainId → only Solana validation + * - Non-Solana chainId → only EVM validation + * + * When chainId is omitted, EVM is tried first with Solana fallback. + */ +export function validateAddress( + address: string, + chainId?: number | null +): string | undefined { + // Explicit Solana chainId → validate ONLY as Solana + if (chainId !== undefined && chainId !== null && isSolanaChainId(chainId)) { + return getValidSolanaAddress(address) || undefined; + } + + // Explicit non-Solana chainId → validate ONLY as EVM + if (chainId !== undefined && chainId !== null) { + return validateAndChecksumAddress(address); + } + + // No chainId → try EVM first, then Solana fallback + const validEvmAddress = validateAndChecksumAddress(address); + if (validEvmAddress) { + return validEvmAddress; + } + + return getValidSolanaAddress(address) || undefined; +} + +/** + * Blocked EVM addresses that should not emit events * (zero address, dead address) */ const BLOCKED_ADDRESSES = new Set([ @@ -77,8 +130,21 @@ const BLOCKED_ADDRESSES = new Set([ ]); /** - * Check if address is in blocked list + * Check if an address is in a blocked list. + * Handles both EVM (zero/dead addresses) and Solana (system program) blocks. */ export function isBlockedAddress(address: string): boolean { - return BLOCKED_ADDRESSES.has(address.toLowerCase()); + if (!address || typeof address !== "string") return false; + + const trimmed = address.trim(); + + if (isValidAddress(trimmed)) { + return BLOCKED_ADDRESSES.has(trimmed.toLowerCase()); + } + + if (isSolanaAddress(trimmed)) { + return isBlockedSolanaAddress(trimmed); + } + + return false; }