Разработка мобильного криптокошелька (Android)

Проектируем и разрабатываем блокчейн-решения полного цикла: от архитектуры смарт-контрактов до запуска DeFi-протоколов, NFT-маркетплейсов и криптобирж. Аудит безопасности, токеномика, интеграция с существующей инфраструктурой.
Показано 1 из 1 услугВсе 1306 услуг
Разработка мобильного криптокошелька (Android)
Сложная
от 2 недель до 3 месяцев
Часто задаваемые вопросы
Направления блокчейн-разработки
Этапы блокчейн-разработки
Последние работы
  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1221
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1163
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    855
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1056
  • image_logo-advance_0.png
    Разработка логотипа компании B2B Advance
    561
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    828

Разработка кошелька-расширения для браузера

Браузерный кошелёк — самый сложный тип crypto-клиента не потому что там много математики, а потому что он работает в нескольких изолированных контекстах одновременно и должен быть security-first при взаимодействии с любым сайтом в интернете. MetaMask, Phantom, Rabby — каждый из них решает одну и ту же задачу: дать dApp доступ к подписанию транзакций не давая dApp доступа к приватному ключу.

Архитектура расширения: три контекста

Браузерное расширение существует в трёх изолированных JavaScript контекстах с разными привилегиями и ограничениями:

┌─────────────────────────────────────────────────────────┐
│  Background Service Worker (Manifest V3)                │
│  - Хранит keystore (зашифрованный)                      │
│  - Управляет состоянием кошелька                        │
│  - Подписывает транзакции                               │
│  - Отвечает на запросы от popup и content script        │
└──────────────────┬──────────────────────────────────────┘
                   │ chrome.runtime.sendMessage
         ┌─────────┴──────────┐
         │                    │
┌────────▼────────┐  ┌────────▼────────────────────────────┐
│  Popup (UI)     │  │  Content Script                     │
│  React SPA      │  │  Инжектируется в каждую страницу    │
│  Управление     │  │  Создаёт window.ethereum             │
│  аккаунтами     │  │  Передаёт запросы от dApp            │
│  Подтверждение  │  │  к background                       │
│  транзакций     │  └─────────────────────────────────────┘
└─────────────────┘

Это не детали реализации — это security model. Content script не имеет доступа к ключам. Popup не имеет доступа к DOM страницы. Background — единственное место где живут ключи, и он изолирован от web content полностью.

Manifest V3: ограничения и обходы

Переход Chrome с Manifest V2 на V3 создал серьёзные проблемы для кошельков. Background page (persistent) заменён на service worker (ephemeral). Service worker может быть terminated браузером в любой момент — он не держит state постоянно.

// manifest.json (Manifest V3)
{
  "manifest_version": 3,
  "name": "MyWallet",
  "version": "1.0.0",
  
  "background": {
    "service_worker": "background.js",
    "type": "module"
  },
  
  "content_scripts": [{
    "matches": ["<all_urls>"],
    "js": ["content-script.js"],
    "run_at": "document_start",  // Важно: до загрузки страницы
    "world": "ISOLATED"
  }],
  
  "action": {
    "default_popup": "popup.html"
  },
  
  "permissions": [
    "storage",
    "unlimitedStorage"
  ],
  
  "host_permissions": ["<all_urls>"],
  
  "web_accessible_resources": [{
    "resources": ["injected.js"],
    "matches": ["<all_urls>"]
  }]
}

Service worker termination проблема решается через chrome.storage как persistence layer и keep-alive ping:

// background.ts — управление жизненным циклом service worker
class WalletBackground {
  private keepAliveInterval: NodeJS.Timeout | null = null;
  
  constructor() {
    // Восстанавливаем state после возможного restart
    this.restoreState();
    
    // Keep-alive для Chrome MV3 (SW может быть terminated)
    this.setupKeepAlive();
  }
  
  private setupKeepAlive() {
    // Periodic alarm не даёт SW быть terminated надолго
    chrome.alarms.create('keepAlive', { periodInMinutes: 0.4 });
    chrome.alarms.onAlarm.addListener((alarm) => {
      if (alarm.name === 'keepAlive') {
        // Просто активируем SW
      }
    });
  }
  
  private async restoreState() {
    // State хранится в chrome.storage.session (не persists через restart браузера)
    // или chrome.storage.local (persists, но зашифровать обязательно)
    const stored = await chrome.storage.session.get(['walletState']);
    if (stored.walletState) {
      this.state = stored.walletState;
    }
  }
  
  async saveState() {
    await chrome.storage.session.set({ walletState: this.state });
  }
}

Keystore: хранение и шифрование приватных ключей

Главный вопрос безопасности: как хранить приватный ключ. Ключи никогда не хранятся в plaintext — только в зашифрованном виде.

import { ethers } from 'ethers';

interface EncryptedKeystore {
  version: number;
  id: string;
  crypto: {
    ciphertext: string;
    cipherparams: { iv: string };
    cipher: string;
    kdf: string;
    kdfparams: {
      dklen: number;
      salt: string;
      n: number;   // N для scrypt — работа KDF
      r: number;
      p: number;
    };
    mac: string;
  };
}

class KeystoreManager {
  // Encrypt: password → encrypts private key → keystore JSON
  async encryptKey(privateKey: string, password: string): Promise<string> {
    // ethers.js использует EIP-55 keystore формат (совместим с geth)
    const wallet = new ethers.Wallet(privateKey);
    
    // N=131072 для production (медленнее, но безопаснее против brute force)
    // N=1024 для тестов (быстрее)
    const keystore = await wallet.encrypt(password, {
      scrypt: { N: 131072 }
    });
    
    return keystore;
  }
  
  // Decrypt: password + keystore → private key (только в памяти, не сохраняем)
  async decryptKey(keystoreJson: string, password: string): Promise<ethers.Wallet> {
    try {
      const wallet = await ethers.Wallet.fromEncryptedJson(keystoreJson, password);
      return wallet;
    } catch (e) {
      throw new Error('Invalid password or corrupted keystore');
    }
  }
  
  // HD wallet из mnemonic (BIP-39 + BIP-44)
  async createHDWallet(mnemonic: string, password: string): Promise<void> {
    // Validate mnemonic
    if (!ethers.Mnemonic.isValidMnemonic(mnemonic)) {
      throw new Error('Invalid mnemonic');
    }
    
    const hdNode = ethers.HDNodeWallet.fromMnemonic(
      ethers.Mnemonic.fromPhrase(mnemonic)
    );
    
    // Derivation path: m/44'/60'/0'/0/N (MetaMask compatible)
    const accounts: EncryptedKeystore[] = [];
    for (let i = 0; i < 5; i++) {
      const child = hdNode.deriveChild(i);
      const encrypted = await this.encryptKey(child.privateKey, password);
      accounts.push(JSON.parse(encrypted));
    }
    
    // Храним encrypted mnemonic и keystores
    const encryptedMnemonic = await this.encryptKey(
      ethers.hexlify(ethers.toUtf8Bytes(mnemonic)),
      password
    );
    
    await chrome.storage.local.set({
      encryptedMnemonic,
      accounts: accounts.map((k, i) => ({
        index: i,
        address: ethers.computeAddress(
          '0x' + JSON.parse(encryptedMnemonic).address
        ),
        keystore: k
      }))
    });
  }
}

Auto-lock механизм

Ключи не должны оставаться расшифрованными бесконечно:

class SessionManager {
  private unlockedWallets: Map<string, ethers.Wallet> = new Map();
  private lockTimer: NodeJS.Timeout | null = null;
  
  // Настраиваемый auto-lock: 1, 5, 15, 60 минут
  private readonly AUTO_LOCK_MINUTES: number;
  
  unlock(address: string, wallet: ethers.Wallet) {
    this.unlockedWallets.set(address.toLowerCase(), wallet);
    this.resetLockTimer();
  }
  
  lock() {
    // Явно очищаем ключи из памяти
    this.unlockedWallets.clear();
    if (this.lockTimer) clearTimeout(this.lockTimer);
    
    // Сигнализируем popup о lock
    chrome.runtime.sendMessage({ type: 'WALLET_LOCKED' });
  }
  
  private resetLockTimer() {
    if (this.lockTimer) clearTimeout(this.lockTimer);
    this.lockTimer = setTimeout(
      () => this.lock(),
      this.AUTO_LOCK_MINUTES * 60 * 1000
    );
  }
  
  getWallet(address: string): ethers.Wallet | undefined {
    return this.unlockedWallets.get(address.toLowerCase());
  }
}

EIP-1193 Provider: интерфейс для dApp

window.ethereum — это EIP-1193 провайдер. dApp вызывает window.ethereum.request({ method, params }) и получает ответ. Кошелёк должен реализовывать этот интерфейс.

Content script + injected script

Проблема: content script изолирован (не имеет доступа к window страницы). Нужен второй слой — injected script, который выполняется в контексте страницы.

// content-script.ts — выполняется в ISOLATED world, имеет доступ к DOM
// Инжектирует провайдер в window страницы

function injectProvider() {
  const script = document.createElement('script');
  script.src = chrome.runtime.getURL('injected.js');
  script.type = 'module';
  
  // Важно: inject до выполнения страничных скриптов
  (document.head ?? document.documentElement).prepend(script);
  script.remove();
}

injectProvider();

// Мост между injected.js (page context) и background
window.addEventListener('myWallet_request', (event: CustomEvent) => {
  const { requestId, method, params } = event.detail;
  
  chrome.runtime.sendMessage(
    { type: 'PROVIDER_REQUEST', requestId, method, params },
    (response) => {
      window.dispatchEvent(new CustomEvent('myWallet_response', {
        detail: { requestId, ...response }
      }));
    }
  );
});
// injected.ts — выполняется в контексте страницы, имеет доступ к window
// Создаёт window.ethereum

class EIP1193Provider extends EventEmitter {
  private requestId = 0;
  private pendingRequests = new Map<number, {
    resolve: (value: unknown) => void;
    reject: (error: unknown) => void;
  }>();
  
  constructor() {
    super();
    
    window.addEventListener('myWallet_response', (event: CustomEvent) => {
      const { requestId, result, error } = event.detail;
      const pending = this.pendingRequests.get(requestId);
      
      if (pending) {
        this.pendingRequests.delete(requestId);
        if (error) {
          pending.reject(new Error(error.message));
        } else {
          pending.resolve(result);
        }
      }
    });
  }
  
  async request({ method, params }: { method: string; params?: unknown[] }): Promise<unknown> {
    const requestId = ++this.requestId;
    
    return new Promise((resolve, reject) => {
      this.pendingRequests.set(requestId, { resolve, reject });
      
      window.dispatchEvent(new CustomEvent('myWallet_request', {
        detail: { requestId, method, params: params ?? [] }
      }));
      
      // Timeout для зависших запросов
      setTimeout(() => {
        if (this.pendingRequests.has(requestId)) {
          this.pendingRequests.delete(requestId);
          reject(new Error('Request timeout'));
        }
      }, 30000);
    });
  }
  
  // Legacy MetaMask compatible methods
  async enable(): Promise<string[]> {
    return this.request({ method: 'eth_requestAccounts' }) as Promise<string[]>;
  }
  
  isConnected(): boolean {
    return true;
  }
}

// EIP-6963: современный способ анонсирования провайдера
const provider = new EIP1193Provider();
window.ethereum = provider;

// Анонсируем через EIP-6963
window.dispatchEvent(new CustomEvent('eip6963:announceProvider', {
  detail: {
    info: {
      uuid: 'your-unique-uuid',
      name: 'MyWallet',
      icon: 'data:image/svg+xml,...',
      rdns: 'com.mywallet'
    },
    provider
  }
}));

Обработка запросов в background

// background.ts — обработка EIP-1193 запросов
class ProviderRequestHandler {
  private keystoreManager: KeystoreManager;
  private sessionManager: SessionManager;
  private connectedSites: Map<string, string[]>;  // origin → [addresses]
  
  async handleRequest(
    method: string,
    params: unknown[],
    origin: string
  ): Promise<unknown> {
    switch (method) {
      case 'eth_requestAccounts':
        return this.requestAccounts(origin);
      
      case 'eth_accounts':
        return this.getConnectedAccounts(origin);
      
      case 'eth_chainId':
        return this.getCurrentChainId();
      
      case 'eth_sendTransaction':
        return this.handleSendTransaction(params[0] as TransactionRequest, origin);
      
      case 'personal_sign':
        return this.handlePersonalSign(params[0] as string, params[1] as string, origin);
      
      case 'eth_signTypedData_v4':
        return this.handleSignTypedData(params[0] as string, params[1] as string, origin);
      
      case 'wallet_switchEthereumChain':
        return this.handleChainSwitch(params[0] as { chainId: string });
      
      default:
        // Форвардим RPC запросы к провайдеру
        return this.forwardToRPC(method, params);
    }
  }
  
  private async handleSendTransaction(tx: TransactionRequest, origin: string): Promise<string> {
    // Показываем popup для подтверждения
    await this.openConfirmationPopup('transaction', {
      tx,
      origin,
      estimatedGas: await this.estimateGas(tx),
      gasPrices: await this.getGasPrices()
    });
    
    // Ждём ответа от пользователя через popup
    const approved = await this.waitForUserApproval();
    if (!approved) throw new Error('User rejected transaction');
    
    const wallet = this.sessionManager.getWallet(tx.from!);
    if (!wallet) throw new Error('Account locked');
    
    // Подписываем и отправляем
    const signedTx = await wallet.signTransaction(tx);
    const txHash = await this.provider.broadcastTransaction(signedTx);
    
    return txHash;
  }
}

Popup UI: confirmation экраны

Popup — это отдельное React SPA. Критические экраны:

// TransactionConfirmation.tsx
interface TransactionDetails {
  to: string;
  value: bigint;
  data: string;
  estimatedGas: bigint;
  gasPrice: bigint;
  origin: string;
  decoded?: {   // если удалось декодировать calldata
    function: string;
    args: Record<string, unknown>;
  };
}

function TransactionConfirmation({ tx }: { tx: TransactionDetails }) {
  const isContract = tx.data !== '0x' && tx.data !== '';
  const totalCost = tx.value + tx.estimatedGas * tx.gasPrice;
  
  return (
    <div className="confirm-tx">
      <div className="origin-badge">
        <img src={getFavicon(tx.origin)} />
        <span>{tx.origin}</span>
      </div>
      
      {/* Предупреждение для незнакомых контрактов */}
      {isContract && !isKnownContract(tx.to) && (
        <div className="warning">
          Unverified contract interaction
        </div>
      )}
      
      <div className="tx-details">
        <div>To: <Address address={tx.to} /></div>
        <div>Value: {formatETH(tx.value)} ETH</div>
        {tx.decoded && (
          <div className="decoded-call">
            <span>Function: {tx.decoded.function}</span>
            {/* Показываем decoded args в понятном виде */}
          </div>
        )}
        <div>Gas fee: ~{formatETH(tx.estimatedGas * tx.gasPrice)} ETH</div>
        <div>Total: {formatETH(totalCost)} ETH</div>
      </div>
      
      <div className="actions">
        <button onClick={onReject} className="btn-reject">Reject</button>
        <button onClick={onApprove} className="btn-approve">Confirm</button>
      </div>
    </div>
  );
}

Безопасность: защита от фишинга

Кошелёк должен защищать пользователей от фишинговых сайтов:

// Список известных фишинговых доменов (MetaMask публично поддерживает список)
async function checkPhishingDomain(origin: string): Promise<boolean> {
  const domain = new URL(origin).hostname;
  
  // Проверяем против etherscan PhishDetector или MetaMask списка
  const response = await fetch(
    `https://phishing-detection.metaswap.codefi.network/v1/domains/${domain}`
  );
  const data = await response.json();
  
  return data.result === 'blocked';
}

// Визуальное предупреждение в popup при работе с suspicious сайтом
function PhishingWarning({ domain }: { domain: string }) {
  return (
    <div className="phishing-warning">
      <strong>Warning: Potential Phishing Site</strong>
      <p>{domain} has been flagged as a phishing site.</p>
      <p>Do not approve any transactions on this site.</p>
    </div>
  );
}

EIP-712 typed data signing: безопасный approve

async function handleSignTypedData(
  address: string,
  typedDataJson: string,
  origin: string
): Promise<string> {
  const typedData = JSON.parse(typedDataJson);
  
  // Декодируем что подписывается — показываем пользователю в понятном виде
  const decoded = decodeTypedData(typedData);
  
  // Специальная обработка permit (ERC-2612) — пользователь должен знать
  // что он подписывает infinite approval
  if (typedData.primaryType === 'Permit') {
    const spender = typedData.message.spender;
    const value = typedData.message.value;
    const isInfinite = value === ethers.MaxUint256;
    
    await showPermitWarning({ spender, value, isInfinite, origin });
  }
  
  const approved = await waitForUserApproval(decoded);
  if (!approved) throw new Error('User rejected');
  
  const wallet = sessionManager.getWallet(address);
  return wallet.signTypedData(
    typedData.domain,
    { [typedData.primaryType]: typedData.types[typedData.primaryType] },
    typedData.message
  );
}

Стек и инструменты

Компонент Технология
Extension framework Manifest V3, WXT (Vite-based) или CRXJS
UI (popup) React 18 + TypeScript + Tailwind
Crypto primitives ethers.js v6 или viem
Key derivation BIP-39 (mnemonic), BIP-44 (HD paths)
Storage encryption AES-256-GCM + scrypt KDF
State management Zustand или Recoil
Build Vite + rollup
Testing Playwright для E2E, Vitest для unit

Этапы разработки

Фаза Содержание Срок
Архитектура MV3 design, IPC схема, security model 2 нед
Keystore Encrypt/decrypt, HD wallet, auto-lock 3–4 нед
Provider (EIP-1193) window.ethereum, content script, injected 3–4 нед
Background handler Все RPC методы, chain management 3–4 нед
Popup UI Account management, tx confirmation, signing 4–6 нед
Security Phishing detection, simulation preview 2–3 нед
Multi-chain Добавление Solana, TON или других VM 4–8 нед
Тестирование E2E с реальными dApp, security review 3–4 нед
Аудит Crypto primitives + key storage 3–4 нед

Совместимость со store: Chrome Web Store имеет строгие требования к MV3, требует проверку extension. Firefox использует MV2/MV3 с отличиями. Сборки для обоих браузеров — отдельная задача в pipeline.