diff --git a/AGENTS.md b/AGENTS.md index b750b49..58f3667 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,15 +61,16 @@ See `project_structure.md`. Summary: one Next.js app (`app/`, `components/`, `li Installed and verified 2026-11-04 with **pnpm 11** (`packageManager` is pinned; esbuild's build script is approved in `pnpm-workspace.yaml`). Exact versions are pinned in `package.json`; add new ones pinned too. Keep this table accurate. -| Task | Command | -| ---------------------------------- | ----------------------------------------------------------------------------------------------------- | -| Install | `pnpm install` | -| Dev app / worker | `pnpm dev` / `pnpm worker` | -| **Demo mode (zero config)** | `pnpm demo` | -| Typecheck / lint / format | `pnpm typecheck` / `pnpm lint` / `pnpm format` (check without writing: `pnpm format:check`) | -| Tests / E2E | `pnpm test` / `pnpm e2e` | -| DB | `pnpm db:generate && pnpm db:migrate` | -| Storage payer (S0.10, 👤 funds it) | `pnpm payer` status · `pnpm payer:generate` local key → `.env` · `pnpm payer:check` read-only balance | +| Task | Command | +| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| Install | `pnpm install` | +| Dev app / worker | `pnpm dev` / `pnpm worker` | +| **Demo mode (zero config)** | `pnpm demo` | +| Typecheck / lint / format | `pnpm typecheck` / `pnpm lint` / `pnpm format` (check without writing: `pnpm format:check`) | +| Tests / E2E | `pnpm test` / `pnpm e2e` | +| DB | `pnpm db:generate && pnpm db:migrate` | +| Storage payer (S0.10, 👤 funds it) | `pnpm payer` status · `pnpm payer:generate` local key → `.env` · `pnpm payer:check` read-only balance | +| 1Click credential (S8.7) | `pnpm oneclick:check` (dry preview) · `pnpm oneclick:check --real ` (proves a deposit address; moves no funds) | Before saying done: `pnpm format && pnpm typecheck && pnpm lint && pnpm test`. CI does not run the formatter, so an edit that skips `pnpm format` leaves drift in the tree — it came back within three days of the last sweep (#97). `pnpm demo` must work on a fresh clone with **no `.env`, no keys, no external DB**. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e96413f..d0181c0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -88,7 +88,7 @@ Conventions every route follows: ### 3.2 1Click integration (`lib/oneclick`) -Base `https://1click.chaindefuser.com`; `ONECLICK_JWT` is the partner credential. Measured 2026-11-10: unauthenticated `POST /v0/quote` answers `400 Internal server error` on every route tried, dry or not, where the same body returned 200 on 2026-11-09; a bogus bearer answers `401 Invalid token`, and `GET /v0/tokens` still answers 200. Treat the JWT as required for any quote (§8), not merely as a fee discount. +Base `https://1click.chaindefuser.com`; `ONECLICK_JWT` is the partner credential. Measured 2026-11-10 (S8.7): a **dry** quote answers 200 without a token, while a **non-dry** quote — the one that carries `depositAddress` — answers `400 Internal server error` without one and `401 Invalid token` with a bogus one; `GET /v0/tokens` answers 200 either way. The vendor's docs agree: unauthenticated quotes are allowed at +25 bps, but an address is issued only to a partner credential. So the JWT is required for a real quote, not for a preview (§8, S8.4/S8.7). Invoice quote: diff --git a/DEPLOY.md b/DEPLOY.md index 2f3870f..e503a57 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -148,11 +148,26 @@ Point the host's health check at it, and use it to confirm S8.3 from the outside | `SESSION_SECRET` | yes | yes | ≥ 32 chars; without it nobody can sign in | | `PUBLIC_APP_URL` | yes | yes | the `https://` URL; also decides cookie `Secure` | | `NEAR_RPC_URL` | no | yes | signed actions fail closed with `not_configured` | -| `ONECLICK_JWT` | no | no | optional; 0.2% fee without it (Q-006) | +| `ONECLICK_JWT` | no | **yes** | a non-dry quote needs it; previews work without it | | `DEMO_MODE` | no | never | must be **unset** on a host, per §3 | `pnpm check-env` prints what is set and what is missing without echoing a secret. +### Obtaining the 1Click credential (S8.4, 👤) + +`ONECLICK_JWT` is the only secret that cannot be generated here: it is issued by a partner account. +Without it, previews price but no deposit address is issued. + +1. Sign in at https://partners.near-intents.org (WorkOS email/SSO) and copy the API key (JWT) from + the Partner Dashboard. An API key is per integration; never paste it into chat or a commit. +2. Local: add `ONECLICK_JWT=…` to `.env.local`, then + `set -a; . ./.env.local; set +a; pnpm oneclick:check`. + `pnpm oneclick:check --real ` additionally proves that a deposit address is + issued; it quotes 10.5 USDC on NEAR into itself, sends nothing, and the quote expires. +3. Hosted: `vercel env add ONECLICK_JWT production`, then redeploy — the Vercel project is not + Git-connected, so a merge alone does not ship it. Verify with the same command locally against + the stored value; the hosted pay page then issues real addresses. + ## 6. Host options, read on 2026-11-08 S8.1 has to buy the shape proven in §3: **two long-lived processes and one Postgres, all reachable @@ -214,8 +229,10 @@ Two limits of this host, stated so nobody is surprised by them: live quote, and even with the JWT a deposit would not be driven to `paid` without a worker. Live payments need a §1 two-process host (Render/Railway/Fly or one machine under a supervisor). This is why the full walkthrough stays `pnpm demo` — offline, no JWT, no worker, all statuses reachable. -- **No `ONECLICK_JWT`** (S0.8, the partner credential), so quotes are refused by design. The rest of - the app is exercisable; payment pricing is not. +- **No `ONECLICK_JWT`** (S0.8, the partner credential), so a deposit address is refused by design. + A dry preview still prices (measured 2026-11-10), but a real quote answers `400 Internal server +error` without the credential. The app now checks the deployment first and answers + `server_misconfigured` instead of blaming the caller (S8.7). What was found by deploying and not by testing: the app returned `503 database:unreachable` on a cold serverless boot with `Error: Can't find meta/_journal.json`. Drizzle's migrator reads `./drizzle` from diff --git a/LAUNCH.md b/LAUNCH.md index 853c026..6614e24 100644 --- a/LAUNCH.md +++ b/LAUNCH.md @@ -16,19 +16,19 @@ job is to show the exact shape and escaping a real POST body needs. Read from https://nearhackathon.com/HACKATHON.md on 2026-11-06. What it changes for us: -| Fact | Value in the live file | Effect on our plan | -| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Development ends | 2026-11-14T23:59:00Z | unchanged; our target submit date of 2026-11-13 still leaves a day of margin | -| Judging starts | 2026-11-16T00:00:00Z | the hosted demo must be up before this, not merely before the deadline | -| Results announced by | 2026-11-18T23:59:00Z | S11.1's "keep the demo up to at least Nov 18" is exactly right; Nov 25 remains the safer target | -| Code window | from the 2026-10-19 announcement to 2026-11-14 (Q-001) | our README says "written between 2026-11-03 and the deadline", which sits inside the window; no baseline commits exist to separate | -| 1Click JWT | **not required**; without it a 0.2% fee applies (Q-006) | the app must keep working with no `ONECLICK_JWT`, which it does — the token list still loads and the pay page says what it could not do. Registering at https://partners.near-intents.org would remove the fee. S0.12/S0.13 were answered on 2026-11-09 with unauthenticated `dry` quotes. **2026-11-10 correction:** the vendor now refuses every unauthenticated quote — the same USDC→USDC dry call that returned 200 the day before returns `400 Internal server error`, a bogus bearer returns `401 Invalid token`, and `/v0/tokens` still answers — so the JWT is required for any quote, not just a non-dry one, and S8.4 is blocked on it | -| Testnet for Intents | none exists or is planned (Q-007) | confirms the demo-recording design: mainnet, separate dev accounts, tiny amounts, `dry: true` quotes | -| Local-only demo | allowed, **but** "screenshots are not enough when judges cannot open the demo" — a video is needed; a hosted demo is a plus (Q-012) | S10.2's video is mandatory in practice, not optional. This raises the priority of the recording above the hosting | -| AI screening | Claude Code and Codex screen every project: reproducibility check, feature verification, structured report, preliminary score; humans decide (Q-017) | the README's "do X → see Y" table and `pnpm demo` are written for exactly this reader | -| `website` field | **a spam trap**: any non-empty value rejects the submission | our payload must not contain it. The contract is the eleven fields below and nothing else | -| Request changes | mistakes are fixed at https://nearhackathon.com/request-changes, never by re-submitting; the same repo answers 409 | one attempt per project, as AGENTS.md §7 already says | -| Tracks | `realworld` = "Real-World Money … payroll, invoicing, remittances, merchant payments and stablecoin rails … STABLECOINS, NEAR INTENTS, META TRANSACTIONS" | Settle is invoicing + stablecoin rails over NEAR Intents: `realworld` is the fit. `defi` and `business` are arguable but the hints there are trading/agent-treasury; one honest track beats three loose ones | +| Fact | Value in the live file | Effect on our plan | +| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Development ends | 2026-11-14T23:59:00Z | unchanged; our target submit date of 2026-11-13 still leaves a day of margin | +| Judging starts | 2026-11-16T00:00:00Z | the hosted demo must be up before this, not merely before the deadline | +| Results announced by | 2026-11-18T23:59:00Z | S11.1's "keep the demo up to at least Nov 18" is exactly right; Nov 25 remains the safer target | +| Code window | from the 2026-10-19 announcement to 2026-11-14 (Q-001) | our README says "written between 2026-11-03 and the deadline", which sits inside the window; no baseline commits exist to separate | +| 1Click JWT | **not required**; without it a 0.2% fee applies (Q-006) | the app keeps working with no `ONECLICK_JWT`: the token list loads, a dry preview prices, and the pay page says what it could not do. Registering at https://partners.near-intents.org removes the extra fee. **2026-11-10, re-measured (S8.7):** a dry quote answers 200 unauthenticated, but the non-dry quote that carries `depositAddress` answers `400 Internal server error` without the credential and `401 Invalid token` with a bogus one — so the JWT is required for a real payment address, not for a preview. `pnpm oneclick:check` proves a token the moment it exists; S8.4 is blocked on the credential itself | +| Testnet for Intents | none exists or is planned (Q-007) | confirms the demo-recording design: mainnet, separate dev accounts, tiny amounts, `dry: true` quotes | +| Local-only demo | allowed, **but** "screenshots are not enough when judges cannot open the demo" — a video is needed; a hosted demo is a plus (Q-012) | S10.2's video is mandatory in practice, not optional. This raises the priority of the recording above the hosting | +| AI screening | Claude Code and Codex screen every project: reproducibility check, feature verification, structured report, preliminary score; humans decide (Q-017) | the README's "do X → see Y" table and `pnpm demo` are written for exactly this reader | +| `website` field | **a spam trap**: any non-empty value rejects the submission | our payload must not contain it. The contract is the eleven fields below and nothing else | +| Request changes | mistakes are fixed at https://nearhackathon.com/request-changes, never by re-submitting; the same repo answers 409 | one attempt per project, as AGENTS.md §7 already says | +| Tracks | `realworld` = "Real-World Money … payroll, invoicing, remittances, merchant payments and stablecoin rails … STABLECOINS, NEAR INTENTS, META TRANSACTIONS" | Settle is invoicing + stablecoin rails over NEAR Intents: `realworld` is the fit. `defi` and `business` are arguable but the hints there are trading/agent-treasury; one honest track beats three loose ones | No rule changed in a way that invalidates work already merged. The two live consequences are: **the demo video is effectively required**, and **the payload must not gain a `website` field**. diff --git a/README.md b/README.md index f4406a3..d6d0dfc 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ app/ (routes, pages) worker/ (two interval loops) ## Limitations, honestly - **Web app deployed; the worker is not.** The Next.js app is live on Vercel (https://settle-wine-alpha.vercel.app) in **real mode** against a hosted Postgres — landing, NEAR-wallet sign-in, invoice create / list / dashboard / receipt — and `GET /api/health` returns `{"status":"ok","demo":false,"database":"ok"}` from the running deployment, not only from a local boot. It was **re-deployed on 2026-11-09** from the merged tip — the Vercel project is not connected to Git, so a merge does not deploy — and the staleness probe in `DEPLOY.md` §8 went from 404 to 200. Loading the live URL in a real browser then found a hydration mismatch on the landing page (React #418): the server's HTML said wallet sign-in was unavailable while the browser's first render offered it, so React threw the markup away. Fixed here and pinned by the fifth e2e test, then deployed: the live page re-checked in Chromium at 1440 px and 390 px reports the sign-in button enabled, no page or console error, no failed request and no horizontal overflow. A screenshot of that page then caught the one defect no probe could: the hero receipt's amounts inherited the brand field's white text and rendered white-on-white, so the same e2e test now reads the amount's computed colour and fails it, and the fix (a `text-ink` on the card root) was proven by re-injecting the bug and watching the test go red first. A green `/api/health` detects neither problem, which is why the deploy notes now say to probe a route only the new artifact has and then read the first screen's console. It was **re-deployed once more on 2026-11-10** with S1.7: the challenge probe answers 200, `/api/health` is green, the landing page opens in Chromium with no console message, and the live `/api/tokens` carries `refundChain` and lists BTC as refund-only (`settleChain: null`) with no BTC asset among the payouts. The **same day** it was deployed again with S4.15, the dark landing redesign: the served HTML carries the new hero ("Invoice in dollars. Paid in any token."), the challenge probe answers 200, `/api/health` is green, the sign-in button is enabled after hydration, and Chromium at 1440 px and 390 px reports **no page or console error and no horizontal overflow**. Two things that URL does not do yet, stated plainly: **no live payment quote**, because the vendor `ONECLICK_JWT` (S0.8) is not in hand, so the pay page fails closed before it prices anything; and **no background worker**, because a serverless host cannot keep the long-lived 5 s poll / 60 s expire process. That second limit is exactly why `DEPLOY.md` §6 concluded that a two-process host is a paid decision — Render's free-plan page says free plans do not support background workers, and usage-based hosts bill a worker that never idles. A full live payment therefore still needs a host that runs both processes plus the JWT. The offline evidence is unchanged: `pnpm demo`, the E2E browser journey — which drives a **production build** (`pnpm build` + `pnpm start`, the same two commands a host runs) rather than `next dev` — and 671 passing offline tests (672 total, one Postgres-only skip). `DEPLOY.md` also records two constraints that were measured, not assumed: PGlite is single-process — two processes on one directory lost half their writes and killed one outright — so a hosted Settle needs Postgres; and demo mode deliberately refuses an `https://` URL, so the hosted app runs **real mode** (decision `SD-8`) and the demo story lives in `pnpm demo` and the video. -- **A real quote has never been driven offline, and since 2026-11-10 no quote is served without the partner JWT.** The minimum refusal itself is not a guess — `try at least N` was provoked against the live API on 2026-11-09 with unauthenticated _dry_ quotes, which is what fixed the units it is rendered in (S0.13). Then, on **2026-11-10**, the same unauthenticated `POST /v0/quote` — dry or not, on three routes including the USDC→USDC call that had answered 200 the day before — returned `400 Internal server error`, while the same body with a bogus bearer returned `401 Invalid token`. The token list still answers without credentials. So a real payment now needs the partner JWT (S0.8, #11) even for a preview, plus a funded signer, a running worker and a two-process host. +- **A real quote needs the partner JWT; a preview does not.** Measured on **2026-11-10**: an unauthenticated _dry_ `POST /v0/quote` answers 200 (the same body had returned a transient `400` earlier that day), while a **non-dry** quote — the one that carries the `depositAddress` — answers `400 Internal server error` without the credential and `401 Invalid token` with a bogus one. The vendor's docs match: unauthenticated quotes are allowed at +25 bps, but an address is issued only to a partner credential. `pnpm oneclick:check` proves a token the moment it exists, and `--real ` proves it can issue an address without moving funds. The minimum refusal itself is not a guess — `try at least N` was provoked against the live API with unauthenticated dry quotes on 2026-11-09, which fixed the units it is rendered in (S0.13). So a real payment needs the partner JWT (S0.8, #11) plus a funded signer, a running worker and a two-process host; the preview and the whole demo story work without it. - **No testnet, because NEAR Intents has none.** Re-read live on 2026-11-10: the rules still answer Q-007 "None exists or is planned" and the vendor's docs say "There is no testnet deployment and no plans for one." Every money path is therefore mainnet — the wallet selector is pinned to mainnet (`lib/ui/config.ts`), the storage check reads the real USDC contract, and the no-funds paths are `pnpm demo` and `dry: true` quotes. Testnet NEAR buys nothing here; the submission's prize-payout account is the one mainnet account the human supplies. The full per-path audit is `ARCHITECTURE.md` §11 (S7.14). - **Refund addresses are checked on five payout chains, plus Bitcoin.** `lib/core/address.ts` validates NEAR, EVM (EIP-55), Solana and, since S1.7, **BTC** — base58-check P2PKH/P2SH and segwit bech32/bech32m, mainnet only. Bitcoin is a _refund-only_ chain: a client can pay from BTC and is refunded there, but Settle never pays out to it. Everything else a client might pay from is still refused with a reason rather than accepted unchecked; for TON and Tron that is a missing validator, and for chains that need a memo it is the missing memo field. The memo list itself is ours and flagged 🔍, because 1Click does not publish which origins require one. Refusing errs safe: an address Settle cannot check is not one it will send money back to. - **A region block is passed through, not recognised.** The vendor's own wording reaches the client; Settle does not match it against strings it guessed. diff --git a/ROADMAP.md b/ROADMAP.md index e3eee7a..62b1454 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,24 +11,24 @@ ## Readiness -| Phase | Target dates | Progress | Status | -| ----------------------------------- | ------------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| D. Design & docs | Nov 4 | 100% | ✅ done | -| 0. Setup & verification | Nov 4–5 | 88% | in progress (14 of 16; **S0.12 + S0.13 ✅ 2026-11-09** answered from the live API and the vendor docs, no JWT needed — and S0.13 found a units bug that is fixed. Only S0.8 and S0.10 remain, both 👤) | -| 1. Core logic (`lib/core`) | Nov 5–6 | 100% | ✅ done (S1.1–S1.7; S1.7 validates Bitcoin refund addresses, so a BTC payer is checked here rather than only by the vendor) | -| 2. Integrations (1Click, NEAR, DB) | Nov 5–7 | 90% | in progress (S2.1–S2.9 done; S2.10 spends payer funds, blocked) | -| 3. API routes | Nov 6–7 | 100% | ✅ done (S3.1–S3.8; `storage_deposit` spend stays blocked on S2.10) | -| 4. UI | Nov 7–9 | 100% | ✅ done (S4.1–S4.15; the E2E demo journey shipped with Phase 6, S4.9 is the app-wide identity, S4.10 designs the landing page, S4.11 keeps the wallet stack out of the first bundle, S4.12/13 are the Landbook-inspired redesign and the brand assets, S4.14 keeps a 24-decimal amount inside its quote column, S4.15 restyles the landing as a dark crypto-exchange hero, and one live wallet sign-in is still 👤) | -| 5. Worker | Nov 8 | 100% | ✅ done (S5.1–S5.3; both loops run on the app's own deps and were run as a real process) | -| 6. Demo mode & dry-run | Nov 9–10 | 100% | ✅ done (S6.1–S6.7; `pnpm demo` runs with no `.env`, and the Playwright journey runs in CI against a **production build** — `pnpm demo:prod`, S6.7 — so CI drives the artifact Phase 8 deploys) | -| 7. Hardening & edge cases | Nov 10–11 | 100% | ✅ done (S7.1–S7.14; four were already built and are now pinned by tests, #108 closed the bare-receipt-link dead-end, and S7.13/#140 — found on the hosted URL, not by CI — stopped the landing page's first render depending on `window`, with a fifth e2e test that opens `/` and refuses a hydration mismatch. S7.14 re-read testnet live, found no path that can use it, and pinned the wallet to mainnet. 🔍 the memo-chain list and the region-block mapping are still ours — S0.12/S0.13 came back from the vendor in 2026-11-09 and published neither) | -| 8. Deploy & real run | Nov 11 | 67% | in progress (**S8.1/S8.2/S8.3 ✅ 2026-11-08 — the human chose Vercel and said deploy; the web app is live in real mode on https://settle-wine-alpha.vercel.app against a hosted Neon Postgres, `/api/health` green from the running deployment, no login wall. The serverless host cannot run the worker, so payments stay fail-closed on that URL and one real payment (S8.4 👤) still needs the partner JWT. **Re-deployed 2026-11-09** on the human's "follow your recommendation": the live build was ~5 merged PRs stale — `GET /api/auth/challenge?intent=session` answered 404 — and now answers 200 from `ab9d241`, with `/api/health`, `/` and `/demo` all 200 and no login wall. Verifying it in a real browser found S7.13/#140. **Re-deployed again 2026-11-10** with S1.7/#149: challenge probe 200, `/api/health` green, landing page clean in Chromium, and the live `/api/tokens` shows `refundChain` with BTC refund-only and absent from `payoutAssets`. S8.4 is blocked on a credential, not on code: measured 2026-11-10, **every unauthenticated `POST /v0/quote` now answers `400 Internal server error`** — dry or not, three routes, including the USDC→USDC dry call that returned 200 on 2026-11-09 — while a bogus bearer gets `401 Invalid token` and `/v0/tokens` still answers 200**. **Re-deployed the same day with S4.15**: the live HTML serves the dark landing hero, the challenge probe is 200, `/api/health` is green, and Chromium at 1440 px and 390 px reports an enabled sign-in button, no page or console error and no horizontal overflow**) | -| 9. README & public repo | Nov 11–12 | 100% | ✅ done (S9.1–S9.7; the fresh clone was run against the public URL and passes. The hosted demo URL is Phase 8, not this one) | -| 10. Launch: video, post, submission | Nov 12–13 | 57% | in progress (S10.1, S10.3, S10.5, S10.6 ✅ as drafts in `LAUNCH.md`; S10.2, S10.4, S10.7 are 👤 — voice-over decision and upload, posting, submitting; three walkthrough cuts are recorded, the newest against the dark design) | -| 11. After submission | Nov 14–25 | 0% | not started | -| **Overall (MVP)** | | **92%** | by the formula under this table: (1100) ÷ 12 = **91.7**. See the correction note below — the number printed here for the last two days, ~82%, was itself miscalculated. **The figure that describes the entry is the one under it: 5 of 9 submission gates (56%).** | - -Phase % = done tasks ÷ non-dropped tasks in that phase, and each of those is a count, not an impression: Phase 0 is 14 of 16, Phase 1 is 7 of 7, Phase 2 is 9 of 10, Phase 8 is 4 of 6, Phase 10 is 4 of 7, the rest are complete. Overall = D and phases 0–10, twelve equally weighted numbers: (100+100+86+90+100+100+100+100+100+67+100+57) = 1100, ÷ 12 = **91.7%**. +| Phase | Target dates | Progress | Status | +| ----------------------------------- | ------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| D. Design & docs | Nov 4 | 100% | ✅ done | +| 0. Setup & verification | Nov 4–5 | 88% | in progress (14 of 16; **S0.12 + S0.13 ✅ 2026-11-09** answered from the live API and the vendor docs, no JWT needed — and S0.13 found a units bug that is fixed. Only S0.8 and S0.10 remain, both 👤) | +| 1. Core logic (`lib/core`) | Nov 5–6 | 100% | ✅ done (S1.1–S1.7; S1.7 validates Bitcoin refund addresses, so a BTC payer is checked here rather than only by the vendor) | +| 2. Integrations (1Click, NEAR, DB) | Nov 5–7 | 90% | in progress (S2.1–S2.9 done; S2.10 spends payer funds, blocked) | +| 3. API routes | Nov 6–7 | 100% | ✅ done (S3.1–S3.8; `storage_deposit` spend stays blocked on S2.10) | +| 4. UI | Nov 7–9 | 100% | ✅ done (S4.1–S4.15; the E2E demo journey shipped with Phase 6, S4.9 is the app-wide identity, S4.10 designs the landing page, S4.11 keeps the wallet stack out of the first bundle, S4.12/13 are the Landbook-inspired redesign and the brand assets, S4.14 keeps a 24-decimal amount inside its quote column, S4.15 restyles the landing as a dark crypto-exchange hero, and one live wallet sign-in is still 👤) | +| 5. Worker | Nov 8 | 100% | ✅ done (S5.1–S5.3; both loops run on the app's own deps and were run as a real process) | +| 6. Demo mode & dry-run | Nov 9–10 | 100% | ✅ done (S6.1–S6.7; `pnpm demo` runs with no `.env`, and the Playwright journey runs in CI against a **production build** — `pnpm demo:prod`, S6.7 — so CI drives the artifact Phase 8 deploys) | +| 7. Hardening & edge cases | Nov 10–11 | 100% | ✅ done (S7.1–S7.14; four were already built and are now pinned by tests, #108 closed the bare-receipt-link dead-end, and S7.13/#140 — found on the hosted URL, not by CI — stopped the landing page's first render depending on `window`, with a fifth e2e test that opens `/` and refuses a hydration mismatch. S7.14 re-read testnet live, found no path that can use it, and pinned the wallet to mainnet. 🔍 the memo-chain list and the region-block mapping are still ours — S0.12/S0.13 came back from the vendor in 2026-11-09 and published neither) | +| 8. Deploy & real run | Nov 11 | 71% | in progress (**S8.1/S8.2/S8.3 ✅ 2026-11-08 — the human chose Vercel and said deploy; the web app is live in real mode on https://settle-wine-alpha.vercel.app against a hosted Neon Postgres, `/api/health` green from the running deployment, no login wall. The serverless host cannot run the worker, so payments stay fail-closed on that URL and one real payment (S8.4 👤) still needs the partner JWT. **Re-deployed 2026-11-09** on the human's "follow your recommendation": the live build was ~5 merged PRs stale — `GET /api/auth/challenge?intent=session` answered 404 — and now answers 200 from `ab9d241`, with `/api/health`, `/` and `/demo` all 200 and no login wall. Verifying it in a real browser found S7.13/#140. **Re-deployed again 2026-11-10** with S1.7/#149: challenge probe 200, `/api/health` green, landing page clean in Chromium, and the live `/api/tokens` shows `refundChain` with BTC refund-only and absent from `payoutAssets`. S8.4 is blocked on a credential, not on code: re-measured 2026-11-10 (S8.7), **a dry quote answers 200 unauthenticated, while the non-dry quote that carries `depositAddress` answers `400 Internal server error`** without the credential and `401 Invalid token` with a bogus bearer; `/v0/tokens` answers either way**. The app now reports the missing credential as `server_misconfigured` instead of blaming the caller, and `pnpm oneclick:check` proves a token the moment it exists. **Re-deployed the same day with S4.15**: the live HTML serves the dark landing hero, the challenge probe is 200, `/api/health` is green, and Chromium at 1440 px and 390 px reports an enabled sign-in button, no page or console error and no horizontal overflow**) | +| 9. README & public repo | Nov 11–12 | 100% | ✅ done (S9.1–S9.7; the fresh clone was run against the public URL and passes. The hosted demo URL is Phase 8, not this one) | +| 10. Launch: video, post, submission | Nov 12–13 | 57% | in progress (S10.1, S10.3, S10.5, S10.6 ✅ as drafts in `LAUNCH.md`; S10.2, S10.4, S10.7 are 👤 — voice-over decision and upload, posting, submitting; three walkthrough cuts are recorded, the newest against the dark design) | +| 11. After submission | Nov 14–25 | 0% | not started | +| **Overall (MVP)** | | **92%** | by the formula under this table: (1104) ÷ 12 = **92.0**. See the correction note below — the number printed here for the last two days, ~82%, was itself miscalculated. **The figure that describes the entry is the one under it: 5 of 9 submission gates (56%).** | + +Phase % = done tasks ÷ non-dropped tasks in that phase, and each of those is a count, not an impression: Phase 0 is 14 of 16, Phase 1 is 7 of 7, Phase 2 is 9 of 10, Phase 8 is 5 of 7, Phase 10 is 4 of 7, the rest are complete. Overall = D and phases 0–10, twelve equally weighted numbers: (100+100+86+90+100+100+100+100+100+71+100+57) = 1104, ÷ 12 = **92.0%**. Two corrections land in the same place, and both are mine. The table said ~89% until 2026-11-09, and that figure had not come from this formula. The ~82% I put in its place _claimed_ to — but 1088 ÷ 12 was 90.7, not the 82.3 written under it, so the arithmetic was asserted rather than done. Disclosing it matters more than the direction: a percentage is only worth what someone plans against it. @@ -181,9 +181,10 @@ Format: `- [task id] what blocks it · who can unblock · since date` - [x] **S8.1** [#69](https://github.com/MFrancis-dev/settle/issues/69) ~~👤 Pick hosting (web, worker, Postgres) and create accounts~~ → **decided: Vercel, on the human's instruction, 2026-11-08** · the Vercel and Render CLIs were already authenticated to her accounts, so no account was created. The web tier is a Vercel serverless Next.js deploy; the database is a dedicated Neon Postgres (`settle-hackathon`, region `aws-us-east-1`, free tier — Neon stores are provisioned through the marketplace/dashboard, not `vercel storage create`, so it was made with the authenticated `neon` CLI). The honest cost of the serverless choice: it satisfies "reachable web + Postgres" but **cannot run the background worker**, which needs a long-lived process — so this host shows the app but is not, on its own, the full two-process topology `DEPLOY.md` §1–§2 requires for live payments. That limit is stated in the README, not hidden · [#69](https://github.com/MFrancis-dev/settle/issues/69) - [x] **S8.2** [#70](https://github.com/MFrancis-dev/settle/issues/70) ~~👤 Set production env vars~~ → **set on the Vercel project, 2026-11-08** · `SESSION_SECRET` (a fresh `openssl rand -hex 32`, generated here and stored as a Vercel secret — it is required on a serverless host because `randomProcessSecret()` differs per function and would break every session), `DATABASE_URL` (the Neon pooled `-pooler` string, which the app's own `postgres`+Drizzle path was tested against before deploy), `PUBLIC_APP_URL=https://settle-wine-alpha.vercel.app`, and `NEAR_RPC_URL=https://rpc.mainnet.near.org`. `DEMO_MODE` is unset (correct — `demoModeConflict` would refuse it behind `https://`); `ONECLICK_JWT` is deliberately absent, so payment quotes stay fail-closed until S0.8 · [#70](https://github.com/MFrancis-dev/settle/issues/70) - [x] **S8.3** [#71](https://github.com/MFrancis-dev/settle/issues/71) ~~Deploy; hosted demo mode reachable~~ → **Deployed real mode; the hosted app is reachable and `/api/health` is green** · 2026-11-08: `vercel deploy --prod` built the app and it is live on https://settle-wine-alpha.vercel.app with deployment protection turned off so a judge reaches it (SSO was on by default — a login wall would fail the "reachable" bar). Landing 200, protected routes 307 to the sign-in redirect, `/api/tokens` 200, `/api/invoices` 401 without a session, `GET /api/health` → `{"status":"ok","demo":false,"database":"ok"}` **from the running deployment**. Running it exposed and fixed a real serverless defect that no local test caught: Drizzle's migrator reads `./drizzle/meta/_journal.json` from the filesystem at request time and is never `import`ed, so output-file tracing omitted it and every DB route 503'd with `Can't find meta/_journal.json` — fixed with `outputFileTracingIncludes: { "/*": ["./drizzle/**/*"] }` in `next.config.ts`. The demo-mode story remains `pnpm demo` (SD-8), and live payments still need the worker + JWT (S8.4). **Re-deployed 2026-11-09** (`vercel deploy --prod --yes --scope accensa`, deployment `settle-hq4v2wxpg-accensa.vercel.app`, aliased to the same URL) because the running build predated PR #133: the staleness probe `GET /api/auth/challenge?intent=session` answered **404** before and **200** after, with a real nonce, the server's own recipient and a 5-minute expiry. Re-probed from outside: `/api/health` 200 `{"status":"ok","demo":false,"database":"ok"}`, `/` 200, `/demo` 200, `/settings` 307 to sign-in, no HTTP ≥ 400 on any asset the page loads, and no horizontal overflow at 390 px. A real-browser visit found one defect the curl probes could not — S7.13/#140, the landing page's hydration mismatch, fixed in PR #141 and carried to the same URL by a second deploy from `5fb11cc` (`settle-e25w2k2lm-accensa.vercel.app`): the served HTML now offers sign-in instead of denying it, and Chromium at 1440 px and 390 px reports the button enabled with zero page errors, zero console errors, zero failed requests and no horizontal overflow · [#71](https://github.com/MFrancis-dev/settle/issues/71) -- [ ] **S8.4** [#72](https://github.com/MFrancis-dev/settle/issues/72) 👤 Approve and do one small real payment end-to-end; record tx links · **approved 2026-11-10, blocked on the vendor credential.** The minimum state was measured against the live API that day: unauthenticated quotes are refused now, so there is no preview to drive either. A real payment needs, in this order: `ONECLICK_JWT` from partners.near-intents.org (S0.8, 👤), a funded signer for the client's side, a host that runs both the web app and the worker (Vercel runs neither the 5 s poll nor the 60 s expiry), and a payout address the seller controls. +- [ ] **S8.4** [#72](https://github.com/MFrancis-dev/settle/issues/72) 👤 Approve and do one small real payment end-to-end; record tx links · **approved 2026-11-10, blocked on the vendor credential.** The refusal was measured against the live API that day, and it is narrower than first recorded (S8.7): a **dry** quote still answers 200 without a credential, so a preview is drivable — only the **non-dry** quote that carries a `depositAddress` is gated. A real payment needs, in this order: `ONECLICK_JWT` from partners.near-intents.org (S0.8, 👤; the steps are in `DEPLOY.md` "Obtaining the 1Click credential", and `pnpm oneclick:check --real ` proves it without moving funds), a funded signer for the client's side, a host that runs both the web app and the worker (Vercel runs neither the 5 s poll nor the 60 s expiry), and a payout address the seller controls. - [ ] **S8.5** [#73](https://github.com/MFrancis-dev/settle/issues/73) Fix anything the real run exposed · 2026-11-09: the **hosted** run exposed one — the landing page's hydration mismatch, fixed as S7.13 ([#140](https://github.com/MFrancis-dev/settle/issues/140)). Stays open because S8.4's real payment has not run, and that is the run this task is really waiting on. - [x] **S8.6** [#118](https://github.com/MFrancis-dev/settle/issues/118) Make the app deployable by whoever picks the host: `GET /api/health` (200 `ok` / 503 `degraded`, no-store, unauthenticated, not rate limited, 5 tests, probed against a real running demo server), `tsx` moved to runtime dependencies so `pnpm worker` can start on a production install, and `DEPLOY.md` — including the two **measured** constraints above. No vendor manifest committed and nothing deployed: an untested `render.yaml` would read as a claim that it works · 2026-11-08 +- [x] **S8.7** [#159](https://github.com/MFrancis-dev/settle/issues/159) Make a missing 1Click credential legible, and prove one in one command · 2026-11-10, on the human's "address the ONECLICK_JWT blocker": re-measured the live API and the earlier finding was too broad. A **dry** `POST /v0/quote` answers 200 with no credential; the **non-dry** quote that carries `depositAddress` answers `400 {"message":"Internal server error"}` without one and `401 Invalid token` with a bogus bearer; `/v0/tokens` answers 200 either way. `400 Internal server error` for a missing deployment secret would have been shown to the client as their own mistake, so `lib/server/pay.ts` now checks `deps.oneClick.hasJwt` inside the real-quote branch (after the reuse/conflict checks, dry previews untouched, **demo mode exempt** — its transport mints the address itself and never forwards a real quote) and throws `500 server_misconfigured` with a sentence that says the deployment is at fault. Two route tests pin it — 500 + `server_misconfigured`, **zero calls to 1Click**, and the dry preview still 200; and a demo deployment with no JWT still gets an address. `pnpm oneclick:check` (`scripts/check-oneclick.ts`) does the rest: with no token it exits 1 saying previews still work but no address can be issued; with a token it runs a real `EXACT_OUTPUT` quote for 10.5 USDC→USDC and prints status, in/out base units, fee, signature verdict and whether a deposit address came back; `--real ` validates the address (`validateAddress`, exit 2 if bad) and proves the address-issuing path without moving funds; a bogus token prints `failure: unauthorized (401) Invalid token`. It never prints the JWT. Corrected the stale "every unauthenticated quote" claim in `ARCHITECTURE.md` §3.2, README, `DEPLOY.md`, `LAUNCH.md` §1 and the S8.2 note; `DEPLOY.md` gained "Obtaining the 1Click credential" (portal → `.env.local` → check → `vercel env add`). The account itself is still human-only: the partner portal signs in through WorkOS (email/SSO) · [#159](https://github.com/MFrancis-dev/settle/issues/159) ## Phase 9 — README & public repo (Nov 11–12) @@ -226,6 +227,8 @@ Format: `- [task id] what blocks it · who can unblock · since date` One line per contribution, newest first: `YYYY-MM-DD · who/agent · what changed · tasks` +- 2026-11-10 · agent · Turned the dead end of a missing vendor credential into a named state and a one-command proof, on the human's "address the ONECLICK_JWT blocker". Re-measured the live API and the earlier "every quote is refused" record was too broad: a **dry** quote answers 200 without a credential, while the **non-dry** quote that returns `depositAddress` answers `400 {"message":"Internal server error"}` without one and `401 Invalid token` with a bogus bearer (`/v0/tokens` answers either way). **S8.7** ([#159](https://github.com/MFrancis-dev/settle/issues/159)): `lib/server/pay.ts` checks `deps.oneClick.hasJwt` in the real-quote branch (demo mode exempt, since its transport mints the address itself) and throws `500 server_misconfigured`, so a missing deployment secret no longer surfaces to the client as their own mistake — two route tests pin 500 + zero upstream calls + a dry preview still 200, and a JWT-less demo still getting an address. `pnpm oneclick:check` (`scripts/check-oneclick.ts`) exits 1 with "previews still work" when no token exists, and with one runs a real `EXACT_OUTPUT` 10.5 USDC→USDC quote printing status, base units, fee, signature verdict and whether an address came back; `--real ` validates the address and proves the address-issuing path without moving funds; a bogus token prints `failure: unauthorized (401)`. JWT never printed. Corrected the stale claim in `ARCHITECTURE.md` §3.2, README, `DEPLOY.md`, `LAUNCH.md` §1 and the S8.2 note, and added `DEPLOY.md` "Obtaining the 1Click credential". The portal sign-in (WorkOS email/SSO) is still 👤, then S8.4 needs a funded signer, a two-process host and a seller payout address. + - 2026-11-10 · agent · Checked every NEAR-dependent path against live testnet availability and found that none can use it: the hackathon's Q-007 and the vendor docs both say NEAR Intents has no testnet and none is planned, the live token list has only mainnet `near` contracts, and the storage check reads the real USDC contract. **S7.14** ([#157](https://github.com/MFrancis-dev/settle/issues/157)) removes the untested `NEXT_PUBLIC_NEAR_NETWORK` switch — `lib/ui/config.ts` now exports the literal `"mainnet"` — which also closes the mixed-network footgun where a testnet wallet could sign in and register a `*.testnet` payout address that 1Click would try to pay on mainnet. Demo mode and `dry: true` quotes remain the no-funds paths; the per-path answer is in `ARCHITECTURE.md` §11 and the README limitation. - 2026-11-10 · agent · The human picked a different voice and asked for the real-payment slot. **Daniel (UK, rate 175)** replaces Samantha for all 15 lines (14 cues plus one for the real-payment segment), and the cut was re-recorded because `record-demo.mjs` paces every shot by its line's measured length. Added `assemble.mjs`, which builds the whole file from the raw captures, computes the cue offsets, mixes and loudness-normalises the narration, and — when `recordings/real/real-payment.{mp4,mov,webm}` exists — inserts a "One real payment" card plus the muted clip after the demo receipt with line n15 over it, shifting the phone and outro. The slot was proven with a 10 s stand-in (110.267 s → 122.767 s; n15 at 102.0 s), the stand-in was deleted, and the shipped cut is **110.267 s, 7.31 MB** with the first line starting over the brand card. Still 👤: recording the real payment (S8.4), approving the synthesized voice, and uploading · S10.2 ([#82](https://github.com/MFrancis-dev/settle/issues/82)) - 2026-11-10 · agent · S10.1's script, recorded against the new design and voiced. The human asked for "a new video with voiceover" after the S4.15 restyle. The existing Playwright recorder was reused, so the footage is still demo-mode and honest. Two narration lines were rewritten and re-synthesized to match what is now on screen (the hero's own promise; one address, one exact amount, the fee named before anything is sent); captions and the cursor were moved from the old green to the new chrome because green is reserved for money that arrived. Added a 3.2 s brand intro card and a 5 s outro card rendered from `cards/cards.html`, re-paced the landing scroll by the second line's real spoken length, and scaled the phone segment to 1000 px high inside a framed dark field. Verification: every cue's mean level is −15.9 to −18.6 dB where speech should be and −91 dB in the intentional gaps; stream probe reports 1920×1080 at 30 fps, H.264 + AAC 48 kHz stereo, **103.333 s, 6.96 MB** at `/Users/allison/NEAR/recordings/settle-demo-walkthrough-voiced-2026-11-10.mp4`. Still 👤: a human voice-over decision (or approval of the synthesized cut), the S8.4 real-payment shot and the upload · S10.2 ([#82](https://github.com/MFrancis-dev/settle/issues/82)) diff --git a/app/api/pay/[publicId]/quote/route.test.ts b/app/api/pay/[publicId]/quote/route.test.ts index 800f2fd..63c5c47 100644 --- a/app/api/pay/[publicId]/quote/route.test.ts +++ b/app/api/pay/[publicId]/quote/route.test.ts @@ -3,6 +3,7 @@ import { eq } from "drizzle-orm"; // Roadmap: S3.5, S3.8 · quote: the only public write, where the double-payment and funds-loss rules live. import { POST } from "./route"; +import { installServerDeps } from "@/lib/server/deps"; import { invoices, paymentAttempts, payoutDestinations } from "@/lib/db/schema"; import { TEST_NOW_MS, @@ -128,6 +129,45 @@ describe("POST /api/pay/:publicId/quote", () => { expect(String(attemptOf(body).receiptUrl)).toContain("/receipt/"); }); + it("refuses a real quote when the deployment has no partner credential", async () => { + // The vendor answers this case with a 400 whose message is "Internal server error". That would + // be reported as the caller's bad request, so the deployment is checked first — and a dry + // preview must still work without the credential. + installServerDeps({ ...harness.deps, oneClick: { ...harness.deps.oneClick, hasJwt: false } }); + try { + const { publicId } = await harness.seededInvoice(); + const real = await quote(publicId, quoteBody({ dry: false })); + const error = await errorOf(real); + + expect(real.status).toBe(500); + expect(error.code).toBe("server_misconfigured"); + expect(harness.oneClick.quoteRequests()).toHaveLength(0); + + const preview = await quote(publicId, quoteBody()); + expect(preview.status).toBe(200); + } finally { + installServerDeps(harness.deps); + } + }); + + it("issues a real quote in demo mode without a partner credential", async () => { + // Demo mode mints its own address and never forwards a real quote, so the credential check must + // not run there — otherwise `pnpm demo`, which has no JWT by design, could not get an address. + installServerDeps({ + ...harness.deps, + env: { ...harness.deps.env, DEMO_MODE: "1" }, + oneClick: { ...harness.deps.oneClick, hasJwt: false }, + }); + try { + const { publicId } = await harness.seededInvoice(); + const real = await quote(publicId, quoteBody({ dry: false })); + expect(real.status).toBe(200); + expect(attemptOf(await jsonOf(real))).toHaveProperty("depositAddress"); + } finally { + installServerDeps(harness.deps); + } + }); + it("hands back the same address instead of opening a second one", async () => { const { publicId } = await harness.seededInvoice(); const first = await jsonOf(await quote(publicId, quoteBody({ dry: false }))); diff --git a/lib/server/pay.ts b/lib/server/pay.ts index 418beeb..f4eeae7 100644 --- a/lib/server/pay.ts +++ b/lib/server/pay.ts @@ -323,6 +323,17 @@ export async function quote(deps: ServerDeps, call: QuoteCall): Promise # a non-dry quote: proves a deposit address is issued + * + * A dry quote answers 200 even with no token, so it only rules out a *wrong* one (1Click answers + * 401 `Invalid token`). The deposit address is the part a real payment needs, and 1Click returns + * it only to a partner credential — without one the same request answers 400 "Internal server + * error". `--real` asks for that address once, for 10.5 USDC on NEAR quoted into itself; nothing + * is deposited, no invoice is touched, and the quote expires on its own. + */ + +import { validateAddress } from "../lib/core/address"; +import { toBaseUnits } from "../lib/core/money"; +import { OneClickError, createOneClickClient } from "../lib/oneclick/client"; +import { requestInvoiceQuote, type InvoiceQuoteParams } from "../lib/oneclick/quote"; +import { createTokenRegistry } from "../lib/oneclick/tokens"; +import { parseEnv } from "../lib/server/env-schema"; + +const USDC_NEAR = "USDC on near"; +const CHECK_ADDRESS = "settle-check.near"; + +function usage(): void { + console.error("usage: pnpm oneclick:check [--real ]"); +} + +async function main(): Promise { + const args = process.argv.slice(2); + const realIndex = args.indexOf("--real"); + const realRecipient = realIndex === -1 ? null : (args[realIndex + 1] ?? null); + if (realIndex !== -1 && realRecipient === null) { + usage(); + return 2; + } + if (realRecipient !== null) { + const checked = validateAddress("NEAR", realRecipient); + if (!checked.valid) { + console.error(`--real needs a NEAR account id: ${checked.reason}`); + return 2; + } + } + + const env = parseEnv(); + console.log("oneclick:check — 1Click partner credential"); + console.log(` base: ${env.ONECLICK_BASE_URL}`); + console.log(` token: ${env.ONECLICK_JWT ? "set (not printed)" : "NOT SET"}`); + if (!env.ONECLICK_JWT) { + console.log(" a token comes from https://partners.near-intents.org (Partner Dashboard)."); + console.log( + "Verdict: no ONECLICK_JWT. Previews still work; a deposit address cannot be issued.", + ); + return 1; + } + + const client = createOneClickClient({ baseUrl: env.ONECLICK_BASE_URL, jwt: env.ONECLICK_JWT }); + const tokens = createTokenRegistry({ client }); + const assets = await tokens.assets(); + const usdc = assets.find((asset) => asset.blockchain === "near" && asset.symbol === "USDC"); + if (!usdc) { + console.error("1Click no longer lists USDC on NEAR; the check needs another route."); + return 2; + } + + const recipient = realRecipient ?? CHECK_ADDRESS; + const params: InvoiceQuoteParams = { + originAsset: usdc.assetId, + refundTo: recipient, + destinationAsset: usdc.assetId, + recipient, + amountOut: toBaseUnits("10.5", usdc.decimals), + deadline: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }; + + console.log(` tokens: ${assets.length} listed; ${USDC_NEAR} = ${usdc.assetId}`); + console.log( + realRecipient + ? ` mode: real quote to ${recipient} (an address is issued; nothing is sent, it expires)` + : " mode: dry preview (no address is issued)", + ); + + try { + const quote = await requestInvoiceQuote(client, params, { + dry: realRecipient === null, + ...(env.ONECLICK_MANAGER_PUBLIC_KEY + ? { managerPublicKey: env.ONECLICK_MANAGER_PUBLIC_KEY } + : {}), + }); + console.log( + ` quote: 200 · in ${quote.amountIn} → out ${quote.amountOut} base units · ${quote.timeEstimateS}s`, + ); + console.log(` fee: ${quote.feeUsd === null ? "not reported" : `${quote.feeUsd} USD`}`); + console.log(" signature: verified"); + if (quote.depositAddress) { + console.log(` deposit address: ${quote.depositAddress}`); + console.log("Verdict: the credential can issue a deposit address. S8.4 can run."); + return 0; + } + console.log( + "Verdict: the token is accepted for a quote (dry). Run with --real to prove a deposit address.", + ); + return 0; + } catch (error) { + if (error instanceof OneClickError) { + console.error(` failure: ${error.kind} (${error.status ?? "no response"}) ${error.detail}`); + console.error( + error.kind === "unauthorized" + ? "Verdict: the token was refused. Generate a new one in the Partner Dashboard." + : "Verdict: the credential was not proven. See the failure above.", + ); + return 1; + } + throw error; + } +} + +main() + .then((code) => process.exit(code)) + .catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + });