diff --git a/Makefile b/Makefile index 814b5969e9..a300d11485 100644 --- a/Makefile +++ b/Makefile @@ -245,6 +245,11 @@ solana: @echo "Building solana docker image" $(DOCKER) build -t solana-local -f contrib/localnet/solana/Dockerfile contrib/localnet/solana/ +ton: + @echo "Building ton docker image" + contrib/localnet/ton/download-jar.sh + $(DOCKER) buildx build --platform linux/amd64 -t ton-local -f contrib/localnet/ton/Dockerfile contrib/localnet/ton/ + start-e2e-test: zetanode @echo "--> Starting e2e test" cd contrib/localnet/ && $(DOCKER_COMPOSE) up -d diff --git a/contrib/localnet/docker-compose.yml b/contrib/localnet/docker-compose.yml index 606b4db4be..fca0e42a72 100644 --- a/contrib/localnet/docker-compose.yml +++ b/contrib/localnet/docker-compose.yml @@ -241,6 +241,20 @@ services: ipv4_address: 172.20.0.103 entrypoint: [ "/usr/bin/start-solana.sh" ] + ton: + image: ton-local:latest + container_name: ton + hostname: ton + profiles: + - ton + - all + ports: + - "8111:8000" # sidecar + - "4443:4443" # lite client + networks: + mynetwork: + ipv4_address: 172.20.0.104 + orchestrator: image: orchestrator:latest tty: true diff --git a/contrib/localnet/ton/.gitignore b/contrib/localnet/ton/.gitignore new file mode 100644 index 0000000000..d392f0e82c --- /dev/null +++ b/contrib/localnet/ton/.gitignore @@ -0,0 +1 @@ +*.jar diff --git a/contrib/localnet/ton/Dockerfile b/contrib/localnet/ton/Dockerfile new file mode 100644 index 0000000000..25a376ea3d --- /dev/null +++ b/contrib/localnet/ton/Dockerfile @@ -0,0 +1,34 @@ +# Let's build a sidecar +FROM golang:1.20 AS go-builder +WORKDIR /opt/sidecar +COPY ./sidecar.go . +RUN go build -o /opt/sidecar/sidecar sidecar.go + +FROM openjdk:24-slim AS ton-node + +ARG WORKDIR="/opt/my-local-ton" + +# Install dependencies && drop apt cache +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + curl jq vim lsb-release \ + && rm -rf /var/lib/apt/lists/* + +COPY ./my-local-ton.jar $WORKDIR/ +COPY ./entrypoint.sh $WORKDIR/ + +COPY --from=go-builder /opt/sidecar $WORKDIR/ + +WORKDIR $WORKDIR + +# Ensure whether the build works or not. +RUN chmod +x entrypoint.sh && chmod +x sidecar \ + && java -jar my-local-ton.jar debug nogui test-binaries; + +# Lite Client +EXPOSE 4443 + +# Sidecar +EXPOSE 8000 + +ENTRYPOINT ["/opt/my-local-ton/entrypoint.sh"] diff --git a/contrib/localnet/ton/download-jar.sh b/contrib/localnet/ton/download-jar.sh new file mode 100755 index 0000000000..762ff3fd06 --- /dev/null +++ b/contrib/localnet/ton/download-jar.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +script_dir=$(cd -- "$(dirname -- "$0")" &> /dev/null && pwd) + +# Downloads JAR outside of the Dockerfile to avoid re-downloading it every time during rebuilds. +jar_version="v120" +jar_url="https://github.com/neodix42/MyLocalTon/releases/download/${jar_version}/MyLocalTon-x86-64.jar" +jar_file="$script_dir/my-local-ton.jar" + +if [ -f "$jar_file" ]; then + echo "File $jar_file already exists. Skipping download." + exit 0 +fi + +echo "File not found. Downloading..." +echo "URL: $jar_url" +wget -q --show-progress -O "$jar_file" "$jar_url" diff --git a/contrib/localnet/ton/entrypoint.sh b/contrib/localnet/ton/entrypoint.sh new file mode 100644 index 0000000000..8a50339953 --- /dev/null +++ b/contrib/localnet/ton/entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +java -jar my-local-ton.jar with-validators-1 nogui debug & + +./sidecar & + +# Wait for both processes to finish +wait -n diff --git a/contrib/localnet/ton/readme.md b/contrib/localnet/ton/readme.md new file mode 100644 index 0000000000..f5eb0d78d7 --- /dev/null +++ b/contrib/localnet/ton/readme.md @@ -0,0 +1,61 @@ +# TON localnet + +This docker image represents a fully working TON node with 1 validator and a faucet wallet. + +## How it works + +- It uses [my-local-ton](https://github.com/neodix42/MyLocalTon) project without GUI. + Port `4443` is used for [lite-client connection](https://docs.ton.org/participate/run-nodes/enable-liteserver-node). +- It also has a convenient sidecar on port `8000` with some useful tools. +- Please note that it might take **several minutes** to bootstrap the network. + +## Sidecar + +### Getting faucet wallet + +```shell +curl -s http://ton:8000/faucet.json | jq +{ + "initialBalance": 1000001000000000, + "privateKey": "...", + "publicKey": "...", + "walletRawAddress": "...", + "mnemonic": "...", + "walletVersion": "V3R2", + "workChain": 0, + "subWalletId": 42, + "created": false +} +``` + +### Getting lite client config + +Please note that the config returns IP of localhost (`int 2130706433`). +You need to replace it with the actual IP of the docker container. + +```shell +curl -s http://ton:8000/lite-client.json | jq +{ + "@type": "config.global", + "dht": { ... }, + "liteservers": [ + { + "id": { "key": "...", "@type": "pub.ed25519" }, + "port": 4443, + "ip": 2130706433 + } + ], + "validator": { ... } +} +``` + +### Checking the node status + +It checks for config existence and the fact of faucet wallet deployment + +```shell +curl -s http://ton:8000/status | jq +{ + "status": "OK" +} +``` \ No newline at end of file diff --git a/contrib/localnet/ton/sidecar.go b/contrib/localnet/ton/sidecar.go new file mode 100644 index 0000000000..ec9860633e --- /dev/null +++ b/contrib/localnet/ton/sidecar.go @@ -0,0 +1,124 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "os" +) + +const ( + port = ":8000" + + basePath = "/opt/my-local-ton/myLocalTon" + liteClientConfigPath = basePath + "/genesis/db/my-ton-local.config.json" + settingsPath = basePath + "/settings.json" + + faucetJSONKey = "faucetWalletSettings" +) + +func main() { + http.HandleFunc("/faucet.json", errorWrapper(faucetHandler)) + http.HandleFunc("/lite-client.json", errorWrapper(liteClientHandler)) + http.HandleFunc("/status", errorWrapper(statusHandler)) + + //nolint:gosec + if err := http.ListenAndServe(port, nil); err != nil { + log.Fatal(err) + } +} + +func errorWrapper(handler func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := handler(w, r); err != nil { + errResponse(w, http.StatusInternalServerError, err) + } + } +} + +// Handler for the /faucet.json route +func faucetHandler(w http.ResponseWriter, _ *http.Request) error { + faucet, err := extractFaucetFromSettings(settingsPath) + if err != nil { + return err + } + + jsonResponse(w, http.StatusOK, faucet) + return nil +} + +func liteClientHandler(w http.ResponseWriter, _ *http.Request) error { + data, err := os.ReadFile(liteClientConfigPath) + if err != nil { + return fmt.Errorf("could not read lite client config: %w", err) + } + + jsonResponse(w, http.StatusOK, json.RawMessage(data)) + return nil +} + +// Handler for the /status route +func statusHandler(w http.ResponseWriter, _ *http.Request) error { + if _, err := os.Stat(liteClientConfigPath); err != nil { + return fmt.Errorf("lite client config %q not found: %w", liteClientConfigPath, err) + } + + faucet, err := extractFaucetFromSettings(settingsPath) + if err != nil { + return err + } + + type faucetShape struct { + Created bool `json:"created"` + } + + var fs faucetShape + if err = json.Unmarshal(faucet, &fs); err != nil { + return fmt.Errorf("failed to parse faucet settings: %w", err) + } + + if !fs.Created { + return errors.New("faucet is not created yet") + } + + jsonResponse(w, http.StatusOK, map[string]string{"status": "OK"}) + return nil +} + +func extractFaucetFromSettings(filePath string) (json.RawMessage, error) { + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("could not read faucet settings: %w", err) + } + + var keyValue map[string]json.RawMessage + if err := json.Unmarshal(data, &keyValue); err != nil { + return nil, fmt.Errorf("failed to parse faucet settings: %w", err) + } + + faucet, ok := keyValue[faucetJSONKey] + if !ok { + return nil, errors.New("faucet settings not found in JSON") + } + + return faucet, nil +} + +func errResponse(w http.ResponseWriter, status int, err error) { + jsonResponse(w, status, map[string]string{"error": err.Error()}) +} + +func jsonResponse(w http.ResponseWriter, status int, data any) { + bytes, err := json.Marshal(data) + if err != nil { + bytes = []byte("Failed to marshal JSON") + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + + //nolint:errcheck + w.Write(bytes) +} diff --git a/go.mod b/go.mod index 922c2cf4b1..57f3e3b7c1 100644 --- a/go.mod +++ b/go.mod @@ -335,6 +335,12 @@ require ( sigs.k8s.io/yaml v1.4.0 // indirect ) +require ( + github.com/oasisprotocol/curve25519-voi v0.0.0-20220328075252-7dd334e3daae // indirect + github.com/snksoft/crc v1.1.0 // indirect + github.com/tonkeeper/tongo v1.9.3 // indirect +) + replace ( github.com/agl/ed25519 => github.com/binance-chain/edwards25519 v0.0.0-20200305024217-f36fc4b53d43 github.com/btcsuite/btcd => github.com/btcsuite/btcd v0.22.3 diff --git a/go.sum b/go.sum index 89792efb1f..c6d2d28543 100644 --- a/go.sum +++ b/go.sum @@ -1243,6 +1243,8 @@ github.com/nishanths/predeclared v0.0.0-20200524104333-86fad755b4d3/go.mod h1:nt github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/oasisprotocol/curve25519-voi v0.0.0-20220328075252-7dd334e3daae h1:7smdlrfdcZic4VfsGKD2ulWL804a4GVphr4s7WZxGiY= +github.com/oasisprotocol/curve25519-voi v0.0.0-20220328075252-7dd334e3daae/go.mod h1:hVoHR2EVESiICEMbg137etN/Lx+lSrHPTD39Z/uE+2s= github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs= github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= @@ -1445,6 +1447,8 @@ github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9 github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg= github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= github.com/snikch/goodman v0.0.0-20171125024755-10e37e294daa/go.mod h1:oJyF+mSPHbB5mVY2iO9KV3pTt/QbIkGaO8gQ2WrDbP4= +github.com/snksoft/crc v1.1.0 h1:HkLdI4taFlgGGG1KvsWMpz78PkOC9TkPVpTV/cuWn48= +github.com/snksoft/crc v1.1.0/go.mod h1:5/gUOsgAm7OmIhb6WJzw7w5g2zfJi4FrHYgGPdshE+A= github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= @@ -1563,6 +1567,8 @@ github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+F github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/tonkeeper/tongo v1.9.3 h1:VNIZIuPeMw0+KZPvP57+EbgRwGZocN2v5CulRxba20A= +github.com/tonkeeper/tongo v1.9.3/go.mod h1:MjgIgAytFarjCoVjMLjYEtpZNN1f2G/pnZhKjr28cWs= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/tyler-smith/go-bip39 v1.0.1-0.20181017060643-dbb3b84ba2ef/go.mod h1:sJ5fKU0s6JVwZjjcUEX2zFOnvq0ASQ2K9Zr6cf67kNs= github.com/tyler-smith/go-bip39 v1.0.2/go.mod h1:sJ5fKU0s6JVwZjjcUEX2zFOnvq0ASQ2K9Zr6cf67kNs= diff --git a/zetaclient/chains/ton/observer/observer_test.go b/zetaclient/chains/ton/observer/observer_test.go new file mode 100644 index 0000000000..e978a589b9 --- /dev/null +++ b/zetaclient/chains/ton/observer/observer_test.go @@ -0,0 +1,63 @@ +package observer + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tonkeeper/tongo/config" + "github.com/tonkeeper/tongo/liteapi" +) + +// todo tmp (will be resolved automatically) +// taken from ton:8000/lite-client.json +const configRaw = `{"@type":"config.global","dht":{"@type":"dht.config.global","k":3,"a":3,"static_nodes": +{"@type":"dht.nodes","nodes":[]}},"liteservers":[{"id":{"key":"+DjLFqH/N5jO1ZO8PYVYU6a6e7EnnsF0GWFsteE+qy8=","@type": +"pub.ed25519"},"port":4443,"ip":2130706433}],"validator":{"@type":"validator.config.global","zero_state": +{"workchain":-1,"shard":-9223372036854775808,"seqno":0,"root_hash":"rR8EFZNlyj3rfYlMyQC8gT0A6ghDrbKe4aMmodiNw6I=", +"file_hash":"fT2hXGv1OF7XDhraoAELrYz6wX3ue16QpSoWTiPrUAE="},"init_block":{"workchain":-1,"shard":-9223372036854775808, +"seqno":0,"root_hash":"rR8EFZNlyj3rfYlMyQC8gT0A6ghDrbKe4aMmodiNw6I=", +"file_hash":"fT2hXGv1OF7XDhraoAELrYz6wX3ue16QpSoWTiPrUAE="}}}` + +func TestObserver(t *testing.T) { + t.Skip("skip test") + + ctx := context.Background() + + cfg, err := config.ParseConfig(strings.NewReader(configRaw)) + require.NoError(t, err) + + client, err := liteapi.NewClient(liteapi.WithConfigurationFile(*cfg)) + require.NoError(t, err) + + res, err := client.GetMasterchainInfo(ctx) + require.NoError(t, err) + + // Outputs: + // { + // "Last": { + // "Workchain": 4294967295, + // "Shard": 9223372036854775808, + // "Seqno": 915, + // "RootHash": "2e9e312c5bd3b7b96d23ce1342ac76e5486012c9aac44781c2c25dbc55f5c8ad", + // "FileHash": "d3745319bfaeebb168d9db6bb5b4752b6b28ab9041735c81d4a02fc820040851" + // }, + // "StateRootHash": "02538fb9dc802004012285a90a7af9ba279706e2deea9ca635decd80e94a7045", + // "Init": { + // "Workchain": 4294967295, + // "RootHash": "ad1f04159365ca3deb7d894cc900bc813d00ea0843adb29ee1a326a1d88dc3a2", + // "FileHash": "7d3da15c6bf5385ed70e1adaa0010bad8cfac17dee7b5e90a52a164e23eb5001" + // } + // } + t.Logf("Masterchain info") + logJSON(t, res) +} + +func logJSON(t *testing.T, v any) { + b, err := json.MarshalIndent(v, "", " ") + require.NoError(t, err) + + t.Log(string(b)) +} diff --git a/zetaclient/chains/ton/utils.go b/zetaclient/chains/ton/utils.go new file mode 100644 index 0000000000..905d58517f --- /dev/null +++ b/zetaclient/chains/ton/utils.go @@ -0,0 +1,24 @@ +package ton + +import ( + "fmt" + "net/http" + + "github.com/tonkeeper/tongo/config" +) + +// ConfigFromURL downloads & parses config. +// +//nolint:gosec +func ConfigFromURL(url string) (*config.GlobalConfigurationFile, error) { + res, err := http.Get(url) + if err != nil { + return nil, err + } + + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to download config file: %s", res.Status) + } + + return config.ParseConfig(res.Body) +}