diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 24d664d..f17a61b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -186,11 +186,13 @@ Rules: `verifyQuoteSignature()` before using `depositAddress`; ids/decimals from ### 4.4 Worker loops | Loop | Every | Does | |---|---|---| -| `pollFunding` | 5 s | Non-terminal funding attempts → status | -| `scheduleBuys` | 60 s | Due vaults (`get_state`, balance) → quote → verify → plan match → cost check → `execute_buy` → submit tx | -| `pollBuys` | 10 s | In-flight buys → final status, destination tx | -| `expireQuotes` | 60 s | | -Alert when the keeper's NEAR balance is low. +| `pollFunding` | 5 s | Non-final funding attempts → 1Click status; PENDING past deadline → `expired`; alert on `underpaid`. One failing attempt never stops the others | +| `scheduleBuys` | 60 s | Per vault: skip if paused / not due / a buy is in flight / within the 15-min backoff after a skip or failure / vault empty. Amount = min(`amount_per_run`, balance). Real quote (fails closed on signature, K3.5) → cost check (`skipped_cost` + note) → plan match (`failed` "refused: …" + alert) → **insert row first** → `execute_buy` → `submitDepositTx` (best effort) | +| `pollBuys` | 10 s | In-flight buys → final status; on success the actual `amountOut` and destination tx from `swapDetails`; refunds noted with 1Click's reason | +| `expireQuotes` | 60 s | DB-only expiry of unpaid funding quotes (works while 1Click is down) | +| `keeperBalance` | 10 min | Alert below 1 NEAR — buys stop without gas | + +Runner: a loop never overlaps itself, errors are logged and the loop continues, SIGINT/SIGTERM wait for running ticks. Logs are JSON lines with no secrets or personal data. Rows are timestamped with the worker clock (`deps.now`), never the DB's. --- @@ -211,6 +213,7 @@ buys (id uuid pk, user_id uuid fk, quote_ref text unique not null, amount_in num amount_in_usd numeric, amount_out numeric(78,0), deposit_address text unique, quote_signature text, quote_raw jsonb, near_tx_hash text, oneclick_status text, status text not null, -- skipped_cost|no_quote|sent|processing|success|refunded|failed|expired + note text, -- why it was skipped, refused, failed or refunded (migration 0001) destination_tx_hash text, created_at timestamptz default now(), completed_at timestamptz) ``` On-chain vault = truth for plan and balance. DB = history and metadata. diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index 5a30f17..869eb30 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -79,7 +79,10 @@ On-chain vault = truth for plan + balance. - Funding status refreshes from 1Click on read; plan preview refunds to the user's own account before a vault exists ## Worker -`pollFunding` 5 s · `scheduleBuys` 60 s · `pollBuys` 10 s · `expireQuotes` 60 s · alert on low keeper gas +`pollFunding` 5 s · `scheduleBuys` 60 s · `pollBuys` 10 s · `expireQuotes` 60 s · keeper gas alert 10 min +- Every loop is `(deps) => Promise` (worker/deps.ts) — test one tick with fakes +- scheduleBuys order: due? → not in flight / not in 15-min backoff → amount = min(per-run, balance) → quote → cost → plan match → **insert row** → execute_buy → submit +- Every skip/refusal/failure writes `buys.note`; refusals and failures `log.alert` ## Demo & dry-run `pnpm demo` = zero secrets, PGlite, seeded vault + history, fast-forward 30 days with one `skipped_cost`. Never moves funds. diff --git a/ROADMAP.md b/ROADMAP.md index 7b02531..99517f4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -19,7 +19,7 @@ | 2. Core logic (`lib/core`) | Nov 6–7 | 100% | ✅ done | | 3. Integrations (1Click, NEAR, DB) | Nov 7–8 | 80% | in progress (K3.5 blocked on K0.8) | | 4. API routes | Nov 8–9 | 100% | ✅ done | -| 5. Worker (scheduler) | Nov 9–10 | 0% | not started | +| 5. Worker (scheduler) | Nov 9–10 | 100% | ✅ done | | 6. UI | Nov 9–11 | 0% | not started | | 7. Demo mode & dry-run | Nov 11 | 0% | not started | | 8. Hardening & edge cases | Nov 11–12 | 0% | not started | @@ -27,7 +27,7 @@ | 10. README & public repo | Nov 12–13 | 14% | in progress | | 11. Launch: video, post, submission | Nov 13–14 | 0% | not started | | 12. After submission | Nov 14–25 | 0% | not started | -| **Overall (MVP)** | | **~40%** | | +| **Overall (MVP)** | | **~48%** | | Phase % = done tasks ÷ non-dropped tasks in that phase. Overall = average of all 14 rows (D + phases 0–12). (Earlier entries over-estimated the overall %; recalculated 2026-11-04.) @@ -129,11 +129,11 @@ Format: `- [task id] what blocks it · who can unblock · since date` ## Phase 5 — Worker (Nov 9–10) -- [ ] **K5.1** [#50](https://github.com/MFrancis-dev/stack/issues/50) `worker/index.ts`, graceful shutdown -- [ ] **K5.2** [#51](https://github.com/MFrancis-dev/stack/issues/51) `pollFunding` · tests -- [ ] **K5.3** [#52](https://github.com/MFrancis-dev/stack/issues/52) `scheduleBuys`: due check → quote → verify → plan match → cost check → `execute_buy` → submit · tests for skip, no_quote, mismatch, success -- [ ] **K5.4** [#53](https://github.com/MFrancis-dev/stack/issues/53) `pollBuys` · tests -- [ ] **K5.5** [#54](https://github.com/MFrancis-dev/stack/issues/54) `expireQuotes`; low keeper-gas alert +- [x] **K5.1** [#50](https://github.com/MFrancis-dev/stack/issues/50) `worker/index.ts`, graceful shutdown · 2026-11-04: entry + `startLoops` runner (no overlap, errors logged, SIGINT/SIGTERM wait for ticks); smoke-run OK with no env +- [x] **K5.2** [#51](https://github.com/MFrancis-dev/stack/issues/51) `pollFunding` · tests · 2026-11-04: status mapping, expiry past deadline, underpaid alert, per-attempt error isolation +- [x] **K5.3** [#52](https://github.com/MFrancis-dev/stack/issues/52) `scheduleBuys`: due check → quote → verify → plan match → cost check → `execute_buy` → submit · tests for skip, no_quote, mismatch, success · 2026-11-04: due/in-flight/backoff/empty checks, min(per-run, balance), cost → plan match → insert-then-execute → submit; every skip/refusal/failure recorded with a note (new `buys.note`, migration 0001) +- [x] **K5.4** [#53](https://github.com/MFrancis-dev/stack/issues/53) `pollBuys` · tests · 2026-11-04: final status, actual amountOut + destination tx from swapDetails, refund reason +- [x] **K5.5** [#54](https://github.com/MFrancis-dev/stack/issues/54) `expireQuotes`; low keeper-gas alert · 2026-11-04: DB-only quote expiry; keeper balance alert below 1 NEAR ## Phase 6 — UI (Nov 9–11) @@ -216,6 +216,7 @@ Format: `- [task id] what blocks it · who can unblock · since date` ## Update log One line per contribution, newest first: `YYYY-MM-DD · who/agent · what changed · tasks` +- 2026-11-04 · agent · Phase 5 worker: pollFunding, scheduleBuys (full safety chain, backoff, insert-before-execute), pollBuys, expireQuotes, keeper gas alert, no-overlap runner; `buys.note` migration; 100 TS tests · K5.1–K5.5 - 2026-11-04 · agent · Phase 4 API: NEP-413 sessions, onboard, vault, tokens, funding (dry/real/status/submit), plan preview, buys, CSV; services seam for tests/demo; 84 TS tests · K4.1–K4.7, K8.7 - 2026-11-04 · agent · Phase 3 integrations: 1Click layer (fail-closed signatures), NEAR bindings + NEP-413 sign-in, first DB migration + queries; 66 TS tests; new K3.5 (signature verification) blocked on K0.8 · K3.1–K3.5 - 2026-11-04 · agent · Phase 2 core logic: money, cost, planMatch, address (checksums), assets, schemas; 33 TS tests · K2.1–K2.6 diff --git a/drizzle/0001_buy_note.sql b/drizzle/0001_buy_note.sql new file mode 100644 index 0000000..479a682 --- /dev/null +++ b/drizzle/0001_buy_note.sql @@ -0,0 +1 @@ +ALTER TABLE "buys" ADD COLUMN "note" text; \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..3351106 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,399 @@ +{ + "id": "e3b2ba63-4d7b-446c-bd57-f320950743ec", + "prevId": "75969550-fdff-4e54-abb8-cf6ef1bd5b4d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.buys": { + "name": "buys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "quote_ref": { + "name": "quote_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_in": { + "name": "amount_in", + "type": "numeric(78, 0)", + "primaryKey": false, + "notNull": true + }, + "target_asset": { + "name": "target_asset", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "destination_address": { + "name": "destination_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cost_bps": { + "name": "cost_bps", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "amount_in_usd": { + "name": "amount_in_usd", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "amount_out": { + "name": "amount_out", + "type": "numeric(78, 0)", + "primaryKey": false, + "notNull": false + }, + "deposit_address": { + "name": "deposit_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quote_signature": { + "name": "quote_signature", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quote_raw": { + "name": "quote_raw", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "near_tx_hash": { + "name": "near_tx_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oneclick_status": { + "name": "oneclick_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "destination_tx_hash": { + "name": "destination_tx_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "buys_user_idx": { + "name": "buys_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "buys_user_id_users_id_fk": { + "name": "buys_user_id_users_id_fk", + "tableFrom": "buys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "buys_quote_ref_unique": { + "name": "buys_quote_ref_unique", + "nullsNotDistinct": false, + "columns": [ + "quote_ref" + ] + }, + "buys_deposit_address_unique": { + "name": "buys_deposit_address_unique", + "nullsNotDistinct": false, + "columns": [ + "deposit_address" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.funding_attempts": { + "name": "funding_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "origin_asset": { + "name": "origin_asset", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_in": { + "name": "amount_in", + "type": "numeric(78, 0)", + "primaryKey": false, + "notNull": true + }, + "expected_out": { + "name": "expected_out", + "type": "numeric(78, 0)", + "primaryKey": false, + "notNull": true + }, + "refund_to": { + "name": "refund_to", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deposit_address": { + "name": "deposit_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quote_deadline": { + "name": "quote_deadline", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "quote_signature": { + "name": "quote_signature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quote_raw": { + "name": "quote_raw", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "oneclick_status": { + "name": "oneclick_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'awaiting_deposit'" + }, + "deposit_tx_hash": { + "name": "deposit_tx_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "funding_attempts_user_idx": { + "name": "funding_attempts_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "funding_attempts_user_id_users_id_fk": { + "name": "funding_attempts_user_id_users_id_fk", + "tableFrom": "funding_attempts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "funding_attempts_deposit_address_unique": { + "name": "funding_attempts_deposit_address_unique", + "nullsNotDistinct": false, + "columns": [ + "deposit_address" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "near_account_id": { + "name": "near_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vault_account_id": { + "name": "vault_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_demo": { + "name": "is_demo", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_near_account_id_unique": { + "name": "users_near_account_id_unique", + "nullsNotDistinct": false, + "columns": [ + "near_account_id" + ] + }, + "users_vault_account_id_unique": { + "name": "users_vault_account_id_unique", + "nullsNotDistinct": false, + "columns": [ + "vault_account_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index e38480e..8f5a4e1 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1793819928395, "tag": "0000_damp_silver_surfer", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1793820914606, + "tag": "0001_buy_note", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/db/queries/buys.ts b/lib/db/queries/buys.ts index fdf612a..a6e121c 100644 --- a/lib/db/queries/buys.ts +++ b/lib/db/queries/buys.ts @@ -26,3 +26,8 @@ export async function listBuysByUser(db: Db, userId: string, limit = 200): Promi export async function listInFlightBuys(db: Db): Promise { return db.select().from(buys).where(inArray(buys.status, ["sent", "processing"])); } + +/** Most recent buy row for a user (any status), used for in-flight and retry-backoff checks. */ +export async function lastBuyForUser(db: Db, userId: string): Promise { + return (await db.select().from(buys).where(eq(buys.userId, userId)).orderBy(desc(buys.createdAt)).limit(1))[0]; +} diff --git a/lib/db/queries/fundingAttempts.ts b/lib/db/queries/fundingAttempts.ts index 41e5892..685859a 100644 --- a/lib/db/queries/fundingAttempts.ts +++ b/lib/db/queries/fundingAttempts.ts @@ -2,7 +2,7 @@ * Queries for funding attempts (any token → USDC into the vault). Roadmap: K3.4 */ -import { and, desc, eq, notInArray } from "drizzle-orm"; +import { and, desc, eq, lt, notInArray } from "drizzle-orm"; import { FUNDING_TERMINAL, type FundingStatus } from "../../core/status"; import type { Db } from "../client"; import { fundingAttempts, type FundingAttempt } from "../schema"; @@ -37,3 +37,13 @@ export async function updateFundingStatus( .set({ ...patch, updatedAt: new Date() }) .where(eq(fundingAttempts.id, id)); } + +/** DB-only expiry for quotes nobody paid, so the worker stops polling them even if 1Click is unreachable. */ +export async function expireStaleFunding(db: Db, now: Date): Promise { + const rows = await db + .update(fundingAttempts) + .set({ status: "expired", updatedAt: now }) + .where(and(eq(fundingAttempts.status, "awaiting_deposit"), lt(fundingAttempts.quoteDeadline, now))) + .returning({ id: fundingAttempts.id }); + return rows.length; +} diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 10a244a..4b19e25 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -1,7 +1,6 @@ /** * Database schema (Drizzle). Amounts are numeric(78,0) base units. On-chain vault is the truth for plan and balance. * Roadmap: K3.4 · Spec: ARCHITECTURE.md §5 - * Status: scaffold — not implemented yet. */ import { boolean, index, integer, jsonb, numeric, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; @@ -65,6 +64,8 @@ export const buys = pgTable( oneclickStatus: text("oneclick_status"), status: text("status", { enum: BUY_STATUSES }).notNull(), destinationTxHash: text("destination_tx_hash"), + /** Why a buy was skipped, refused or failed — shown in history. */ + note: text("note"), createdAt: ts("created_at").notNull().defaultNow(), completedAt: ts("completed_at"), }, diff --git a/lib/near/rpc.ts b/lib/near/rpc.ts index 12a7ab2..bfa6299 100644 --- a/lib/near/rpc.ts +++ b/lib/near/rpc.ts @@ -4,7 +4,7 @@ import { JsonRpcProvider } from "near-api-js"; -export type NearProvider = Pick; +export type NearProvider = Pick; export function createProvider(url: string): JsonRpcProvider { return new JsonRpcProvider({ url }); diff --git a/project_structure.md b/project_structure.md index e2f01e6..e55c224 100644 --- a/project_structure.md +++ b/project_structure.md @@ -100,7 +100,8 @@ lib/ ## 6. `worker/` ``` -worker/index.ts · pollFunding.ts (5 s) · scheduleBuys.ts (60 s) · pollBuys.ts (10 s) · expireQuotes.ts (60 s) · alerts.ts +worker/index.ts (entry, intervals, shutdown) · runner.ts (no-overlap loops) · deps.ts (WorkerDeps, JSON logger) + pollFunding.ts (5 s) · scheduleBuys.ts (60 s) · pollBuys.ts (10 s) · expireQuotes.ts (60 s) · alerts.ts (keeper gas, 10 min) · worker.test.ts ``` ## 7. `e2e/`, `scripts/` diff --git a/worker/alerts.ts b/worker/alerts.ts index ed4f563..2f9f709 100644 --- a/worker/alerts.ts +++ b/worker/alerts.ts @@ -1,7 +1,17 @@ /** - * Alerts: low keeper gas, underpaid funding, failed buys. - * Roadmap: K5.5 - * Status: scaffold — not implemented yet. + * Health alerts. Low keeper gas would silently stop every scheduled buy, so it is checked on a timer. + * Roadmap: K5.5 · Spec: ARCHITECTURE.md §4.4 */ -export {}; +import type { WorkerDeps } from "./deps"; + +/** 1 NEAR in yocto. Each execute_buy also attaches ~0.013 NEAR for deposit-address storage. */ +export const KEEPER_MIN_BALANCE = 10n ** 24n; + +export async function checkKeeperBalance(deps: Pick, min = KEEPER_MIN_BALANCE): Promise { + if (!deps.near || !deps.keeperAccountId) return null; + const { amount } = await deps.near.viewAccount({ accountId: deps.keeperAccountId }); + const balance = BigInt(amount); + if (balance < min) deps.log.alert("keeper balance low — top it up or buys will stop", { keeper: deps.keeperAccountId, balanceYocto: balance.toString() }); + return balance; +} diff --git a/worker/deps.ts b/worker/deps.ts new file mode 100644 index 0000000..5e3d4ee --- /dev/null +++ b/worker/deps.ts @@ -0,0 +1,38 @@ +/** + * What every worker loop receives. Real values come from getServices(); tests pass fakes. + * Roadmap: K5.x + */ + +import type { QuoteProvider, StatusProvider, VaultClient } from "../lib/core/adapters"; +import type { Db } from "../lib/db/client"; +import type { NearProvider } from "../lib/near/rpc"; + +export interface Logger { + info(msg: string, data?: Record): void; + warn(msg: string, data?: Record): void; + /** Something a human should look at (low keeper gas, refused buy, …). */ + alert(msg: string, data?: Record): void; +} + +export interface WorkerDeps { + db: Db; + quotes: QuoteProvider; + status: StatusProvider; + /** null when the keeper isn't configured: scheduleBuys then does nothing. */ + vault: VaultClient | null; + near: Pick | null; + keeperAccountId: string | null; + now: () => number; + log: Logger; +} + +/** JSON-lines logger. Callers must never pass secrets or personal data. */ +export const consoleLogger: Logger = { + info: (msg, data) => console.log(JSON.stringify({ level: "info", msg, ...data })), + warn: (msg, data) => console.warn(JSON.stringify({ level: "warn", msg, ...data })), + alert: (msg, data) => console.error(JSON.stringify({ level: "alert", msg, ...data })), +}; + +export function errorMessage(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} diff --git a/worker/expireQuotes.ts b/worker/expireQuotes.ts index 3cb0cef..ad93a1b 100644 --- a/worker/expireQuotes.ts +++ b/worker/expireQuotes.ts @@ -1,10 +1,12 @@ /** - * Every 60 s: expire stale funding quotes. + * Every 60 s: unpaid funding quotes past their deadline → expired (DB only, works while 1Click is down). * Roadmap: K5.5 · Spec: ARCHITECTURE.md §4.4 - * Status: scaffold — not implemented yet. */ -/** One tick of the loop. Pure-ish: all I/O comes through `deps` so tests can run a single tick. */ -export async function expireQuotes(_deps: unknown): Promise { - // TODO(K5.5) +import { expireStaleFunding } from "../lib/db/queries/fundingAttempts"; +import type { WorkerDeps } from "./deps"; + +export async function expireQuotes(deps: Pick): Promise { + const n = await expireStaleFunding(deps.db, new Date(deps.now())); + if (n > 0) deps.log.info("expired unpaid funding quotes", { count: n }); } diff --git a/worker/index.ts b/worker/index.ts index e36f55c..0e95a83 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -1,8 +1,62 @@ /** - * Starts worker loops with graceful shutdown. + * Worker entry: `pnpm worker`. Polls funding and buys, schedules buys, expires quotes, watches keeper gas. + * Must run on a long-lived host (not serverless). Stops cleanly on SIGINT/SIGTERM. * Roadmap: K5.1 · Spec: ARCHITECTURE.md §4.4 - * Status: scaffold — not implemented yet. */ -// Loops: pollFunding 5 s, scheduleBuys 60 s, pollBuys 10 s, expireQuotes 60 s. -console.log("worker: not implemented yet (K5.1)"); +import { getServices } from "../lib/server/adapters"; +import { checkKeeperBalance } from "./alerts"; +import { consoleLogger, type WorkerDeps } from "./deps"; +import { expireQuotes } from "./expireQuotes"; +import { pollBuys } from "./pollBuys"; +import { pollFunding } from "./pollFunding"; +import { startLoops } from "./runner"; +import { scheduleBuys } from "./scheduleBuys"; + +export const INTERVALS = { + pollFunding: 5_000, + pollBuys: 10_000, + scheduleBuys: 60_000, + expireQuotes: 60_000, + keeperBalance: 10 * 60_000, +} as const; + +async function main() { + const s = await getServices(); + const deps: WorkerDeps = { + db: s.db, + quotes: s.quotes, + status: s.status, + vault: s.vault, + near: s.near, + keeperAccountId: process.env.KEEPER_ACCOUNT_ID || null, + now: Date.now, + log: consoleLogger, + }; + if (!deps.vault) consoleLogger.warn("keeper not configured: scheduleBuys will do nothing (run `pnpm check-env`)"); + + const runner = startLoops( + [ + { name: "pollFunding", everyMs: INTERVALS.pollFunding, tick: () => pollFunding(deps) }, + { name: "pollBuys", everyMs: INTERVALS.pollBuys, tick: () => pollBuys(deps) }, + { name: "scheduleBuys", everyMs: INTERVALS.scheduleBuys, tick: () => scheduleBuys(deps) }, + { name: "expireQuotes", everyMs: INTERVALS.expireQuotes, tick: () => expireQuotes(deps) }, + { name: "keeperBalance", everyMs: INTERVALS.keeperBalance, tick: () => checkKeeperBalance(deps) }, + ], + consoleLogger, + ); + consoleLogger.info("worker started", { loops: Object.keys(INTERVALS) }); + + const shutdown = async (signal: string) => { + consoleLogger.info("worker stopping", { signal }); + await runner.stop(); + process.exit(0); + }; + process.once("SIGINT", () => void shutdown("SIGINT")); + process.once("SIGTERM", () => void shutdown("SIGTERM")); +} + +main().catch((e) => { + consoleLogger.alert("worker failed to start", { error: e instanceof Error ? e.message : String(e) }); + process.exit(1); +}); diff --git a/worker/pollBuys.ts b/worker/pollBuys.ts index 0b9c996..ce224c0 100644 --- a/worker/pollBuys.ts +++ b/worker/pollBuys.ts @@ -1,10 +1,41 @@ /** - * Every 10 s: in-flight buys → final status + destination tx. - * Roadmap: K5.4 · Spec: ARCHITECTURE.md §4.4 - * Status: scaffold — not implemented yet. + * Every 10 s: in-flight buys → 1Click status → final status, actual amount out, destination tx. + * Refunds go back to the vault (KD-3), so a refunded buy needs no follow-up call. + * Roadmap: K5.4 · Spec: ARCHITECTURE.md §4.3 */ -/** One tick of the loop. Pure-ish: all I/O comes through `deps` so tests can run a single tick. */ -export async function pollBuys(_deps: unknown): Promise { - // TODO(K5.4) +import { asBaseUnits } from "../lib/core/money"; +import { BUY_TERMINAL, buyStatusFromOneClick } from "../lib/core/status"; +import { listInFlightBuys, updateBuy } from "../lib/db/queries/buys"; +import { errorMessage, type WorkerDeps } from "./deps"; + +interface SwapDetailsLike { + amountOut?: string; + destinationChainTxHashes?: { hash?: string }[]; + refundReason?: string; +} + +export async function pollBuys(deps: Pick): Promise { + for (const b of await listInFlightBuys(deps.db)) { + if (!b.depositAddress) continue; + try { + const r = await deps.status.getStatus(b.depositAddress); + const status = buyStatusFromOneClick(r.status); + if (status === b.status && r.status === b.oneclickStatus) continue; + const details = ((r.raw as { swapDetails?: SwapDetailsLike } | undefined)?.swapDetails ?? {}) as SwapDetailsLike; + const final = BUY_TERMINAL.has(status); + await updateBuy(deps.db, b.id, { + status, + oneclickStatus: r.status, + ...(final ? { completedAt: new Date(deps.now()) } : {}), + ...(status === "success" && details.amountOut && /^\d+$/.test(details.amountOut) ? { amountOut: asBaseUnits(details.amountOut) } : {}), + ...(details.destinationChainTxHashes?.[0]?.hash ? { destinationTxHash: details.destinationChainTxHashes[0].hash } : {}), + ...(status === "refunded" ? { note: `refunded to the vault${details.refundReason ? `: ${details.refundReason}` : ""}` } : {}), + }); + deps.log.info("buy status", { quoteRef: b.quoteRef, from: b.status, to: status }); + if (status === "failed") deps.log.alert("buy failed at 1Click", { quoteRef: b.quoteRef, oneclickStatus: r.status }); + } catch (e) { + deps.log.warn("buy poll failed", { quoteRef: b.quoteRef, error: errorMessage(e) }); + } + } } diff --git a/worker/pollFunding.ts b/worker/pollFunding.ts index 69fd6d7..32058ef 100644 --- a/worker/pollFunding.ts +++ b/worker/pollFunding.ts @@ -1,10 +1,26 @@ /** - * Every 5 s: non-terminal funding attempts → status. - * Roadmap: K5.2 · Spec: ARCHITECTURE.md §4.4 - * Status: scaffold — not implemented yet. + * Every 5 s: non-final funding attempts → 1Click status → our status. One failing attempt never + * stops the others. Roadmap: K5.2 · Spec: ARCHITECTURE.md §4.4 */ -/** One tick of the loop. Pure-ish: all I/O comes through `deps` so tests can run a single tick. */ -export async function pollFunding(_deps: unknown): Promise { - // TODO(K5.2) +import { fundingStatusFromOneClick, type FundingStatus } from "../lib/core/status"; +import { listActiveFunding, updateFundingStatus } from "../lib/db/queries/fundingAttempts"; +import { errorMessage, type WorkerDeps } from "./deps"; + +export async function pollFunding(deps: Pick): Promise { + const active = await listActiveFunding(deps.db); + for (const a of active) { + try { + const r = await deps.status.getStatus(a.depositAddress); + let status: FundingStatus = fundingStatusFromOneClick(r.status); + if (r.status === "PENDING_DEPOSIT" && a.quoteDeadline.getTime() < deps.now()) status = "expired"; + if (status !== a.status || r.status !== a.oneclickStatus) { + await updateFundingStatus(deps.db, a.id, { status, oneclickStatus: r.status }); + deps.log.info("funding status", { id: a.id, from: a.status, to: status }); + if (status === "underpaid") deps.log.alert("funding underpaid", { id: a.id }); + } + } catch (e) { + deps.log.warn("funding poll failed", { id: a.id, error: errorMessage(e) }); + } + } } diff --git a/worker/runner.ts b/worker/runner.ts new file mode 100644 index 0000000..fcf74cd --- /dev/null +++ b/worker/runner.ts @@ -0,0 +1,44 @@ +/** + * Runs named loops on intervals. A tick never overlaps itself, errors are logged and the loop goes on, + * and stop() waits for running ticks to finish. Roadmap: K5.1 + */ + +import { errorMessage, type Logger } from "./deps"; + +export interface LoopSpec { + name: string; + everyMs: number; + tick: () => Promise; +} + +export interface Runner { + stop(): Promise; +} + +export function startLoops(loops: LoopSpec[], log: Logger): Runner { + const timers: NodeJS.Timeout[] = []; + const running = new Map>(); + let stopping = false; + + for (const loop of loops) { + const run = () => { + if (stopping || running.has(loop.name)) return; + const p = loop + .tick() + .then(() => undefined) + .catch((e) => log.warn(`${loop.name} tick failed`, { error: errorMessage(e) })) + .finally(() => running.delete(loop.name)); + running.set(loop.name, p); + }; + run(); + timers.push(setInterval(run, loop.everyMs)); + } + + return { + async stop() { + stopping = true; + timers.forEach(clearInterval); + await Promise.all(running.values()); + }, + }; +} diff --git a/worker/scheduleBuys.ts b/worker/scheduleBuys.ts index 791144e..b5a7373 100644 --- a/worker/scheduleBuys.ts +++ b/worker/scheduleBuys.ts @@ -1,10 +1,141 @@ /** - * Every 60 s: due vaults → quote → verify → plan match → cost check → execute_buy → submit. - * Roadmap: K5.3 · Spec: ARCHITECTURE.md §4.4 - * Status: scaffold — not implemented yet. + * Every 60 s: due vaults → quote → cost check → plan match → execute_buy → submit deposit tx. + * The contract bounds amount and timing (I2, I3); this loop guards price and recipient. + * Roadmap: K5.3 · Spec: ARCHITECTURE.md §4.2, §4.4 */ -/** One tick of the loop. Pure-ish: all I/O comes through `deps` so tests can run a single tick. */ -export async function scheduleBuys(_deps: unknown): Promise { - // TODO(K5.3) +import { randomUUID } from "node:crypto"; +import { checkCost } from "../lib/core/cost"; +import { minUnits } from "../lib/core/money"; +import { quoteMatchesPlan } from "../lib/core/planMatch"; +import type { BaseUnits, QuoteSummary, VaultState } from "../lib/core/types"; +import { insertBuy, lastBuyForUser, updateBuy } from "../lib/db/queries/buys"; +import { listUsersWithVault } from "../lib/db/queries/users"; +import type { User } from "../lib/db/schema"; +import { errorMessage, type WorkerDeps } from "./deps"; + +/** After a skipped or failed attempt, wait this long before trying the same run again. */ +export const RETRY_BACKOFF_MS = 15 * 60 * 1000; + +export type BuyOutcome = "not_due" | "in_flight" | "backoff" | "empty" | "no_quote" | "skipped_cost" | "refused" | "failed" | "sent"; + +export async function scheduleBuys(deps: WorkerDeps): Promise> { + const outcomes = new Map(); + if (!deps.vault) return outcomes; + for (const user of await listUsersWithVault(deps.db)) { + try { + outcomes.set(user.id, await runForUser(deps, user)); + } catch (e) { + deps.log.warn("schedule failed", { userId: user.id, error: errorMessage(e) }); + } + } + return outcomes; } + +async function runForUser(deps: WorkerDeps, user: User): Promise { + const vaults = deps.vault!; + const vaultAccountId = user.vaultAccountId!; + const state = await vaults.getState(vaultAccountId); + const now = deps.now(); + if (state.paused || state.nextRunAtS === null || state.nextRunAtS * 1000 > now) return "not_due"; + + const last = await lastBuyForUser(deps.db, user.id); + if (last && (last.status === "sent" || last.status === "processing")) return "in_flight"; + if (last && last.status !== "success" && last.createdAt.getTime() > state.nextRunAtS * 1000 && now - last.createdAt.getTime() < RETRY_BACKOFF_MS) { + return "backoff"; + } + + const balance = await vaults.usdcBalance(vaultAccountId); + const amount = minUnits(state.amountPerRun, balance); + if (BigInt(amount) === 0n) return "empty"; + + const record = (status: "no_quote" | "skipped_cost" | "failed", note: string, quote?: QuoteSummary, costBps?: number | null) => + insertBuy(deps.db, { + userId: user.id, + quoteRef: newQuoteRef(), + amountIn: amount, + targetAsset: state.targetAsset!, + destinationAddress: state.destinationAddress!, + status, + note, + costBps: costBps ?? null, + amountInUsd: quote?.amountInUsd ?? null, + quoteRaw: quote ? (quote.raw as object) : null, + // Same clock as the backoff check below, never the DB's. + createdAt: new Date(now), + completedAt: new Date(now), + }); + + let quote: QuoteSummary; + try { + quote = await deps.quotes.buyQuote({ vault: state, vaultAccountId, amountIn: amount }, { dry: false }); + } catch (e) { + await record("no_quote", errorMessage(e)); + deps.log.warn("no quote", { userId: user.id, error: errorMessage(e) }); + return "no_quote"; + } + + const cost = checkCost(quote, state.maxCostBps); + if (!cost.ok) { + const note = cost.reason === "unknown_cost" ? "quote had no USD values" : `cost ${cost.costBps} bps above limit ${state.maxCostBps}`; + await record("skipped_cost", note, quote, cost.costBps); + deps.log.info("buy skipped for cost", { userId: user.id, costBps: cost.costBps }); + return "skipped_cost"; + } + + const match = quoteMatchesPlan(quote, state, vaultAccountId); + if (!match.ok) { + await record("failed", `refused: ${match.reason}`, quote, cost.costBps); + deps.log.alert("buy quote refused", { userId: user.id, reason: match.reason }); + return "refused"; + } + + return execute(deps, user, state, vaultAccountId, amount, quote, cost.costBps); +} + +async function execute( + deps: WorkerDeps, + user: User, + state: VaultState, + vaultAccountId: string, + amount: BaseUnits, + quote: QuoteSummary, + costBps: number, +): Promise { + // Record before acting, so an on-chain action is never left without a row. + const row = await insertBuy(deps.db, { + userId: user.id, + quoteRef: newQuoteRef(), + amountIn: amount, + targetAsset: state.targetAsset!, + destinationAddress: state.destinationAddress!, + costBps, + amountInUsd: quote.amountInUsd, + amountOut: quote.amountOut, + depositAddress: quote.depositAddress, + quoteSignature: quote.signature, + quoteRaw: quote.raw as object, + status: "sent", + createdAt: new Date(deps.now()), + }); + try { + const { txHash } = await deps.vault!.executeBuy({ vaultAccountId, quoteRef: row.quoteRef, depositAddress: quote.depositAddress!, amount }); + await updateBuy(deps.db, row.id, { nearTxHash: txHash }); + deps.log.info("buy sent", { userId: user.id, quoteRef: row.quoteRef, txHash }); + try { + await deps.status.submitDepositTx(quote.depositAddress!, txHash); + } catch (e) { + deps.log.warn("deposit submit failed (1Click will still detect it)", { quoteRef: row.quoteRef, error: errorMessage(e) }); + } + return "sent"; + } catch (e) { + await updateBuy(deps.db, row.id, { status: "failed", note: errorMessage(e), completedAt: new Date(deps.now()) }); + deps.log.alert("execute_buy failed", { userId: user.id, quoteRef: row.quoteRef, error: errorMessage(e) }); + return "failed"; + } +} + +function newQuoteRef(): string { + return `b_${randomUUID()}`; +} + diff --git a/worker/worker.test.ts b/worker/worker.test.ts new file mode 100644 index 0000000..8bd434f --- /dev/null +++ b/worker/worker.test.ts @@ -0,0 +1,294 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { USDC_ON_NEAR } from "../lib/core/assets"; +import { asBaseUnits } from "../lib/core/money"; +import type { OneClickStatus } from "../lib/core/status"; +import type { QuoteSummary, VaultState } from "../lib/core/types"; +import { createDb, type Db } from "../lib/db/client"; +import { insertBuy, listBuysByUser } from "../lib/db/queries/buys"; +import { getFundingAttempt, insertFundingAttempt } from "../lib/db/queries/fundingAttempts"; +import { setVault, upsertUser } from "../lib/db/queries/users"; +import { checkKeeperBalance } from "./alerts"; +import type { Logger, WorkerDeps } from "./deps"; +import { expireQuotes } from "./expireQuotes"; +import { pollBuys } from "./pollBuys"; +import { pollFunding } from "./pollFunding"; +import { startLoops } from "./runner"; +import { RETRY_BACKOFF_MS, scheduleBuys } from "./scheduleBuys"; + +// Roadmap: K5.1–K5.5 +const VAULT = "vabc0123456789def.factory.stack.near"; +const NOW = Date.parse("2026-11-10T12:00:00Z"); +const DEST = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq"; + +let db: Db; +let userId: string; +let statusOf: (depositAddress: string) => OneClickStatus; +let rawStatus: unknown; +let state: VaultState; +let balance: string; + +const logs: { level: string; msg: string }[] = []; +const log: Logger = { + info: (msg) => logs.push({ level: "info", msg }), + warn: (msg) => logs.push({ level: "warn", msg }), + alert: (msg) => logs.push({ level: "alert", msg }), +}; + +const goodQuote = (amountIn: string, over: Partial = {}): QuoteSummary => ({ + dry: false, + originAsset: USDC_ON_NEAR, + refundTo: VAULT, + depositAddress: "dep-buy-1", + amountIn: asBaseUnits(amountIn), + amountOut: asBaseUnits("121000"), + minAmountOut: asBaseUnits("119000"), + timeEstimateS: 465, + amountInUsd: "100.00", + amountOutUsd: "98.40", + recipient: DEST, + destinationAsset: "nep141:btc.omft.near", + deadline: new Date(NOW + 600_000).toISOString(), + signature: "ed25519:sig", + raw: {}, + ...over, +}); + +const fakes = { + buyQuote: vi.fn(async (input: { amountIn: string }) => goodQuote(input.amountIn)), + executeBuy: vi.fn(async () => ({ txHash: "NEARTX" })), + submitDepositTx: vi.fn(async () => {}), +}; + +function deps(over: Partial = {}): WorkerDeps { + return { + db, + quotes: { fundingQuote: vi.fn(), buyQuote: fakes.buyQuote } as never, + status: { getStatus: async (a: string) => ({ status: statusOf(a), raw: rawStatus }), submitDepositTx: fakes.submitDepositTx }, + vault: { + findVault: vi.fn(), + createVaultFor: vi.fn(), + getState: async () => state, + usdcBalance: async () => asBaseUnits(balance), + executeBuy: fakes.executeBuy, + }, + near: null, + keeperAccountId: null, + now: () => NOW, + log, + ...over, + }; +} + +beforeEach(async () => { + db = createDb({ pgliteDataDir: "memory://" }); + await db.migrate(); + const u = await upsertUser(db, "alice.near"); + await setVault(db, u.id, VAULT); + userId = u.id; + statusOf = () => "PENDING_DEPOSIT"; + rawStatus = {}; + balance = "500000000"; + state = { + owner: "alice.near", + keeper: "keeper.near", + usdc: "usdc.near", + targetAsset: "nep141:btc.omft.near", + destinationAddress: DEST, + amountPerRun: asBaseUnits("100000000"), + intervalS: 86_400, + maxCostBps: 300, + paused: false, + lastRunAtS: NOW / 1000 - 86_400, + nextRunAtS: NOW / 1000, + }; + logs.length = 0; + vi.clearAllMocks(); +}); + +describe("scheduleBuys (K5.3)", () => { + it("runs the full chain: quote → cost → plan match → execute_buy → submit", async () => { + const out = await scheduleBuys(deps()); + expect(out.get(userId)).toBe("sent"); + expect(fakes.executeBuy).toHaveBeenCalledWith({ vaultAccountId: VAULT, quoteRef: expect.stringMatching(/^b_/), depositAddress: "dep-buy-1", amount: "100000000" }); + expect(fakes.submitDepositTx).toHaveBeenCalledWith("dep-buy-1", "NEARTX"); + const [b] = await listBuysByUser(db, userId); + expect(b).toMatchObject({ status: "sent", nearTxHash: "NEARTX", costBps: 160, depositAddress: "dep-buy-1" }); + }); + + it("buys only what the vault holds when the balance is below one run", async () => { + balance = "40000000"; + await scheduleBuys(deps()); + expect(fakes.executeBuy).toHaveBeenCalledWith(expect.objectContaining({ amount: "40000000" })); + }); + + it("does nothing when not due, paused, or empty", async () => { + state = { ...state, nextRunAtS: NOW / 1000 + 60 }; + expect((await scheduleBuys(deps())).get(userId)).toBe("not_due"); + state = { ...state, nextRunAtS: NOW / 1000, paused: true }; + expect((await scheduleBuys(deps())).get(userId)).toBe("not_due"); + state = { ...state, paused: false }; + balance = "0"; + expect((await scheduleBuys(deps())).get(userId)).toBe("empty"); + expect(fakes.buyQuote).not.toHaveBeenCalled(); + }); + + it("skips (and records why) when the cost is over the limit", async () => { + fakes.buyQuote.mockImplementationOnce(async (i) => goodQuote(i.amountIn, { amountInUsd: "10.00", amountOutUsd: "8.46" })); + expect((await scheduleBuys(deps())).get(userId)).toBe("skipped_cost"); + expect(fakes.executeBuy).not.toHaveBeenCalled(); + const [b] = await listBuysByUser(db, userId); + expect(b).toMatchObject({ status: "skipped_cost", costBps: 1540, note: "cost 1540 bps above limit 300" }); + }); + + it("refuses and alerts when the quote doesn't match the on-chain plan", async () => { + fakes.buyQuote.mockImplementationOnce(async (i) => goodQuote(i.amountIn, { recipient: "bc1qattacker" })); + expect((await scheduleBuys(deps())).get(userId)).toBe("refused"); + expect(fakes.executeBuy).not.toHaveBeenCalled(); + expect((await listBuysByUser(db, userId))[0]?.note).toBe("refused: recipient differs from plan"); + expect(logs.some((l) => l.level === "alert" && l.msg === "buy quote refused")).toBe(true); + }); + + it("records no_quote when 1Click can't quote", async () => { + fakes.buyQuote.mockRejectedValueOnce(new Error("No quote available")); + expect((await scheduleBuys(deps())).get(userId)).toBe("no_quote"); + expect((await listBuysByUser(db, userId))[0]).toMatchObject({ status: "no_quote", note: "No quote available" }); + }); + + it("marks the row failed (and alerts) when execute_buy fails", async () => { + fakes.executeBuy.mockRejectedValueOnce(new Error("Buy transfer failed on-chain (tx X)")); + expect((await scheduleBuys(deps())).get(userId)).toBe("failed"); + expect((await listBuysByUser(db, userId))[0]).toMatchObject({ status: "failed", note: "Buy transfer failed on-chain (tx X)" }); + }); + + it("waits while a buy is in flight, and backs off after a skip", async () => { + await insertBuy(db, { userId, quoteRef: "inflight", amountIn: asBaseUnits("1"), targetAsset: "t", destinationAddress: "d", status: "sent" }); + expect((await scheduleBuys(deps())).get(userId)).toBe("in_flight"); + + db = createDb({ pgliteDataDir: "memory://" }); + await db.migrate(); + const u = await upsertUser(db, "alice.near"); + await setVault(db, u.id, VAULT); + userId = u.id; + fakes.buyQuote.mockImplementationOnce(async (i) => goodQuote(i.amountIn, { amountInUsd: "10.00", amountOutUsd: "8.46" })); + expect((await scheduleBuys(deps({ now: () => NOW + 1_000 }))).get(userId)).toBe("skipped_cost"); + expect((await scheduleBuys(deps({ now: () => NOW + 60_000 }))).get(userId)).toBe("backoff"); + expect((await scheduleBuys(deps({ now: () => NOW + 1_000 + RETRY_BACKOFF_MS + 1 }))).get(userId)).toBe("sent"); + }); + + it("does nothing without a keeper", async () => { + expect((await scheduleBuys(deps({ vault: null }))).size).toBe(0); + }); +}); + +describe("pollBuys (K5.4)", () => { + async function sentBuy() { + return insertBuy(db, { userId, quoteRef: "q1", amountIn: asBaseUnits("100000000"), targetAsset: "nep141:btc.omft.near", destinationAddress: DEST, depositAddress: "dep-buy-1", amountOut: asBaseUnits("121000"), status: "sent" }); + } + + it("finalises a successful buy with the actual amount and destination tx", async () => { + await sentBuy(); + statusOf = () => "SUCCESS"; + rawStatus = { swapDetails: { amountOut: "120500", destinationChainTxHashes: [{ hash: "btc-tx-1", explorerUrl: "" }] } }; + await pollBuys(deps()); + expect((await listBuysByUser(db, userId))[0]).toMatchObject({ status: "success", amountOut: "120500", destinationTxHash: "btc-tx-1" }); + }); + + it("records refunds back to the vault with the reason", async () => { + await sentBuy(); + statusOf = () => "REFUNDED"; + rawStatus = { swapDetails: { refundReason: "price moved" } }; + await pollBuys(deps()); + expect((await listBuysByUser(db, userId))[0]).toMatchObject({ status: "refunded", note: "refunded to the vault: price moved" }); + }); + + it("keeps polling processing buys", async () => { + await sentBuy(); + statusOf = () => "PROCESSING"; + await pollBuys(deps()); + const [b] = await listBuysByUser(db, userId); + expect(b).toMatchObject({ status: "processing", completedAt: null }); + }); +}); + +describe("pollFunding + expireQuotes (K5.2, K5.5)", () => { + const attempt = (depositAddress: string, deadline: number) => + insertFundingAttempt(db, { + userId, + originAsset: "nep141:sol.omft.near", + amountIn: asBaseUnits("1000000000"), + expectedOut: asBaseUnits("95000000"), + refundTo: "So1", + depositAddress, + quoteDeadline: new Date(deadline), + quoteSignature: "s", + quoteRaw: {}, + }); + + it("credits, flags underpaid, expires, and keeps going past errors", async () => { + const ok = await attempt("dep-ok", NOW + 600_000); + const short = await attempt("dep-short", NOW + 600_000); + const stale = await attempt("dep-stale", NOW - 1); + const broken = await attempt("dep-broken", NOW + 600_000); + statusOf = (a) => { + if (a === "dep-broken") throw new Error("1Click down"); + return a === "dep-ok" ? "SUCCESS" : a === "dep-short" ? "INCOMPLETE_DEPOSIT" : "PENDING_DEPOSIT"; + }; + await pollFunding(deps()); + expect((await getFundingAttempt(db, ok.id, userId))?.status).toBe("credited"); + expect((await getFundingAttempt(db, short.id, userId))?.status).toBe("underpaid"); + expect((await getFundingAttempt(db, stale.id, userId))?.status).toBe("expired"); + expect((await getFundingAttempt(db, broken.id, userId))?.status).toBe("awaiting_deposit"); + expect(logs.some((l) => l.level === "alert" && l.msg === "funding underpaid")).toBe(true); + }); + + it("expires unpaid quotes without calling 1Click", async () => { + const a = await attempt("dep-old", NOW - 1); + const b = await attempt("dep-new", NOW + 1); + await expireQuotes(deps()); + expect((await getFundingAttempt(db, a.id, userId))?.status).toBe("expired"); + expect((await getFundingAttempt(db, b.id, userId))?.status).toBe("awaiting_deposit"); + }); +}); + +describe("alerts (K5.5)", () => { + it("alerts when the keeper's NEAR balance is low", async () => { + const near = { viewAccount: vi.fn(async () => ({ amount: 5n * 10n ** 23n })) }; + expect(await checkKeeperBalance(deps({ near: near as never, keeperAccountId: "keeper.near" }))).toBe(5n * 10n ** 23n); + expect(logs.some((l) => l.level === "alert")).toBe(true); + logs.length = 0; + near.viewAccount.mockResolvedValueOnce({ amount: 3n * 10n ** 24n }); + await checkKeeperBalance(deps({ near: near as never, keeperAccountId: "keeper.near" })); + expect(logs).toHaveLength(0); + }); +}); + +describe("runner (K5.1)", () => { + it("never overlaps a tick, survives errors, and waits for running ticks on stop", async () => { + let active = 0; + let maxActive = 0; + let calls = 0; + const runner = startLoops( + [ + { + name: "slow", + everyMs: 5, + tick: async () => { + calls++; + active++; + maxActive = Math.max(maxActive, active); + await new Promise((r) => setTimeout(r, 30)); + active--; + if (calls === 1) throw new Error("boom"); + }, + }, + ], + log, + ); + await new Promise((r) => setTimeout(r, 100)); + await runner.stop(); + expect(maxActive).toBe(1); + expect(calls).toBeGreaterThan(1); + expect(active).toBe(0); + expect(logs.some((l) => l.msg === "slow tick failed")).toBe(true); + }); +});