Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ Entry point: `main.go` → opens SQLite DB → runs CLI or starts HTTP server on
- `/admin` — React SPA admin UI (static assets + SPA index fallback, PathPrefix without trailing slash)
- `/admin/api/` — Admin JSON API (cookie-based session auth, 21 endpoints)
- `/new`, `/batch`, `/wipe` — Bolt Card Programmer endpoints (`/wipe?s=<secret>` returns a card's keys for the admin Wipe Card deeplink so the app can reset the physical chip)

> **`POST /batch?s=<secret>` mints a real card on every call** — there is no idempotency and no preview mode. `db.Db_dispense_batch_card` (`db/db_tx.go`) claims a slot and inserts the card in one `BEGIN IMMEDIATE` transaction: it checks the batch's expiry window, refuses past `max_group_num` (`program_cards.cards_issued`, schema v14; `0` = no limit), and credits `initial_balance` as a settled `card_receipt` so the card arrives funded. Errors come back as `{"status": "ERROR", "reason": ...}` with a 4xx — `"batch limit reached"` vs `"program card expired or not found"`. Before v0.24.0 neither `max_group_num` nor `initial_balance` was enforced or applied, so a batch link was an unlimited card printing press (WWT-140).
- BoltCardHub API (`/create`, `/auth`, `/balance`, `/payinvoice`, etc.) — LndHub-compatible, feature-gated via `bolt_card_hub_api` setting
- PoS API (`/pos/`) — Point-of-Sale subset of LndHub API, feature-gated via `bolt_card_pos_api` setting
- `/admin/api/websocket` — Real-time payment notifications (JSON events via `wsHub` broadcast, requires admin session cookie)
Expand All @@ -130,7 +132,7 @@ SQLite at `/card_data/cards.db` with WAL mode, FULL synchronous, foreign keys, s

**Tables:** `settings` (key-value config), `cards` (card keys/auth/limits), `card_payments` (spending), `card_receipts` (loading/receiving), `program_cards` (batch programming), `pay_link_addresses` (rotating pay-link addresses), `admin_withdrawals` (admin payout audit log)

Schema version managed by idempotent `update_schema_*` functions in `db_create.go`. Current schema version: 13. (v13 adds `wipe_secret`/`wipe_secret_expiry` columns to `cards` — the transient capability token for the admin Wipe Card deeplink; see `web/bcp_wipe.go`.)
Schema version managed by idempotent `update_schema_*` functions in `db_create.go`. Current schema version: 14. (v13 adds `wipe_secret`/`wipe_secret_expiry` columns to `cards` — the transient capability token for the admin Wipe Card deeplink; see `web/bcp_wipe.go`. v14 adds `program_cards.cards_issued`, the batch slot counter; see below.)

**Admin withdrawals:** the `admin_withdrawals` table (schema v12) is an audit log of admin-initiated payouts of node liquidity (paying out the hub's own funds, not tied to any card). Each row records the destination Lightning address, amount, routing fee, payment hash, and status (`pending`/`paid`/`failed`). See `db/db_admin_withdrawal.go`.

Expand Down
5 changes: 5 additions & 0 deletions docker/card/admin-ui/src/components/batch-program-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,11 @@ export function BatchProgramDialog() {
One link programs up to this many cards. Leave at 1 for a single card.
</p>
)}
{Number(form.initialBalance) > 0 && (
<p className="text-xs text-muted-foreground">
Each card is credited with this balance as it is programmed.
</p>
)}

<button
type="button"
Expand Down
2 changes: 1 addition & 1 deletion docker/card/build/build.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
package build

var Version string = "0.23.2"
var Version string = "0.24.0"
var Date string
var Time string
18 changes: 18 additions & 0 deletions docker/card/db/db_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,24 @@ func update_schema_12(db *sql.DB) {
}
}

func update_schema_13(db *sql.DB) {

// Number of cards dispensed by a batch so far. Db_dispense_batch_card
// increments it under the write lock and refuses to go past
// max_group_num, so a /batch?s= link mints at most the number of cards
// the admin asked for (0 = no limit).
sqlStmt := `
BEGIN TRANSACTION;
ALTER TABLE program_cards ADD COLUMN cards_issued INTEGER NOT NULL DEFAULT 0;
UPDATE settings SET value='14' WHERE name='schema_version_number';
COMMIT TRANSACTION;
`
_, err := db.Exec(sqlStmt)
if err != nil {
log.Printf("update_schema_13 alter error: %q", err)
}
}

// randomHex8 generates an 8-character random hex string for lightning addresses.
func randomHex8() string {
b := make([]byte, 4)
Expand Down
6 changes: 5 additions & 1 deletion docker/card/db/db_init.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,11 @@ func Db_init(db_conn *sql.DB) {
update_schema_12(db_conn) // wipe_secret columns (admin wipe deeplink)
}

if Db_get_setting(db_conn, "schema_version_number") != "13" {
if Db_get_setting(db_conn, "schema_version_number") == "13" {
update_schema_13(db_conn) // program_cards.cards_issued (batch slot counter)
}

if Db_get_setting(db_conn, "schema_version_number") != "14" {
panic("database schema is not as expected")
}

Expand Down
8 changes: 5 additions & 3 deletions docker/card/db/db_select.go
Original file line number Diff line number Diff line change
Expand Up @@ -329,14 +329,15 @@ type ProgramCard struct {
InitialBalance int
CreateTime int
ExpireTime int
CardsIssued int
}

func Db_select_program_card_for_secret(db_conn *sql.DB, secret string) (result ProgramCard) {
var programCard ProgramCard

// get card id
sqlStatement := `SELECT secret, group_tag, max_group_num, initial_balance, create_time, expire_time` +
` FROM program_cards WHERE secret = $1;`
sqlStatement := `SELECT secret, group_tag, max_group_num, initial_balance, create_time, expire_time,` +
` cards_issued FROM program_cards WHERE secret = $1;`
rows, err := db_conn.Query(sqlStatement, secret)
if err != nil {
log.Error("db_select_program_card_for_secret query error: ", err)
Expand All @@ -351,7 +352,8 @@ func Db_select_program_card_for_secret(db_conn *sql.DB, secret string) (result P
&programCard.MaxGroupNum,
&programCard.InitialBalance,
&programCard.CreateTime,
&programCard.ExpireTime)
&programCard.ExpireTime,
&programCard.CardsIssued)
if err != nil {
log.Error("db_select_program_card_for_secret scan error: ", err)
}
Expand Down
166 changes: 164 additions & 2 deletions docker/card/db/db_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ package db

import (
"database/sql"
"errors"
"os"
"strings"
"testing"
"time"
)

func openTestDB(t *testing.T) *sql.DB {
Expand All @@ -23,8 +25,8 @@ func TestDbInit_SchemaMigratesToLatest(t *testing.T) {
Db_init(db)

version := Db_get_setting(db, "schema_version_number")
if version != "13" {
t.Fatalf("expected schema version 13, got %q", version)
if version != "14" {
t.Fatalf("expected schema version 14, got %q", version)
}
}

Expand Down Expand Up @@ -1099,3 +1101,163 @@ func TestDbInsertCard_UniqueLnAddresses(t *testing.T) {
t.Fatalf("expected unique ln_addresses, both got %q", card1.Ln_address)
}
}

// --- Db_dispense_batch_card tests (batch slot enforcement) ---

func insertTestBatch(t *testing.T, db_conn *sql.DB, secret string, maxCards int, initialBalance int) {
t.Helper()
now := int(time.Now().Unix())
Db_insert_program_cards(db_conn, secret, "batchtag", maxCards, initialBalance, now-60, now+3600)
}

func testBatchKeys() CardKeys {
return CardKeys{Key0: "k0", Key1: "k1", Key2: "k2", Key3: "k3", Key4: "k4"}
}

func countCards(t *testing.T, db_conn *sql.DB) int {
t.Helper()
var n int
if err := db_conn.QueryRow(`SELECT count(*) FROM cards`).Scan(&n); err != nil {
t.Fatal(err)
}
return n
}

func TestDbDispenseBatchCard_CreatesCardAndCountsIssue(t *testing.T) {
db_conn := openTestDB(t)
Db_init(db_conn)
insertTestBatch(t, db_conn, "s1", 10, 0)

cardId, groupTag, err := Db_dispense_batch_card(db_conn, "s1", "04AABBCCDDEE80", testBatchKeys(), "login1", "pass1")
if err != nil {
t.Fatalf("expected dispense to succeed, got %v", err)
}
if cardId == 0 {
t.Fatal("expected a card id")
}
if groupTag != "batchtag" {
t.Fatalf("expected group tag 'batchtag', got %q", groupTag)
}

var uid, tag, lnurlwEnable string
err = db_conn.QueryRow(`SELECT uid, group_tag, lnurlw_enable FROM cards WHERE card_id=$1`,
cardId).Scan(&uid, &tag, &lnurlwEnable)
if err != nil {
t.Fatal(err)
}
if uid != "04AABBCCDDEE80" {
t.Fatalf("expected uid to be stored, got %q", uid)
}
if tag != "batchtag" {
t.Fatalf("expected group_tag 'batchtag', got %q", tag)
}
if lnurlwEnable != "Y" {
t.Fatalf("expected withdrawals enabled, got %q", lnurlwEnable)
}

pc := Db_select_program_card_for_secret(db_conn, "s1")
if pc.CardsIssued != 1 {
t.Fatalf("expected cards_issued 1, got %d", pc.CardsIssued)
}
}

func TestDbDispenseBatchCard_EnforcesMaxGroupNum(t *testing.T) {
db_conn := openTestDB(t)
Db_init(db_conn)
insertTestBatch(t, db_conn, "s1", 2, 0)

for i := 0; i < 2; i++ {
if _, _, err := Db_dispense_batch_card(db_conn, "s1", "", testBatchKeys(), "login", "pass"); err != nil {
t.Fatalf("dispense %d should succeed, got %v", i+1, err)
}
}

_, _, err := Db_dispense_batch_card(db_conn, "s1", "", testBatchKeys(), "login", "pass")
if !errors.Is(err, ErrBatchExhausted) {
t.Fatalf("expected ErrBatchExhausted, got %v", err)
}
if n := countCards(t, db_conn); n != 2 {
t.Fatalf("expected 2 cards, got %d", n)
}
pc := Db_select_program_card_for_secret(db_conn, "s1")
if pc.CardsIssued != 2 {
t.Fatalf("expected cards_issued to stay at 2, got %d", pc.CardsIssued)
}
}

func TestDbDispenseBatchCard_ZeroMaxIsUnlimited(t *testing.T) {
db_conn := openTestDB(t)
Db_init(db_conn)
insertTestBatch(t, db_conn, "s1", 0, 0)

for i := 0; i < 3; i++ {
if _, _, err := Db_dispense_batch_card(db_conn, "s1", "", testBatchKeys(), "login", "pass"); err != nil {
t.Fatalf("dispense %d should succeed with no limit, got %v", i+1, err)
}
}
if n := countCards(t, db_conn); n != 3 {
t.Fatalf("expected 3 cards, got %d", n)
}
}

func TestDbDispenseBatchCard_CreditsInitialBalance(t *testing.T) {
db_conn := openTestDB(t)
Db_init(db_conn)
insertTestBatch(t, db_conn, "s1", 10, 5000)

cardId, _, err := Db_dispense_batch_card(db_conn, "s1", "", testBatchKeys(), "login", "pass")
if err != nil {
t.Fatalf("expected dispense to succeed, got %v", err)
}

if balance := Db_get_card_balance(db_conn, cardId); balance != 5000 {
t.Fatalf("expected balance 5000, got %d", balance)
}
}

func TestDbDispenseBatchCard_ZeroInitialBalanceAddsNoReceipt(t *testing.T) {
db_conn := openTestDB(t)
Db_init(db_conn)
insertTestBatch(t, db_conn, "s1", 10, 0)

cardId, _, err := Db_dispense_batch_card(db_conn, "s1", "", testBatchKeys(), "login", "pass")
if err != nil {
t.Fatalf("expected dispense to succeed, got %v", err)
}

var n int
if err := db_conn.QueryRow(`SELECT count(*) FROM card_receipts WHERE card_id=$1`, cardId).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 0 {
t.Fatalf("expected no receipt for a zero initial balance, got %d", n)
}
}

func TestDbDispenseBatchCard_ExpiredBatch(t *testing.T) {
db_conn := openTestDB(t)
Db_init(db_conn)
now := int(time.Now().Unix())
Db_insert_program_cards(db_conn, "expired", "batchtag", 10, 0, now-7200, now-3600)

_, _, err := Db_dispense_batch_card(db_conn, "expired", "", testBatchKeys(), "login", "pass")
if !errors.Is(err, ErrBatchExpired) {
t.Fatalf("expected ErrBatchExpired, got %v", err)
}
if n := countCards(t, db_conn); n != 0 {
t.Fatalf("expected no card to be created, got %d", n)
}
}

func TestDbDispenseBatchCard_UnknownSecret(t *testing.T) {
db_conn := openTestDB(t)
Db_init(db_conn)

_, _, err := Db_dispense_batch_card(db_conn, "nosuchsecret", "", testBatchKeys(), "login", "pass")
if !errors.Is(err, ErrBatchNotFound) {
t.Fatalf("expected ErrBatchNotFound, got %v", err)
}
if n := countCards(t, db_conn); n != 0 {
t.Fatalf("expected no card to be created, got %d", n)
}
}
Loading
Loading