// cmd/client — CLI client for interacting with the chain. // // Commands: // // client keygen --out // client register --key --nick // client balance --key --db // client transfer --key --to --amount // client info --db // client request-contact --key --to --fee [--intro "text"] --node // client accept-contact --key --from --node // client block-contact --key --from --node // client contacts --key --node // client send-msg --key --to --msg "text" --node // client inbox --key --node // client deploy-contract --key --wasm --abi --node // client call-contract --key --contract --method [--args '["v"]'] [--gas N] --node // client stake --key --amount --node // client unstake --key --node // client wait-tx --id [--timeout ] --node // client issue-token --key --name --symbol [--decimals N] --supply --node // client transfer-token --key --token --to --amount --node // client burn-token --key --token --amount --node // client token-balance --token [--address ] --node package main import ( "bufio" "bytes" "context" "crypto/sha256" "encoding/base64" "crypto/ed25519" "encoding/hex" "encoding/json" "flag" "fmt" "io" "log" "net/http" "os" "strconv" "strings" "time" "go-blockchain/blockchain" "go-blockchain/economy" "go-blockchain/identity" "go-blockchain/node/version" "go-blockchain/relay" ) func main() { if len(os.Args) < 2 { usage() os.Exit(1) } cmd := os.Args[1] args := os.Args[2:] switch cmd { case "keygen": cmdKeygen(args) case "register": cmdRegister(args) case "balance": cmdBalance(args) case "transfer": cmdTransfer(args) case "info": cmdInfo(args) case "request-contact": cmdRequestContact(args) case "accept-contact": cmdAcceptContact(args) case "block-contact": cmdBlockContact(args) case "contacts": cmdContacts(args) case "add-validator": cmdAddValidator(args) case "admit-sign": cmdAdmitSign(args) case "remove-validator": cmdRemoveValidator(args) case "send-msg": cmdSendMsg(args) case "inbox": cmdInbox(args) case "deploy-contract": cmdDeployContract(args) case "call-contract": cmdCallContract(args) case "stake": cmdStake(args) case "unstake": cmdUnstake(args) case "wait-tx": cmdWaitTx(args) case "issue-token": cmdIssueToken(args) case "transfer-token": cmdTransferToken(args) case "burn-token": cmdBurnToken(args) case "token-balance": cmdTokenBalance(args) case "mint-nft": cmdMintNFT(args) case "transfer-nft": cmdTransferNFT(args) case "burn-nft": cmdBurnNFT(args) case "nft-info": cmdNFTInfo(args) case "version", "--version", "-v": fmt.Println(version.String()) default: usage() os.Exit(1) } } func usage() { fmt.Println(`Usage: client [flags] Commands: keygen --out Generate a new identity register --key --nick Build a REGISTER_KEY transaction balance --key --db Show token balance transfer --key --to --amount Send tokens via node API [--memo "text"] [--node http://localhost:8080] info --db Show chain info request-contact --key --to Send a paid contact request (ICQ-style) --fee [--intro "text"] [--node http://localhost:8080] accept-contact --key --from Accept a contact request [--node http://localhost:8080] block-contact --key --from Block a contact request [--node http://localhost:8080] contacts --key List incoming contact requests [--node http://localhost:8080] add-validator --key --target Add a validator (caller must be a validator) [--reason "text"] [--node http://localhost:8080] remove-validator --key --target Remove a validator (or self-remove) [--reason "text"] [--node http://localhost:8080] send-msg --key --to Send an encrypted message --msg "text" [--node http://localhost:8080] inbox --key Read and decrypt inbox messages [--node http://localhost:8080] [--limit N] deploy-contract --key --wasm --abi Deploy a WASM smart contract [--node http://localhost:8080] call-contract --key --contract Call a smart contract method --method [--args '["val",42]'] [--gas N] [--node http://localhost:8080] stake --key --amount Lock tokens as validator stake [--node http://localhost:8080] unstake --key Release all staked tokens [--node http://localhost:8080] wait-tx --id [--timeout ] Wait for a transaction to be confirmed [--node http://localhost:8080] issue-token --key --name Issue a new fungible token --symbol [--decimals N] --supply [--node http://localhost:8080] transfer-token --key --token Transfer fungible tokens --to --amount [--node http://localhost:8080] burn-token --key --token Burn (destroy) fungible tokens --amount [--node http://localhost:8080] token-balance --token [--address ] Query token balance [--node http://localhost:8080] mint-nft --key --name Mint a new NFT [--desc "text"] [--uri ] [--attrs '{"k":"v"}'] [--node http://localhost:8080] transfer-nft --key --nft --to Transfer NFT ownership [--node http://localhost:8080] burn-nft --key --nft Burn (destroy) an NFT [--node http://localhost:8080] nft-info --nft [--owner ] Query NFT info or owner's NFTs [--node http://localhost:8080]`) } // --- keygen --- func cmdKeygen(args []string) { fs := flag.NewFlagSet("keygen", flag.ExitOnError) out := fs.String("out", "key.json", "output file") if err := fs.Parse(args); err != nil { log.Fatal(err) } id, err := identity.Generate() if err != nil { log.Fatal(err) } saveKey(*out, id) fmt.Printf("New identity generated:\n pub_key: %s\n x25519_pub: %s\n saved to: %s\n", id.PubKeyHex(), id.X25519PubHex(), *out) } // --- register --- func cmdRegister(args []string) { fs := flag.NewFlagSet("register", flag.ExitOnError) keyFile := fs.String("key", "key.json", "identity file") nick := fs.String("nick", "", "nickname") difficulty := fs.Int("pow", 4, "PoW difficulty (hex nibbles)") nodeURL := fs.String("node", "http://localhost:8080", "node API URL") dryRun := fs.Bool("dry-run", false, "print TX JSON without broadcasting") if err := fs.Parse(args); err != nil { log.Fatal(err) } id := loadKey(*keyFile) fmt.Printf("Mining registration PoW (difficulty %d)...\n", *difficulty) tx, err := identity.RegisterTx(id, *nick, *difficulty) if err != nil { log.Fatal(err) } if *dryRun { data, _ := json.MarshalIndent(tx, "", " ") fmt.Println(string(data)) return } result, err := postTx(*nodeURL, tx) if err != nil { log.Fatalf("submit tx: %v", err) } fmt.Printf("Registration submitted: %s\n", result) } // --- balance --- func cmdBalance(args []string) { fs := flag.NewFlagSet("balance", flag.ExitOnError) keyFile := fs.String("key", "key.json", "identity file") dbPath := fs.String("db", "./chaindata", "chain DB path") if err := fs.Parse(args); err != nil { log.Fatal(err) } id := loadKey(*keyFile) chain, err := blockchain.NewChain(*dbPath) if err != nil { log.Fatal(err) } defer chain.Close() bal, err := chain.Balance(id.PubKeyHex()) if err != nil { log.Fatal(err) } fmt.Printf("Balance of %s:\n %s (%d µT)\n", id.PubKeyHex(), economy.FormatTokens(bal), bal) } // --- transfer --- func cmdTransfer(args []string) { fs := flag.NewFlagSet("transfer", flag.ExitOnError) keyFile := fs.String("key", "key.json", "sender identity file") nodeURL := fs.String("node", "http://localhost:8080", "node API URL") to := fs.String("to", "", "recipient: hex pubkey, DC address, or @username") amountStr := fs.String("amount", "0", "amount in tokens (e.g. 1.5)") memo := fs.String("memo", "", "optional transfer memo") dryRun := fs.Bool("dry-run", false, "print TX JSON without broadcasting") registry := fs.String("registry", "", "username_registry contract ID for @username resolution") if err := fs.Parse(args); err != nil { log.Fatal(err) } if *to == "" { log.Fatal("--to is required") } toResolved := strings.TrimPrefix(*to, "@") recipient, err := resolveRecipientPubKey(*nodeURL, toResolved, *registry) if err != nil { log.Fatalf("resolve recipient: %v", err) } id := loadKey(*keyFile) amount, err := parseTokenAmount(*amountStr) if err != nil { log.Fatalf("invalid amount: %v", err) } tx := buildTransferTx(id, recipient, amount, *memo) if *dryRun { data, _ := json.MarshalIndent(tx, "", " ") fmt.Println(string(data)) return } result, err := postTx(*nodeURL, tx) if err != nil { log.Fatalf("submit tx: %v", err) } fmt.Printf("Transaction submitted: %s\n", result) } // --- info --- func cmdInfo(args []string) { fs := flag.NewFlagSet("info", flag.ExitOnError) dbPath := fs.String("db", "./chaindata", "chain DB path") if err := fs.Parse(args); err != nil { log.Fatal(err) } chain, err := blockchain.NewChain(*dbPath) if err != nil { log.Fatal(err) } defer chain.Close() tip := chain.Tip() if tip == nil { fmt.Println("Chain is empty (no genesis block yet)") return } fmt.Printf("Chain info:\n") fmt.Printf(" Height: %d\n", tip.Index) fmt.Printf(" Tip hash: %s\n", tip.HashHex()) fmt.Printf(" Tip time: %s\n", tip.Timestamp.Format(time.RFC3339)) fmt.Printf(" Validator: %s\n", tip.Validator) fmt.Printf(" Tx count: %d\n", len(tip.Transactions)) } // --- request-contact --- func cmdRequestContact(args []string) { fs := flag.NewFlagSet("request-contact", flag.ExitOnError) keyFile := fs.String("key", "key.json", "sender identity file") nodeURL := fs.String("node", "http://localhost:8080", "node API URL") to := fs.String("to", "", "recipient pub key or DC address") feeStr := fs.String("fee", "0.005", "contact fee in tokens (min 0.005)") intro := fs.String("intro", "", "optional intro message (≤ 280 chars)") if err := fs.Parse(args); err != nil { log.Fatal(err) } if *to == "" { log.Fatal("--to is required") } if len(*intro) > 280 { log.Fatal("--intro must be ≤ 280 characters") } recipient, err := resolveRecipientPubKey(*nodeURL, *to) if err != nil { log.Fatalf("resolve recipient: %v", err) } id := loadKey(*keyFile) feeUT, err := parseTokenAmount(*feeStr) if err != nil { log.Fatalf("invalid fee: %v", err) } if feeUT < blockchain.MinContactFee { log.Fatalf("fee must be at least %s (%d µT)", economy.FormatTokens(blockchain.MinContactFee), blockchain.MinContactFee) } payload, _ := json.Marshal(blockchain.ContactRequestPayload{Intro: *intro}) tx := &blockchain.Transaction{ ID: fmt.Sprintf("tx-%d", time.Now().UnixNano()), Type: blockchain.EventContactRequest, From: id.PubKeyHex(), To: recipient, Amount: feeUT, Fee: blockchain.MinFee, Payload: payload, Timestamp: time.Now().UTC(), } tx.Signature = id.Sign(txSignBytes(tx)) result, err := postTx(*nodeURL, tx) if err != nil { log.Fatalf("submit tx: %v", err) } fmt.Printf("Contact request sent to %s\n fee: %s\n result: %s\n", recipient, economy.FormatTokens(feeUT), result) } // --- accept-contact --- func cmdAcceptContact(args []string) { fs := flag.NewFlagSet("accept-contact", flag.ExitOnError) keyFile := fs.String("key", "key.json", "identity file") nodeURL := fs.String("node", "http://localhost:8080", "node API URL") from := fs.String("from", "", "requester pub key") if err := fs.Parse(args); err != nil { log.Fatal(err) } if *from == "" { log.Fatal("--from is required") } id := loadKey(*keyFile) payload, _ := json.Marshal(blockchain.AcceptContactPayload{}) tx := &blockchain.Transaction{ ID: fmt.Sprintf("tx-%d", time.Now().UnixNano()), Type: blockchain.EventAcceptContact, From: id.PubKeyHex(), To: *from, Fee: blockchain.MinFee, Payload: payload, Timestamp: time.Now().UTC(), } tx.Signature = id.Sign(txSignBytes(tx)) result, err := postTx(*nodeURL, tx) if err != nil { log.Fatalf("submit tx: %v", err) } fmt.Printf("Accepted contact request from %s\n result: %s\n", *from, result) } // --- block-contact --- func cmdBlockContact(args []string) { fs := flag.NewFlagSet("block-contact", flag.ExitOnError) keyFile := fs.String("key", "key.json", "identity file") nodeURL := fs.String("node", "http://localhost:8080", "node API URL") from := fs.String("from", "", "requester pub key to block") reason := fs.String("reason", "", "optional block reason") if err := fs.Parse(args); err != nil { log.Fatal(err) } if *from == "" { log.Fatal("--from is required") } id := loadKey(*keyFile) payload, _ := json.Marshal(blockchain.BlockContactPayload{Reason: *reason}) tx := &blockchain.Transaction{ ID: fmt.Sprintf("tx-%d", time.Now().UnixNano()), Type: blockchain.EventBlockContact, From: id.PubKeyHex(), To: *from, Fee: blockchain.MinFee, Payload: payload, Timestamp: time.Now().UTC(), } tx.Signature = id.Sign(txSignBytes(tx)) result, err := postTx(*nodeURL, tx) if err != nil { log.Fatalf("submit tx: %v", err) } fmt.Printf("Blocked contact from %s\n result: %s\n", *from, result) } // --- contacts --- func cmdContacts(args []string) { fs := flag.NewFlagSet("contacts", flag.ExitOnError) keyFile := fs.String("key", "key.json", "identity file") nodeURL := fs.String("node", "http://localhost:8080", "node API URL") if err := fs.Parse(args); err != nil { log.Fatal(err) } id := loadKey(*keyFile) resp, err := http.Get(*nodeURL + "/relay/contacts?pub=" + id.PubKeyHex()) if err != nil { log.Fatalf("fetch contacts: %v", err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { log.Fatalf("node returned %d: %s", resp.StatusCode, body) } var result struct { Count int `json:"count"` Contacts []blockchain.ContactInfo `json:"contacts"` } if err := json.Unmarshal(body, &result); err != nil { log.Fatalf("parse response: %v", err) } if result.Count == 0 { fmt.Println("No contact requests.") return } fmt.Printf("Contact requests (%d):\n", result.Count) for _, c := range result.Contacts { ts := time.Unix(c.CreatedAt, 0).UTC().Format(time.RFC3339) fmt.Printf(" from: %s\n addr: %s\n status: %s\n fee: %s\n intro: %q\n tx: %s\n at: %s\n\n", c.RequesterPub, c.RequesterAddr, c.Status, economy.FormatTokens(c.FeeUT), c.Intro, c.TxID, ts) } } // --- add-validator --- func cmdAddValidator(args []string) { fs := flag.NewFlagSet("add-validator", flag.ExitOnError) keyFile := fs.String("key", "key.json", "caller identity file (must already be a validator)") nodeURL := fs.String("node", "http://localhost:8080", "node API URL") target := fs.String("target", "", "pub key of the new validator to add") reason := fs.String("reason", "", "optional reason") // Multi-sig: on a chain with >1 validators the chain requires ⌈2/3⌉ // approvals. Use `client admit-sign --target X` on each other validator // to get their signature, then pass them here as `pubkey:sig_hex` pairs. cosigsFlag := fs.String("cosigs", "", "comma-separated cosignatures from other validators, each `pubkey:signature_hex`. "+ "Required when current validator set has more than 1 member.") if err := fs.Parse(args); err != nil { log.Fatal(err) } if *target == "" { log.Fatal("--target is required") } cosigs, err := parseCoSigs(*cosigsFlag) if err != nil { log.Fatalf("--cosigs: %v", err) } id := loadKey(*keyFile) payload, _ := json.Marshal(blockchain.AddValidatorPayload{ Reason: *reason, CoSignatures: cosigs, }) tx := &blockchain.Transaction{ ID: fmt.Sprintf("tx-%d", time.Now().UnixNano()), Type: blockchain.EventAddValidator, From: id.PubKeyHex(), To: *target, Fee: blockchain.MinFee, Payload: payload, Timestamp: time.Now().UTC(), } tx.Signature = id.Sign(txSignBytes(tx)) result, err := postTx(*nodeURL, tx) if err != nil { log.Fatalf("submit tx: %v", err) } fmt.Printf("ADD_VALIDATOR submitted: added %s (cosigs=%d)\n result: %s\n", *target, len(cosigs), result) } // cmdAdmitSign produces a signature that a current validator hands over // off-chain to the operator assembling the ADD_VALIDATOR tx. Prints // // : // // ready to drop into `--cosigs`. The signer never broadcasts anything // themselves — assembly happens at the submitter. func cmdAdmitSign(args []string) { fs := flag.NewFlagSet("admit-sign", flag.ExitOnError) keyFile := fs.String("key", "key.json", "your validator key file") target := fs.String("target", "", "candidate pubkey you are approving") if err := fs.Parse(args); err != nil { log.Fatal(err) } if *target == "" { log.Fatal("--target is required") } if len(*target) != 64 { log.Fatalf("--target must be a 64-char hex pubkey, got %d chars", len(*target)) } id := loadKey(*keyFile) sig := ed25519.Sign(id.PrivKey, blockchain.AdmitDigest(*target)) fmt.Printf("%s:%s\n", id.PubKeyHex(), hex.EncodeToString(sig)) } // parseCoSigs decodes the `--cosigs pub1:sig1,pub2:sig2,...` format into // the payload-friendly slice. Each entry validates: pubkey is 64 hex chars, // signature decodes cleanly. func parseCoSigs(raw string) ([]blockchain.ValidatorCoSig, error) { raw = strings.TrimSpace(raw) if raw == "" { return nil, nil } var out []blockchain.ValidatorCoSig for _, entry := range strings.Split(raw, ",") { entry = strings.TrimSpace(entry) if entry == "" { continue } parts := strings.SplitN(entry, ":", 2) if len(parts) != 2 { return nil, fmt.Errorf("bad cosig %q: expected pubkey:sig_hex", entry) } pub := strings.TrimSpace(parts[0]) if len(pub) != 64 { return nil, fmt.Errorf("bad cosig pubkey %q: expected 64 hex chars", pub) } sig, err := hex.DecodeString(strings.TrimSpace(parts[1])) if err != nil || len(sig) != 64 { return nil, fmt.Errorf("bad cosig signature for %s: %w", pub, err) } out = append(out, blockchain.ValidatorCoSig{PubKey: pub, Signature: sig}) } return out, nil } // --- remove-validator --- func cmdRemoveValidator(args []string) { fs := flag.NewFlagSet("remove-validator", flag.ExitOnError) keyFile := fs.String("key", "key.json", "caller identity file (must be an active validator)") nodeURL := fs.String("node", "http://localhost:8080", "node API URL") target := fs.String("target", "", "pub key of the validator to remove (omit for self-removal)") reason := fs.String("reason", "", "optional reason") if err := fs.Parse(args); err != nil { log.Fatal(err) } id := loadKey(*keyFile) targetPub := *target if targetPub == "" { targetPub = id.PubKeyHex() // self-removal } payload, _ := json.Marshal(blockchain.RemoveValidatorPayload{Reason: *reason}) tx := &blockchain.Transaction{ ID: fmt.Sprintf("tx-%d", time.Now().UnixNano()), Type: blockchain.EventRemoveValidator, From: id.PubKeyHex(), To: targetPub, Fee: blockchain.MinFee, Payload: payload, Timestamp: time.Now().UTC(), } tx.Signature = id.Sign(txSignBytes(tx)) result, err := postTx(*nodeURL, tx) if err != nil { log.Fatalf("submit tx: %v", err) } fmt.Printf("REMOVE_VALIDATOR submitted: removed %s\n result: %s\n", targetPub, result) } // --- send-msg --- func cmdSendMsg(args []string) { fs := flag.NewFlagSet("send-msg", flag.ExitOnError) keyFile := fs.String("key", "key.json", "sender identity file") nodeURL := fs.String("node", "http://localhost:8080", "node API URL") to := fs.String("to", "", "recipient: hex pubkey, DC address, or @username") msg := fs.String("msg", "", "plaintext message to send") registry := fs.String("registry", "", "username_registry contract ID for @username resolution") if err := fs.Parse(args); err != nil { log.Fatal(err) } if *to == "" { log.Fatal("--to is required") } if *msg == "" { log.Fatal("--msg is required") } // Strip leading @ from username if present toResolved := strings.TrimPrefix(*to, "@") // Resolve recipient Ed25519 pub key and get their X25519 pub key recipientPub, err := resolveRecipientPubKey(*nodeURL, toResolved, *registry) if err != nil { log.Fatalf("resolve recipient: %v", err) } info, err := fetchIdentityInfo(*nodeURL, recipientPub) if err != nil { log.Fatalf("fetch identity: %v", err) } if info.X25519Pub == "" { log.Fatalf("recipient %s has not published an X25519 key (not registered)", recipientPub) } x25519Bytes, err := hex.DecodeString(info.X25519Pub) if err != nil || len(x25519Bytes) != 32 { log.Fatalf("invalid x25519 pub key from node") } var recipX25519 [32]byte copy(recipX25519[:], x25519Bytes) // Load sender identity and build relay KeyPair id := loadKey(*keyFile) senderKP := &relay.KeyPair{Pub: id.X25519Pub, Priv: id.X25519Priv} // Seal the message env, err := relay.Seal(senderKP, id, recipX25519, []byte(*msg), 0, time.Now().Unix()) if err != nil { log.Fatalf("seal message: %v", err) } // Broadcast via node type broadcastReq struct { Envelope *relay.Envelope `json:"envelope"` } data, _ := json.Marshal(broadcastReq{Envelope: env}) resp, err := http.Post(*nodeURL+"/relay/broadcast", "application/json", bytes.NewReader(data)) if err != nil { log.Fatalf("broadcast: %v", err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { log.Fatalf("node returned %d: %s", resp.StatusCode, body) } fmt.Printf("Message sent!\n envelope id: %s\n to: %s\n", env.ID, recipientPub) } // --- inbox --- func cmdInbox(args []string) { fs := flag.NewFlagSet("inbox", flag.ExitOnError) keyFile := fs.String("key", "key.json", "recipient identity file") nodeURL := fs.String("node", "http://localhost:8080", "node API URL") limit := fs.Int("limit", 20, "max messages to fetch") deleteAfter := fs.Bool("delete", false, "delete messages after reading") if err := fs.Parse(args); err != nil { log.Fatal(err) } id := loadKey(*keyFile) kp := &relay.KeyPair{Pub: id.X25519Pub, Priv: id.X25519Priv} url := fmt.Sprintf("%s/relay/inbox?pub=%s&limit=%d", *nodeURL, id.X25519PubHex(), *limit) resp, err := http.Get(url) if err != nil { log.Fatalf("fetch inbox: %v", err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { log.Fatalf("node returned %d: %s", resp.StatusCode, body) } var result struct { Count int `json:"count"` HasMore bool `json:"has_more"` Items []struct { ID string `json:"id"` SenderPub string `json:"sender_pub"` SentAtHuman string `json:"sent_at_human"` Nonce []byte `json:"nonce"` Ciphertext []byte `json:"ciphertext"` } `json:"items"` } if err := json.Unmarshal(body, &result); err != nil { log.Fatalf("parse response: %v", err) } if result.Count == 0 { fmt.Println("Inbox is empty.") return } fmt.Printf("Inbox (%d messages%s):\n\n", result.Count, func() string { if result.HasMore { return ", more available" } return "" }()) decrypted := 0 for _, item := range result.Items { env := &relay.Envelope{ ID: item.ID, SenderPub: item.SenderPub, RecipientPub: id.X25519PubHex(), Nonce: item.Nonce, Ciphertext: item.Ciphertext, } msg, err := relay.Open(kp, env) if err != nil { fmt.Printf(" [%s] from %s at %s\n (could not decrypt: %v)\n\n", item.ID, item.SenderPub, item.SentAtHuman, err) continue } decrypted++ fmt.Printf(" [%s]\n from: %s\n at: %s\n msg: %s\n\n", item.ID, item.SenderPub, item.SentAtHuman, string(msg)) if *deleteAfter { delURL := fmt.Sprintf("%s/relay/inbox/%s?pub=%s", *nodeURL, item.ID, id.X25519PubHex()) req, _ := http.NewRequest(http.MethodDelete, delURL, nil) delResp, err := http.DefaultClient.Do(req) if err == nil { delResp.Body.Close() } } } fmt.Printf("Decrypted %d/%d messages.\n", decrypted, result.Count) } // --- deploy-contract --- func cmdDeployContract(args []string) { fs := flag.NewFlagSet("deploy-contract", flag.ExitOnError) keyFile := fs.String("key", "key.json", "deployer identity file") nodeURL := fs.String("node", "http://localhost:8080", "node API URL") wasmFile := fs.String("wasm", "", "path to .wasm binary") abiFile := fs.String("abi", "", "path to ABI JSON file") if err := fs.Parse(args); err != nil { log.Fatal(err) } if *wasmFile == "" { log.Fatal("--wasm is required") } if *abiFile == "" { log.Fatal("--abi is required") } wasmBytes, err := os.ReadFile(*wasmFile) if err != nil { log.Fatalf("read wasm: %v", err) } abiBytes, err := os.ReadFile(*abiFile) if err != nil { log.Fatalf("read abi: %v", err) } id := loadKey(*keyFile) payload, _ := json.Marshal(blockchain.DeployContractPayload{ WASMBase64: base64.StdEncoding.EncodeToString(wasmBytes), ABIJson: string(abiBytes), }) tx := &blockchain.Transaction{ ID: fmt.Sprintf("tx-%d", time.Now().UnixNano()), Type: blockchain.EventDeployContract, From: id.PubKeyHex(), Fee: blockchain.MinDeployFee, Payload: payload, Memo: "Deploy contract", Timestamp: time.Now().UTC(), } tx.Signature = id.Sign(txSignBytes(tx)) // Compute contract_id locally (deterministic: sha256(deployerPub || wasmBytes)[:16]). h := sha256.New() h.Write([]byte(id.PubKeyHex())) h.Write(wasmBytes) contractID := hex.EncodeToString(h.Sum(nil)[:16]) result, err := postTx(*nodeURL, tx) if err != nil { log.Fatalf("submit tx: %v", err) } fmt.Printf("DEPLOY_CONTRACT submitted\n contract_id: %s\n tx: %s\n", contractID, result) } // --- call-contract --- func cmdCallContract(args []string) { fs := flag.NewFlagSet("call-contract", flag.ExitOnError) keyFile := fs.String("key", "key.json", "caller identity file") nodeURL := fs.String("node", "http://localhost:8080", "node API URL") contractID := fs.String("contract", "", "contract ID (hex)") method := fs.String("method", "", "method name to call") argsFlag := fs.String("args", "", `JSON array of arguments, e.g. '["alice"]' or '[42]'`) gas := fs.Uint64("gas", 1_000_000, "gas limit") // --amount is the payment attached to the call, visible as tx.Amount. // Contracts that require payment (e.g. username_registry.register costs // 10 000 µT) enforce exact values; unused for read-only methods. amount := fs.Uint64("amount", 0, "µT to attach to the call (for payable methods)") if err := fs.Parse(args); err != nil { log.Fatal(err) } if *contractID == "" { log.Fatal("--contract is required") } if *method == "" { log.Fatal("--method is required") } // Validate --args is a JSON array if provided. argsJSON := "" if *argsFlag != "" { var check []interface{} if err := json.Unmarshal([]byte(*argsFlag), &check); err != nil { log.Fatalf("--args must be a JSON array: %v", err) } argsJSON = *argsFlag } id := loadKey(*keyFile) payload, _ := json.Marshal(blockchain.CallContractPayload{ ContractID: *contractID, Method: *method, ArgsJSON: argsJSON, GasLimit: *gas, }) tx := &blockchain.Transaction{ ID: fmt.Sprintf("tx-%d", time.Now().UnixNano()), Type: blockchain.EventCallContract, From: id.PubKeyHex(), Amount: *amount, // paid to contract via tx.Amount Fee: blockchain.MinFee, Payload: payload, Memo: fmt.Sprintf("Call %s.%s", (*contractID)[:min(8, len(*contractID))], *method), Timestamp: time.Now().UTC(), } tx.Signature = id.Sign(txSignBytes(tx)) result, err := postTx(*nodeURL, tx) if err != nil { log.Fatalf("submit tx: %v", err) } fmt.Printf("CALL_CONTRACT submitted\n contract: %s\n method: %s\n tx: %s\n", *contractID, *method, result) } // --- helpers --- type keyJSON struct { PubKey string `json:"pub_key"` PrivKey string `json:"priv_key"` X25519Pub string `json:"x25519_pub,omitempty"` X25519Priv string `json:"x25519_priv,omitempty"` } func saveKey(path string, id *identity.Identity) { kj := keyJSON{ PubKey: id.PubKeyHex(), PrivKey: id.PrivKeyHex(), X25519Pub: id.X25519PubHex(), X25519Priv: id.X25519PrivHex(), } data, _ := json.MarshalIndent(kj, "", " ") if err := os.WriteFile(path, data, 0600); err != nil { log.Fatalf("save key: %v", err) } } func loadKey(path string) *identity.Identity { data, err := os.ReadFile(path) if err != nil { log.Fatalf("read key file %s: %v", path, err) } var kj keyJSON if err := json.Unmarshal(data, &kj); err != nil { log.Fatalf("parse key file: %v", err) } id, err := identity.FromHexFull(kj.PubKey, kj.PrivKey, kj.X25519Pub, kj.X25519Priv) if err != nil { log.Fatalf("load identity: %v", err) } // If X25519 keys were missing in file (old format), backfill and re-save. if kj.X25519Pub == "" { saveKey(path, id) } return id } func resolveRecipientPubKey(nodeURL, input string, registryID ...string) (string, error) { // DC address (26-char Base58Check starting with "DC") if len(input) == 26 && input[:2] == "DC" { resp, err := http.Get(nodeURL + "/api/address/" + input) if err != nil { return "", err } defer resp.Body.Close() var result struct { PubKey string `json:"pub_key"` Error string `json:"error"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return "", err } if result.Error != "" { return "", fmt.Errorf("%s", result.Error) } fmt.Printf("Resolved %s → %s\n", input, result.PubKey) return result.PubKey, nil } // Already a 64-char hex pubkey if isHexPubKey(input) { return input, nil } // Try username_registry resolution if len(registryID) > 0 && registryID[0] != "" { pub, err := resolveViaRegistry(nodeURL, registryID[0], input) if err != nil { return "", fmt.Errorf("resolve username %q: %w", input, err) } return pub, nil } // Return as-is (caller's problem) return input, nil } // isHexPubKey reports whether s looks like a 64-char hex Ed25519 public key. func isHexPubKey(s string) bool { if len(s) != 64 { return false } for _, c := range s { if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { return false } } return true } // resolveViaRegistry looks up a username in the username_registry contract. // The registry stores state["name:"] = raw bytes of the owner pubkey. func resolveViaRegistry(nodeURL, registryID, username string) (string, error) { url := nodeURL + "/api/contracts/" + registryID + "/state/name:" + username resp, err := http.Get(url) if err != nil { return "", err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("registry lookup failed (%d): %s", resp.StatusCode, body) } var result struct { ValueHex string `json:"value_hex"` Error string `json:"error"` } if err := json.Unmarshal(body, &result); err != nil { return "", fmt.Errorf("parse registry response: %w", err) } if result.Error != "" || result.ValueHex == "" { return "", fmt.Errorf("username %q not registered", username) } // value_hex is the hex-encoding of the raw pubkey bytes stored in state. // The pubkey was stored as a plain ASCII hex string, so decode hex → string. pubBytes, err := hex.DecodeString(result.ValueHex) if err != nil { return "", fmt.Errorf("decode registry value: %w", err) } pubkey := string(pubBytes) fmt.Printf("Resolved @%s → %s...\n", username, pubkey[:min(8, len(pubkey))]) return pubkey, nil } func fetchIdentityInfo(nodeURL, pubKey string) (*blockchain.IdentityInfo, error) { resp, err := http.Get(nodeURL + "/api/identity/" + pubKey) if err != nil { return nil, err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("node returned %d: %s", resp.StatusCode, body) } var info blockchain.IdentityInfo if err := json.Unmarshal(body, &info); err != nil { return nil, err } return &info, nil } func postTx(nodeURL string, tx *blockchain.Transaction) (string, error) { data, err := json.Marshal(tx) if err != nil { return "", err } resp, err := http.Post(nodeURL+"/api/tx", "application/json", bytes.NewReader(data)) if err != nil { return "", fmt.Errorf("connect to node: %w", err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("node returned %d: %s", resp.StatusCode, body) } return string(body), nil } func parseTokenAmount(s string) (uint64, error) { if n, err := strconv.ParseUint(s, 10, 64); err == nil { return n * blockchain.Token, nil } f, err := strconv.ParseFloat(s, 64) if err != nil { return 0, err } return uint64(f * float64(blockchain.Token)), nil } func buildTransferTx(id *identity.Identity, to string, amount uint64, memo string) *blockchain.Transaction { payload, _ := json.Marshal(blockchain.TransferPayload{Memo: memo}) tx := &blockchain.Transaction{ ID: fmt.Sprintf("tx-%d", time.Now().UnixNano()), Type: blockchain.EventTransfer, From: id.PubKeyHex(), To: to, Amount: amount, Fee: blockchain.MinFee, Memo: memo, Payload: payload, Timestamp: time.Now().UTC(), } tx.Signature = id.Sign(txSignBytes(tx)) return tx } // txSignBytes returns canonical bytes for transaction signing. // Delegates to the exported identity.TxSignBytes so both the client CLI and // the node use a single authoritative implementation. func txSignBytes(tx *blockchain.Transaction) []byte { return identity.TxSignBytes(tx) } // --- stake --- func cmdStake(args []string) { fs := flag.NewFlagSet("stake", flag.ExitOnError) keyFile := fs.String("key", "key.json", "identity file") amountStr := fs.String("amount", "", "amount to stake in T (e.g. 1000)") nodeURL := fs.String("node", "http://localhost:8080", "node API URL") if err := fs.Parse(args); err != nil { log.Fatal(err) } if *amountStr == "" { log.Fatal("--amount is required") } amount, err := parseTokenAmount(*amountStr) if err != nil { log.Fatalf("invalid amount: %v", err) } id := loadKey(*keyFile) payload, _ := json.Marshal(blockchain.StakePayload{}) tx := &blockchain.Transaction{ ID: fmt.Sprintf("tx-%d", time.Now().UnixNano()), Type: blockchain.EventStake, From: id.PubKeyHex(), Amount: amount, Fee: blockchain.MinFee, Memo: "stake", Payload: payload, Timestamp: time.Now().UTC(), } tx.Signature = id.Sign(txSignBytes(tx)) result, err := postTx(*nodeURL, tx) if err != nil { log.Fatalf("submit stake: %v", err) } fmt.Printf("Stake submitted: %s\ntx_id: %s\n", result, tx.ID) } // --- unstake --- func cmdUnstake(args []string) { fs := flag.NewFlagSet("unstake", flag.ExitOnError) keyFile := fs.String("key", "key.json", "identity file") nodeURL := fs.String("node", "http://localhost:8080", "node API URL") if err := fs.Parse(args); err != nil { log.Fatal(err) } id := loadKey(*keyFile) payload, _ := json.Marshal(blockchain.UnstakePayload{}) tx := &blockchain.Transaction{ ID: fmt.Sprintf("tx-%d", time.Now().UnixNano()), Type: blockchain.EventUnstake, From: id.PubKeyHex(), Fee: blockchain.MinFee, Memo: "unstake", Payload: payload, Timestamp: time.Now().UTC(), } tx.Signature = id.Sign(txSignBytes(tx)) result, err := postTx(*nodeURL, tx) if err != nil { log.Fatalf("submit unstake: %v", err) } fmt.Printf("Unstake submitted: %s\ntx_id: %s\n", result, tx.ID) } // --- wait-tx --- func cmdWaitTx(args []string) { fs := flag.NewFlagSet("wait-tx", flag.ExitOnError) txID := fs.String("id", "", "transaction ID to wait for") timeout := fs.Int("timeout", 60, "timeout in seconds") nodeURL := fs.String("node", "http://localhost:8080", "node API URL") if err := fs.Parse(args); err != nil { log.Fatal(err) } if *txID == "" { log.Fatal("--id is required") } ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*timeout)*time.Second) defer cancel() // Check if already confirmed. if rec := fetchTxRecord(*nodeURL, *txID); rec != nil { printTxRecord(rec) return } // Subscribe to SSE and wait for the tx event. req, err := http.NewRequestWithContext(ctx, "GET", *nodeURL+"/api/events", nil) if err != nil { log.Fatalf("create SSE request: %v", err) } resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatalf("connect to SSE: %v", err) } defer resp.Body.Close() fmt.Printf("Waiting for tx %s (timeout %ds)…\n", *txID, *timeout) scanner := bufio.NewScanner(resp.Body) var eventType string for scanner.Scan() { line := scanner.Text() if strings.HasPrefix(line, "event:") { eventType = strings.TrimSpace(strings.TrimPrefix(line, "event:")) continue } if strings.HasPrefix(line, "data:") && eventType == "tx" { data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) var evt struct { ID string `json:"id"` } if err := json.Unmarshal([]byte(data), &evt); err == nil && evt.ID == *txID { // Confirmed — fetch full record. if rec := fetchTxRecord(*nodeURL, *txID); rec != nil { printTxRecord(rec) } else { fmt.Printf("tx_id: %s — confirmed (block info pending)\n", *txID) } return } } if line == "" { eventType = "" } } if ctx.Err() != nil { log.Fatalf("timeout: tx %s not confirmed within %ds", *txID, *timeout) } } func fetchTxRecord(nodeURL, txID string) *blockchain.TxRecord { resp, err := http.Get(nodeURL + "/api/tx/" + txID) if err != nil || resp.StatusCode != http.StatusOK { return nil } defer resp.Body.Close() var rec blockchain.TxRecord if err := json.NewDecoder(resp.Body).Decode(&rec); err != nil { return nil } if rec.Tx == nil { return nil } return &rec } func printTxRecord(rec *blockchain.TxRecord) { fmt.Printf("Confirmed:\n tx_id: %s\n type: %s\n block: %d (%s)\n block_hash: %s\n", rec.Tx.ID, rec.Tx.Type, rec.BlockIndex, rec.BlockTime.UTC().Format(time.RFC3339), rec.BlockHash) if rec.GasUsed > 0 { fmt.Printf(" gas_used: %d\n", rec.GasUsed) } } // --- issue-token --- func cmdIssueToken(args []string) { fs := flag.NewFlagSet("issue-token", flag.ExitOnError) keyFile := fs.String("key", "key.json", "identity file") name := fs.String("name", "", "token name (e.g. \"My Token\")") symbol := fs.String("symbol", "", "ticker symbol (e.g. MTK)") decimals := fs.Uint("decimals", 6, "decimal places") supply := fs.Uint64("supply", 0, "initial supply in base units") nodeURL := fs.String("node", "http://localhost:8080", "node API URL") if err := fs.Parse(args); err != nil { log.Fatal(err) } if *name == "" || *symbol == "" { log.Fatal("--name and --symbol are required") } if *supply == 0 { log.Fatal("--supply must be > 0") } id := loadKey(*keyFile) payload, _ := json.Marshal(blockchain.IssueTokenPayload{ Name: *name, Symbol: *symbol, Decimals: uint8(*decimals), TotalSupply: *supply, }) tx := &blockchain.Transaction{ ID: fmt.Sprintf("tx-%d", time.Now().UnixNano()), Type: blockchain.EventIssueToken, From: id.PubKeyHex(), Fee: blockchain.MinIssueTokenFee, Memo: fmt.Sprintf("Issue token %s", *symbol), Payload: payload, Timestamp: time.Now().UTC(), } tx.Signature = id.Sign(txSignBytes(tx)) result, err := postTx(*nodeURL, tx) if err != nil { log.Fatalf("submit issue-token: %v", err) } fmt.Printf("Token issue submitted: %s\ntx_id: %s\n", result, tx.ID) // Derive and print the token ID so user knows it immediately. h := sha256.Sum256([]byte("token:" + id.PubKeyHex() + ":" + *symbol)) tokenID := hex.EncodeToString(h[:16]) fmt.Printf("token_id: %s (pending confirmation)\n", tokenID) } // --- transfer-token --- func cmdTransferToken(args []string) { fs := flag.NewFlagSet("transfer-token", flag.ExitOnError) keyFile := fs.String("key", "key.json", "identity file") tokenID := fs.String("token", "", "token ID") to := fs.String("to", "", "recipient pubkey") amount := fs.Uint64("amount", 0, "amount in base units") nodeURL := fs.String("node", "http://localhost:8080", "node API URL") if err := fs.Parse(args); err != nil { log.Fatal(err) } if *tokenID == "" || *to == "" || *amount == 0 { log.Fatal("--token, --to, and --amount are required") } id := loadKey(*keyFile) payload, _ := json.Marshal(blockchain.TransferTokenPayload{ TokenID: *tokenID, Amount: *amount, }) tx := &blockchain.Transaction{ ID: fmt.Sprintf("tx-%d", time.Now().UnixNano()), Type: blockchain.EventTransferToken, From: id.PubKeyHex(), To: *to, Fee: blockchain.MinFee, Payload: payload, Timestamp: time.Now().UTC(), } tx.Signature = id.Sign(txSignBytes(tx)) result, err := postTx(*nodeURL, tx) if err != nil { log.Fatalf("submit transfer-token: %v", err) } fmt.Printf("Token transfer submitted: %s\ntx_id: %s\n", result, tx.ID) } // --- burn-token --- func cmdBurnToken(args []string) { fs := flag.NewFlagSet("burn-token", flag.ExitOnError) keyFile := fs.String("key", "key.json", "identity file") tokenID := fs.String("token", "", "token ID") amount := fs.Uint64("amount", 0, "amount to burn in base units") nodeURL := fs.String("node", "http://localhost:8080", "node API URL") if err := fs.Parse(args); err != nil { log.Fatal(err) } if *tokenID == "" || *amount == 0 { log.Fatal("--token and --amount are required") } id := loadKey(*keyFile) payload, _ := json.Marshal(blockchain.BurnTokenPayload{ TokenID: *tokenID, Amount: *amount, }) tx := &blockchain.Transaction{ ID: fmt.Sprintf("tx-%d", time.Now().UnixNano()), Type: blockchain.EventBurnToken, From: id.PubKeyHex(), Fee: blockchain.MinFee, Payload: payload, Timestamp: time.Now().UTC(), } tx.Signature = id.Sign(txSignBytes(tx)) result, err := postTx(*nodeURL, tx) if err != nil { log.Fatalf("submit burn-token: %v", err) } fmt.Printf("Token burn submitted: %s\ntx_id: %s\n", result, tx.ID) } // --- token-balance --- func cmdTokenBalance(args []string) { fs := flag.NewFlagSet("token-balance", flag.ExitOnError) tokenID := fs.String("token", "", "token ID") address := fs.String("address", "", "pubkey or DC address (omit to list all holders)") nodeURL := fs.String("node", "http://localhost:8080", "node API URL") if err := fs.Parse(args); err != nil { log.Fatal(err) } if *tokenID == "" { log.Fatal("--token is required") } if *address != "" { resp, err := http.Get(*nodeURL + "/api/tokens/" + *tokenID + "/balance/" + *address) if err != nil { log.Fatalf("query balance: %v", err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) fmt.Println(string(body)) return } // No address — show token metadata. resp, err := http.Get(*nodeURL + "/api/tokens/" + *tokenID) if err != nil { log.Fatalf("query token: %v", err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) fmt.Println(string(body)) } // --- mint-nft --- func cmdMintNFT(args []string) { fs := flag.NewFlagSet("mint-nft", flag.ExitOnError) keyFile := fs.String("key", "key.json", "identity file") name := fs.String("name", "", "NFT name") desc := fs.String("desc", "", "description") uri := fs.String("uri", "", "metadata URI (IPFS, https, etc.)") attrs := fs.String("attrs", "", "JSON attributes e.g. '{\"trait\":\"value\"}'") nodeURL := fs.String("node", "http://localhost:8080", "node API URL") if err := fs.Parse(args); err != nil { log.Fatal(err) } if *name == "" { log.Fatal("--name is required") } id := loadKey(*keyFile) txID := fmt.Sprintf("tx-%d", time.Now().UnixNano()) payload, _ := json.Marshal(blockchain.MintNFTPayload{ Name: *name, Description: *desc, URI: *uri, Attributes: *attrs, }) tx := &blockchain.Transaction{ ID: txID, Type: blockchain.EventMintNFT, From: id.PubKeyHex(), Fee: blockchain.MinMintNFTFee, Memo: fmt.Sprintf("Mint NFT: %s", *name), Payload: payload, Timestamp: time.Now().UTC(), } tx.Signature = id.Sign(txSignBytes(tx)) result, err := postTx(*nodeURL, tx) if err != nil { log.Fatalf("submit mint-nft: %v", err) } // Derive NFT ID so user sees it immediately. h := sha256.Sum256([]byte("nft:" + id.PubKeyHex() + ":" + txID)) nftID := hex.EncodeToString(h[:16]) fmt.Printf("NFT mint submitted: %s\ntx_id: %s\nnft_id: %s (pending confirmation)\n", result, txID, nftID) } // --- transfer-nft --- func cmdTransferNFT(args []string) { fs := flag.NewFlagSet("transfer-nft", flag.ExitOnError) keyFile := fs.String("key", "key.json", "identity file") nftID := fs.String("nft", "", "NFT ID") to := fs.String("to", "", "recipient pubkey") nodeURL := fs.String("node", "http://localhost:8080", "node API URL") if err := fs.Parse(args); err != nil { log.Fatal(err) } if *nftID == "" || *to == "" { log.Fatal("--nft and --to are required") } id := loadKey(*keyFile) payload, _ := json.Marshal(blockchain.TransferNFTPayload{NFTID: *nftID}) tx := &blockchain.Transaction{ ID: fmt.Sprintf("tx-%d", time.Now().UnixNano()), Type: blockchain.EventTransferNFT, From: id.PubKeyHex(), To: *to, Fee: blockchain.MinFee, Payload: payload, Timestamp: time.Now().UTC(), } tx.Signature = id.Sign(txSignBytes(tx)) result, err := postTx(*nodeURL, tx) if err != nil { log.Fatalf("submit transfer-nft: %v", err) } fmt.Printf("NFT transfer submitted: %s\ntx_id: %s\n", result, tx.ID) } // --- burn-nft --- func cmdBurnNFT(args []string) { fs := flag.NewFlagSet("burn-nft", flag.ExitOnError) keyFile := fs.String("key", "key.json", "identity file") nftID := fs.String("nft", "", "NFT ID") nodeURL := fs.String("node", "http://localhost:8080", "node API URL") if err := fs.Parse(args); err != nil { log.Fatal(err) } if *nftID == "" { log.Fatal("--nft is required") } id := loadKey(*keyFile) payload, _ := json.Marshal(blockchain.BurnNFTPayload{NFTID: *nftID}) tx := &blockchain.Transaction{ ID: fmt.Sprintf("tx-%d", time.Now().UnixNano()), Type: blockchain.EventBurnNFT, From: id.PubKeyHex(), Fee: blockchain.MinFee, Payload: payload, Timestamp: time.Now().UTC(), } tx.Signature = id.Sign(txSignBytes(tx)) result, err := postTx(*nodeURL, tx) if err != nil { log.Fatalf("submit burn-nft: %v", err) } fmt.Printf("NFT burn submitted: %s\ntx_id: %s\n", result, tx.ID) } // --- nft-info --- func cmdNFTInfo(args []string) { fs := flag.NewFlagSet("nft-info", flag.ExitOnError) nftID := fs.String("nft", "", "NFT ID (omit to list by owner)") owner := fs.String("owner", "", "owner pubkey (list NFTs for this address)") nodeURL := fs.String("node", "http://localhost:8080", "node API URL") if err := fs.Parse(args); err != nil { log.Fatal(err) } var url string if *owner != "" { url = *nodeURL + "/api/nfts/owner/" + *owner } else if *nftID != "" { url = *nodeURL + "/api/nfts/" + *nftID } else { url = *nodeURL + "/api/nfts" } resp, err := http.Get(url) if err != nil { log.Fatalf("query NFT: %v", err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) fmt.Println(string(body)) }