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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,9 @@ The keeper chooses `deposit_address`. A stolen keeper key could redirect at most
### 4.1 API routes
| Route | Auth | Purpose |
|---|---|---|
| `POST /api/session` | NEP-413 | Sign in |
| `GET /api/session` | public | Challenge: 32-byte nonce + message + recipient (app host), in a signed 5-min cookie |
| `POST /api/session` | NEP-413 | Verify signature (full-access key, matching nonce/message/recipient) → upsert user → HMAC-signed HTTP-only session cookie (7 days) |
| `DELETE /api/session` | – | Sign out |
| `POST /api/onboard` | session | Keeper `create_vault_for(owner)` (once) |
| `GET /api/vault` | session | `get_state()` + `ft_balance_of` + next run |
| `GET /api/tokens` | public | Cached token list |
Expand All @@ -153,6 +155,9 @@ The keeper chooses `deposit_address`. A stolen keeper key could redirect at most

Owner contract calls (`set_plan`, `resume`, `pause`, `withdraw`, `recover_token`) are wallet transactions built in the browser; no server involvement.

All routes go through `lib/server/adapters.ts#getServices()` (DB, 1Click, NEAR, token list). Real mode builds it from env; tests and demo mode inject their own. A missing real-mode dependency returns **503** with what to set. Errors: 400 validation, 401 session, 404 not yours, 409 no vault yet, 429 rate limit, 502 1Click.
Sessions: `SESSION_SECRET` (≥ 32 chars) is required in production; locally a per-process secret is used.

### 4.2 1Click requests

Funding (`EXACT_INPUT`): origin = user's asset, destination = USDC on NEAR, recipient = vault, refundTo = user's origin address, `recipientType: DESTINATION_CHAIN`, `refundType: ORIGIN_CHAIN`.
Expand Down
6 changes: 6 additions & 0 deletions ARCHITECTURE_ESSENTIALS.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ Rust near-sdk =5.30.0 + cargo-near 0.22 + near-workspaces =0.24.0 · Next.js 15.
`users` (near_account_id, vault_account_id, is_demo) · `funding_attempts` (deposit_address uniq, quote_raw, signature, status) · `buys` (quote_ref uniq, cost_bps, deposit_address, status)
On-chain vault = truth for plan + balance.

## API
- Every route uses `getServices()` (lib/server/adapters.ts); tests/demo inject fakes via `setServicesForTest`
- Sign-in: GET /api/session challenge → wallet `signMessage` → POST verifies (full-access key) → signed HTTP-only cookie
- Statuses: 400 · 401 · 404 (not yours) · 409 (no vault) · 429 · 502 (1Click) · 503 (real mode not configured)
- 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

Expand Down
21 changes: 11 additions & 10 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
| 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 | 80% | in progress (K3.5 blocked on K0.8) |
| 4. API routes | Nov 8–9 | 0% | not started |
| 4. API routes | Nov 8–9 | 100% | ✅ done |
| 5. Worker (scheduler) | Nov 9–10 | 0% | not started |
| 6. UI | Nov 9–11 | 0% | not started |
| 7. Demo mode & dry-run | Nov 11 | 0% | not started |
Expand All @@ -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)** | | **~33%** | |
| **Overall (MVP)** | | **~40%** | |

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.)

Expand Down Expand Up @@ -119,13 +119,13 @@ Format: `- [task id] what blocks it · who can unblock · since date`

## Phase 4 — API routes (Nov 8–9)

- [ ] **K4.1** [#43](https://github.com/MFrancis-dev/stack/issues/43) Session + `middleware.ts`
- [ ] **K4.2** [#44](https://github.com/MFrancis-dev/stack/issues/44) `POST /api/onboard` (keeper `create_vault_for`, once)
- [ ] **K4.3** [#45](https://github.com/MFrancis-dev/stack/issues/45) `GET /api/vault`, `GET /api/tokens`
- [ ] **K4.4** [#46](https://github.com/MFrancis-dev/stack/issues/46) Funding: quote (dry/real, rate limit), submit-tx, status
- [ ] **K4.5** [#47](https://github.com/MFrancis-dev/stack/issues/47) `POST /api/plan/preview` (cost % for a buy size) · 2026-11-04: dry quotes need an existing `refundTo` → use the owner's account before the vault exists
- [ ] **K4.6** [#48](https://github.com/MFrancis-dev/stack/issues/48) `GET /api/buys`, `GET /api/export.csv`
- [ ] **K4.7** [#49](https://github.com/MFrancis-dev/stack/issues/49) Route tests (happy + validation + auth)
- [x] **K4.1** [#43](https://github.com/MFrancis-dev/stack/issues/43) Session + `middleware.ts` · 2026-11-04: NEP-413 challenge (signed 5-min cookie) + verify + HMAC session cookie; middleware redirects signed-out page visits
- [x] **K4.2** [#44](https://github.com/MFrancis-dev/stack/issues/44) `POST /api/onboard` (keeper `create_vault_for`, once) · 2026-11-04: keeper `create_vault_for` once per user (findVault first), rate-limited; 503 with guidance when not configured
- [x] **K4.3** [#45](https://github.com/MFrancis-dev/stack/issues/45) `GET /api/vault`, `GET /api/tokens` · 2026-11-04: `GET /api/vault` (state + USDC balance), `GET /api/tokens` (public, cached)
- [x] **K4.4** [#46](https://github.com/MFrancis-dev/stack/issues/46) Funding: quote (dry/real, rate limit), submit-tx, status · 2026-11-04: dry preview (nothing stored) / real attempt with deposit address; submit-tx; status refreshed from 1Click on read incl. expiry; 20/min rate limit
- [x] **K4.5** [#47](https://github.com/MFrancis-dev/stack/issues/47) `POST /api/plan/preview` (cost % for a buy size) · 2026-11-04: dry quotes need an existing `refundTo` → use the owner's account before the vault exists · 2026-11-04: dry buy quote → cost bps + suggested minimum; example recipients; refund to the user's account before a vault exists
- [x] **K4.6** [#48](https://github.com/MFrancis-dev/stack/issues/48) `GET /api/buys`, `GET /api/export.csv` · 2026-11-04: `GET /api/buys`; CSV export (UTC, USD value per buy, formula-safe)
- [x] **K4.7** [#49](https://github.com/MFrancis-dev/stack/issues/49) Route tests (happy + validation + auth) · 2026-11-04: 16 route tests on real handlers (in-memory PGlite, fake 1Click/NEAR, real NEP-413 signature); 84 TS tests total

## Phase 5 — Worker (Nov 9–10)

Expand Down Expand Up @@ -163,7 +163,7 @@ Format: `- [task id] what blocks it · who can unblock · since date`
- [ ] **K8.4** [#72](https://github.com/MFrancis-dev/stack/issues/72) Balance below buy amount (remainder only if it passes the cost check)
- [ ] **K8.5** [#73](https://github.com/MFrancis-dev/stack/issues/73) Funding underpaid from an exchange: warning + status
- [ ] **K8.6** [#74](https://github.com/MFrancis-dev/stack/issues/74) 1Click down / no route → `no_quote`
- [ ] **K8.7** [#75](https://github.com/MFrancis-dev/stack/issues/75) Rate limits; logs without secrets
- [~] **K8.7** [#75](https://github.com/MFrancis-dev/stack/issues/75) Rate limits; logs without secrets · 2026-11-04: in-memory rate limits on quote, preview and onboard routes; log hygiene still to review
- [ ] **K8.8** [#76](https://github.com/MFrancis-dev/stack/issues/76) Re-run all contract invariant tests against the deployed code hash

## Phase 9 — Deploy & real run (Nov 12)
Expand Down Expand Up @@ -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 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
- 2026-11-04 · agent · Rewrote `main` history (approved force-push) to drop tool attribution lines from commit messages; every commit's files unchanged · —
Expand Down
33 changes: 26 additions & 7 deletions app/api/buys/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,30 @@
import { NextResponse } from "next/server";

/**
* GET /api/buys — buy history.
* GET /api/buys — the signed-in user's buys, including skipped ones with their reason.
* Roadmap: K4.6 · Spec: ARCHITECTURE.md §4.1
* Status: scaffold — not implemented yet.
*/

export async function GET() {
return NextResponse.json({ error: "Not implemented (K4.6)" }, { status: 501 });
}
import { listBuysByUser } from "@/lib/db/queries/buys";
import { requireUser } from "@/lib/server/context";
import { handle, json } from "@/lib/server/http";

export const GET = handle(async (req: Request) => {
const { services, user } = await requireUser(req);
const rows = await listBuysByUser(services.db, user.id);
return json({
buys: rows.map((b) => ({
id: b.id,
quoteRef: b.quoteRef,
status: b.status,
amountIn: b.amountIn,
targetAsset: b.targetAsset,
destinationAddress: b.destinationAddress,
amountOut: b.amountOut,
amountInUsd: b.amountInUsd,
costBps: b.costBps,
nearTxHash: b.nearTxHash,
destinationTxHash: b.destinationTxHash,
createdAt: b.createdAt.toISOString(),
completedAt: b.completedAt?.toISOString() ?? null,
})),
});
});
75 changes: 67 additions & 8 deletions app/api/export.csv/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,70 @@
import { NextResponse } from "next/server";

/**
* GET /api/export.csv — CSV with USD value per buy.
* Roadmap: K4.6 · Spec: ARCHITECTURE.md §4.1
* Status: scaffold — not implemented yet.
* GET /api/export.csv — deposits and buys with the USD value of each buy (cost basis for tax, K-E20).
* Times in UTC. Roadmap: K4.6 · Spec: ARCHITECTURE.md §4.1
*/

export async function GET() {
return NextResponse.json({ error: "Not implemented (K4.6)" }, { status: 501 });
}
import { targetByAssetId, USDC_DECIMALS } from "@/lib/core/assets";
import { toCsv, type CsvValue } from "@/lib/core/csv";
import { formatAmount } from "@/lib/core/money";
import { listBuysByUser } from "@/lib/db/queries/buys";
import { listFundingByUser } from "@/lib/db/queries/fundingAttempts";
import { requireUser } from "@/lib/server/context";
import { handle } from "@/lib/server/http";

const HEADER = [
"date_utc",
"type",
"status",
"amount_in",
"asset_in",
"amount_out",
"asset_out",
"usd_value_in",
"cost_bps",
"deposit_address",
"near_tx",
"destination_tx",
] as const;

export const GET = handle(async (req: Request) => {
const { services, user } = await requireUser(req);
const [funding, buys, tokens] = await Promise.all([
listFundingByUser(services.db, user.id, 1_000),
listBuysByUser(services.db, user.id, 10_000),
services.listTokens().catch(() => []),
]);
const decimalsOf = (assetId: string) => tokens.find((t) => t.assetId === assetId)?.decimals;
const fmt = (amount: string | null, decimals: number | undefined) => (amount && decimals !== undefined ? formatAmount(amount, decimals) : amount);

const rows: { at: Date; row: CsvValue[] }[] = [
...funding.map((f) => ({
at: f.createdAt,
row: [f.createdAt.toISOString(), "deposit", f.status, fmt(f.amountIn, decimalsOf(f.originAsset)), f.originAsset, fmt(f.expectedOut, USDC_DECIMALS), "USDC", null, null, f.depositAddress, f.depositTxHash, null],
})),
...buys.map((b) => ({
at: b.createdAt,
row: [
b.createdAt.toISOString(),
"buy",
b.status,
fmt(b.amountIn, USDC_DECIMALS),
"USDC",
fmt(b.amountOut, targetByAssetId(b.targetAsset)?.decimals),
targetByAssetId(b.targetAsset)?.symbol ?? b.targetAsset,
b.amountInUsd,
b.costBps,
b.depositAddress,
b.nearTxHash,
b.destinationTxHash,
],
})),
].sort((a, b) => a.at.getTime() - b.at.getTime());

return new Response(toCsv(HEADER, rows.map((r) => r.row)), {
headers: {
"content-type": "text/csv; charset=utf-8",
"content-disposition": 'attachment; filename="stack-history.csv"',
"cache-control": "no-store",
},
});
});
43 changes: 35 additions & 8 deletions app/api/fund/[id]/status/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,38 @@
import { NextResponse } from "next/server";

/**
* GET /api/fund/:id/status — funding status.
* Roadmap: K4.4 · Spec: ARCHITECTURE.md §4.1
* Status: scaffold — not implemented yet.
* GET /api/fund/:id/status — current funding status. Non-final attempts are refreshed from 1Click
* on read, so the page stays live even without the worker. Roadmap: K4.4 · Spec: ARCHITECTURE.md §4.3
*/

export async function GET() {
return NextResponse.json({ error: "Not implemented (K4.4)" }, { status: 501 });
}
import { FUNDING_TERMINAL, fundingStatusFromOneClick, type FundingStatus } from "@/lib/core/status";
import { getFundingAttempt, updateFundingStatus } from "@/lib/db/queries/fundingAttempts";
import { requireUser } from "@/lib/server/context";
import { handle, HttpError, json } from "@/lib/server/http";

export const GET = handle(async (req: Request, ctx: { params: Promise<{ id: string }> }) => {
const { id } = await ctx.params;
const { services, user } = await requireUser(req);
const attempt = await getFundingAttempt(services.db, id, user.id);
if (!attempt) throw new HttpError(404, "Not found");

let status = attempt.status as FundingStatus;
let oneclickStatus = attempt.oneclickStatus;
if (!FUNDING_TERMINAL.has(status)) {
const r = await services.status.getStatus(attempt.depositAddress);
oneclickStatus = r.status;
status = fundingStatusFromOneClick(r.status);
if (r.status === "PENDING_DEPOSIT" && attempt.quoteDeadline.getTime() < Date.now()) status = "expired";
if (status !== attempt.status || oneclickStatus !== attempt.oneclickStatus) {
await updateFundingStatus(services.db, attempt.id, { status, oneclickStatus });
}
}
return json({
id: attempt.id,
status,
oneclickStatus,
depositAddress: attempt.depositAddress,
amountIn: attempt.amountIn,
expectedOut: attempt.expectedOut,
deadline: attempt.quoteDeadline.toISOString(),
depositTxHash: attempt.depositTxHash,
});
});
27 changes: 19 additions & 8 deletions app/api/fund/[id]/submit-tx/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
import { NextResponse } from "next/server";

/**
* POST /api/fund/:id/submit-tx — forward tx hash to 1Click.
* Roadmap: K4.4 · Spec: ARCHITECTURE.md §4.1
* Status: scaffold — not implemented yet.
* POST /api/fund/:id/submit-tx — the user tells us (and 1Click) their deposit tx hash. Optional speed-up.
* Roadmap: K4.4 · Spec: ARCHITECTURE.md §4.2
*/

export async function POST() {
return NextResponse.json({ error: "Not implemented (K4.4)" }, { status: 501 });
}
import { z } from "zod";
import { getFundingAttempt, updateFundingStatus } from "@/lib/db/queries/fundingAttempts";
import { requireUser } from "@/lib/server/context";
import { handle, HttpError, json, parseBody } from "@/lib/server/http";

const input = z.object({ txHash: z.string().trim().min(8).max(200) });

export const POST = handle(async (req: Request, ctx: { params: Promise<{ id: string }> }) => {
const { id } = await ctx.params;
const { services, user } = await requireUser(req);
const { txHash } = await parseBody(req, input);
const attempt = await getFundingAttempt(services.db, id, user.id);
if (!attempt) throw new HttpError(404, "Not found");
await services.status.submitDepositTx(attempt.depositAddress, txHash);
await updateFundingStatus(services.db, attempt.id, { status: attempt.status, depositTxHash: txHash });
return json({ ok: true });
});
52 changes: 44 additions & 8 deletions app/api/fund/quote/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,47 @@
import { NextResponse } from "next/server";

/**
* POST /api/fund/quote — dry preview or real funding attempt.
* Roadmap: K4.4 · Spec: ARCHITECTURE.md §4.1
* Status: scaffold — not implemented yet.
* POST /api/fund/quote — any token on any chain → USDC into the user's vault.
* dry: preview only (nothing stored, no deposit address). Real: stores a funding attempt with the
* verified deposit address. Roadmap: K4.4 · Spec: ARCHITECTURE.md §4.2
*/

export async function POST() {
return NextResponse.json({ error: "Not implemented (K4.4)" }, { status: 501 });
}
import { fundQuoteInput } from "@/lib/core/schemas";
import { asBaseUnits } from "@/lib/core/money";
import { insertFundingAttempt } from "@/lib/db/queries/fundingAttempts";
import { requireVaultOwner } from "@/lib/server/context";
import { clientIp, handle, HttpError, json, parseBody } from "@/lib/server/http";
import { rateLimit } from "@/lib/server/rateLimit";

export const POST = handle(async (req: Request) => {
const { services, user, vaultAccountId } = await requireVaultOwner(req);
rateLimit(`fund-quote:${user.id}:${clientIp(req)}`, 20, 60_000);
const body = await parseBody(req, fundQuoteInput);

const quote = await services.quotes.fundingQuote(
{ originAsset: body.originAsset, amountIn: asBaseUnits(body.amountIn), refundTo: body.refundTo, vault: vaultAccountId },
{ dry: body.dry },
);
const preview = {
amountIn: quote.amountIn,
amountOut: quote.amountOut,
minAmountOut: quote.minAmountOut,
amountInUsd: quote.amountInUsd,
amountOutUsd: quote.amountOutUsd,
timeEstimateS: quote.timeEstimateS,
deadline: quote.deadline,
};
if (body.dry) return json({ dry: true, quote: preview });

if (!quote.depositAddress) throw new HttpError(502, "1Click returned no deposit address");
const attempt = await insertFundingAttempt(services.db, {
userId: user.id,
originAsset: body.originAsset,
amountIn: quote.amountIn,
expectedOut: quote.amountOut,
refundTo: body.refundTo,
depositAddress: quote.depositAddress,
quoteDeadline: new Date(quote.deadline),
quoteSignature: quote.signature,
quoteRaw: quote.raw as object,
});
return json({ dry: false, quote: preview, attempt: { id: attempt.id, depositAddress: attempt.depositAddress, status: attempt.status } }, { status: 201 });
});
27 changes: 19 additions & 8 deletions app/api/onboard/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
import { NextResponse } from "next/server";

/**
* POST /api/onboard — keeper create_vault_for(owner), once.
* Roadmap: K4.2 · Spec: ARCHITECTURE.md §4.1
* Status: scaffold — not implemented yet.
* POST /api/onboard — the keeper creates the signed-in user's vault (once). Free for the user.
* A new vault is paused with no plan (I7). Roadmap: K4.2 · Spec: ARCHITECTURE.md §6
*/

export async function POST() {
return NextResponse.json({ error: "Not implemented (K4.2)" }, { status: 501 });
}
import { setVault } from "@/lib/db/queries/users";
import { requireVaultClient } from "@/lib/server/adapters";
import { requireUser } from "@/lib/server/context";
import { handle, json } from "@/lib/server/http";
import { rateLimit } from "@/lib/server/rateLimit";

export const POST = handle(async (req: Request) => {
const { services, user } = await requireUser(req);
if (user.vaultAccountId) return json({ vaultAccountId: user.vaultAccountId, created: false });

rateLimit(`onboard:${user.id}`, 3, 60 * 60 * 1000);
const vaults = requireVaultClient(services);
const existing = await vaults.findVault(user.nearAccountId);
const vaultAccountId = existing ?? (await vaults.createVaultFor(user.nearAccountId)).vaultAccountId;
await setVault(services.db, user.id, vaultAccountId);
return json({ vaultAccountId, created: !existing }, { status: existing ? 200 : 201 });
});
Loading
Loading