
Aprofundamento no Protocolo
A especificação do Mobile Wallet Adapter é uma das soluções mais elegantes do ecossistema Solana. Entender como ela funciona por dentro fará de você um desenvolvedor mobile melhor e ajudará a depurar os casos extremos estranhos que você inevitavelmente encontrará.
A maioria dos desenvolvedores nunca lê especificações de protocolo. Eles chamam transact(), funciona, e seguem em frente. Mas quando uma conexão falha silenciosamente, quando a autorização expira, quando uma carteira não responde, você precisa entender a mecânica.
Visão Geral da Arquitetura
Quando seu dApp se conecta a uma carteira, dois aplicativos no mesmo dispositivo estabelecem uma conexão WebSocket:
+-----------------+ +-----------------+
| | solana-wallet:// | |
| Seu dApp | -------- URI intent ----> | App Carteira |
| (Client) | | (Server) |
| | ws://localhost | |
| | <------ WebSocket ------> | :random_port |
+-----------------+ +-----------------+Seu dApp é sempre o cliente. O app da carteira é sempre o servidor. Essa assimetria é fundamental para o protocolo.
Mas aqui está o que torna o MWA inteligente: o app da carteira não executa um servidor WebSocket o tempo todo. Isso drenaria a bateria e criaria riscos de segurança. Em vez disso, a carteira só inicia seu servidor quando seu app solicita explicitamente uma conexão.
Associação: Encontrando a Carteira
Quando você chama transact(), o SDK executa estas etapas antes de qualquer conexão WebSocket existir:
Passo 1: Gerar um Par de Chaves de Associação
Seu app gera um par de chaves EC P-256 efêmero (não Ed25519 como as chaves Solana; isso é para troca de chaves ECDH).
// Isso acontece dentro do SDK automaticamente
const associationKeypair = generateP256Keypair();
const associationToken = base64url(associationKeypair.publicKey);
// ex.: "BGUNPbYRiwz6G2K..."Este associationToken é um identificador de uso único para esta sessão específica. Ele é incluído na URI e usado durante o handshake criptográfico.
Passo 2: Construir a URI de Associação
O SDK constrói uma URI que iniciará o app da carteira:
solana-wallet:/v1/associate/local
?association=<association_token>
&port=<random_port>
&v=2Decompondo isso:
solana-wallet:: O esquema de URI registrado pelas carteiras MWA/v1/associate/local: Conexão local (mesmo dispositivo)association: A chave pública codificada em base64url do Passo 1port: Uma porta aleatória (49152-65535) onde seu app se conectaráv=2: Versão do protocolo (MWA 2.0)
Passo 3: Iniciar a Carteira
No Android, abrir esta URI dispara um Intent. Se múltiplas carteiras estão instaladas, o SO solicita que o usuário escolha. A carteira selecionada é iniciada e recebe os parâmetros da URI.
// Visão simplificada do que acontece
await Linking.openURL(associationUri);
// O app da carteira vem para o primeiro planoPasso 4: A Carteira Inicia o Servidor
O app da carteira:
Analisa o parâmetro
portInicia um servidor WebSocket naquela porta
Começa a escutar por exatamente uma conexão
Expira após 10 segundos se nenhuma conexão chegar
Passo 5: O dApp se Conecta
Seu app tenta se conectar a ws://localhost:<port>/solana-wallet:
// O SDK gerencia isso automaticamente
const socket = new WebSocket(`ws://localhost:${port}/solana-wallet`);Se a carteira ainda não estiver pronta, a conexão falha. O SDK tenta novamente por até 30 segundos antes de desistir.
Estabelecimento de Sessão: O Handshake
Uma vez que o WebSocket se conecta, a verdadeira mágica criptográfica começa. Ambas as partes precisam estabelecer um segredo compartilhado para criptografar toda a comunicação subsequente, sem nunca enviar esse segredo pela rede.
A Troca de Chaves ECDH
O Elliptic Curve Diffie-Hellman (ECDH) permite que duas partes derivem o mesmo segredo trocando apenas chaves públicas. A matemática garante que, mesmo que alguém intercepte as chaves públicas, não consegue derivar o segredo compartilhado.
dApp Carteira
│ │
│─────── HELLO_REQ ───────────────────────>│
│ (Chave pública ECDH do dApp Qd) │
│ (Assinatura com chave de assoc.) │
│ │
│<─────── HELLO_RSP ───────────────────────│
│ (Chave pública ECDH da carteira) │
│ (Propriedades da sessão) │
│ │
│ Ambos computam o segredo K │
│ │
│<══════ JSON-RPC Criptografado ══════════>│
│ (AES-128-GCM com chave K) │HELLO_REQ: O dApp se Apresenta
Seu dApp gera outro par de chaves efêmero (para ECDH, separado do par de chaves de associação) e envia:
Qd || Signature(Qd, association_private_key)Onde:
Qd: Chave pública ECDH codificada em X9.62 (65 bytes)Signature: Prova ECDSA-SHA256 de que isso vem do mesmo app que criou a URI de associação
A carteira verifica a assinatura usando o token de associação (chave pública) da URI. Isso previne ataques man-in-the-middle: somente o app que gerou a URI original pode completar o handshake.
HELLO_RSP: A Carteira Responde
A carteira gera seu próprio par de chaves ECDH e envia de volta:
Qw || EncryptedSessionPropertiesAgora ambas as partes têm Qd e Qw. Usando a matemática ECDH:
// O dApp computa:
const sharedSecret = ecdh(dAppPrivateKey, Qw);
// A carteira computa (chega no mesmo valor):
const sharedSecret = ecdh(walletPrivateKey, Qd);O SDK então deriva a chave de criptografia AES-128 usando HKDF:
const encryptionKey = hkdf({
ikm: sharedSecret, // 32 bytes do ECDH
salt: associationPublicKey, // 65 bytes (formato X9.62)
length: 16, // Chave AES de 128 bits
});Por Que Toda Essa Complexidade?
Cada sessão usa chaves efêmeras novas. Mesmo que um atacante:
Intercepte a URI de associação
Monitore o tráfego WebSocket
Grave o handshake inteiro
Ele ainda assim não consegue descriptografar sessões passadas ou futuras. Isso é sigilo direto (forward secrecy) em ação.
Mensagens Criptografadas
Após o handshake, todas as mensagens JSON-RPC são criptografadas com AES-128-GCM. Cada mensagem tem esta estrutura:
+--------------------------------------------------------------+
| Sequência (4 bytes) | IV (12 bytes) | Ciphertext | Tag (16 bytes) |
+--------------------------------------------------------------+Sequence Number: Contador monotonicamente crescente (previne ataques de replay)
IV: Vetor de inicialização aleatório (diferente para cada mensagem)
Ciphertext: O payload JSON-RPC criptografado
Tag: Tag de autenticação (garante que a mensagem não foi adulterada)
O número de sequência é incluído como Additional Authenticated Data (AAD). Se um atacante tentar reproduzir uma mensagem antiga, o número de sequência não corresponderá e a descriptografia falha.
// Dentro do SDK (simplificado)
function encryptMessage(payload: string): Uint8Array {
const iv = crypto.getRandomValues(new Uint8Array(12));
const sequenceBytes = uint32ToBytes(this.sequenceNumber++);
const { ciphertext, tag } = aesGcmEncrypt({
key: this.encryptionKey,
iv,
plaintext: new TextEncoder().encode(payload),
aad: sequenceBytes,
});
return concat(sequenceBytes, iv, ciphertext, tag);
}A Interface JSON-RPC
Com a criptografia estabelecida, seu dApp envia requisições JSON-RPC 2.0. A carteira responde com resultados ou erros JSON-RPC.
Métodos Não Privilegiados
Estes funcionam imediatamente após o estabelecimento da sessão:
Métodos Privilegiados
Estes exigem um authorize bem-sucedido primeiro:
Se você chamar um método privilegiado sem autorização, receberá:
{
"error": {
"code": -32003,
"message": "Not authorized"
}
}A Requisição authorize
{
"jsonrpc": "2.0",
"id": 1,
"method": "authorize",
"params": {
"identity": {
"name": "My dApp",
"uri": "https://mydapp.com",
"icon": "favicon.ico"
},
"chain": "solana:devnet",
"auth_token": "cached..._any"
}
}A Resposta authorize
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"auth_token": "***",
"accounts": [
{
"address": "base64_encoded_pubkey",
"display_address": "base58_encoded_pubkey",
"display_address_format": "base58",
"label": "Main Wallet",
"chains": ["solana:mainnet", "solana:devnet"],
"features": ["solana:signAndSendTransaction"]
}
],
"wallet_uri_base": "https://phantom.app/ul/v1/"
}
}Note que address é codificado em base64, enquanto display_address é em base58 (legível para humanos). O SDK gerencia essa conversão para você.
Verificação de Identidade
Quando as carteiras exibem "O App X deseja se conectar", como sabem que a requisição realmente veio do App X?
Apps Nativos Android
No Android, a carteira pode consultar a assinatura do pacote do app chamador usando a CallingActivity do Intent. A carteira:
Obtém o nome do pacote do app chamador
Recupera o hash do certificado de assinatura do app
Compara com apps legítimos conhecidos (ou exibe o hash para o usuário)
Isso significa que um app malicioso não pode se passar pelo seu dApp. Mesmo que copiem seu identity.uri, a assinatura do pacote não corresponderá.
dApps Web
dApps baseados em navegador têm verificação mais fraca. A carteira recebe um cabeçalho referrer, mas isso pode ser falsificado. As carteiras tipicamente exibem a URI declarada e alertam os usuários para verificá-la.
Conexões Remotas (QR Codes)
Para sessões remotas (desktop para mobile via QR code), a conexão passa por um servidor refletor. O protocolo permanece criptografado de ponta a ponta, mas a verificação de identidade é ainda mais difícil. O usuário deve verificar manualmente se está se conectando ao dApp correto.
Códigos de Erro
O MWA usa códigos de erro JSON-RPC. Conhecê-los ajuda você a escrever um tratamento de erros melhor:
O erro mais comum que você lidará é 4001: o usuário pressionou "Cancelar" ou "Rejeitar" na interface da carteira.
Ciclo de Vida da Sessão
Entender quando as sessões começam e terminam previne bugs sutis:
await transact(async (wallet) => {
// ← A SESSÃO COMEÇA aqui
// O app da carteira vem para o primeiro plano
// WebSocket conectado
// Criptografia estabelecida
await wallet.authorize(/* ... */);
await wallet.signAndSendTransactions(/* ... */);
// ← A SESSÃO TERMINA quando o callback retorna
});
// A carteira pode ir para o segundo plano
// WebSocket desconectado
// Chaves de criptografia descartadasInsight crítico: Se seu callback lançar um erro, a sessão ainda assim fecha. Se você precisar tentar novamente, precisa de uma nova chamada transact(), o que significa uma sessão inteiramente nova.
// Errado: tentar reutilizar uma sessão falha
await transact(async (wallet) => {
try {
await wallet.signAndSendTransactions(/* ... */);
} catch (e) {
// A sessão está encerrando, não é possível tentar novamente aqui
await wallet.signAndSendTransactions(/* ... */); // Isso não funcionará
}
});
// Certo: nova sessão para tentar novamente
for (let attempt = 0; attempt < 3; attempt++) {
try {
await transact(async (wallet) => {
await wallet.authorize(/* ... */);
await wallet.signAndSendTransactions(/* ... */);
});
break; // Sucesso
} catch (e) {
if (e.code === 4001) throw e; // Usuário cancelou, não tentar novamente
// Caso contrário, tentar novamente com uma sessão nova
}
}Referência
Para a especificação completa do protocolo, consulte a Especificação MWA 2.0 oficial. A especificação inclui:
Layouts de bytes exatos para mensagens de handshake
Schemas JSON-RPC completos para todos os métodos
Requisitos da camada de transporte (subprotocolos WebSocket)
Protocolo do refletor para conexões remotas
Transporte Bluetooth LE (especificado, mas ainda não implementado)
A especificação foi projetada pela Solana Mobile e é implementada pelas principais carteiras, incluindo Phantom e Solflare.
Agora que você entende o protocolo, vamos configurar um projeto React Native para usá-lo.