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
1565 lines
48 KiB
Go
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))
|
|
}
|