diff --git a/advanced/wallets/react-wallet-v2/package.json b/advanced/wallets/react-wallet-v2/package.json index 2ef1a9fe8..1c57aa881 100644 --- a/advanced/wallets/react-wallet-v2/package.json +++ b/advanced/wallets/react-wallet-v2/package.json @@ -32,6 +32,7 @@ "@reown/appkit-experimental": "1.6.8", "@reown/walletkit": "1.0.0", "@rhinestone/module-sdk": "0.1.25", + "@solana/spl-token": "^0.4.13", "@solana/web3.js": "1.89.2", "@taquito/signer": "^15.1.0", "@taquito/taquito": "^15.1.0", diff --git a/advanced/wallets/react-wallet-v2/public/chain-logos/chain-placeholder.png b/advanced/wallets/react-wallet-v2/public/chain-logos/chain-placeholder.png new file mode 100644 index 000000000..12975dfa5 Binary files /dev/null and b/advanced/wallets/react-wallet-v2/public/chain-logos/chain-placeholder.png differ diff --git a/advanced/wallets/react-wallet-v2/public/chain-logos/chain-placeholder.svg b/advanced/wallets/react-wallet-v2/public/chain-logos/chain-placeholder.svg deleted file mode 100644 index ada9bf6a4..000000000 --- a/advanced/wallets/react-wallet-v2/public/chain-logos/chain-placeholder.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - \ No newline at end of file diff --git a/advanced/wallets/react-wallet-v2/public/token-logos/SOL.png b/advanced/wallets/react-wallet-v2/public/token-logos/SOL.png new file mode 100644 index 000000000..0ba7236a9 Binary files /dev/null and b/advanced/wallets/react-wallet-v2/public/token-logos/SOL.png differ diff --git a/advanced/wallets/react-wallet-v2/public/token-logos/token-placeholder.png b/advanced/wallets/react-wallet-v2/public/token-logos/token-placeholder.png new file mode 100644 index 000000000..466fa52f3 Binary files /dev/null and b/advanced/wallets/react-wallet-v2/public/token-logos/token-placeholder.png differ diff --git a/advanced/wallets/react-wallet-v2/public/token-logos/token-placeholder.svg b/advanced/wallets/react-wallet-v2/public/token-logos/token-placeholder.svg deleted file mode 100644 index bfecbba6c..000000000 --- a/advanced/wallets/react-wallet-v2/public/token-logos/token-placeholder.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - \ No newline at end of file diff --git a/advanced/wallets/react-wallet-v2/src/data/EIP155Data.ts b/advanced/wallets/react-wallet-v2/src/data/EIP155Data.ts index e0df05d71..11b4c701d 100644 --- a/advanced/wallets/react-wallet-v2/src/data/EIP155Data.ts +++ b/advanced/wallets/react-wallet-v2/src/data/EIP155Data.ts @@ -17,7 +17,7 @@ export type EIP155Chain = { namespace: string smartAccountEnabled?: boolean } -const blockchainApiRpc = (chainId: number) => { +export const blockchainApiRpc = (chainId: number) => { return `https://rpc.walletconnect.org/v1?chainId=eip155:${chainId}&projectId=${process.env.NEXT_PUBLIC_PROJECT_ID}` } /** diff --git a/advanced/wallets/react-wallet-v2/src/data/SolanaData.ts b/advanced/wallets/react-wallet-v2/src/data/SolanaData.ts index 86711c328..c8066ab0c 100644 --- a/advanced/wallets/react-wallet-v2/src/data/SolanaData.ts +++ b/advanced/wallets/react-wallet-v2/src/data/SolanaData.ts @@ -63,5 +63,6 @@ export const SOLANA_SIGNING_METHODS = { SOLANA_SIGN_TRANSACTION: 'solana_signTransaction', SOLANA_SIGN_MESSAGE: 'solana_signMessage', SOLANA_SIGN_AND_SEND_TRANSACTION: 'solana_signAndSendTransaction', - SOLANA_SIGN_ALL_TRANSACTIONS: 'solana_signAllTransactions' + SOLANA_SIGN_ALL_TRANSACTIONS: 'solana_signAllTransactions', + SOLANA_WALLET_CHECKOUT: 'wallet_checkout' } diff --git a/advanced/wallets/react-wallet-v2/src/data/tokenUtil.ts b/advanced/wallets/react-wallet-v2/src/data/tokenUtil.ts index 461f1f991..d64234eaa 100644 --- a/advanced/wallets/react-wallet-v2/src/data/tokenUtil.ts +++ b/advanced/wallets/react-wallet-v2/src/data/tokenUtil.ts @@ -1,7 +1,7 @@ export type EIP155Token = { name: string icon: string - address?: string + assetAddress?: string symbol: string decimals: number } @@ -24,9 +24,31 @@ const ALL_TOKENS: EIP155Token[] = [ icon: '/token-logos/ETH.png', symbol: 'ETH', decimals: 18 + }, + { + name: 'SOL', + icon: '/token-logos/SOL.png', + symbol: 'SOL', + decimals: 9 } ] export function getTokenData(tokenSymbol: string) { return Object.values(ALL_TOKENS).find(token => token.symbol === tokenSymbol) } + +const SOLANA_KNOWN_TOKENS = [ + { + name: 'USDC', + icon: '/token-logos/USDC.png', + symbol: 'USDC', + decimals: 6, + assetAddress: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU' + ] + } +] + +export function getSolanaTokenData(caip19AssetAddress: string) { + return SOLANA_KNOWN_TOKENS.find(token => token.assetAddress.includes(caip19AssetAddress)) +} diff --git a/advanced/wallets/react-wallet-v2/src/hooks/useWalletConnectEventsManager.ts b/advanced/wallets/react-wallet-v2/src/hooks/useWalletConnectEventsManager.ts index e9d9f00d2..28bfdd32d 100644 --- a/advanced/wallets/react-wallet-v2/src/hooks/useWalletConnectEventsManager.ts +++ b/advanced/wallets/react-wallet-v2/src/hooks/useWalletConnectEventsManager.ts @@ -23,6 +23,8 @@ import { EIP7715_METHOD } from '@/data/EIP7715Data' import { refreshSessionsList } from '@/pages/wc' import WalletCheckoutUtil from '@/utils/WalletCheckoutUtil' import WalletCheckoutCtrl from '@/store/WalletCheckoutCtrl' +import { CheckoutErrorCode } from '@/types/wallet_checkout' +import { createCheckoutError } from '@/types/wallet_checkout' export default function useWalletConnectEventsManager(initialized: boolean) { /****************************************************************************** @@ -95,10 +97,18 @@ export default function useWalletConnectEventsManager(initialized: boolean) { return ModalStore.open('SessionSendCallsModal', { requestEvent, requestSession }) } - case 'wallet_checkout': + case EIP155_SIGNING_METHODS.WALLET_CHECKOUT: try { await WalletCheckoutCtrl.actions.prepareFeasiblePayments(request.params[0]) } catch (error) { + // If it's not a CheckoutError, create one + if (!(error && typeof error === 'object' && 'code' in error)) { + error = createCheckoutError( + CheckoutErrorCode.INVALID_CHECKOUT_REQUEST, + `Unexpected error: ${error instanceof Error ? error.message : String(error)}` + ) + } + return await walletkit.respondSessionRequest({ topic, response: WalletCheckoutUtil.formatCheckoutErrorResponse(id, error) diff --git a/advanced/wallets/react-wallet-v2/src/lib/SolanaLib.ts b/advanced/wallets/react-wallet-v2/src/lib/SolanaLib.ts index 5999ee5fb..c57487244 100644 --- a/advanced/wallets/react-wallet-v2/src/lib/SolanaLib.ts +++ b/advanced/wallets/react-wallet-v2/src/lib/SolanaLib.ts @@ -1,6 +1,20 @@ -import { Keypair, Connection, SendOptions, VersionedTransaction } from '@solana/web3.js' +import { + Keypair, + Connection, + SendOptions, + VersionedTransaction, + PublicKey, + Transaction, + SystemProgram +} from '@solana/web3.js' import bs58 from 'bs58' import nacl from 'tweetnacl' +import { + getAssociatedTokenAddress, + createTransferInstruction, + TOKEN_PROGRAM_ID, + getOrCreateAssociatedTokenAccount +} from '@solana/spl-token' import { SOLANA_MAINNET_CHAINS, SOLANA_TEST_CHAINS } from '@/data/SolanaData' /** @@ -101,7 +115,9 @@ export default class SolanaLib { try { bytes = bs58.decode(transaction) } catch { - bytes = Buffer.from(transaction, 'base64') + // Convert base64 to Uint8Array to avoid type issues + const buffer = Buffer.from(transaction, 'base64') + bytes = new Uint8Array(buffer) } return VersionedTransaction.deserialize(bytes) @@ -110,6 +126,124 @@ export default class SolanaLib { private sign(transaction: VersionedTransaction) { transaction.sign([this.keypair]) } + + /** + * Send SOL to a recipient + * @param recipientAddress The recipient's address + * @param amount The amount to send in lamports (as a bigint) + * @returns The transaction signature/hash + */ + public async sendSol(recipientAddress: string, chainId: string, amount: bigint): Promise { + console.log({ chainId }) + const rpc = { ...SOLANA_TEST_CHAINS, ...SOLANA_MAINNET_CHAINS }[chainId]?.rpc + + if (!rpc) { + throw new Error('There is no RPC URL for the provided chain') + } + + const connection = new Connection(rpc, 'confirmed') + const fromPubkey = this.keypair.publicKey + const toPubkey = new PublicKey(recipientAddress) + + // Create a simple SOL transfer transaction + const transaction = new Transaction().add( + SystemProgram.transfer({ + fromPubkey, + toPubkey, + lamports: amount + }) + ) + + // Get recent blockhash + const { blockhash } = await connection.getLatestBlockhash('confirmed') + transaction.recentBlockhash = blockhash + transaction.feePayer = fromPubkey + + // Sign the transaction + transaction.sign(this.keypair) + + // Send and confirm the transaction + const signature = await connection.sendRawTransaction(transaction.serialize()) + + // Wait for confirmation + await connection.confirmTransaction(signature, 'confirmed') + + return signature + } + + /** + * Send an SPL token to a recipient + * @param tokenAddress The token's mint address + * @param recipientAddress The recipient's address + * @param amount The amount to send (as a bigint) + * @returns The transaction signature/hash + */ + public async sendSplToken( + tokenAddress: string, + recipientAddress: string, + chainId: string, + amount: bigint + ): Promise { + const rpc = { ...SOLANA_TEST_CHAINS, ...SOLANA_MAINNET_CHAINS }[chainId]?.rpc + + if (!rpc) { + throw new Error('There is no RPC URL for the provided chain') + } + + const connection = new Connection(rpc, 'confirmed') + const fromWallet = this.keypair + const fromPubkey = fromWallet.publicKey + const toPubkey = new PublicKey(recipientAddress) + const mint = new PublicKey(tokenAddress) + + // Get sender's token account (create if it doesn't exist) + const fromTokenAccount = await getOrCreateAssociatedTokenAccount( + connection, + fromWallet, + mint, + fromPubkey + ) + + // Check if recipient has a token account WITHOUT creating one + const associatedTokenAddress = await getAssociatedTokenAddress(mint, toPubkey) + + const recipientTokenAccount = await connection.getAccountInfo(associatedTokenAddress) + + if (!recipientTokenAccount) { + throw new Error( + `Recipient ${recipientAddress} doesn't have a token account for this SPL token. Transaction cannot proceed.` + ) + } + + // Create transfer instruction to existing account + const transferInstruction = createTransferInstruction( + fromTokenAccount.address, + associatedTokenAddress, + fromPubkey, + amount, + [], + TOKEN_PROGRAM_ID + ) + + // Create transaction and add the transfer instruction + const transaction = new Transaction().add(transferInstruction) + + // Get recent blockhash + const { blockhash } = await connection.getLatestBlockhash('confirmed') + transaction.recentBlockhash = blockhash + transaction.feePayer = fromPubkey + + // Sign the transaction + transaction.sign(fromWallet) + + // Send and confirm the transaction + const signature = await connection.sendRawTransaction(transaction.serialize()) + + // Wait for confirmation + await connection.confirmTransaction(signature, 'confirmed') + + return signature + } } export namespace SolanaLib { diff --git a/advanced/wallets/react-wallet-v2/src/schema/WalletCheckoutSchema.ts b/advanced/wallets/react-wallet-v2/src/schema/WalletCheckoutSchema.ts index 06cca5441..3af464aa4 100644 --- a/advanced/wallets/react-wallet-v2/src/schema/WalletCheckoutSchema.ts +++ b/advanced/wallets/react-wallet-v2/src/schema/WalletCheckoutSchema.ts @@ -1,63 +1,16 @@ -import { ContractInteraction } from '@/types/wallet_checkout' +import { + CheckoutErrorCode, + ContractInteraction, + createCheckoutError, + SolanaContractInteraction +} from '@/types/wallet_checkout' import { z } from 'zod' -// Define Zod schemas for validation -export const ProductMetadataSchema = z.object({ - name: z.string().min(1, 'Product name is required'), - description: z.string().optional(), - imageUrl: z.string().url().optional(), - price: z.string().optional() -}) -export const ContractInteractionSchema = z.object({ - type: z.string().min(1, 'Contract interaction type is required'), - data: z - .union([ - z.record(z.any()), // Object with string keys - z.array(z.any()) // Array of any values - ]) - .refine( - data => data !== null && (typeof data === 'object' || Array.isArray(data)), - 'Contract data must be an object or an array' - ) -}) - -export const PaymentOptionSchema = z - .object({ - asset: z - .string() - .min(1, 'Asset is required') - .refine(isValidCAIP19AssetId, 'Invalid CAIP-19 asset'), - amount: z.string().regex(/^0x[0-9a-fA-F]+$/, 'Amount must be a hex string'), - recipient: z.string().refine(isValidCAIP10AccountId, 'Invalid CAIP-10 recipient').optional(), - contractInteraction: ContractInteractionSchema.refine( - isValidContractInteraction, - 'Invalid contract interaction' - ).optional() - }) - .refine( - data => - (data.recipient && !data.contractInteraction) || - (!data.recipient && data.contractInteraction), - 'Either recipient or contractInteraction must be provided, but not both' - ) - .refine(data => { - if (!data.recipient) return true - return matchingChainIds(data.asset, data.recipient) - }, 'Asset and recipient must be on the same chain') - -export const CheckoutRequestSchema = z.object({ - orderId: z.string().max(128, 'Order ID must not exceed 128 characters'), - acceptedPayments: z.array(PaymentOptionSchema).min(1, 'At least one payment option is required'), - products: z.array(ProductMetadataSchema).optional(), - expiry: z.number().int().optional() -}) +// ======== Helper Validation Functions ======== /** * Validates if a string follows the CAIP-19 format * Simple validation: chainNamespace:chainId/assetNamespace:assetReference - * - * @param assetId - CAIP-19 asset ID to validate - * @returns Whether the asset ID is valid */ export function isValidCAIP19AssetId(assetId: string): boolean { if (typeof assetId !== 'string') return false @@ -73,7 +26,7 @@ export function isValidCAIP19AssetId(assetId: string): boolean { chainParts?.length === 2 && chainParts[0]?.length > 0 && chainParts[1]?.length > 0 && - assetParts?.length === 2 && + assetParts.length === 2 && assetParts[0]?.length > 0 && assetParts[1]?.length > 0 ) @@ -82,9 +35,6 @@ export function isValidCAIP19AssetId(assetId: string): boolean { /** * Validates if a string follows the CAIP-10 format * Simple validation: chainNamespace:chainId:address - * - * @param accountId - CAIP-10 account ID to validate - * @returns Whether the account ID is valid */ export function isValidCAIP10AccountId(accountId: string): boolean { if (typeof accountId !== 'string') return false @@ -95,31 +45,44 @@ export function isValidCAIP10AccountId(accountId: string): boolean { } /** - * Checks if a contract interaction is valid - * - * @param contractInteraction - Contract interaction to validate - * @returns Whether the contract interaction is valid + * Validates if a Solana instruction is valid */ -export function isValidContractInteraction( - contractInteraction: ContractInteraction | undefined -): boolean { - if (!contractInteraction) return false +export function isValidSolanaInstruction(instruction: SolanaContractInteraction['data']): boolean { + try { + if (!instruction || typeof instruction !== 'object') return false - return ( - typeof contractInteraction === 'object' && - typeof contractInteraction.type === 'string' && - contractInteraction.type.trim() !== '' && - typeof contractInteraction.data === 'object' && - contractInteraction.data !== null - ) + // Check for required properties + if (!instruction.programId || typeof instruction.programId !== 'string') return false + if (!instruction.accounts || !Array.isArray(instruction.accounts)) return false + if (!instruction.data || typeof instruction.data !== 'string') return false + + // Validate each account + for (const account of instruction.accounts) { + if (!account || typeof account !== 'object') return false + if (!account.pubkey || typeof account.pubkey !== 'string') return false + if (typeof account.isSigner !== 'boolean') return false + if (typeof account.isWritable !== 'boolean') return false + } + + return true + } catch (e) { + return false + } +} + +/** + * Checks if an EVM call is valid + */ +export function isValidEvmCall(call: { to: string; data: string; value?: string }): boolean { + if (!call.to || typeof call.to !== 'string') return false + if (!call.data || typeof call.data !== 'string') return false + // Check value only if it's provided + if (call.value !== undefined && (typeof call.value !== 'string' || !call.value)) return false + return true } /** * Checks if the chain IDs in the asset and recipient match - * - * @param assetId - CAIP-19 asset ID - * @param accountId - CAIP-10 account ID - * @returns Whether the chain IDs match */ export function matchingChainIds(assetId: string, accountId: string): boolean { try { @@ -142,3 +105,257 @@ export function matchingChainIds(assetId: string, accountId: string): boolean { return false } } + +/** + * Validates Solana-specific asset format + */ +function validateSolanaAsset(asset: string, ctx: z.RefinementCtx) { + const assetParts = asset.split('/') + if (assetParts.length !== 2) return + + const chainParts = assetParts[0].split(':') + if (chainParts[0] !== 'solana') return + + // For Solana assets, validate asset namespace and reference + const assetType = assetParts[1].split(':') + if (assetType.length !== 2) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid Solana asset format: ${asset}` + }) + throw createCheckoutError( + CheckoutErrorCode.INVALID_CHECKOUT_REQUEST, + `Invalid Solana asset format: ${asset}` + ) + } + + // Check supported Solana asset namespaces + if (assetType[0] !== 'slip44' && assetType[0] !== 'token') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Unsupported Solana asset namespace: ${assetType[0]}` + }) + throw createCheckoutError( + CheckoutErrorCode.INVALID_CHECKOUT_REQUEST, + `Unsupported Solana asset namespace: ${assetType[0]}` + ) + } + + // For slip44, validate the coin type is 501 for SOL + if (assetType[0] === 'slip44' && assetType[1] !== '501') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid Solana slip44 asset reference: ${assetType[1]}` + }) + throw createCheckoutError( + CheckoutErrorCode.INVALID_CHECKOUT_REQUEST, + `Invalid Solana slip44 asset reference: ${assetType[1]}` + ) + } +} + +/** + * Validates EVM-specific asset format + */ +function validateEvmAsset(asset: string, ctx: z.RefinementCtx) { + const assetParts = asset.split('/') + if (assetParts.length !== 2) return + + const chainParts = assetParts[0].split(':') + if (chainParts[0] !== 'eip155') return + + // For EVM assets, validate asset namespace and reference + const assetType = assetParts[1].split(':') + if (assetType.length !== 2) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid EVM asset format: ${asset}` + }) + throw createCheckoutError( + CheckoutErrorCode.INVALID_CHECKOUT_REQUEST, + `Invalid EVM asset format: ${asset}` + ) + } + + // Check supported EVM asset namespaces + if (assetType[0] !== 'slip44' && assetType[0] !== 'erc20') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Unsupported EVM asset namespace: ${assetType[0]}` + }) + throw createCheckoutError( + CheckoutErrorCode.INVALID_CHECKOUT_REQUEST, + `Unsupported EVM asset namespace: ${assetType[0]}` + ) + } + + // For slip44, validate the coin type is 60 for ETH + if (assetType[0] === 'slip44' && assetType[1] !== '60') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid EVM slip44 asset reference: ${assetType[1]}` + }) + throw createCheckoutError( + CheckoutErrorCode.INVALID_CHECKOUT_REQUEST, + `Invalid EVM slip44 asset reference: ${assetType[1]}` + ) + } +} + +/** + * Validates asset format based on chain type + */ +function validateAssetFormat(asset: string, ctx: z.RefinementCtx) { + const assetParts = asset.split('/') + if (assetParts.length !== 2) return + + const chainParts = assetParts[0].split(':') + + // Validate based on chain namespace + switch (chainParts[0]) { + case 'solana': + validateSolanaAsset(asset, ctx) + break + case 'eip155': + validateEvmAsset(asset, ctx) + break + } +} + +// ======== Basic Schema Definitions ======== + +export const ProductMetadataSchema = z.object({ + name: z.string().min(1, 'Product name is required'), + description: z.string().optional(), + imageUrl: z.string().url().optional(), + price: z.string().optional() +}) + +export const SolanaAccountSchema = z.object({ + pubkey: z.string().min(1, 'Account public key is required'), + isSigner: z.boolean(), + isWritable: z.boolean() +}) + +export const SolanaInstructionDataSchema = z.object({ + programId: z.string().min(1, 'Program ID is required'), + accounts: z.array(SolanaAccountSchema).min(1, 'At least one account is required'), + data: z.string().min(1, 'Instruction data is required') +}) + +// ======== Contract Interaction Schemas ======== + +const EvmCallSchema = z + .object({ + to: z.string().min(1), + data: z.string().min(1), + value: z.string().optional() + }) + .refine(isValidEvmCall, { + message: 'Invalid EVM call data' + }) + +const SolanaInstructionSchema = z + .object({ + programId: z.string().min(1), + accounts: z.array(SolanaAccountSchema).min(1), + data: z.string().min(1) + }) + .refine(isValidSolanaInstruction, { + message: 'Invalid Solana instruction data' + }) + +export const ContractInteractionSchema = z + .object({ + type: z.string().min(1, 'Contract interaction type is required'), + data: z.any() + }) + .superRefine((interaction, ctx) => { + // Check if interaction type is supported + if (interaction.type !== 'evm-calls' && interaction.type !== 'solana-instruction') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Unsupported contract interaction type' + }) + throw createCheckoutError(CheckoutErrorCode.UNSUPPORTED_CONTRACT_INTERACTION) + } + + // Validate based on interaction type + if (interaction.type === 'evm-calls') { + validateEvmCalls(interaction, ctx) + } else if (interaction.type === 'solana-instruction') { + validateSolanaInstruction(interaction, ctx) + } + }) + +// Extracted validation functions for cleaner code +function validateEvmCalls(interaction: any, ctx: z.RefinementCtx) { + if (!interaction.data || !Array.isArray(interaction.data) || interaction.data.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Invalid EVM calls data structure' + }) + throw createCheckoutError(CheckoutErrorCode.INVALID_CONTRACT_INTERACTION_DATA) + } + + // Validate each EVM call + for (const call of interaction.data) { + try { + EvmCallSchema.parse(call) + } catch (e) { + throw createCheckoutError(CheckoutErrorCode.INVALID_CONTRACT_INTERACTION_DATA) + } + } +} + +function validateSolanaInstruction(interaction: any, ctx: z.RefinementCtx) { + if (!interaction.data || typeof interaction.data !== 'object') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Invalid Solana instruction data structure' + }) + throw createCheckoutError(CheckoutErrorCode.INVALID_CONTRACT_INTERACTION_DATA) + } + + try { + SolanaInstructionSchema.parse(interaction.data) + } catch (e) { + throw createCheckoutError(CheckoutErrorCode.INVALID_CONTRACT_INTERACTION_DATA) + } +} + +// ======== Payment Schema Definitions ======== + +// Asset validation schema with chain-specific checks +const AssetSchema = z + .string() + .min(1, 'Asset is required') + .refine(isValidCAIP19AssetId, 'Invalid CAIP-19 asset') + .superRefine(validateAssetFormat) + +export const PaymentOptionSchema = z + .object({ + asset: AssetSchema, + amount: z.string().regex(/^0x[0-9a-fA-F]+$/, 'Amount must be a hex string'), + recipient: z.string().refine(isValidCAIP10AccountId, 'Invalid CAIP-10 recipient').optional(), + contractInteraction: ContractInteractionSchema.optional() + }) + .refine( + data => + (data.recipient && !data.contractInteraction) || + (!data.recipient && data.contractInteraction), + 'Either recipient or contractInteraction must be provided, but not both' + ) + .refine(data => { + if (!data.recipient) return true + return matchingChainIds(data.asset, data.recipient) + }, 'Asset and recipient must be on the same chain') + +// ======== Checkout Request Schema ======== + +export const CheckoutRequestSchema = z.object({ + orderId: z.string().max(128, 'Order ID must not exceed 128 characters'), + acceptedPayments: z.array(PaymentOptionSchema).min(1, 'At least one payment option is required'), + products: z.array(ProductMetadataSchema).optional(), + expiry: z.number().int().optional() +}) diff --git a/advanced/wallets/react-wallet-v2/src/types/wallet_checkout.ts b/advanced/wallets/react-wallet-v2/src/types/wallet_checkout.ts index 6fb38b12c..877420fe3 100644 --- a/advanced/wallets/react-wallet-v2/src/types/wallet_checkout.ts +++ b/advanced/wallets/react-wallet-v2/src/types/wallet_checkout.ts @@ -52,6 +52,25 @@ export type EvmContractInteraction = { }[] } +/** + * Solana-specific contract interaction + * @property type - Must be "solana-instruction" + * @property data - Array of Solana instruction data objects + */ +export type SolanaContractInteraction = { + type: 'solana-instruction' + data: { + programId: string // Program ID + accounts: { + // Accounts involved in the instruction + pubkey: string + isSigner: boolean + isWritable: boolean + }[] + data: string // Base64-encoded instruction data + } +} + /** * A payment option for the checkout * @property asset - CAIP-19 asset identifier diff --git a/advanced/wallets/react-wallet-v2/src/utils/PaymentValidatorUtil.ts b/advanced/wallets/react-wallet-v2/src/utils/PaymentValidatorUtil.ts index bf4eb70dd..f3404131c 100644 --- a/advanced/wallets/react-wallet-v2/src/utils/PaymentValidatorUtil.ts +++ b/advanced/wallets/react-wallet-v2/src/utils/PaymentValidatorUtil.ts @@ -1,12 +1,28 @@ import { getChainData } from '@/data/chainsUtil' -import { type PaymentOption, type DetailedPaymentOption, Hex } from '@/types/wallet_checkout' +import { + type PaymentOption, + type DetailedPaymentOption, + Hex, + SolanaContractInteraction +} from '@/types/wallet_checkout' import { createPublicClient, erc20Abi, http, getContract, encodeFunctionData } from 'viem' import TransactionSimulatorUtil from './TransactionSimulatorUtil' import SettingsStore from '@/store/SettingsStore' -import { getTokenData } from '@/data/tokenUtil' +import { getSolanaTokenData, getTokenData } from '@/data/tokenUtil' import { getChainById } from './ChainUtil' -import { EIP155_CHAINS } from '@/data/EIP155Data' - +import { blockchainApiRpc } from '@/data/EIP155Data' +import { + Connection, + PublicKey, + SystemProgram, + Transaction, + TransactionInstruction +} from '@solana/web3.js' +import { SOLANA_TEST_CHAINS } from '@/data/SolanaData' +import { SOLANA_MAINNET_CHAINS } from '@/data/SolanaData' +import { createTransferInstruction, TOKEN_PROGRAM_ID } from '@solana/spl-token' +import { createAssociatedTokenAccountInstruction } from '@solana/spl-token' +import { getAssociatedTokenAddress } from '@solana/spl-token' /** * Interface for token details */ @@ -22,8 +38,8 @@ interface TokenDetails { */ export class PaymentValidationUtils { // Constants for fallback asset paths - private static readonly PLACEHOLDER_TOKEN_ICON = '/token-logos/token-placeholder.svg' - private static readonly PLACEHOLDER_CHAIN_ICON = '/chain-logos/chain-placeholder.svg' + private static readonly PLACEHOLDER_TOKEN_ICON = '/token-logos/token-placeholder.png' + private static readonly PLACEHOLDER_CHAIN_ICON = '/chain-logos/chain-placeholder.png' /** * Parses and validates a CAIP-19 asset ID @@ -77,24 +93,19 @@ export class PaymentValidationUtils { * @returns Whether the namespace is supported */ private static isSupportedAssetNamespace(assetNamespace: string): boolean { - // Currently only support ERC20 tokens and native tokens - return ['erc20', 'slip44'].includes(assetNamespace) + // Support ERC20 tokens, native tokens, and solana token + return ['erc20', 'slip44', 'token'].includes(assetNamespace) } - /** - * Gets details for a native blockchain asset (like ETH) - * - * @param chainId - Chain ID number - * @param account - Account address - * @returns Native asset details including balance and metadata - */ + // methods to get token details + private static async getNativeAssetDetails( chainId: number, account: `0x${string}` ): Promise { const publicClient = createPublicClient({ chain: getChainById(chainId), - transport: http(EIP155_CHAINS[`eip155:${chainId}`].rpc) + transport: http(blockchainApiRpc(Number(chainId))) }) const balance = await publicClient.getBalance({ @@ -109,14 +120,6 @@ export class PaymentValidationUtils { } } - /** - * Gets details for an ERC20 token - * - * @param tokenAddress - Token contract address - * @param chainId - Chain ID number - * @param account - Account address - * @returns Token details including balance and metadata - */ private static async getErc20TokenDetails( tokenAddress: Hex, chainId: number, @@ -124,7 +127,7 @@ export class PaymentValidationUtils { ): Promise { const publicClient = createPublicClient({ chain: getChainById(chainId), - transport: http(EIP155_CHAINS[`eip155:${chainId}`].rpc) + transport: http(blockchainApiRpc(Number(chainId))) }) const contract = getContract({ @@ -148,15 +151,110 @@ export class PaymentValidationUtils { } } - /** - * Validates a contract interaction to ensure it can be executed successfully - * - * @param contractInteraction - The contract interaction data - * @param chainId - Chain ID - * @param account - User account address - * @returns Whether the contract interaction can succeed - */ - private static async simulateContractInteraction( + private static async getSolNativeAssetDetails( + account: string, + chainId: string + ): Promise { + const defaultTokenDetails: TokenDetails = { + balance: BigInt(0), + decimals: 9, + symbol: 'SOL', + name: 'Solana' + } + + try { + // Get the RPC URL for the chain + const rpc = { ...SOLANA_TEST_CHAINS, ...SOLANA_MAINNET_CHAINS }[chainId]?.rpc + + if (!rpc) { + return defaultTokenDetails + } + + // Connect to Solana + const connection = new Connection(rpc, 'confirmed') + const publicKey = new PublicKey(account) + + const balance = await connection.getBalance(publicKey) + return { + ...defaultTokenDetails, + balance: BigInt(balance) + } + } catch (error) { + console.error('Error getting SOL balance:', error) + return defaultTokenDetails + } + } + + private static async getSplTokenDetails( + tokenAddress: string, + account: string, + chainId: string, + caip19AssetAddress: string + ): Promise { + const defaultTokenDetails: TokenDetails = { + balance: BigInt(0), + decimals: 6, + symbol: 'UNK', + name: 'Unknown Token' + } + + try { + const rpc = { ...SOLANA_TEST_CHAINS, ...SOLANA_MAINNET_CHAINS }[chainId]?.rpc + + if (!rpc) { + return defaultTokenDetails + } + + // Connect to Solana + const connection = new Connection(rpc, 'confirmed') + const publicKey = new PublicKey(account) + const mintAddress = new PublicKey(tokenAddress) + + const token = getSolanaTokenData(caip19AssetAddress) + + // Get token balance + let balance = BigInt(0) + let decimals = token?.decimals || 0 // Use known token decimals or default + + // Find the associated token account(s) + const tokenAccounts = await connection.getParsedTokenAccountsByOwner(publicKey, { + mint: mintAddress + }) + + // If token account exists, get balance + if (tokenAccounts.value.length > 0) { + const tokenAccountPubkey = tokenAccounts.value[0].pubkey + const tokenBalance = await connection.getTokenAccountBalance(tokenAccountPubkey) + balance = BigInt(tokenBalance.value.amount) + + // Update decimals from on-chain data if not a known token + if (!token) { + decimals = tokenBalance.value.decimals + } + } else if (!token) { + // If no token accounts and not a known token, try to get decimals from mint + const mintInfo = await connection.getParsedAccountInfo(mintAddress) + if (mintInfo.value) { + const parsedMintInfo = (mintInfo.value.data as any).parsed?.info + decimals = parsedMintInfo?.decimals || decimals + } + } + + // Return with known metadata or fallback to generic + return { + balance, + decimals, + symbol: token?.symbol || tokenAddress.slice(0, 4).toUpperCase(), + name: token?.name || `SPL Token (${tokenAddress.slice(0, 8)}...)` + } + } catch (error) { + // Return default values in case of error + return defaultTokenDetails + } + } + + // methods to simulate payments + private static async simulateEvmContractInteraction( contractInteraction: any, chainId: string, account: string @@ -165,123 +263,160 @@ export class PaymentValidationUtils { return false } + if (Array.isArray(contractInteraction.data)) { + const simulateEvmTransaction = await TransactionSimulatorUtil.simulateEvmTransaction( + chainId, + account as `0x${string}`, + contractInteraction.data as { to: string; value: string; data: string }[] + ) + return simulateEvmTransaction + } + // If data is not an array, it's an invalid format + return false + } + private static async simulateSolanaContractInteraction(params: { + contractInteraction: SolanaContractInteraction + account: string + chainId: string + }) { try { - if (Array.isArray(contractInteraction.data)) { - const canTransactionSucceed = await TransactionSimulatorUtil.canTransactionSucceed( - chainId, - account as `0x${string}`, - contractInteraction.data as { to: string; value: string; data: string }[] - ) - return canTransactionSucceed - } else { - // If data is not an array, it's an invalid format + const { contractInteraction, account, chainId } = params + const rpc = { ...SOLANA_TEST_CHAINS, ...SOLANA_MAINNET_CHAINS }[chainId]?.rpc + if (!rpc) { return false } - } catch (error) { - console.error('Error validating contract interaction:', error) + + const connection = new Connection(rpc, 'confirmed') + const publicKey = new PublicKey(account) + // Create a new transaction + const transaction = new Transaction() + + const instruction = contractInteraction.data + const accountMetas = instruction.accounts.map(acc => ({ + pubkey: new PublicKey(acc.pubkey), + isSigner: acc.isSigner, + isWritable: acc.isWritable + })) + + // Create the instruction + const txInstruction = new TransactionInstruction({ + programId: new PublicKey(instruction.programId), + keys: accountMetas, + data: Buffer.from(instruction.data, 'base64') + }) + + // Add to transaction + transaction.add(txInstruction) + + const simulationResult = await TransactionSimulatorUtil.simulateSolanaTransaction({ + connection, + transaction, + feePayer: publicKey + }) + return simulationResult + } catch (e) { return false } } - - /** - * Validates an ERC20 token payment and retrieves token details - * - * @param assetAddress - Token contract address - * @param chainId - Chain ID - * @param senderAccount - Sender's account address - * @param recipientAddress - Recipient's address - * @param amount - Payment amount in hex - * @returns Object containing token details and validation status - */ - private static async simulateAndGetErc20PaymentDetails( - assetAddress: string, - chainId: string, - senderAccount: string, - recipientAddress: string, + private static async simulateSolanaNativeTransfer(params: { + account: string + recipientAddress: string amount: string - ): Promise<{ tokenDetails: TokenDetails; isValid: boolean }> { - // Get token details - const tokenDetails = await PaymentValidationUtils.getErc20TokenDetails( - assetAddress as `0x${string}`, - Number(chainId), - senderAccount as `0x${string}` - ) + chainId: string + }) { + try { + const { account, recipientAddress, amount, chainId } = params + const rpc = { ...SOLANA_TEST_CHAINS, ...SOLANA_MAINNET_CHAINS }[chainId]?.rpc + if (!rpc) { + return false + } - // Check if transaction can succeed - const canTransactionSucceed = await TransactionSimulatorUtil.canTransactionSucceed( - chainId, - senderAccount as `0x${string}`, - [ - { - to: assetAddress as `0x${string}`, - value: '0x0', - data: encodeFunctionData({ - abi: erc20Abi, - functionName: 'transfer', - args: [recipientAddress as `0x${string}`, BigInt(amount)] - }) - } - ] - ) + const connection = new Connection(rpc, 'confirmed') + const publicKey = new PublicKey(account) - return { - tokenDetails, - isValid: canTransactionSucceed + const transaction = new Transaction() + transaction.add( + SystemProgram.transfer({ + fromPubkey: publicKey, + toPubkey: new PublicKey(recipientAddress), + lamports: BigInt(amount) + }) + ) + const simulationResult = await TransactionSimulatorUtil.simulateSolanaTransaction({ + connection, + transaction, + feePayer: publicKey + }) + return simulationResult + } catch (e) { + return false } } + private static async simulateSolanaTokenTransfer(params: { + account: string + recipientAddress: string + amount: bigint + tokenAddress: string + chainId: string + }) { + try { + const { account, recipientAddress, amount, tokenAddress, chainId } = params + const rpc = { ...SOLANA_TEST_CHAINS, ...SOLANA_MAINNET_CHAINS }[chainId]?.rpc + if (!rpc) { + return false + } - /** - * Validates a native token payment and retrieves token details - * - * @param chainId - Chain ID - * @param senderAccount - Sender's account address - * @param recipientAddress - Recipient's address - * @param amount - Payment amount in hex - * @returns Object containing token details and validation status - */ - private static async simulateAndGetNativeTokenPaymentDetails( - chainId: string, - senderAccount: string, - recipientAddress: string, - amount: string - ): Promise<{ tokenDetails: TokenDetails; isValid: boolean }> { - // Check if transaction can succeed - const canTransactionSucceed = await TransactionSimulatorUtil.canTransactionSucceed( - chainId, - senderAccount as `0x${string}`, - [ - { - to: recipientAddress as `0x${string}`, - value: amount, - data: '0x' - } - ] - ) + const connection = new Connection(rpc, 'confirmed') + const fromPubkey = new PublicKey(account) + const mintAddress = new PublicKey(tokenAddress) + const toPubkey = new PublicKey(recipientAddress) + + const fromTokenAccountAddress = await getAssociatedTokenAddress(mintAddress, fromPubkey) + + const fromTokenAccount = await connection.getAccountInfo(fromTokenAccountAddress) + if (!fromTokenAccount) { + return false + } + + const toTokenAccountAddress = await getAssociatedTokenAddress(mintAddress, toPubkey) + const recipientTokenAccount = await connection.getAccountInfo(toTokenAccountAddress) - // Get native token details - const tokenDetails = canTransactionSucceed - ? await PaymentValidationUtils.getNativeAssetDetails( - Number(chainId), - senderAccount as `0x${string}` + // Create transaction + const transaction = new Transaction() + + // Add instruction to create recipient token account if needed + if (!recipientTokenAccount) { + const createAccountInstruction = createAssociatedTokenAccountInstruction( + fromPubkey, + toTokenAccountAddress, + toPubkey, + mintAddress ) - : { decimals: 18, symbol: 'ETH', name: 'Ethereum', balance: BigInt(0) } + transaction.add(createAccountInstruction) + } - return { - tokenDetails, - isValid: canTransactionSucceed + // Add transfer instruction + const transferInstruction = createTransferInstruction( + fromTokenAccountAddress, + toTokenAccountAddress, + fromPubkey, + amount, + [], + TOKEN_PROGRAM_ID + ) + transaction.add(transferInstruction) + + const simulationResult = await TransactionSimulatorUtil.simulateSolanaTransaction({ + connection, + transaction, + feePayer: fromPubkey + }) + return simulationResult + } catch (e) { + return false } } - /** - * Creates a detailed payment option with all necessary metadata - * - * @param payment - Original payment option - * @param tokenDetails - Token details - * @param assetNamespace - Asset namespace - * @param chainId - Chain ID - * @param chainNamespace - Chain namespace - * @returns Detailed payment option - */ private static createDetailedPaymentOption( payment: PaymentOption, tokenDetails: TokenDetails, @@ -310,17 +445,7 @@ export class PaymentValidationUtils { } } - /** - * Validates a single direct payment option and creates a detailed version if valid - * - * @param payment - Payment option to validate - * @param account - User account address - * @returns Object containing the validated payment (or null) and asset availability flag - */ - private static async getDetailedDirectPaymentOption( - payment: PaymentOption, - account: string - ): Promise<{ + private static async getDetailedDirectPaymentOption(payment: PaymentOption): Promise<{ validatedPayment: DetailedPaymentOption | null hasMatchingAsset: boolean }> { @@ -340,61 +465,190 @@ export class PaymentValidationUtils { return { validatedPayment: null, hasMatchingAsset: false } } - let validationResult + let result + + switch (chainNamespace) { + case 'solana': + result = await this.processSolanaDirectPayment( + payment, + recipientAddress, + chainId, + assetAddress, + assetNamespace + ) + break + + case 'eip155': + result = await this.processEvmDirectPayment( + payment, + recipientAddress, + chainId, + assetAddress, + assetNamespace + ) + break + + default: + return { validatedPayment: null, hasMatchingAsset: false } + } - // Validate based on asset type - if (assetNamespace === 'erc20') { - validationResult = await PaymentValidationUtils.simulateAndGetErc20PaymentDetails( - assetAddress, - chainId, - account, - recipientAddress, - payment.amount - ) - } else { - // slip44 - native token - validationResult = await PaymentValidationUtils.simulateAndGetNativeTokenPaymentDetails( + return result + } catch (error) { + console.error('Error validating payment option:', error) + return { validatedPayment: null, hasMatchingAsset: false } + } + } + + private static async processSolanaDirectPayment( + payment: PaymentOption, + recipientAddress: string, + chainId: string, + assetAddress: string, + assetNamespace: string + ): Promise<{ + validatedPayment: DetailedPaymentOption | null + hasMatchingAsset: boolean + }> { + const account = SettingsStore.state.solanaAddress + let tokenDetails: TokenDetails | undefined + let simulationResult: boolean | undefined + + if (assetNamespace === 'slip44' && assetAddress === '501') { + simulationResult = await this.simulateSolanaNativeTransfer({ + account, + recipientAddress: recipientAddress, + amount: payment.amount, + chainId: `solana:${chainId}` + }) + tokenDetails = simulationResult + ? await this.getSolNativeAssetDetails(account, `solana:${chainId}`) + : undefined + } else if (assetNamespace === 'token') { + simulationResult = await this.simulateSolanaTokenTransfer({ + account, + recipientAddress: recipientAddress, + amount: BigInt(payment.amount), + tokenAddress: assetAddress, + chainId: `solana:${chainId}` + }) + tokenDetails = simulationResult + ? await this.getSplTokenDetails(assetAddress, account, `solana:${chainId}`, payment.asset) + : undefined + } else { + return { validatedPayment: null, hasMatchingAsset: false } + } + + // Check if token details were assigned + if (!tokenDetails) { + return { validatedPayment: null, hasMatchingAsset: false } + } + + // Check if user has the asset (balance > 0) + const hasMatchingAsset = tokenDetails.balance > BigInt(0) + console.log({ tokenDetails }) + if (!hasMatchingAsset) { + return { validatedPayment: null, hasMatchingAsset } + } + + // Create detailed payment option with metadata + const detailedPayment = simulationResult + ? PaymentValidationUtils.createDetailedPaymentOption( + payment, + tokenDetails, + assetNamespace, chainId, - account, - recipientAddress, - payment.amount + 'solana' ) - } + : null - // Check if user has the asset (balance > 0) - const hasMatchingAsset = validationResult.tokenDetails.balance > BigInt(0) + return { validatedPayment: detailedPayment, hasMatchingAsset: true } + } - if (!validationResult.isValid) { - return { validatedPayment: null, hasMatchingAsset } - } + private static async processEvmDirectPayment( + payment: PaymentOption, + recipientAddress: string, + chainId: string, + assetAddress: string, + assetNamespace: string + ): Promise<{ + validatedPayment: DetailedPaymentOption | null + hasMatchingAsset: boolean + }> { + const account = SettingsStore.state.eip155Address as `0x${string}` + let tokenDetails: TokenDetails | undefined + let simulationResult: boolean | undefined - // Create detailed payment option with metadata - const detailedPayment = PaymentValidationUtils.createDetailedPaymentOption( - payment, - validationResult.tokenDetails, - assetNamespace, + if (assetNamespace === 'erc20') { + simulationResult = await PaymentValidationUtils.simulateEvmContractInteraction( + { + data: [ + { + to: assetAddress as `0x${string}`, + value: '0x0', + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [recipientAddress as `0x${string}`, BigInt(payment.amount)] + }) + } + ] + }, + chainId, + account + ) + tokenDetails = await PaymentValidationUtils.getErc20TokenDetails( + assetAddress as `0x${string}`, + Number(chainId), + account as `0x${string}` + ) + } else if (assetNamespace === 'slip44' && assetAddress === '60') { + // slip44:60 - native ETH token + simulationResult = await TransactionSimulatorUtil.simulateEvmTransaction( chainId, - chainNamespace + account as `0x${string}`, + [ + { + to: recipientAddress as `0x${string}`, + value: payment.amount, + data: '0x' + } + ] ) + tokenDetails = await PaymentValidationUtils.getNativeAssetDetails( + Number(chainId), + account as `0x${string}` + ) + } else { + return { validatedPayment: null, hasMatchingAsset: false } + } - return { validatedPayment: detailedPayment, hasMatchingAsset: true } - } catch (error) { - console.error('Error validating payment option:', error) + // Check if token details were assigned + if (!tokenDetails || simulationResult === undefined) { return { validatedPayment: null, hasMatchingAsset: false } } + + // Check if user has the asset (balance > 0) + const hasMatchingAsset = tokenDetails.balance > BigInt(0) + console.log({ tokenDetails }) + if (!hasMatchingAsset) { + return { validatedPayment: null, hasMatchingAsset } + } + + // Create detailed payment option with metadata + const detailedPayment = simulationResult + ? PaymentValidationUtils.createDetailedPaymentOption( + payment, + tokenDetails, + assetNamespace, + chainId, + 'eip155' + ) + : null + + return { validatedPayment: detailedPayment, hasMatchingAsset: true } } - /** - * Validates a contract payment option and creates a detailed version if valid - * - * @param payment - Payment option to validate - * @param account - User account address - * @returns Object containing the validated payment (or null) and asset availability flag - */ - private static async getDetailedContractPaymentOption( - payment: PaymentOption, - account: string - ): Promise<{ + private static async getDetailedContractPaymentOption(payment: PaymentOption): Promise<{ validatedPayment: DetailedPaymentOption | null hasMatchingAsset: boolean }> { @@ -409,121 +663,195 @@ export class PaymentValidationUtils { const { chainId, assetAddress, chainNamespace, assetNamespace } = PaymentValidationUtils.getAssetDetails(asset) - // Validate contract interaction - const isContractValid = await PaymentValidationUtils.simulateContractInteraction( - contractInteraction, - chainId, - account - ) - - if (!isContractValid) { - return { validatedPayment: null, hasMatchingAsset: false } - } - // Check if asset namespace is supported if (!PaymentValidationUtils.isSupportedAssetNamespace(assetNamespace)) { return { validatedPayment: null, hasMatchingAsset: false } } - // Get asset details based on asset namespace - let tokenDetails: TokenDetails - if (assetNamespace === 'erc20') { - tokenDetails = await PaymentValidationUtils.getErc20TokenDetails( - assetAddress as `0x${string}`, - Number(chainId), - account as `0x${string}` - ) - } else { - // must be slip44 since we already checked supported namespaces - tokenDetails = await PaymentValidationUtils.getNativeAssetDetails( - Number(chainId), - account as `0x${string}` - ) + let result + + switch (chainNamespace) { + case 'solana': + result = await this.processSolanaContractPayment( + payment, + chainId, + assetAddress, + assetNamespace, + contractInteraction + ) + break + + case 'eip155': + result = await this.processEvmContractPayment( + payment, + chainId, + assetAddress, + assetNamespace, + contractInteraction + ) + break + + default: + return { validatedPayment: null, hasMatchingAsset: false } } - // Check if user has the asset (balance > 0) - const hasMatchingAsset = tokenDetails.balance > BigInt(0) - - // Create detailed payment option with metadata (reusing the common method) - const detailedPayment = PaymentValidationUtils.createDetailedPaymentOption( - payment, - tokenDetails, - assetNamespace, - chainId, - chainNamespace - ) - - return { - validatedPayment: detailedPayment, - hasMatchingAsset - } + return result } catch (error) { console.error('Error validating contract payment option:', error) return { validatedPayment: null, hasMatchingAsset: false } } } - /** - * Finds and validates all feasible direct payment options - * - * @param directPayments - Array of direct payment options - * @returns Object containing feasible payments and asset availability flag - */ - static async findFeasibleDirectPayments(directPayments: PaymentOption[]): Promise<{ - feasibleDirectPayments: DetailedPaymentOption[] - isUserHaveAtleastOneMatchingAssets: boolean + private static async processSolanaContractPayment( + payment: PaymentOption, + chainId: string, + assetAddress: string, + assetNamespace: string, + contractInteraction: any + ): Promise<{ + validatedPayment: DetailedPaymentOption | null + hasMatchingAsset: boolean }> { - let isUserHaveAtleastOneMatchingAssets = false - const account = SettingsStore.state.eip155Address + const account = SettingsStore.state.solanaAddress + let tokenDetails: TokenDetails | undefined + let isValid = false - // Validate each payment option - const results = await Promise.all( - directPayments.map(payment => - PaymentValidationUtils.getDetailedDirectPaymentOption(payment, account) + if (contractInteraction.type !== 'solana-instruction') { + return { validatedPayment: null, hasMatchingAsset: false } + } + + isValid = await this.simulateSolanaContractInteraction({ + contractInteraction: contractInteraction as SolanaContractInteraction, + account, + chainId: `solana:${chainId}` + }) + if (!isValid) { + return { validatedPayment: null, hasMatchingAsset: false } + } + + if (assetNamespace === 'slip44' && assetAddress === '501') { + // Native SOL + tokenDetails = await this.getSolNativeAssetDetails(account, `solana:${chainId}`) + } else if (assetNamespace === 'token') { + // SPL token + tokenDetails = await this.getSplTokenDetails( + assetAddress, + account, + `solana:${chainId}`, + payment.asset ) - ) + } else { + return { validatedPayment: null, hasMatchingAsset: false } + } - // Collect results - const feasibleDirectPayments: DetailedPaymentOption[] = [] + // Check if token details were assigned + if (!tokenDetails) { + return { validatedPayment: null, hasMatchingAsset: false } + } - for (const result of results) { - if (result.hasMatchingAsset) { - isUserHaveAtleastOneMatchingAssets = true - } + // Check if user has the asset (balance > 0) + const hasMatchingAsset = tokenDetails.balance > BigInt(0) - if (result.validatedPayment) { - feasibleDirectPayments.push(result.validatedPayment) - } + // Create detailed payment option with metadata + const detailedPayment = isValid + ? PaymentValidationUtils.createDetailedPaymentOption( + payment, + tokenDetails, + assetNamespace, + chainId, + 'solana' + ) + : null + return { validatedPayment: detailedPayment, hasMatchingAsset } + } + + private static async processEvmContractPayment( + payment: PaymentOption, + chainId: string, + assetAddress: string, + assetNamespace: string, + contractInteraction: any + ): Promise<{ + validatedPayment: DetailedPaymentOption | null + hasMatchingAsset: boolean + }> { + const account = SettingsStore.state.eip155Address as `0x${string}` + let tokenDetails: TokenDetails | undefined + let isValid = false + + if (contractInteraction.type !== 'evm-calls') { + return { validatedPayment: null, hasMatchingAsset: false } } - return { - feasibleDirectPayments, - isUserHaveAtleastOneMatchingAssets + isValid = await PaymentValidationUtils.simulateEvmContractInteraction( + contractInteraction, + chainId, + account + ) + + if (!isValid) { + return { validatedPayment: null, hasMatchingAsset: false } } + + if (assetNamespace === 'erc20') { + tokenDetails = await PaymentValidationUtils.getErc20TokenDetails( + assetAddress as `0x${string}`, + Number(chainId), + account as `0x${string}` + ) + } else if (assetNamespace === 'slip44') { + // must be slip44 since we already checked supported namespaces + tokenDetails = await PaymentValidationUtils.getNativeAssetDetails( + Number(chainId), + account as `0x${string}` + ) + } else { + return { validatedPayment: null, hasMatchingAsset: false } + } + + // Check if token details were assigned + if (!tokenDetails) { + return { validatedPayment: null, hasMatchingAsset: false } + } + + // Check if user has the asset (balance > 0) + const hasMatchingAsset = tokenDetails.balance > BigInt(0) + + // Create detailed payment option with metadata + const detailedPayment = isValid + ? PaymentValidationUtils.createDetailedPaymentOption( + payment, + tokenDetails, + assetNamespace, + chainId, + 'eip155' + ) + : null + return { validatedPayment: detailedPayment, hasMatchingAsset } } - /** - * Finds and validates all feasible contract payment options - * - * @param contractPayments - Array of contract payment options - * @returns Object containing feasible payments and asset availability flag - */ - static async findFeasibleContractPayments(contractPayments: PaymentOption[]): Promise<{ - feasibleContractPayments: DetailedPaymentOption[] + static async findFeasiblePayments(payments: PaymentOption[]): Promise<{ + feasiblePayments: DetailedPaymentOption[] isUserHaveAtleastOneMatchingAssets: boolean }> { let isUserHaveAtleastOneMatchingAssets = false - const account = SettingsStore.state.eip155Address - // Validate each contract payment option const results = await Promise.all( - contractPayments.map(payment => - PaymentValidationUtils.getDetailedContractPaymentOption(payment, account) - ) + payments.map(async payment => { + if (payment.recipient && !payment.contractInteraction) { + // Direct payment + return await this.getDetailedDirectPaymentOption(payment) + } else if (payment.contractInteraction) { + return await this.getDetailedContractPaymentOption(payment) + } else { + console.warn('Invalid payment: missing both recipient and contractInteraction') + return { validatedPayment: null, hasMatchingAsset: false } + } + }) ) // Collect results - const feasibleContractPayments: DetailedPaymentOption[] = [] + const feasiblePayments: DetailedPaymentOption[] = [] for (const result of results) { if (result.hasMatchingAsset) { @@ -531,12 +859,12 @@ export class PaymentValidationUtils { } if (result.validatedPayment) { - feasibleContractPayments.push(result.validatedPayment) + feasiblePayments.push(result.validatedPayment) } } return { - feasibleContractPayments, + feasiblePayments, isUserHaveAtleastOneMatchingAssets } } diff --git a/advanced/wallets/react-wallet-v2/src/utils/TransactionSimulatorUtil.ts b/advanced/wallets/react-wallet-v2/src/utils/TransactionSimulatorUtil.ts index d7a58cc3a..67a5a1c66 100644 --- a/advanced/wallets/react-wallet-v2/src/utils/TransactionSimulatorUtil.ts +++ b/advanced/wallets/react-wallet-v2/src/utils/TransactionSimulatorUtil.ts @@ -1,3 +1,5 @@ +import { blockchainApiRpc } from '@/data/EIP155Data' +import { Connection, PublicKey, Transaction } from '@solana/web3.js' import { createPublicClient, http } from 'viem' const TransactionSimulatorUtil = { @@ -10,23 +12,24 @@ const TransactionSimulatorUtil = { * @param calls - Array of transaction details to simulate * @returns Boolean indicating if all transactions would be valid */ - canTransactionSucceed: async ( + simulateEvmTransaction: async ( chainId: string, fromWalletAddress: string, calls: { to: string; value: string; data?: string }[] ) => { - const projectId = process.env.NEXT_PUBLIC_PROJECT_ID || '' - - const client = createPublicClient({ - transport: http( - `https://rpc.walletconnect.org/v1?chainId=eip155:${chainId}&projectId=${projectId}` - ) - }) + if (!calls || calls.length === 0) { + console.warn('No transaction calls provided for simulation') + return false + } try { - // Process all calls in parallel + const client = createPublicClient({ + transport: http(blockchainApiRpc(Number(chainId))) + }) + + // Process all calls in parallel with individual error handling const results = await Promise.all( - calls.map(async call => { + calls.map(async (call, index) => { try { // Get current fee estimates const { maxFeePerGas, maxPriorityFeePerGas } = await client.estimateFeesPerGas() @@ -46,8 +49,8 @@ const TransactionSimulatorUtil = { return true } catch (error) { // Gas estimation failed - transaction would not succeed - console.error( - `Transaction simulation failed: ${ + console.warn( + `Transaction #${index + 1} simulation failed for address ${call.to}: ${ error instanceof Error ? error.message : 'Unknown error' }` ) @@ -59,14 +62,48 @@ const TransactionSimulatorUtil = { // Return true only if all transactions would succeed return results.every(success => success) } catch (error) { - // Handle any unexpected errors console.error( - `Error in transaction simulation: ${ + `Overall transaction simulation process failed: ${ error instanceof Error ? error.message : 'Unknown error' }` ) return false } + }, + + /** + * Simulates a Solana transaction + * + * @param connection - Solana connection + * @param transaction - Transaction to simulate + * @param feePayer - Fee payer's public key + * @returns Object with simulation success status and error details if applicable + * @throws CheckoutError if there's a critical simulation error that should block the transaction + */ + async simulateSolanaTransaction(param: { + connection: Connection + transaction: Transaction + feePayer: PublicKey + }): Promise { + try { + const { connection, transaction, feePayer } = param + // Set recent blockhash for the transaction + transaction.recentBlockhash = (await connection.getLatestBlockhash()).blockhash + transaction.feePayer = feePayer + + // Simulate the transaction + const simulation = await connection.simulateTransaction(transaction) + + // Check simulation results + if (simulation.value.err) { + console.warn('Solana simulation error:', simulation.value.err) + return false + } + + return true + } catch (error) { + return false + } } } diff --git a/advanced/wallets/react-wallet-v2/src/utils/WalletCheckoutPaymentHandler.ts b/advanced/wallets/react-wallet-v2/src/utils/WalletCheckoutPaymentHandler.ts index 21091965b..f59b51922 100644 --- a/advanced/wallets/react-wallet-v2/src/utils/WalletCheckoutPaymentHandler.ts +++ b/advanced/wallets/react-wallet-v2/src/utils/WalletCheckoutPaymentHandler.ts @@ -1,8 +1,16 @@ import { encodeFunctionData } from 'viem' import { erc20Abi } from 'viem' - -import { DetailedPaymentOption, CheckoutErrorCode, CheckoutError } from '@/types/wallet_checkout' -import { Wallet } from 'ethers' +import { Connection, PublicKey, Transaction, TransactionInstruction } from '@solana/web3.js' +import { Buffer } from 'buffer' + +import { + DetailedPaymentOption, + CheckoutErrorCode, + CheckoutError, + SolanaContractInteraction +} from '@/types/wallet_checkout' +import { SOLANA_MAINNET_CHAINS } from '@/data/SolanaData' +import { SOLANA_TEST_CHAINS } from '@/data/SolanaData' export interface PaymentResult { txHash: string @@ -21,13 +29,121 @@ const WalletCheckoutPaymentHandler = { } }, + /** + * Process a Solana contract interaction + */ + async processSolanaContractInteraction( + wallet: any, + contractInteraction: SolanaContractInteraction, + chainId: string + ): Promise { + const rpc = { ...SOLANA_TEST_CHAINS, ...SOLANA_MAINNET_CHAINS }[chainId]?.rpc + + if (!rpc) { + throw new Error(`There is no RPC URL for the provided chain ${chainId}`) + } + const connection = new Connection(rpc) + + // Create a new transaction + const transaction = new Transaction() + + const instruction = contractInteraction.data + const accountMetas = instruction.accounts.map(acc => ({ + pubkey: new PublicKey(acc.pubkey), + isSigner: acc.isSigner, + isWritable: acc.isWritable + })) + + // Create the instruction + const txInstruction = new TransactionInstruction({ + programId: new PublicKey(instruction.programId), + keys: accountMetas, + data: Buffer.from(instruction.data, 'base64') + }) + + // Add to transaction + transaction.add(txInstruction) + + // Set the wallet's public key as feePayer + const walletAddress = await wallet.getAddress() + const publicKey = new PublicKey(walletAddress) + transaction.feePayer = publicKey + + // Get recent blockhash from the connection + const { blockhash } = await connection.getLatestBlockhash('confirmed') + transaction.recentBlockhash = blockhash + + const txHash = await connection.sendRawTransaction(transaction.serialize()) + await connection.confirmTransaction(txHash, 'confirmed') + + return { txHash } + }, /** * Process any payment type and handle errors */ - async processPayment(wallet: Wallet, payment: DetailedPaymentOption): Promise { + async processPayment(wallet: any, payment: DetailedPaymentOption): Promise { try { - const { contractInteraction, recipient } = payment + const { contractInteraction, recipient, asset, chainMetadata } = payment + const { chainNamespace, chainId } = chainMetadata + + // ------ Process Solana payments ------ + if (chainNamespace === 'solana') { + // Check if wallet supports Solana operations + if (!wallet.getAddress || !wallet.signAndSendTransaction) { + throw new CheckoutError( + CheckoutErrorCode.INVALID_CHECKOUT_REQUEST, + 'Solana payment requires a compatible wallet' + ) + } + // Contract interaction payment + if ( + contractInteraction && + !recipient && + contractInteraction.type === 'solana-instruction' + ) { + return await this.processSolanaContractInteraction( + wallet, + contractInteraction as SolanaContractInteraction, + `${chainNamespace}:${chainId}` + ) + } + // Direct payment (with recipient) + if (recipient && !contractInteraction) { + const recipientAddress = recipient.split(':')[2] + const assetParts = asset.split('/') + const assetNamespace = assetParts[1]?.split(':')[0] + const assetReference = assetParts[1]?.split(':')[1] + + // Handle SOL transfers (slip44:501) + if (assetNamespace === 'slip44' && assetReference === '501') { + const txHash = await wallet.sendSol( + recipientAddress, + `${chainNamespace}:${chainId}`, + BigInt(payment.amount) + ) + return { txHash } + } + + // Handle SPL token transfers (token:) + if (assetNamespace === 'token') { + const txHash = await wallet.sendSplToken( + assetReference, + recipientAddress, + `${chainNamespace}:${chainId}`, + BigInt(payment.amount) + ) + return { txHash } + } + } + } + // Ensure wallet is an EVM wallet + if (!wallet.sendTransaction) { + throw new CheckoutError( + CheckoutErrorCode.INVALID_CHECKOUT_REQUEST, + 'EVM payment requires an EVM wallet' + ) + } // Direct payment (with recipient) if (recipient && !contractInteraction) { const { asset, amount, assetMetadata } = payment @@ -58,37 +174,32 @@ const WalletCheckoutPaymentHandler = { }) return { txHash: tx.hash } } - - throw new CheckoutError(CheckoutErrorCode.INVALID_CHECKOUT_REQUEST) } // Contract interaction payment - else if (contractInteraction && !recipient) { - // Handle array of calls - if (Array.isArray(contractInteraction.data) && contractInteraction.type === 'evm-calls') { - let lastTxHash = '0x' - - for (const call of contractInteraction.data) { - console.log('Processing contract call:', call) - const tx = await wallet.sendTransaction({ - to: call.to, - value: call.value, - data: call.data - }) - console.log('Transaction sent:', tx) - lastTxHash = tx.hash - } - - return { txHash: lastTxHash } + if ( + contractInteraction && + !recipient && + Array.isArray(contractInteraction.data) && + contractInteraction.type === 'evm-calls' + ) { + let lastTxHash = '0x' + + for (const call of contractInteraction.data) { + console.log('Processing contract call:', call) + const tx = await wallet.sendTransaction({ + to: call.to, + value: call.value, + data: call.data + }) + console.log('Transaction sent:', tx) + lastTxHash = tx.hash } - throw new CheckoutError(CheckoutErrorCode.UNSUPPORTED_CONTRACT_INTERACTION) + return { txHash: lastTxHash } } // Neither or both are present - throw new CheckoutError( - CheckoutErrorCode.INVALID_CHECKOUT_REQUEST, - 'Payment must have either recipient or contractInteraction, not both or neither' - ) + throw new CheckoutError(CheckoutErrorCode.INVALID_CHECKOUT_REQUEST) } catch (error) { console.error('Payment processing error:', error) diff --git a/advanced/wallets/react-wallet-v2/src/utils/WalletCheckoutUtil.ts b/advanced/wallets/react-wallet-v2/src/utils/WalletCheckoutUtil.ts index 0136887ed..f32d83769 100644 --- a/advanced/wallets/react-wallet-v2/src/utils/WalletCheckoutUtil.ts +++ b/advanced/wallets/react-wallet-v2/src/utils/WalletCheckoutUtil.ts @@ -11,47 +11,6 @@ import { CheckoutRequestSchema } from '@/schema/WalletCheckoutSchema' import { PaymentValidationUtils } from './PaymentValidatorUtil' const WalletCheckoutUtil = { - /** - * Format the asset ID for display - * Extracts the asset reference from a CAIP-19 asset ID - * - * @param assetId - CAIP-19 asset ID - * @returns The formatted asset display name - */ - formatAsset(assetId: string): string { - try { - const parts = assetId.split('/') - if (parts.length !== 2) return assetId - - const assetParts = parts[1].split(':') - if (assetParts.length !== 2) return parts[1] - - // For ERC20 tokens, return the token address - // In a production app, you might want to map this to token symbols - return assetParts[1] - } catch (e) { - return assetId - } - }, - - /** - * Format the hex amount to a decimal value - * - * @param hexAmount - Hex-encoded amount string - * @param decimals - Number of decimals for the asset (default: 6) - * @returns The formatted amount as a string - */ - formatAmount(hexAmount: string, decimals = 6): string { - try { - if (!hexAmount.startsWith('0x')) return hexAmount - - const amount = parseInt(hexAmount, 16) / Math.pow(10, decimals) - return amount.toFixed(2) - } catch (e) { - return hexAmount - } - }, - /** * Format the recipient address for display * Shortens the address with ellipsis for better display @@ -92,29 +51,7 @@ const WalletCheckoutUtil = { // Use Zod to validate the checkout request structure CheckoutRequestSchema.parse(checkoutRequest) - - // Additional validation for CAIP formats that Zod can't easily handle - const { acceptedPayments } = checkoutRequest - - for (const payment of acceptedPayments) { - // For contract payments, additional validation - if (payment.contractInteraction) { - // check if contract interaction type is supported - if (payment.contractInteraction.type !== 'evm-calls') { - throw createCheckoutError(CheckoutErrorCode.UNSUPPORTED_CONTRACT_INTERACTION) - } - - // check if contract interaction data is valid - if ( - !payment.contractInteraction.data || - !Array.isArray(payment.contractInteraction.data) - ) { - throw createCheckoutError(CheckoutErrorCode.INVALID_CONTRACT_INTERACTION_DATA) - } - } - } } catch (error) { - // Convert Zod validation errors or custom errors to CheckoutError if (error instanceof z.ZodError) { const errorDetails = error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ') throw createCheckoutError( @@ -126,47 +63,10 @@ const WalletCheckoutUtil = { } }, - /** - * Separates the accepted payments into direct payments and contract payments - * following the CAIP standard requirements - * - * @param acceptedPayments - Array of payment options - * @returns An object containing arrays of direct payments and contract payments - */ - separatePayments(acceptedPayments: PaymentOption[]) { - const directPayments: PaymentOption[] = [] - const contractPayments: PaymentOption[] = [] - - acceptedPayments.forEach(payment => { - // Skip if payment is undefined or null - if (!payment) { - return - } - - const { recipient, contractInteraction } = payment - const hasRecipient = typeof recipient === 'string' && recipient.trim() !== '' - const hasContractInteraction = contractInteraction !== undefined - - // Direct payment: recipient is present and contractInteraction is absent - if (hasRecipient && !hasContractInteraction) { - directPayments.push(payment) - } - // Contract interaction: contractInteraction is present and recipient is absent - else if (hasContractInteraction && !hasRecipient) { - contractPayments.push(payment) - } - }) - - return { - directPayments, - contractPayments - } - }, /** * Prepares a checkout request by validating it and checking if the user has sufficient balances * for at least one payment option. * - * @param account - User's account address * @param checkoutRequest - The checkout request to prepare * @returns A promise that resolves to an object with feasible payments * @throws CheckoutError if validation or preparation fails @@ -174,51 +74,24 @@ const WalletCheckoutUtil = { async getFeasiblePayments(checkoutRequest: CheckoutRequest): Promise<{ feasiblePayments: DetailedPaymentOption[] }> { - // Validate the checkout request (will throw if invalid) this.validateCheckoutRequest(checkoutRequest) - try { - const { acceptedPayments } = checkoutRequest - - // Separate payments for processing - const { directPayments, contractPayments } = this.separatePayments(acceptedPayments) + const { acceptedPayments } = checkoutRequest - // find feasible direct payments - const { feasibleDirectPayments, isUserHaveAtleastOneMatchingAssets } = - await PaymentValidationUtils.findFeasibleDirectPayments(directPayments) - // find feasible contract payments - const { - feasibleContractPayments, - isUserHaveAtleastOneMatchingAssets: validContractPayments - } = await PaymentValidationUtils.findFeasibleContractPayments(contractPayments) - // This return error if user have no matching assets - if (!isUserHaveAtleastOneMatchingAssets && !validContractPayments) { - throw createCheckoutError(CheckoutErrorCode.NO_MATCHING_ASSETS) - } - - // Combine all feasible payments - const feasiblePayments: DetailedPaymentOption[] = [ - ...feasibleDirectPayments, - ...feasibleContractPayments - ] + // find feasible direct payments + const { feasiblePayments, isUserHaveAtleastOneMatchingAssets } = + await PaymentValidationUtils.findFeasiblePayments(acceptedPayments) - // This return error if user have atleast one matching assets but no feasible payments - if (feasiblePayments.length === 0) { - throw createCheckoutError(CheckoutErrorCode.INSUFFICIENT_FUNDS) - } - - return { feasiblePayments } - } catch (error) { - // If it's already a CheckoutError, rethrow it - if (error && typeof error === 'object' && 'code' in error) { - throw error - } + // This return error if user have no matching assets + if (!isUserHaveAtleastOneMatchingAssets && !feasiblePayments) { + throw createCheckoutError(CheckoutErrorCode.NO_MATCHING_ASSETS) + } - // Otherwise wrap it in a CheckoutError - throw createCheckoutError( - CheckoutErrorCode.INVALID_CHECKOUT_REQUEST, - `Unexpected error: ${error instanceof Error ? error.message : String(error)}` - ) + // This return error if user have atleast one matching assets but no feasible payments + if (feasiblePayments.length === 0) { + throw createCheckoutError(CheckoutErrorCode.INSUFFICIENT_FUNDS) } + + return { feasiblePayments } }, // Add these methods directly to the object diff --git a/advanced/wallets/react-wallet-v2/src/views/SessionCheckoutModal.tsx b/advanced/wallets/react-wallet-v2/src/views/SessionCheckoutModal.tsx index 22a6c46de..7bb354d51 100644 --- a/advanced/wallets/react-wallet-v2/src/views/SessionCheckoutModal.tsx +++ b/advanced/wallets/react-wallet-v2/src/views/SessionCheckoutModal.tsx @@ -22,6 +22,8 @@ import { providers } from 'ethers' import { EIP155_CHAINS, TEIP155Chain } from '@/data/EIP155Data' import WalletCheckoutPaymentHandler from '@/utils/WalletCheckoutPaymentHandler' import WalletCheckoutCtrl from '@/store/WalletCheckoutCtrl' +import { solanaWallets } from '@/utils/SolanaWalletUtil' + // Custom styles for the modal const modalStyles = { modal: { @@ -70,7 +72,8 @@ export default function SessionCheckoutModal() { const checkoutRequest = useMemo(() => request?.params?.[0] || ({} as CheckoutRequest), [request]) // Use our custom hook to fetch payments - const address = SettingsStore.state.eip155Address + const eip155Address = SettingsStore.state.eip155Address + const solanaAddress = SettingsStore.state.solanaAddress const feasiblePayments = WalletCheckoutCtrl.state.feasiblePayments // Handle reject action @@ -94,6 +97,34 @@ export default function SessionCheckoutModal() { } }, [requestEvent, topic]) + // Get the appropriate wallet based on the selected payment's chain namespace + const getWalletForPayment = (payment: DetailedPaymentOption) => { + const { chainMetadata } = payment + const { chainNamespace, chainId } = chainMetadata + + if (chainNamespace === 'eip155') { + const wallet = eip155Wallets[eip155Address] + if (!(wallet instanceof EIP155Lib)) { + throw new Error('EVM wallet not available') + } + + // Set up the provider + const provider = new providers.JsonRpcProvider( + EIP155_CHAINS[`eip155:${chainId}` as TEIP155Chain].rpc + ) + return wallet.connect(provider) + } else if (chainNamespace === 'solana') { + const wallet = solanaWallets[solanaAddress] + console.log({ solanaWallet: wallet }) + if (!wallet) { + throw new Error('Solana wallet not available') + } + return wallet + } + + throw new Error(`Unsupported chain namespace: ${chainNamespace}`) + } + // Handle approve action const onApprove = useCallback(async () => { if (!requestEvent || !topic || !selectedPayment) return @@ -104,25 +135,11 @@ export default function SessionCheckoutModal() { // Validate the request before processing WalletCheckoutPaymentHandler.validateCheckoutExpiry(checkoutRequest) - const wallet = eip155Wallets[address] - - if (!(wallet instanceof EIP155Lib)) { - throw new Error('Wallet not available') - } - - // Set up the provider - const { chainMetadata } = selectedPayment - const { chainId } = chainMetadata - const provider = new providers.JsonRpcProvider( - EIP155_CHAINS[`eip155:${chainId}` as TEIP155Chain].rpc - ) - const connectedWallet = wallet.connect(provider) + // Get the wallet for this payment + const wallet = getWalletForPayment(selectedPayment) // Process the payment using the unified method - const result = await WalletCheckoutPaymentHandler.processPayment( - connectedWallet, - selectedPayment - ) + const result = await WalletCheckoutPaymentHandler.processPayment(wallet, selectedPayment) // Handle the result if (result.txHash) { @@ -139,14 +156,11 @@ export default function SessionCheckoutModal() { await walletkit.respondSessionRequest({ topic, response }) styledToast('Payment approved successfully', 'success') - } + } } catch (error) { // Handle any unexpected errors console.error('Error processing payment:', error) - const response = WalletCheckoutUtil.formatCheckoutErrorResponse( - requestEvent.id, - error - ) + const response = WalletCheckoutUtil.formatCheckoutErrorResponse(requestEvent.id, error) await walletkit.respondSessionRequest({ topic, response }) styledToast((error as Error).message, 'error') @@ -154,7 +168,7 @@ export default function SessionCheckoutModal() { setIsLoadingApprove(false) ModalStore.close() } - }, [checkoutRequest, requestEvent, selectedPayment, topic, address]) + }, [checkoutRequest, requestEvent, selectedPayment, topic]) // Handle payment selection const onSelectPayment = useCallback((payment: DetailedPaymentOption) => { diff --git a/advanced/wallets/react-wallet-v2/src/views/SessionProposalModal.tsx b/advanced/wallets/react-wallet-v2/src/views/SessionProposalModal.tsx index bbfc9fa48..91a9e9596 100644 --- a/advanced/wallets/react-wallet-v2/src/views/SessionProposalModal.tsx +++ b/advanced/wallets/react-wallet-v2/src/views/SessionProposalModal.tsx @@ -120,9 +120,7 @@ export default function SessionProposalModal() { return { eip155: { chains: eip155Chains, - methods: eip155Methods - .concat(eip5792Methods) - .concat(eip7715Methods), + methods: eip155Methods.concat(eip5792Methods).concat(eip7715Methods), events: ['accountsChanged', 'chainChanged'], accounts: eip155Chains .map(chain => diff --git a/advanced/wallets/react-wallet-v2/yarn.lock b/advanced/wallets/react-wallet-v2/yarn.lock index 27b7af60e..e38052af9 100644 --- a/advanced/wallets/react-wallet-v2/yarn.lock +++ b/advanced/wallets/react-wallet-v2/yarn.lock @@ -2342,13 +2342,111 @@ "@noble/hashes" "~1.7.1" "@scure/base" "~1.2.4" -"@solana/buffer-layout@^4.0.1": +"@solana/buffer-layout-utils@^0.2.0": + version "0.2.0" + resolved "https://registry.yarnpkg.com/@solana/buffer-layout-utils/-/buffer-layout-utils-0.2.0.tgz#b45a6cab3293a2eb7597cceb474f229889d875ca" + integrity sha512-szG4sxgJGktbuZYDg2FfNmkMi0DYQoVjN2h7ta1W1hPrwzarcFLBq9UpX1UjNXsNpT9dn+chgprtWGioUAr4/g== + dependencies: + "@solana/buffer-layout" "^4.0.0" + "@solana/web3.js" "^1.32.0" + bigint-buffer "^1.1.5" + bignumber.js "^9.0.1" + +"@solana/buffer-layout@^4.0.0", "@solana/buffer-layout@^4.0.1": version "4.0.1" resolved "https://registry.yarnpkg.com/@solana/buffer-layout/-/buffer-layout-4.0.1.tgz#b996235eaec15b1e0b5092a8ed6028df77fa6c15" integrity sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA== dependencies: buffer "~6.0.3" +"@solana/codecs-core@2.0.0-rc.1": + version "2.0.0-rc.1" + resolved "https://registry.yarnpkg.com/@solana/codecs-core/-/codecs-core-2.0.0-rc.1.tgz#1a2d76b9c7b9e7b7aeb3bd78be81c2ba21e3ce22" + integrity sha512-bauxqMfSs8EHD0JKESaNmNuNvkvHSuN3bbWAF5RjOfDu2PugxHrvRebmYauvSumZ3cTfQ4HJJX6PG5rN852qyQ== + dependencies: + "@solana/errors" "2.0.0-rc.1" + +"@solana/codecs-data-structures@2.0.0-rc.1": + version "2.0.0-rc.1" + resolved "https://registry.yarnpkg.com/@solana/codecs-data-structures/-/codecs-data-structures-2.0.0-rc.1.tgz#d47b2363d99fb3d643f5677c97d64a812982b888" + integrity sha512-rinCv0RrAVJ9rE/rmaibWJQxMwC5lSaORSZuwjopSUE6T0nb/MVg6Z1siNCXhh/HFTOg0l8bNvZHgBcN/yvXog== + dependencies: + "@solana/codecs-core" "2.0.0-rc.1" + "@solana/codecs-numbers" "2.0.0-rc.1" + "@solana/errors" "2.0.0-rc.1" + +"@solana/codecs-numbers@2.0.0-rc.1": + version "2.0.0-rc.1" + resolved "https://registry.yarnpkg.com/@solana/codecs-numbers/-/codecs-numbers-2.0.0-rc.1.tgz#f34978ddf7ea4016af3aaed5f7577c1d9869a614" + integrity sha512-J5i5mOkvukXn8E3Z7sGIPxsThRCgSdgTWJDQeZvucQ9PT6Y3HiVXJ0pcWiOWAoQ3RX8e/f4I3IC+wE6pZiJzDQ== + dependencies: + "@solana/codecs-core" "2.0.0-rc.1" + "@solana/errors" "2.0.0-rc.1" + +"@solana/codecs-strings@2.0.0-rc.1": + version "2.0.0-rc.1" + resolved "https://registry.yarnpkg.com/@solana/codecs-strings/-/codecs-strings-2.0.0-rc.1.tgz#e1d9167075b8c5b0b60849f8add69c0f24307018" + integrity sha512-9/wPhw8TbGRTt6mHC4Zz1RqOnuPTqq1Nb4EyuvpZ39GW6O2t2Q7Q0XxiB3+BdoEjwA2XgPw6e2iRfvYgqty44g== + dependencies: + "@solana/codecs-core" "2.0.0-rc.1" + "@solana/codecs-numbers" "2.0.0-rc.1" + "@solana/errors" "2.0.0-rc.1" + +"@solana/codecs@2.0.0-rc.1": + version "2.0.0-rc.1" + resolved "https://registry.yarnpkg.com/@solana/codecs/-/codecs-2.0.0-rc.1.tgz#146dc5db58bd3c28e04b4c805e6096c2d2a0a875" + integrity sha512-qxoR7VybNJixV51L0G1RD2boZTcxmwUWnKCaJJExQ5qNKwbpSyDdWfFJfM5JhGyKe9DnPVOZB+JHWXnpbZBqrQ== + dependencies: + "@solana/codecs-core" "2.0.0-rc.1" + "@solana/codecs-data-structures" "2.0.0-rc.1" + "@solana/codecs-numbers" "2.0.0-rc.1" + "@solana/codecs-strings" "2.0.0-rc.1" + "@solana/options" "2.0.0-rc.1" + +"@solana/errors@2.0.0-rc.1": + version "2.0.0-rc.1" + resolved "https://registry.yarnpkg.com/@solana/errors/-/errors-2.0.0-rc.1.tgz#3882120886eab98a37a595b85f81558861b29d62" + integrity sha512-ejNvQ2oJ7+bcFAYWj225lyRkHnixuAeb7RQCixm+5mH4n1IA4Qya/9Bmfy5RAAHQzxK43clu3kZmL5eF9VGtYQ== + dependencies: + chalk "^5.3.0" + commander "^12.1.0" + +"@solana/options@2.0.0-rc.1": + version "2.0.0-rc.1" + resolved "https://registry.yarnpkg.com/@solana/options/-/options-2.0.0-rc.1.tgz#06924ba316dc85791fc46726a51403144a85fc4d" + integrity sha512-mLUcR9mZ3qfHlmMnREdIFPf9dpMc/Bl66tLSOOWxw4ml5xMT2ohFn7WGqoKcu/UHkT9CrC6+amEdqCNvUqI7AA== + dependencies: + "@solana/codecs-core" "2.0.0-rc.1" + "@solana/codecs-data-structures" "2.0.0-rc.1" + "@solana/codecs-numbers" "2.0.0-rc.1" + "@solana/codecs-strings" "2.0.0-rc.1" + "@solana/errors" "2.0.0-rc.1" + +"@solana/spl-token-group@^0.0.7": + version "0.0.7" + resolved "https://registry.yarnpkg.com/@solana/spl-token-group/-/spl-token-group-0.0.7.tgz#83c00f0cd0bda33115468cd28b89d94f8ec1fee4" + integrity sha512-V1N/iX7Cr7H0uazWUT2uk27TMqlqedpXHRqqAbVO2gvmJyT0E0ummMEAVQeXZ05ZhQ/xF39DLSdBp90XebWEug== + dependencies: + "@solana/codecs" "2.0.0-rc.1" + +"@solana/spl-token-metadata@^0.1.6": + version "0.1.6" + resolved "https://registry.yarnpkg.com/@solana/spl-token-metadata/-/spl-token-metadata-0.1.6.tgz#d240947aed6e7318d637238022a7b0981b32ae80" + integrity sha512-7sMt1rsm/zQOQcUWllQX9mD2O6KhSAtY1hFR2hfFwgqfFWzSY9E9GDvFVNYUI1F0iQKcm6HmePU9QbKRXTEBiA== + dependencies: + "@solana/codecs" "2.0.0-rc.1" + +"@solana/spl-token@^0.4.13": + version "0.4.13" + resolved "https://registry.yarnpkg.com/@solana/spl-token/-/spl-token-0.4.13.tgz#8f65c3c2b315e1a00a91b8d0f60922c6eb71de62" + integrity sha512-cite/pYWQZZVvLbg5lsodSovbetK/eA24gaR0eeUeMuBAMNrT8XFCwaygKy0N2WSg3gSyjjNpIeAGBAKZaY/1w== + dependencies: + "@solana/buffer-layout" "^4.0.0" + "@solana/buffer-layout-utils" "^0.2.0" + "@solana/spl-token-group" "^0.0.7" + "@solana/spl-token-metadata" "^0.1.6" + buffer "^6.0.3" + "@solana/web3.js@1.89.2": version "1.89.2" resolved "https://registry.yarnpkg.com/@solana/web3.js/-/web3.js-1.89.2.tgz#d3d732f54eba86d5a13202d95385820ae9d90343" @@ -2370,6 +2468,27 @@ rpc-websockets "^7.5.1" superstruct "^0.14.2" +"@solana/web3.js@^1.32.0": + version "1.98.0" + resolved "https://registry.yarnpkg.com/@solana/web3.js/-/web3.js-1.98.0.tgz#21ecfe8198c10831df6f0cfde7f68370d0405917" + integrity sha512-nz3Q5OeyGFpFCR+erX2f6JPt3sKhzhYcSycBCSPkWjzSVDh/Rr1FqTVMRe58FKO16/ivTUcuJjeS5MyBvpkbzA== + dependencies: + "@babel/runtime" "^7.25.0" + "@noble/curves" "^1.4.2" + "@noble/hashes" "^1.4.0" + "@solana/buffer-layout" "^4.0.1" + agentkeepalive "^4.5.0" + bigint-buffer "^1.1.5" + bn.js "^5.2.1" + borsh "^0.7.0" + bs58 "^4.0.1" + buffer "6.0.3" + fast-stable-stringify "^1.0.0" + jayson "^4.1.1" + node-fetch "^2.7.0" + rpc-websockets "^9.0.2" + superstruct "^2.0.2" + "@solana/web3.js@^1.66.2": version "1.95.4" resolved "https://registry.yarnpkg.com/@solana/web3.js/-/web3.js-1.95.4.tgz#771603f60d75cf7556ad867e1fd2efae32f9ad09" @@ -3816,6 +3935,11 @@ chalk@^4.0.0: ansi-styles "^4.1.0" supports-color "^7.1.0" +chalk@^5.3.0: + version "5.4.1" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.4.1.tgz#1b48bf0963ec158dce2aacf69c093ae2dd2092d8" + integrity sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w== + character-entities-legacy@^1.0.0: version "1.1.4" resolved "https://registry.yarnpkg.com/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz#94bc1845dce70a5bb9d2ecc748725661293d8fc1" @@ -3928,6 +4052,11 @@ comma-separated-tokens@^1.0.0: resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz#632b80b6117867a158f1080ad498b2fbe7e3f5ea" integrity sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw== +commander@^12.1.0: + version "12.1.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-12.1.0.tgz#01423b36f501259fdaac4d0e4d60c96c991585d3" + integrity sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA== + commander@^2.20.3: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"