From 1f84164089cf71340d4376b085506624c1146080 Mon Sep 17 00:00:00 2001 From: Allison Muyideen Opeyemi Date: Sun, 8 Nov 2026 09:49:32 +0100 Subject: [PATCH 1/2] Add an alternative demo video script --- ROADMAP.md | 3 ++- docs/launch/VIDEO_SCRIPT_ONE_SAVER.md | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 docs/launch/VIDEO_SCRIPT_ONE_SAVER.md diff --git a/ROADMAP.md b/ROADMAP.md index 8dd41ab..01a87fb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -190,7 +190,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 @@ -219,6 +219,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-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 diff --git a/docs/launch/VIDEO_SCRIPT_ONE_SAVER.md b/docs/launch/VIDEO_SCRIPT_ONE_SAVER.md new file mode 100644 index 0000000..aa28dee --- /dev/null +++ b/docs/launch/VIDEO_SCRIPT_ONE_SAVER.md @@ -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. From 227b29c3aeba38d4b0ad75f802816fa5f7433f1c Mon Sep 17 00:00:00 2001 From: Allison Muyideen Opeyemi Date: Sun, 8 Nov 2026 09:52:42 +0100 Subject: [PATCH 2/2] Expire buys whose deposit never reaches 1Click A buy still PENDING_DEPOSIT 30 minutes after it was sent counted as in flight forever and blocked every later buy of the plan. Closes #127 --- ARCHITECTURE.md | 2 +- README.md | 2 ++ ROADMAP.md | 20 ++++++++++++++++++-- worker/pollBuys.ts | 13 +++++++++++-- worker/worker.test.ts | 27 ++++++++++++++++++++++++++- 5 files changed, 58 insertions(+), 6 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 12ce247..2735fdb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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) | diff --git a/README.md b/README.md index 5b4faaf..a94ced1 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/ROADMAP.md b/ROADMAP.md index 01a87fb..020fee2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 @@ -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) @@ -219,6 +234,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-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 diff --git a/worker/pollBuys.ts b/worker/pollBuys.ts index ce224c0..8b2e2d4 100644 --- a/worker/pollBuys.ts +++ b/worker/pollBuys.ts @@ -1,7 +1,9 @@ /** * 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"; @@ -9,6 +11,11 @@ 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 }[]; @@ -20,7 +27,8 @@ export async function pollBuys(deps: Pick 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); @@ -31,6 +39,7 @@ export async function pollBuys(deps: Pick { 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";