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 @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
11 changes: 11 additions & 0 deletions lib/core/adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ export interface StatusProvider {
submitDepositTx(depositAddress: string, txHash: string): Promise<void>;
}

/**
* `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<string | null>;
Expand Down
12 changes: 9 additions & 3 deletions lib/near/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NearProvider, "callFunction">;
Expand All @@ -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 };
},
Expand Down
21 changes: 21 additions & 0 deletions lib/near/vault.test.ts
Original file line number Diff line number Diff line change
@@ -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 = <T extends object>(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);
});
});
10 changes: 10 additions & 0 deletions lib/near/vault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 },
Expand Down
10 changes: 9 additions & 1 deletion worker/scheduleBuys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<Map<string, BuyOutcome>> {
const outcomes = new Map<string, BuyOutcome>();
Expand Down Expand Up @@ -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";
Expand Down
11 changes: 11 additions & 0 deletions worker/worker.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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");
Expand Down
Loading