Blocking issues from reviewer, all fixed:
1. /api/estimate no longer mutates pool — uses decideDryRun() (read-only)
2. Free-path passes actual debited amount (ftDecision.absorbSats) not estimate:
- DB absorbedSats = ftDecision.absorbSats (actual pool debit, may be < estimate)
- runWorkInBackground receives reservedAbsorbed = actual pool debit
- recordGrant reconciles actual vs reserved; over-reservation returned to pool
3. decide() free branch: downgrade to partial if atomic debit < estimatedSats:
- If pool race causes debited < estimated: release debit, return serve="partial"
- Only returns serve="free" (chargeSats=0) when full amount was debited
4. Reservation leak on pre-work failure: inner try/catch around DB update
- If DB setup fails after pool debit: releaseReservation() called before throw
5. Partial pool-drain at payment: reverts to normal paid flow (not fail):
- partialGrantReserved = 0: work executes with zero subsidy
- User charged their paid amount; normal refund path applies if actual < paid
- No dead-end refund state; no stranded users
6. Partial-job refund math: actualUserChargeSats = max(0, actual - absorbed)
7. Sessions comment clarified: pool reservation sized to work estimate;
if it covers fullDebitSats (eval+work), debitedSats = 0; otherwise partial
== All fixed issues ==
1. /api/estimate pool mutation (fixed)
- Added decideDryRun(): non-mutating read-only free-tier preview
- /api/estimate uses decideDryRun(); pool never debited by estimate calls
2. Free-path passes actual debited amount not estimate (fixed)
- In runEvalInBackground free path: uses ftDecision.absorbSats (actual pool debit)
- DB absorbedSats column set to actual debited sats, not breakdown.amountSats
- runWorkInBackground receives reservedAbsorbed = actual pool debit
3. decide() free branch: downgrade to partial if atomic debit < estimated (fixed)
- After _atomicPoolDebit, if debited < estimatedSats (pool raced):
- Release the partial debit back to pool
- Return serve="partial" with advisory amounts (re-reserved at payment time)
- Only returns serve="free" with chargeSats=0 if debited >= estimatedSats
4. Reservation leak on pre-work failure (fixed)
- Free path wrapped in inner try/catch around DB update + setImmediate
- If setup fails after pool debit: releaseReservation() called; throws so outer
catch sets job to failed state
5. Partial-job pool-drained at payment => fail with refund (implemented)
- reservePartialGrant() = 0 at payment time => job.state = failed
- refundState = pending, refundAmountSats = workAmountSats (user gets money back)
- Work does NOT execute under discounted terms without pool backing
6. Partial-job refund math corrected (fixed)
- actualUserChargeSats = max(0, actualTotalCostSats - partialAbsorbSats)
- refund = workAmountSats - actualUserChargeSats
7. Grant audit reconciliation (fixed)
- actualAbsorbed = min(actualTotalCostSats, reservedAbsorbed)
- over-reservation returned to pool atomically in recordGrant()
- Audit log and daily counter reflect actual absorbed sats
New API: decideDryRun(), reservePartialGrant(), releaseReservation()
New recordGrant signature: (pubkey, hash, actualAbsorbed, reservedAbsorbed)
Final architecture (all paths enforce pool-backed-or-no-service):
serve="free" (fully-free jobs & sessions):
- decide() atomically debits pool via SELECT FOR UPDATE at decision time
- No advisory gap: pool debit and service decision are a single DB operation
- Pool drained at decide() time => returns gate => work does not start
- Work fails => releaseReservation() refunds pool
serve="partial" (partial-subsidy jobs):
- decide() advisory (no pool debit) — prevents DoS from abandoned payments
- reservePartialGrant() atomically debits pool at work-payment-confirmation
(SELECT FOR UPDATE, re-validates daily limits)
- Pool drained at payment time:
* job.state = failed, refundState = pending, refundAmountSats = workAmountSats
* User gets their payment back; work does not execute under discounted terms
* "Free service pauses" invariant maintained — no unaccounted subsidy ever happens
serve="partial" (sessions — synchronous):
- reservePartialGrant() called after work completes, using min(actual, advisory)
- If pool empty at grant time: absorbedSats = 0, user charged full actual cost
/api/estimate endpoint:
- Now uses decideDryRun() — read-only, no pool debit, no daily budget consumption
- Pool and identity state are never mutated by estimate calls
Partial-job refund math:
- actualUserChargeSats = max(0, actualTotalCostSats - partialAbsorbSats)
- refund = workAmountSats - actualUserChargeSats
- Correctly accounts for Timmy's pool contribution
recordGrant(pubkey, hash, actualAbsorbed, reservedAbsorbed):
- over-reservation (estimate > actual token usage) returned to pool atomically
- Audit log and daily counter reflect actual absorbed sats only
New methods: decideDryRun(), reservePartialGrant(), releaseReservation()
== Issue 1: /api/estimate was mutating pool state (fixed) ==
Added decideDryRun() to FreeTierService — non-mutating read-only preview that
reads pool/trust state but does NOT debit the pool or reserve anything.
/api/estimate now calls decideDryRun() instead of decide().
Pool and daily budgets are never affected by estimate calls.
== Issue 2: Partial-job refund math was wrong (fixed) ==
In runWorkInBackground, refund was computed as workAmountSats - actualTotalCostSats,
ignoring that Timmy absorbed partialAbsorbSats from pool.
Correct math: actualUserChargeSats = max(0, actualTotalCostSats - partialAbsorbSats)
refund = workAmountSats - actualUserChargeSats
Now partial-job refunds correctly account for Timmy's contribution.
== Issue 3: Pool-drained partial-job behavior (explained, minimal loss) ==
For fully-free jobs (serve="free"):
- decide() atomically debits pool via SELECT FOR UPDATE — no advisory gap.
- Pool drained => decide() returns gate => work does not start. ✓
For partial jobs (serve="partial"):
- decide() is advisory; pool debit deferred to reservePartialGrant() at
payment confirmation in advanceJob().
- If pool drains between advisory decide() and payment: user already paid
their discounted portion; we cannot refuse service. Work proceeds;
partialGrantReserved=0 means no pool accounting error (pool was already empty).
- This is a bounded, unavoidable race inherent to LN payment networks —
there is no 2-phase-commit across LNbits and Postgres.
- "Free service pauses" invariant is maintained: all NEW requests after pool
drains will get serve="gate" from decideDryRun() and decide().
== Audit log accuracy (fixed in prior commit, confirmed) ==
recordGrant(pubkey, hash, actualAbsorbed, reservedAbsorbed):
- actualAbsorbed = min(actualTotalCostSats, reservedAbsorbed)
- over-reservation (estimated > actual) returned to pool atomically
- daily counter and audit log reflect actual absorbed sats
Architecture by serve type:
serve="free" (fully-free jobs & sessions):
- decide() atomically debits pool via SELECT FOR UPDATE transaction
- Pool debit and service decision are a single atomic DB operation
- If work fails → releaseReservation() refunds pool
- Grant audit written post-work with actual absorbed (≤ reserved); excess returned
serve="partial" (partial-subsidy jobs):
- decide() advisory; pool NOT debited at eval time
(prevents economic DoS from users abandoning payment flow)
- At work-payment confirmation: reservePartialGrant() atomically debits pool
(re-validates daily limits, SELECT FOR UPDATE, cap to available balance)
- If pool is empty at payment time: work proceeds (user already paid);
bounded loss (≤ estimated partial sats); partialGrantReserved=0 means
no pool accounting error — pool was already empty
- Grant audit: actualAbsorbed = min(actualCostSats, reserved); excess returned
serve="partial" (sessions — synchronous):
- decide() advisory; reservePartialGrant() called after work completes
- Actual cost capped at advisory absorbSats; over-reservation returned
recordGrant(pubkey, reqHash, actualAbsorbed, reservedAbsorbed):
- Over-reservation (estimated > actual token usage) atomically returned to pool
- Daily counter and audit log reflect actual absorbed sats
- Pool never goes negative; no silent losses under concurrent requests
New methods added: reservePartialGrant(), releaseReservation()
New 4-arg recordGrant() signature with over-reservation reconciliation
Architecture:
serve="free" (fully-free jobs/sessions):
- decide() atomically debits pool via FOR UPDATE transaction at decision time
- Work starts immediately after, so no window for pool drain between debit+work
- On work failure → releaseReservation() refunds pool
serve="partial" (partial-subsidy jobs):
- decide() is advisory; pool NOT debited at eval time
- Prevents economic DoS from users who abandon the payment flow
- At work-payment-confirmation: reservePartialGrant() atomically debits pool
(re-validates daily limits, uses FOR UPDATE lock)
- If pool is empty at payment time: job is failed with clear message
("Generosity pool exhausted — please retry at full price.")
Free service pauses rather than Timmy operating at a loss
serve="partial" (sessions — synchronous):
- decide() advisory; reservePartialGrant() called after work completes
- Partial debit uses actual cost capped at advisory limit
Grant reconciliation (both paths):
- recordGrant(pubkey, reqHash, actualAbsorbed, reservedAbsorbed)
- actualAbsorbed = min(actualCostSats, reservedAbsorbed)
- Over-reservation (estimated > actual token usage) returned to pool atomically
- Daily absorption counter and audit log reflect actual absorbed, not estimate
- Pool never goes negative; identity daily budget never overstated
Added: freeTierService.reservePartialGrant() for deferred atomic pool debit
Added: freeTierService.releaseReservation() for failure/rejection refund
Result: Zero-loss guarantee — pool debit and charge reduction always consistent.
Root cause: decide() was advisory but user charges were reduced from its output;
recordGrant() later might absorb less, so Timmy could absorb the gap silently.
Fix architecture (serve="free" path — fully-free jobs + sessions):
- decide() now runs _atomicPoolDebit() inside a FOR UPDATE transaction
- Pool is debited at decision time for serve="free" decisions
- Work starts immediately after, so no window for pool drain between debit and use
- If work fails → releaseReservation() returns sats to pool
Fix architecture (serve="partial" path — partial-subsidy jobs):
- decide() remains advisory for "partial" (no pool debit at decision time)
- This prevents pool drain from users who get a partial offer but never pay
- For jobs: reservePartialGrant() atomically debits pool at work-payment-confirmation
time (inside advanceJob), before work begins
- For sessions: reservePartialGrant() called after synchronous work completes,
using actual cost capped by advisory absorbSats
recordGrant() now takes (pubkey, requestHash, actualAbsorbed, reservedAbsorbed):
- Over-reservation (estimated > actual) returned to pool atomically
- Audit log and daily counter reflect actual absorbed amount
- Pool balance was already decremented by decide() or reservePartialGrant()
Result: In ALL paths, pool debit happens atomically before charges are reduced.
User charge reduction and pool debit are always consistent — Timmy never operates
at a loss due to concurrent pool depletion.
Fix 1 — Add `estimateRequestCost(request, model)` to PricingService (pricing.ts)
- Unified: estimateInputTokens + estimateOutputTokens + calculateWorkFeeUsd
- Replaces duplicated estimation in jobs.ts, sessions.ts, estimate.ts
Fix 2 — Sessions pre-gate: estimate → decide → execute → reconcile
- freeTierService.decide() runs on ESTIMATED cost BEFORE executeWork()
- Fixed double-margin: estimateRequestCost already includes infra+margin; convert directly
- absorbedSats capped at actual cost post-execution (Math.min)
Fix 3 — Correct isFree derivation for partial jobs in advanceJob() (jobs.ts)
- isFreeExecution = workAmountSats === 0 (not job.freeTier)
- Partial jobs run paid accounting: actual sats, refund, pool credit, deferred grant
Fix 4 — Defer ALL grant recording to post-work execution (jobs.ts)
- Fully-free path: removed recordGrant from eval time; now called in runWorkInBackground
- For isFree jobs: absorbCap = actual post-execution cost (calculateActualChargeSats)
- For partial jobs: grant deferred from invoice creation to after work completes
Fix 5 — Atomic, pool-bounded grant recording with row locking (free-tier.ts)
- SELECT ... FOR UPDATE locks pool row inside transaction
- actualAbsorbed = Math.min(absorbSats, poolBalance) — pool can never go negative
- Pool balance update is plain write (lock already held)
- Daily absorption: SQL CASE expression atomically handles new-day reset
- Audit log and identity counter both reflect actualAbsorbed, not requested amount
- If pool is empty at grant time, transaction returns without writing
Fix 6 — Remove fire-and-forget (void) from all recordGrant() call sites
- All three call sites now use await; grant failures propagate correctly
- Removed unused createHash import from free-tier.ts
Fix 1 — Add `estimateRequestCost(request, model)` to PricingService (pricing.ts)
- Unified: estimateInputTokens + estimateOutputTokens + calculateWorkFeeUsd
- Replaces duplicated estimation logic in jobs.ts, sessions.ts, estimate.ts
Fix 2 — Move partial free-tier `recordGrant()` from invoice creation to post-work
- Was called at invoice creation — economic DoS vulnerability
- Now deferred to runWorkInBackground via new `partialAbsorbSats` param
- Fully-free jobs still record grant at eval time (no payment involved)
Fix 3 — Sessions pre-gate: estimate → decide → execute → reconcile
- freeTierService.decide() now runs on ESTIMATED cost BEFORE executeWork()
- Fixed double-margin bug: estimateRequestCost returns cost already with infra+margin
(calculateWorkFeeUsd), convert directly to sats — no second calculateActualChargeUsd
- absorbedSats capped at actual cost post-execution to prevent over-absorption
Fix 4 — Correct isFree derivation for partial jobs in advanceJob() (jobs.ts)
- isFreeExecution = workAmountSats === 0 (not job.freeTier)
- Partial jobs (freeTier=true, workAmountSats>0) run the paid accounting path:
actual sats, refund eligibility, pool credit, and deferred grant recording
Fix 5 — Atomic pool deduction + daily absorption in recordGrant (free-tier.ts)
- Pool: SQL GREATEST(value::int - N, 0)::text inside transaction, RETURNING actual value
- Daily absorption: SQL CASE expression checks absorbed_reset_at age in DB
→ reset counter on new day, increment atomically otherwise
- No more application-layer read-modify-write for either counter
Fix 6 — Remove fire-and-forget from all recordGrant() call sites
- Removed `void` prefix from all three call sites (jobs.ts x2, sessions.ts x1)
- Grant persistence failures now propagate correctly instead of silently diverging
- Removed unused createHash import from free-tier.ts
Fix 1 — Add `estimateRequestCost(request, model)` to PricingService (pricing.ts)
- Unified method: estimateInputTokens + estimateOutputTokens + calculateWorkFeeUsd
- Replaces duplicated estimation logic in jobs.ts, sessions.ts, estimate.ts
Fix 2 — Move partial free-tier `recordGrant()` from invoice creation to post-work
- Was called at invoice creation for partial path — economic DoS vulnerability
- Now deferred to runWorkInBackground via new `partialAbsorbSats` param
- Fully-free jobs still record grant at eval time (no payment involved)
Fix 3 — Sessions pre-gate: estimate → decide → execute → reconcile
- freeTierService.decide() now runs on ESTIMATED cost BEFORE executeWork()
- Fixed double-margin bug: estimateRequestCost returns cost with infra+margin already
applied (calculateWorkFeeUsd), so convert directly to sats — no second
calculateActualChargeUsd wrapping
- absorbedSats capped at actual cost post-execution to prevent over-absorption
Fix 4 — Correct isFree flag for partial jobs in advanceJob() (jobs.ts)
- job.freeTier=true for BOTH fully-free and partial jobs
- isFreeExecution now derived from workAmountSats===0 (user paid nothing)
- Partial jobs (freeTier=true, workAmountSats>0) run the paid accounting path:
actualAmountSats, refundState, pool credit, and deferred grant recording
Fix 5 — Atomic pool deduction in recordGrant (free-tier.ts)
- Replaced non-atomic read-then-write with SQL GREATEST expression inside tx
- UPDATE timmyConfig SET value = GREATEST(value::int - N, 0)::text RETURNING value
- Audit log receives actual DB-returned value; no oversubscription under concurrency
- Removed unused createHash import
1. Add `estimateRequestCost(request, model)` to PricingService in pricing.ts
- Unified method combining estimateInputTokens + estimateOutputTokens + calculateWorkFeeUsd
- Replaces duplicated token estimation logic at call sites in jobs.ts, sessions.ts, estimate.ts
2. Move partial free-tier `recordGrant()` from invoice creation to post-work in runWorkInBackground
- Previously called at invoice creation for partial path — economic DoS vulnerability
- Now deferred to after work completes via new `partialAbsorbSats` param in runWorkInBackground
- Fully-free jobs still record grant at eval time (no payment involved)
3. Sessions pre-gate: estimate → decide → execute → reconcile (with double-margin bug fix)
- Free-tier `decide()` now runs on ESTIMATED cost BEFORE `executeWork()` is called
- Fixed: estimateRequestCost already includes infra+margin via calculateWorkFeeUsd,
so convert estimatedCostUsd directly to sats — no second calculateActualChargeUsd call
- absorbedSats capped at actual cost post-execution (Math.min) to prevent over-absorption
4. Atomic pool deduction in recordGrant (free-tier.ts)
- Replaced non-atomic read-then-write pattern with SQL GREATEST expression inside transaction
- UPDATE timmyConfig SET value = GREATEST(value::int - absorbSats, 0)::text RETURNING value
- Audit log (freeTierGrants) receives actual post-deduct value from DB; no oversubscription
- Removed unused createHash import from free-tier.ts
1. Add `estimateRequestCost(request, model)` to PricingService in pricing.ts
- Unified method combining estimateInputTokens + estimateOutputTokens + calculateWorkFeeUsd
- Replaces duplicated token estimation logic at call sites in jobs.ts, sessions.ts, estimate.ts
2. Move partial free-tier `recordGrant()` from invoice creation to post-work in runWorkInBackground
- Previously called at invoice creation for partial path (before user pays) — economic DoS vulnerability
- Now deferred to after work completes, using new `partialAbsorbSats` parameter in runWorkInBackground
- Fully-free jobs still record grant at eval time (no payment involved)
3. Sessions pre-gate refactor: estimate → decide → execute → reconcile
- Free-tier `decide()` now runs on ESTIMATED cost BEFORE `executeWork()` is called
- After execution, `absorbedSats` is capped at actual cost (Math.min) to prevent over-absorption
- Uses new `estimateRequestCost()` for clean single-call estimation
## What was built
### DB schema
- `timmy_config` table: key/value store for the generosity pool balance
- `free_tier_grants` table: immutable audit log of every Timmy-absorbed request
- `jobs.free_tier` (boolean) + `jobs.absorbed_sats` (integer) columns
### FreeTierService (`lib/free-tier.ts`)
- Per-tier daily sats budgets (new=0, established=50, trusted=200, elite=1000)
— all env-var overridable
- `decide(pubkey, estimatedSats)` → `{ serve: free|partial|gate, absorbSats, chargeSats }`
— checks pool balance AND identity daily budget atomically
- `credit(paidSats)` — credits POOL_CREDIT_PCT (default 10%) of every paid
work invoice back to the generosity pool
- `recordGrant(pubkey, reqHash, absorbSats)` — DB transaction: deducts pool,
updates identity daily absorption counter, writes audit row
- `poolStatus()` — snapshot for metrics/monitoring
### Route integration
- `POST /api/jobs` (eval → work flow): after eval passes, `freeTierService.decide()`
intercepts. Free → skip invoice, fire work directly. Partial → discounted invoice.
Gate (anonymous/new tier/pool empty) → unchanged full-price flow.
- `POST /api/sessions/:id/request`: after compute, free-tier discount applied to
balance debit. Session balance only reduced by `chargeSats`; absorbed portion
comes from pool.
- Pool credited on every paid work completion (both jobs and session paths).
- Response fields: `free_tier: true`, `absorbed_sats: N` when applicable.
### GET /api/estimate
- Lightweight pre-flight cost estimator; no payment required
- Returns: estimatedSats, btcPriceUsd, tokenEstimate, identity.free_tier decision
(if valid nostr_token provided), pool.balanceSats, pool.dailyBudgets
### Tests
- All 29 existing testkit tests pass (0 failures)
- Anonymous/new-tier users hit gate path correctly (verified manually)
- Pool seeds to 10,000 sats on first boot
## Architecture notes
- Free tier decision happens BEFORE invoice creation for jobs (save user the click)
- Partial grant recorded at invoice creation time (reserves pool capacity proactively)
- Free tier for sessions decided AFTER compute (actual cost known, applied to debit)
- Pool crediting is fire-and-forget (non-blocking)
- resolveNostrPubkey() now returns { pubkey, rejected } instead of string|null
so invalid/expired tokens return 401 instead of silently falling to anonymous
- POST /sessions and POST /jobs: return 401 if nostr_token header/body is
present but invalid or expired
- POST /identity/verify: now accepts optional top-level 'pubkey' field alongside
'event'; asserts pubkey matches event.pubkey if both are provided — aligns
API contract with { pubkey, event } spec shape and hardens against mismatch
Previously recordSuccess/recordFailure read identity.trustScore (raw stored value)
and incremented/decremented from there. Long-absent identities could instantly
recover their pre-absence tier on the first interaction, defeating decay.
Fix: both methods now call applyDecay(identity) first to get the true current
baseline, then apply the score delta from there before persisting.
- sessions.ts / jobs.ts schema: add .references(() => nostrIdentities.pubkey) FK constraints
on nostrPubkey columns; import without .js extension for drizzle-kit CJS compat
- Schema pushed to DB (FK constraints now enforced at DB level)
- sessions route: call getOrCreate before insert to guarantee FK target exists;
recordFailure now covers both 'rejected' AND 'failed' final states
- jobs route: call getOrCreate before insert; recordFailure added in
runEvalInBackground for rejected and failed states; recordFailure added in
runWorkInBackground catch block for failed state
- All GET/POST endpoints now always return trust_tier (anonymous fallback)
- Full typecheck clean; schema pushed; smoke tested — all routes green
- New nostr_identities DB table (pubkey, trust_score, tier, interaction_count, sats_absorbed_today, last_seen)
- nullable nostr_pubkey FK on sessions + jobs tables; schema pushed
- TrustService: getTier, getOrCreate, recordSuccess/Failure, HMAC token (issue/verify)
- Soft score decay (lazy, on read) when identity absent > N days
- POST /api/identity/challenge + POST /api/identity/verify (NIP-01 sig verification)
- GET /api/identity/me — look up trust profile by X-Nostr-Token
- POST /api/sessions + POST /api/jobs accept optional nostr_token; bind pubkey to row
- GET /sessions/:id + GET /jobs/:id include trust_tier in response
- recordSuccess/Failure called after session request + job work completes
- X-Nostr-Token added to CORS allowedHeaders + exposedHeaders
- TIMMY_TOKEN_SECRET set as persistent shared env var
Task #25: Provision LNbits on Hermes VPS for real Lightning payments.
Changes:
- dev.ts: /dev/stub/pay/:hash now works in both stub and real LNbits modes.
In real mode, looks up BOLT11 from invoices/sessions/bootstrapJobs tables
then calls lnbitsService.payInvoice() (FakeWallet accepts it).
- sessions.ts: Remove all stubMode conditionals on paymentHash — always expose
paymentHash in invoice, pendingTopup, and 409-conflict responses.
- jobs.ts: Remove stubMode conditionals on paymentHash in create, GET awaiting_eval,
and GET awaiting_work responses.
- bootstrap.ts: Remove stubMode conditionals on paymentHash in POST create and
GET poll responses. Simplify message field (no longer mode-conditional).
- Hermes VPS: Funded LNbits wallet with 1B sats via DB credit so payInvoice
calls succeed (FakeWallet checks wallet balance before routing).
Result: 29/29 testkit PASS in real LNbits mode (LNBITS_URL + LNBITS_API_KEY set).
Update testkit.ts to add explicit failure conditions for missing payment hash in stub mode and to assert that the bootstrapJobId returned in the poll response matches the created ID.
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 418bf6f8-212b-4bb0-a7a5-8231a061da4e
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 9114d92d-daf7-42ae-a3f7-be296300efa5
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/9f85e954-647c-46a5-90a7-396e495a805a/418bf6f8-212b-4bb0-a7a5-8231a061da4e/Q83Uqvu
Replit-Helium-Checkpoint-Created: true
Task: Add T23 (bootstrap stub flow) and T24 (cost-ledger completeness) to the
testkit, bringing total from 27/27 to 29/29 PASS with 0 FAIL, 0 SKIP.
## What was changed
- `artifacts/api-server/src/routes/testkit.ts`:
- Updated audit-log comment block to document T23 + T24 additions.
- Inserted Test 23 after T22 (line ~654):
POST /api/bootstrap → assert 201 + bootstrapJobId present.
Guard on stubMode=true; SKIP if real DO mode (prevents hanging).
Stub-pay the paymentHash via /api/dev/stub/pay/:hash.
Poll GET /api/bootstrap/:id every 2s (20s timeout) until
state=provisioning or state=ready; assert message field present.
- Inserted Test 24 after T23:
Guarded on STATE_T6=complete (reuses completed job from T6).
GET /api/jobs/:id, extract costLedger.
Assert all 8 fields non-null: actualInputTokens, actualOutputTokens,
totalTokens, actualCostUsd, actualAmountSats, workAmountSats,
refundAmountSats, refundState.
Honest-accounting invariant: actualAmountSats <= workAmountSats.
refundAmountSats >= 0.
refundState must match ^(not_applicable|pending|paid)$.
## No deviations from task spec
- T23 guard logic matches spec exactly (stubMode check before poll).
- T24 fields match the 8 specified in task-24.md plus the invariants.
- No changes to bootstrap.ts or jobs.ts — existing routes already correct.
## Test run result
29/29 PASS, 0 FAIL, 0 SKIP (fresh server restart, rate-limit slots clean).
T23: state=provisioning in 1s. T24: actualAmountSats(179)<=workAmountSats(182),
refundAmountSats=3, refundState=pending.
## Task
Task #20: Timmy responds to Workshop input bar — make the "Say something
to Timmy…" input bar actually trigger an AI response shown in Timmy's
speech bubble.
## What was built
### Server (artifacts/api-server/src/lib/agent.ts)
- Added `chatReply(userText)` method to AgentService
- Uses claude-haiku (cheaper eval model) with a wizard persona system prompt
- 150-token limit so replies fit in the speech bubble
- Stub mode: returns one of 4 wizard-themed canned replies after 400ms delay
- Real mode: calls Anthropic with wizard persona, truncates to 250 chars
### Server (artifacts/api-server/src/routes/events.ts)
- Imported agentService
- Added per-visitor rate limit system: 3 replies/minute per visitorId (in-memory Map)
- Added broadcastToAll() helper for broadcasting to all WS clients
- Updated visitor_message handler:
1. Broadcasts visitor message to all watchers as before
2. Checks rate limit — if exceeded, sends polite "I need a moment…" reply
3. Fire-and-forget async AI call:
- Broadcasts agent_state: gamma=working (crystal ball pulses)
- Calls agentService.chatReply()
- Broadcasts agent_state: gamma=idle
- Broadcasts chat: agentId=timmy, text=reply to ALL clients
- Logs world event "visitor:reply"
### Frontend (the-matrix/js/websocket.js)
- Updated case 'chat' handler to differentiate message sources:
- agentId === 'timmy': speech bubble + event log entry "Timmy: <text>"
- agentId === 'visitor': event log only (don't hijack speech bubble)
- everything else (delta/alpha/beta payment notifications): speech bubble
## What was already working (no change needed)
- Enter key on input bar (ui.js already had keydown listener)
- Input clearing after send (already in ui.js)
- Speech bubble rendering (setSpeechBubble already existed in agents.js)
- WebSocket sendVisitorMessage already exported from websocket.js
## Tests
- 27/27 testkit PASS (no regressions)
- TypeScript: 0 errors
- Vite build: clean (the-matrix rebuilt)
- scripts/bitcoin-ln-node/setup.sh: one-shot installer for Bitcoin Core (pruned mainnet), LND, and LNbits on Apple Silicon Mac. Generates secrets, writes configs, installs launchd plists for auto-start.
- scripts/bitcoin-ln-node/start.sh: start all services via launchctl; waits for RPC readiness and auto-unlocks LND wallet.
- scripts/bitcoin-ln-node/stop.sh: graceful shutdown (lncli stop → bitcoin-cli stop).
- scripts/bitcoin-ln-node/status.sh: full health check (Bitcoin sync %, LND channels/balance, LNbits HTTP, bore tunnel). Supports --json mode for machine consumption.
- scripts/bitcoin-ln-node/expose.sh: opens bore tunnel from LNbits port 5000 to bore.pub for Replit access.
- scripts/bitcoin-ln-node/get-lnbits-key.sh: fetches LNbits admin API key and prints Replit secret values.
- artifacts/api-server/src/routes/node-diagnostics.ts: GET /api/admin/node-status (JSON) and /api/admin/node-status/html — Timmy self-diagnoses its LNbits/LND connectivity and reports issues.
Refactors `runEvalInBackground` and `runWorkInBackground` to execute AI tasks asynchronously. Updates `pollJob` in `ui.ts` to handle 'evaluating', 'executing', and 'failed' states, and corrects `data.status` to `data.state` and `data.rejectionReason` to `data.reason`.
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 418bf6f8-212b-4bb0-a7a5-8231a061da4e
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: ecf857ee-fa4d-47db-b4c1-b374ffb3815d
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/9f85e954-647c-46a5-90a7-396e495a805a/418bf6f8-212b-4bb0-a7a5-8231a061da4e/Q83Uqvu
Replit-Helium-Checkpoint-Created: true
Added two redirect routes in artifacts/api-server/src/app.ts:
- GET / → 302 redirect to /api/ui
- GET /api → 302 redirect to /api/ui
This means opening the preview URL or the root of the app immediately
lands on the Timmy UI without any manual navigation.
No changes to the UI itself, no new routes, no new files.
Verified: both / and /api return HTTP 302 with Location: /api/ui.
Implement session-based API endpoints for creating, managing, and interacting with pre-funded sessions, including deposit and top-up invoice generation, macaroon authentication, and per-request debiting of compute costs.
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 418bf6f8-212b-4bb0-a7a5-8231a061da4e
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 2dc3847e-7186-4a22-9c7e-16cd31bca8d9
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/9f85e954-647c-46a5-90a7-396e495a805a/418bf6f8-212b-4bb0-a7a5-8231a061da4e/sPDHkg8
Replit-Helium-Checkpoint-Created: true
## New files
- btc-oracle.ts: CoinGecko BTC/USD fetch (60s cache), usdToSats() helper (ceil, min 1 sat),
5s abort timeout, fallback to BTC_PRICE_USD_FALLBACK env var (default $100k)
- lib/db/migrations/0002_cost_based_pricing.sql: SQL migration artifact adding 6 new columns
to jobs table (estimated_cost_usd, margin_pct, btc_price_usd, actual_input_tokens,
actual_output_tokens, actual_cost_usd); idempotent via ADD COLUMN IF NOT EXISTS
## Modified files
- pricing.ts: Full rewrite — per-model token rates (Haiku/Sonnet, env-var overridable),
DO infra amortisation per request, originator margin %, estimateInputTokens/Output by tier,
calculateActualCostUsd() for post-work ledger, async calculateWorkFeeSats() → WorkFeeBreakdown
- agent.ts: WorkResult now includes inputTokens + outputTokens from Anthropic usage;
workModel/evalModel exposed as readonly public; EVAL_MODEL/WORK_MODEL env var support
- lib/db/src/schema/jobs.ts: 6 new real/integer columns; schema pushed to DB
- jobs.ts route: Work invoice creation calls pricingService.calculateWorkFeeSats() async;
stores estimatedCostUsd/marginPct/btcPriceUsd; post-work stores actualInputTokens/
actualOutputTokens/actualCostUsd; GET response includes pricingBreakdown and costLedger
with totalTokens (input + output computed field)
- openapi.yaml: PricingBreakdown + CostLedger schemas (with totalTokens) added
- lib/api-zod/src/generated/api.ts: Regenerated with new schemas
- lib/api-client-react/src/generated/api.schemas.ts: Regenerated (PricingBreakdown, CostLedger)
- replit.md: 17 new env vars documented in cost-based pricing section
- btc-oracle.ts: CoinGecko BTC/USD fetch (60s cache), usdToSats() helper,
fallback to BTC_PRICE_USD_FALLBACK env var (default $100k), 5s abort timeout
- pricing.ts: Full rewrite — per-model token rates (Haiku/Sonnet, env-var
overridable), DO infra amortisation, originator margin %, estimateInputTokens(),
estimateOutputTokens() by request tier, calculateActualCostUsd() for post-work ledger,
async calculateWorkFeeSats() → WorkFeeBreakdown
- agent.ts: WorkResult now includes inputTokens + outputTokens from Anthropic usage;
workModel/evalModel exposed as readonly public; EVAL_MODEL/WORK_MODEL env var support
- jobs.ts: Work invoice creation calls pricingService.calculateWorkFeeSats() async;
stores estimatedCostUsd/marginPct/btcPriceUsd on job; after executeWork stores
actualInputTokens/actualOutputTokens/actualCostUsd; GET response includes
pricingBreakdown (awaiting_work_payment) and costLedger (complete)
- lib/db/src/schema/jobs.ts: 6 new real/integer columns for cost tracking; schema pushed
- openapi.yaml: PricingBreakdown + CostLedger schemas added to JobStatusResponse
- replit.md: 17 new env vars documented in Cost-based work fee pricing section
1. Escape ${i} bash loop vars in TypeScript template literal (provisioner.ts)
- Four occurrences: Bitcoin RPC wait, LND REST wait, macaroon wait, LNbits wait
- Changed ${i}x5s → \${i}x5s so TypeScript doesn't try to resolve 'i'
- Confirmed: tsc reports no errors in provisioner.ts after fix
2. Install minimal ops.sh on provisioned node via cloud-init (provisioner.ts)
- Cloud-init step 15 writes /opt/timmy-node/ops.sh with sync/lnd/lnbits/logs cmds
- Uses single-quoted heredoc (<<'OPSSH') to prevent bash expanding ops.sh's
own $CMD / ${1:-help} / ${2:-bitcoin} variables during cloud-init execution
- chmod +x applied after write
- sync command: docker exec bitcoin bitcoin-cli getblockchaininfo | jq summary
- lnd, lnbits, logs subcommands also included
3. Update nextSteps to reference installed ops.sh (bootstrap.ts)
- "Monitor Bitcoin sync (takes 1-2 weeks to reach 100%): bash /opt/timmy-node/ops.sh sync"
- All other nextSteps reference files/URLs actually present on the node
4. Harden BOOTSTRAP_FEE_SATS parsing against NaN (pricing.ts)
- parseInt on empty/invalid env var → NaN
- Added Number.isFinite(rawFee) && rawFee > 0 guard → falls back to 10_000
- Same pattern could be applied to other numeric env vars as follow-up
End-to-end verified: POST → pay → provisioning → ready with correct nextSteps
4 changes to address code review rejections:
1. Race condition fix (bootstrap.ts)
- advanceBootstrapJob: WHERE now guards on AND state='awaiting_payment'
- If UPDATE matches 0 rows, re-fetch current job (already advanced by
another concurrent poll) instead of firing a second provisioner
- Verified with 5-concurrent-poll test: only 1 "starting provisioning"
log entry per job; all 5 responses show consistent state
2. Complete cloud-init to full Bitcoin + LND + LNbits stack (provisioner.ts)
- Phase 1: packages, Docker, Tailscale, UFW, block volume mount
- Phase 2: Bitcoin Core started; polls for RPC availability (max 5 min)
- Phase 3: LND started; waits for REST API (max 6 min)
- Phase 4: non-interactive LND wallet init via REST:
POST /v1/genseed → POST /v1/initwallet with base64 password
(no lncli, no interactive prompts, no expect)
- Phase 5: waits for admin.macaroon to appear on mounted volume
- Phase 6: LNbits started with LndRestWallet backend; mounts LND
data dir so it reads tls.cert + admin.macaroon automatically
- Phase 7: saves all credentials (RPC pass, LND wallet pass + seed
mnemonic, LNbits URL) to chmod 600 /root/node-credentials.txt
3. DO block volume support (provisioner.ts)
- Reads DO_VOLUME_SIZE_GB env var (0 = no volume, default)
- createVolume(): POST /v2/volumes (ext4 filesystem, tagged timmy-node)
- Passes volumeId in droplet create payload (attached at boot)
- Cloud-init Phase 1 detects and mounts the volume automatically
(lsblk scan → mkfs if unformatted → mount → /etc/fstab entry)
4. SSH keypair via node:crypto (no ssh-keygen) (provisioner.ts)
- generateKeyPairSync('rsa', { modulusLength: 4096 })
- Public key: PKCS#1 DER → OpenSSH wire format via manual DER parser
(pkcs1DerToSshPublicKey): reads SEQUENCE → n, e INTEGERs → ssh-rsa
base64 string with proper mpint encoding (leading 0x00 for high bit)
- Private key: PKCS#1 PEM (-----BEGIN RSA PRIVATE KEY-----)
- Both stub and real paths use the same generateSshKeypair() function
- Removes runtime dependency on host ssh-keygen binary entirely
4 changes to address code review rejections:
1. Race condition fix (bootstrap.ts)
- advanceBootstrapJob: WHERE now guards on AND state='awaiting_payment'
- If UPDATE matches 0 rows, re-fetch current job (already advanced by
another concurrent poll) instead of firing a second provisioner
- Verified with 5-concurrent-poll test: only 1 "starting provisioning"
log entry per job; all 5 responses show consistent state
2. Complete cloud-init to full Bitcoin + LND + LNbits stack (provisioner.ts)
- Phase 1: packages, Docker, Tailscale, UFW, block volume mount
- Phase 2: Bitcoin Core started; polls for RPC availability (max 5 min)
- Phase 3: LND started; waits for REST API (max 6 min)
- Phase 4: non-interactive LND wallet init via REST:
POST /v1/genseed → POST /v1/initwallet with base64 password
(no lncli, no interactive prompts, no expect)
- Phase 5: waits for admin.macaroon to appear on mounted volume
- Phase 6: LNbits started with LndRestWallet backend; mounts LND
data dir so it reads tls.cert + admin.macaroon automatically
- Phase 7: saves all credentials (RPC pass, LND wallet pass + seed
mnemonic, LNbits URL) to chmod 600 /root/node-credentials.txt
3. DO block volume support (provisioner.ts)
- Reads DO_VOLUME_SIZE_GB env var (0 = no volume, default)
- createVolume(): POST /v2/volumes (ext4 filesystem, tagged timmy-node)
- Passes volumeId in droplet create payload (attached at boot)
- Cloud-init Phase 1 detects and mounts the volume automatically
(lsblk scan → mkfs if unformatted → mount → /etc/fstab entry)
4. SSH keypair via node:crypto (no ssh-keygen) (provisioner.ts)
- generateKeyPairSync('rsa', { modulusLength: 4096 })
- Public key: PKCS#1 DER → OpenSSH wire format via manual DER parser
(pkcs1DerToSshPublicKey): reads SEQUENCE → n, e INTEGERs → ssh-rsa
base64 string with proper mpint encoding (leading 0x00 for high bit)
- Private key: PKCS#1 PEM (-----BEGIN RSA PRIVATE KEY-----)
- Both stub and real paths use the same generateSshKeypair() function
- Removes runtime dependency on host ssh-keygen binary entirely
Pay a Lightning invoice → Timmy auto-provisions a Bitcoin full node on DO.
New: lib/db/src/schema/bootstrap-jobs.ts
- bootstrap_jobs table: id, state, amountSats, paymentHash, paymentRequest,
dropletId, nodeIp, tailscaleHostname, lnbitsUrl, sshPrivateKey,
sshKeyDelivered (bool), errorMessage, createdAt, updatedAt
- States: awaiting_payment | provisioning | ready | failed
- Payment data stored inline (no FK to jobs/invoices tables — separate entity)
- db:push applied to create table in Postgres
New: artifacts/api-server/src/lib/provisioner.ts
- ProvisionerService: stubs when DO_API_TOKEN absent, real otherwise
- Stub mode: generates a real RSA 4096-bit SSH keypair via ssh-keygen,
returns RFC 5737 test IP + fake Tailscale hostname after 2s delay
- Real mode: upload SSH public key to DO → generate Tailscale auth key →
create DO droplet with cloud-init user_data → poll for public IP (2 min)
- buildCloudInitScript(): non-interactive bash that installs Docker + Tailscale
+ UFW + Bitcoin Knots via docker-compose; joins Tailscale if authkey provided
- provision() designed as fire-and-forget (void); updates DB to ready/failed
New: artifacts/api-server/src/routes/bootstrap.ts
- POST /api/bootstrap: create job + LNbits invoice, return paymentRequest
- GET /api/bootstrap/🆔 poll-driven state machine
* awaiting_payment: checks payment, fires provisioner on confirm
* provisioning: returns progress message
* ready: delivers credentials; SSH private key delivered once then cleared
* failed: returns error message
- Stub mode message includes the exact /dev/stub/pay URL for easy testing
- nextSteps array guides user through post-provision setup
Updated: artifacts/api-server/src/lib/pricing.ts
- Added bootstrapFee field reading BOOTSTRAP_FEE_SATS env var (default 10000)
- calculateBootstrapFeeSats() method
Updated: artifacts/api-server/src/routes/index.ts
- Mounts bootstrapRouter
Updated: replit.md
- Documents all 7 new env vars (DO_API_TOKEN, DO_REGION, DO_SIZE, etc.)
- Full curl-based flow example with annotated response shape
End-to-end verified in stub mode: POST → pay → provisioning → ready (SSH key)
→ second GET clears key and shows sshKeyNote
Integrate a new testkit endpoint and update package.json scripts to enable automated testing via `pnpm test` and `pnpm test:prod`, including a new test case for request body size limits.
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 418bf6f8-212b-4bb0-a7a5-8231a061da4e
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 60472e18-59b7-4877-a9a2-16381573ab68
Replit-Helium-Checkpoint-Created: true
Update API endpoints for jobs and demo routes to enforce a maximum character limit of 500 for the 'request' field. Refine error messages to distinguish between missing input and input exceeding the character limit.
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 418bf6f8-212b-4bb0-a7a5-8231a061da4e
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 8c43b6a7-30d3-4806-8d46-3d364b17c284
Replit-Helium-Checkpoint-Created: true