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
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ On a long-running host `pnpm worker` runs the loops below on their intervals. On
|---|---|---|
| `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 |
| `pollBuys` | 10 s | In-flight buys → final status; on success the actual `amountOut` and destination tx from `swapDetails`; refunds noted with 1Click's reason. Still `PENDING_DEPOSIT` 30 min after the buy (quote deadline 10 min + margin) → `expired`, so a lost deposit never blocks the plan |
| `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 |
| `cleanupDemo` | 60 min | Delete demo users older than 24 h with their rows (never real users) |
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ Other protections: real quotes are refused unless their signature verifies (fail

Being honest about what is and isn't live:

- **Pre-launch review (2026-11-08):** a security and reliability review found nine issues; they are fixed one at a time and tracked as ROADMAP Phase 13. Fixed so far: a buy whose deposit never reaches NEAR Intents now expires after 30 minutes instead of blocking the plan forever.

- **Contracts are built and tested but not deployed yet** (ROADMAP K1.13). Real mode needs deployed contracts, funded keeper/factory accounts and a 1Click partner JWT.
- **Quote signatures:** real 1Click quotes work without a partner key, but `verifyQuoteSignature()` in the newest SDK (0.1.26) returns false on dry and real quotes (tested 2026-11-05). Real mode refuses unverified quotes, so real funding and buys stay closed until 1Click confirms the current signing key or hash (ROADMAP K3.5). The demo is not affected.
- **Fees without a partner key:** 1Click adds about 0.25%. A real $100 Bitcoin quote came to about 2.0%, against 1.75% in the fee estimates shown in the demo and on the landing page.
Expand Down
23 changes: 20 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,10 @@
| 10. README & public repo | Nov 12–13 | 100% | ✅ done (hosted URL added) |
| 11. Launch: video, post, submission | Nov 13–14 | 57% | in progress (video, post and submission need the human) |
| 12. After submission | Nov 14–25 | 0% | not started |
| **Overall (MVP)** | | **~81%** | |
| 13. Launch review fixes | Nov 8–13 | 11% | in progress (pre-launch security and reliability review) |
| **Overall (MVP)** | | **~77%** | |

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.)
Phase % = done tasks ÷ non-dropped tasks in that phase. Overall = average of all 15 rows (D + phases 0–13; Phase 13 was added on 2026-11-08, which lowered the overall %). (Earlier entries over-estimated the overall %; recalculated 2026-11-04.)

## Definition of 100% readiness

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

## Phase 11 — Launch (Nov 13–14)

- [x] **K11.1** [#90](https://github.com/MFrancis-dev/stack/issues/90) Video script: saver's problem → fund from another chain → buy lands in own wallet → NEAR's role → next step · 2026-11-05: `docs/launch/VIDEO_SCRIPT.md` (90 s, hosted demo, shot list)
- [x] **K11.1** [#90](https://github.com/MFrancis-dev/stack/issues/90) Video script: saver's problem → fund from another chain → buy lands in own wallet → NEAR's role → next step · 2026-11-05: `docs/launch/VIDEO_SCRIPT.md` (90 s, hosted demo, shot list) · 2026-11-06: alternative story-led script in `docs/launch/VIDEO_SCRIPT_ONE_SAVER.md`
- [ ] **K11.2** [#91](https://github.com/MFrancis-dev/stack/issues/91) 👤 Record the video
- [x] **K11.3** [#92](https://github.com/MFrancis-dev/stack/issues/92) Draft the X post (@NEARProtocol #NEARHackathon, optional @NearLegion @NearBuilders, repo + demo links) · 2026-11-05: `docs/launch/X_POST.md` (one-post and two-post versions) · 2026-11-05: link preview card + site icon so the post's links show the product (`app/opengraph-image.tsx`, `app/icon.svg`)
- [ ] **K11.4** [#93](https://github.com/MFrancis-dev/stack/issues/93) 👤 Publish; paste URL here
Expand All @@ -205,6 +206,20 @@ Format: `- [task id] what blocks it · who can unblock · since date`
- [ ] **K12.3** [#99](https://github.com/MFrancis-dev/stack/issues/99) Repo public through judging; no breaking changes to the demo
- [ ] **K12.4** [#100](https://github.com/MFrancis-dev/stack/issues/100) Watch Discord/X for judge questions

## Phase 13 — Launch review fixes (Nov 8–13)

From the pre-launch security and reliability review (2026-11-08), ranked with auth, payments and user data first. One fix per PR. A tenth finding (a failed vault transfer recorded as sent) turned out to be handled already in `lib/near/index.ts:30`.

- [x] **K13.1** [#127](https://github.com/MFrancis-dev/stack/issues/127) Expire buys whose deposit never reaches 1Click · 2026-11-08: `pollBuys` marks a buy `expired` when 1Click still shows `PENDING_DEPOSIT` 30 min after it (quote deadline 10 min + margin); buys 1Click has seen are never expired; 3 tests
- [ ] **K13.2** [#128](https://github.com/MFrancis-dev/stack/issues/128) Unclear `execute_buy` errors must not be recorded as failed (a timeout after broadcast showed "Nothing spent" while USDC left)
- [ ] **K13.3** [#129](https://github.com/MFrancis-dev/stack/issues/129) ⛔ Vault creation spends the factory's NEAR for anyone who signs in (~1.5 NEAR per vault): owner pays storage, or create after the first deposit
- [ ] **K13.4** [#130](https://github.com/MFrancis-dev/stack/issues/130) Make sessions revocable (sign-out only cleared the cookie)
- [ ] **K13.5** [#131](https://github.com/MFrancis-dev/stack/issues/131) Keep preview and development off the production database
- [ ] **K13.6** [#132](https://github.com/MFrancis-dev/stack/issues/132) Cap demo starts and fast-forwards in the database
- [ ] **K13.7** [#133](https://github.com/MFrancis-dev/stack/issues/133) Rate limits shared across serverless instances
- [ ] **K13.8** [#134](https://github.com/MFrancis-dev/stack/issues/134) Bad ids and malformed cookies return 500
- [ ] **K13.9** [#135](https://github.com/MFrancis-dev/stack/issues/135) Add a Content-Security-Policy

---

## Stretch (only after Phase 11 or explicitly approved)
Expand All @@ -219,6 +234,8 @@ 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-08 · agent · Pre-launch review: new Phase 13 (9 tasks, issues #127–#135); K13.1 buys whose deposit never reaches 1Click expire after 30 min; README status updated · K13.1
- 2026-11-06 · agent · Added an alternative demo video script ("One saver, one month") · K11.1
- 2026-11-05 · agent · Real-quote findings: works without a JWT (K0.8 dropped), signature check false on real quotes (K3.5 reason changed), refund-account behaviour, fee gap documented; README status updated · K0.8, K0.9, K3.5
- 2026-11-05 · agent · Link preview card (Open Graph + X large card, display font with fallback), site icon, page title/description; README updated · K11.3
- 2026-11-05 · agent · Hosted worker: `worker/tick.ts` + `GET /api/cron/tick` (cron-secret protected, loops isolated, in-flight work first) + scheduled `Worker tick` workflow, off until enabled; shared `cronAuthorized`; 4 tests; README/ARCHITECTURE updated · K9.2
Expand Down
26 changes: 26 additions & 0 deletions docs/launch/VIDEO_SCRIPT_ONE_SAVER.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Demo video script: "One saver, one month" (K11.1)

An alternative to [`VIDEO_SCRIPT.md`](VIDEO_SCRIPT.md). It tells one saver's story instead of explaining features. About 85 seconds, recorded on the hosted demo at https://stack-savings.vercel.app.

| Time | On screen | Voiceover |
|---|---|---|
| 0:00–0:10 | Landing page, coins dropping in | "Meet a saver who puts $100 into Bitcoin every week. On an exchange, someone else holds those coins. On small buys, fees quietly take a share." |
| 0:10–0:15 | Stay on the headline "Savings on a schedule" | "Stack is a personal savings vault on NEAR that does the buying for you." |
| 0:15–0:30 | Scroll to **Fees, checked every week**, drag from $100 to $30, then back | "Before every buy, Stack checks the fees. $100 of Bitcoin costs about 1.75%, so it goes ahead. At $30, fees pass the 3% limit, so that week is skipped and nothing is spent." |
| 0:30–0:38 | Press **Try it with example data**, then **Open the demo** | "This demo uses example data, so you don't need a wallet or any money." |
| 0:38–0:46 | Overview: the stack and $996.70 | "Here's a saver four weeks in. Three buys went to their own Bitcoin address. One week was skipped." |
| 0:46–1:00 | **Add funds**: SOL on Solana, amount 2, **Preview** | "Need to top up? Pay from what you already hold, like SOL on Solana. NEAR Intents converts it into dollars in your vault, with no bridge and no exchange account." |
| 1:00–1:10 | **Fast-forward 30 days**, then **History**, pointing at the skipped row | "A month later, the plan kept buying on its own. When fees spiked, it skipped the week and said why." |
| 1:10–1:20 | Press **Pause**, then go back to the landing page | "The limits live in your vault's contract on NEAR. One buy per period, never above your amount, and only you can withdraw." |
| 1:20–1:25 | Show the site address and GitHub link | "Stack. Built at NEAR HACKATHON. Try the demo and tell me what fee limit you'd set." |

## Before you record

- **Refund address:** the Add funds preview needs a Solana refund address. Paste this example before recording so the scene doesn't stall: `4Nd1mBQtrMJVYVfKf2PJy9NZUZdTAsp7D4xWLs4gDB4T`.
- **Fresh data:** press **Start over** in the demo banner, so the dates are recent.
- **Fee numbers:** say "about", because live prices move. If NEAR Intents is reachable, the Add funds preview can differ a little from take to take.

## Keep these lines as written

- **"This demo uses example data":** the contracts aren't deployed yet, so judges shouldn't think they're watching real money move.
- **"Only you can withdraw":** this is what the vault contract's rules enforce, and tests cover it. Avoid "nobody can freeze it", because the money in the vault is USDC, which its issuer can freeze.
13 changes: 11 additions & 2 deletions worker/pollBuys.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
/**
* 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
* A buy whose deposit 1Click still hasn't seen well after its quote expired is marked `expired`;
* otherwise it would count as in flight forever and block every later buy of the plan.
* Roadmap: K5.4, K13.1 · Spec: ARCHITECTURE.md §4.3
*/

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";

/** Buy quotes expire after 10 minutes (lib/oneclick/quote.ts buyDeadline); the rest is margin for slow chains. */
export const BUY_EXPIRY_MS = 30 * 60_000;

const EXPIRED_NOTE = "The deposit never reached NEAR Intents before the quote expired, so nothing was bought. Any USDC sent is refunded to the vault.";

interface SwapDetailsLike {
amountOut?: string;
destinationChainTxHashes?: { hash?: string }[];
Expand All @@ -20,7 +27,8 @@ export async function pollBuys(deps: Pick<WorkerDeps, "db" | "status" | "now" |
if (!b.depositAddress) continue;
try {
const r = await deps.status.getStatus(b.depositAddress);
const status = buyStatusFromOneClick(r.status);
const neverDeposited = r.status === "PENDING_DEPOSIT" && deps.now() - b.createdAt.getTime() > BUY_EXPIRY_MS;
const status = neverDeposited ? "expired" : 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);
Expand All @@ -31,6 +39,7 @@ export async function pollBuys(deps: Pick<WorkerDeps, "db" | "status" | "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}` : ""}` } : {}),
...(neverDeposited ? { note: EXPIRED_NOTE } : {}),
});
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 });
Expand Down
27 changes: 26 additions & 1 deletion worker/worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { listBuysByUser as buysOf } from "../lib/db/queries/buys";
import { getUserByAccount } from "../lib/db/queries/users";
import type { Logger, WorkerDeps } from "./deps";
import { expireQuotes } from "./expireQuotes";
import { pollBuys } from "./pollBuys";
import { BUY_EXPIRY_MS, pollBuys } from "./pollBuys";
import { pollFunding } from "./pollFunding";
import { startLoops } from "./runner";
import { RETRY_BACKOFF_MS, scheduleBuys } from "./scheduleBuys";
Expand Down Expand Up @@ -206,6 +206,31 @@ describe("pollBuys (K5.4)", () => {
expect((await listBuysByUser(db, userId))[0]).toMatchObject({ status: "refunded", note: "refunded to the vault: price moved" });
});

it("expires a buy whose deposit never reached 1Click, so the plan can run again", async () => {
await insertBuy(db, { userId, quoteRef: "q-old", amountIn: asBaseUnits("100000000"), targetAsset: "nep141:btc.omft.near", destinationAddress: DEST, depositAddress: "dep-old", status: "sent", createdAt: new Date(NOW - BUY_EXPIRY_MS - 60_000) });
statusOf = () => "PENDING_DEPOSIT";
await pollBuys(deps());
const [b] = await listBuysByUser(db, userId);
expect(b).toMatchObject({ status: "expired", oneclickStatus: "PENDING_DEPOSIT" });
expect(b!.completedAt).not.toBeNull();
expect(b!.note).toMatch(/never reached NEAR Intents/);
expect((await scheduleBuys(deps())).get(userId)).not.toBe("in_flight");
});

it("keeps waiting for a recent buy that 1Click hasn't seen yet", async () => {
await insertBuy(db, { userId, quoteRef: "q-new", amountIn: asBaseUnits("100000000"), targetAsset: "nep141:btc.omft.near", destinationAddress: DEST, depositAddress: "dep-new", status: "sent", createdAt: new Date(NOW - 5 * 60_000) });
statusOf = () => "PENDING_DEPOSIT";
await pollBuys(deps());
expect((await listBuysByUser(db, userId))[0]).toMatchObject({ status: "sent", completedAt: null });
});

it("never expires a buy once 1Click has seen its deposit", async () => {
await insertBuy(db, { userId, quoteRef: "q-seen", amountIn: asBaseUnits("100000000"), targetAsset: "nep141:btc.omft.near", destinationAddress: DEST, depositAddress: "dep-seen", status: "sent", createdAt: new Date(NOW - 3 * BUY_EXPIRY_MS) });
statusOf = () => "KNOWN_DEPOSIT_TX";
await pollBuys(deps());
expect((await listBuysByUser(db, userId))[0]).toMatchObject({ status: "sent", oneclickStatus: "KNOWN_DEPOSIT_TX" });
});

it("keeps polling processing buys", async () => {
await sentBuy();
statusOf = () => "PROCESSING";
Expand Down
Loading