"Não custodial" costuma ser uma palavra de marketing. Usada com precisão, é uma restrição arquitetural, e restrições desse tipo são interessantes porque eliminam trabalho em vez de adicioná-lo.
Um bot de trading precisa colocar ordens em seu nome. As exchanges tornam isso possível com chaves API, e as chaves API carregam escopos: leitura, trading e, na maioria das plataformas, saque. Então a questão de design não é se deve pedir permissões. É quais, e o que fazer quando você recebe mais do que pediu.
Pontos-chave
- Uma promessa de não usar uma permissão é mais fraca do que não tê-la. Recusamos chaves que carregam permissão de saque em vez de aceitá-las e ignorá-las.
- Recusar é uma garantia mais forte do que confiar, porque não depende de o nosso código estar correto ou de a nossa equipe ser honesta.
- Se um fluxo de conexão grava na conta do usuário, essas gravações devem vir depois da decisão de aceitar, não antes. Caso contrário, você altera as configurações de alguém no caminho para dizer não.
- Não guardar fundos elimina uma classe inteira de problemas difíceis: sem livro-razão de custódia, sem reconciliação, sem fila de saques.
- O que a substitui é mais estreito e mais afiado: seu sistema precisa estar correto em tempo real, porque nunca pode desfazer nada.
1. Recusar uma permissão é melhor do que prometer não usá-la
A implementação óbvia é pedir aos usuários uma chave com escopos de leitura e trading e simplesmente nunca chamar os endpoints de saque. A maior parte do setor faz isso, e não é algo irracional.
Nós fazemos algo mais rígido. Se uma chave chega carregando permissão de saque, ela é recusada. O usuário vê uma mensagem dizendo exatamente o que fazer:
Esta chave API tem a permissão de saque ativada. A Freya nunca saca fundos, então chaves com essa permissão não são aceitas. Por favor, crie uma chave apenas com Leitura e Trading.
A mesma regra vale nas três exchanges que suportamos. Não há override nem exceção de administrador.
À primeira vista, isso parece atrito por atrito. A chave funcionaria. O usuário teve algum trabalho para criá-la. Rejeitá-la significa mandá-lo de volta à exchange para fazer outra.
A razão é que os dois designs fazem promessas diferentes, e a diferença só se torna visível no seu pior dia.
- "Nunca chamamos os endpoints de saque" é uma afirmação sobre o nosso comportamento. Ela se mantém enquanto o nosso código estiver correto, as nossas dependências estiverem limpas e todos com acesso de deploy continuarem honestos. Essas são coisas razoáveis de se esperar. Não são coisas que um usuário possa verificar.
- "A chave não pode sacar" é uma afirmação sobre a chave. Ela se mantém independentemente do que o nosso código faz, porque a exchange a impõe, e o próprio usuário pode confirmá-la nas configurações da sua exchange.
A segunda é um pedido mais fraco e uma garantia mais forte. Prefira designs cuja propriedade de segurança seja imposta por alguém que não seja você, especialmente quando o usuário não tem como auditar o seu lado.
Há um benefício menor que importa na prática: ele torna o modo de falha barulhento. Uma chave com escopo demais é pega no momento da conexão, diante de um usuário que está configurando as coisas ativamente e pode corrigi-la em um minuto. A alternativa é que a chave com escopo excessivo fique ali funcionando bem, e ninguém aprende nada até que isso importe.
2. Diga não antes de tocar em qualquer coisa
Este é o detalhe que eu mais gostaria que outro engenheiro levasse consigo, porque é fácil de errar e quase invisível quando você erra.
Conectar uma exchange não é uma operação puramente de leitura. Dependendo da plataforma, deixar uma conta em um estado utilizável pode envolver gravar nela: ajustar um modo de conta, definir um modo de posição e assim por diante. Esses são passos legítimos que acontecem como parte da configuração.
Agora considere a ordem. Se a verificação de permissão acontece depois dessas gravações, então uma chave rejeitada ainda deixa um rastro. O usuário lhe entregou uma credencial, você modificou configurações na conta dele e depois disse não. Do lado dele, nada explica por que a configuração da sua exchange está diferente de como ele a deixou.
Então a regra que adotamos: uma credencial recusada deve deixar a conta do usuário exatamente como foi encontrada. Qualquer passo que grava pertence a depois da decisão de aceitar, nunca antes dela. A rejeição precisa ser alcançável sem efeitos colaterais.
Generalizando, isso é apenas o pensamento transacional aplicado ao sistema de outra pessoa. Você não pode reverter uma gravação em uma conta de terceiros, então a ordem é a sua única proteção. Quando você não pode desfazer, sequencie com cuidado.
Uma nota de implementação que nos salvou de uma categoria inteira de bugs: a recusa é expressa como um resultado inválido em vez de uma flag especial, então todo caminho que consome um resultado de validação a trata sem precisar saber que ela existe. Temos dois fluxos de conexão, e ambos herdam a mesma recusa porque nenhum deles teve de lembrar de perguntar sobre ela. Codificar uma regra em um tipo que todos os consumidores já leem é melhor do que documentá-la e torcer.
3. O que a restrição elimina
É aqui que a troca se torna claramente favorável.
Os fundos nunca saem da conta do usuário na exchange. Os bots operam com chaves de leitura e trading, e o saldo permanece onde começou. O que decorre disso é uma longa lista de coisas que simplesmente não existem no nosso sistema:
- Sem livro-razão de custódia, portanto sem reconciliação entre um saldo interno e um externo
- Sem fila de saques, sem fluxo de aprovação, sem limites diários sobre o capital de trading
- Sem divisão entre carteira quente e fria para esse capital, e sem gestão de chaves em torno dele
- Sem a questão do que acontece com os fundos do usuário se a empresa desaparecer
Cada um deles é um subsistema inteiro em um produto custodial, e cada um é um lugar onde dinheiro pode sumir. Eles estão ausentes aqui não porque os resolvemos, mas porque nunca assumimos aquilo que os cria.
Quero ser preciso em vez de generalista, porém: isso se aplica ao capital de trading. Os saldos de plataforma usados para assinaturas são uma questão separada, com o seu próprio tratamento. A propriedade não custodial diz respeito ao dinheiro que opera, e esse é o dinheiro que mais importa.
4. O que ela não resolve
Uma restrição que remove problemas também remove as ferramentas que você usaria para corrigir erros, e vale a pena ser honesto sobre isso.
As operações são finais. Com custódia, uma transferência interna pode em princípio ser revertida. Uma ordem executada na conta de exchange de outra pessoa não pode. Todo o peso de engenharia se move para estar correto antes de a requisição sair, em vez de reconciliar depois que ela chega. Essa é uma disciplina mais difícil do que parece, e é por isso que boa parte do nosso trabalho gira em torno de validação, precisão e idempotência.
A permissão de trading ainda é uma permissão de verdade. Uma chave de leitura e trading vazada não pode drenar uma conta para um endereço externo, que é o desfecho que as pessoas de fato temem. Mas pode colocar ordens. O raio de impacto é limitado, não zero, e seria desonesto descrevê-lo como zero.
O usuário ainda tem obrigações. A rotação de chaves, não reutilizar chaves entre serviços e vigiar o que mais tem acesso à mesma conta continuam sendo dele. A nossa arquitetura limita o dano; ela não torna a chave inofensiva.
O que eu diria a outra equipe
- Peça o escopo mais estreito e depois verifique se você o obteve. Pedir menos não é o mesmo que receber menos.
- Prefira garantias impostas fora do seu próprio código, particularmente aquelas que o usuário pode verificar sem confiar em você.
- Torne a rejeição livre de efeitos colaterais. Se o seu fluxo grava em uma conta de terceiros, tudo que grava vai depois da decisão de aceitar.
- Codifique a regra onde os consumidores já olham. Uma regra expressa em um tipo compartilhado não precisa que todo chamador se lembre dela.
O ponto maior é que "não vamos fazer X" e "não podemos fazer X" se leem de forma parecida numa landing page e são completamente diferentes em um incidente. Sempre que você puder mover uma promessa da primeira categoria para a segunda, o custo costuma ser pequeno e o retorno aparece exatamente quando todo o resto está dando errado.
