Files
dchain/cmd/client/main.go
vsecoder 7e7393e4f8 chore: initial commit for v0.0.1
DChain single-node blockchain + React Native messenger client.

Core:
- PBFT consensus with multi-sig validator admission + equivocation slashing
- BadgerDB + schema migration scaffold (CurrentSchemaVersion=0)
- libp2p gossipsub (tx/v1, blocks/v1, relay/v1, version/v1)
- Native Go contracts (username_registry) alongside WASM (wazero)
- WebSocket gateway with topic-based fanout + Ed25519-nonce auth
- Relay mailbox with NaCl envelope encryption (X25519 + Ed25519)
- Prometheus /metrics, per-IP rate limit, body-size cap

Deployment:
- Single-node compose (deploy/single/) with Caddy TLS + optional Prometheus
- 3-node dev compose (docker-compose.yml) with mocked internet topology
- 3-validator prod compose (deploy/prod/) for federation
- Auto-update from Gitea via /api/update-check + systemd timer
- Build-time version injection (ldflags → node --version)
- UI / Swagger toggle flags (DCHAIN_DISABLE_UI, DCHAIN_DISABLE_SWAGGER)

Client (client-app/):
- Expo / React Native / NativeWind
- E2E NaCl encryption, typing indicator, contact requests
- Auto-discovery of canonical contracts, chain_id aware, WS reconnect on node switch

Documentation:
- README.md, CHANGELOG.md, CONTEXT.md
- deploy/single/README.md with 6 operator scenarios
- deploy/UPDATE_STRATEGY.md with 4-layer forward-compat design
- docs/contracts/*.md per contract
2026-04-17 14:16:44 +03:00

1565 lines
48 KiB
Go

// cmd/client — CLI client for interacting with the chain.
//
// Commands:
//
// client keygen --out <file>
// client register --key <file> --nick <name>
// client balance --key <file> --db <dir>
// client transfer --key <file> --to <pubkey|addr> --amount <T>
// client info --db <dir>
// client request-contact --key <file> --to <pubkey|addr> --fee <T> [--intro "text"] --node <url>
// client accept-contact --key <file> --from <pubkey> --node <url>
// client block-contact --key <file> --from <pubkey> --node <url>
// client contacts --key <file> --node <url>
// client send-msg --key <file> --to <pubkey|addr> --msg "text" --node <url>
// client inbox --key <file> --node <url>
// client deploy-contract --key <file> --wasm <file> --abi <file> --node <url>
// client call-contract --key <file> --contract <id> --method <name> [--args '["v"]'] [--gas N] --node <url>
// client stake --key <file> --amount <T> --node <url>
// client unstake --key <file> --node <url>
// client wait-tx --id <txid> [--timeout <secs>] --node <url>
// client issue-token --key <file> --name <name> --symbol <SYM> [--decimals N] --supply <amount> --node <url>
// client transfer-token --key <file> --token <id> --to <pubkey> --amount <amount> --node <url>
// client burn-token --key <file> --token <id> --amount <amount> --node <url>
// client token-balance --token <id> [--address <pubkey>] --node <url>
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 <command> [flags]
Commands:
keygen --out <file> Generate a new identity
register --key <file> --nick <name> Build a REGISTER_KEY transaction
balance --key <file> --db <dir> Show token balance
transfer --key <file> --to <pubkey> --amount <T> Send tokens via node API
[--memo "text"] [--node http://localhost:8080]
info --db <dir> Show chain info
request-contact --key <file> --to <pubkey|addr> Send a paid contact request (ICQ-style)
--fee <T> [--intro "text"]
[--node http://localhost:8080]
accept-contact --key <file> --from <pubkey> Accept a contact request
[--node http://localhost:8080]
block-contact --key <file> --from <pubkey> Block a contact request
[--node http://localhost:8080]
contacts --key <file> List incoming contact requests
[--node http://localhost:8080]
add-validator --key <file> --target <pubkey> Add a validator (caller must be a validator)
[--reason "text"] [--node http://localhost:8080]
remove-validator --key <file> --target <pubkey> Remove a validator (or self-remove)
[--reason "text"] [--node http://localhost:8080]
send-msg --key <file> --to <pubkey|addr> Send an encrypted message
--msg "text" [--node http://localhost:8080]
inbox --key <file> Read and decrypt inbox messages
[--node http://localhost:8080] [--limit N]
deploy-contract --key <file> --wasm <file> --abi <file> Deploy a WASM smart contract
[--node http://localhost:8080]
call-contract --key <file> --contract <id> Call a smart contract method
--method <name> [--args '["val",42]'] [--gas N]
[--node http://localhost:8080]
stake --key <file> --amount <T> Lock tokens as validator stake
[--node http://localhost:8080]
unstake --key <file> Release all staked tokens
[--node http://localhost:8080]
wait-tx --id <txid> [--timeout <secs>] Wait for a transaction to be confirmed
[--node http://localhost:8080]
issue-token --key <file> --name <name> Issue a new fungible token
--symbol <SYM> [--decimals N]
--supply <base-units>
[--node http://localhost:8080]
transfer-token --key <file> --token <id> Transfer fungible tokens
--to <pubkey> --amount <base-units>
[--node http://localhost:8080]
burn-token --key <file> --token <id> Burn (destroy) fungible tokens
--amount <base-units>
[--node http://localhost:8080]
token-balance --token <id> [--address <pubkey>] Query token balance
[--node http://localhost:8080]
mint-nft --key <file> --name <name> Mint a new NFT
[--desc "text"] [--uri <url>] [--attrs '{"k":"v"}']
[--node http://localhost:8080]
transfer-nft --key <file> --nft <id> --to <pubkey> Transfer NFT ownership
[--node http://localhost:8080]
burn-nft --key <file> --nft <id> Burn (destroy) an NFT
[--node http://localhost:8080]
nft-info --nft <id> [--owner <pubkey>] 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
//
// <pubkey>:<sig_hex>
//
// 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:<username>"] = 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))
}