From d180fc35606682b0180f3a378c2bc39e8c6a9da4 Mon Sep 17 00:00:00 2001 From: Lucas Kenji Uezu <49196318+luc10921@users.noreply.github.com> Date: Tue, 19 Sep 2023 11:10:30 -0300 Subject: [PATCH] generators: support on-chain TypeScript SDK (#10) --------- Co-authored-by: luc10921 --- generators/common.go | 62 +++++++-- generators/typescript.go | 290 +++++++++++++++++++++++++++++++++++++++ main.go | 31 +++-- 3 files changed, 365 insertions(+), 18 deletions(-) create mode 100644 generators/typescript.go diff --git a/generators/common.go b/generators/common.go index 4ef17cf..48bebc4 100644 --- a/generators/common.go +++ b/generators/common.go @@ -3,6 +3,7 @@ package generators import ( "fmt" "os" + "regexp" "strconv" "strings" @@ -30,19 +31,29 @@ type ( Imports []string Hash string Methods []methodTmpl + Events []eventTmpl } methodTmpl struct { - Name string - NameABI string - Comment string - Arguments []paramTmpl - ReturnType string + Name string + NameABI string + Comment string + Safe bool + Arguments []paramTmpl + ReturnType string + ReturnTypeABI string + } + + eventTmpl struct { + Name string + NameABI string + Arguments []paramTmpl } paramTmpl struct { - Name string - Type string + Name string + Type string + TypeABI string } convertParam func(typ smartcontract.ParamType) string @@ -50,7 +61,7 @@ type ( func templateFromManifest(cfg *GenerateCfg) (contractTmpl, error) { ctr := contractTmpl{ - ContractName: upperFirst(cfg.Manifest.Name), + ContractName: cleanContractName(cfg.Manifest.Name), Hash: "0x" + cfg.ContractHash.StringLE(), } @@ -77,6 +88,7 @@ func templateFromManifest(cfg *GenerateCfg) (contractTmpl, error) { Name: cfg.MethodNameConverter(name), NameABI: method.Name, Comment: fmt.Sprintf("invokes `%s` method of contract.", method.Name), + Safe: method.Safe, } for i := range method.Parameters { @@ -88,16 +100,46 @@ func templateFromManifest(cfg *GenerateCfg) (contractTmpl, error) { var typeStr = cfg.ParamTypeConverter(method.Parameters[i].Type) mtd.Arguments = append(mtd.Arguments, paramTmpl{ - Name: name, - Type: typeStr, + Name: name, + Type: typeStr, + TypeABI: (smartcontract.ParamType).String(method.Parameters[i].Type), }) } mtd.ReturnType = cfg.ParamTypeConverter(method.ReturnType) + mtd.ReturnTypeABI = (smartcontract.ParamType).String(method.ReturnType) ctr.Methods = append(ctr.Methods, mtd) } + + for _, event := range cfg.Manifest.ABI.Events { + name := event.Name + + evt := eventTmpl{ + Name: name, + } + + for i := range event.Parameters { + name := event.Parameters[i].Name + if name == "" { + name = fmt.Sprintf("arg%d", i) + } + + var typeStr = cfg.ParamTypeConverter(event.Parameters[i].Type) + + evt.Arguments = append(evt.Arguments, paramTmpl{ + Name: name, + Type: typeStr, + }) + } + ctr.Events = append(ctr.Events, evt) + } + return ctr, nil } func upperFirst(s string) string { return strings.ToUpper(s[0:1]) + s[1:] } + +func cleanContractName(s string) string { + return upperFirst(regexp.MustCompile(`[\W]+`).ReplaceAllString(s, "")) +} diff --git a/generators/typescript.go b/generators/typescript.go new file mode 100644 index 0000000..6563da7 --- /dev/null +++ b/generators/typescript.go @@ -0,0 +1,290 @@ +package generators + +import ( + "fmt" + "os" + "regexp" + "strings" + "text/template" + + "github.com/iancoleman/strcase" + "github.com/nspcc-dev/neo-go/pkg/smartcontract" + log "github.com/sirupsen/logrus" +) + +/* + Creates a TypeScript SDK that can be easily used when trying to invoke a smart contract. + Given a contract named `Sample Contract`, the output is a folder with the following structure: + . + ├── SampleContract + │   ├── api.ts + │   ├── index.ts + │   └── SampleContract.ts + + which can be used in your TypeScript project with + + import { SampleContract } from './SampleContract' + const sampleContract = new SampleContract({ + SampleContract.SCRIPT_HASH, + invoker: await NeonInvoker.init({ rpcAddress: 'https://mainnet1.neo.coz.io:443' }), + parser: NeonParser, + eventListener: new NeonEventListener('https://mainnet1.neo.coz.io:443') + }) + + const txId = sampleContract.func1() + const testInvokeResponse = sampleContract.testFunc1() +*/ + +const typescriptSrcApiTmpl = ` +{{- define "APIMETHOD" }} +export function {{ .Name }}API(scriptHash: string{{if .Arguments}}, params: { {{range $index, $arg := .Arguments -}} + {{- if ne $index 0}}, {{end}}{{- .Name}}: {{.Type}} +{{- end}} }, parser: Neo3Parser {{end}}): ContractInvocation { + return { + scriptHash, + operation: '{{ .NameABI }}', + args: [{{range $index, $arg := .Arguments -}} + parser.formatRpcArgument(params.{{- .Name}}, { type: '{{ .TypeABI }}' }), + {{- end}} + ], + } +} +{{- end -}} +import { Neo3Parser, ContractInvocation} from "@cityofzion/neon-dappkit-types" + +{{- range $m := .Methods}} +{{ template "APIMETHOD" $m -}} +{{end}} +` + +const typescriptSrcClassTmpl = ` +{{- define "INVOKEMETHOD" }} + async {{ .Name }}({{if .Arguments}}params: { {{range $index, $arg := .Arguments -}} + {{- if ne $index 0}}, {{end}}{{- .Name}}: {{.Type}} + {{- end}} } {{end}}){{if .ReturnType }}: Promise{{ else }} {{end}}{ + return await this.config.invoker.invokeFunction({ + invocations: [Invocation.{{ .Name }}API(this.config.scriptHash{{if .Arguments}}, params, this.config.parser{{end}})], + signers: [], + }) + } +{{- end -}} +{{- define "ITERATORGENERATORMETHOD" }} + async* {{if not .Safe}}test{{ upperFirst .Name }}{{else}}{{ .Name }}{{end}}({{if .Arguments}}params: { {{range $index, $arg := .Arguments -}} + {{- if ne $index 0}}, {{end}}{{- .Name}}: {{.Type}} + {{- end}} } {{end}}): AsyncGenerator { + const res = await this.config.invoker.testInvoke({ + invocations: [Invocation.{{ .Name }}API(this.config.scriptHash{{if .Arguments}}, params, this.config.parser{{end}})], + signers: [], + }) + + if (res.stack.length !== 0 && res.session !== undefined && typeChecker.isStackTypeInteropInterface(res.stack[0])) { + + let iterator = await this.config.invoker.traverseIterator(res.session, res.stack[0].id, 1) + + while (iterator.length !== 0){ + if (typeChecker.isStackTypeInteropInterface(iterator[0])){ + throw new Error(res.exception ?? 'can not have an iterator inside another iterator') + }else{ + yield this.config.parser.parseRpcResponse(iterator[0]) + iterator = await this.config.invoker.traverseIterator(res.session, res.stack[0].id, 1) + } + } + } + else { + throw new Error(res.exception ?? 'unrecognized response') + } + } +{{- end -}} +{{- define "TESTINVOKEMETHOD" }} + async {{if not .Safe}}test{{ upperFirst .Name }}{{else}}{{ .Name }}{{end}}({{if .Arguments}}params: { {{range $index, $arg := .Arguments -}} + {{- if ne $index 0}}, {{end}}{{- .Name}}: {{.Type}} + {{- end}} } {{end}}){{if .ReturnType }}: Promise<{{ .ReturnType }}>{{ else }} {{end}}{ + const res = await this.config.invoker.testInvoke({ + invocations: [Invocation.{{ .Name }}API(this.config.scriptHash{{if .Arguments}}, params, this.config.parser{{end}})], + signers: [], + }) + + if (res.stack.length === 0) { + throw new Error(res.exception ?? 'unrecognized response') + } + {{- if ne .ReturnType "void"}} + + return this.config.parser.parseRpcResponse(res.stack[0], { type: '{{ .ReturnTypeABI }}' }) + {{- end}} + } +{{- end -}} +{{- define "TESTMETHOD" }} + {{- if eq .ReturnTypeABI "InteropInterface" }} + {{- template "ITERATORGENERATORMETHOD" . -}} + {{ else }} + {{- template "TESTINVOKEMETHOD" . -}} + {{- end -}} +{{- end -}} +{{- define "EVENTLISTENER" }} + async confirm{{ upperFirst .Name }}Event(txId: string): Promise{ + if (!this.config.eventListener) throw new Error('EventListener not provided') + + const txResult = await this.config.eventListener.waitForApplicationLog(txId) + this.config.eventListener.confirmTransaction( + txResult, {contract: this.config.scriptHash, eventname: '{{ .Name }}'} + ) + } + + listen{{ upperFirst .Name }}Event(callback: Neo3EventListenerCallback): void{ + if (!this.config.eventListener) throw new Error('EventListener not provided') + + this.config.eventListener.addEventListener(this.config.scriptHash, '{{ .Name }}', callback) + } + + remove{{ upperFirst .Name }}EventListener(callback: Neo3EventListenerCallback): void{ + if (!this.config.eventListener) throw new Error('EventListener not provided') + + this.config.eventListener.removeEventListener(this.config.scriptHash, '{{ .Name }}', callback) + } +{{- end -}} +import { Neo3EventListener, Neo3EventListenerCallback, Neo3Invoker, Neo3Parser } from "@cityofzion/neon-dappkit-types" +import { typeChecker } from "@cityofzion/neon-dappkit" +import * as Invocation from './api' + +export type SmartContractConfig = { + scriptHash: string; + invoker: Neo3Invoker; + parser?: Neo3Parser; + eventListener?: Neo3EventListener | null; +} + +export class {{ .ContractName }}{ + static SCRIPT_HASH = '{{ .Hash }}' + + private config: Required + + constructor(configOptions: SmartContractConfig) { + this.config = { + ...configOptions, + parser: configOptions.parser ?? require("@cityofzion/neon-dappkit").NeonParser, + eventListener: configOptions.eventListener ?? null + } + } + +{{- range $e := .Events}} +{{ template "EVENTLISTENER" $e -}} +{{end}} +{{- range $m := .Methods}} +{{if .Safe -}} +{{ template "TESTMETHOD" $m -}} +{{- else -}} +{{ template "INVOKEMETHOD" $m }} +{{ template "TESTMETHOD" $m -}} +{{end -}} +{{end}} +} +` + +const typescriptSrcIndexTmpl = `export * from './{{ .ContractName }}' +export * from './api'` + +func GenerateTypeScriptSDK(cfg *GenerateCfg) error { + cfg.MethodNameConverter = strcase.ToLowerCamel + cfg.ParamTypeConverter = scTypeToTypeScript + ctr, err := templateFromManifest(cfg) + if err != nil { + return fmt.Errorf("failed to parse manifest into contract template: %v", err) + } + + folderName := strings.ToLower(strings.Join(regexp.MustCompile(`[\W]+`).Split(cfg.Manifest.Name, -1), "-")) + sdkDir := cfg.SdkDestination + folderName + err = os.MkdirAll(sdkDir, 0755) + if err != nil { + return fmt.Errorf("can't create directory %s: %w", sdkDir, err) + } + + err = generateTypeScriptSdkFile(cfg, ctr, sdkDir, "api", typescriptSrcApiTmpl) + if err != nil { + return err + } + + err = generateTypeScriptSdkFile(cfg, ctr, sdkDir, ctr.ContractName, typescriptSrcClassTmpl) + if err != nil { + return err + } + + err = generateTypeScriptSdkFile(cfg, ctr, sdkDir, "index", typescriptSrcIndexTmpl) + if err != nil { + return err + } + + wd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %v", err) + } + sdkLocation := wd + "/" + cfg.SdkDestination + folderName + log.Infof("Created SDK for contract '%s' at %s with contract hash 0x%s", cfg.Manifest.Name, sdkLocation, cfg.ContractHash.StringLE()) + + return nil +} + +func generateTypeScriptSdkFile(cfg *GenerateCfg, ctr contractTmpl, sdkDir string, fileName string, templateString string) error { + err := createTypeScriptSdkFile(cfg, sdkDir, fileName) + defer cfg.ContractOutput.Close() + if err != nil { + return err + } + + funcMap := template.FuncMap{ + "upperFirst": upperFirst, + } + + tmp, err := template.New("generate").Funcs(funcMap).Parse(templateString) + if err != nil { + return fmt.Errorf("failed to parse TypeScript source %s file template: %v", fileName, err) + } + + err = tmp.Execute(cfg.ContractOutput, ctr) + if err != nil { + return fmt.Errorf("failed to generate TypeScript %s file code using template: %v", fileName, err) + } + + return nil +} + +func createTypeScriptSdkFile(cfg *GenerateCfg, sdkDir string, fileName string) error { + f, err := os.Create(sdkDir + "/" + fileName + ".ts") + if err != nil { + f.Close() + return fmt.Errorf("can't create %s.ts file: %w", fileName, err) + } else { + cfg.ContractOutput = f + } + return nil +} + +func scTypeToTypeScript(typ smartcontract.ParamType) string { + switch typ { + case smartcontract.AnyType: + return "any" + case smartcontract.BoolType: + return "boolean" + case smartcontract.InteropInterfaceType: + return "object" + case smartcontract.IntegerType: + return "number" + case smartcontract.ByteArrayType: + return "string" + case smartcontract.StringType: + return "string" + case smartcontract.Hash160Type: + return "string" + case smartcontract.Hash256Type: + return "string" + case smartcontract.PublicKeyType: + return "string" + case smartcontract.ArrayType: + return "any[]" + case smartcontract.MapType: + return "object" + case smartcontract.VoidType: + return "void" + default: + panic(fmt.Sprintf("unknown type: %T %s", typ, typ)) + } +} diff --git a/main.go b/main.go index c111f28..c27952c 100644 --- a/main.go +++ b/main.go @@ -20,10 +20,11 @@ var ( TOOL_NEO_GO = "neo-go" TOOL_NEO_EXPRESS = "neo-express" - LANG_GO = "go" - LANG_PYTHON = "python" - LANG_JAVA = "java" - LANG_CSHARP = "csharp" + LANG_GO = "go" + LANG_PYTHON = "python" + LANG_JAVA = "java" + LANG_CSHARP = "csharp" + LANG_TYPESCRIPT = "ts" LOG_INFO = "INFO" LOG_DEBUG = "DEBUG" @@ -120,7 +121,7 @@ func main() { Subcommands: []*cli.Command{ { Name: LANG_GO, - Usage: "Generate an SDK for use with Golang", + Usage: "Generate an on-chain SDK for use with Golang", Action: func(c *cli.Context) error { return handleCliGenerate(c, LANG_GO) }, @@ -132,7 +133,7 @@ func main() { }, { Name: LANG_PYTHON, - Usage: "Generate an SDK for use with Python", + Usage: "Generate an on-chain SDK for use with Python", Action: func(c *cli.Context) error { return handleCliGenerate(c, LANG_PYTHON) }, @@ -144,7 +145,7 @@ func main() { }, { Name: LANG_JAVA, - Usage: "Generate an SDK for use with Java", + Usage: "Generate an on-chain SDK for use with Java", Action: func(c *cli.Context) error { return handleCliGenerate(c, LANG_JAVA) }, @@ -156,7 +157,7 @@ func main() { }, { Name: LANG_CSHARP, - Usage: "Generate an SDK for use with C#", + Usage: "Generate an on-chain SDK for use with C#", Action: func(c *cli.Context) error { return handleCliGenerate(c, LANG_CSHARP) }, @@ -166,6 +167,18 @@ func main() { &cli.StringFlag{Name: "o", Usage: "Output folder", Required: false}, }, }, + { + Name: LANG_TYPESCRIPT, + Usage: "Generate an off-chain SDK for use with TypeScript", + Action: func(c *cli.Context) error { + return handleCliGenerate(c, LANG_TYPESCRIPT) + }, + Flags: []cli.Flag{ + &cli.StringFlag{Name: "m", Usage: "Path to contract manifest.json", Required: true}, + &cli.StringFlag{Name: "c", Usage: "Contract script hash if known", Required: false}, + &cli.StringFlag{Name: "o", Usage: "Output folder", Required: false}, + }, + }, }, }, }, @@ -468,6 +481,8 @@ func generateSDK(m *manifest.Manifest, scriptHash util.Uint160, language string, err = generators.GenerateCsharpSDK(&cfg) } else if language == LANG_GO { err = generators.GenerateGoSDK(&cfg) + } else if language == LANG_TYPESCRIPT { + err = generators.GenerateTypeScriptSDK(&cfg) } else { log.Fatalf("language '%s' is unsupported", language) }