refactor(v2): hoist LP state (wallet, mode, autoforward) into dca_lp table

LP-level preferences were denormalised across every `dca_clients` row
of a given user. Every LP enrolment carried its own wallet_id /
dca_mode / fixed_mode_daily_limit / autoforward_ln_address /
autoforward_enabled — and satmachineclient's `update_lp_autoforward`
did a multi-row UPDATE to keep them in sync. That sync dance was the
smell: user-level intent stored at machine-enrolment granularity.

New shape:

  dca_lp  (user_id PK, dca_wallet_id, default_dca_mode,
           fixed_mode_daily_limit, autoforward_ln_address,
           autoforward_enabled, ...)
  dca_clients  (id, machine_id, user_id, username, status, ...)
        // pure (machine, LP) enrolment — wallet/mode/autoforward gone

Authority split:
  - LP writes dca_lp via satmachineclient (Phase 2, separate commit).
  - Operator writes dca_clients via satmachineadmin. They cannot
    choose the LP's destination wallet — it's resolved from dca_lp
    at distribution time. Better trust hygiene.

Onboarding gate:
  - `api_create_deposit` refuses (HTTP 422) when the target LP has
    no dca_lp row. Forces every LP through a "yes, I am here and
    this is where I want my sats" gesture via satmachineclient
    before any fiat starts accumulating against them.

Schema:
  - m001 canonical schema updated: slim `dca_clients`, new `dca_lp`.
    Fresh installs land here directly.
  - m004 idempotent migration for installs that already have the
    legacy `dca_clients.wallet_id` column: creates dca_lp,
    backfills from the latest dca_clients row per user (window
    function), then DROP COLUMN on the moved fields. Greg's live
    test data survives the upgrade.

Distribution:
  - `get_flow_mode_clients_for_machine` INNER JOINs dca_lp so
    un-onboarded LPs are filtered out (no destination wallet).
  - `_pay_one_dca_leg`, `_attempt_autoforward`, `settle_lp_balance`
    all fetch `dca_lp` via the new `get_dca_lp(user_id)` helper.
    Wallet + autoforward read from prefs, not from client.

Models:
  - `DcaClient` loses 5 fields. `CreateDcaClientData` reduces to
    (machine_id, user_id, username). `UpdateDcaClientData` keeps
    only operator-controlled fields (username, status).
  - New `DcaLpPreferences` + `UpsertDcaLpData` models for the
    per-user surface (satmachineclient writes these in Phase 2).

CRUD:
  - New: `get_dca_lp`, `lp_is_onboarded`, `upsert_dca_lp` (the
    latter takes a `fallback_wallet_id` for first-onboarding when
    satmachineclient auto-seeds from the LP's default LNbits wallet).
  - `create_dca_client` insert reduces to the new column set.

Tests: 86 unit tests still green.

Next:
  - Phase 1c (this repo): UI simplification for operator's
    Add/Edit LP dialogs + deposit-gating UX.
  - Phase 2 (satmachineclient): own dca_lp writes + auto-init with
    the LP's default LNbits wallet on first dashboard visit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-16 10:05:54 +02:00
commit 80b5a6d785
5 changed files with 307 additions and 49 deletions

View file

@ -102,20 +102,17 @@ async def m001_satmachine_v2_initial(db):
"ON dca_machines (wallet_id)"
)
# 4. dca_clients — LP registrations scoped per (machine, user). An LP
# can hold positions at many machines (and many operators) on the
# same LNbits instance.
# 4. dca_clients — per-(machine, LP) registrations. Pure machine
# enrolment record: no wallet, no mode, no autoforward — those are
# LP-controlled at the user level via dca_lp (see below). Operator
# just decides "this LP is enrolled at my machine"; everything
# delivery-related is the LP's own preference.
await db.execute(f"""
CREATE TABLE IF NOT EXISTS satoshimachine.dca_clients (
id TEXT PRIMARY KEY,
machine_id TEXT NOT NULL,
user_id TEXT NOT NULL,
wallet_id TEXT NOT NULL,
username TEXT,
dca_mode TEXT NOT NULL DEFAULT 'flow',
fixed_mode_daily_limit DECIMAL(10,2),
autoforward_ln_address TEXT,
autoforward_enabled BOOLEAN NOT NULL DEFAULT false,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
@ -126,9 +123,35 @@ async def m001_satmachine_v2_initial(db):
"ON dca_clients (machine_id, user_id)"
)
await db.execute(
"CREATE INDEX IF NOT EXISTS dca_clients_user_idx " "ON dca_clients (user_id)"
"CREATE INDEX IF NOT EXISTS dca_clients_user_idx ON dca_clients (user_id)"
)
# 4a. dca_lp — LP-level (per-user) DCA preferences. ONE row per LNbits
# user that has onboarded as a Liquidity Provider, regardless of
# how many machines they're enrolled at. Owned by the LP (writes
# come from the satmachineclient extension under the LP's session),
# read by satmachineadmin during distribution to resolve "where do
# DCA payouts for this LP go?"
#
# Gating: satmachineadmin refuses to create deposits for an LP who
# doesn't have a dca_lp row yet. The LP must onboard via
# satmachineclient first (which auto-creates the row with their
# default LNbits wallet on first dashboard visit). Forces every
# LP through a "yes, I am here and this is where I want my sats"
# gesture before any fiat starts accumulating against them.
await db.execute(f"""
CREATE TABLE IF NOT EXISTS satoshimachine.dca_lp (
user_id TEXT PRIMARY KEY,
dca_wallet_id TEXT NOT NULL,
default_dca_mode TEXT NOT NULL DEFAULT 'flow',
fixed_mode_daily_limit DECIMAL(10,2),
autoforward_ln_address TEXT,
autoforward_enabled BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
""")
# 5. dca_deposits — fiat the operator (or super) records against an LP
# at a machine. creator_user_id preserves audit trail.
await db.execute(f"""
@ -336,3 +359,81 @@ async def m003_rename_settlements_net_sats_to_principal_sats(db):
"ALTER TABLE satoshimachine.dca_settlements "
"RENAME COLUMN net_sats TO principal_sats"
)
async def m004_introduce_dca_lp_table(db):
"""Hoist LP-level state (wallet, mode, autoforward) out of dca_clients
into a per-user dca_lp table. dca_clients becomes a pure (machine, LP)
enrolment record; everything delivery-related becomes the LP's own
preference, owned and written by satmachineclient.
Why: the per-row state on dca_clients was a denormalised duplicate of
user-level intent ("which wallet should my DCA land in?" + "should it
forward to my LN address?" — same answer regardless of which machine
paid). Today's update_lp_autoforward already does a multi-row UPDATE
to keep the rows in sync a smell of state belonging one level up.
Fresh installs from m001 onward land on the new schema directly.
Existing installs (pre-m004 test data) get migrated here:
1. Create dca_lp table (no-op if already present from m001 path).
2. Backfill dca_lp from existing dca_clients rows, picking the
most-recently-updated row per user_id when an LP is enrolled at
multiple machines.
3. Drop the moved columns from dca_clients.
Idempotent: probes for the legacy `dca_clients.wallet_id` column. If
absent the install already on the new shape; no-op.
"""
try:
await db.fetchone("SELECT wallet_id FROM satoshimachine.dca_clients LIMIT 1")
except Exception:
return
# Step 1: create dca_lp if it doesn't exist yet. m001 on a fresh install
# already created it; on a pre-m004 install we're creating it here.
await db.execute(f"""
CREATE TABLE IF NOT EXISTS satoshimachine.dca_lp (
user_id TEXT PRIMARY KEY,
dca_wallet_id TEXT NOT NULL,
default_dca_mode TEXT NOT NULL DEFAULT 'flow',
fixed_mode_daily_limit DECIMAL(10,2),
autoforward_ln_address TEXT,
autoforward_enabled BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
""")
# Step 2: backfill dca_lp from dca_clients. Pick the latest row per
# user (by updated_at, falling back to created_at) when the LP is
# enrolled at multiple machines — that row reflects their most
# recent intent. ROW_NUMBER() OVER (...) requires SQLite 3.25+ (2018).
await db.execute("""
INSERT OR IGNORE INTO satoshimachine.dca_lp
(user_id, dca_wallet_id, default_dca_mode, fixed_mode_daily_limit,
autoforward_ln_address, autoforward_enabled,
created_at, updated_at)
SELECT user_id, wallet_id, dca_mode, fixed_mode_daily_limit,
autoforward_ln_address, autoforward_enabled,
created_at, updated_at
FROM (
SELECT *, ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY updated_at DESC, created_at DESC
) AS rn
FROM satoshimachine.dca_clients
) ranked
WHERE rn = 1
""")
# Step 3: drop the moved columns from dca_clients. ALTER TABLE DROP
# COLUMN needs SQLite 3.35+ (2021). One column per ALTER (SQLite
# doesn't support multi-column DROP).
for col in (
"wallet_id",
"dca_mode",
"fixed_mode_daily_limit",
"autoforward_ln_address",
"autoforward_enabled",
):
await db.execute(f"ALTER TABLE satoshimachine.dca_clients DROP COLUMN {col}")