Execução de código personalizado no GO

Isso é realmente tudo sobre contratos inteligentes.


Mas se você não imagina o que é um contrato inteligente e, em geral, está longe de criptografar, então o que é um procedimento armazenado em um banco de dados, você pode imaginar completamente. O usuário cria trechos de código que funcionam no nosso servidor. É conveniente para o usuário escrevê-los e publicá-los, e é seguro executá-los.

Infelizmente, ainda não desenvolvemos a segurança, então agora não vou descrevê-la, mas darei algumas dicas.

Também escrevemos no Go, e seu tempo de execução impõe algumas restrições muito específicas, a principal delas é que, em geral, não podemos vincular a outro projeto que não esteja em movimento, isso interromperá nosso tempo de execução toda vez que executarmos código de terceiros. Em geral, temos a opção de usar algum tipo de intérprete, para o qual encontramos um WASM completamente sadio e um sã completamente sadio, mas de alguma forma não quero adicionar clientes a Lua, mas agora com o WASM há mais problemas do que benefícios, está em um estado preliminar , que é atualizado todos os meses, portanto, aguardaremos até que a especificação seja estabelecida. Nós o usamos como um segundo mecanismo.

Como resultado de longas batalhas com sua própria consciência, foi decidido escrever contratos inteligentes no GO. O fato é que, se você construir a arquitetura para executar o código GO compilado, terá que transferir essa execução para um processo separado, como se lembra, por segurança, e transferir para um processo separado é uma perda de desempenho no IPC, embora no futuro, quando entendamos o volume do executável código, foi até agradável de alguma maneira termos escolhido essa solução. O fato é que é escalável, embora adicione um atraso a cada chamada individual. Podemos aumentar muitos tempos de execução remotos.

Um pouco mais sobre as decisões tomadas para que fique claro. Cada contrato inteligente consiste em duas partes, uma parte é o código da classe e a segunda são os dados do objeto; portanto, no mesmo código, podemos, uma vez publicado o código, criar muitos contratos que se comportarão basicamente da mesma forma, mas com configurações diferentes e com um estado diferente. Se conversarmos mais, então isso já é sobre blockchain e não o tópico desta história.

E assim, executamos GO


Decidimos usar o mecanismo de plug-in, que não está apenas pronto e bom. Ele faz o seguinte, compilamos o que será um plugin de uma maneira especial em uma biblioteca compartilhada e, em seguida, carregamos, encontramos os símbolos nele e passamos a execução para lá. Mas o problema é que o GO tem um tempo de execução, e isso é quase um megabyte de código, e por padrão esse tempo de execução também está indo para esta biblioteca, e temos um tempo de execução raznipipenny em todos os lugares. Mas agora decidimos seguir em frente, tendo certeza de que poderemos derrotá-lo no futuro.

Tudo é simples quando você cria sua biblioteca, você a constrói com o key - buildmode = plugin e obtém o arquivo .so, que você abre.

p, err := plugin.Open(path) 

Procurando o personagem que você está interessado:

 symbol, err := p.Lookup(Method) 

E agora, dependendo de a variável ser uma função ou uma função, você pode chamá-la ou usá-la como variável.

Sob esse capô, existe um dlopen (3) simples, carregamos a biblioteca, verificamos que é um plug-in e fornecemos o wrapper sobre ele. Ao criar o wrapper, todos os caracteres exportados são agrupados na interface {} e armazenados. Se for uma função, deve ser reduzida ao tipo correto de função e simplesmente chamada, se a variável - funcionar como uma variável.

O principal a lembrar é que, se um símbolo é uma variável, ele é global durante todo o processo e você não pode usá-lo sem pensar.

Se um tipo foi declarado no plug-in, esse tipo faz sentido em um pacote separado, para que o processo principal possa trabalhar com ele, por exemplo, passando como argumentos para as funções do plug-in. Isso é opcional, você não pode usar vapor e usar reflexão.

Nossos contratos são objetos da "classe" correspondente e, no início, a instância desse objeto era armazenada em nossa variável exportada, para que pudéssemos criar outra mesma variável:

 export, err := p.Lookup("EXPORT") obj := reflect.New(reflect.ValueOf(export).Elem().Type()).Interface() 

E já dentro dessa variável local do tipo correto, desserialize o estado do objeto. Depois que o objeto é restaurado, podemos chamar métodos nele. Após o qual o objeto é serializado e adicionado de volta à loja, aplausos, chamamos o método no contrato.

Se você estiver interessado em saber como, mas com preguiça de ler a documentação, então:

 method := reflect.ValueOf(obj).MethodByName(Method) res:= method.Call(in) 

No meio, você ainda precisa preencher a matriz com interfaces vazias contendo o tipo correto de argumento; se estiver interessado, veja por si mesmo como foi feito, as fontes estão abertas, embora seja difícil encontrar esse lugar na história .

Em geral, tudo funcionou para nós, você pode escrever código com algo como uma classe, colocá-lo no blockchain, criar um contrato dessa classe novamente no blockchain, fazer uma chamada de método e o novo estado do contrato é gravado de volta no blockchain. Ótimo! Como criar um novo contrato com o código disponível? Muito simples, temos funções de construtor que retornam um objeto recém-criado, que é o novo contrato. Até agora, tudo funciona através da reflexão e o usuário deve escrever:

 var EXPORT ContractType 

Para que possamos saber qual símbolo é uma representação do contrato e realmente usá-lo como modelo.

Nós realmente não gostamos. E nós batemos forte.

Análise


Em primeiro lugar, o usuário não deve escrever nada supérfluo e, em segundo lugar, temos a ideia de que a interação do contrato com o contrato deve ser simples e testada sem aumentar a blockchain, a blockchain é lenta e difícil.

Portanto, decidimos quebrar o contrato em um invólucro, que é gerado com base no contrato e no modelo do invólucro, em princípio, uma solução compreensível. Em primeiro lugar, o wrapper cria um objeto de exportação para nós e, em segundo lugar, substitui a biblioteca com a qual o contrato é coletado quando o usuário escreve o contrato, a biblioteca de base é usada com os mokas internos e, quando o contrato é publicado, é substituído por um de combate que funciona com o próprio blockchain .

Para começar, você precisa analisar o código e entender o que geralmente temos, encontrar a estrutura que é herdada do BaseContract para gerar um wrapper em torno dele.

Isso é feito de maneira simples: lemos o arquivo com o código em [] byte, embora o analisador possa ler os arquivos, é bom ter o texto em algum lugar ao qual todos os elementos AST se referem, eles se referem ao número de bytes no arquivo e, no futuro, queremos receber o código de estrutura como ele é, apenas pegamos algo parecido.

 func (pf *ParsedFile) codeOfNode(n ast.Node) string { return string(pf.code[n.Pos()-1 : n.End()-1]) } 

Na verdade, analisamos o arquivo e obtemos o nó AST mais alto do qual rastrearemos o arquivo.

 fileSet = token.NewFileSet() node, err := parser.ParseFile(fileSet, name, code, parser.ParseComments) 

Em seguida, analisamos o código a partir do nó superior e coletamos tudo de interessante em uma estrutura separada.

 for _, decl := range node.Decls { switch d := decl.(type) { case *ast.GenDecl: … case *ast.FuncDecl: … } } 

Decls, ele já foi analisado em uma matriz, uma lista de tudo o que é definido no arquivo, mas é uma matriz de interfaces Decl que não descreve o que está dentro; portanto, cada elemento deve ser convertido em um tipo específico. Aqui os autores do idioma se afastaram da ideia de usar interfaces, a interface em go / ast é bastante uma classe base.

Estamos interessados ​​em nós do tipo GenDecl e FuncDecl. GenDecl é a definição de uma variável ou tipo, e você precisa verificar qual é exatamente o tipo interno e, mais uma vez, convertê-lo no tipo TypeDecl com o qual você já pode trabalhar. FuncDecl é mais simples - é uma função e, se o campo Recv for preenchido, esse é um método da estrutura correspondente. Coletamos tudo isso em um armazenamento conveniente, porque usamos texto / modelo e ele não tem muito poder expressivo.

A única coisa que precisamos lembrar separadamente é o nome do tipo de dado que é herdado do BaseContract, e vamos dançar ao redor dele.

Geração de código


E assim, conhecemos todos os tipos e funções que estão em nosso contrato e precisamos poder chamar um método em um objeto a partir do nome do método recebido e da matriz serializada de argumentos. Afinal, no momento da geração do código, conhecemos todo o dispositivo do contrato, então colocamos ao lado do arquivo do contrato próximo a outro arquivo, com o mesmo nome de pacote, no qual colocamos todas as importações necessárias, os tipos já estão definidos no arquivo principal e são desnecessários.

E aqui está o principal, wrappers sobre funções. O nome do wrapper é complementado por algum tipo de prefixo e agora é fácil encontrá-lo.

 symbol, err := p.Lookup("INSMETHOD_" + Method) wrapper, ok := symbol.(func(ph proxyctx.ProxyHelper, object []byte, data []byte) (object []byte, result []byte, err error)) 

Cada invólucro tem a mesma assinatura; portanto, quando o chamamos do programa principal, não precisamos de reflexões extras; a única coisa é que os invólucros da função são diferentes dos invólucros do método, eles não recebem e não retornam o estado do objeto.

O que temos dentro do invólucro?

Criamos uma matriz de variáveis ​​vazias correspondentes aos argumentos da função, colocamos em uma variável do tipo uma matriz de interfaces e desserializamos os argumentos, se formos um método, também devemos serializar o estado do objeto, geralmente algo como isto:

 {{ range $method := .Methods }} func INSMETHOD_{{ $method.Name }}(ph proxyctx.ProxyHelper, object []byte, data []byte) ([]byte, []byte, error) { self := new({{ $.ContractType }}) err := ph.Deserialize(object, self) if err != nil { return nil, nil, err } {{ $method.ArgumentsZeroList }} err = ph.Deserialize(data, &args) if err != nil { return nil, nil, err } {{ if $method.Results }} {{ $method.Results }} := self.{{ $method.Name }}( {{ $method.Arguments }} ) {{ else }} self.{{ $method.Name }}( {{ $method.Arguments }} ) {{ end }} state := []byte{} err = ph.Serialize(self, &state) if err != nil { return nil, nil, err } {{ range $i := $method.ErrorInterfaceInRes }} ret{{ $i }} = ph.MakeErrorSerializable(ret{{ $i }}) {{ end }} ret := []byte{} err = ph.Serialize([]interface{} { {{ $method.Results }} }, &ret) return state, ret, err } {{ end }} 

Um leitor atento estará interessado no que é um auxiliar de proxy? - este é um objeto tão combinado que ainda precisamos, mas, por enquanto, usamos sua capacidade de serializar e desserializar.

Bem, quem lê pergunta: "Mas esses são seus argumentos, de onde eles são?" Aqui também está uma resposta compreensível: sim, o texto / modelo não possui estrelas suficientes do céu; é por isso que calculamos essas linhas no código, e não no modelo.

method.ArgumentsZeroList contém algo como

 var arg0 int = 0 Var arg1 string = “” Var arg2 ackwardType = ackwardType{} Args := []interface{}{&arg0, &arg1, &arg2} 

E Argumentos, portanto, contém "arg0, arg1, arg2".

Assim, podemos chamar o que quisermos, com qualquer assinatura.

Mas não podemos serializar nenhuma resposta, o fato é que os serializadores funcionam com reflexão e não dão acesso a campos de estruturas não exportados, é por isso que temos um método auxiliar de proxy especial que pega um objeto de interface de erro e cria um objeto de tipo base a partir dele. Erro, que difere do usual, pois o texto do erro está no campo exportado, e podemos serializá-lo, embora com algumas perdas.

Mas se usarmos um esterilizador gerador de código, nem precisamos dele, somos compilados no mesmo pacote, temos acesso a campos não exportados.

Mas e se quisermos chamar um contrato de um contrato?


Você não entende a profundidade do problema se acha fácil chamar um contrato a partir de um contrato. O fato é que a validade de outro contrato deve ser confirmada por consenso e o fato dessa chamada deve ser assinado no blockchain, em geral, simplesmente compilar com outro contrato e invocar seu método não funcionará, embora eu realmente queira. Mas somos amigos dos programadores, portanto devemos dar a eles a oportunidade de fazer tudo diretamente e ocultar todos os truques sob o capô do sistema. Assim, o desenvolvimento do contrato é como se houvesse chamadas diretas, e os contratos se alternam de forma transparente, mas quando coletamos o contrato para publicação, colocamos um proxy em vez de outro contrato, que apenas conhece seu endereço e assinaturas de chamada sobre o contrato.

Como organizar tudo isso? - Teremos que armazenar outros contratos em um diretório especial que nosso gerador possa reconhecer e criar proxies para cada contrato importado.

Ou seja, se nos conhecemos:

 import “ContractsDir/ContractAddress" 

Escrevemos na lista de contratos importados.

A propósito, para isso, você não precisa saber o código-fonte do contrato, apenas a descrição que já coletamos; por isso, se publicarmos essa descrição em algum lugar e todas as chamadas passarem pelo sistema principal, não nos importamos com o que outro contrato está escrito no idioma; se pudermos chamar métodos, podemos escrever um esboço para ele no Go, que parecerá um pacote com um contrato que pode ser chamado diretamente. Planos napoleônicos, vamos começar.

Em princípio, já temos um método auxiliar de proxy, com esta assinatura:

 RouteCall(ref Address, method string, args []byte) ([]byte, error) 

Esse método pode ser chamado diretamente do contrato, ele chama de contrato remoto, retorna uma resposta serializada que precisamos analisar e retornar ao nosso contrato.

Mas é necessário que o usuário pareça tudo:

 ret := contractPackage.GetObject(Address).Method(arg1,arg2, …) 

Vamos começar, primeiramente, no proxy, você precisa listar todos os tipos que são usados ​​nas assinaturas dos métodos de contrato, mas, como lembramos, para cada nó AST podemos obter sua representação textual e agora chegou a hora desse mecanismo.

Em seguida, precisamos criar um tipo de contrato, em princípio, ele já conhece sua classe, apenas um endereço é necessário.

 type {{ .ContractType }} struct { Reference Address } 

Em seguida, precisamos implementar de alguma forma a função GetObject, que no endereço da blockchain retornará uma instância de proxy que sabe trabalhar com este contrato e, para o usuário, parece uma instância de contrato.

 func GetObject(ref Address) (r *{{ .ContractType }}) { return &{{ .ContractType }}{Reference: ref} } 

Curiosamente, o método GetObject no modo de depuração do usuário é diretamente um método de estrutura BaseContract, mas não há nada, nada nos impede, observando o SLA, de fazer o que for conveniente para nós. Agora podemos criar um contrato de proxy, cujos métodos controlamos. Resta realmente criar métodos.

 {{ range $method := .MethodsProxies }} func (r *{{ $.ContractType }}) {{ $method.Name }}( {{ $method.Arguments }} ) ( {{ $method.ResultsTypes }} ) { {{ $method.InitArgs }} var argsSerialized []byte err := proxyctx.Current.Serialize(args, &argsSerialized) if err != nil { panic(err) } res, err := proxyctx.Current.RouteCall(r.Reference, "{{ $method.Name }}", argsSerialized) if err != nil { panic(err) } {{ $method.ResultZeroList }} err = proxyctx.Current.Deserialize(res, &resList) if err != nil { panic(err) } return {{ $method.Results }} } {{ end }} 

Aqui está a mesma história com a construção da lista de argumentos, já que somos preguiçosos e armazenamos exatamente o ast.Node do método, para cálculos são necessárias muitas conversões de tipo que os modelos não conhecem, para que tudo seja preparado com antecedência. Com funções, tudo fica seriamente mais complicado, e esse é o tópico de outro artigo.

As funções que temos são construtores de objetos e há muita ênfase em como os objetos são realmente criados em nosso sistema, o fato de a criação ser registrada em um executor remoto, o objeto é transferido para outro executor, é verificado e salvo ali, e existem muitas maneiras de salvar, em vão essa área de conhecimento é chamada cripta. E a idéia é basicamente simples, um invólucro no qual apenas o endereço é armazenado e métodos que serializam a chamada e puxam nosso processador singleton, que faz o resto. Não podemos usar o auxiliar de proxy transmitido, porque o usuário não o passou para nós, então tivemos que torná-lo um singleton.

Outro truque - na verdade, ainda usamos o contexto de chamada, esse é um objeto que armazena informações sobre quem, quando, por que, por que nosso contrato inteligente foi chamado, com base nessas informações, o usuário decide se deve executar a execução e, se possível então como.

Anteriormente, passávamos o contexto simplesmente, era um campo não expressável no tipo BaseContract com um setter e getter, e o setter permitia definir o campo apenas uma vez, para que o contexto fosse definido antes da execução do contrato e o usuário pudesse apenas lê-lo.

Mas aqui está o problema, o usuário só lê esse contexto, se ele faz uma chamada para algum tipo de função do sistema, por exemplo, uma chamada de proxy para outro contrato, essa chamada de proxy não recebe nenhum contexto, já que ninguém passa para ele. E então o armazenamento local de goroutine entra em cena. Decidimos não escrever por conta própria, mas use github.com/tylerb/gls.

Permite definir e definir o contexto para a goroutina atual. Assim, se nenhuma goroutine foi criada dentro do contrato, apenas definimos o contexto em gls antes de iniciar o contrato, agora fornecemos ao usuário não um método, mas apenas uma função.

 func GetContext() *core.LogicCallContext { return gls.Get("ctx").(*core.LogicCallContext) } 

E ele o usa alegremente, mas nós o usamos em RouteCall (), por exemplo, para entender qual contrato está atualmente invocando alguém.

Em princípio, o usuário pode criar goroutine, mas se o fizer, o contexto será perdido; portanto, precisamos fazer algo com isso; por exemplo, se o usuário usar a palavra-chave go, será necessário agrupar essas chamadas em nosso wrapper, que o contexto lembrará e criará goroutine e restaure o contexto, mas este é o tópico de outro artigo.

Todos juntos


Basicamente, gostamos de como a cadeia de ferramentas da linguagem GO funciona, na verdade, são vários comandos diferentes que fazem uma coisa, que são executados juntos quando você constrói, por exemplo. Decidimos fazer o mesmo, uma equipe coloca o arquivo do contrato em um diretório temporário, o segundo coloca um wrapper e chama pela terceira vez, o que cria um proxy para cada contrato importado, o quarto compila tudo, o quinto publica no blockchain. E há um comando para executá-los todos na ordem correta.

Hoje, temos agora uma cadeia de ferramentas e um tempo de execução para o lançamento do GO from GO. Ainda existem muitos problemas, por exemplo, você precisa de alguma forma descarregar o código não utilizado, precisa determinar de alguma forma que ele trava e reiniciar o processo suspenso, mas essas são tarefas claras sobre como resolvê-lo.

Sim, é claro, o código que escrevemos não finge ser uma biblioteca, não pode ser usado diretamente, mas ler um exemplo de geração de código de trabalho é sempre bom, ao mesmo tempo em que senti falta dele. Por conseguinte, parte da geração de código pode ser visualizada no compilador , mas como é iniciada no executor .

Source: https://habr.com/ru/post/pt425985/


All Articles