From 548ece61b157f524db09f2c601a8651e3739faba Mon Sep 17 00:00:00 2001 From: Allison Muyideen Opeyemi Date: Wed, 4 Nov 2026 20:21:12 +0100 Subject: [PATCH] Phase 3: 1Click, NEAR and database integrations - lib/oneclick: SDK config, token cache, funding and buy quote requests with per-chain deadlines, fail-closed signature check for real quotes, status polling, deposit submit, readable errors; real dry-quote fixtures - lib/near: RPC views, NEP-297 event parsing, vault and factory bindings, keeper signer, NEP-413 sign-in verification, VaultClient adapter - lib/db: first migration, migrate() for PGlite and Postgres, queries for users, funding attempts and buys - 66 TS tests; new task K3.5 (#110) for signature verification, blocked on the partner JWT --- ARCHITECTURE.md | 1 + ARCHITECTURE_ESSENTIALS.md | 1 + ROADMAP.md | 15 +- drizzle/0000_damp_silver_surfer.sql | 66 +++ drizzle/meta/0000_snapshot.json | 393 ++++++++++++++++++ drizzle/meta/_journal.json | 13 + lib/core/status.ts | 7 +- lib/db/client.test.ts | 72 +++- lib/db/client.ts | 19 +- lib/db/queries/buys.ts | 29 +- lib/db/queries/fundingAttempts.ts | 40 +- lib/db/queries/users.ts | 34 +- lib/near/events.ts | 44 +- lib/near/factory.ts | 41 +- lib/near/index.ts | 33 ++ lib/near/keeper.ts | 11 +- lib/near/near.test.ts | 119 ++++++ lib/near/nep413.ts | 36 +- lib/near/rpc.ts | 16 +- lib/near/vault.ts | 82 +++- lib/oneclick/__fixtures__/.gitkeep | 0 .../__fixtures__/dry-quote-usdc-btc-100.json | 1 + .../__fixtures__/dry-quote-usdc-eth-10.json | 1 + lib/oneclick/client.ts | 38 +- lib/oneclick/index.ts | 34 ++ lib/oneclick/quote.test.ts | 106 +++++ lib/oneclick/quote.ts | 127 +++++- lib/oneclick/status.test.ts | 43 ++ lib/oneclick/status.ts | 37 +- lib/oneclick/tokens.ts | 42 +- project_structure.md | 6 +- 31 files changed, 1438 insertions(+), 69 deletions(-) create mode 100644 drizzle/0000_damp_silver_surfer.sql create mode 100644 drizzle/meta/0000_snapshot.json create mode 100644 drizzle/meta/_journal.json create mode 100644 lib/near/index.ts create mode 100644 lib/near/near.test.ts delete mode 100644 lib/oneclick/__fixtures__/.gitkeep create mode 100644 lib/oneclick/__fixtures__/dry-quote-usdc-btc-100.json create mode 100644 lib/oneclick/__fixtures__/dry-quote-usdc-eth-10.json create mode 100644 lib/oneclick/index.ts create mode 100644 lib/oneclick/quote.test.ts create mode 100644 lib/oneclick/status.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d80203b..af15fdd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -315,6 +315,7 @@ Top level: `correlationId`, `quote`, `quoteRequest`, `signature`, `timestamp`. ` - Intents withdrawals to a NEAR account **give no refund if the receiver isn't storage-registered** on the token → the factory must register the vault on USDC (K1.7) and owners must be registered on `wrap.near` before a NEAR plan (K8.2). Non-negotiable. - `customRecipientMsg` (marked "highly experimental") delivers with `ft_transfer_call`; not needed by the vault design. - Optional `insured: true` adds 0.02%; a LIMIT-order API exists in the SDK (relevant to the "buy the dip" stretch). +- **Quote signatures (K3.5):** SDK 0.1.26 `verifyQuoteSignature()` returns false for real, untampered unauthenticated dry quotes (the SDK hashes a fixed field list; the API now echoes `confidentiality`, `insured`, `depositMode`). Real quotes therefore **fail closed** until verification is confirmed with a partner JWT. - Non-dry quotes without a partner JWT returned "Failed to get quote" → **K0.9 (deposit-address storage) is blocked on K0.8 (JWT)**. ### Global contracts (docs.near.org, NEP-591) diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index 8252369..a64d95e 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -35,6 +35,7 @@ _None._ ## 🔍 Still to verify 1. Does 1Click storage-register NEAR deposit addresses? (K-B1) — **blocked on the partner JWT (K0.8)** 2. Deposits arriving after the quote deadline +3. **K3.5** Quote signatures verify on real quotes — **blocked on the JWT**; until then real quotes fail closed, dry previews skip the check ## Stack Rust near-sdk =5.30.0 + cargo-near 0.22 + near-workspaces =0.24.0 · Next.js 15.5 + TS strict · Tailwind + shadcn/ui · TanStack Query · zod · Drizzle (PGlite/Postgres) · 1Click SDK 0.1.26 · `@hot-labs/near-connect` 0.12.0 + `near-api-js` 7.4.0 · vitest + Playwright · pnpm 11 (worker/scripts run with `--conditions=react-server` so `server-only` works) diff --git a/ROADMAP.md b/ROADMAP.md index 0c8b9e9..59d626e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -17,7 +17,7 @@ | 0. Setup & verification | Nov 4–5 | 80% | in progress (K0.9 blocked on K0.8; K0.8, K0.14 👤) | | 1. Contracts | Nov 5–7 | 92% | in progress (K1.13 deploy is 👤) | | 2. Core logic (`lib/core`) | Nov 6–7 | 100% | ✅ done | -| 3. Integrations (1Click, NEAR, DB) | Nov 7–8 | 0% | in progress (schema drafted) | +| 3. Integrations (1Click, NEAR, DB) | Nov 7–8 | 80% | in progress (K3.5 blocked on K0.8) | | 4. API routes | Nov 8–9 | 0% | not started | | 5. Worker (scheduler) | Nov 9–10 | 0% | not started | | 6. UI | Nov 9–11 | 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)** | | **~27%** | | +| **Overall (MVP)** | | **~33%** | | 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.) @@ -47,6 +47,7 @@ Phase % = done tasks ÷ non-dropped tasks in that phase. Overall = average of al ## Blockers - [K0.9] Real (non-dry) 1Click quotes need a partner JWT · 👤 human via K0.8 (partners.near-intents.org) · since 2026-11-04 +- [K3.5] Confirming quote-signature verification needs a real quote → needs the partner JWT (K0.8) · 👤 human · since 2026-11-04 Format: `- [task id] what blocks it · who can unblock · since date` @@ -110,10 +111,11 @@ Format: `- [task id] what blocks it · who can unblock · since date` ## Phase 3 — Integrations (Nov 7–8) -- [ ] **K3.1** [#39](https://github.com/MFrancis-dev/stack/issues/39) `lib/oneclick`: client, tokens cache, funding + buy quotes (dry/real), verify, status, submit -- [ ] **K3.2** [#40](https://github.com/MFrancis-dev/stack/issues/40) Fixtures for every status + valid/tampered signatures -- [ ] **K3.3** [#41](https://github.com/MFrancis-dev/stack/issues/41) `lib/near`: rpc (`get_state`, `ft_balance_of`), nep413 verify, factory + vault bindings, keeper signer (server only), event parsing -- [~] **K3.4** [#42](https://github.com/MFrancis-dev/stack/issues/42) (2026-11-04: all 3 tables written; migration not generated) `lib/db`: schema (users, funding_attempts, buys) + migration + queries +- [x] **K3.1** [#39](https://github.com/MFrancis-dev/stack/issues/39) `lib/oneclick`: client, tokens cache, funding + buy quotes (dry/real), verify, status, submit · 2026-11-04: `lib/oneclick`: configure, tokens (10-min cache, stale on outage), funding/buy EXACT_INPUT requests, per-chain deadlines, `requestQuote` (real quotes fail closed on bad signature), status + submit, readable errors +- [x] **K3.2** [#40](https://github.com/MFrancis-dev/stack/issues/40) Fixtures for every status + valid/tampered signatures · 2026-11-04: real dry-quote fixtures (BTC $100, ETH $10) + status fixtures for all 7 values; tampered/unknown cases tested +- [x] **K3.3** [#41](https://github.com/MFrancis-dev/stack/issues/41) `lib/near`: rpc (`get_state`, `ft_balance_of`), nep413 verify, factory + vault bindings, keeper signer (server only), event parsing · 2026-11-04: `lib/near`: rpc views, NEP-297 event parsing, vault/factory bindings, keeper signer (server-only), NEP-413 `verifySignIn` (tested with a real signature), `nearVaultClient` adapter +- [x] **K3.4** [#42](https://github.com/MFrancis-dev/stack/issues/42) (2026-11-04: all 3 tables written; migration not generated) `lib/db`: schema (users, funding_attempts, buys) + migration + queries · 2026-11-04: first migration (`drizzle/0000_…sql`), `db.migrate()` for PGlite + Postgres, queries for users/funding/buys, terminal-status sets; tested on in-memory PGlite +- [!] **K3.5** [#110](https://github.com/MFrancis-dev/stack/issues/110) 🔍 Verify 1Click quote signatures on real quotes · 2026-11-04: SDK 0.1.26 `verifyQuoteSignature` fails on real unauthenticated dry quotes; real quotes fail closed until verified with the partner JWT (K0.8) ## Phase 4 — API routes (Nov 8–9) @@ -214,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 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 - 2026-11-04 · agent · Rewrote `main` history (approved force-push) to drop tool attribution lines from commit messages; every commit's files unchanged · — - 2026-11-04 · agent · Phase 1 contracts: vault + factory implemented, mock-ft crate, 49 contract tests passing (unit + near-workspaces sandbox); wasm vault 137 KB / factory 146 KB · K1.1–K1.10, K1.12 diff --git a/drizzle/0000_damp_silver_surfer.sql b/drizzle/0000_damp_silver_surfer.sql new file mode 100644 index 0000000..145151c --- /dev/null +++ b/drizzle/0000_damp_silver_surfer.sql @@ -0,0 +1,66 @@ +CREATE TABLE IF NOT EXISTS "buys" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "quote_ref" text NOT NULL, + "amount_in" numeric(78, 0) NOT NULL, + "target_asset" text NOT NULL, + "destination_address" text NOT NULL, + "cost_bps" integer, + "amount_in_usd" numeric, + "amount_out" numeric(78, 0), + "deposit_address" text, + "quote_signature" text, + "quote_raw" jsonb, + "near_tx_hash" text, + "oneclick_status" text, + "status" text NOT NULL, + "destination_tx_hash" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "completed_at" timestamp with time zone, + CONSTRAINT "buys_quote_ref_unique" UNIQUE("quote_ref"), + CONSTRAINT "buys_deposit_address_unique" UNIQUE("deposit_address") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "funding_attempts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "origin_asset" text NOT NULL, + "amount_in" numeric(78, 0) NOT NULL, + "expected_out" numeric(78, 0) NOT NULL, + "refund_to" text NOT NULL, + "deposit_address" text NOT NULL, + "quote_deadline" timestamp with time zone NOT NULL, + "quote_signature" text NOT NULL, + "quote_raw" jsonb NOT NULL, + "oneclick_status" text, + "status" text DEFAULT 'awaiting_deposit' NOT NULL, + "deposit_tx_hash" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "funding_attempts_deposit_address_unique" UNIQUE("deposit_address") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "users" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "near_account_id" text NOT NULL, + "vault_account_id" text, + "is_demo" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "users_near_account_id_unique" UNIQUE("near_account_id"), + CONSTRAINT "users_vault_account_id_unique" UNIQUE("vault_account_id") +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "buys" ADD CONSTRAINT "buys_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "funding_attempts" ADD CONSTRAINT "funding_attempts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "buys_user_idx" ON "buys" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "funding_attempts_user_idx" ON "funding_attempts" USING btree ("user_id"); \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..455e966 --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,393 @@ +{ + "id": "75969550-fdff-4e54-abb8-cf6ef1bd5b4d", + "prevId": "00000000-0000-0000-0000-000000000000", + "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 + }, + "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 new file mode 100644 index 0000000..e38480e --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1793819928395, + "tag": "0000_damp_silver_surfer", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/lib/core/status.ts b/lib/core/status.ts index fe7ccb3..564bee9 100644 --- a/lib/core/status.ts +++ b/lib/core/status.ts @@ -1,7 +1,6 @@ /** * Status unions and 1Click → our status mapping for funding and buys. * Roadmap: K2.4 · Spec: ARCHITECTURE.md §4.3 - * Status: scaffold — not implemented yet. */ /** 1Click /v0/status values. 🔍 Verify the exact enum (K0.11). */ @@ -75,3 +74,9 @@ export function buyStatusFromOneClick(s: OneClickStatus): BuyStatus { return "refunded"; } } + +/** Funding attempts the worker stops polling. `underpaid` stays open: 1Click may allow a top-up until the deadline. */ +export const FUNDING_TERMINAL: ReadonlySet = new Set(["credited", "refunded", "failed", "expired"]); + +/** Buys the worker stops polling. */ +export const BUY_TERMINAL: ReadonlySet = new Set(["skipped_cost", "no_quote", "success", "refunded", "failed", "expired"]); diff --git a/lib/db/client.test.ts b/lib/db/client.test.ts index 7cd0ba1..2906d52 100644 --- a/lib/db/client.test.ts +++ b/lib/db/client.test.ts @@ -1,12 +1,74 @@ import { sql } from "drizzle-orm"; -import { describe, expect, it } from "vitest"; -import { createDb } from "./client"; +import { beforeEach, describe, expect, it } from "vitest"; +import { asBaseUnits } from "../core/money"; +import { createDb, type Db } from "./client"; +import { insertBuy, listBuysByUser, listInFlightBuys, updateBuy } from "./queries/buys"; +import { getFundingAttempt, insertFundingAttempt, listActiveFunding, updateFundingStatus } from "./queries/fundingAttempts"; +import { getUserByAccount, listUsersWithVault, setVault, upsertUser } from "./queries/users"; -// Roadmap: K0.6 -describe("db client", () => { +// Roadmap: K0.6, K3.4 — runs the real migrations on in-memory PGlite +let db: Db; +beforeEach(async () => { + db = createDb({ pgliteDataDir: "memory://" }); + await db.migrate(); +}); + +const funding = (userId: string, depositAddress: string) => ({ + userId, + originAsset: "nep141:sol.omft.near", + amountIn: asBaseUnits("1000000000"), + expectedOut: asBaseUnits("95000000"), + refundTo: "So1Refund", + depositAddress, + quoteDeadline: new Date("2026-11-04T20:00:00Z"), + quoteSignature: "ed25519:x", + quoteRaw: { any: "json" }, +}); + +describe("db", () => { it("runs a query on in-memory PGlite with no DATABASE_URL", async () => { - const db = createDb({ pgliteDataDir: "memory://" }); const rows = await db.execute(sql`select 1 + 1 as two`); expect(JSON.stringify(rows)).toContain("2"); }); + + it("upserts users and records their vault", async () => { + const u = await upsertUser(db, "alice.near"); + expect((await upsertUser(db, "alice.near")).id).toBe(u.id); + expect(await listUsersWithVault(db)).toHaveLength(0); + await setVault(db, u.id, "vabc.factory.near"); + expect((await getUserByAccount(db, "alice.near"))?.vaultAccountId).toBe("vabc.factory.near"); + expect(await listUsersWithVault(db)).toHaveLength(1); + }); + + it("tracks funding attempts until they are terminal, scoped to the user", async () => { + const alice = await upsertUser(db, "alice.near"); + const bob = await upsertUser(db, "bob.near"); + const a = await insertFundingAttempt(db, funding(alice.id, "dep1")); + expect(a.status).toBe("awaiting_deposit"); + expect(a.amountIn).toBe("1000000000"); + expect(await listActiveFunding(db)).toHaveLength(1); + expect(await getFundingAttempt(db, a.id, bob.id)).toBeUndefined(); + await updateFundingStatus(db, a.id, { status: "underpaid", oneclickStatus: "INCOMPLETE_DEPOSIT" }); + expect(await listActiveFunding(db)).toHaveLength(1); // underpaid can still be topped up + await updateFundingStatus(db, a.id, { status: "credited", oneclickStatus: "SUCCESS" }); + expect(await listActiveFunding(db)).toHaveLength(0); + }); + + it("enforces unique deposit addresses", async () => { + const u = await upsertUser(db, "alice.near"); + await insertFundingAttempt(db, funding(u.id, "dep1")); + await expect(insertFundingAttempt(db, funding(u.id, "dep1"))).rejects.toThrow(); + }); + + it("records buys incl. skipped ones and lists in-flight buys", async () => { + const u = await upsertUser(db, "alice.near"); + const base = { userId: u.id, amountIn: asBaseUnits("100000000"), targetAsset: "nep141:btc.omft.near", destinationAddress: "bc1q" }; + await insertBuy(db, { ...base, quoteRef: "q-skip", status: "skipped_cost", costBps: 1542 }); + const b = await insertBuy(db, { ...base, quoteRef: "q-1", status: "sent", depositAddress: "dep9" }); + expect((await listInFlightBuys(db)).map((x) => x.quoteRef)).toEqual(["q-1"]); + await updateBuy(db, b.id, { status: "success", completedAt: new Date() }); + expect(await listInFlightBuys(db)).toHaveLength(0); + expect(await listBuysByUser(db, u.id)).toHaveLength(2); + await expect(insertBuy(db, { ...base, quoteRef: "q-1", status: "sent" })).rejects.toThrow(); + }); }); diff --git a/lib/db/client.ts b/lib/db/client.ts index 4fb2218..e249740 100644 --- a/lib/db/client.ts +++ b/lib/db/client.ts @@ -1,20 +1,27 @@ /** * DB client: embedded PGlite when DATABASE_URL is empty (local + demo mode, zero config), - * Postgres when it is set (hosted). Both expose the same Drizzle pg API. - * Roadmap: K0.6 · Spec: ARCHITECTURE.md §2 + * Postgres when it is set (hosted). Both expose the same Drizzle pg API plus `migrate()`. + * Roadmap: K0.6, K3.4 · Spec: ARCHITECTURE.md §2 */ import { PGlite } from "@electric-sql/pglite"; +import path from "node:path"; import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core"; import { drizzle as drizzlePglite } from "drizzle-orm/pglite"; +import { migrate as migratePglite } from "drizzle-orm/pglite/migrator"; import { drizzle as drizzlePostgres } from "drizzle-orm/postgres-js"; +import { migrate as migratePostgres } from "drizzle-orm/postgres-js/migrator"; import postgres from "postgres"; import * as schema from "./schema"; -export type Db = PgDatabase; +export type Db = PgDatabase & { + /** Applies the SQL migrations in ./drizzle. Safe to call repeatedly. */ + migrate(): Promise; +}; /** Same folder drizzle.config.ts uses, so `pnpm db:migrate` and the app share one local DB. */ export const DEFAULT_PGLITE_DIR = "./.pglite"; +export const MIGRATIONS_FOLDER = path.join(process.cwd(), "drizzle"); export interface DbOptions { /** Postgres connection string. Empty or undefined → PGlite. */ @@ -25,9 +32,11 @@ export interface DbOptions { export function createDb({ databaseUrl, pgliteDataDir = DEFAULT_PGLITE_DIR }: DbOptions = {}): Db { if (databaseUrl) { - return drizzlePostgres(postgres(databaseUrl), { schema }) as unknown as Db; + const db = drizzlePostgres(postgres(databaseUrl), { schema }); + return Object.assign(db, { migrate: () => migratePostgres(db, { migrationsFolder: MIGRATIONS_FOLDER }) }) as unknown as Db; } - return drizzlePglite(new PGlite(pgliteDataDir), { schema }) as unknown as Db; + const db = drizzlePglite(new PGlite(pgliteDataDir), { schema }); + return Object.assign(db, { migrate: () => migratePglite(db, { migrationsFolder: MIGRATIONS_FOLDER }) }) as unknown as Db; } let shared: Db | undefined; diff --git a/lib/db/queries/buys.ts b/lib/db/queries/buys.ts index 2e2bbb7..fdf612a 100644 --- a/lib/db/queries/buys.ts +++ b/lib/db/queries/buys.ts @@ -1,7 +1,28 @@ /** - * Queries for the buys table. - * Roadmap: K3.4 - * Status: scaffold — not implemented yet. + * Queries for buys, including skipped ones. Roadmap: K3.4 */ -export {}; +import { desc, eq, inArray } from "drizzle-orm"; +import type { BuyStatus } from "../../core/status"; +import type { Db } from "../client"; +import { buys, type Buy } from "../schema"; + +export type NewBuy = typeof buys.$inferInsert; + +export async function insertBuy(db: Db, row: NewBuy): Promise { + const [r] = await db.insert(buys).values(row).returning(); + return r!; +} + +export async function updateBuy(db: Db, id: string, patch: Partial> & { status?: BuyStatus }): Promise { + await db.update(buys).set(patch).where(eq(buys.id, id)); +} + +export async function listBuysByUser(db: Db, userId: string, limit = 200): Promise { + return db.select().from(buys).where(eq(buys.userId, userId)).orderBy(desc(buys.createdAt)).limit(limit); +} + +/** Buys sent on-chain whose 1Click status isn't final yet. */ +export async function listInFlightBuys(db: Db): Promise { + return db.select().from(buys).where(inArray(buys.status, ["sent", "processing"])); +} diff --git a/lib/db/queries/fundingAttempts.ts b/lib/db/queries/fundingAttempts.ts index 1bd173a..41e5892 100644 --- a/lib/db/queries/fundingAttempts.ts +++ b/lib/db/queries/fundingAttempts.ts @@ -1,7 +1,39 @@ /** - * Queries for the fundingAttempts table. - * Roadmap: K3.4 - * Status: scaffold — not implemented yet. + * Queries for funding attempts (any token → USDC into the vault). Roadmap: K3.4 */ -export {}; +import { and, desc, eq, notInArray } from "drizzle-orm"; +import { FUNDING_TERMINAL, type FundingStatus } from "../../core/status"; +import type { Db } from "../client"; +import { fundingAttempts, type FundingAttempt } from "../schema"; + +export type NewFundingAttempt = typeof fundingAttempts.$inferInsert; + +export async function insertFundingAttempt(db: Db, row: NewFundingAttempt): Promise { + const [r] = await db.insert(fundingAttempts).values(row).returning(); + return r!; +} + +export async function getFundingAttempt(db: Db, id: string, userId: string): Promise { + return (await db.select().from(fundingAttempts).where(and(eq(fundingAttempts.id, id), eq(fundingAttempts.userId, userId))).limit(1))[0]; +} + +export async function listFundingByUser(db: Db, userId: string, limit = 50): Promise { + return db.select().from(fundingAttempts).where(eq(fundingAttempts.userId, userId)).orderBy(desc(fundingAttempts.createdAt)).limit(limit); +} + +/** Attempts the worker still polls. */ +export async function listActiveFunding(db: Db): Promise { + return db.select().from(fundingAttempts).where(notInArray(fundingAttempts.status, [...FUNDING_TERMINAL])); +} + +export async function updateFundingStatus( + db: Db, + id: string, + patch: { status: FundingStatus; oneclickStatus?: string | null; depositTxHash?: string | null }, +): Promise { + await db + .update(fundingAttempts) + .set({ ...patch, updatedAt: new Date() }) + .where(eq(fundingAttempts.id, id)); +} diff --git a/lib/db/queries/users.ts b/lib/db/queries/users.ts index 19a7563..4b6e019 100644 --- a/lib/db/queries/users.ts +++ b/lib/db/queries/users.ts @@ -1,7 +1,33 @@ /** - * Queries for the users table. - * Roadmap: K3.4 - * Status: scaffold — not implemented yet. + * Queries for users. Roadmap: K3.4 */ -export {}; +import { eq, isNotNull } from "drizzle-orm"; +import type { Db } from "../client"; +import { users, type User } from "../schema"; + +export async function upsertUser(db: Db, nearAccountId: string, opts: { isDemo?: boolean } = {}): Promise { + const [row] = await db + .insert(users) + .values({ nearAccountId, isDemo: opts.isDemo ?? false }) + .onConflictDoUpdate({ target: users.nearAccountId, set: { nearAccountId } }) + .returning(); + return row!; +} + +export async function getUserByAccount(db: Db, nearAccountId: string): Promise { + return (await db.select().from(users).where(eq(users.nearAccountId, nearAccountId)).limit(1))[0]; +} + +export async function getUserById(db: Db, id: string): Promise { + return (await db.select().from(users).where(eq(users.id, id)).limit(1))[0]; +} + +export async function setVault(db: Db, userId: string, vaultAccountId: string): Promise { + await db.update(users).set({ vaultAccountId }).where(eq(users.id, userId)); +} + +/** Everyone the scheduler has to look at. */ +export async function listUsersWithVault(db: Db): Promise { + return db.select().from(users).where(isNotNull(users.vaultAccountId)); +} diff --git a/lib/near/events.ts b/lib/near/events.ts index 926907e..8f8eca6 100644 --- a/lib/near/events.ts +++ b/lib/near/events.ts @@ -1,7 +1,43 @@ /** - * Parses NEP-297 vault events from tx outcomes. - * Roadmap: K3.3 · Spec: ARCHITECTURE.md §3 - * Status: scaffold — not implemented yet. + * NEP-297 events from transaction outcomes (`EVENT_JSON:{...}` logs). Roadmap: K3.3 */ -export {}; +export interface NearEvent { + standard: string; + version: string; + event: string; + data?: unknown; +} + +const PREFIX = "EVENT_JSON:"; + +export function parseEvents(logs: readonly string[]): NearEvent[] { + const out: NearEvent[] = []; + for (const log of logs) { + if (!log.startsWith(PREFIX)) continue; + try { + const e = JSON.parse(log.slice(PREFIX.length)) as Partial; + if (typeof e.standard === "string" && typeof e.event === "string") { + out.push({ standard: e.standard, version: String(e.version ?? ""), event: e.event, data: e.data }); + } + } catch { + /* not ours */ + } + } + return out; +} + +/** Minimal shape of an RPC transaction response we rely on. */ +export interface TxOutcomeLike { + transaction_outcome?: { id?: string }; + transaction?: { hash?: string }; + receipts_outcome?: { outcome?: { logs?: string[] } }[]; +} + +export function txHash(res: TxOutcomeLike): string { + return res.transaction_outcome?.id ?? res.transaction?.hash ?? ""; +} + +export function eventsOf(res: TxOutcomeLike): NearEvent[] { + return parseEvents((res.receipts_outcome ?? []).flatMap((r) => r.outcome?.logs ?? [])); +} diff --git a/lib/near/factory.ts b/lib/near/factory.ts index f3a412a..9fc2f77 100644 --- a/lib/near/factory.ts +++ b/lib/near/factory.ts @@ -1,7 +1,40 @@ /** - * Typed factory bindings (create_vault_for, get_vault). - * Roadmap: K3.3 · Spec: ARCHITECTURE.md §3 - * Status: scaffold — not implemented yet. + * Typed factory bindings. Roadmap: K3.3 · Spec: ARCHITECTURE.md §3.2 */ -export {}; +import { actions } from "near-api-js"; +import { eventsOf, txHash, type NearEvent, type TxOutcomeLike } from "./events"; +import { view, type NearProvider } from "./rpc"; +import type { KeeperAccount } from "./vault"; + +export const CREATE_VAULT_GAS = 300_000_000_000_000n; + +export async function getVault(provider: Pick, factoryId: string, owner: string): Promise { + return (await view(provider, factoryId, "get_vault", { owner })) ?? null; +} + +export interface CreateVaultResult { + txHash: string; + /** Account id when the factory emitted `vault_created`. */ + vaultAccountId: string | null; + /** Created but USDC registration failed: keeper should call `retry_registration`. */ + registrationFailed: boolean; + events: NearEvent[]; +} + +export async function createVaultFor(keeper: KeeperAccount, factoryId: string, owner: string): Promise { + const res = (await keeper.signAndSendTransaction({ + receiverId: factoryId, + actions: [actions.functionCall("create_vault_for", { owner }, CREATE_VAULT_GAS, 0n)], + })) as TxOutcomeLike; + const events = eventsOf(res); + const created = events.find((e) => e.event === "vault_created"); + const data = (created?.data as { vault?: string }[] | { vault?: string } | undefined) ?? undefined; + const vault = Array.isArray(data) ? data[0]?.vault : data?.vault; + return { + txHash: txHash(res), + vaultAccountId: vault ?? null, + registrationFailed: events.some((e) => e.event === "vault_registration_failed"), + events, + }; +} diff --git a/lib/near/index.ts b/lib/near/index.ts new file mode 100644 index 0000000..f432d6c --- /dev/null +++ b/lib/near/index.ts @@ -0,0 +1,33 @@ +/** + * Real VaultClient backed by NEAR RPC + the keeper account. Demo client lives in demo.ts (K7.1). + * Roadmap: K3.3 + */ + +import type { VaultClient } from "../core/adapters"; +import { createVaultFor } from "./factory"; +import type { NearProvider } from "./rpc"; +import { executeBuy, getUsdcBalance, getVaultState, type KeeperAccount } from "./vault"; + +export function nearVaultClient(deps: { + provider: Pick; + keeper: KeeperAccount; + factoryId: string; + usdcContractId: string; +}): VaultClient { + return { + getState: (vaultAccountId) => getVaultState(deps.provider, vaultAccountId), + usdcBalance: (vaultAccountId) => getUsdcBalance(deps.provider, deps.usdcContractId, vaultAccountId), + async createVaultFor(owner) { + const r = await createVaultFor(deps.keeper, deps.factoryId, owner); + if (!r.vaultAccountId) { + throw new Error(r.registrationFailed ? `Vault created but USDC registration failed (tx ${r.txHash})` : `Vault creation failed (tx ${r.txHash})`); + } + return { vaultAccountId: r.vaultAccountId, txHash: r.txHash }; + }, + async executeBuy(input) { + const r = await executeBuy(deps.keeper, input); + if (!r.sent) throw new Error(`Buy transfer failed on-chain (tx ${r.txHash})`); + return { txHash: r.txHash }; + }, + }; +} diff --git a/lib/near/keeper.ts b/lib/near/keeper.ts index 31bb794..281698e 100644 --- a/lib/near/keeper.ts +++ b/lib/near/keeper.ts @@ -1,9 +1,14 @@ import "server-only"; /** - * Keeper signer. Server/worker only — never imported by client code. + * Keeper signer. Server/worker only — never imported by client code. The keeper account holds gas + * money only; it has no path to vault funds beyond bounded buys (invariant I5). * Roadmap: K3.3 · Spec: ARCHITECTURE.md §3.1 - * Status: scaffold — not implemented yet. */ -export {}; +import { Account, KeyPairSigner, type KeyPairString } from "near-api-js"; +import { createProvider } from "./rpc"; + +export function createKeeper({ accountId, privateKey, rpcUrl }: { accountId: string; privateKey: string; rpcUrl: string }): Account { + return new Account(accountId, createProvider(rpcUrl), KeyPairSigner.fromSecretKey(privateKey as KeyPairString)); +} diff --git a/lib/near/near.test.ts b/lib/near/near.test.ts new file mode 100644 index 0000000..527d238 --- /dev/null +++ b/lib/near/near.test.ts @@ -0,0 +1,119 @@ +import { Account, KeyPair, KeyPairSigner } from "near-api-js"; +import { signMessage } from "near-api-js/nep413"; +import { describe, expect, it, vi } from "vitest"; +import { asBaseUnits } from "../core/money"; +import { createVaultFor } from "./factory"; +import { eventsOf, parseEvents } from "./events"; +import { nearVaultClient } from "./index"; +import { verifySignIn } from "./nep413"; +import { executeBuy, toVaultState, type VaultViewRaw } from "./vault"; + +// Roadmap: K3.3 +const ev = (event: string, data: unknown = []) => `EVENT_JSON:${JSON.stringify({ standard: "stack_vault", version: "1.0.0", event, data })}`; +const outcome = (logs: string[]) => ({ transaction_outcome: { id: "TX1" }, receipts_outcome: [{ outcome: { logs } }] }); + +describe("events", () => { + it("parses NEP-297 logs and ignores everything else", () => { + const e = parseEvents(["hello", ev("paused"), "EVENT_JSON:{broken", ev("buy_sent", [{ quote_ref: "q1" }])]); + expect(e.map((x) => x.event)).toEqual(["paused", "buy_sent"]); + expect(eventsOf(outcome([ev("resumed")]))[0]?.event).toBe("resumed"); + }); +}); + +describe("vault bindings", () => { + const raw: VaultViewRaw = { + owner: "alice.near", + keeper: "keeper.near", + usdc: "usdc.near", + target_asset: "nep141:btc.omft.near", + destination_address: "bc1q", + amount_per_run: "100000000", + interval_s: 3600, + max_cost_bps: 300, + paused: false, + last_run_at_s: 10, + next_run_at_s: 3610, + }; + + it("maps get_state to camelCase", () => { + expect(toVaultState(raw)).toMatchObject({ targetAsset: "nep141:btc.omft.near", amountPerRun: "100000000", nextRunAtS: 3610 }); + }); + + it("sends execute_buy with storage deposit and reports buy_sent", async () => { + const keeper = { signAndSendTransaction: vi.fn().mockResolvedValue(outcome([ev("buy_sent")])) }; + const r = await executeBuy(keeper, { vaultAccountId: "v1.factory.near", quoteRef: "q1", depositAddress: "dep.near", amount: asBaseUnits("5") }); + expect(r).toMatchObject({ txHash: "TX1", sent: true }); + const call = keeper.signAndSendTransaction.mock.calls[0]![0]; + expect(call.receiverId).toBe("v1.factory.near"); + expect(JSON.stringify(call.actions[0], (_k, v) => (typeof v === "bigint" ? v.toString() : v))).toContain("execute_buy"); + }); + + it("treats buy_failed as a failure in the adapter", async () => { + const keeper = { signAndSendTransaction: vi.fn().mockResolvedValue(outcome([ev("buy_failed")])) }; + const client = nearVaultClient({ provider: { callFunction: vi.fn() } as never, keeper, factoryId: "f", usdcContractId: "u" }); + await expect(client.executeBuy({ vaultAccountId: "v", quoteRef: "q", depositAddress: "d", amount: asBaseUnits("1") })).rejects.toThrow(/failed on-chain/); + }); + + it("reads views through the provider", async () => { + const provider = { callFunction: vi.fn().mockResolvedValueOnce(raw).mockResolvedValueOnce("250") }; + const client = nearVaultClient({ provider: provider as never, keeper: { signAndSendTransaction: vi.fn() }, factoryId: "f", usdcContractId: "usdc.near" }); + expect((await client.getState("v")).paused).toBe(false); + expect(await client.usdcBalance("v")).toBe("250"); + expect(provider.callFunction).toHaveBeenLastCalledWith({ contractId: "usdc.near", method: "ft_balance_of", args: { account_id: "v" } }); + }); +}); + +describe("factory bindings", () => { + it("returns the vault id from vault_created", async () => { + const log = `EVENT_JSON:${JSON.stringify({ standard: "stack_factory", version: "1.0.0", event: "vault_created", data: [{ owner: "alice.near", vault: "vabc.factory.near" }] })}`; + const keeper = { signAndSendTransaction: vi.fn().mockResolvedValue(outcome([log])) }; + expect(await createVaultFor(keeper, "factory.near", "alice.near")).toMatchObject({ vaultAccountId: "vabc.factory.near", registrationFailed: false }); + }); + + it("flags a failed USDC registration", async () => { + const log = `EVENT_JSON:${JSON.stringify({ standard: "stack_factory", version: "1.0.0", event: "vault_registration_failed", data: [{}] })}`; + const keeper = { signAndSendTransaction: vi.fn().mockResolvedValue(outcome([log])) }; + const client = nearVaultClient({ provider: { callFunction: vi.fn() } as never, keeper, factoryId: "f", usdcContractId: "u" }); + await expect(client.createVaultFor("alice.near")).rejects.toThrow(/registration failed/); + }); +}); + +describe("NEP-413 sign-in", () => { + const recipient = "stack.app"; + const nonce = new Uint8Array(32).fill(7); + + async function signed(message = "Sign in to Stack") { + const key = KeyPair.fromRandom("ed25519"); + const account = new Account("alice.near", "http://127.0.0.1:1", new KeyPairSigner(key)); + const s = await signMessage({ signerAccount: account, payload: { message, recipient, nonce } }); + return { + proof: { + accountId: "alice.near", + publicKey: key.getPublicKey().toString(), + signature: Buffer.from(s.signature).toString("base64"), + message, + nonce: Buffer.from(nonce).toString("base64"), + }, + }; + } + + const fullAccess = { viewAccessKey: vi.fn().mockResolvedValue({ permission: "FullAccess" }) }; + + it("accepts a valid signature from a full-access key", async () => { + const { proof } = await signed(); + await expect(verifySignIn(proof, recipient, fullAccess as never)).resolves.toBeUndefined(); + }); + + it("rejects a changed message, a wrong recipient, and a function-call key", async () => { + const { proof } = await signed(); + await expect(verifySignIn({ ...proof, message: "Sign in to Stack!" }, recipient, fullAccess as never)).rejects.toThrow(); + await expect(verifySignIn(proof, "evil.app", fullAccess as never)).rejects.toThrow(); + const fcKey = { viewAccessKey: vi.fn().mockResolvedValue({ permission: { FunctionCall: {} } }) }; + await expect(verifySignIn(proof, recipient, fcKey as never)).rejects.toThrow(/FullAccess/); + }); + + it("rejects nonces that aren't 32 bytes", async () => { + const { proof } = await signed(); + await expect(verifySignIn({ ...proof, nonce: Buffer.from("short").toString("base64") }, recipient, fullAccess as never)).rejects.toThrow(/32 bytes/); + }); +}); diff --git a/lib/near/nep413.ts b/lib/near/nep413.ts index eb980fb..b0648f0 100644 --- a/lib/near/nep413.ts +++ b/lib/near/nep413.ts @@ -1,7 +1,35 @@ /** - * Verifies NEP-413 signed sign-in messages. - * Roadmap: K3.3 · Spec: ARCHITECTURE.md §3 - * Status: scaffold — not implemented yet. + * Sign-in verification for NEP-413 signed messages (from `wallet.signMessage` in near-connect). + * `verifyMessage` checks the signature AND that the key is a full-access key of the account. + * Roadmap: K3.3 · Spec: ARCHITECTURE.md §6 (onboard) */ -export {}; +import { verifyMessage } from "near-api-js/nep413"; +import type { NearProvider } from "./rpc"; + +export interface SignInProof { + accountId: string; + publicKey: string; + /** base64, as NEP-413 wallets return it */ + signature: string; + message: string; + /** base64 of the 32-byte nonce the server issued */ + nonce: string; +} + +export function decodeNonce(nonceB64: string): Uint8Array { + const bytes = Uint8Array.from(Buffer.from(nonceB64, "base64")); + if (bytes.length !== 32) throw new Error("Nonce must be 32 bytes"); + return bytes; +} + +/** Throws if the proof is invalid. `recipient` must be this app's identifier. */ +export async function verifySignIn(proof: SignInProof, recipient: string, provider: Pick): Promise { + await verifyMessage({ + signerAccountId: proof.accountId, + signerPublicKey: proof.publicKey, + payload: { message: proof.message, recipient, nonce: decodeNonce(proof.nonce) }, + signature: Uint8Array.from(Buffer.from(proof.signature, "base64")), + provider: provider as Parameters[0]["provider"], + }); +} diff --git a/lib/near/rpc.ts b/lib/near/rpc.ts index dc7e518..12a7ab2 100644 --- a/lib/near/rpc.ts +++ b/lib/near/rpc.ts @@ -1,7 +1,15 @@ /** - * NEAR RPC views: get_state, ft_balance_of. - * Roadmap: K3.3 · Spec: ARCHITECTURE.md §3 - * Status: scaffold — not implemented yet. + * NEAR RPC views. Roadmap: K3.3 · Spec: ARCHITECTURE.md §3 */ -export {}; +import { JsonRpcProvider } from "near-api-js"; + +export type NearProvider = Pick; + +export function createProvider(url: string): JsonRpcProvider { + return new JsonRpcProvider({ url }); +} + +export async function view(provider: Pick, contractId: string, method: string, args: Record = {}): Promise { + return (await provider.callFunction({ contractId, method, args })) as T; +} diff --git a/lib/near/vault.ts b/lib/near/vault.ts index 20ab553..745d8a0 100644 --- a/lib/near/vault.ts +++ b/lib/near/vault.ts @@ -1,7 +1,81 @@ /** - * Typed vault bindings (views + keeper execute_buy). - * Roadmap: K3.3 · Spec: ARCHITECTURE.md §3 - * Status: scaffold — not implemented yet. + * Typed vault bindings: views (anyone) and execute_buy (keeper). Roadmap: K3.3 · Spec: ARCHITECTURE.md §3.3 */ -export {}; +import { actions } from "near-api-js"; +import { asBaseUnits } from "../core/money"; +import type { BaseUnits, VaultState } from "../core/types"; +import { eventsOf, txHash, type NearEvent, type TxOutcomeLike } from "./events"; +import { view, type NearProvider } from "./rpc"; + +/** Raw `get_state` (contracts/vault/src/lib.rs VaultView). */ +export interface VaultViewRaw { + owner: string; + keeper: string; + usdc: string; + target_asset: string | null; + destination_address: string | null; + amount_per_run: string; + interval_s: number; + max_cost_bps: number; + paused: boolean; + last_run_at_s: number; + next_run_at_s: number | null; +} + +export function toVaultState(v: VaultViewRaw): VaultState { + return { + owner: v.owner, + keeper: v.keeper, + usdc: v.usdc, + targetAsset: v.target_asset, + destinationAddress: v.destination_address, + amountPerRun: asBaseUnits(v.amount_per_run), + intervalS: v.interval_s, + maxCostBps: v.max_cost_bps, + paused: v.paused, + lastRunAtS: v.last_run_at_s, + nextRunAtS: v.next_run_at_s, + }; +} + +export async function getVaultState(provider: Pick, vaultId: string): Promise { + return toVaultState(await view(provider, vaultId, "get_state")); +} + +export async function getUsdcBalance(provider: Pick, usdcContractId: string, accountId: string): Promise { + return asBaseUnits(await view(provider, usdcContractId, "ft_balance_of", { account_id: accountId })); +} + +/** Gas and the deposit that registers the 1Click deposit address on USDC (refunded if already registered). */ +export const EXECUTE_BUY_GAS = 150_000_000_000_000n; +export const DEPOSIT_ADDRESS_STORAGE = 13_000_000_000_000_000_000_000n; // 0.013 NEAR + +export interface KeeperAccount { + signAndSendTransaction(args: { receiverId: string; actions: ReturnType[] }): Promise; +} + +export interface BuyResult { + txHash: string; + sent: boolean; + events: NearEvent[]; +} + +export async function executeBuy( + keeper: KeeperAccount, + input: { vaultAccountId: string; quoteRef: string; depositAddress: string; amount: BaseUnits }, +): Promise { + const res = (await keeper.signAndSendTransaction({ + receiverId: input.vaultAccountId, + actions: [ + actions.functionCall( + "execute_buy", + { quote_ref: input.quoteRef, deposit_address: input.depositAddress, amount: input.amount }, + EXECUTE_BUY_GAS, + DEPOSIT_ADDRESS_STORAGE, + ), + ], + })) as TxOutcomeLike; + const events = eventsOf(res); + return { txHash: txHash(res), sent: events.some((e) => e.event === "buy_sent"), events }; +} diff --git a/lib/oneclick/__fixtures__/.gitkeep b/lib/oneclick/__fixtures__/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/lib/oneclick/__fixtures__/dry-quote-usdc-btc-100.json b/lib/oneclick/__fixtures__/dry-quote-usdc-btc-100.json new file mode 100644 index 0000000..2571686 --- /dev/null +++ b/lib/oneclick/__fixtures__/dry-quote-usdc-btc-100.json @@ -0,0 +1 @@ +{"quote":{"amountIn":"100000000","amountInFormatted":"100.0","amountInUsd":"99.961100000000","minAmountIn":"100000000","amountOut":"121953","amountOutFormatted":"0.00121953","amountOutUsd":"98.335582020000","minAmountOut":"120733","timeEstimate":465,"refundFee":"0","withdrawFee":"1900"},"quoteRequest":{"dry":true,"depositMode":"SIMPLE","swapType":"EXACT_INPUT","slippageTolerance":100,"originAsset":"nep141:17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1","depositType":"ORIGIN_CHAIN","destinationAsset":"nep141:btc.omft.near","amount":"100000000","refundTo":"wrap.near","refundType":"ORIGIN_CHAIN","recipient":"bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq","recipientType":"DESTINATION_CHAIN","deadline":"2026-11-04T18:19:57.000Z","confidentiality":"public","quoteWaitingTimeMs":0,"insured":false,"appFees":[{"recipient":"5880ad2b362620fadf759cbceb1cd5737ce8c6ed7fb8e9942881e6731f9247dd","fee":20}]},"signature":"ed25519:kgs8DcC7h7b2WXQxZk3LeY97fPYJdc9sNzZBQ9L2WnMqWWEwe59GqJc8UqFCYrCThFSDB8VBX5uVFk4H1LE8FKr","timestamp":"2026-11-04T18:10:05.108Z","correlationId":"66d91384-fbf5-4169-91df-4439aa708daa"} \ No newline at end of file diff --git a/lib/oneclick/__fixtures__/dry-quote-usdc-eth-10.json b/lib/oneclick/__fixtures__/dry-quote-usdc-eth-10.json new file mode 100644 index 0000000..f37885d --- /dev/null +++ b/lib/oneclick/__fixtures__/dry-quote-usdc-eth-10.json @@ -0,0 +1 @@ +{"quote":{"amountIn":"10000000","amountInFormatted":"10.0","amountInUsd":"9.996110000000","minAmountIn":"10000000","amountOut":"4070991360361812","amountOutFormatted":"0.004070991360361812","amountOutUsd":"9.894707341014","minAmountOut":"4030281446758193","timeEstimate":27,"refundFee":"0","withdrawFee":"35000000000000"},"quoteRequest":{"dry":true,"depositMode":"SIMPLE","swapType":"EXACT_INPUT","slippageTolerance":100,"originAsset":"nep141:17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1","depositType":"ORIGIN_CHAIN","destinationAsset":"nep141:eth.omft.near","amount":"10000000","refundTo":"wrap.near","refundType":"ORIGIN_CHAIN","recipient":"0x1234567890abcdef1234567890abcdef12345678","recipientType":"DESTINATION_CHAIN","deadline":"2026-11-04T18:19:57.000Z","confidentiality":"public","quoteWaitingTimeMs":0,"insured":false,"appFees":[{"recipient":"5880ad2b362620fadf759cbceb1cd5737ce8c6ed7fb8e9942881e6731f9247dd","fee":20}]},"signature":"ed25519:4ZLHx7zhYDxLEvLAgVkZeRsWVSHRjsY8gN2eSmhhtKP35K3GdwgwobjMC6bpUMoH5EPs4nEkfU9F16L63MQhJFC7","timestamp":"2026-11-04T18:09:57.531Z","correlationId":"aaba5cc1-6edf-417e-83f1-e46770ac2c71"} \ No newline at end of file diff --git a/lib/oneclick/client.ts b/lib/oneclick/client.ts index bb80548..6c5322c 100644 --- a/lib/oneclick/client.ts +++ b/lib/oneclick/client.ts @@ -1,7 +1,39 @@ /** - * 1Click SDK setup: base URL, optional JWT. + * 1Click SDK setup. The SDK keeps one global config, so call this once at server/worker start. * Roadmap: K3.1 · Spec: ARCHITECTURE.md §4.2 - * Status: scaffold — not implemented yet. */ -export {}; +import { ApiError, OpenAPI } from "@defuse-protocol/one-click-sdk-typescript"; + +export const DEFAULT_ONECLICK_BASE_URL = "https://1click.chaindefuser.com"; + +export function configureOneClick({ baseUrl = DEFAULT_ONECLICK_BASE_URL, jwt }: { baseUrl?: string; jwt?: string }): void { + OpenAPI.BASE = baseUrl; + // Without a partner JWT 1Click adds a fee and refuses non-dry quotes (ARCHITECTURE §12). + OpenAPI.TOKEN = jwt || undefined; +} + +export class OneClickError extends Error { + constructor( + message: string, + readonly status: number | null, + readonly correlationId: string | null, + ) { + super(message); + this.name = "OneClickError"; + } +} + +/** Normalises SDK errors into a readable message, including the misleading ones we know about. */ +export function toOneClickError(e: unknown): OneClickError { + if (e instanceof OneClickError) return e; + if (e instanceof ApiError) { + const body = (e.body ?? {}) as { message?: string; correlationId?: string }; + let message = body.message ?? e.message; + // A refundTo account that doesn't exist comes back as a 400 "Internal server error" (ARCHITECTURE §12). + if (message === "Internal server error") message = "1Click rejected the quote (check that the refund account exists)"; + if (message === "Failed to get quote") message = "No quote available (real quotes need a partner JWT, or no route exists)"; + return new OneClickError(message, e.status, body.correlationId ?? null); + } + return new OneClickError(e instanceof Error ? e.message : String(e), null, null); +} diff --git a/lib/oneclick/index.ts b/lib/oneclick/index.ts new file mode 100644 index 0000000..03fc8a1 --- /dev/null +++ b/lib/oneclick/index.ts @@ -0,0 +1,34 @@ +/** + * Real QuoteProvider / StatusProvider backed by 1Click. Demo implementations live in demo.ts (K7.1). + * Roadmap: K3.1 + */ + +import type { QuoteProvider, StatusProvider } from "../core/adapters"; +import { buyDeadline, buyQuoteRequest, fundingDeadline, fundingQuoteRequest, requestQuote } from "./quote"; +import { getStatus, submitDepositTx } from "./status"; +import { getToken } from "./tokens"; + +export { configureOneClick, OneClickError } from "./client"; +export { getTokens, getToken } from "./tokens"; + +export const oneClickQuotes: QuoteProvider = { + async fundingQuote({ originAsset, amountIn, refundTo, vault }, { dry }) { + const token = await getToken(originAsset); + const req = fundingQuoteRequest( + { originAsset, amountIn, refundTo, vaultAccountId: vault, deadline: fundingDeadline(token?.blockchain) }, + dry, + ); + return requestQuote(req); + }, + async buyQuote({ vault, vaultAccountId, amountIn }, { dry }) { + return requestQuote(buyQuoteRequest({ vault, vaultAccountId, amountIn, deadline: buyDeadline() }, dry)); + }, +}; + +export const oneClickStatus: StatusProvider = { + async getStatus(depositAddress) { + const { status, raw } = await getStatus(depositAddress); + return { status, raw }; + }, + submitDepositTx, +}; diff --git a/lib/oneclick/quote.test.ts b/lib/oneclick/quote.test.ts new file mode 100644 index 0000000..5ca107b --- /dev/null +++ b/lib/oneclick/quote.test.ts @@ -0,0 +1,106 @@ +import { ApiError, OneClickService, type QuoteResponse } from "@defuse-protocol/one-click-sdk-typescript"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import btc100 from "./__fixtures__/dry-quote-usdc-btc-100.json"; +import { USDC_ON_NEAR } from "../core/assets"; +import { asBaseUnits } from "../core/money"; +import type { VaultState } from "../core/types"; +import { OneClickError } from "./client"; +import { buyDeadline, buyQuoteRequest, fundingDeadline, fundingQuoteRequest, requestQuote, toQuoteSummary } from "./quote"; + +// Roadmap: K3.1, K3.2 · fixture = real dry quote from 2026-11-04 +const fixture = btc100 as unknown as QuoteResponse; +const VAULT_ID = "v0123456789abcdef.factory.stack.near"; +const vault: VaultState = { + owner: "alice.near", + keeper: "keeper.stack.near", + usdc: "17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1", + targetAsset: "nep141:btc.omft.near", + destinationAddress: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + amountPerRun: asBaseUnits("100000000"), + intervalS: 86_400, + maxCostBps: 300, + paused: false, + lastRunAtS: 0, + nextRunAtS: 86_400, +}; + +afterEach(() => vi.restoreAllMocks()); + +describe("quote requests", () => { + it("builds a funding quote: any asset → USDC into the vault, refund to the user's origin address", () => { + const r = fundingQuoteRequest( + { originAsset: "nep141:sol.omft.near", amountIn: asBaseUnits("1000000000"), refundTo: "So1Address", vaultAccountId: VAULT_ID, deadline: "d" }, + true, + ); + expect(r).toMatchObject({ dry: true, swapType: "EXACT_INPUT", destinationAsset: USDC_ON_NEAR, recipient: VAULT_ID, refundTo: "So1Address", recipientType: "DESTINATION_CHAIN" }); + }); + + it("builds a buy quote from the on-chain plan with refunds to the vault", () => { + const r = buyQuoteRequest({ vault, vaultAccountId: VAULT_ID, amountIn: asBaseUnits("100000000"), deadline: "d" }, false); + expect(r).toMatchObject({ + dry: false, + originAsset: USDC_ON_NEAR, + destinationAsset: vault.targetAsset, + recipient: vault.destinationAddress, + refundTo: VAULT_ID, + slippageTolerance: 300, + }); + }); + + it("refuses a buy quote without a plan", () => { + expect(() => buyQuoteRequest({ vault: { ...vault, targetAsset: null }, vaultAccountId: VAULT_ID, amountIn: asBaseUnits("1"), deadline: "d" }, true)).toThrow(OneClickError); + }); + + it("gives Bitcoin deposits a longer deadline", () => { + const now = Date.parse("2026-11-04T00:00:00Z"); + expect(fundingDeadline("btc", now)).toBe("2026-11-04T02:00:00.000Z"); + expect(fundingDeadline("sol", now)).toBe("2026-11-04T00:30:00.000Z"); + expect(buyDeadline(now)).toBe("2026-11-04T00:10:00.000Z"); + }); +}); + +describe("toQuoteSummary", () => { + it("maps a real dry quote", () => { + const s = toQuoteSummary(fixture); + expect(s).toMatchObject({ + dry: true, + originAsset: USDC_ON_NEAR, + depositAddress: null, + amountIn: "100000000", + destinationAsset: "nep141:btc.omft.near", + timeEstimateS: 465, + }); + expect(s.amountInUsd).toBeTruthy(); + expect(s.signature.startsWith("ed25519:")).toBe(true); + }); +}); + +describe("requestQuote", () => { + it("returns dry previews without requiring a verified signature", async () => { + vi.spyOn(OneClickService, "getQuote").mockResolvedValue(fixture as never); + const s = await requestQuote({ ...fixture.quoteRequest, dry: true }, () => false); + expect(s.dry).toBe(true); + }); + + it("refuses a real quote whose signature does not verify", async () => { + const real = { ...fixture, quoteRequest: { ...fixture.quoteRequest, dry: false }, quote: { ...fixture.quote, depositAddress: "abc" } }; + vi.spyOn(OneClickService, "getQuote").mockResolvedValue(real as never); + await expect(requestQuote(real.quoteRequest, () => false)).rejects.toThrow(/signature did not verify/); + }); + + it("accepts a real quote whose signature verifies", async () => { + const real = { ...fixture, quoteRequest: { ...fixture.quoteRequest, dry: false }, quote: { ...fixture.quote, depositAddress: "abc" } }; + vi.spyOn(OneClickService, "getQuote").mockResolvedValue(real as never); + expect((await requestQuote(real.quoteRequest, () => true)).depositAddress).toBe("abc"); + }); + + it("turns 1Click's misleading errors into readable ones", async () => { + const err = new ApiError( + { method: "POST", url: "/v0/quote" }, + { url: "/v0/quote", ok: false, status: 400, statusText: "Bad Request", body: { message: "Internal server error", correlationId: "c1" } }, + "Bad Request", + ); + vi.spyOn(OneClickService, "getQuote").mockRejectedValue(err); + await expect(requestQuote({ ...fixture.quoteRequest })).rejects.toThrow(/refund account exists/); + }); +}); diff --git a/lib/oneclick/quote.ts b/lib/oneclick/quote.ts index 7d34401..89c762f 100644 --- a/lib/oneclick/quote.ts +++ b/lib/oneclick/quote.ts @@ -1,7 +1,126 @@ /** - * Funding + buy EXACT_INPUT quotes (dry + real), signature verification. - * Roadmap: K3.1 · Spec: ARCHITECTURE.md §4.2 - * Status: scaffold — not implemented yet. + * Funding and buy quotes (both EXACT_INPUT). Real quotes must pass signature verification before + * their depositAddress is used; dry previews are display-only. Roadmap: K3.1 · Spec: ARCHITECTURE.md §4.2 + * + * 🔍 K3.5: with SDK 0.1.26, signatures on today's unauthenticated dry quotes don't verify (the SDK + * rebuilds the payload from a fixed field list). Real quotes therefore fail closed until K3.5 confirms + * verification with a partner JWT. */ -export {}; +import { + OneClickService, + QuoteRequest, + verifyQuoteSignature, + type QuoteResponse, +} from "@defuse-protocol/one-click-sdk-typescript"; +import { USDC_ON_NEAR } from "../core/assets"; +import { asBaseUnits } from "../core/money"; +import type { AssetId, BaseUnits, QuoteSummary, VaultState } from "../core/types"; +import { OneClickError, toOneClickError } from "./client"; + +const MINUTE = 60_000; + +/** Bitcoin deposits can need ~1 hour of confirmations (1Click docs), so give them longer. */ +export function fundingDeadline(originBlockchain: string | undefined, now = Date.now()): string { + const minutes = originBlockchain === "btc" ? 120 : 30; + return new Date(now + minutes * MINUTE).toISOString(); +} + +export function buyDeadline(now = Date.now()): string { + return new Date(now + 10 * MINUTE).toISOString(); +} + +export interface FundingQuoteParams { + originAsset: AssetId; + amountIn: BaseUnits; + /** User's own address on the origin chain. Must exist, or 1Click errors. */ + refundTo: string; + vaultAccountId: string; + deadline: string; + slippageBps?: number; +} + +export function fundingQuoteRequest(p: FundingQuoteParams, dry: boolean): QuoteRequest { + return { + dry, + swapType: QuoteRequest.swapType.EXACT_INPUT, + slippageTolerance: p.slippageBps ?? 100, + originAsset: p.originAsset, + depositType: QuoteRequest.depositType.ORIGIN_CHAIN, + destinationAsset: USDC_ON_NEAR, + amount: p.amountIn, + recipient: p.vaultAccountId, + recipientType: QuoteRequest.recipientType.DESTINATION_CHAIN, + refundTo: p.refundTo, + refundType: QuoteRequest.refundType.ORIGIN_CHAIN, + deadline: p.deadline, + }; +} + +export interface BuyQuoteParams { + vault: VaultState; + vaultAccountId: string; + amountIn: BaseUnits; + deadline: string; + /** Before a vault exists, previews must refund to an existing account: the owner's. */ + refundTo?: string; +} + +export function buyQuoteRequest(p: BuyQuoteParams, dry: boolean): QuoteRequest { + if (!p.vault.targetAsset || !p.vault.destinationAddress) throw new OneClickError("Vault has no plan", null, null); + return { + dry, + swapType: QuoteRequest.swapType.EXACT_INPUT, + slippageTolerance: p.vault.maxCostBps, + originAsset: USDC_ON_NEAR, + depositType: QuoteRequest.depositType.ORIGIN_CHAIN, + destinationAsset: p.vault.targetAsset, + amount: p.amountIn, + recipient: p.vault.destinationAddress, + recipientType: QuoteRequest.recipientType.DESTINATION_CHAIN, + refundTo: p.refundTo ?? p.vaultAccountId, + refundType: QuoteRequest.refundType.ORIGIN_CHAIN, + deadline: p.deadline, + }; +} + +export function toQuoteSummary(res: QuoteResponse): QuoteSummary { + const q = res.quote; + const r = res.quoteRequest; + return { + dry: r.dry, + originAsset: r.originAsset, + refundTo: r.refundTo, + depositAddress: q.depositAddress ?? null, + amountIn: asBaseUnits(q.amountIn), + amountOut: asBaseUnits(q.amountOut), + minAmountOut: q.minAmountOut ? asBaseUnits(q.minAmountOut) : null, + timeEstimateS: typeof q.timeEstimate === "number" ? q.timeEstimate : null, + amountInUsd: q.amountInUsd ?? null, + amountOutUsd: q.amountOutUsd ?? null, + recipient: r.recipient, + destinationAsset: r.destinationAsset, + deadline: q.deadline ?? r.deadline, + signature: res.signature, + raw: res, + }; +} + +export type SignatureCheck = (res: QuoteResponse) => boolean; + +/** + * Requests a quote. Real quotes are rejected unless the signature verifies — they are the only ones + * whose depositAddress can receive funds. + */ +export async function requestQuote(req: QuoteRequest, verify: SignatureCheck = verifyQuoteSignature): Promise { + let res: QuoteResponse; + try { + res = await OneClickService.getQuote(req); + } catch (e) { + throw toOneClickError(e); + } + if (!req.dry && !verify(res)) { + throw new OneClickError("Quote signature did not verify; refusing to use its deposit address", null, res.correlationId ?? null); + } + return toQuoteSummary(res); +} diff --git a/lib/oneclick/status.test.ts b/lib/oneclick/status.test.ts new file mode 100644 index 0000000..c2ee179 --- /dev/null +++ b/lib/oneclick/status.test.ts @@ -0,0 +1,43 @@ +import { OneClickService } from "@defuse-protocol/one-click-sdk-typescript"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ONECLICK_STATUSES } from "../core/status"; +import { getStatus, submitDepositTx } from "./status"; +import { clearTokenCache, getTokens } from "./tokens"; + +// Roadmap: K3.1, K3.2 +afterEach(() => vi.restoreAllMocks()); + +const statusFixture = (status: string) => ({ correlationId: "c", status, updatedAt: "2026-11-04T00:00:00Z", quoteResponse: {}, swapDetails: {} }); + +describe("status", () => { + it.each(ONECLICK_STATUSES)("passes through %s", async (s) => { + vi.spyOn(OneClickService, "getExecutionStatus").mockResolvedValue(statusFixture(s) as never); + expect((await getStatus("addr")).status).toBe(s); + }); + + it("rejects a status we don't know instead of guessing", async () => { + vi.spyOn(OneClickService, "getExecutionStatus").mockResolvedValue(statusFixture("SOMETHING_NEW") as never); + await expect(getStatus("addr")).rejects.toThrow(/Unknown 1Click status/); + }); + + it("submits deposit tx hashes", async () => { + const spy = vi.spyOn(OneClickService, "submitDepositTx").mockResolvedValue({} as never); + await submitDepositTx("addr", "hash"); + expect(spy).toHaveBeenCalledWith({ depositAddress: "addr", txHash: "hash" }); + }); +}); + +describe("tokens", () => { + it("caches the list for 10 minutes and serves stale data if 1Click is down", async () => { + clearTokenCache(); + const spy = vi + .spyOn(OneClickService, "getTokens") + .mockResolvedValue([{ assetId: "nep141:btc.omft.near", symbol: "BTC", blockchain: "btc", decimals: 8, price: 80588, priceUpdatedAt: "" }] as never); + const t0 = 1_000_000; + expect((await getTokens(t0))[0]?.decimals).toBe(8); + await getTokens(t0 + 5 * 60_000); + expect(spy).toHaveBeenCalledTimes(1); + spy.mockRejectedValue(new Error("down")); + expect((await getTokens(t0 + 11 * 60_000))[0]?.symbol).toBe("BTC"); + }); +}); diff --git a/lib/oneclick/status.ts b/lib/oneclick/status.ts index 5d606ee..16e9da2 100644 --- a/lib/oneclick/status.ts +++ b/lib/oneclick/status.ts @@ -1,7 +1,36 @@ /** - * /v0/status polling and /v0/deposit/submit. - * Roadmap: K3.1 · Spec: ARCHITECTURE.md §4.2 - * Status: scaffold — not implemented yet. + * /v0/status polling and /v0/deposit/submit. Roadmap: K3.1 · Spec: ARCHITECTURE.md §4.3 */ -export {}; +import { OneClickService } from "@defuse-protocol/one-click-sdk-typescript"; +import { ONECLICK_STATUSES, type OneClickStatus } from "../core/status"; +import { OneClickError, toOneClickError } from "./client"; + +export interface StatusResult { + status: OneClickStatus; + updatedAt: string | null; + raw: unknown; +} + +export async function getStatus(depositAddress: string): Promise { + let res: Awaited>; + try { + res = await OneClickService.getExecutionStatus(depositAddress); + } catch (e) { + throw toOneClickError(e); + } + const status = String(res.status); + if (!(ONECLICK_STATUSES as readonly string[]).includes(status)) { + throw new OneClickError(`Unknown 1Click status "${status}"`, null, res.correlationId ?? null); + } + return { status: status as OneClickStatus, updatedAt: res.updatedAt ?? null, raw: res }; +} + +/** Optional speed-up: tells 1Click about the deposit transaction. */ +export async function submitDepositTx(depositAddress: string, txHash: string): Promise { + try { + await OneClickService.submitDepositTx({ depositAddress, txHash }); + } catch (e) { + throw toOneClickError(e); + } +} diff --git a/lib/oneclick/tokens.ts b/lib/oneclick/tokens.ts index 9f56554..a450ad1 100644 --- a/lib/oneclick/tokens.ts +++ b/lib/oneclick/tokens.ts @@ -1,7 +1,43 @@ /** - * /v0/tokens with a 10-minute in-memory cache. + * /v0/tokens with a 10-minute in-memory cache. Asset ids and decimals always come from here. * Roadmap: K3.1 · Spec: ARCHITECTURE.md §4.2 - * Status: scaffold — not implemented yet. */ -export {}; +import { OneClickService } from "@defuse-protocol/one-click-sdk-typescript"; +import type { Asset } from "../core/types"; +import { toOneClickError } from "./client"; + +export interface TokenInfo extends Asset { + priceUsd: number | null; +} + +const TTL_MS = 10 * 60 * 1000; +let cache: { at: number; tokens: TokenInfo[] } | null = null; + +export async function getTokens(now = Date.now()): Promise { + if (cache && now - cache.at < TTL_MS) return cache.tokens; + try { + const raw = await OneClickService.getTokens(); + const tokens = raw.map((t) => ({ + assetId: t.assetId, + symbol: t.symbol, + blockchain: String(t.blockchain), + decimals: t.decimals, + priceUsd: Number.isFinite(t.price) ? t.price : null, + })); + cache = { at: now, tokens }; + return tokens; + } catch (e) { + if (cache) return cache.tokens; // stale beats nothing for a list that changes rarely + throw toOneClickError(e); + } +} + +export async function getToken(assetId: string): Promise { + return (await getTokens()).find((t) => t.assetId === assetId); +} + +/** For tests. */ +export function clearTokenCache(): void { + cache = null; +} diff --git a/project_structure.md b/project_structure.md index 1ae9663..cf41c61 100644 --- a/project_structure.md +++ b/project_structure.md @@ -91,9 +91,9 @@ components/ ``` lib/ ├─ core/ # pure: assets.ts (KD-6 ids), money.ts, status.ts, cost.ts (cost_bps), planMatch.ts, address.ts (checksums), adapters.ts, schemas.ts -├─ oneclick/ # client.ts, tokens.ts, quote.ts (funding + buy, verify), status.ts, demo.ts, __fixtures__/ -├─ near/ # rpc.ts, nep413.ts, vault.ts (bindings), factory.ts, keeper.ts (server only), events.ts, demo.ts -├─ db/ # schema.ts, client.ts (PGlite/Postgres), queries/, seed-demo.ts +├─ oneclick/ # index.ts (QuoteProvider/StatusProvider), client.ts, tokens.ts, quote.ts (funding + buy, fail-closed verify), status.ts, demo.ts, __fixtures__/ (real dry quotes) +├─ near/ # index.ts (VaultClient), rpc.ts, nep413.ts, vault.ts (bindings), factory.ts, keeper.ts (server only), events.ts, demo.ts +├─ db/ # schema.ts, client.ts (PGlite/Postgres + migrate()), queries/ (users, fundingAttempts, buys), seed-demo.ts └─ server/ # SERVER ONLY: env.ts, session.ts, adapters.ts, rateLimit.ts ```