diff --git a/go.mod b/go.mod index 62ca80b..9f11e75 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,6 @@ go 1.24.3 require ( github.com/google/uuid v1.6.0 - github.com/gregorybednov/lbc_sdk v0.0.0-20250810102513-432a51e65f76 + github.com/gregorybednov/lbc_sdk v0.0.0-20250810123844-a90b874431fa github.com/spf13/pflag v1.0.7 ) diff --git a/go.sum b/go.sum index fbfea12..4a86c95 100644 --- a/go.sum +++ b/go.sum @@ -2,5 +2,7 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gregorybednov/lbc_sdk v0.0.0-20250810102513-432a51e65f76 h1:e3A+1v+Mjt8nuJcVnHuhHZuh4052KRLCUBpH4g74rVs= github.com/gregorybednov/lbc_sdk v0.0.0-20250810102513-432a51e65f76/go.mod h1:DBE00+SaYBtD4qw+nOtSTLuF6h9Ia4TkuBMJB+6krik= +github.com/gregorybednov/lbc_sdk v0.0.0-20250810123844-a90b874431fa h1:8EuqAmsS94ju83o4aEIV8e2fdocdAJ9xVerKRSI+nDA= +github.com/gregorybednov/lbc_sdk v0.0.0-20250810123844-a90b874431fa/go.mod h1:DBE00+SaYBtD4qw+nOtSTLuF6h9Ia4TkuBMJB+6krik= github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= diff --git a/main.go b/main.go index 71c1df1..2ecd898 100644 --- a/main.go +++ b/main.go @@ -6,18 +6,21 @@ import ( "crypto/rand" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "net/http" + "net/url" "os" + "strings" "time" "github.com/google/uuid" - - types "github.com/gregorybednov/lbc_sdk" "github.com/spf13/pflag" ) +// ===== RPC plumbing ===== + type rpcResp struct { JSONRPC string `json:"jsonrpc"` ID any `json:"id"` @@ -38,10 +41,6 @@ type rpcResp struct { } `json:"result"` } -const configDir = "./config" -const privKeyPath = configDir + "/ed25519.key" -const pubKeyPath = configDir + "/ed25519.pub" - func parseRPCResult(data []byte) error { var r rpcResp _ = json.Unmarshal(data, &r) @@ -60,6 +59,31 @@ func parseRPCResult(data []byte) error { return nil } +func postRPC(txB64, rpcURL string) error { + final := map[string]any{ + "jsonrpc": "2.0", + "id": uuid.NewString(), + "method": "broadcast_tx_commit", + "params": map[string]string{ + "tx": txB64, + }, + } + finalBytes, _ := json.Marshal(final) + resp, err := http.Post(rpcURL, "application/json", bytes.NewReader(finalBytes)) + if err != nil { + return err + } + defer resp.Body.Close() + data, _ := io.ReadAll(resp.Body) + return parseRPCResult(data) +} + +// ===== Keys ===== + +const configDir = "./config" +const privKeyPath = configDir + "/ed25519.key" +const pubKeyPath = configDir + "/ed25519.pub" + func ensureKeypair() (ed25519.PublicKey, ed25519.PrivateKey, error) { if _, err := os.Stat(privKeyPath); os.IsNotExist(err) { fmt.Println("🔐 Generating new ed25519 keypair...") @@ -67,163 +91,436 @@ func ensureKeypair() (ed25519.PublicKey, ed25519.PrivateKey, error) { if err != nil { return nil, nil, err } - os.MkdirAll(configDir, 0700) - os.WriteFile(privKeyPath, priv, 0600) - os.WriteFile(pubKeyPath, pub, 0644) + _ = os.MkdirAll(configDir, 0700) + _ = os.WriteFile(privKeyPath, priv, 0600) + _ = os.WriteFile(pubKeyPath, pub, 0644) return pub, priv, nil } - priv, _ := os.ReadFile(privKeyPath) - pub, _ := os.ReadFile(pubKeyPath) + priv, err := os.ReadFile(privKeyPath) + if err != nil { + return nil, nil, err + } + pub, err := os.ReadFile(pubKeyPath) + if err != nil { + return nil, nil, err + } return ed25519.PublicKey(pub), ed25519.PrivateKey(priv), nil } -func sendTx(tx types.SignedTx, rpcURL string) error { - txBytes, _ := json.Marshal(tx) - txB64 := base64.StdEncoding.EncodeToString(txBytes) - final := map[string]any{ - "jsonrpc": "2.0", - "id": uuid.NewString(), - "method": "broadcast_tx_commit", - "params": map[string]string{ - "tx": txB64, - }, +// ===== Tx bodies per ER ===== + +type CommiterTxBody struct { + Type string `json:"type"` // "commiter" + ID string `json:"id"` + Name string `json:"name"` + CommiterPubKey string `json:"commiter_pubkey"` +} + +type BeneficiaryTxBody struct { + Type string `json:"type"` // "beneficiary" + ID string `json:"id"` + Name string `json:"name"` +} + +type PromiseTxBody struct { + Type string `json:"type"` // "promise" + ID string `json:"id"` + Text string `json:"text"` + Due int64 `json:"due"` + BeneficiaryID string `json:"beneficiary_id"` + ParentPromiseID *string `json:"parent_promise_id"` +} + +type CommitmentTxBody struct { + Type string `json:"type"` // "commitment" + ID string `json:"id"` + PromiseID string `json:"promise_id"` + CommiterID string `json:"commiter_id"` + Due int64 `json:"due"` +} + +type SignedTx struct { + Body any `json:"body"` + Signature string `json:"signature"` +} + +type CompositeSignedTx struct { + Body struct { + Promise *PromiseTxBody `json:"promise"` + Commitment *CommitmentTxBody `json:"commitment"` + } `json:"body"` + Signature string `json:"signature"` +} + +// ===== Helpers ===== + +func mustUUID(prefix string) string { return prefix + ":" + uuid.NewString() } + +func parseWhen(s string) (int64, error) { + if s == "" { + return 0, errors.New("missing datetime") } - finalBytes, _ := json.Marshal(final) - fmt.Println("🚧 Raw tx JSON:", string(txBytes)) - fmt.Printf("🚧 Final RPC body:\n%s\n", string(finalBytes)) - resp, err := http.Post(rpcURL, "application/json", bytes.NewReader(finalBytes)) + if t, err := time.Parse(time.RFC3339, s); err == nil { + return t.Unix(), nil + } + if t, err := time.Parse("2006-01-02", s); err == nil { + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC).Unix(), nil + } + return 0, fmt.Errorf("cannot parse time: %q (use 2006-01-02 or RFC3339)", s) +} + +func sign(priv ed25519.PrivateKey, body any) (sigB64 string, raw []byte, err error) { + raw, err = json.Marshal(body) + if err != nil { + return "", nil, err + } + sig := ed25519.Sign(priv, raw) + return base64.StdEncoding.EncodeToString(sig), raw, nil +} + +// ===== High-level ops: send ===== + +func registerCommiter(name, rpcURL string) error { + pub, priv, err := ensureKeypair() if err != nil { return err } - defer resp.Body.Close() - data, _ := io.ReadAll(resp.Body) - fmt.Println("📡 Response:") - fmt.Println(string(data)) - if err := parseRPCResult(data); err != nil { - return err - } - return nil -} - -func registerCommiter(name, rpcURL string) error { - pub, priv, _ := ensureKeypair() pubB64 := base64.StdEncoding.EncodeToString(pub) id := "commiter:" + pubB64 - body := types.CommiterTxBody{ + body := CommiterTxBody{ Type: "commiter", ID: id, Name: name, CommiterPubKey: pubB64, } - bodyBytes, _ := json.Marshal(body) - sig := ed25519.Sign(priv, bodyBytes) - tx := types.SignedTx{Body: body, Signature: base64.StdEncoding.EncodeToString(sig)} - return sendTx(tx, rpcURL) + sigB64, _, err := sign(priv, body) + if err != nil { + return err + } + tx := SignedTx{Body: body, Signature: sigB64} + txBytes, _ := json.Marshal(tx) + return postRPC(base64.StdEncoding.EncodeToString(txBytes), rpcURL) } -func createPromise(desc, rpcURL string) error { - pub, priv, _ := ensureKeypair() - pubB64 := base64.StdEncoding.EncodeToString(pub) - - commiterID := "commiter:" + pubB64 - promiseID := "promise:" + uuid.NewString() - commitmentID := "commitment:" + uuid.NewString() - - // Объекты тела - promise := &types.PromiseTxBody{ - Type: "promise", - ID: promiseID, - Description: desc, - Timestamp: time.Now().Unix(), +func createBeneficiary(name, rpcURL string) (string, error) { + _, priv, err := ensureKeypair() + if err != nil { + return "", err } - commitment := &types.CommitmentTxBody{ + id := mustUUID("beneficiary") + body := BeneficiaryTxBody{ + Type: "beneficiary", + ID: id, + Name: name, + } + sigB64, _, err := sign(priv, body) + if err != nil { + return "", err + } + tx := SignedTx{Body: body, Signature: sigB64} + txBytes, _ := json.Marshal(tx) + if err := postRPC(base64.StdEncoding.EncodeToString(txBytes), rpcURL); err != nil { + return "", err + } + return id, nil +} + +type CreatePromiseArgs struct { + Text string + DueISO string + BeneficiaryID string + ParentPromiseID string + CommitmentDueISO string +} + +func createPromiseAndCommit(args CreatePromiseArgs, rpcURL string) error { + if args.Text == "" { + return errors.New("--text is required") + } + if args.BeneficiaryID == "" { + return errors.New("--beneficiary-id is required") + } + promiseDue, err := parseWhen(args.DueISO) + if err != nil { + return fmt.Errorf("promise --due: %w", err) + } + commitDue, err := parseWhen(args.CommitmentDueISO) + if err != nil { + return fmt.Errorf("commitment --commitment-due: %w", err) + } + + pub, priv, err := ensureKeypair() + if err != nil { + return err + } + pubB64 := base64.StdEncoding.EncodeToString(pub) + commiterID := "commiter:" + pubB64 + + promiseID := mustUUID("promise") + commitmentID := mustUUID("commitment") + + var parentPtr *string + if args.ParentPromiseID != "" { + p := args.ParentPromiseID + parentPtr = &p + } + + promise := &PromiseTxBody{ + Type: "promise", + ID: promiseID, + Text: args.Text, + Due: promiseDue, + BeneficiaryID: args.BeneficiaryID, + ParentPromiseID: parentPtr, + } + commitment := &CommitmentTxBody{ Type: "commitment", ID: commitmentID, PromiseID: promiseID, CommiterID: commiterID, + Due: commitDue, } - // Сборка тела для подписи - bodyStruct := struct { - Promise *types.PromiseTxBody `json:"promise"` - Commitment *types.CommitmentTxBody `json:"commitment"` - }{ - Promise: promise, - Commitment: commitment, - } + var compound CompositeSignedTx + compound.Body.Promise = promise + compound.Body.Commitment = commitment - // Сериализация и подпись - bodyBytes, err := json.Marshal(bodyStruct) + sigB64, _, err := sign(priv, compound.Body) if err != nil { - return fmt.Errorf("failed to marshal compound body: %w", err) + return err } - sig := ed25519.Sign(priv, bodyBytes) - sigB64 := base64.StdEncoding.EncodeToString(sig) + compound.Signature = sigB64 - // Формирование транзакции - compound := types.CompoundTx{ - Body: bodyStruct, - Signature: sigB64, - } - - // Отправка txBytes, err := json.Marshal(compound) - if err != nil { - return fmt.Errorf("failed to marshal compound tx: %w", err) - } - txB64 := base64.StdEncoding.EncodeToString(txBytes) - final := map[string]any{ - "jsonrpc": "2.0", - "id": uuid.NewString(), - "method": "broadcast_tx_commit", - "params": map[string]string{ - "tx": txB64, - }, - } - - finalBytes, _ := json.Marshal(final) - fmt.Printf("🚧 Final RPC body:\n%s\n", string(finalBytes)) - resp, err := http.Post(rpcURL, "application/json", bytes.NewReader(finalBytes)) if err != nil { return err } + return postRPC(base64.StdEncoding.EncodeToString(txBytes), rpcURL) +} + +// ===== High-level ops: get (abci_query) ===== + +type abciQueryResp struct { + JSONRPC string `json:"jsonrpc"` + ID any `json:"id"` + Result struct { + Response struct { + Code int `json:"code"` + Log string `json:"log"` + Info string `json:"info"` + Index string `json:"index"` + Key string `json:"key"` + Value string `json:"value"` // base64 + ProofOps any `json:"proofOps"` + Height string `json:"height"` + Codespace string `json:"codespace"` + } `json:"response"` + } `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + Data string `json:"data"` + } `json:"error"` +} + +func abciQuery(rpcURL, path, dataB64 string, height string) (*abciQueryResp, error) { + v := url.Values{} + // Tendermint любит path в кавычках, как в твоём примере + v.Set("path", fmt.Sprintf("%q", path)) + if dataB64 != "" { + v.Set("data", dataB64) + } + if height != "" { + v.Set("height", height) + } + u := strings.TrimRight(rpcURL, "/") + "/abci_query?" + v.Encode() + resp, err := http.Get(u) + if err != nil { + return nil, err + } defer resp.Body.Close() - data, _ := io.ReadAll(resp.Body) - fmt.Println("📡 Response:") - fmt.Println(string(data)) - if err := parseRPCResult(data); err != nil { - return err + b, _ := io.ReadAll(resp.Body) + var q abciQueryResp + if err := json.Unmarshal(b, &q); err != nil { + return nil, fmt.Errorf("decode json: %w", err) } - return nil + if q.Error != nil { + return nil, fmt.Errorf("RPC error: %d %s (%s)", q.Error.Code, q.Error.Message, q.Error.Data) + } + return &q, nil } +func printRawJSON(obj any) { + out, _ := json.MarshalIndent(obj, "", " ") + fmt.Println(string(out)) +} + +func tryPrintValueAsJSONOrText(b []byte) { + // попытка как JSON + var anyJSON any + if json.Unmarshal(b, &anyJSON) == nil { + printRawJSON(anyJSON) + return + } + // если выглядит как текст — печатаем строкой + s := string(b) + // грубая эвристика: если есть нулевые байты — покажем как base64 + if strings.IndexByte(s, 0x00) >= 0 { + fmt.Println(base64.StdEncoding.EncodeToString(b)) + return + } + fmt.Println(s) +} + +// ===== CLI ===== + func main() { - var name, desc, rpc string - pflag.StringVar(&name, "name", "", "register commiter name") - pflag.StringVar(&desc, "desc", "", "create promise description") - pflag.StringVar(&rpc, "rpc", "http://localhost:26657", "Tendermint RPC URL") - pflag.Parse() - - if name != "" { - err := registerCommiter(name, rpc) - if err != nil { - fmt.Fprintf(os.Stderr, "❌ Error: %v\n", err) - os.Exit(1) - } - fmt.Println("✅ Commiter registered successfully") - return + if len(os.Args) < 2 { + usage() + os.Exit(1) } - - if desc != "" { - err := createPromise(desc, rpc) - if err != nil { - fmt.Fprintf(os.Stderr, "❌ Error: %v\n", err) - os.Exit(1) - } - fmt.Println("✅ Promise and Commitment created successfully") - return + cmd := os.Args[1] + switch cmd { + case "send": + sendMain(os.Args[2:]) + case "get": + getMain(os.Args[2:]) + default: + usage() + os.Exit(1) } - - fmt.Println("⛔ Please provide either --name or --desc") - os.Exit(1) +} + +func usage() { + fmt.Println("Usage:") + fmt.Println(" lbc_client send [--rpc URL] (--name NAME | --beneficiary-name NAME | --text TXT --due DATE --beneficiary-id ID [--parent-id ID] --commitment-due DATE)") + fmt.Println(" lbc_client get [--rpc URL] --path PATH [--data BYTES] [--height H] [--raw-json | --value]") +} + +func sendMain(args []string) { + fs := pflag.NewFlagSet("send", pflag.ExitOnError) + var rpc string + var name string + var beneficiaryName string + var text, due, beneficiaryID, parentID, commitmentDue string + + fs.StringVar(&rpc, "rpc", "http://localhost:26657", "Tendermint RPC URL") + fs.StringVar(&name, "name", "", "register commiter name") + fs.StringVar(&beneficiaryName, "beneficiary-name", "", "create beneficiary with a given name") + fs.StringVar(&text, "text", "", "promise text (required for promise)") + fs.StringVar(&due, "due", "", "promise due (YYYY-MM-DD or RFC3339)") + fs.StringVar(&beneficiaryID, "beneficiary-id", "", "beneficiary ID (required for promise)") + fs.StringVar(&parentID, "parent-id", "", "optional parent promise ID") + fs.StringVar(&commitmentDue, "commitment-due", "", "commitment due (YYYY-MM-DD or RFC3339)") + + _ = fs.Parse(args) + + switch { + case name != "": + if err := registerCommiter(name, rpc); err != nil { + fmt.Fprintf(os.Stderr, "❌ Error: %v\n", err) + os.Exit(1) + } + fmt.Println("✅ Commiter registered") + case beneficiaryName != "": + id, err := createBeneficiary(beneficiaryName, rpc) + if err != nil { + fmt.Fprintf(os.Stderr, "❌ Error: %v\n", err) + os.Exit(1) + } + fmt.Printf("✅ Beneficiary created: %s\n", id) + default: + // promise+commitment + if text == "" || due == "" || beneficiaryID == "" || commitmentDue == "" { + fmt.Fprintln(os.Stderr, "⛔ For promise+commitment you must pass --text, --due, --beneficiary-id, --commitment-due") + os.Exit(1) + } + args := CreatePromiseArgs{ + Text: text, + DueISO: due, + BeneficiaryID: beneficiaryID, + ParentPromiseID: parentID, + CommitmentDueISO: commitmentDue, + } + if err := createPromiseAndCommit(args, rpc); err != nil { + fmt.Fprintf(os.Stderr, "❌ Error: %v\n", err) + os.Exit(1) + } + fmt.Println("✅ Promise+Commitment created atomically") + } +} + +func getMain(args []string) { + fs := pflag.NewFlagSet("get", pflag.ExitOnError) + var rpc string + var path string + var listAlias string + var dataArg string + var height string + var rawJSON bool + var value bool + + fs.StringVar(&rpc, "rpc", "http://localhost:26657", "Tendermint RPC URL") + fs.StringVar(&path, "path", "", "ABCI path (e.g. /list/promise)") + fs.StringVar(&listAlias, "list", "", "entity alias: promise | commitment | commiter | beneficiary") + fs.StringVar(&dataArg, "data", "", "optional key/arg (sent as base64)") + fs.StringVar(&height, "height", "", "block height") + fs.BoolVar(&rawJSON, "raw-json", false, "print raw abci_query JSON") + fs.BoolVar(&value, "value", false, "decode response.value (base64) and try to parse JSON") + + _ = fs.Parse(args) + + // алиасы -> path + if path == "" && listAlias != "" { + switch listAlias { + case "promise", "commitment", "commiter", "beneficiary": + path = "/list/" + listAlias + default: + fmt.Fprintf(os.Stderr, "⛔ unknown alias for --list: %q\n", listAlias) + os.Exit(1) + } + } + + if path == "" { + fmt.Fprintln(os.Stderr, "⛔ Either --path or --list is required") + os.Exit(1) + } + + // поведение по умолчанию: + // - при --list, если ни --raw-json, ни --value не заданы → включаем --value + // - при --path — остаётся прежняя логика (тоже включает --value по умолчанию) + if !rawJSON && !value { + value = true + } + + // data → base64 + var dataB64 string + if dataArg != "" { + dataB64 = base64.StdEncoding.EncodeToString([]byte(dataArg)) + } + + q, err := abciQuery(rpc, path, dataB64, height) + if err != nil { + fmt.Fprintf(os.Stderr, "❌ Error: %v\n", err) + os.Exit(1) + } + + if rawJSON { + printRawJSON(q) + return + } + + // value mode + valB64 := q.Result.Response.Value + if valB64 == "" { + fmt.Println("") + return + } + raw, err := base64.StdEncoding.DecodeString(valB64) + if err != nil { + fmt.Fprintf(os.Stderr, "⚠️ cannot base64-decode value: %v\n", err) + fmt.Println(valB64) + return + } + tryPrintValueAsJSONOrText(raw) }