// Command paywatch is the pangolin-pay service: it hands out per-order TRON // receiving addresses (watch-only, derived from an account xpub), watches // TronGrid for confirmed USDT payments, and marks orders paid. It holds NO // private keys — sweeping funds to cold storage is a separate offline step. // // Env: // // PAY_ACCOUNT_XPUB (required) watch-only account xpub, m/44'/195'/0' // PAY_DB SQLite path (default pay.db) // PAY_ADDR HTTP listen addr (default :8090) // PAY_POLL_SECONDS watcher poll interval (default 20) // TRONGRID_BASE TronGrid base URL (default https://api.trongrid.io) // TRONGRID_API_KEY TronGrid API key (recommended) // USDT_CONTRACT TRC20 USDT contract (default mainnet) package main import ( "context" "fmt" "log/slog" "net/http" "os" "os/signal" "strconv" "syscall" "time" "github.com/wangjia/pangolin/pay/internal/httpapi" "github.com/wangjia/pangolin/pay/internal/pay" "github.com/wangjia/pangolin/pay/internal/store" "github.com/wangjia/pangolin/pay/internal/tron" "github.com/wangjia/pangolin/pay/internal/wallet" "github.com/wangjia/pangolin/pay/internal/watcher" ) func env(k, def string) string { if v := os.Getenv(k); v != "" { return v } return def } // selfcheck prints the first N receiving addresses derived from PAY_ACCOUNT_XPUB. // MONEY-CRITICAL: compare these against your own wallet app / iancoleman.io for // the SAME account xpub. If they don't match, the service would hand buyers // addresses you cannot spend — do NOT accept any payment until they match. func selfcheck() { xpub := os.Getenv("PAY_ACCOUNT_XPUB") if xpub == "" { fmt.Fprintln(os.Stderr, "PAY_ACCOUNT_XPUB is required") os.Exit(1) } n := 5 if len(os.Args) > 2 { if v, err := strconv.Atoi(os.Args[2]); err == nil && v > 0 { n = v } } fmt.Println("Derived receiving addresses (compare against your wallet for this xpub):") for i := 0; i < n; i++ { addr, err := wallet.AddressFromAccountXpub(xpub, 0, uint32(i)) if err != nil { fmt.Fprintf(os.Stderr, "derive %d: %v\n", i, err) os.Exit(1) } fmt.Printf(" m/44'/195'/0'/0/%d -> %s\n", i, addr) } } func main() { if len(os.Args) > 1 && os.Args[1] == "selfcheck" { selfcheck() return } log := slog.New(slog.NewJSONHandler(os.Stdout, nil)) xpub := os.Getenv("PAY_ACCOUNT_XPUB") if xpub == "" { log.Error("PAY_ACCOUNT_XPUB is required (watch-only account xpub, m/44'/195'/0')") os.Exit(1) } dbPath := env("PAY_DB", "pay.db") addr := env("PAY_ADDR", ":8090") pollSec, _ := strconv.Atoi(env("PAY_POLL_SECONDS", "20")) if pollSec <= 0 { pollSec = 20 } st, err := store.Open("file:" + dbPath + "?_txlock=immediate") if err != nil { log.Error("open store", "err", err) os.Exit(1) } defer func() { _ = st.Close() }() svc := pay.New(st, pay.Config{AccountXpub: xpub, OrderTTL: 15 * time.Minute}) fetcher := tron.NewClient(env("TRONGRID_BASE", ""), env("USDT_CONTRACT", ""), os.Getenv("TRONGRID_API_KEY")) w := watcher.New(st, fetcher, log) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() go w.Loop(ctx, time.Duration(pollSec)*time.Second) srv := &http.Server{Addr: addr, Handler: httpapi.New(svc), ReadHeaderTimeout: 10 * time.Second} go func() { log.Info("pangolin-pay listening", "addr", addr, "poll_seconds", pollSec) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Error("http server", "err", err) stop() } }() <-ctx.Done() sc, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = srv.Shutdown(sc) log.Info("pangolin-pay stopped") }