From 5869cb76d21b8d4ecac24853edce7d8ecf459844 Mon Sep 17 00:00:00 2001 From: Allison Muyideen Opeyemi Date: Sun, 8 Nov 2026 09:56:50 +0100 Subject: [PATCH] Keep buys in flight when NEAR gives no clear result A timeout after the transaction was sent marked the buy failed, and History said nothing was spent while the USDC may already have left. Closes #128 --- ARCHITECTURE.md | 2 +- README.md | 2 +- ROADMAP.md | 5 +++-- lib/core/adapters.ts | 11 +++++++++++ lib/near/index.ts | 12 +++++++++--- lib/near/vault.test.ts | 21 +++++++++++++++++++++ lib/near/vault.ts | 10 ++++++++++ worker/scheduleBuys.ts | 10 +++++++++- worker/worker.test.ts | 11 +++++++++++ 9 files changed, 76 insertions(+), 8 deletions(-) create mode 100644 lib/near/vault.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2735fdb..471e366 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -192,7 +192,7 @@ On a long-running host `pnpm worker` runs the loops below on their intervals. On | Loop | Every | Does | |---|---|---| | `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) | +| `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). If NEAR gives no clear result from `execute_buy` (timeout, network error) the row stays `sent` with a note, never `failed`: the USDC may have left. Only a contract refusal, failed action or rejected transaction marks it failed (`isClearTxFailure`) | | `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 | diff --git a/README.md b/README.md index a94ced1..18204bf 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ 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. +- **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; when NEAR gives no clear result for a buy (a timeout after sending), it stays in flight until NEAR Intents confirms it, instead of being shown as failed with nothing spent. - **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. diff --git a/ROADMAP.md b/ROADMAP.md index 020fee2..7072bfc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -27,7 +27,7 @@ | 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 | -| 13. Launch review fixes | Nov 8–13 | 11% | in progress (pre-launch security and reliability review) | +| 13. Launch review fixes | Nov 8–13 | 22% | in progress (pre-launch security and reliability review) | | **Overall (MVP)** | | **~77%** | | 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.) @@ -211,7 +211,7 @@ Format: `- [task id] what blocks it · who can unblock · since date` 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) +- [x] **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) · 2026-11-08: the NEAR adapter raises `BuyOutcomeUnknownError` unless the error is a clear failure (contract refusal, failed action, rejected transaction); the scheduler keeps that buy `sent` with a note and alerts; K13.1's expiry settles it if nothing arrived; 3 tests - [ ] **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 @@ -234,6 +234,7 @@ From the pre-launch security and reliability review (2026-11-08), ranked with au ## Update log One line per contribution, newest first: `YYYY-MM-DD · who/agent · what changed · tasks` +- 2026-11-08 · agent · K13.2 unclear `execute_buy` results stay in flight (`BuyOutcomeUnknownError`, `isClearTxFailure`) instead of being marked failed; README status and ARCHITECTURE §4.4 updated · K13.2 - 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 diff --git a/lib/core/adapters.ts b/lib/core/adapters.ts index 915af81..5d0b6c5 100644 --- a/lib/core/adapters.ts +++ b/lib/core/adapters.ts @@ -19,6 +19,17 @@ export interface StatusProvider { submitDepositTx(depositAddress: string, txHash: string): Promise; } +/** + * `executeBuy` sent the transaction but can't tell whether it landed (timeout, network error). + * The USDC may already have left, so the buy must stay in flight until 1Click's status settles it. + */ +export class BuyOutcomeUnknownError extends Error { + constructor(message: string) { + super(message); + this.name = "BuyOutcomeUnknownError"; + } +} + export interface VaultClient { /** The owner's vault per the factory, if one exists. */ findVault(owner: string): Promise; diff --git a/lib/near/index.ts b/lib/near/index.ts index ac8e0a1..41fec5d 100644 --- a/lib/near/index.ts +++ b/lib/near/index.ts @@ -3,10 +3,10 @@ * Roadmap: K3.3 */ -import type { VaultClient } from "../core/adapters"; +import { BuyOutcomeUnknownError, type VaultClient } from "../core/adapters"; import { createVaultFor, getVault } from "./factory"; import type { NearProvider } from "./rpc"; -import { executeBuy, getUsdcBalance, getVaultState, type KeeperAccount } from "./vault"; +import { executeBuy, getUsdcBalance, getVaultState, isClearTxFailure, type KeeperAccount } from "./vault"; export function nearVaultClient(deps: { provider: Pick; @@ -26,7 +26,13 @@ export function nearVaultClient(deps: { return { vaultAccountId: r.vaultAccountId, txHash: r.txHash }; }, async executeBuy(input) { - const r = await executeBuy(deps.keeper, input); + let r; + try { + r = await executeBuy(deps.keeper, input); + } catch (e) { + if (isClearTxFailure(e)) throw e; + throw new BuyOutcomeUnknownError(`No clear result from NEAR: ${e instanceof Error ? e.message : String(e)}`); + } if (!r.sent) throw new Error(`Buy transfer failed on-chain (tx ${r.txHash})`); return { txHash: r.txHash }; }, diff --git a/lib/near/vault.test.ts b/lib/near/vault.test.ts new file mode 100644 index 0000000..7e05805 --- /dev/null +++ b/lib/near/vault.test.ts @@ -0,0 +1,21 @@ +import { ContractExecutionError, InvalidTransactionError, RpcError, TransactionExecutionError, TransactionTimeoutError } from "near-api-js/rpc-errors"; +import { describe, expect, it } from "vitest"; +import { isClearTxFailure } from "./vault"; + +// Roadmap: K13.2. Instances made without constructors: only the class matters here. +const as = (cls: { prototype: T }) => Object.create(cls.prototype) as T; + +describe("isClearTxFailure", () => { + it("treats contract refusals and failed or rejected transactions as clear failures", () => { + expect(isClearTxFailure(as(ContractExecutionError))).toBe(true); + expect(isClearTxFailure(as(TransactionExecutionError))).toBe(true); + expect(isClearTxFailure(as(InvalidTransactionError))).toBe(true); + }); + + it("treats timeouts, RPC trouble and network errors as unknown", () => { + expect(isClearTxFailure(as(TransactionTimeoutError))).toBe(false); + expect(isClearTxFailure(as(RpcError))).toBe(false); + expect(isClearTxFailure(new TypeError("fetch failed"))).toBe(false); + expect(isClearTxFailure("boom")).toBe(false); + }); +}); diff --git a/lib/near/vault.ts b/lib/near/vault.ts index 745d8a0..f6e3f22 100644 --- a/lib/near/vault.ts +++ b/lib/near/vault.ts @@ -2,6 +2,7 @@ * Typed vault bindings: views (anyone) and execute_buy (keeper). Roadmap: K3.3 · Spec: ARCHITECTURE.md §3.3 */ +import { ContractExecutionError, InvalidTransactionError, TransactionExecutionError } from "near-api-js/rpc-errors"; import { actions } from "near-api-js"; import { asBaseUnits } from "../core/money"; import type { BaseUnits, VaultState } from "../core/types"; @@ -61,6 +62,15 @@ export interface BuyResult { events: NearEvent[]; } +/** + * True when NEAR gave a clear answer that the buy did not happen: the contract refused it, an + * action failed, or the transaction was rejected before it ran. Anything else (timeouts, network + * errors, RPC trouble) leaves the outcome unknown. + */ +export function isClearTxFailure(e: unknown): boolean { + return e instanceof ContractExecutionError || e instanceof TransactionExecutionError || e instanceof InvalidTransactionError; +} + export async function executeBuy( keeper: KeeperAccount, input: { vaultAccountId: string; quoteRef: string; depositAddress: string; amount: BaseUnits }, diff --git a/worker/scheduleBuys.ts b/worker/scheduleBuys.ts index 122d06e..f4c8083 100644 --- a/worker/scheduleBuys.ts +++ b/worker/scheduleBuys.ts @@ -5,6 +5,7 @@ */ import { randomUUID } from "node:crypto"; +import { BuyOutcomeUnknownError } from "../lib/core/adapters"; import { checkCost, costSkipNote } from "../lib/core/cost"; import { minUnits } from "../lib/core/money"; import { quoteMatchesPlan } from "../lib/core/planMatch"; @@ -17,7 +18,7 @@ import { errorMessage, type WorkerDeps } from "./deps"; /** After a skipped or failed attempt, wait this long before trying the same run again. */ export const RETRY_BACKOFF_MS = 15 * 60 * 1000; -export type BuyOutcome = "not_due" | "in_flight" | "backoff" | "empty" | "no_quote" | "skipped_cost" | "refused" | "failed" | "sent"; +export type BuyOutcome = "not_due" | "in_flight" | "backoff" | "empty" | "no_quote" | "skipped_cost" | "refused" | "failed" | "sent" | "unclear"; export async function scheduleBuys(deps: WorkerDeps): Promise> { const outcomes = new Map(); @@ -129,6 +130,13 @@ async function execute( } return "sent"; } catch (e) { + if (e instanceof BuyOutcomeUnknownError) { + // The USDC may already have left. Stay in flight: pollBuys settles it from 1Click's status, and + // a deposit 1Click never sees expires after BUY_EXPIRY_MS (K13.2). + await updateBuy(deps.db, row.id, { note: `Waiting for NEAR Intents to confirm: ${errorMessage(e)}` }); + deps.log.alert("execute_buy outcome unclear", { userId: user.id, quoteRef: row.quoteRef, error: errorMessage(e) }); + return "unclear"; + } await updateBuy(deps.db, row.id, { status: "failed", note: errorMessage(e), completedAt: new Date(deps.now()) }); deps.log.alert("execute_buy failed", { userId: user.id, quoteRef: row.quoteRef, error: errorMessage(e) }); return "failed"; diff --git a/worker/worker.test.ts b/worker/worker.test.ts index 8832de2..f1e2bfd 100644 --- a/worker/worker.test.ts +++ b/worker/worker.test.ts @@ -1,5 +1,6 @@ import { sql } from "drizzle-orm"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { BuyOutcomeUnknownError } from "../lib/core/adapters"; import { USDC_ON_NEAR } from "../lib/core/assets"; import { asBaseUnits } from "../lib/core/money"; import type { OneClickStatus } from "../lib/core/status"; @@ -165,6 +166,16 @@ describe("scheduleBuys (K5.3)", () => { expect((await listBuysByUser(db, userId))[0]).toMatchObject({ status: "failed", note: "Buy transfer failed on-chain (tx X)" }); }); + it("keeps a buy in flight when NEAR gives no clear result, instead of calling it failed", async () => { + fakes.executeBuy.mockRejectedValueOnce(new BuyOutcomeUnknownError("No clear result from NEAR: timeout")); + expect((await scheduleBuys(deps())).get(userId)).toBe("unclear"); + const [b] = await listBuysByUser(db, userId); + expect(b).toMatchObject({ status: "sent", completedAt: null, depositAddress: "dep-buy-1" }); + expect(b!.note).toMatch(/Waiting for NEAR Intents to confirm/); + expect(logs).toContainEqual({ level: "alert", msg: "execute_buy outcome unclear" }); + expect((await scheduleBuys(deps())).get(userId)).toBe("in_flight"); + }); + it("waits while a buy is in flight, and backs off after a skip", async () => { await insertBuy(db, { userId, quoteRef: "inflight", amountIn: asBaseUnits("1"), targetAsset: "t", destinationAddress: "d", status: "sent" }); expect((await scheduleBuys(deps())).get(userId)).toBe("in_flight");