payments: call CoinPay REST API directly (drop coinpay CLI dependency)

CreatePremiumCharge/VerifyPremium now POST /payments/create and GET
/payments/:id against the CoinPay API (Bearer COINPAY_API_KEY, business_id =
AGENTBBS_COINPAY_MERCHANT_ID), so the droplet needs no coinpay CLI installed.
The created payment id is stored (store: User.PremiumPayID + premium_pay_id col
+ SetPremiumPayment); ensurePremium verifies that id on a later connect and
grants premium on confirmed/forwarded status. Removed the CLI command-template
env knobs; added AGENTBBS_COINPAY_API_URL. Tests: httptest-backed payments_test
+ store SetPremiumPayment test. Build/vet/gofmt/test green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-14 08:27:33 +00:00
parent 5c0feda8d4
commit 7fd10c3436
6 changed files with 289 additions and 167 deletions

View file

@ -1,63 +1,59 @@
// Package payments gates paid features (the pod subscription, $1/mo) on
// CoinPay — the default LogicSRC payment/DID/wallet plugin.
// Package payments gates the one-time $10 lifetime Premium membership on
// CoinPay (coinpayportal.com). It talks to the CoinPay REST API directly over
// HTTP — no `coinpay` CLI needs to be installed on the host.
//
// v1 integration is CLI-shaped: join@ hands the user a `coinpay` command
// carrying a unique payment reference, and verification shells out to the
// coinpay CLI. The exact command templates are env-configurable so the
// deployed CoinPay surface can evolve without a rebuild:
// Config (env):
//
// AGENTBBS_COINPAY_PAY_TMPL e.g. "coinpay pay --to profullstack --amount 1 --currency USDC --memo %s"
// AGENTBBS_COINPAY_VERIFY_CMD e.g. "coinpay verify --memo %s" (exit 0 == paid)
// COINPAY_API_KEY Bearer key for the CoinPay API (cp_live_…)
// AGENTBBS_COINPAY_MERCHANT_ID merchant/business id payments are created under
// AGENTBBS_COINPAY_API_URL API base (default https://coinpayportal.com/api)
// AGENTBBS_PREMIUM_AMOUNT fiat amount (default 10)
// AGENTBBS_PREMIUM_CURRENCY fiat currency (default USD)
// AGENTBBS_PREMIUM_BLOCKCHAIN settlement chain (default eth)
//
// Operators can also grant manually: `agentbbs grant-pod <user> --months N`.
// Operators can also grant pod time manually: `agentbbs grant-pod <user> N`.
package payments
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"strings"
"strconv"
"time"
)
// PodPriceLabel is the human-readable price for the pod membership.
const PodPriceLabel = "$1/mo"
// PodTerm is how much access one payment buys.
// PodTerm is how much access one manual pod grant buys (grant-pod CLI).
const PodTerm = 31 * 24 * time.Hour
// PremiumPriceLabel is the human-readable price for the one-time lifetime
// membership offered at join@.
const PremiumPriceLabel = "$10 (lifetime)"
// premium charge defaults — all overridable via env so the CoinPay surface can
// change without a rebuild (mirrors the pod templates above).
// Premium charge parameters — overridable via env so the offer can change
// without a rebuild.
func PremiumAmount() string { return envOr("AGENTBBS_PREMIUM_AMOUNT", "10") }
func PremiumCurrency() string { return envOr("AGENTBBS_PREMIUM_CURRENCY", "USD") }
func PremiumBlockchain() string { return envOr("AGENTBBS_PREMIUM_BLOCKCHAIN", "eth") }
// MerchantID is the CoinPay merchant/business id payments are created under
// (AGENTBBS_COINPAY_MERCHANT_ID). When set it is passed as --business-id.
// MerchantID is the CoinPay merchant/business id payments are created under.
func MerchantID() string { return os.Getenv("AGENTBBS_COINPAY_MERCHANT_ID") }
// premiumCreateCmd builds the default `coinpay payment create` command, adding
// --business-id when a merchant id is configured. extra is appended verbatim
// (e.g. " --json --metadata %s"). The coinpay CLI reads COINPAY_API_KEY from
// the environment for auth.
func premiumCreateCmd(extra string) string {
cmd := "coinpay payment create --amount " + PremiumAmount() +
" --currency " + PremiumCurrency() +
" --blockchain " + PremiumBlockchain()
if m := MerchantID(); m != "" {
cmd += " --business-id " + m
}
return cmd + extra
func apiKey() string { return os.Getenv("COINPAY_API_KEY") }
func apiBase() string {
return trimSlash(envOr("AGENTBBS_COINPAY_API_URL", "https://coinpayportal.com/api"))
}
// APIConfigured reports whether live CoinPay calls can be made.
func APIConfigured() bool { return apiKey() != "" && MerchantID() != "" }
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
@ -65,143 +61,156 @@ func envOr(k, def string) string {
return def
}
// Charge is a created CoinPay payment a user must fund: a unique deposit
// address plus the crypto amount (and the fiat amount it settles).
type Charge struct {
Address string `json:"payment_address"`
CryptoAmount string `json:"crypto_amount"`
Currency string `json:"crypto_currency"`
FiatAmount string `json:"amount"`
FiatCurrency string `json:"currency"`
ID string `json:"id"`
QR string `json:"qr_code"`
func trimSlash(s string) string {
for len(s) > 0 && s[len(s)-1] == '/' {
s = s[:len(s)-1]
}
return s
}
// PremiumReference derives the stable CoinPay memo for a user's lifetime
// membership from their key fingerprint.
// Charge is a created CoinPay payment a user must fund: a unique deposit
// address, the crypto amount, the fiat amount it settles, and the payment id
// (store it to verify settlement later).
type Charge struct {
Address string
CryptoAmount string
Currency string
FiatAmount string
FiatCurrency string
ID string
QR string
}
// PremiumReference derives a stable memo for a user's lifetime membership from
// their key fingerprint; sent as payment metadata for reconciliation.
func PremiumReference(pubkeyFP string) string { return Reference("premium", pubkeyFP) }
// CreatePremiumCharge shells out to the CoinPay CLI to mint a payment address
// for the $10 lifetime membership and parses the JSON it prints. created is
// false when no create command is configured or the CLI is unavailable, so the
// caller can fall back to PremiumPayCommand. The reference is passed as the
// payment metadata/memo so the eventual settlement reconciles to the account.
//
// AGENTBBS_COINPAY_PREMIUM_CREATE_CMD
// default: coinpay payment create --amount 10 --currency USD --blockchain eth --json --metadata %s
func CreatePremiumCharge(ref string) (Charge, bool, error) {
tmpl := os.Getenv("AGENTBBS_COINPAY_PREMIUM_CREATE_CMD")
if tmpl == "" {
tmpl = premiumCreateCmd(" --json --metadata %s")
}
line := tmpl
if strings.Contains(tmpl, "%s") {
line = fmt.Sprintf(tmpl, ref)
} else {
line = tmpl + " " + ref
}
parts := strings.Fields(line)
if len(parts) == 0 {
return Charge{}, false, nil
}
if _, err := exec.LookPath(parts[0]); err != nil {
return Charge{}, false, nil // CLI not installed — caller falls back
}
out, err := exec.Command(parts[0], parts[1:]...).Output()
if err != nil {
return Charge{}, false, err
}
var c Charge
if err := json.Unmarshal(out, &c); err != nil {
// Some CLIs wrap the payment under a top-level key, e.g. {"payment":{…}}.
var wrap struct {
Payment Charge `json:"payment"`
}
if json.Unmarshal(out, &wrap) == nil && wrap.Payment.Address != "" {
c = wrap.Payment
} else {
return Charge{}, false, err
}
}
if c.Address == "" {
return Charge{}, false, nil
}
return c, true, nil
}
// PremiumPayCommand is the manual fallback shown when no charge could be minted
// in-session: the command the user can run themselves to pay.
//
// AGENTBBS_COINPAY_PREMIUM_PAY_TMPL
func PremiumPayCommand(ref string) string {
tmpl := os.Getenv("AGENTBBS_COINPAY_PREMIUM_PAY_TMPL")
if tmpl == "" {
tmpl = premiumCreateCmd(" --metadata %s")
}
if strings.Contains(tmpl, "%s") {
return fmt.Sprintf(tmpl, ref)
}
return tmpl + " " + ref
}
// VerifyPremium checks whether a premium charge has settled, via the CoinPay
// status command. Like Verify, checked is false when unconfigured/unavailable.
//
// AGENTBBS_COINPAY_PREMIUM_STATUS_CMD e.g. "coinpay payment status %s" (exit 0 == paid)
func VerifyPremium(payRef string) (paid bool, checked bool) {
return runVerify(os.Getenv("AGENTBBS_COINPAY_PREMIUM_STATUS_CMD"), payRef)
}
// Reference derives a stable, short payment reference for a user+plan from
// the user's key fingerprint, so CoinPay memos can be reconciled to accounts.
// Reference derives a stable, short payment reference for a user+plan.
func Reference(plan, pubkeyFP string) string {
mac := hmac.New(sha256.New, []byte("agentbbs."+plan))
mac.Write([]byte(pubkeyFP))
return "abbs-" + plan + "-" + hex.EncodeToString(mac.Sum(nil))[:12]
}
// PayCommand renders the coinpay command a user should run, with the payment
// reference substituted.
func PayCommand(ref string) string {
tmpl := os.Getenv("AGENTBBS_COINPAY_PAY_TMPL")
if tmpl == "" {
tmpl = "coinpay pay --to profullstack --amount 1 --currency USDC --memo %s"
}
if strings.Contains(tmpl, "%s") {
return fmt.Sprintf(tmpl, ref)
}
return tmpl + " " + ref
// coinpayPayment is the (subset of the) CoinPay payment object, returned
// wrapped as {"payment": {…}}.
type coinpayPayment struct {
ID string `json:"id"`
Status string `json:"status"`
Address string `json:"payment_address"`
CryptoAmount string `json:"crypto_amount"`
CryptoCurr string `json:"crypto_currency"`
QR string `json:"qr_code"`
}
// Verify checks a payment reference against the coinpay CLI. It returns
// (paid, checked): checked is false when no verifier is configured or the
// coinpay binary is unavailable, so callers can fall back to store state.
func Verify(ref string) (paid bool, checked bool) {
return runVerify(os.Getenv("AGENTBBS_COINPAY_VERIFY_CMD"), ref)
type paymentEnvelope struct {
Payment coinpayPayment `json:"payment"`
}
// runVerify runs a "%s"-templated verify command and maps its exit status to
// (paid, checked): checked is false when the template is empty or the binary is
// absent, so callers fall back to store state.
func runVerify(tmpl, ref string) (paid bool, checked bool) {
if tmpl == "" {
// coinpayDo performs an authenticated CoinPay API call and decodes the
// {"payment":{…}} envelope.
func coinpayDo(ctx context.Context, method, path string, body any) (*paymentEnvelope, error) {
var rdr io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
rdr = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, apiBase()+path, rdr)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+apiKey())
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("coinpay %s %s: %s: %s", method, path, resp.Status, bytesTrim(raw))
}
var env paymentEnvelope
if err := json.Unmarshal(raw, &env); err != nil {
return nil, fmt.Errorf("coinpay %s %s: bad response: %w", method, path, err)
}
return &env, nil
}
func bytesTrim(b []byte) string { return string(bytes.TrimSpace(b)) }
// CreatePremiumCharge creates a CoinPay payment for the lifetime membership and
// returns the deposit address, amount, and payment id (store the id to verify
// settlement later). created is false when CoinPay isn't configured.
func CreatePremiumCharge(ref string) (Charge, bool, error) {
if !APIConfigured() {
return Charge{}, false, nil
}
amount := json.Number(PremiumAmount())
if f, err := strconv.ParseFloat(PremiumAmount(), 64); err == nil {
amount = json.Number(strconv.FormatFloat(f, 'f', -1, 64))
}
body := map[string]any{
"business_id": MerchantID(),
"amount": amount,
"currency": PremiumCurrency(),
"blockchain": toUpper(PremiumBlockchain()),
"description": "AgentBBS Premium membership (lifetime)",
"metadata": map[string]string{"ref": ref},
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
env, err := coinpayDo(ctx, http.MethodPost, "/payments/create", body)
if err != nil {
return Charge{}, false, err
}
p := env.Payment
if p.Address == "" {
return Charge{}, false, fmt.Errorf("coinpay: payment created without an address")
}
return Charge{
Address: p.Address,
CryptoAmount: p.CryptoAmount,
Currency: p.CryptoCurr,
FiatAmount: PremiumAmount(),
FiatCurrency: PremiumCurrency(),
ID: p.ID,
QR: p.QR,
}, true, nil
}
// VerifyPremium reports whether a created premium payment has settled. payID is
// the CoinPay payment id from CreatePremiumCharge. checked is false when we
// could not reach CoinPay (caller falls back to store state); paid is true on a
// confirmed/forwarded status.
func VerifyPremium(payID string) (paid bool, checked bool) {
if payID == "" || !APIConfigured() {
return false, false
}
line := tmpl
if strings.Contains(tmpl, "%s") {
line = fmt.Sprintf(tmpl, ref)
} else {
line = tmpl + " " + ref
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
env, err := coinpayDo(ctx, http.MethodGet, "/payments/"+url.PathEscape(payID), nil)
if err != nil {
return false, false // network/API error — fall back to store state
}
parts := strings.Fields(line)
if len(parts) == 0 {
return false, false
}
if _, err := exec.LookPath(parts[0]); err != nil {
return false, false
}
if err := exec.Command(parts[0], parts[1:]...).Run(); err != nil {
switch env.Payment.Status {
case "confirmed", "forwarded", "completed", "paid":
return true, true
default:
return false, true
}
return true, true
}
func toUpper(s string) string {
b := []byte(s)
for i, c := range b {
if c >= 'a' && c <= 'z' {
b[i] = c - 32
}
}
return string(b)
}