diff --git a/.agents/settings.json b/.agents/settings.json new file mode 100644 index 0000000..6831ce7 --- /dev/null +++ b/.agents/settings.json @@ -0,0 +1,11 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { "type": "command", "command": "bash .claude/sync-ic-skills.sh" } + ] + } + ] + } +} diff --git a/.agents/sync-ic-skills.sh b/.agents/sync-ic-skills.sh new file mode 100644 index 0000000..fcc380d --- /dev/null +++ b/.agents/sync-ic-skills.sh @@ -0,0 +1,206 @@ +#!/usr/bin/env bash +# sync-ic-skills.sh — mirror the latest Internet Computer skills into .claude/skills/ +# +# Differential sync: fetches the discovery index once and re-downloads only the +# skills whose published `hash` changed (or are new). Skills already at the current +# hash are skipped entirely — no per-file downloads. Prints a one-line summary only +# when something actually changed. +# +# Idempotent and offline-safe. Only skills this script installed are ever pruned, +# so your own local skills are never touched. +set -euo pipefail + +BASE="https://skills.internetcomputer.org/.well-known/skills" +INDEX_URL="$BASE/index.json" +DEST=".claude/skills" +MANIFEST="$DEST/.ic-managed.json" # { "": "" } of skills this script manages + +mkdir -p "$DEST" + +# --- Temp files. NEW_MANIFEST is built up as we go, then swapped in atomically. +# STAGING holds the skill dir currently being downloaded, so the trap can +# remove a half-written skill if the run is interrupted. --- +TMP_INDEX="$(mktemp)" +NEW_MANIFEST="$(mktemp)" +STAGING="" +trap 'rm -f "$TMP_INDEX" "$NEW_MANIFEST"; [ -n "$STAGING" ] && rm -rf "$STAGING"' EXIT + +# Remove any staging dirs left by a previously interrupted run — an in-progress +# download is always safe to discard. (.old-* backups are handled by the recovery +# step below, which never deletes one that is still the only copy of a skill.) +rm -rf "${DEST:?}"/.staging-* 2>/dev/null || true + +# --- Path-safety guards. `name` and `f` come from the remote index and flow into +# rm -rf / mv / file writes, so reject anything that could escape $DEST. --- +is_safe_name() { # a flat skill slug: non-empty, no slash, no ".." + case "$1" in + ""|.|..|*/*|*..*) return 1 ;; + *) return 0 ;; + esac +} +is_safe_relpath() { # a file path within a skill: subdirs ok, but not absolute or ".." + case "$1" in + ""|/*|*..*) return 1 ;; + *) return 0 ;; + esac +} + +# --- Recover from a run interrupted mid-swap. A `.old-.` dir is the +# previous good copy of , moved aside just before its swap. If that swap +# never finished (the skill dir is now missing), restore it; otherwise it is +# stale and safe to drop. This runs BEFORE the index fetch, so an interrupted +# skill is restored even on an offline run — keeping the cached copy available. --- +for backup in "$DEST"/.old-*; do + [ -e "$backup" ] || continue # unmatched glob stays literal — skip + bname="$(basename "$backup")"; bname="${bname#.old-}"; bname="${bname%.*}" + if is_safe_name "$bname" && [ ! -e "$DEST/$bname" ]; then + mv "$backup" "$DEST/$bname" + echo "[autosync-ic-skills] recovered '$bname' from an interrupted sync" >&2 + else + rm -rf "$backup" + fi +done + +# --- Fetch the index. On any network failure, keep cached skills and exit cleanly. --- +if ! curl -fsSL --max-time 20 "$INDEX_URL" -o "$TMP_INDEX"; then + echo "[autosync-ic-skills] could not reach $INDEX_URL — keeping cached skills" >&2 + exit 0 +fi + +# --- jq is required to parse the index. If absent, warn and exit without failing. --- +if ! command -v jq >/dev/null 2>&1; then + echo "[autosync-ic-skills] 'jq' not found — install jq to enable IC skill sync" >&2 + exit 0 +fi + +# --- Previously-managed skill names. Supports the legacy manifest format +# (a bare array of names, no hashes) as well as the current object form. --- +managed_names() { + [ -f "$MANIFEST" ] || return 0 + jq -r 'if type == "object" then keys[] elif type == "array" then .[] else empty end' \ + "$MANIFEST" 2>/dev/null || true +} + +# --- Stored hash for a skill, or empty if unknown (new skill, or legacy manifest). --- +stored_hash() { + [ -f "$MANIFEST" ] || return 0 + jq -r --arg n "$1" 'if type == "object" then (.[$n] // "") else "" end' \ + "$MANIFEST" 2>/dev/null || true +} + +# --- Append a name->hash pair to the new manifest being built. --- +record() { + local tmp; tmp="$(mktemp)" + jq --arg n "$1" --arg h "$2" '.[$n] = $h' "$NEW_MANIFEST" > "$tmp" && mv "$tmp" "$NEW_MANIFEST" +} + +NEW_NAMES="$(jq -r '.skills[].name' "$TMP_INDEX")" +MANAGED="$(managed_names)" +echo '{}' > "$NEW_MANIFEST" + +# --- Prune: drop previously-managed skills that are no longer in the index. --- +removed=0 +while IFS= read -r old; do + [ -n "$old" ] || continue + is_safe_name "$old" || { echo "[autosync-ic-skills] skipping unsafe managed name: $old" >&2; continue; } + if ! grep -qxF "$old" <<<"$NEW_NAMES"; then + rm -rf "${DEST:?}/$old" + removed=$((removed + 1)) + echo "[autosync-ic-skills] removed: $old" >&2 + fi +done <<<"$MANAGED" + +# --- Sync: download only skills whose hash changed (new / hashless always download). --- +added=0; updated=0; unchanged=0 +while IFS= read -r entry; do + name="$(jq -r '.name' <<<"$entry")" + [ -n "$name" ] && [ "$name" != "null" ] || continue + is_safe_name "$name" || { echo "[autosync-ic-skills] skipping skill with unsafe name: $name" >&2; continue; } + new_hash="$(jq -r '.hash // ""' <<<"$entry")" + old_hash="$(stored_hash "$name")" + + # Skip when the hash is known, unchanged, and the files are already on disk. + if [ -n "$new_hash" ] && [ "$new_hash" = "$old_hash" ] && [ -d "$DEST/$name" ]; then + unchanged=$((unchanged + 1)) + record "$name" "$new_hash" + continue + fi + + # Otherwise download this skill into a fresh staging dir, then swap it in + # atomically. A clean staging dir means an intra-skill file rename or removal + # leaves no orphaned files behind, and a mid-download failure keeps the existing + # copy intact — the swap happens only after every file downloaded successfully. + ok=1 + STAGING="$(mktemp -d "${DEST}/.staging-${name}.XXXXXX")" + while IFS= read -r f; do + [ -n "$f" ] || continue + if ! is_safe_relpath "$f"; then + echo "[autosync-ic-skills] warning: unsafe file path in $name: $f — skipping skill" >&2 + ok=0 + break + fi + mkdir -p "$(dirname "$STAGING/$f")" # files may live in subdirs (e.g. scripts/) + if ! curl -fsSL --max-time 20 "$BASE/$name/$f" -o "$STAGING/$f"; then + echo "[autosync-ic-skills] warning: failed to fetch $name/$f" >&2 + ok=0 + break + fi + done < <(jq -r '.files[]?' <<<"$entry") + + if [ "$ok" -eq 1 ]; then + # Swap in the fresh copy. Move any existing dir aside first, move the new one + # into place, and only then drop the old copy — so a failed swap restores the + # existing copy intact, while files removed or renamed upstream don't survive. + backup="" + if [ -e "$DEST/$name" ]; then + backup="${DEST}/.old-${name}.$$" + rm -rf "$backup" + mv "$DEST/$name" "$backup" + fi + if mv "$STAGING" "$DEST/$name"; then + STAGING="" + [ -n "$backup" ] && rm -rf "$backup" + # Record the new hash so the next run can skip this skill. A hashless server + # records an empty hash, which never equals new_hash -> always re-downloads. + record "$name" "$new_hash" + if grep -qxF "$name" <<<"$MANAGED"; then + updated=$((updated + 1)) + else + added=$((added + 1)) + fi + else + # Swap failed: restore any existing copy and retry on the next run. + echo "[autosync-ic-skills] warning: failed to install $name — kept any existing copy; will retry next run" >&2 + [ -n "$backup" ] && mv "$backup" "$DEST/$name" + rm -rf "$STAGING" + STAGING="" + record "$name" "$old_hash" + fi + else + # Download incomplete: discard the staging dir, keep the existing skill dir + # untouched, and keep the old hash so the next run retries this skill. + rm -rf "$STAGING" + STAGING="" + record "$name" "$old_hash" + fi +done < <(jq -c '.skills[]' "$TMP_INDEX") + +# --- Swap in the updated manifest. --- +mv "$NEW_MANIFEST" "$MANIFEST" + +# --- Report only when something changed; stay silent on a no-op sync. --- +# SessionStart hook stdout/stderr is NOT shown in the Claude Code UI — only JSON +# fields are surfaced. We emit a single JSON object on stdout: +# - systemMessage -> rendered to the USER as a visible system notice +# - additionalContext -> injected into Claude's context so it can mention it too +if [ $((added + updated + removed)) -gt 0 ]; then + summary="[autosync-ic-skills] ${added} added, ${updated} updated, ${removed} removed (${unchanged} unchanged) in $DEST" + jq -n --arg msg "$summary" '{ + systemMessage: $msg, + hookSpecificOutput: { + reloadSkills: true, + hookEventName: "SessionStart", + additionalContext: $msg + } + }' +fi diff --git a/.github/workflows/mops-test.yml b/.github/workflows/mops-test.yml index f8f6e90..b018791 100644 --- a/.github/workflows/mops-test.yml +++ b/.github/workflows/mops-test.yml @@ -1,4 +1,4 @@ -name: mops test +name: verification gate on: push: @@ -6,22 +6,206 @@ on: - main - master pull_request: + # So the gate can be run on demand against any branch, which is otherwise + # impossible: a push to a feature branch matches none of the triggers above. + workflow_dispatch: jobs: - test: + # Motoko lint + unit suites. Fast, so it stays its own job and fails early. + motoko: runs-on: ubuntu-latest - steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + # The pinned BLS12-381/vetKD packages are a git submodule that `mops` + # resolves as a path dependency, so without this the backend does not compile + # here at all — `mops install --locked` fails on a package directory that is + # present in the lock and empty on disk. The `frontend` job deliberately does + # NOT check it out: it builds from the committed .did and never runs mops. + submodules: true - uses: caffeinelabs/setup-mops@v1 with: - mops-version: 2 + # Matches the CLI that writes `mops.lock` locally. It was pinned to 2 while the + # committed lock was format 3, so CI regenerated it on every run and the + # lockfile guaranteed nothing here. + mops-version: 3 - name: install mops packages - run: mops install + # `--locked`: fail rather than silently rewrite the lock. CI is the one place + # that must build from exactly what was committed, and a lock nothing enforces + # is a lock in name only. + run: mops install --locked + + # -Werror: M0145 (non-exhaustive match) is a warning, so without this a new + # `Types.Owner` case compiles green and traps at runtime on the authz path. + # Not in mops.toml, because `mops test` passes --hide-warnings and moc + # rejects the pair. + - name: type-check and lint (-Werror) + run: mops check -- -Werror - - name: type-check and lint - run: mops check + # ⚠️ Before the unit tests: `test/sealed.test.mo` asserts our wiring on top of this + # curve implementation, so a broken curve would surface as our bug. + - name: crypto vectors (pinned BLS12-381/vetKD vs the Rust reference) + run: bash scripts/check-crypto-vectors.sh - - name: run tests + - name: run unit tests run: mops test + + # The committed .did is both the embedded candid:service metadata and the + # frontend's bindgen source. A stale one means the frontend typechecks + # against an interface the canister does not have. + - name: committed .did is current + run: | + mops build + git diff --exit-code -- src/backend/dist/backend.did + + # ⚠️ **These ran ONLY in `scripts/test-all.sh` until now, so none of them + # could block a merge.** Every one exists because something slipped past a review: + # a config setter with no reader, an admin method offered no command, a §N citing a + # deleted section, an export whose only caller was its own test, a doc block + # published on the wrong method. A check that gates nothing is a check the next + # change walks past — and this is the third time CI has been found running a weaker + # configuration than the local gate (after `mops-version: 2` and check-bindings). + # + # Placed after the `.did` step deliberately: five of them read that file, so they + # are only meaningful once it is known current. `scripts/sweep-vocabulary.py` is + # deliberately absent — it is advisory by design and reads a diff against + # origin/main, which this checkout does not have. + - name: static checks that gate a merge + run: | + scripts/check-heredocs.sh + scripts/check-doc-surface.py + scripts/check-endpoint-docs.py + scripts/check-config-readers.py + scripts/check-admin-tiers.py + scripts/check-admin-commands.py + scripts/check-doc-calls.py + scripts/check-design-sections.py + scripts/check-unused-exports.py + scripts/check-main-doc-comments.py + scripts/check-unused-motoko.py + scripts/check-duplicate-bodies.py + scripts/check-typed-errors.py + scripts/check-mermaid.py + scripts/check-doc-links.py + scripts/check-issue-refs.py + scripts/check-markers.py + + frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: install + run: npm --prefix src/frontend ci + + # Build first: it regenerates the bindings the typecheck needs. + - name: build (regenerates bindings) + run: npm --prefix src/frontend run build + + - name: typecheck + run: npm --prefix src/frontend run typecheck + + - name: unit tests + run: npm --prefix src/frontend run test + + # The brand rules that do not need eyes: banned characters and vocabulary, + # hardcoded colour, and the no-auto-dark rule. + - name: brand lint + run: bash scripts/brand-lint.sh + + # The shipping bundle must not carry the test-only fixture hook. Vite's + # dead-code elimination is what guarantees it, and DCE is a compiler + # behaviour rather than a promise, so it is checked against the built bytes. + - name: no test hooks in the shipping bundle + run: | + if grep -rl "__cyclepayFixtures" src/frontend/dist; then + echo "::error::the fixture hook is present in src/frontend/dist" + exit 1 + fi + + # The cascade-and-layout bugs jsdom cannot see: it has neither, so + # `el.hidden` reads true for an element that is plainly on screen. Also + # the paint-level ones no assertion reaches, via committed `-linux` + # screenshot baselines (test/browser/visual.spec.ts). Serves a build of the + # frontend; no local network or deployed canister needed. + - name: install browser deps + run: npm --prefix test/browser ci + + - name: install chromium + run: npx --prefix test/browser playwright install --with-deps chromium + + - name: browser specs + run: npm --prefix test/browser test + + # The screenshot diffs are the only failure here you cannot read from a log. + - name: upload failure artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: browser-failures + path: test/browser/test-results/ + retention-days: 7 + + # The go-live bar (spec §9). ubuntu-latest because the IC replica's memory + # tracker hard-asserts 4096-byte pages: x86_64 Linux and macOS are fine, arm64 + # Linux guests with 16 KiB pages are not. + # + # `npm test`, never `npx vitest run` — the latter skips pretest, which fetches + # the sha256-pinned wasms and rebuilds the backend, so it would silently test a + # stale wasm and pass. + integration: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + # The pinned BLS12-381/vetKD packages are a git submodule that `mops` + # resolves as a path dependency, so without this the backend does not compile + # here at all — `mops install --locked` fails on a package directory that is + # present in the lock and empty on disk. The `frontend` job deliberately does + # NOT check it out: it builds from the committed .did and never runs mops. + submodules: true + - uses: actions/setup-node@v4 + with: + node-version: '22' + - uses: caffeinelabs/setup-mops@v1 + with: + # Matches the CLI that writes `mops.lock` locally. It was pinned to 2 while the + # committed lock was format 3, so CI regenerated it on every run and the + # lockfile guaranteed nothing here. + mops-version: 3 + + - name: install mops packages + # `--locked`: fail rather than silently rewrite the lock. CI is the one place + # that must build from exactly what was committed, and a lock nothing enforces + # is a lock in name only. + run: mops install --locked + + - name: install integration deps + run: npm --prefix test/integration ci + + # ⚠️ **CI ran a SUBSET of the gate's steps, and this is the gap that mattered + # twice.** The committed bindings can be stale while every CI job passes: this job + # typechecks against whatever is in the checkout, so a stale binding is invisible to + # it — and a comment-only Motoko edit moves both generated artifacts, because moc + # emits doc comments into the `.did`. + # + # It belongs in THIS job and needs no backend build: it regenerates from the + # committed `.did` (present in the checkout) using this suite's own + # `@icp-sdk/bindgen` devDependency, installed above. Paired with the motoko job's + # "committed .did is current", the two together mean the bindings match the code. + # + # Before the typecheck, because a typecheck against stale declarations is checking + # the wrong interface. + - name: committed bindings match the .did + run: bash scripts/check-bindings.sh + + - name: typecheck + run: npm --prefix test/integration run typecheck + + - name: PocketIC suite + run: npm --prefix test/integration test diff --git a/.gitignore b/.gitignore index 7398ca5..4a3509c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,17 @@ -# These can be reinstalled with npx skills -.agents/skills +# ICP skills — auto-synced by the SessionStart hook (.agents/sync-ic-skills.sh, +# reached as .claude/sync-ic-skills.sh through the committed .claude -> .agents +# symlink). The hook and script are committed; the skills they fetch are not. +.agents/skills/ +# Local Claude Code settings (personal; the shared hook lives in .agents/settings.json) +.agents/settings.local.json .mops .migrations-*/ -# build artifacts (commit .icp/data/ when it appears — it holds canister ID mappings) +# ⚠️ **`.icp/cache/` ONLY — never broaden this to `.icp/`.** `.icp/data/` holds the +# canister ID mappings and MUST be committed: it is the record of which canister id is +# which environment, and for a deployed gateway that record is not reconstructible from +# the repo. `.icp/cache/` is rebuildable build output (`icp deploy` refills it). .icp/cache/ # reproducible-build output (scripts/reproducible-build.sh) — hashes are # published in release notes, binaries are rebuildable from the tag @@ -15,13 +22,59 @@ release/ src/backend/dist/* !src/backend/dist/backend.did +# a stray root-level node_modules appears if anyone runs npx/vitest from the +# repo root instead of the workspace that owns the tooling +/node_modules/ + # frontend (src/frontend) src/frontend/node_modules/ src/frontend/dist/ +# the fixtures build the browser specs serve (npm run build:fixtures). A separate +# outDir on purpose: `dist/` is what ships, and a bundle carrying the test hook +# must never be reachable by a deploy. +src/frontend/dist-fixtures/ src/frontend/src/bindings/ +# the sealer (scripts/seal) — `package-lock.json` is the committed contract, the install +# is not. ⚠️ This repo has already tracked a node_modules once (see test/browser below, +# 193 files and 18 MB including a macOS-only binary in a Linux-CI repo), and the rules here +# are per-directory rather than a global `node_modules/`, so a NEW npm package is ignored +# by nothing until someone adds it. Staging this one first showed 1554 such files. +scripts/seal/node_modules/ + +# Your own Stripe SANDBOX Payment Links, read by scripts/local-dev-seed.sh so they +# do not have to be exported into every new shell. Never committed: they are yours, +# they are per-account, and a live-mode link in here would be a loaded gun. +scripts/.local-dev.env + +# browser suite (test/browser). `package-lock.json` is the committed contract; +# the install is not. 193 files and 18 MB of it were tracked for three days — +# including `fsevents.node`, a macOS-only Mach-O binary, in a repo whose CI is +# Linux — because the commit that added the suite committed its node_modules +# alongside the specs. Nothing ever read it: CI runs `npm --prefix test/browser ci`, +# which deletes and reinstalls the directory from the lockfile. +test/browser/node_modules/ +# Playwright's per-run failure artifacts (traces, diff images). The screenshot +# BASELINES beside them are committed on purpose — see test/browser/visual.spec.ts. +test/browser/test-results/ +test/browser/playwright-report/ + # PocketIC integration suite (test/integration) test/integration/node_modules/ # fetched by scripts/fetch-ledger-wasm.mjs, sha256-pinned there — the pin is # committed, the binary is not test/integration/wasm/ + +# `state-hash` verifiers built by scripts/check-frontend-hash.py, one directory per +# certified-assets release. Built from the pinned tag on first use and cached; never +# committed (a compiled binary, and this repo has tracked build output before). +.cache/ + +# Python bytecode from the repo scripts (they are run directly, never imported +# except by a one-off probe, which is how this got committed once). +__pycache__/ +*.pyc + +# macOS Finder metadata. Committed once by the demo-playbook branch and caught +# before it reached main — nothing reads it, and it changes on every Finder visit. +.DS_Store diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..78d7719 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "vendor/icp-seeding-secrets-poc"] + path = vendor/icp-seeding-secrets-poc + url = https://github.com/marc0olo/icp-seeding-secrets-poc diff --git a/.icp/data/mappings/ic.ids.json b/.icp/data/mappings/ic.ids.json new file mode 100644 index 0000000..0d3dad6 --- /dev/null +++ b/.icp/data/mappings/ic.ids.json @@ -0,0 +1,4 @@ +{ + "backend": "saz2a-riaaa-aaaay-aadha-cai", + "frontend": "shy4u-4qaaa-aaaay-aadhq-cai" +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5f13987 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,493 @@ +# Agent instructions + +> **The rewrite this file used to warn about is done.** Settlement is a cycles +> reserve, payment is per-order Stripe Checkout Sessions, and the ICP mint path is +> gone. There is no plan issue standing above this file any more: **this file, +> `docs/DESIGN.md` (decisions) and `RUNBOOK.md` (operations) are the sources of +> truth**, and `RUNBOOK.md` is operations and `docs/OPERATE.md` is setup — its Mode 3 +> carries what remains to do before real money. + +## ICP skills + +ICP skills are tested, frequently-updated instruction files maintained by DFINITY +(). Consult the relevant skill **before** +making changes — the `writing-motoko`, `icp-cli`, and `canister-security` skills all +contradict pre-training knowledge in ways that matter here. + +**This repo uses [autosync](https://skills.internetcomputer.org/skills/autosync-ic-skills).** +A committed `SessionStart` hook (`.claude/settings.json` → `.claude/sync-ic-skills.sh`) +mirrors the published skills into `.claude/skills/` at the start of every session, +so they stay current with **nothing to commit** when a skill changes. The skills +directory itself is gitignored. The first time it runs, Claude Code asks you to +trust the hook. + +Why this replaced the old `skills-lock.json` pin: the lock had drifted badly and +nothing surfaced it. At the moment of the switch it listed **six skills that +upstream no longer published** — four of them still sitting on disk being read as +authoritative — and was **missing four** that had since been added. One was not a +deletion but a rename: `asset-canister` → `static-site`. A pin only records what +you last ran; it cannot tell you the world moved. + + + +ICP skills auto-update each session via a SessionStart hook +(`.claude/sync-ic-skills.sh`) and live in your agent skills directory — you don't +need to run anything to refresh them. Skills are authoritative — prefer them over +general knowledge for all ICP work. If they are not present (hook hasn't run, or +`jq` is missing), fetch them on demand per the fallback below. + + +**On-demand fallback** (any agent, no hook needed): the hook is Claude Code-only, +so Cursor, Copilot, Codex and friends should fetch the index once per session from +`https://skills.internetcomputer.org/.well-known/skills/index.json`, then fetch the +matching skill's `SKILL.md` before writing ICP code for a task. + +**Layout note:** `.claude` is a committed symlink to `.agents`, so the two real +files live at `.agents/settings.json` and `.agents/sync-ic-skills.sh` and are +tracked there. Everything still resolves under the `.claude/` paths the hook and +Claude Code expect. `.agents/skills/` is gitignored. + +Two places this project knowingly departs from skill guidance, both with +recorded reasoning — don't "fix" them without reading the rationale: + +- **`canister-security` pitfall 9** ("never store API secrets in canister + state"). The Stripe webhook signing secret *is* stored plaintext, by design. + HMAC is symmetric, so a canister that can verify can forge — encryption only + moves the problem to a key the canister also needs. See `src/backend/Secret.mo` + and `docs/STRIPE.md` §7. Confidentiality comes from the SEV-SNP subnet, and the + **reserve balance is the blast radius** — a forged webhook delivers from it, and + nothing caps that, so the reserve is sized to what a leak could cost. +- **`writing-motoko` architecture pattern — now followed, with one naming difference.** + ⚠️ **This entry used to claim a departure that no longer exists**, so read the shape in + the code rather than a memory of this file: + + - **Endpoints are in `src/backend/mixins/`**, nine of them split by feature, and + `Main.mo` declares **no public methods**. `docs/DESIGN.md` §9.1 has the rules + that split rests on — chiefly that `include` passes its arguments **by value**, so + mutable state is grouped into records and transient state arrives as closures. + - **The `lib/` layer is flat modules rather than a `lib/` directory**, and that is the + remaining difference: `Orders`, `Delivery`, `Gate`, `Reserve`, `Pricing`, `Receipts`, + `rails/Card` with its explicit `Card.Deps`. Stateless, state as a parameter, which is + what lets the whole ingestion path unit-test with no IC environment. Equivalent + separation under a different filename; nothing about it is a departure in substance. + - **A3, and why nine endpoint bodies exceed 20 code lines anyway.** The rule is + "if a body is more than authorize → delegate → map, the middle belongs in a module", + and the decisions that were extractable have been: `create_order`'s — amount, + admission, quote, the caller's floor — is `Purchase.plan`, which is what made its + **error precedence** unit-testable rather than reachable only through PocketIC. + ⚠️ **In the nine bodies still over the line, the length is the RETURN TYPE or the + guards, not logic** — `reserve_status` is 16 of 33 lines of inline record type, + `operator_summary` 11 of 23. A3 is about logic, and a return type is neither, so + those are not pending work: the metric counts type lines. Measure a body as + non-blank, non-comment lines INCLUSIVE of the `public …func` line and the closing + `};`, because a reviewer and I differed by exactly those two. + - ⚠️ **`check-did-signatures.sh` is an IDENTITY check, not a compatibility one, and it + is not the authority on whether a deliberate interface edit is safe.** Naming an + inline return record is `didc`-compatible in both directions — Candid is structural, + so a type alias is not part of the wire type — and this script still reports it as + changed, correctly for its own job: proving a relocation moved nothing. There is no + compatibility check in the gate (`didc` is not wired in), so a deliberate change + needs that judgement made by hand. + - ⚠️ **Extracting a decision buys TESTS, not a smaller body.** Measured: one + extraction left the body the same length and another made it LARGER, because a named + ladder call site is longer than the inline `if` it replaces. What it buys is that the + decision becomes checkable without an IC environment — `Purchase.plan`'s error + precedence, `Orders.cancelShape`'s whole status space. So do not read a body over the + line as work not done, and do not chase the count by moving code that has nowhere + better to live. + - ⚠️ **One part of A3 is a STATED EXCEPTION, not unfinished work.** `create_order`'s + body keeps commit → outcall → re-check → attach. The commit takes the reserve hold in + a block with no `await` and the order id IS the `client_reference_id`, so the sequence + cannot be rearranged; it needs actor capabilities (`raw_rand`, the outcall) and moving + it into a module would make it harder to see, not easier. Two integration scenarios + guard it — 67b (the hold exists while the outcall is parked) and 67c (the cycles + delivered are the cycles held, across a price move) — and each was mutation-verified + against the specific defect it covers. **Do not "finish" A3 by moving that block.** + + ⚠️ **A recorded departure is a claim with a scope, and it expires.** The previous + version of this entry was written about `lib/` and then read as covering the monolith + too, which is how an agent skips a finding nobody decided to accept. When a skill and + this file disagree, the skill wins unless the reasoning here says why *for that + specific finding* — and if the reasoning has expired, fix this file rather than working + around it. + +## Running it locally + +```sh +git submodule update --init --recursive # first time only +icp network start -d && icp deploy && scripts/local-dev-seed.sh +``` + +**`docs/OPERATE.md`, Mode 1, is the procedure** — the submodule, the seed, the +iteration loops and the troubleshooting table. Two things it explains that will +otherwise cost you an hour: the pinned crypto submodule is a `mops` path dependency, +so without it nothing compiles and the error names a missing package rather than a +missing submodule; and the seed is **not optional**, because a fresh deploy is +fail-closed on five separate axes at once and presents as a broken app. + +This section deliberately carries no copy of that procedure. It had one, and it drifted +— it claimed four axes where the README said five, and the axis it omitted was the one +the README calls "the one nobody guesses". + +### Do not cycle the network + +`icp network start` takes **minutes**, and **two projects cannot run local +networks at once** (the fixed `gateway.port: 8000` in `icp.yaml`). + +- **Check first** with `icp network status`. If one is running, **use it** — do + not restart it to get a clean slate. +- **Start only if absent**, and remember that you started it. +- **Never stop a network you did not start.** You cannot tell whether a human is + mid-manual-run, or whether the network belongs to another project. Stopping one + has already destroyed a session's worth of delivered test orders, their audit + trail, and a local Internet Identity registration. +- **For a clean slate, reinstall — do not restart.** `icp deploy --mode reinstall + --yes` then `scripts/local-dev-seed.sh` takes seconds and is the documented loop + for a stable-shape change; a network restart takes minutes and throws away more + than you wanted. +- **Leave it running when you finish**, and say so in the PR. The next piece of + work needs it. + +## Scope: the Card rail is the product + +CyclePay onboards developers who have **no ICP, no wallet, and no exchange +account**. That is the whole point, and it is why the Stripe rail gets the +attention: for that user a stablecoin rail is not an option, because acquiring +the stablecoin is the same problem over again. + +The card rail is the only rail — a second, disabled ck-USDC rail was removed, +because carrying a rail we do not ship made every other change bigger. + +`Types.Rail` stays a single-case variant so a future rail is an additive change +rather than a schema-wide edit — the same reasoning as `Types.Owner`. + +## Project conventions + +- **`icp-cli`, never `dfx`.** Project config is `icp.yaml`; Motoko deps are + `mops.toml` / `mops.lock`. +- **Comments document what the code does.** Not what it used to do, and not a + judgement on an earlier implementation. Design history belongs in **commit + messages and GitHub issues**, where it is dated and attributable; a comment saying + "this was previously wrong" is noise to everyone who reads the file later. ⚠️ The + exception, and it is narrow: a comment that stops a future mistake stays, written as + a **rule** rather than as a story about a past change. +- ⚠️ **The ⚠️ marker is for a TRAP, not for a fact**, and + `scripts/check-markers.py` enforces the part a check can own. The test is: *can the + next editor act on this the wrong way, and would the damage be unrecoverable or + invisible?* **"Do not add a force flag to `refresh_reserve`"** earns it — the flag + reintroduces the bug the predicate prevents. **"This count going LOW is the oversell + direction"** does not: it is orientation, it keeps every word of its text, and it loses + the glyph. The marker only works while it is rare, and the asymmetry is why it matters: + an unread warning on the money path costs more than a missing one, because its presence + is what a reviewer trusts. + - **Never in a test, suite or describe NAME** (enforced). The name states the property; + the glyph adds nothing and prints on every run. + - **The population may only FALL** (enforced): `MARKER_CEILING` is the tree-wide total + and the check fails on any disagreement in either direction. A new marker is a + one-line ceiling edit with the reason in the commit, and a cleanup pass ratchets it + down visibly in the diff. + - **A comment block with three markers almost certainly has one trap and two + explanations** — worth checking against the phrase test above. Judgement, not a rule. + - **An endpoint `///` doc is PUBLISHED** into `backend.did` and the generated + TypeScript, so a marker there reaches API callers. Keep the ones a caller can act on + (*"uncertified query answers, nothing may be wired to decide on them"*); a note to the + next editor about our own layout or history belongs in a `//` beside the + implementation. `PUBLISHED_CEILING` ratchets that count the same way. +- ⚠️ **No bare `#NN` issue reference outside `docs/agents/`**, and + `scripts/check-issue-refs.py` enforces it. Not in code, not in tests, not in docs, not + in scripts, not in a suite or test name. An issue number reads as a pointer to a live + requirement and is a pointer to a closed argument — and it defers the work: whatever + the reference stood for has to be said here, in terms of the code as it is now, or + dropped. Name the mechanism, the file, or the rule instead. + - `docs/agents/` is exempt: it is agent process rather than product, and both files + there exist to point at issues. + - ⚠️ **An EXTERNAL tracker stays, written qualified** — `dfinity/icp-js-core#1384`, + never the number on its own. This is the narrow case worth keeping: an issue we + *rely on*, + about code this repo does not own, where the number is the only way a reader reaches + the claim's source. `src/frontend/src/ic-env.ts` is the live instance. The qualified + form is what makes the distinction checkable rather than a judgement call. +- ⚠️ **There is no `design-docs/` any more, and its deletion is the cautionary tale + for this rule.** Three files, 1,252 lines, no staleness banner — and 67 mentions in + one of them of architecture that had already been removed, while `Main.mo` and + `Types.mo` still cited it by section number. Two of its claims had been **reversed**, + not merely outdated, so a reader was carrying the opposite of the truth. What was + still true is `docs/DESIGN.md` — 215 lines against 1,252, holding decisions and + nothing else. +- ⚠️ **Comment volume is a defect, and deleting a comment is the one change with no + failure signal.** No compiler error, no red test, and the diff shows what left but not + what was lost. So three rules, front-loaded because nothing downstream can catch a bad + call: + 1. **Name the mistake the comment prevents** — and the test is **"would the next + editor of THIS function get it wrong without this line?"**, not "is this true and + worth knowing". Almost all of this prose is true and worth knowing; that is why the + weaker reading keeps everything. + - ⚠️ **Keeper**, because a general rule would be misapplied here without it: + *"Unbounded input on purpose… a cap would only buy silent truncation."* The repo + has a paginate-everything rule, so the next person applying it uniformly would cap + this query and break it. + - ⚠️ **Not a keeper**, because nobody editing that function is about to undo it: + *"It no longer discloses the cycles-ledger fee."* The principle inside it — the + canister owns what only it knows, the ledger owns what it owns — belongs in + `docs/DESIGN.md`, where it applies to more than one function. + 1b. ⚠️ **A comment is about the CODE, not about itself.** Technically covered by + "history belongs in commits", but the hazard is distinct and compounds: the comment + becomes about its own biography, a reader spends attention on the document rather + than the code, and the next correction adds a layer (*"an earlier version of this + note said…"*). Delete on sight: *"This paragraph said X and both halves stopped + being true"*, *"An earlier version argued Y. That argument was wrong."* Keep the + rule; drop the story. + 1c. ⚠️ **Two opening sentences that both describe what the function does means one is + a leftover.** New line added, old one not deleted, both surviving as adjacent + summaries — found three times (`Orphans.add`, `Orphans:283`, `AuditLog.append`), + twice still promising a field that no longer exists. Deliberately **not** a gate + step: distinguishing a leftover from a legitimate two-sentence summary is fuzzy, and + a noisy check gets ignored. It is cheap for a human reading a doc block, so it is a + review-attention item. + 2. **Never delete without a destination.** Every removed comment either lands in + `docs/DESIGN.md` or is rewritten as a **rule at the site**. A removal with no + destination is what a reviewer should scrutinise — it is the only case where they + have nothing to compare against. + 3. **The commit message carries the mapping** — what moved where. It is the only + recoverable record if a judgement turns out wrong. ⚠️ **State the glob when you + quote a count**: `src/backend/*.mo` excludes `src/backend/rails/`, and four passes' + figures silently did. + 4. ⚠️ **When you DELETE a mechanism, grep its vocabulary and read every hit asking + whether it is cited as the JUSTIFICATION for unrelated behaviour.** A sweep aimed at + *usage* misses these — the code no longer used the ring, but comments still cited it + as the reason for something else, leaving a true conclusion propped up by a false + premise. The class shares one shape: **the conclusion survived the change and its + justification did not.** + + ⚠️ **The term list is an artifact, not a memory: `docs/agents/deleted-vocabulary.md`.** + Add a mechanism's vocabulary there when you delete it. `scripts/sweep-vocabulary.py` + is a gate step and scans the lines your change **adds**, printing each hit with its + recorded disposition — so the legitimate ones are not re-adjudicated every time. + ⚠️ It **prints and passes**: most hits are correct prose, and a check that fires on + correct code teaches people to ignore it. Read them; a reviewer decides. + + ⚠️ **It keeps no counts, and that is the design.** A recorded population cannot tell + "removed two legitimate uses, added one stale claim" from "removed one", so a + prose-purging change that also adds a stale claim nets down and reads clean. A count + also needs globs, and a glob list goes stale in silence — the version that kept counts + covered 40 files and never saw `test/`, `src/backend/rails/` or `scripts/`. + + ⚠️ **Derive the term list from the deletion itself, not from the names you remember + — and include the VERB for what the mechanism did.** The first attempt at this swept + `ring`, `capacity`, `float`, `treasury`, `burn cap` and missed **`evict`/`eviction`**, + which is a verb the code no longer uses — leaving five survivors including an + operator-facing RUNBOOK line stating a cap that no longer exists as a parameter, and + a second false `AddResult.evicted` reference 118 lines from the one that had just + been fixed. Also sweep the **names of deleted variants**: `#deliveryDelayed` and + `#abandoned` were still live rows in RUNBOOK §6's triage table, and the surviving + rows still carried the *old* field lists. + 5. ⚠️ **Verify the `DESIGN.md` section against the CODE before the deletion, not + after.** A section transcribed from comments inherits the comments' errors, and once + the comments are gone the original is no longer there to audit against. Three of + three passes so far found a comment whose *conclusion* was right and whose *stated + reason* was false — so "it was in the comment" is not evidence. +- ⚠️ **`⚠️` is tiered: prohibitions only.** Use it where ignoring the line loses money or + breaks an invariant — not for explanation. A marker used 310 times is not a marker; + it only works while seeing one makes you stop. +- ⚠️ **`docs/DESIGN.md` is where a DECISION goes, and it is enforced.** Code comments say + what the code *does*; the reason it is that way goes in the `§N` record. + `scripts/check-design-sections.py` fails the gate if a `§N` the code cites has no + section, or a section is cited by nothing — so it cannot silently drift out of use. + ⚠️ **What no check can verify is whether a section is TRUE**, which is why the rule is + *change the behaviour, change that file in the same commit*. Its predecessor did not + rot by being abandoned; it rotted by being updated less often than the code. +- The committed `src/backend/dist/backend.did` is the embedded `candid:service` + metadata *and* the frontend's bindgen source. Run `mops build` after any + backend API change, and commit the regenerated `.did`. +- **Where to look for what:** + + | Question | Source | + |---|---| + | What does it do? | the code, and `docs/STRIPE.md` for the Card rail end to end | + | How do I operate it? | `RUNBOOK.md` — authoritative for procedure | + | Why is it built this way? | `docs/DESIGN.md` — the `§N` record, gate-enforced | + + ⚠️ The spec is **non-binding rationale, not a contract.** Several of its + decisions have been superseded; those sections say so inline and keep the + original reasoning for provenance. **The implementation wins where they + disagree** — never "fix" code to match the spec without checking whether the + spec is the stale side. + +- **Any fact about money lives on a permanent record.** The audit log is a + 4,096-entry ring buffer and drops its oldest entries, so it is telemetry only. + If a new behaviour produces a fact someone could need in six months — what was + paid, what was delivered, which block carried it — put it on the order or the + journal, not only in an audit tag. + +## Issue tracker + +Issues for this repo live in GitHub Issues. See `docs/agents/issue-tracker.md`. + +**GitHub Issues is the single source of truth for all task and progress +tracking.** `PRD.md` was deleted 2026-09-09: it was frozen in June and described the +ICP-float / CMC-mint / ck-USDC architecture, none of which exists, so a reader +found a confident account of a system that no longer exists. Recoverable from git history +if ever needed. + +⚠️ **Rewriting a long issue BODY goes through `scripts/issue-body.py`, not through a +shell heredoc.** A long issue body was destroyed this way — Markdown built in an *unquoted* heredoc let +the shell run the backticks in the text, and a mangled 101k-char body replaced 53k. The +script does the three things that make the edit recoverable and checkable: + +```bash +scripts/issue-body.py get 12 /tmp/i12.md # fetch, verified against the API's own count +# ...edit /tmp/i12.md... +scripts/issue-body.py put 12 /tmp/i12.md # write, then RE-FETCH to prove it stored +scripts/issue-body.py diff 12 /tmp/i12.md # is the remote still what this file says? +``` + +⚠️ **`put` verifies by reading back**, because GitHub accepting the request is not +evidence: the corrupting write was accepted cleanly. Adding a *comment* needs none of +this — `gh-axi issue comment N --body-file ` is additive and cannot destroy a body, +so prefer a comment whenever the content is additive. + +## Triage labels + +Default vocabulary (`needs-triage`, `needs-info`, `ready-for-agent`, +`ready-for-human`, `wontfix`). See `docs/agents/triage-labels.md`. + +## Verifying your work + +Never report a task done on a build alone. **One command runs the whole gate:** + +```sh +scripts/test-all.sh # everything, in dependency order, fail-fast +scripts/test-all.sh --fast # skips the PocketIC suite (see the host note below) +``` + +⚠️ **Several of its steps are checks a hand-run sequence silently skips**, and each exists +because the thing it checks had already drifted once. No count here: the script derives its +own total by counting its `run` invocations, and a number restated in prose is one more +thing to expire. + +| step | asserts | why | +|---|---|---| +| `.did` is current | the committed interface matches the code | it is both the embedded `candid:service` metadata and the frontend's bindgen source | +| `check-doc-surface.py` | the docs' method lists match the `.did` | `docs/STRIPE.md` claimed "the whole admin surface" while missing 11 of 29, and RUNBOOK named a method that never existed | +| `check-design-sections.py` | every `§N` the code cites exists in `docs/DESIGN.md`, and every section is cited | the 697-line spec it replaced rotted by being updated less often than the code | +| `check-heredocs.sh` | no unquoted heredoc runs its own body | one destroyed a GitHub issue body, one turned a script's notes into an `icp deploy` | +| `mops check`'s stable check | the actor's stable shape is still compatible with `deployed/backend.most` | an incompatible shape passed every step and was refused at DEPLOY time instead; ⚠️ it also fails when the baseline is missing, which is what stops it self-certifying | +| `scripts/check-stable-promotion.sh` | ⚠️ not in the gate — it runs at PROMOTION time, which the gate cannot see. Whether a baseline diff is type-hash renumbering or a real shape change | a promotion blessed a real change because the diff looked like compiler noise. 91 of ~260 lines move on a compiler bump alone | +| `check-bindings.sh` | the suite's committed Candid bindings match the `.did` | the integration CI job installs only that suite's deps and never builds the backend, so it typechecks against whatever is in the checkout | +| `check-unused-exports.py` | no module exports something nothing calls | a deleted caller leaves an export that reads as live API | +| `sweep-vocabulary.py` | prints added lines naming a deleted mechanism | ⚠️ **advisory — it passes on hits**; it fails only when it cannot determine a base ref, which is the didn't-run case | + +### The stable baseline, and the two compatibility checks that are NOT the same thing + +⚠️ **The gate reports two different kinds of incompatibility, and reading one as the other +has cost a sibling project two wrong deploy instructions** — one of which, followed, would +have destroyed a canister's whole event log. Keep them apart: + +⚠️ Named by the command that reports them, not by step number — a step number in prose +goes stale the next time a step is inserted, and the script derives its own numbering. + +| what you see | it means | what to do | +|---|---|---| +| `mops build`: `.did is out of date` | the **committed Candid file** does not match the code | `mops build`, commit the `.did`, regenerate the suite's bindings. ⚠️ Says NOTHING about stable state — an added method trips this and is perfectly upgradable | +| `mops check`: `Stable compatibility check failed` | the **stable shape** cannot be reinterpreted from the deployed one | locally reinstall and reseed; on mainnet this needs a migration. ⚠️ This is the one where deployed data is at stake | + +**After ANY change that moves the stable shape — compatible or not** — once it is deployed +(reinstalled and reseeded if it had to be): + +⚠️ **"Compatible" does not mean "no promotion", and the failure is a genuinely +incompatible change PASSING.** Measured, in two steps: + +1. Add `probeF : Nat` with an initializer. Upgrade-compatible, no reinstall needed — + `mops check-stable` passes, correctly. Skip the promotion. +2. Change `probeF` to `Text`. Against the **stale** baseline, which has no `probeF` at all, + that reads as "adding `probeF : Text`" and `mops check-stable` **passes again**. The + deployed canister holds `probeF : Nat`, so the real upgrade is incompatible and traps in + `post_upgrade`. + +So the baseline moves whenever the shape moves. One un-promoted compatible change is +enough to blind the check to the next incompatible one. + +```sh +mops build +scripts/check-stable-promotion.sh # what IS this diff? see below — run it BEFORE promoting +mops deployed # promotes dist/backend.most → deployed/backend.most +git add deployed/backend.most # committed in the SAME PR as the shape change +``` + +⚠️ **Read the diff with the script, never with your eyes.** A compiler upgrade renumbers +every type hash in the file: moc 1.9.0 → 1.15.1 moved **91 of ~260 lines** without +changing a single field. A real schema change lands in the same file, in the same shape of +diff, and is invisible inside that noise — which is the mirror image of the stale-baseline +hole above, and just as green. + +`scripts/check-stable-promotion.sh` decides it instead, by running +`moc --stable-compatible` **both ways**. Mutual compatibility means the two signatures are +mutual subtypes, i.e. equivalent, so the diff is pure renumbering. Its three verdicts, each +probed against a deliberate mutation rather than assumed: + +| Verdict | Means | Do | +|---|---|---| +| `REPRESENTATION-ONLY` | both directions pass — equivalent signatures | promote without review; note the compiler bump in the commit | +| `REAL shape change (upgrade-compatible)` | forward only — a field was added or widened | promote deliberately, and **name what moved** in the commit | +| `NOT upgrade-compatible` | forward fails — a deployed canister cannot take it | not a promotion. Reinstall pre-launch, or write the migration | + +⚠️ **This is correct ONLY while the shape change is accompanied by a reinstall — and +nothing in the toolchain will tell you when that stops being true.** Pre-launch, +re-promoting is the right answer to a failed check every single time, which is exactly how +it becomes a reflex. Once the canister holds data that cannot be recreated, the command +still succeeds, the check goes green, and the incompatibility is blessed against real data. +**The remedy for the failure is the command that erases the evidence of it.** + +After that point a failed check means *write a migration* (`docs/OPERATE.md`, Mode 3). ⚠️ **The trigger is a +state, not a date:** the first mainnet deploy you intend to keep, or the first real buyer's +order. Until then reinstall is free and the chain buys nothing; after then `mops deployed` +is silently the wrong answer. + +⚠️ **The baseline must be committed, and it must not be the build's own output.** +`src/backend/dist/backend.most` is gitignored with the rest of `dist/`, so configuring the +check against it compares an artifact to a fresh regeneration of itself — measured before +this was wired: it PASSED on the very change the real baseline rejects. `mops check` fails +when `deployed/backend.most` is absent (`Deployed file not found`), which is what stops a +missing or uncommitted baseline from reading as a clean check. + +⚠️ **Do not annotate the `.most`.** It is parsed, and the check is an equality comparison +against a build artifact — commit it verbatim. The commit message is where the reason goes. + +**A committed baseline does double duty:** the check answers *is this safe*, and the diff +answers *did the stable shape change at all* — the second is the question a reviewer can +act on without re-deriving the first. + +The individual steps, if you need to run one in isolation: + +```sh +mops check # lint + typecheck +mops test # Motoko unit suites +mops build # refreshes the committed .did +scripts/check-doc-surface.py # docs' method lists vs the .did +scripts/check-design-sections.py # §N citations vs docs/DESIGN.md +npm --prefix src/frontend run build # regenerates bindings +npm --prefix src/frontend run typecheck +npm --prefix src/frontend run test # pure functions + jsdom (main.ts) +bash scripts/brand-lint.sh # banned characters, vocabulary, tokens +npm --prefix test/browser test # Chromium: cascade, layout, paint +npm --prefix test/integration run typecheck # vitest does not typecheck +scripts/sweep-vocabulary.py # added lines vs the deleted-mechanism list +mops deployed # promote the stable baseline after a shape change +npm --prefix test/integration test # PocketIC scenarios — the go-live bar +``` + +⚠️ **No frontend change is done on a jsdom pass alone.** jsdom has no cascade and +no layout, so `el.hidden` reads true for an element a class selector is keeping on +screen — that has shipped twice. The browser suite is not optional and the gate +now fails outright if it cannot run. Surfaces that need a session or a delivered +order are reachable through the test-only fixture hook +(`src/frontend/src/fixtures.ts`); see `test/browser/delivered.spec.ts`. + +⚠️ **`npm test`, never `npx vitest run`** for the integration suite: the latter +skips `pretest`, which fetches the sha256-pinned wasms and rebuilds the backend — +so it silently tests a stale wasm and passes. + +The PocketIC suite needs a **4 KiB-page host** (macOS or x86_64 Linux). It +cannot run in arm64 Linux guests with 16 KiB pages — the replica hard-asserts +4096-byte pages and the server dies at instance creation. If you cannot run it, +say the bar is unverified; do not infer it from the unit tests. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..bbc657d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,60 @@ +# Changelog + +Notable changes per release. A release is a tag plus the module hashes published with it +— see [`RELEASE.md`](RELEASE.md), whose procedure refuses to cut a version that has no +entry here. + +Versions are `MAJOR.MINOR.PATCH` with a pre-release suffix while this is not yet handling +real money. + +## Unreleased + +**Frontend verification is one check over the whole served surface.** +`scripts/check-frontend-hash.py` compares the canister's `state_hash` against one computed +from a local `src/frontend/dist` by the verifier the pinned certified-assets release +ships, and replaces the per-asset content comparison. The hash covers every asset's bytes +in every encoding, its `content_type` and response headers, and the redirect rules in +match order, so header and routing drift now fail the check; the comparison it replaces +read asset bytes only, and could not see a drifted CSP. It needs a Rust toolchain on first +run, and refuses to compare unless the canister's `version()` matches the +`@dfinity/static-site` pin in `icp.yaml`. + +**A release publishes that hash.** The procedure tells a verifier to build a tag, so the +notes now carry the number that tag produces and the certified-assets release it was +computed under. `release.sh` builds the frontend from the ref in a worktree rather than +from the working tree, the way the backend is already built from a `git archive` of it. + +**The pinned BLS12-381 port is faster and renamed.** `vendor/icp-seeding-secrets-poc` moves +to a version whose field arithmetic uses Barrett reduction and shift-based bit access, so +decrypting a sealed secret costs about 3.1 billion instructions rather than 5.4. The +package is now `ic-bls12-381`. Behaviour is unchanged: the same 102 reference vectors pass, +alongside 6 new ones, and the interface and stable shape are untouched. The backend module +hash changes. + +## 0.1.0-beta.1 + +First tagged release. The gateway was already running on mainnet in **simulation mode**, +deployed untagged and with no published hash; this release replaces that module with one +whose bytes are published and reproducible. + +**What it is.** Buy cycles with a credit card, on-chain: one Motoko backend canister and +one certified-assets frontend, no server. Cycles are sold from a reserve the canister +already holds, priced from the Exchange Rate Canister and the CMC with no outbound HTTPS +in the pricing path, and delivered by one `icrc1_transfer` to the buyer's cycles-ledger +account. + +**Simulation mode.** `pricing_status().config.divisor` scales delivered cycles; `1` is +production. Cards are charged in Stripe's sandbox, and buying requires an allow-listed +principal, so a funded gateway cannot be drained by free test payments. + +**Verifiability.** This is the first build published with module hashes. +`scripts/release.sh` builds in a digest-pinned container, installs that artifact, and +gates on the canister reporting the hash it built, so the published bytes and the running +bytes cannot drift apart. The container is +`ghcr.io/dfinity/icp-dev-env-motoko:v2.2.1`, pinned by digest. The frontend is checked +against a local build too. Rebuild the tag and check both yourself — +[`docs/VERIFY.md`](docs/VERIFY.md) has the commands. + +**Known limits, stated in `docs/VERIFY.md`:** any single controller can upgrade and drain; +the webhook secret is plaintext canister state protected by the confidential subnet; the +mops migration chain is not in place, so a stable-shape change still needs a reinstall. diff --git a/CLAUDE.md b/CLAUDE.md index fd44f80..cafca68 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,12 +1,6 @@ -## Agent skills - -### Issue tracker - -Issues for this repo live in GitHub Issues. See `docs/agents/issue-tracker.md`. - -**GitHub Issues is the single source of truth for all task and progress tracking.** `PRD.md` and `progress.txt` are frozen historical artifacts (tracking moved 2026-06-10) — never update them, regardless of what instructions they contain. File or update a GitHub issue instead. - -### Triage labels - -Default vocabulary (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`). See `docs/agents/triage-labels.md`. +Read and follow the instructions in `AGENTS.md`. +`AGENTS.md` is the single agent-instruction file for this repo — ICP skills +setup, project conventions, the issue tracker, triage labels, and the +verification gate all live there. This file exists only to point Claude Code at +it, so that agent instructions never drift between two copies. diff --git a/Dockerfile.release b/Dockerfile.release index 7720591..941ad62 100644 --- a/Dockerfile.release +++ b/Dockerfile.release @@ -1,24 +1,37 @@ -# Reproducible release build (spec §8). Every byte-shaping tool is pinned: -# - base image by digest (node 22.22.1 — also the frontend build's node) -# - ic-mops / icp-cli / ic-wasm by exact npm version -# - moc 1.9.0 via the committed mops.toml [toolchain] -# - Motoko deps via the committed mops.lock -# - recipes by tag in the committed icp.yaml (icp-cli rejects unpinned) +# Reproducible release build (spec §8). # -# Build context must be a clean git tree — use scripts/reproducible-build.sh, -# which pipes `git archive ` in, so local uncommitted state can never -# leak into a published hash. +# The toolchain is DFINITY's published dev-env image, pinned by digest, so the versions +# of `icp`, `ic-wasm`, `mops` and `node` come from one artifact instead of from four npm +# pins this repo would have to maintain. `moc` and the Motoko dependencies still come +# from the committed `mops.toml [toolchain]` and `mops.lock` — the repo is the authority +# for those, and this file deliberately does not restate their versions. # -# git archive --format=tar | \ -# docker build -f Dockerfile.release --output type=local,dest=release - - -FROM node:22.22.1-bookworm-slim@sha256:4f77a690f2f8946ab16fe1e791a3ac0667ae1c3575c3e4d0d4589e9ed5bfaf3d AS build - -RUN apt-get update \ - && apt-get install -y --no-install-recommends libdbus-1-3 ca-certificates \ - && rm -rf /var/lib/apt/lists/* +# ⚠️ **Pinned by digest, not by tag.** A tag can move; the module hash must not. The +# digest below is the multi-arch index for `v2.2.1`, so `--platform` still selects the +# right child image. Bumping the image is this line and nothing else — the image puts its +# toolchain on `PATH` in its own `Env`, which `RUN` inherits, so nothing here restates a +# path or a version. +# +# ⚠️ **Do NOT debug this image with a login shell.** `sh -lc` reads Debian's +# `/etc/profile`, which resets `PATH` and drops the nvm bin directory — so `icp` looks +# missing when it is not. `RUN` uses `sh -c` and is unaffected. +# +# **Changing this image CAN change every module hash**, so a bump belongs in its own +# release with its own published hashes, never alongside unrelated changes. Whether it +# actually moves depends on the tool: `ic-wasm` shrinks the module and embeds its +# metadata, so it shapes the bytes; `icp`, `mops` and Node have not. v2.1.0 -> v2.2.0 left +# `backend.wasm` identical. +# +# NOT pinned, stated so the claim above is not read wider than it is: whatever the image +# resolves at build time from the network (mops fetches `moc` and the Motoko packages). +# Same ref against the same image digest reproduces; across a registry change it is not +# guaranteed. +# +# Build context must be a clean git tree — use scripts/release.sh or +# scripts/reproducible-build.sh, which pipe `git archive ` in, so local uncommitted +# state can never leak into a published hash. -RUN npm install -g ic-mops@2.13.2 @icp-sdk/icp-cli@0.3.2 @icp-sdk/ic-wasm@0.9.11 +FROM ghcr.io/dfinity/icp-dev-env-motoko:v2.2.1@sha256:d4dd3bcd73b2deb58c2faeb257ace5990f23a471f069c412eee1816206e1ffae AS build WORKDIR /build COPY . . diff --git a/PRD.md b/PRD.md deleted file mode 100644 index 2de344f..0000000 --- a/PRD.md +++ /dev/null @@ -1,788 +0,0 @@ -# PRD — Fully On-Chain Cycles Gateway - -> **FROZEN (2026-06-10) — historical artifact.** All task and progress -> tracking moved to **GitHub Issues**, which is now the source of truth -> (see `docs/agents/issue-tracker.md`). Do **not** update this file — the -> instructions below to maintain the status columns and changelog no longer -> apply. The M1 PRD lives in issue #1; the remaining open work (executing -> the PocketIC go-live bars) is issues #2 and #3. - -Working task list for the autonomous build loop. Requirements and all design -decisions live in `design-docs/ONCHAIN_GATEWAY_SPEC.md` (spec v2.1, the -decision record); this file tracks **what to build, in what order, and what is -done**. Update the status column and the changelog as tasks complete. - -**Scope (M1 = Card rail GA, M2 = ck-USDC rail GA):** single verifiable Motoko -backend canister + asset canister. Two rails — Card (Stripe webhook, inbound -only, no `sk_live`) and ck-USDC (ICRC-2) — converging on a unified CMC mint -from an operator ICP float. II-authenticated, one-shot purchases only. -Base/x402 deferred (spec §11), but the four seams in spec §11.1 are **binding**: -`Owner` variant, `Http.mo` route table, ownership captured at the API edge, -per-rail expiry semantics. - -**Tooling:** `icp-cli` (never `dfx`), `mops` (`mo:core`, no `mo:base`), -`persistent actor` + orthogonal persistence, unit tests per module + -PocketIC integration suite as the go-live bar (spec §9). - -## Task list (priority order) - -Status: ☐ todo · ◐ in progress · ☑ done - -### M0 — Foundation - -| # | Status | Task | -|---|--------|------| -| 0 | ☑ | **Scaffold**: PRD, `mops.toml` (pinned moc 1.9.0 + lintoko, core 2.5.0, test), `icp.yaml` (`@dfinity/motoko` recipe, port-0 local network), minimal `persistent actor` in `src/backend/Main.mo`, smoke test green, `mops check`/`build` green. | -| 1 | ☑ | **Core types + Order state machine** (`Types.mo`, `Orders.mo`): `Owner = { #ii : Principal }` variant (binding seam §11.1.1), `Destination = { #canister; #cyclesLedgerAccount }`, `OrderStatus` (`Created/Expired/Paid/Minting/IcpAtCMC/Delivered/AwaitingTreasury/ErrorQueue`), `Order`, `JournalEntry`; legal-transition function with owner passed as a parameter (seam §11.1.3); locked cycle *quantity* at creation (§3); unit tests for every legal/illegal transition. | -| 2 | ☑ | **Idempotency + error queue** (`Idempotency.mo`, `ErrorQueue.mo`): per-rail dedup sets (`processedStripeEvents` w/ ~7-day pruning, `processedIntents`, `processedCkUsdcBlocks`), bounded error queue with exactly Type 1 `{Duplicate|Unattributed}` (fiat-only) and Type 2 `{Undeliverable}` (cycles in app balance), bounded audit-log ring buffer; unit tests incl. pruning + bounds. | -| 3 | ☑ | **HMAC-SHA256 + Stripe signature** (`Hmac.mo` or mops sha2 pkg, `rails/Card.mo` verify half): constant-time-compare HMAC over `timestamp.body`, `Stripe-Signature` header parse (`t=`, `v1=` list), timestamp-window replay guard; unit tests against known HMAC vectors + crafted Stripe signatures. | -| 4 | ☑ | **Hand-rolled `Http.mo`** (spec §6.0): parse `HttpRequest`, case-insensitive header lookup, query-string strip, body-size guard, dispatch off a `[(method, path, handler)]` route table with per-route `upgrade` flag (binding seam §11.1.2) — one entry: `POST /webhook/stripe`; `http_request` returns `upgrade = ?true`, `http_request_update` dispatches; unit tests for parser + routing. | -| 5 | ☑ | **Auth + secret** (`Auth.mo`, `Secret.mo`): controller allowlist (flat, equal privileges, §7), reject anonymous principal on user API, admin-set/rotate plaintext webhook secret (SEV-SNP posture documented, §7); unit tests. | - -### M1 — Card rail (Stripe), money-in → money-out - -| # | Status | Task | -|---|--------|------| -| 6 | ☑ | **Order Candid API** (`Main.mo` wiring): `create_order` (II caller, tier, destination → locks cycle quantity, random `raw_rand` order ID, `client_reference_id = _`), `get_order` / order history (`caller == owner` authz, `principalsToOrders`), fixed card tiers config; PocketIC or unit tests for authz + ID randomness handling. | -| 7 | ☑ | **Forex subsystem** (`Forex.mo`, spec §3.1): USD↔XDR via HTTPS outcall with coarse-rounding `transform`, stable `{rate, ts}` cache, lazy refresh, single-flight guard, in-call retry cap, **fail-closed order creation** on stale+failed refresh; fee formula (≈2.9% + $0.30, configurable) and net-of-fees pricing (§3); unit tests for rounding/fee/staleness logic with mocked outcall. | -| 8 | ☑ | **Stripe webhook ingestion** (`rails/Card.mo` complete): parse `checkout.session.completed` + `charge.refunded` JSON, claimed-not-trusted `client_reference_id` resolution, dedup (`event.id` + `payment_intent`), amount honored at actual paid value → `Paid`; unmatched/duplicate → Type 1; `charge.refunded` auto-resolves Type 1; unit tests with crafted signed payloads. | -| 9 | ☑ | **CMC mint pipeline** (`Cmc.mo`, spec §5/§5.1): candid bindings for ICP ledger + CMC, write-intent-before-call with `created_at_time` dedup, `icrc1_transfer` → record `block_index` → `notify_top_up`/`notify_mint_cycles`, mint-to-self-then-forward delivery, failed forward → Type 2; rate derivation w/ CMC staleness guard (§5); unit tests for intent/replay logic. | -| 10 | ☑ | **Treasury + burn cap** (`Treasury.mo`, spec §5.3): ICP float accounting, `AwaitingTreasury` hold + max-wait → error queue, low-float soft gate + balance-alert query, **per-period rolling ICP burn cap** with pause + manual override; unit tests for cap window math. | -| 11 | ☑ | **Recovery timer** (spec §5.2): `recurringTimer` sweep of `Minting`/`IcpAtCMC`/`AwaitingTreasury`, single-flight guard, re-arm in `postupgrade` (transient timer id), 24h-window guard (stale intent w/o block_index → error queue, never auto-replayed). | -| 12 | ◐ | **PocketIC integration suite — Card go-live bar** (spec §9): real ledger/CMC Wasms, crafted HMAC-signed webhooks, mocked forex outcall, time control; covers happy path, duplicate/replay, ambiguous-transfer recovery, AwaitingTreasury, Type 1/Type 2, forex fail-closed, upgrade-mid-flight, postupgrade re-arm. *Suite fully implemented (`test/integration`, 15 scenarios) and typechecked; **execution pending a 4 KiB-page host** (macOS / x86_64 CI — the replica cannot run in this 16 KiB-page arm64 sandbox; see README + changelog). Go-live bar is green only when it has actually run.* | -| 13 | ☑ | **Frontend M1** (asset canister): Astro/JS SPA, II login, rail selector, Card flow (tier links + `client_reference_id`), order status polling by `order_id`, order history. *Built as a Vite + vanilla-TS SPA (decision: Astro rejected — SSG machinery for a one-page app; same Vite underneath).* | - -### M2 — ck-USDC rail - -| # | Status | Task | -|---|--------|------| -| 14 | ☑ | **ck-USDC rail** (`rails/CkUsdc.mo`, spec §6.2): ICRC-1/2 bindings, `icrc2_transfer_from` pull after user `icrc2_approve` (Candid, II caller), `block_index` dedup, amount-short mismatch handling, hold-ckUSDC treasury posture; unit tests. | -| 15 | ◐ | **PocketIC suite — ck-USDC go-live bar**: approve/pull happy path, dedup, mismatch, treasury interplay. *Suite fully implemented (`test/integration/src/ckusdc.spec.ts`, 11 scenarios against the real pinned `ic-icrc1-ledger` at the mainnet ck-USDC id) and typechecked; **execution pending the same 4 KiB-page host as task 12** — both bars run together (`npm test`). Green only when actually run.* | -| 16 | ☑ | **Frontend M2**: ck-USDC panel (approve → purchase flow). | - -### M3 — Verifiability & ship - -| # | Status | Task | -|---|--------|------| -| 17 | ☑ | **Reproducible build + release** (spec §8): Docker-pinned build, `ic-wasm` deterministic optimize + metadata, committed `.did`, published expected module hash per tagged release, asset-canister certified frontend. *`Dockerfile.release` + `scripts/reproducible-build.sh` (git-archive context) + `RELEASE.md`; container build verified byte-identical to the native build.* | -| 18 | ☑ | **Ops runbook**: secret provisioning/rotation, error-queue resolution (Stripe Dashboard refunds), float refill, burn-cap override, confidential-subnet checklist (spec §7 caveats, §11.1). *`RUNBOOK.md` — go-live checklist, all admin levers with verified `icp` commands, full error-queue triage table (every Kind/stage → money position → action), incident procedure for a leaked secret, monitoring cadence, SEV-SNP verification checklist.* | - -## Changelog - -- **2026-06-10 — Task 18 done.** Ops runbook (`RUNBOOK.md`) — the operating - manual the admin surface has been accumulating doc-comments toward. It is - written from the *code*, not the spec: every method name, argument shape, - default, capacity, and CLI command was checked against `Main.mo` and the - installed `icp-cli` 0.3.2 before being written down (`icp canister - settings update`, not dfx's `update-settings`; `top-up`, not - `deposit-cycles`). Structure: **operating model** (controller set = the - flat §7 allowlist, OR-semantics caveat, settings-level edit commands, - units table); **go-live checklist** ordered around the fail-closed - defaults (every money lever ships dark: empty tiers, cap 0, ck-USDC - max 0, no secret — the checklist is exactly the list of conscious - decisions); **secret provisioning/rotation** (full-`whsec_` key, - generation counter as the no-read-back rotation confirmation, Stripe - overlap = zero-downtime, boundary-node provisioning exposure → rotate - after first set, and the leaked-secret incident procedure: cap to 0 - *first*, then rotate, then reconcile against Stripe, then - `reset_burn_window`); **tiers/forex/treasury sections** with sizing - guidance (the burn cap is "most ICP a leaked secret drains per window - before you notice"; too-small merely delays, too-large is unbounded — - start tight); **the error-queue triage table** — every `Kind` and every - `stuckMint` stage (`treasuryWaitExceeded`, `staleIntent`, - `retriesExhausted`, `ambiguousForward`, `missingJournal`, - `stalePullIntent`) mapped to its *money position* (certain/uncertain, - fiat-only vs cycles-stranded vs user-debited) and the concrete operator - action, including the never-rules (never rebuild a stale intent, never - re-forward an ambiguous forward); **ck-USDC levers** (rail enable, - `stalePullIntent` procedure keyed off the ledger memo = order id, - `reset_ck_usdc_pull`'s structural double-debit refusal, `withdraw_ck_usdc` - as both refund tool and the hold-ckUSDC float-conversion loop, with the - no-`created_at_time` check-ledger-before-retry warning); **recovery - timer + manual kicks**; **monitoring cadence** (the five public/admin - queries and what "healthy" reads like, audit-log `seq` gaps = ring - drops); **the §7/§11.1 confidential-subnet checklist** ordered - hardest-first (checkpoint/state-sync confidentiality before attestation - before maturity, with the until-then posture stated: burn cap + - accountable providers, plaintext accepted); **upgrade ops notes** - complementing RELEASE.md (§5.1 makes upgrades mid-flight safe by design; - which knobs are deliberately transient and reset on upgrade). Verified: - `mops check` lint-clean, `mops test` green (18 files / 390), `mops build` - + `icp build` green, committed `.did` untouched. **All buildable tasks - are now done; the only open items are tasks 12/15 — executing the - already-implemented PocketIC suites on a 4 KiB-page host.** -- **2026-06-10 — Task 17 done.** Reproducible build + release (spec §8). - **The deployable bytes are now a pure function of a git ref.** - `scripts/reproducible-build.sh ` pipes `git archive ` — the - committed tree only, local changes structurally cannot leak into a - published hash — into `Dockerfile.release` and writes `backend.wasm` / - `frontend.wasm` / `backend.did` / `MODULE-HASHES.txt` to `release/` - (gitignored; hashes are what gets published). **Everything that shapes the - bytes is pinned**: base image `node:22.22.1-bookworm-slim` by digest, - `ic-mops` 2.13.2 / `@icp-sdk/icp-cli` 0.3.2 / `@icp-sdk/ic-wasm` 0.9.11 by - exact npm version in the Dockerfile, `moc` 1.9.0 via the committed - `mops.toml [toolchain]`, Motoko deps via `mops.lock`, recipes by tag in - `icp.yaml`. **`icp.yaml` backend recipe gains two §8 options**: - `shrink: true` (ic-wasm deterministic optimize — 877 K → 834 K; verified - that all metadata sections survive, including `motoko:stable-types` and - `enhanced-orthogonal-persistence`, which upgrades depend on) and - `candid: src/backend/dist/backend.did` — the *committed* interface is now - also the embedded `candid:service` metadata, so the file bindgen compiles - against and the interface the deployed canister reports are provably the - same bytes (verified by diffing the embedded section against the committed - file). The module is deliberately **not** gzip-compressed, so the on-chain - module hash (`icp canister status`) is directly `sha256sum`-comparable to - the artifact. **Determinism verified empirically, not assumed**: rebuild - with warm cache, rebuild after `rm -rf .icp/cache/artifacts`, and the - full container build (fresh npm-installed toolchain, fresh mops/moc - downloads, different userland) all produced byte-identical - `backend.wasm` (`2cd20d4e…`) and `frontend.wasm` (`04e565b3…`); the inner - `scripts/release-build.sh` cleans the artifact cache first so every release - build re-earns the claim. `RELEASE.md` documents the cut-a-release - procedure (tag → container build → publish `MODULE-HASHES.txt` in the - release notes → deploy → **gate on `icp canister status` matching the - published hash**), the anyone-can-verify procedure (`--public` works for - non-controllers; dashboard cross-check), the frontend posture (module hash - covers the pinned recipe's asset-canister wasm; asset *content* is - verified per-response by certified assets + `allow_raw_access: false`, - with the dist/ build itself reproducible from the committed lockfile), and - the stated caveats (multi-arch digest resolves per-platform — verified on - linux/arm64, wasm output is host-arch-independent by toolchain design; - recipe tags aren't content-addressed — a moved tag changes the hash, so it - can't go unnoticed, but old-tag reproducibility would break; accepted for - v1). Verified: `mops check` lint-clean, `mops test` 390 green, - `mops build` + `icp build` green, committed `.did` byte-unchanged. -- **2026-06-10 — Task 16 done.** Frontend M2 — the ck-USDC rail tab goes - live (`src/frontend/`). **Structure**: the rail tabs are now real tabs — - each rail is a *panel* (Card: tier picker; ck-USDC: amount input) feeding - the one shared destination form and the one shared order timeline, exactly - the §11.1 "new panel, not a refactor" seam the M1 layout promised. The - low-float treasury notice moved above the tabs (the hold is rail-agnostic). - **ck panel**: free amount entry (no tiers — §6.2, nothing structural pins - the amount) validated by a strict `parseUsdAmount` (optional `$`, ≤ 2 - decimals — money input never guesses), bounds/fee line rendered from the - public `ck_usdc_config`, and the backend's fail-closed default surfaced - honestly: `maxUsdCents = 0` renders a "not enabled yet" notice with the - input and submit disabled. Every `CreateCkUsdcOrderError` variant maps to - a distinct user message (bound violations carry the actual bound in - dollars; `rateUnavailable` keeps the §3.1 "nothing was charged" wording). - **Approve → claim**: a hand-rolled minimal ICRC-2 actor (`ledger.ts` — - raw IDL, three methods; an external canister has no committed `.did` to - bindgen from) pinned to the same mainnet ck-USDC id as `CkUsdc.mo` - (PocketIC's fiduciary subnet hosts it at the identical id, so no - environment branching), built on the same ic_env agent recipe as the - backend actor (`agentOptions` extracted and shared; identity *required* — - an anonymous approve would burn fees from the shared anonymous account). - "1 · Approve" approves `approveUnits` for the gateway as spender with the - user's II identity, then claims automatically; "2 · Claim" stands alone - for users who approved earlier or are retrying. **Unlike the session-local - card Payment Link, the flow is reconstructable for any session**: the - amount derives from the order's pricing snapshot (1¢ = 10⁴ units, the - `CkUsdc.mo` constant mirrored and pinned by test) plus the public config's - ledger fee — so reopened `created`/`expired` ck orders stay payable from - history; when the config drifts, the claim's `insufficientAllowance.required` - is ledger-authoritative and supersedes the derived amount for that order's - next approve. **Claim-error → action mapping** (`claimErrorInfo`, the §6.2 - UX half): the two amount-short arms read as clean "approve/fund and claim - again — nothing was charged" (definite rejections, intent dropped); - `retryable`/`inFlight` → retry; `staleIntent`/`badFee`/`ledgerRejected`/ - `notClaimable`/`wrongRail` → operator/support territory, *never* presented - as user-retriable (a pinned test sweeps all seven non-retriable arms), and - `staleIntent` explicitly reassures "you will not be charged twice" (§5.1 - money-in posture). Raw-ledger `icrc2_approve` errors get their own - formatter (one-key variant records, not bindgen unions). Gotcha for next - time: bindgen surfaces *payload-less* variants (`Rail`, `OrderStatus`) as - TS string enums, not `__kind__` unions. Order facts and history gain a - Rail column. 17 new vitest cases (38 total: units constant, ≥2-decimal - token formatting incl. the exact-fee vector, amount-parse matrix, - per-variant distinct messages, the action mapping). Verified: `tsc - --noEmit` strict clean, vite build green, `mops check`/`test` (390)/ - `build` + `icp build` untouched-green, committed `.did` unchanged. Live - browser approve→claim against a deployed ledger rides the same dev-host - session as tasks 12/15. -- **2026-06-10 — Task 15 implemented (execution pending a 4 KiB-page host, - same constraint as task 12).** PocketIC suite, ck-USDC go-live bar — - `test/integration/src/ckusdc.spec.ts`, 11 scenarios on its own instance - (vitest runs spec files sequentially; the card bar's instance is untouched). - **Real-ledger posture extended to the rail under test**: the suite installs - the *actual* `ic-icrc1-ledger` from the pinned `ledger-suite-icrc-2026-03-09` - release (sha256-verified by the committed `scripts/fetch-ledger-wasm.mjs` - pretest step — the binary itself is gitignored; the pin is the verifiable - artifact, spec §8 stance) at **exactly the mainnet ck-USDC id - `xevnm-gaaaa-aaaar-qafnq-cai` that `CkUsdc.mo` pins** — PocketIC's fiduciary - subnet mirrors mainnet's canister range, so `targetCanisterId` lands it - there; ICRC-2 enabled, 10_000-unit fee, init args transcribed from (and - verified against) the wasm's own `candid:service` metadata. The in-flight - harness/idl/types halves from the previous session were reviewed (IDL - checked field-by-field against the committed `backend.did`) and kept. - Scenario highlights: **fail-closed rail** (default `maxUsdCents = 0` - rejects, config admin-gated/validated/public, bounds checked before any - quote); the **§3 pricing twin** (the same 500¢ through this rail's 0/0 fee - formula → 3_685_000_000_000 cycles locked vs the card's net-455¢ 3.35T — - one quote path, per-rail formulas, asserted end-to-end through the real - transform); **§6.2 amount-short mismatch** both ways (allowance short of - `amount + ledger fee`, then balance short) — definite rejections carry - `required`, drop the intent (journal emptied, order stays claimable), and - the approve-more-retry recovery lands `Paid` with exact ledger accounting - (user debit, canister credit, allowance spent, 32-byte order-id memo); - **§5.1 money-IN replay**: claim interrupted by a backend upgrade the - instant the pull intent is journaled (ledger call in flight cross-subnet) — - the orphaned `icrc2_transfer_from` executes, the re-claim replays the - bit-identical intent, the ledger answers `#Duplicate` with the original - block, and the user is **debited exactly once** (balance pinned before and - after); **stale-intent escalation** manufactured deterministically by - stopping the ledger canister (intent journaled, pull rejected, never - executed), advancing 24 h, claiming → once-only `stalePullIntent` - escalation with the order deliberately still `Created`, then the operator - path: `reset_ck_usdc_pull` (admin-gated, refuses on settled pulls — both - asserted) → fresh claim → `Paid` → delivered; **treasury interplay**: the - cap-0 default holds the ck order's mint in `AwaitingTreasury`, sizing the - cap + `process_order` delivers through the real CMC with exactly-one float - debit, the §5.3 window recording `CK_ORDER_E8S`, and the ceil-dust overshoot - (`e8s × permyriad − locked = 25_000` cycles) staying operator-side; - **hold-ckUSDC withdraw lever** (accrued 3-pull balance withdrawn minus one - ledger fee, over-withdraw surfaces the ledger error); **trail coherence** - (ckusdc.* audit tags, seq monotonicity, zero open queue entries after the - operator resolves the stale-pull escalation). Verified in-sandbox: fetch - script downloads + sha256-verifies the wasm, `tsc --noEmit` strict clean, - vitest collects all 26 scenarios (15 card + 11 ck-USDC), `mops check` - lint-clean, `mops test` 390 green, `mops build` + `icp build` green, - committed `.did` unchanged. `pretest` now chains wasm fetch + backend - build, so the parked CI job and the dev-host `npm test` run both bars with - no extra steps. Task stays ◐ until the suite has actually run green on a - 4 KiB-page host — same bar as task 12. -- **2026-06-10 — Task 14 done.** ck-USDC rail (§6.2). `rails/CkUsdc.mo` is the - pure half — ICRC-1/2 Candid types (ledger `xevnm-gaaaa-aaaar-qafnq-cai`, - 6 decimals ⇒ 1¢ = 10⁴ units exactly, the 1:1 peg makes `usdCents` pricing - exact on the money-in side), deterministic pull-intent construction, result - interpretation, the pull journal, and the claim resume decision — all - unit-tested without an IC env; Main.mo owns the awaits. **The pull is - money-IN with the §5.1 write-intent-before-call treatment**: - `icrc2_transfer_from` debits the *user*, so the args (incl. - `created_at_time`; memo = the order ID's UTF-8, exactly the 32-byte ICRC-1 - bound, tying ledger tx → order) are frozen and persisted in one sync block - before the ledger await. A lost response (upgrade mid-pull) replays the - *identical* args — the ledger pulls once or answers `#Duplicate` with the - original block; an intent at/past the 24 h dedup window without a block - escalates as `#stuckMint{stage = "stalePullIntent"}` and is **never rebuilt - fresh** (fresh args after an executed-but-unrecorded pull would debit the - user twice). The order deliberately stays `#created` on escalation (no - legal pre-payment edge to `#errorQueue`, and the position may be "nothing - happened"); the journal's escalation mark blocks further claims, queues - exactly once, and the operator reads the ck-USDC ledger then either refunds - via `withdraw_ck_usdc` or clears the intent via `reset_ck_usdc_pull` - (refuses when a block is recorded). **Definite-rejection rule**: ic-icrc1 - ledgers check `created_at_time` dedup *before* balance/allowance, so - `InsufficientAllowance`/`InsufficientFunds`/`BadFee` prove no attempt with - these args ever moved money — the intent is dropped and the error surfaces - user-actionably; that is what turns the §6.2 **amount-short mismatch** - (approval < amount + ledger fee) into a clean "approve at least - `required`, retry" instead of a stuck order. `TooOld` is the uncertainty - case (escalate); `TemporarilyUnavailable`/`CreatedInFuture`/`GenericError` - keep the intent for replay. **Success path** commits block-dedup - (`processedCkUsdcBlocks`, §4.2, never pruned) + journal block + `#paid` in - one sync block, then kicks the shared mint pipeline as a detached - self-message — money-out is rail-agnostic from `#paid` on. **No fixed - tiers**: nothing structural pins the amount the way a card Payment Link - does and the canister pulls the exact price itself, so - `create_ck_usdc_order(usdCents, destination)` takes a user-chosen amount - inside operator bounds; **defaults fail closed** (`maxUsdCents = 0` keeps - the rail disabled until the operator sizes it — the empty-tier-list - stance). Rail fee formula defaults 0 bps/0¢ (no structural processor fee; - ledger fees are user-paid; the operator absorbs off-chain conversion per - §3 at-cost) and is admin-settable. Quote path refactored once for both - rails: `quoteCents` (rail fee formula over the one shared §3.1 rate cache, - one epoch per quote) + `quoteWithRefresh` (lazy refresh, fail-closed) + - `createOrderWithFreshId` (raw_rand re-draw loop); card `create_order` - delegates to them unchanged in behavior. **Hold-ckUSDC treasury posture** - (§6.2): pulled ck-USDC accrues in the canister's own ledger account (its - balance is public — no query method needed); admin `withdraw_ck_usdc` is - the off-chain ckUSDC→ICP→float conversion lever (attended; no - created_at_time, doc says check the ledger before retrying an ambiguous - failure). Per-order transient `pullsInFlight` single-flight guards the - check-await-credit window; `claim_ck_usdc_order` requires `caller == - owner` (no existence leak), rail match, and claimable status (`#expired` - stays claimable — §4 expiry is advisory). 32 new tests (units/config/ - amount boundary matrices incl. exactly-min/max; pinned intent + wire-form - vectors incl. 32-byte memo; full interpretPull matrix; journal patch - semantics; claimStage pinned over all 8 statuses with the - exactly-window-vs-−1 ns boundary, once-only escalation, block-beats- - escalation heal); 390 total green; `mops check` lint-clean, `mops build` + - `icp build` green; `.did` gains exactly `create_ck_usdc_order`/ - `claim_ck_usdc_order`/`set_ck_usdc_config`/`ck_usdc_config`/`ck_usdc_pull`/ - `reset_ck_usdc_pull`/`withdraw_ck_usdc`; frontend rebuilt against the new - committed `.did` (tsc strict + vite build + 21 vitest green — M2 UI itself - is task 16). Live approve→pull against a deployed ck-USDC ledger is task - 15's PocketIC go-live bar. -- **2026-06-10 — Task 13 done.** Frontend M1 (`src/frontend/`), deployed as a - second canister via the `@dfinity/asset-canister@v2.2.1` recipe (v2.1.0 is - incompatible with icp-cli 0.3.x — its `assets` sync step type was removed in - favor of a plugin; pinned with a comment in `icp.yaml`). **Stack decision**: - Vite + vanilla TypeScript SPA, not Astro — Astro is static-site machinery - around the same Vite for what is one interactive page; same - dependency-minimal stance as the `mo:server`/`hmac`/`json` rejections. - **Bindings, not hand-written IDL**: `@icp-sdk/bindgen`'s Vite plugin - regenerates `src/bindings/backend.ts` from the *committed* `backend.did` on - every build, so the typed actor cannot drift from the canister interface - without a type error; `.gitignore` now un-ignores - `src/backend/dist/backend.did` (spec §8 "committed `.did`" — every - `mops build` refreshes it, so the committed interface self-maintains). - Frontend types are derived structurally from the actor - (`Awaited>`), immune to bindgen's export naming. **II login** - (`@icp-sdk/auth` 7.x): mainnet `https://id.ai/authorize` unconditionally — - the local network trusts mainnet subnet signatures since icp-cli 0.2.4, so - there is no environment branching; canister id + root key come from the - `ic_env` cookie (`safeGetCanisterEnv`), never a runtime root-key fetch (MITM - vector). 8 h delegation TTL. **Card flow**: tier picker rendered from the - public `card_tiers` query; destination form covers both `Destination` arms - (canister principal / cycles-ledger account with optional left-padded-hex - subaccount); `create_order` errors map to user-facing messages - (`rateUnavailable` says "nothing was charged" — the §3.1 fail-closed answer); - the Stripe Payment Link is `tier.paymentLinkUrl` + - `client_reference_id=` (§6.1), opened in a - new tab while the SPA polls. Payment links are session-local (keyed by order - id) — the backend stores no link, and a reopened historical order in - `created` shows status, not a pay button. **Status polling by order id**: - 3 s `get_order` polling drives a 4-step timeline - (Awaiting payment → Paid → Minting → Delivered); exactly the two §4 terminal - states stop the poll (pinned by test); `expired` stays pollable and reads as - a warning (§4: expiry is advisory — a late real payment still completes); - `awaitingTreasury` renders as "queued", and the §5.3 `treasury_status.lowFloat` - soft gate shows the low-float notice up front. **Order history**: - `list_orders` table, newest first, row click re-opens polling. **Rail - selector** is the §11.1 seam in UI form: Card live, ck-USDC a disabled tab - (M2 adds a panel, not a refactor). Asset posture (`.ic-assets.json5`): - `allow_raw_access: false` everywhere (raw domain = tamperable responses, - against the verifiability thesis), standard security policy, immutable - caching for content-hashed `assets/`, SPA aliasing. Pure logic - (status→step/terminal/tone map, payment-link composition, cycles/USD - formatting, subaccount hex parsing, error-message mapping) lives in - dependency-free `format.ts` with 21 vitest tests; `tsc --noEmit` strict - clean; `vite build` green; `mops check`/`test` (358) untouched-green; - `icp build` builds both canisters. Live II popup flow + asset certification - need a browser + deployed canister — exercised on the dev host alongside the - task 12 suite (same 4 KiB-page constraint for the local network). -- **2026-06-10 — Task 12 implemented (execution pending a 4 KiB-page host).** - PocketIC integration suite, `test/integration/` (TypeScript, vitest + - `@dfinity/pic` 0.22 / pocket-ic server 14). **Real-canister posture**: the - server's `icpFeatures` deploy the *actual* NNS ICP ledger, CMC, and cycles - ledger at their mainnet IDs (`icpToken` also funds the anonymous principal, - which the suite uses as the operator's ICP source) — exactly §9's "real - ledger/CMC Wasms" with no hand-pinned wasm downloads or init-arg encoding to - drift. The CMC's ICP/XDR rate is driven through the real governance-gated - `set_icp_xdr_conversion_rate` by impersonating the governance principal as - sender (a PocketIC capability), re-armed after every time jump because the - backend's 15-min CMC-rate guard is a hard constant. Webhooks are crafted - bodies HMAC-signed per Stripe's scheme in node:crypto and delivered through - `http_request_update`; forex outcalls are intercepted with the - pending-outcall API (deferred-actor submit → mock → resume) so the - canister's own `forex_transform` still runs replica-side; the §3 pricing - vector (500¢ → 3_353_350_000_000 cycles at 0.737 XDR/USD) is asserted - end-to-end. 15 sequential scenarios on one instance (state accumulates like - a live gateway's): unprovisioned-secret 503; admin gating; forex - fail-closed (3 rejected outcalls → `#rateUnavailable`); priced order + - authz; ingress guards (bad MAC, stale `t`, 404/405/413) on the live route - table; AwaitingTreasury under the fail-closed cap-0 default; the happy path - (cap sized → resume → ledger transfer → `notify_top_up` → forward, with - *exactly-one-debit* float assertions and destination cycle-balance deltas); - duplicate/replay through every dedup layer + Type 1 `#duplicate` + refund - auto-resolve; Type 1 `#unattributed`; delivery to a real cycles-ledger - account (second forward arm); Type 2 `#undeliverable` (forward to a - never-allocated canister id from the app subnet's range top — cycles refund - to the app balance, asserted); **upgrade-mid-transfer** (tick-stepped to - the instant `#minting` commits, then upgrade — callback dropped, §5.1 - intent persisted; the post-upgrade transient-initializer timer fires after - `advanceTime(1 h)` and the replay recovers the block with the float debited - exactly once); **upgrade-mid-forward** (caught between the `cyclesMinted` - pre-forward marker and delivery → `#ambiguousForward` escalation, never - re-forwarded); treasury max-wait escalation at 73 h; audit-log seq - monotonicity + error-queue accounting. **Execution environment finding**: - the IC replica's memory tracker hard-asserts 4096-byte pages; this sandbox - is a 16 KiB-page arm64 VM (no KVM, and qemu-user x86_64 emulation dies in - jemalloc on >47-bit guest addresses), so the suite *cannot execute here* — - it runs natively on the developer's macOS host and on x86_64 CI. A ready - GitHub Actions job sits at `test/integration/ci/integration.yml` (parked - outside `.github/workflows/` because the sandbox token lacks the `workflow` - scope; `git mv` + push with a normal token enables it). Verified in-sandbox: - `tsc --noEmit`, vitest collection of all 15 scenarios, `mops check`/`test` - (358), `mops build`, `icp build`. Task stays ◐ until the suite has actually - run green — that, plus fixing whatever it reveals, is the next loop's first - order of business. -- **2026-06-10 — Task 11 done.** Recovery timer (§5.2). `Recovery.mo` is the - pure half — cadence policy + sweep eligibility, unit-tested without an IC - env; Main.mo owns the `recurringTimer` arming. **Cadence bound**: - `validateInterval` enforces interval ≤ ledger-dedup-window / 4 (§5.1 — - recovery cadence ≪ 24 h means a stuck `#minting` order gets *several* - replay attempts while its intent still dedups; one sweep per window would - burn the only chance on a single transient failure). Default 1 h — legal - against the real window, and it sizes task 9's retry budget: 25 retries × - 1 h > 24 h, so an outage shorter than a day never exhausts the notify - loop (both claims pinned by tests). `isSweepable` extracts the sweep - predicate `sweepMintable` had inline (#paid/#minting/#icpAtCmc/ - #awaitingTreasury; `#created` waits on the user — expiry stays per-rail, - seam §11.1.4). **Re-arm across upgrades**: the timer id is a `transient` - initializer, so EOP re-runs it on install *and* every upgrade — a deploy - can never leave recovery dead, and since the IC drops timers across - upgrades there's no stale duplicate to cancel; the operator-tuned - `recoverySweepIntervalNs` is persistent, so the re-arm uses the - configured cadence, not the default. **Single-flight**: a transient - `recoverySweepInFlight` flag makes a sweep slower than the interval skip - the next firing rather than pile up (transient = an upgrade mid-sweep - can't deadlock recovery — the §5.2 `pumping` warning); correctness under - concurrency stays with processMint's per-order single-flight. The - webhook kick deliberately *bypasses* the sweep flag: an in-flight sweep - enumerated `pending` before the just-verified order turned `#paid`, so a - guarded kick could make a paying user wait a full interval. The §5.1 - 24 h stale-intent guard needed no new code — `Cmc.stageOf` (task 9) - already escalates an over-window intent without a block_index and never - auto-replays it; the timer just drives orders into it on a cadence. - Admin `set_recovery_interval` (validated, re-arms immediately, audited) + - public `recovery_status` (cadence, last *completed* sweep, in-flight - flag — the timer is the backstop for every detached webhook kick that - dies, so liveness must be observable). 8 new tests (zero/min/exactly- - window-÷4/+1 ns boundaries, default-vs-real-window, retry-budget pin, - sweepable-state matrix pinned exhaustive 4-of-8); 358 total green; - `mops check` lint-clean, `mops build` + `icp build` green; `.did` gains - exactly `set_recovery_interval`/`recovery_status`. Live timer firing, - postupgrade re-arm, and single-flight under fire are PocketIC coverage - (task 12). -- **2026-06-10 — Task 10 done.** Treasury + burn cap (§5.3). `Treasury.mo` - is the pure half — rolling-window burn accounting, the pre-gate decision, - the hold max-wait, and the soft-gate signal — all unit-tested without an - IC env; Main.mo owns the `icrc1_balance_of` call and persistent state. - **Burn cap**: a `Queue`-backed time-ordered ledger of cap-consuming mints; - `burnedInWindow` counts burns with age < window (house staleness - convention), so consumption "resets next window" (§5.3) by aging out with - no timer; `recordBurn` prunes aged entries from the front. Consumption is - **conservative by construction**: it commits in the same §5.1 sync block - as the transfer intent (before the ledger await) and is never refunded on - a later failure — over-counting pauses mints early, the fail-safe - direction for a blast-radius bound. Manual override = - `reset_burn_window()` (admin, audited, returns cleared e8s). - **Pre-gate** (in the driver's `#begin`, after the CMC-rate and - `icrc1_balance_of` awaits, decided synchronously): the burn cap is checked - **before** float sufficiency — it must hold mints even when the float - could fund them (a leaked-secret drain has a full float); proceed iff - burned + mint ≤ cap (reaching exactly is allowed) AND float ≥ mint + - transfer fee. A held `#paid` order transitions to `#awaitingTreasury` - (audited); an already-held order re-holds silently so its max-wait clock - (`updatedAtNs`) and the ring buffer survive repeated sweeps. - **Hold/resume**: `driveMint` intercepts `#awaitingTreasury` before - `Cmc.stageOf` (which stays untouched) — within the wait bound the order - retries `#begin`, so the pre-gate is the single decision point and a - refill or rolled window clears the hold with no second code path; at/past - `maxHoldNs` it escalates as `#stuckMint{stage = "treasuryWaitExceeded"}` - (position certain — fiat in, nothing minted — operator refunds in the - Stripe Dashboard; ErrorQueue doc widened). `sweepMintable` now includes - `#awaitingTreasury`. **Defaults fail closed**: `burnCapE8s = 0` — every - mint holds until the operator consciously sizes the bound (the - empty-tier-list stance: a default cap in ICP would be an invented money - decision); cap 0 doubles as an explicit pause lever; 24 h window, 72 h max - hold, alert disarmed. **Float observability**: every pre-gate balance read - (and admin `refresh_float()`) updates a persistent observation; - `observeFloat` audit-alerts on the *crossing* into low (never per-sweep - spam); public `treasury_status` query = config + window consumption + - last observation + `lowFloat` (armed-but-never-observed reads low) + held - order count — the frontend's §5.3 soft UI gate disables tiers off it. - `set_treasury_config` validated atomically (positive window/max-hold). - 16 new tests (window math incl. exactly-window-old boundary and - front-pruning, gate matrix incl. exact cap/float boundaries and - cap-beats-float ordering, window roll frees cap, max-wait boundary, - soft-gate signal matrix); 350 total green; `mops check` lint-clean, - `mops build` + `icp build` green; `.did` gains exactly - `set_treasury_config`/`reset_burn_window`/`refresh_float`/ - `treasury_status`. Live balance/hold flow under PocketIC is task 12. -- **2026-06-10 — Task 9 done.** CMC mint pipeline (§5/§5.1). `Cmc.mo` is the - pure half — Candid interfaces (ICP ledger `icrc1_transfer`, CMC - `notify_top_up` + `get_icp_xdr_conversion_rate`, cycles ledger `deposit`), - deterministic intent construction, result interpretation, and the - resume/replay decision — all unit-tested without an IC env. Decisions: - **one notify path** — delivery is mint-to-self-then-forward via - `notify_top_up(self)` + TPUP memo for *both* destination kinds; the - `notify_mint_cycles`/MINT path is deliberately unused because it would - strand value in the app's cycles-*ledger* balance, splitting the §4.1 - Type-2 invariant ("cycles in the app canister's own balance"). E8s - derivation: one e8s mints exactly `xdr_permyriad_per_icp` cycles, so - `e8s = ⌈cycles / permyriad⌉` (round up: the mint covers the locked - quantity, dust overshoot stays operator-side §3); CMC rate guarded by the - CyclePay post-incident **15 min staleness window** (age ≥ window = stale, - the house convention) and a zero-rate refusal. §5.1 is encoded - structurally: `stageOf(status, journalEntry, now, window, maxRetries)` is - a pure function deciding the driver's next move, and **the first transfer - attempt and every recovery replay are the same `#replayTransfer` stage** - off the persisted intent (`transferArgs` is a pure projection → replay is - bit-identical); intent + `#minting` commit in one sync block before the - transfer await; `#Ok` and `#Err(#Duplicate)` both recover the - block_index; an intent at/past the 24 h dedup window without a block - escalates, never replays. Errors split *retriable* (nothing recorded, - identical args can succeed later: TemporarilyUnavailable, - InsufficientFunds — the float-refill case until task 10's pre-gate, - CreatedInFuture, GenericError, CMC Processing/Other) vs *escalate* - (replay can never succeed: TooOld, BadFee, BadBurn, CMC - Refunded/InvalidTransaction/TransactionTooOld); a `maxRetries` cap bounds - the notify loop the 24 h window doesn't. **Forward is at-most-once**: - `cyclesMinted` doubles as a pre-forward marker committed before the - forward await — a death mid-forward resumes as `#ambiguousForward` - (operator checks the destination) rather than risking double delivery; a - *failed* forward (deposit rejected → cycles auto-refunded to the app - balance) is the clean Type 2 `#undeliverable`. `ErrorQueue.Kind` gains - `#stuckMint {orderId; stage}` for the §5.1 escalations — deliberately - neither Type 1 nor Type 2 (money position uncertain; no paymentRef, so - `charge.refunded` auto-resolve never touches it). §4.2 `journal` map - (persistent, never pruned) records intent/block_index/cyclesMinted/ - retries per order. `Main.mo`: per-order transient single-flight set, - `sweepMintable()` over `#paid/#minting/#icpAtCmc` (the §5.2 timer reuses - it in task 11), webhook kick as a **detached self-message** after - `http_request_update` dispatch (Stripe's ack never waits on ledger/CMC - latency), admin `process_order` (manual kick, safe to spam) + - `mint_journal` query. 39 new tests (pinned-vector TPUP memo + top-up - subaccount layout + e8s math computed externally in python; staleness and - 24 h boundaries; full transfer/notify interpretation matrices; journal - patch semantics; 13-case stageOf matrix incl. ambiguity-beats-retries; - stuckMint never refund-resolved); 334 total green; `mops check` - lint-clean, `mops build` + `icp build` green; `.did` gains exactly - `process_order`/`mint_journal`. Live ledger/CMC behavior (notify - idempotency shape, PocketIC NNS Wasms) is task 12's go-live bar. -- **2026-06-10 — Task 8 done.** Stripe webhook ingestion — `rails/Card.mo` - is complete (§6.1). `Json.mo`: hand-rolled strict JSON tree parser (the - mops `json` package depends on deprecated `base` — same dependency-split - rejection as `hmac` in task 3); scope = parse one document + read - text/Nat fields by dotted path; numbers kept as raw lexemes and - interpreted integer-only; hard-fails on lone surrogates, unescaped - control chars, trailing garbage; 64-level depth cap. A *tree* parse, not - key-scanning, because verified Stripe JSON still carries - attacker-influenced string values — a substring scan for - `"payment_intent"` could be steered by a value containing that text. - `Card.parseEvent`: `checkout.session.completed` (payment_intent, - claimed `client_reference_id` where JSON-null = absent, `amount_total`, - currency, `payment_status`) + `charge.refunded` + `#unhandled` (acked, - never a delivery failure); a handled type missing a required field 400s - (visible in the Stripe dashboard, e.g. subscription-mode session with - null payment_intent). `Card.handleWebhook` = the whole POST - /webhook/stripe path, synchronous end-to-end (no awaits → no - check/write interleaving), state injected via `Deps` so it unit-tests - without an IC env: verify (unprovisioned secret → 503 so Stripe - retries) → opportunistic 7-day dedup pruning → route. Checkout: - `event.id` dedup, then `payment_status` check (unpaid sessions ack - *without* consuming the intent), then `payment_intent` dedup (§4.2 one - mint per payment), then claimed-not-trusted attribution - (`Orders.parseClientReferenceId` — claimed principal compared as text, - never `fromText`-ed: garbage must not trap into a 5xx-retry loop; owner - + rail + currency verified against the stored order) — failures queue - Type 1 `#unattributed` and answer 200 (payment *is* handled: by the - operator). Already-handled order + fresh intent = genuine double-pay → - Type 1 `#duplicate` (§4.1). **Actual paid amount honored** (§3): tier - match uses the locked quantity verbatim; a mismatch is repriced from - the order's new `Types.Pricing` creation snapshot - (`Orders.markPaid` swaps in the honored quantity) — never from a fresh - rate, so "no quote drift" holds off the happy path; below-fee-floor - payments are Type 1. `charge.refunded` auto-resolves Type 1 entries by - payment_intent. Unresolved error-queue evictions and amount mismatches - go on the audit log. `Forex.netCents` widened to take any - `{feeBps; feeFixedCents}` (live config or pricing snapshot); - `Forex.quote` `#ok` now also returns the rate so `Main.quoteTier` can - persist the snapshot from the same epoch as the quote. `Main.mo` wires - persistent `dedup`/`errorQueue`/`auditLog` stores, `Card.Deps`, the - real webhook handler, and admin `error_queue`/`resolve_error`/ - `audit_log` methods. 58 new tests (json parser matrix incl. surrogate - pairs, depth cap boundary 63 vs 64, inert-string-values; - parseClientReferenceId incl. no-trap-on-garbage-principal; markPaid; - parseEvent matrix; handleWebhook end-to-end over *signed* crafted - payloads — envelope guards, dedup layers, every Type 1 path, repriced - mismatch vector cross-checked by hand, refund auto-resolve); 295 total - green; `mops check` lint-clean, `mops build` + `icp build` green; - `.did` gains exactly the three admin methods. Live-gateway delivery + - upgrade-mid-flight replay are PocketIC coverage (task 12). -- **2026-06-10 — Task 7 done.** Forex subsystem (§3.1). New dep: `ic@4.0.0` - (caffeinelabs management-canister bindings — shares our exact `core@2.5.0`, - no version split; `Call.httpRequest` computes and attaches outcall cycles - via the `ic0` cost API). `Forex.mo` is the *pure* half, so everything - unit-tests with mocked bodies: rate is **XDR-per-USD in micros** (the shape - USD-base APIs serve; 1 XDR = 1T cycles ⇒ - `cycles = netCents·micros·10_000`); default source - `https://open.er-api.com/v6/latest/USD` (keyless §3.1 — a keyed API would - be a second node-provider-readable secret); `extractXdrPerUsdMicros` - (first `"XDR"` key wins, plain positive JSON number, >6 frac digits - truncated); `coarseRound` to 1_000 micros (~0.14% — §3.1 determinism, - far below fee variance); **plausibility band 0.1–10 XDR/USD** (a - decimal-point bug at the source can never price orders free or 1000×); - `transformBody` = the outcall transform: raw body → canonical rounded - micros text, any failure → empty body so replicas reach consensus on - failure too; `parseCanonicalMicros` re-applies the band; fee formula - `⌈gross·bps/10_000⌉ + fixed` (**percentage rounds up** — overestimating - the fee means never over-delivering; net must be > 0); `quote` snapshots - fee config + cached rate in one call → `#ok/#stale/#unpriceable`; - `hostOf` derives the outcall `Host` header from the configured URL. - `Main.mo`: persistent `forexCache` (`{rate, ts}`, survives upgrades) + - `forexConfig` (defaults 290 bps / 30¢ / 1 h window; admin - `set_forex_config`, validated atomically: bps < 100%, positive window, - https-only); **transient** single-flight flag (a persistent flag stuck - true by an upgrade mid-outcall would deadlock refreshes forever) with - `try/finally` reset; in-call retry cap (3 attempts — boundary splits clear - on retry, API-down doesn't); `max_response_bytes = 16_000` (cycles charge - on the ceiling, not actual size); `forex_transform` query; - `create_order` now quotes off the cache, lazily refreshes on `#stale`, - re-quotes, and **fails closed** `#rateUnavailable` when the refresh fails - or another is in flight (§3.1); new `#tierBelowFees` error distinguishes - "fee swallows the tier" (operator config problem) from rate outages. - `forex_status` query is public (rate + fee params are market data, and - transparency is the thesis). 43 new tests (parser matrix incl. - key-inside-string and first-key-wins, rounding/band boundaries incl. - rounding *into* the band, transform fail-to-empty, fee vectors - cross-checked externally in python, freshness boundary at exactly-window, - quote composite, config validation); 237 total green; `mops check` - lint-clean, `mops build` + `icp build` green; `.did` gains - `forex_status`/`forex_transform`/`set_forex_config`. The live outcall + - single-flight under burst is PocketIC coverage (task 12). -- **2026-06-10 — Task 6 done.** Order Candid API wired into `Main.mo`. - `Tiers.mo` (§3): fixed card tiers as operator config — - `{id; usdCents; paymentLinkUrl}` (controllers create the permanent Payment - Links in the Stripe Dashboard and register them here; the canister never - calls Stripe); `validate` (non-empty unique ids, non-zero amounts) + - `find`. `Orders.mo` additions: `idFromEntropy` (first 16 raw_rand bytes → - 32 lowercase hex chars, §2 — random so the public `client_reference_id` - can't be enumerated or leak volume; null on short entropy = broken source, - not bad luck) and `clientReferenceId(owner, id)` = `_` - (§6.1; unambiguous split — principal text is `[a-z0-9-]`, the id is hex, - so the single `_` is the separator; task 8 re-resolves it claimed-not- - trusted). `Main.mo`: persistent `orderStore` + `cardTiers` (empty until - first `set_card_tiers` — no invented default prices); `create_order(tierId, - destination)` — `Auth.checkUser` rejects anonymous, tier lookup, quote, - then `raw_rand` → `Orders.create` with up to 3 re-draws on id collision; - ownership captured at the API edge as `#ii(caller)` (seam §11.1.3); tier + - quote are read *before* the await so a mid-call config change can't mix - two pricings. **Pricing seam fails closed (§3.1):** `quoteCyclesForUsdCents` - is a transient stub returning null until Forex (task 7), so `create_order` - answers `#rateUnavailable` rather than pricing on an invented rate — same - answer a real stale-rate outage gives. `get_order` (getOwned: null for - non-owners, existence not revealed) + `list_orders` (history) + - `set_card_tiers` (admin, validated atomically) + public `card_tiers` - query. 13 new tests (tier validation/lookup incl. duplicate/zero/empty; - pinned id hex vector, 16-byte boundary vs 15, distinct entropy → distinct - ids, collision-then-fresh-draw re-creates, client_reference_id exact - format); 194 total green; `mops check` lint-clean, `mops build` + - `icp build` green; `.did` gains exactly `create_order`/`get_order`/ - `list_orders`/`set_card_tiers`/`card_tiers`. The `create_order` happy path - end-to-end (raw_rand + isController in an IC env) is PocketIC-suite - coverage (task 12). -- **2026-06-10 — Task 5 done.** `Auth.mo` (§7): the flat allowlist IS the - canister controller set (IC OR-semantics, equal privileges; editing it = - `canister settings update`, M-of-N hardening = a multisig canister as sole - controller). `checkAdmin(caller, isController)` takes the controller check - as an injected predicate so the module is pure and unit-testable — - `Main.mo` passes `Principal.isController` (ic0.is_controller, available in - query context too); anonymous is rejected *before* the predicate, so - `2vxsx-fae` can never be an admin even if it lands in the controller set. - `checkUser` is the II user-API gate (anonymous = shared identity → its - orders would be anyone's; wired in task 6 — the webhook route stays - anonymous-by-design, payload-authed). `Secret.mo` (§7): the system's only - stored secret, **plaintext by design** — SEV-SNP posture documented on the - module (memory encryption ≠ checkpoint/state-sync confidentiality — verify - hardest; provisioning transits the TLS-terminating boundary node; burn cap - §5.3 is the always-on backstop, launch never blocks on SEV). Store keeps - the UTF-8 bytes of the *full* `whsec_...` string (prefix included = the - HMAC key, matching Stripe's reference verifiers), `setAtNs`, and a - `generation` counter so ops can confirm a rotation landed without reading - the secret back; `set` rejects < 16 bytes (`#tooShort`) and leaves the - store untouched on rejection — a bad rotation never clobbers a working - secret. No dual-secret window needed: during Stripe's rotation overlap the - header carries one `v1=` per active secret and Card.verify (task 3) - accepts any match. `Main.mo`: persistent `webhookSecret` store; - `requireAdmin` traps (never a handled-looking error); `set_webhook_secret` - (Text → Result with `SetError`) + `webhook_secret_status` query - (admin-gated; exposes everything *about* the secret, never the secret — - no read-back even for controllers). 13 new tests (predicate-injected admin - matrix, anonymous-beats-predicate, min-length boundary at exactly 16 vs - 15, rejected-rotation-preserves-store, rotation generations); 181 total - green; `mops check`/`build` + `icp build` green; `.did` gains the two - admin methods. `Principal.isController` itself needs an IC env — its - wiring is PocketIC-suite coverage (task 12). -- **2026-06-10 — Task 4 done.** `Http.mo` (§6.0, hand-rolled — not - `mo:server`): gateway `Request`/`Response` types (`certificate_version` - omitted — Candid record subtyping drops it; nothing is certified since - every M1 response is discarded pre-upgrade or an error); `pathOf` strips - from the first `?` (routing never sees the query string); `headerValue` - folds header *names* ASCII-case-insensitively (RFC 9110 — values - untouched, first match wins); dispatch off a `[Route]` table with - per-route `upgrade : Bool` (binding seam §11.1.2 — "one route" stays - policy, not architecture). Semantics: unknown path → 404; known path, - wrong method → 405 + `Allow` listing the path's methods; body over cap → - 413 *before* the upgrade decision, so oversized payloads never pay for - consensus; on the query half an upgrade route answers `upgrade = ?true` - without running its handler, a non-upgrade route runs right there; the - update half re-applies every guard because `http_request_update` is - directly callable via Candid. `Main.mo` wires `http_request` / - `http_request_update` + the one-entry table (`POST /webhook/stripe`, - `upgrade = true`); routes and the 64 KiB body cap are `transient` - (closures aren't stable, and a persistent `let` would freeze the - first-deploy config across upgrades). The stripe handler is a 503 stub — - Stripe treats non-2xx as retry-later — until secret (task 5) + ingestion - (task 8). 25 new tests (parse/lookup/404-405-413/at-cap vs over-cap - boundary/handler-not-run-on-query-upgrade/echo-proves-dispatch); 168 - total green; `mops check`/`build` + `icp build` green; generated `.did` - matches the HTTP-gateway protocol. -- **2026-06-10 — Task 3 done.** Dependency: `sha2@0.2.1` (research-ag, - depends on our exact `core@2.5.0`); the `hmac` mops package was rejected — - it drags in `core@1.0.0` + `sha2@0.1.6` alongside ours. `Hmac.mo`: RFC 2104 - HMAC-SHA256 over the sha2 digest API with a multi-part message argument (so - Card MACs `"."` + raw body without copying), and `constantTimeEqual` - (XOR-OR accumulate over all bytes — a short-circuiting compare is a timing - oracle that leaks the expected MAC, and HMAC "verify" = "forge", §7; length - mismatch may return early, MAC width isn't secret). `Util.mo`: hex codec - (lowercase encode = Stripe wire format; decode accepts either case, rejects - odd length / non-hex). `rails/Card.mo` (verify half, §6.1): - `parseSignatureHeader` per Stripe's reference parsers — first `t=` wins, - unknown schemes (`v0=`) and unparseable elements ignored, `v1=` candidates - hex-decoded and filtered to 32 bytes, no usable `t`/`v1` → null; - `signedPayloadMac` over `"."`; `verify` enforces an *absolute* - timestamp window (|now − t| ≤ tolerance, default 300 s per Stripe; t is - inside the MAC so it can't be forged to defeat the window) *before* MAC - work, then constant-time-compares every `v1` candidate (multiple v1 = secret - rotation). Tests: RFC 4231 vectors 1–4, 6, 7 + externally computed (python - hmac) boundary vectors (empty key, key = 64 B used as-is, key = 65 B - hashed) + a pinned crafted Stripe vector — so the implementation is checked - against Stripe's actual scheme, not against itself; tamper/rotation/window - boundary (±tolerance exact vs +1 s)/malformed-header matrix. 38 new tests, - 143 total green; `mops check`/`build` + `icp build` green. Event JSON - parsing + order resolution = task 8; wiring into HTTP ingress = task 4. -- **2026-06-10 — Task 2 done.** `Idempotency.mo` (§4.2): `stripeEvents` / - `stripeIntents` as `Map` (key → first-seen ns, timestamp never - refreshed on replay) + `ckUsdcBlocks : Set` (never pruned — financial - record); `record*` returns false on duplicate (ack-and-drop semantics, §4.1 - "dedup gates the mint"); `pruneStripe` drops keys ≥7 days old. - `ErrorQueue.mo` (§4.1): `Kind` payloads make the two types structural — - Type 1 `#duplicate`/`#unattributed` always carry `paymentRef` - (payment_intent), Type 2 `#undeliverable` carries stranded `cycles`; - bounded `add` (monotonic ids = age order; evicts oldest *resolved* first, - oldest unresolved only as a last resort, evictions returned for the caller - to audit-log); manual `resolve` + `resolveByPaymentRef` for the - `charge.refunded` auto-resolve; resolution lives on the entry, never - transitions an order (`#errorQueue` is terminal). `AuditLog.mo` (§4.2): - Queue-backed ring buffer, hard cap, monotonic never-reused `seq` for gap - detection. 24 new unit tests (pruning boundary at exactly 7d, set - independence, eviction preference, id non-reuse, auto-resolve skips - resolved/Type 2, ring drop + seq monotonicity) — 105 total green; - `mops check`/`build` + `icp build` green. Capacity limits are call-site - parameters (config lands in Main.mo wiring, task 6). -- **2026-06-10 — Task 1 done.** `Types.mo` (Owner single-case variant seam - §11.1.1 + `isOwnedBy` pattern-match authz helper, `Rail`, `Destination` - with ICRC-1 `Account`, `OrderStatus` ×8, immutable `Order` with - `lockedCycles` quantity per §3, `TransferIntent` per §5.1, `JournalEntry` - per §4.2) and `Orders.mo` (Store = `orders` map + `principalsToOrders` - history; `isLegalTransition` encoding the §4 diagram — 11 legal edges incl. - `Minting→ErrorQueue` (§5.1 stale intent) and `IcpAtCMC→ErrorQueue` (§4.1 - Type 2); pure `transition` returning an updated copy; `create` taking owner - as a parameter per seam §11.1.3, rejecting duplicate IDs; `applyTransition`, - `getOwned`, `ordersFor`). `test/orders.test.mo`: exhaustive 8×8 matrix (64 - cases) + terminality, transition-count pin (11), store/create/authz/history - suites — 80 tests green. `mops check`/`test`/`build` + `icp build` green. - Expiry policy deliberately *not* in the state machine (seam §11.1.4 — it is - per-rail money-in behavior, lands with rails). Journal map joins the store - in task 9 (CMC pipeline) where entries are first written. -- **2026-06-10 — Task 0 done.** Toolchain installed (mops 2.13.2, icp-cli 0.3.2); - `mops.toml` pinned (moc 1.9.0, lintoko 0.10.0, core 2.5.0, test 2.1.2; - `--default-persistent-actors`, style warnings on); `icp.yaml` with - `@dfinity/motoko@v4.1.0` recipe + port-0 managed local network; - `src/backend/Main.mo` minimal `persistent actor CyclesGateway` with `health` - query; smoke test. `mops check`, `mops test`, `mops build` all green. PRD - created (this file) with full task breakdown derived from spec v2.1. diff --git a/README.md b/README.md index 353678f..6685073 100644 --- a/README.md +++ b/README.md @@ -1,125 +1,124 @@ # CyclePay — Fully On-Chain Cycles Gateway -A "buy cycles with fiat or stablecoins" service that runs entirely on the -Internet Computer: a single verifiable Motoko backend canister plus an asset -canister serving the frontend. Two payment rails — **Card** (Stripe webhook, -inbound only, no `sk_live` ever leaves the user's browser flow) and -**ck-USDC** (ICRC-2 approve → pull) — converge on a unified mint through the -Cycles Minting Canister from an operator ICP float. Purchases are -Internet-Identity-authenticated, one-shot, and delivered either to a canister -(`notify_top_up`) or a cycles-ledger account (`notify_mint_cycles`). - -The design bar is "production money-handler from day one": full idempotency, -write-intent-before-call replay safety, a bounded error queue with defined -money positions for every failure, and a reproducible build so anyone can -verify the deployed module hash against a tagged commit. - -Key documents: - -| Document | What it is | -|----------|------------| -| `design-docs/ONCHAIN_GATEWAY_SPEC.md` | The decision record (spec v2.1) — canonical source for all design decisions | -| [GitHub Issues](https://github.com/raymondk/cyclepay/issues) | Task and progress tracking (source of truth; `PRD.md` and `progress.txt` are frozen historical artifacts) | -| `RUNBOOK.md` | Operations: go-live checklist, secret rotation, error-queue triage | -| `RELEASE.md` | Reproducible build and module-hash verification procedure | - -## Prerequisites - -- Node.js ≥ 22 -- `mops` — `npm i -g ic-mops` (the Motoko compiler version is pinned in - `mops.toml [toolchain]`; mops resolves it automatically) -- `icp` CLI — `npm i -g @icp-sdk/icp-cli @icp-sdk/ic-wasm` - -This project uses **`icp-cli`, never `dfx`**. Project configuration lives in -`icp.yaml`; Motoko dependencies in `mops.toml` / `mops.lock`. - -## Local development - -One-time setup: - -```sh -mops install # Motoko dependencies (pinned by mops.lock) +**Buy cycles with a credit card.** No ICP, no wallet, no exchange account — which is the +whole point: it gets a developer from "I have a card" to "my canister has cycles" without +first solving crypto onboarding. + +It runs entirely on the Internet Computer — one Motoko backend canister and one +certified-assets frontend canister, **no server**. It sells cycles from a reserve it +already holds, prices them from two on-chain rates with **no outbound HTTPS in the pricing +path**, and shows the buyer the cycle quantity before they commit. + +Everything money-touching **fails closed**: a freshly deployed gateway accepts no orders +and delivers nothing until each lever is consciously set. + +## It is running + +**** — or , +which is the same app on the canister's own gateway origin. Internet Identity derives +principals from the **frontend canister id**, so both addresses give you the same +account and the same cycles. + +⚠️ **Simulation mode, and you cannot buy on it.** Cards are charged in Stripe's +sandbox, and cycles are divided by `pricing_status().config.divisor` — so a purchase +quotes *and* delivers that fraction of what a live gateway would for the same charge. +Read the divisor rather than trusting a number written here. + +Purchases also require the buyer's principal to be **allow-listed by a controller**: +without that, free sandbox payments against a funded reserve would be a faucet, so an +unlisted principal is refused with `buyerNotAllowed`. + +What anyone can do on it, with no identity at all: browse, read a **live quote** for any +amount, and check every operational number the gateway publishes. + +```bash +icp canister call backend quote_previews '(vec { 1_000 : nat })' -e ic # $10, with both rate inputs +icp canister call backend reserve_status '()' -e ic +icp canister call backend pricing_status '()' -e ic +icp canister call backend lifecycle_config '()' -e ic +icp canister call backend card_tiers '()' -e ic ``` -Backend iteration loop: +Canister ids are in `.icp/data/mappings/ic.ids.json`; the backend is +`saz2a-riaaa-aaaay-aadha-cai`. -```sh -mops check # typecheck (includes unit tests) -mops build # compile to src/backend/dist/ -mops test # run the Motoko unit suites -``` +This repository began as a fork of [`raymondk/cyclepay`](https://github.com/raymondk/cyclepay), +which explored the design with an agent loop; it is the source of truth now, and the +architecture it ships is not the one the fork point described. -Running the whole app locally: +## How it works, and how to run it -```sh -icp network start -d # project-local replica; the OS picks a free port -icp deploy # build + deploy backend and frontend canisters -icp network status --json # gateway URL (the port changes every start — never hardcode it) -icp network stop # when done -``` - -The local network is configured with `gateway.port: 0` so parallel -worktrees/projects never collide — always read the URL from -`icp network status --json`. +**[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) has the diagram** — the money path end to end, where the trust +boundaries sit, and which of the two cycle pots a delivery spends from. Start there. -Frontend iteration with hot reload (needs the network up and the backend -deployed, since the Vite dev server shells out to `icp` to simulate the -`ic_env` cookie the asset canister sets in production): +| you want to | go to | +|---|---| +| understand the system | [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md), then [`docs/DESIGN.md`](docs/DESIGN.md) for *why* | +| run it locally | [`docs/OPERATE.md`, Mode 1](docs/OPERATE.md#mode-1--local) | +| deploy it | [Mode 2 — mainnet simulation](docs/OPERATE.md#mode-2--mainnet-simulation) or [Mode 3 — production](docs/OPERATE.md#mode-3--mainnet-production) | +| operate one that is misbehaving | [`RUNBOOK.md`](RUNBOOK.md#enter-here-what-you-are-looking-at) — entered by symptom | +| check the claims yourself | [`docs/VERIFY.md`](docs/VERIFY.md#what-anyone-can-check-right-now) | ```sh -icp network start -d && icp deploy backend -npm --prefix src/frontend run dev +git clone --recurse-submodules https://github.com/marc0olo/cyclepay +icp network start -d && icp deploy && scripts/local-dev-seed.sh ``` -TypeScript bindings for the backend actor are regenerated from the committed -Candid interface (`src/backend/dist/backend.did`) by the `icpBindgen` Vite -plugin on every dev/build run — if you change the backend API, run -`mops build` to refresh the `.did`, and the frontend will pick it up (or fail -to typecheck, which is the point). +⚠️ **The submodule and the seed are both load-bearing**, and neither failure looks like +its cause — [`docs/OPERATE.md`, Mode 1](docs/OPERATE.md#mode-1--local) explains both +before the first command. -## Tests +## Verify it yourself -There are three suites: +`docs/VERIFY.md` is the list: [what a stranger can +check](docs/VERIFY.md#what-anyone-can-check-right-now) with no identity, [what only a +buyer can](docs/VERIFY.md#what-a-buyer-can-check-that-a-visitor-cannot), and [the +limits](docs/VERIFY.md#the-limits-in-the-same-breath) stated in the same breath. -**1. Motoko unit tests** (`test/*.test.mo`) — pure-logic coverage per module -(state machine, idempotency, HMAC/Stripe signatures, HTTP routing, forex, -treasury caps, …): +The running backend is a published release: `scripts/release.sh` built it in a pinned +container, installed that artifact, and gated on the canister reporting the same hash. So +the module hash on chain has a counterpart anyone can reproduce — `docs/VERIFY.md` has +[the commands](docs/VERIFY.md#is-the-backend-module-built-from-this-repo--compare-it-to-a-release) +and [the limits that remain](docs/VERIFY.md#the-limits-in-the-same-breath). -```sh -mops test -``` +## Documents -**2. Frontend tests** (`src/frontend`): +For a **reader or verifier**: -```sh -npm --prefix src/frontend run test # vitest unit tests -npm --prefix src/frontend run typecheck -``` +| | | +|---|---| +| [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | The diagram: canisters, money path, trust boundaries, the two cycle pots | +| [`docs/DESIGN.md`](docs/DESIGN.md) | The decision record — *why* it is built this way. What the `§N` comments point at. Gate-enforced | +| [`docs/STRIPE.md`](docs/STRIPE.md) | The Card rail end to end, written from the code: ingress, signature verification, attribution, dedup, pricing, the order lifecycle, refunds | +| [`docs/TEST-COVERAGE.md`](docs/TEST-COVERAGE.md) | What is tested, how, and what is not | +| [`docs/VERIFY.md`](docs/VERIFY.md) | What anyone can check about the live deployment, what they cannot, and the measured state of the module-hash chain | -**3. PocketIC integration suite** (`test/integration`) — the **go-live bar** -(spec §9): end-to-end scenarios against the real ICP ledger, CMC, cycles -ledger, and ck-USDC ledger Wasms, with crafted HMAC-signed Stripe webhooks, -mocked forex outcalls, time control, and upgrade-mid-flight replay checks: +For an **operator**: -```sh -cd test/integration -npm ci -npm test # pretest fetches the sha256-pinned ledger wasm + builds the backend -``` +| | | +|---|---| +| [`docs/OPERATE.md`](docs/OPERATE.md) | Setup, one procedure per mode: local, mainnet simulation, mainnet production | +| [`RUNBOOK.md`](RUNBOOK.md) | Day-2 operations, entered by symptom: secret rotation, rate diagnosis, reserve sizing, obligation triage, monitoring | +| [`RELEASE.md`](RELEASE.md) | Cutting a release: build, publish hashes, install, gate | +| [`CHANGELOG.md`](CHANGELOG.md) | What changed per release | +| [`docs/SANDBOX-TESTPLAN.md`](docs/SANDBOX-TESTPLAN.md) | The manual Stripe-sandbox pass required before go-live, and what a green run does not prove | -Requirements: Node ≥ 20.11, `mops` on PATH, and a **4 KiB-page kernel** — -macOS or x86_64 Linux are fine, but the replica cannot run inside arm64 Linux -VMs with 16 KiB pages (e.g. Apple-Silicon Docker guests). See -`test/integration/README.md` for the full scenario map and the ready-made CI -job. +For an **agent changing the code**: [`AGENTS.md`](AGENTS.md) (conventions, skills, the verification +gate), and [`docs/DESIGN.md`](docs/DESIGN.md) above — it is the primary surface for that audience, along +with the invariant comments in `src/backend`. `docs/agents/` holds the loop's own +conventions: the triage labels, the issue-tracker rules and `deleted-vocabulary.md`. -## Release +Also: [`docs/DEMO-PLAYBOOK.md`](docs/DEMO-PLAYBOOK.md), the running order for demoing this to a technical +audience. -Releases are built reproducibly in a Docker-pinned toolchain and verified -against the on-chain module hash: +## Release -```sh -scripts/reproducible-build.sh -``` +[`RELEASE.md`, Cutting a release](RELEASE.md#cutting-a-release) is the procedure: build in +a digest-pinned container, publish the module hashes, install **that artifact**, and gate +on the canister reporting the hash that was built. [`CHANGELOG.md`](CHANGELOG.md) records what each release changed, and the +release script refuses a version that has no entry there. -See `RELEASE.md` for the full publish/verify procedure. +⚠️ **`backend.wasm` depends on the build architecture**, so each release states the one +it was built on and a verifier has to match it. `frontend.wasm` is the pinned recipe's +module and proves nothing about the page — [`docs/VERIFY.md`](docs/VERIFY.md) has the +check that does. diff --git a/RELEASE.md b/RELEASE.md index a8c4fdc..afb4be4 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -11,11 +11,11 @@ Every tool that shapes the module bytes is pinned by the *committed tree*: | What | Where pinned | |------|--------------| -| `moc` 1.9.0 | `mops.toml [toolchain]` | +| `moc` 1.16.0 | `mops.toml [toolchain]` | | Motoko dependencies (`core`, `sha2`, `ic`) | `mops.lock` | -| `@dfinity/motoko@v4.1.0` / `@dfinity/asset-canister@v2.2.1` recipes | `icp.yaml` (icp-cli rejects unpinned recipes) | -| `ic-mops` 2.13.2, `@icp-sdk/icp-cli` 0.3.2, `@icp-sdk/ic-wasm` 0.9.11 | `Dockerfile.release` | -| Node 22.22.1 (toolchain host + frontend build) | `Dockerfile.release` base image, by digest | +| `@dfinity/motoko@v5.1.0` / `@dfinity/static-site@v0.3.3` recipes | `icp.yaml` (icp-cli rejects unpinned recipes) | +| `icp` 1.5.0, `ic-wasm` 0.11.1, `mops` 3.2.2, Node 24.21.0 | `ghcr.io/dfinity/icp-dev-env-motoko:v2.2.1`, pinned **by digest** in `Dockerfile.release` | +| The pinned crypto submodule | the gitlink in the ref itself — `reproducible-build.sh` reads it with `git ls-tree `, never from the working tree, so a local checkout at a different commit cannot change the output | | Candid interface | `src/backend/dist/backend.did`, committed; the recipe embeds **this file** as the `candid:service` metadata, so the committed interface and the deployed one are the same bytes | The backend recipe runs `ic-wasm` shrink (deterministic optimize) and embeds @@ -24,70 +24,150 @@ metadata (`candid:service` public; `candid:args`, `motoko:stable-types`, **not** gzip-compressed: the on-chain module hash is the sha256 of the wasm file itself, so `sha256sum` and `icp canister status` are directly comparable. -## Building a release +⚠️ **Bumping the dev-env image is one line — the digest — and it CAN move every module +hash.** Whether it does depends on which tool moved: `ic-wasm` shrinks the module and +embeds its metadata, so that one changes the bytes, while `icp`, `mops` and Node have not. +Measured: v2.1.0 → v2.2.0 → v2.2.1 left `backend.wasm` identical, while the earlier +switch off the hand-pinned npm toolchain (`ic-wasm` 0.9.11 → 0.11.1) changed it. Either way the bump is a +release of its own with its own published hashes, never a passenger on unrelated work. Get +the digest with `docker buildx imagetools inspect ghcr.io/dfinity/icp-dev-env-motoko:vX.Y.Z` +and take the **index** digest, not a per-platform one, so `--platform` still selects the +right child. + +`scripts/release.sh` and `scripts/reproducible-build.sh` write `backend.wasm`, +`frontend.wasm`, `backend.did` and `MODULE-HASHES.txt` into `release/`, from +`git archive ` — the committed tree only, so local changes cannot reach a hash. +⚠️ `frontend.wasm` is written for completeness and is **not** a useful check: it is the +pinned recipe's pre-built canister, the same for every project that uses it. + +## Cutting a release + +Six steps, in this order. ```bash -scripts/reproducible-build.sh # default: HEAD -``` +# 1. CHANGELOG.md: rename "Unreleased" to the version, commit it +git tag -a vX.Y.Z -m "vX.Y.Z" && git push origin vX.Y.Z -This pipes `git archive ` — the committed tree only, local changes can -never leak into a hash — into `Dockerfile.release` and writes to `release/`: +scripts/release.sh vX.Y.Z # 2. build, print hashes +# (backend in the container; the frontend's state hash from a worktree at the tag) -- `backend.wasm`, `frontend.wasm` — the exact modules `icp deploy` installs -- `backend.did` — the committed interface, as embedded in the module -- `MODULE-HASHES.txt` — sha256 of all three +# 3. publish. release/NOTES.md is generated by step 2: the changelog section, a +# table of the hashes, the same hashes verbatim for `shasum -c` and diffing, +# the architecture they were built on, and how to reproduce them. +gh release create vX.Y.Z --prerelease --verify-tag -t vX.Y.Z \ + --notes-file release/NOTES.md -`scripts/release-build.sh` (the inner step) also runs directly on any host -with the same pinned toolchain; the container is the canonical environment. +scripts/release.sh vX.Y.Z --install -e ic --identity # 4. install + gate -## Cutting a release +icp deploy frontend -e ic && scripts/check-frontend-hash.py -e ic # 5. frontend -1. Tag: `git tag -a vX.Y.Z -m "..." && git push origin vX.Y.Z`. -2. `scripts/reproducible-build.sh vX.Y.Z` -3. Publish `release/MODULE-HASHES.txt` verbatim in the GitHub release notes. -4. Deploy: `icp deploy -e ic --mode upgrade` (named identity, never anonymous). -5. **Gate:** confirm the deployed module hash equals the published one — - ```bash - icp canister status backend -e ic - ``` - If the reported module hash differs from `MODULE-HASHES.txt`, the deploy - was built from drifted state; reinstall from a clean checkout of the tag. +# 6. update the Status line in docs/VERIFY.md — one sentence, naming this version +``` + +Step 4 rebuilds in the container, installs **that artifact** with +`icp canister install --wasm`, then reads the module hash back from the canister and +fails if it differs from what it built. + +⚠️ **Step 2 builds the frontend from the TAG, in a worktree, not from your checkout.** +The backend gets that from `git archive`; the frontend had no equivalent, and a +working-tree build would publish whatever happened to be checked out. Measured: `main` +one commit past `v0.1.0-beta.1` differs by HTML comments in `index.html`, which changes +the state hash — so the published number would have been one no verifier of that tag +could reproduce. Step 5 then confirms the canister reports the number step 2 published. + +⚠️ **Step 6 is the one thing no script can check.** `docs/VERIFY.md` tells a visitor +whether anything is published to compare against, and that sentence is false the moment +the first release exists. It is deliberately **one line** so updating it is cheap and +forgetting it is obvious — everything around it describes the check rather than the +status. + +⚠️ **Do not attach the wasm to the release.** A downloadable module invites installing +or trusting bytes instead of rebuilding them, which is the opposite of the point. Publish +the hashes; let a verifier produce the bytes themselves. + +⚠️ **The changelog comes first because the check reads the TAGGED tree.** +`scripts/release.sh` refuses a version whose `CHANGELOG.md` has no `## X.Y.Z` section, +and it looks inside the ref rather than the working copy — so the entry is part of the +commit the hash is published for and cannot be backfilled. Building `HEAD` or a bare +commit to inspect hashes needs no entry. + +⚠️ **The notes carry `MODULE-HASHES.txt` verbatim, not reformatted.** A verifier diffs +their own build's copy against the published one, and `shasum -c` reads that exact format +— so the table in the notes is for people and the code block is for tooling. The build +architecture travels with it, because a hash without it cannot be compared (see Caveats), +and the notes mark which hash is worth checking: matching `frontend.wasm` verifies the +recipe, not the page. + +⚠️ **Never `icp deploy` the backend.** It rebuilds on the host, so a container build +followed by `icp deploy` publishes one module and installs another — which is how a +deployment becomes unverifiable without anyone noticing. + +⚠️ **The gate is inside step 4 on purpose.** A verification that is a separate +instruction gets skipped, and a skipped verification is indistinguishable from a passing +one. The hash is read from the canister, never from the build. + +⚠️ **The frontend is checked by its STATE HASH, not its module hash.** The +`@dfinity/static-site` recipe installs a pre-built certified-assets wasm, so that hash +describes the recipe — identical for every project using it, and unrelated to the page +anyone is served. The content lives in canister state, put there by the sync plugin, so +`check-frontend-hash.py` compares the canister's `state_hash` against one computed from +`src/frontend/dist` by the verifier that release ships. That one value covers every +asset's bytes in every encoding, its `content_type` and response headers, and the +redirect rules in match order — so a drifted CSP fails it, which a per-asset content +comparison cannot see. ## Verifying a release (anyone) +No identity, no permissions. + ```bash -git clone && cd -scripts/reproducible-build.sh vX.Y.Z -icp canister status -n ic --public # works for non-controllers -``` +git clone && cd && git checkout vX.Y.Z -Compare the `Module hash` line against your locally built -`release/MODULE-HASHES.txt` and against the hash published in the release -notes. All three must agree. The module hash is also visible on the public -dashboard (`dashboard.internetcomputer.org/canister/`). +scripts/reproducible-build.sh vX.Y.Z # rebuild the backend in the container +icp canister status -n ic -p # read the deployed module hash -## Frontend verifiability +npm --prefix src/frontend ci && npm --prefix src/frontend run build +scripts/check-frontend-hash.py -e ic # compare the served state hash to that build +``` -The frontend **module** (`frontend.wasm`) is the asset-canister wasm bundled -with the pinned recipe — its hash is published and checked the same way. The -asset **content** is not part of the module hash; it is verified per-response -by certified assets: every HTTP response carries a subnet-signed certificate -over the asset tree, `allow_raw_access: false` (`.ic-assets.json5`) refuses -the uncertified raw domain, and the service-worker/gateway rejects responses -whose certificate doesn't verify. The asset build itself is reproducible -(vite + committed `package-lock.json`, node pinned by the container), so an -auditor can rebuild `src/frontend/dist` and compare files against what the -canister serves. +The frontend check builds its verifier from `dfinity/certified-assets` at the tag +`icp.yaml` pins, so it needs a Rust toolchain on the first run and caches the binary +under `.cache/` afterwards. + +⚠️ **`-n` for an id, `-e` for a name.** `icp` refuses a network flag when the canister is +named rather than addressed by principal — *"specify an environment instead"*. The status +line takes the backend's id, so it uses `-n ic`; the frontend check resolves the name +`frontend` from the committed `.icp/data/mappings/ic.ids.json`, so it uses `-e ic`. + +`release/MODULE-HASHES.txt`, the hash in the release notes and the canister's +`Module hash` must all agree — and so must the `# build arch:` line, since the same +commit gives different bytes on different platforms. The module hash is also on the +public dashboard (`dashboard.internetcomputer.org/canister/`). + +**What each half proves.** The backend hash ties the running module to a tagged commit. +The state hash ties the served page to that same tree — the frontend module hash +proves nothing, because it is the recipe's. Beyond that, every HTTP response carries +`IC-Certificate` and `IC-CertificateExpression` over the asset tree and the gateway +rejects responses whose certificate does not verify; there is no uncertified raw mode to +turn off, the way the legacy asset canister needed `allow_raw_access: false`. + +⚠️ **The recipe pin is load-bearing for verification.** The state hash is frozen per +certified-assets release: the compressor builds behind it, `MAX_CHUNK_SIZE`, the digest +layout, and the 404 and clean-URL rules the preparation synthesizes all change the +number. So bumping `@dfinity/static-site` in `icp.yaml` invalidates every hash published +before it, and a verifier needs the new tag. `check-frontend-hash.py` refuses to compare +unless the canister's own `version()` matches that pin, so a version-crossing comparison +fails loudly instead of looking like content drift. ## Caveats (stated, not hidden) -- The base image digest is a multi-arch manifest list: amd64 and arm64 hosts - pull different platform images. The toolchain's wasm output is - host-architecture-independent by design; container and native builds were - verified byte-identical on linux/arm64. If a build on another platform - produces a different hash, treat it as a toolchain bug and pin the platform - with `docker build --platform` while investigating. -- Recipe tags (`@dfinity/motoko@v4.1.0`) are fetched from +- ⚠️ **`backend.wasm` depends on the platform it is built on, so the platform is pinned + and published.** The same commit yields three different hashes on `darwin/arm64` + native, `linux/arm64` container and `linux/amd64` container. `reproducible-build.sh` + pins `--platform linux/amd64` (`RELEASE_PLATFORM=` overrides) and `MODULE-HASHES.txt` + records the architecture, so a hash is never compared across platforms by accident. + `frontend.wasm` and `backend.did` are identical on all three. +- Recipe tags (`@dfinity/motoko@v5.1.0`) are fetched from `dfinity/icp-cli-recipes` by git tag, which is not content-addressed. A moved tag cannot go unnoticed — it changes the hash — but it would break reproducibility of *old* tags. Accepted for v1. diff --git a/RUNBOOK.md b/RUNBOOK.md index d65809d..856185a 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -2,8 +2,31 @@ Day-2 operations for the cycles gateway: provisioning, money levers, error triage, incident response. Build/upgrade/verify procedure lives in -`RELEASE.md`; design rationale in `design-docs/ONCHAIN_GATEWAY_SPEC.md` -(spec v2.1 — section references below are to it). +`RELEASE.md`; first-time setup for all three modes in `docs/OPERATE.md`. + +**`§N` always means a `docs/DESIGN.md` section** (spec v2.1) — that is what the `§N` +comments in the code point at too. This file's own sections are referred to **by name**, +because a bare `§5a` meant two different things depending on which file you were reading +and `check-design-sections.py` read one of them as the other. + +## Enter here: what you are looking at + +This file is read under pressure, by lookup. Find the symptom, not the section number. + +| what you are seeing | go to | +|---|---| +| the rail refuses every purchase, `refusingNow.railClosed` | **3. Presets, the API key** — the rail opens when both secrets are provisioned | +| `refusingNow.reserveShort`, or `availableToSell` is 0 while the ledger holds cycles | **5. The cycles reserve** — a floor that only rises by observation | +| `refusingNow.stripeApiFailing` latched | **8. Monitoring** P1 rows — one lever: rotate the key | +| an order sits at `created` past its own `expiresAtNs` | **5b. Order expiry** — the missed-event case, and why nothing sweeps it | +| buyers report that cancelling does nothing | **8. Monitoring** P2 — run `expire_order` once, read `order.expireRaced` | +| a buyer paid and was never credited | **6. Obligations** — the `#paidNotCredited` row. Resend first, always | +| `pricing_status.lastAttempt.ok` is false, or a quote returns no cycles | **4. Pricing rates** — the staleness window is a security control | +| a delivery is stuck, delayed, or you must establish where the money is | **6. Obligations** and **7. Recovery timer** | +| you suspect the webhook secret leaked | **2. Webhook secret** — rotate, and know what you cannot do | +| an unexpected principal can or cannot buy | **5a. Admission gate** | +| you are about to upgrade the canister | **10. Upgrades & releases**, then `RELEASE.md` | +| you are setting a deployment up for the first time | not here — `docs/OPERATE.md` | ## 0. Operating model @@ -31,66 +54,76 @@ controller identity — never anonymous): icp canister call backend '()' -e ic --identity ``` -Public queries (`treasury_status`, `forex_status`, `recovery_status`, -`card_tiers`, `ck_usdc_config`, `health`) work from any identity and are the -monitoring surface (§9 transparency stance — operational state is public, +**Always pass an explicit `'()'` for zero-argument methods.** Omitting the +argument makes `icp canister call` ask *"Do you want to send this message? +[y/N]"* and read stdin — which hangs any script, cron job, or CI step. + +**For reads, open the console first.** Every read-only command in this +runbook is also a panel at `#/admin`, so a triage that used to be a sequence of +typed calls is now one screen. The `Admin` header link appears for a controller +or a granted admin and for nobody else. + +| panel | what it shows | +|---|---| +| **Now** (default) | the summary split into what needs a person and what clears itself, the reserve, and refusals since deploy | +| **Worklists** | orphans · problems · delayed · pending, as sortable tables | +| **Orders** | order history, paged · look up one order by id | +| **Diagnostics** | `health` · queue depths · the recovery sweep with its count drift · the audit trail, newest first and paged | +| **Configuration** | the config groups, the write commands, and this browser's identity | + +**The CLI is still the answer for four things**, and the panels do not replace +them: anything that **writes** (the console prints the command for you to run, +it does not send it); reading when the **frontend is down or not yet deployed**, +which is most of `docs/OPERATE.md`; a **scripted or monitored** read, where the public queries +below are the interface; and `-e ic` **before** the frontend canister exists. + +**Opening the console spends no audit entries.** `admin_order`, +`admin_receipt` and `delivery_journal` are updates precisely so the read is +recorded, so no panel calls them on open — the Orders lookup is explicit, +and one deliberate lookup is one audited read. + +Public queries (`reserve_status`, `pricing_status`, `recovery_status`, +`card_tiers`, `lifecycle_config`, `can_purchase`, `cycles_status`, +`orphan_depth`, `health`) work from any identity and are the monitoring surface (§8's verifiability stance — operational state is public, the webhook secret is the only secret in the system). -**Units used throughout:** ICP amounts are e8s (1 ICP = 10⁸ e8s); ck-USDC -amounts are units (1 USDC = 10⁶ units, so 1¢ = 10⁴ units); durations are +**Units used throughout:** money is US cents (`usdCents`); durations are nanoseconds (1 h = `3_600_000_000_000`, 24 h = `86_400_000_000_000`, 72 h = `259_200_000_000_000`); cycle prices are XDR-pegged (1 XDR = 1 T cycles). -## 1. Go-live checklist (fresh deployment) - -Everything money-touching **fails closed by default** — a freshly deployed -gateway accepts no orders and mints nothing until each lever below is -consciously set. Work the list in order: - -1. **Deploy + verify** per `RELEASE.md` (module hash gate). -2. **Fund the ICP float**: send ICP to the backend's default ICRC-1 account - (`owner = `, no subaccount). Confirm with - `refresh_float` (returns the observed e8s). -3. **Provision the webhook secret** (§2 below). Until set, the webhook - route answers 503 and Stripe retries. -4. **Register card tiers** (§3 below). Until set, the tier list is empty - and no card order can be created. -5. **Size the burn cap** (§5 below). Until raised from the default 0, - every mint holds in `awaitingTreasury` — cap 0 is the pause lever. -6. **Arm the low-float alert**: include a non-zero `lowFloatThresholdE8s` - in the same `set_treasury_config` call. -7. **(Optional) enable the ck-USDC rail** (§7 below). Default - `maxUsdCents = 0` keeps it disabled. -8. **Configure the Stripe webhook endpoint**: in the Stripe Dashboard, add - a webhook destination `https://.icp0.io/webhook/stripe` - sending exactly the events `checkout.session.completed` and - `charge.refunded`. (Other event types are acked and ignored.) -9. **Smoke-check the public surface**: `forex_status` (rate populates on - the first order attempt — creation before the first successful refresh - correctly answers `rateUnavailable`), `treasury_status`, - `recovery_status` (sweep timer armed), `card_tiers`. - ## 2. Webhook secret — provisioning & rotation (§7) -The Stripe signing secret is the **only stored secret**: HMAC is symmetric, -so verify = forge — anyone holding it can mint cycles at operator expense -until the burn cap stops them. It is stored **plaintext by design** -(`Secret.mo` documents the SEV-SNP posture; §10 below is the checklist). +HMAC is symmetric, so verify = forge — anyone holding this secret can forge "paid" +webhooks and drain **the entire reserve**, one order at a time, at the operator's expense. +**The reserve balance is the blast-radius bound, so size it to what you can afford to +lose in one window** — there is no per-period cap standing behind it. It is stored +**plaintext by design** (`Secret.mo` documents the SEV-SNP posture; the confidential-subnet +checklist below is the checklist). -**Provision / rotate:** +**Provision / rotate — the value is sealed, never typed into a call:** ```bash -icp canister call backend set_webhook_secret '("whsec_…")' -e ic --identity -icp canister call backend webhook_secret_status -e ic --identity +# Reads STRIPE_WEBHOOK_SECRET from the environment (or scripts/.local-dev.env). +STRIPE_WEBHOOK_SECRET='whsec_…' scripts/seal-secret.sh webhook-secret ic ``` -- Pass the **full `whsec_…` string** from the Stripe Dashboard — the whole - string, prefix included, is the HMAC key (matches Stripe's reference - verifiers). -- `set` rejects anything under 16 bytes (`#tooShort`) and leaves the - working secret untouched on rejection — a fat-fingered rotation can't - brick the webhook. +⚠️ **`set_webhook_secret` takes a `blob`, not the string** (§7.3). It is an IBE ciphertext +sealed to this canister's vetKD public key, so the plaintext never becomes an ingress +argument. Calling it by hand with a quoted `whsec_…` returns `#notCiphertext`. + +⚠️ **The trailing `ic` is what selects the MAINNET master key, and it is not optional.** +Mainnet and a local network both have a vetKD key called `key_1` backed by *different* +master keys, so the name does not identify the key. Omit the argument and the script +defaults to `local` → the PocketIC master key → a ciphertext this canister can never +open, reported as `#notSealedToThisCanister`. The script derives the choice from that one +argument precisely so it is never a separate flag to get wrong. + +- Pass the **full `whsec_…` string** in the environment variable — the whole string, + prefix included, is the HMAC key (matches Stripe's reference verifiers). +- The 16-byte floor applies to the **decrypted** value (`#tooShort`), and the working + secret is left untouched on any rejection — a fat-fingered rotation, a wrong master key + or a plaintext argument all leave the webhook working. - `webhook_secret_status` returns `{isSet; generation; setAtNs}` — `generation` increments per successful set, so ops can confirm a rotation landed **without any read-back path existing** (not even for @@ -102,254 +135,1058 @@ icp canister call backend webhook_secret_status -e ic --identity window. During overlap Stripe signs each delivery with **one `v1=` per active secret**, and the canister's verifier accepts *any* matching `v1` — so order of operations is forgiving. -2. `set_webhook_secret` with the new `whsec_…`; confirm `generation` - bumped. +2. `scripts/seal-secret.sh webhook-secret ic` with the new `whsec_…` in the + environment; confirm `generation` bumped. 3. Expire the old secret in Stripe after confirming deliveries succeed. -**Provisioning exposure** (§7): the argument transits the TLS-terminating -boundary node as ordinary ingress. Provision from a trusted network path, -and treat the first secret set over any untrusted path as burned — rotate -it once the endpoint is confirmed working. +**Provisioning exposure — closed** (§7.3). This used to read: *the argument transits the +TLS-terminating boundary node as ordinary ingress, so treat the first secret set over any +untrusted path as burned.* That is no longer true, and the advice is withdrawn rather than +softened: the ingress argument is ciphertext, useless to the boundary node and to anything +reading a shell history or a CI log. Sealing does nothing for the **at-rest** exposure, +which is the confidential-subnet checklist below. **Suspected leak — immediate actions** (in this order): -1. Pause minting: `set_treasury_config` with `burnCapE8s = 0` (§5). The - cap is the blast-radius bound — this stops the drain even while forged - webhooks keep arriving. +1. **Know what you can and cannot do.** A forged webhook drains at most what the + reserve holds — that balance **is** the blast radius, and there is no cap or pause + lever standing behind it. + + ⚠️ **`withdraw_reserve` exists but it will REFUSE during an incident, and + that is by design.** It is guarded on there being no promise-holder at all, and a + forged drain means forged orders are open — so the guard fires. Do not plan an + incident around a one-call evacuation; there isn't one. + + What you *can* do immediately is stop **new** orders while you roll the secret: the + rail is live only while both Stripe secrets are provisioned, so rotating the webhook + secret (step 2) closes it until the new one is set. Forged orders already in flight + will deliver. + + **The evacuation path is three steps, and step 2 cannot be forced:** + + 1. Rotate the webhook secret (step 2 below). This closes the rail to new orders. + 2. Clear every promise-holder. `reserve_status.promiseHolders` is the count, and it + must reach **zero**. + 3. `withdraw_reserve`, which now passes its guard. + + ⚠️ **Step 2 is a wait, not a lever, for anything with a delivery in flight.** + `abandon_order` refuses a paid order whose delivery is outstanding — *"whether its + cycles moved is not yet known — abandoning it now would refund a buyer who may + already hold them"* — so a forged order that is already being delivered cannot be + cleared on demand. It has to settle, or reach `needsReview` at the ~24 h dedup + window where the ledger is the source of truth. `process_order` re-drives a stalled + delivery; `pending_deliveries` is the live view. + + ⚠️ **There is deliberately no lever that releases a promise over an unresolved + delivery**, and that is the same property the withdraw guard has: the reserve does + not move while any buyer's fate is unknown. Plan the incident around this, not + around a one-call evacuation. + + Until `promiseHolders` reads zero the reserve stays where it is. + + The standing control is therefore still a sizing decision made **before** an + incident: keep the reserve at what you are willing to lose between detection and + rotation. `reserve_status.availableToSell` is that figure. 2. Roll the secret in Stripe + `set_webhook_secret` (steps above). 3. Reconcile: compare `audit_log` / order store against the Stripe Dashboard's event log; forged "payments" have no matching Stripe payment_intent. Refund nothing that has no real charge. -4. `reset_burn_window`, restore the sized cap, and let held legitimate - orders resume on the next sweep. +4. Refund the reserve to its sized level with `icp cycles transfer`, then + `refresh_reserve` so the gateway observes it. ⚠️ **There is no "resume held + orders" step** — an order refused for `#reserveShort` was never created, so + nothing is waiting to be released. Legitimate buyers retry and succeed. + + ⚠️ **The order of steps 2–4 is itself the control, so do not renumber them.** + Refunding the reserve before the secret is rolled hands the attacker a freshly + funded account and the capability to drain it again — the reserve's usefulness as + a bound depends entirely on cycles re-entering the account *after* the forgery + capability is dead. Rotate, then reconcile, then refund. + +## 3. Presets, the API key, and the settings that must stay off (§3, §6.1) -## 3. Card tiers & Payment Links (§3, §6.1) +The canister creates a **Checkout Session per order** through the Stripe API, +with inline `price_data`. There are no Products, no Prices, no Payment Links and +no Dashboard objects to create — which is why this section is four commands +rather than a click path through three screens. -The canister never calls Stripe. Controllers create **permanent Payment -Links** in the Stripe Dashboard (one per price point, USD) and register -them: +### Provisioning, in order ```bash +# 1. The API key. RESTRICTED (rk_). Permission: Checkout Sessions = WRITE, everything +# else None. Write is the level that also grants read, and the recovery sweep needs +# the read: it retrieves a session to settle an order whose expiry event never +# arrived. A key without it 401s on every retrieve and stranded capacity is +# never released — watch for refusingNow.stripeApiFailing (monitoring). +# +# SEALED (§7.3): the key is encrypted to this canister before it is sent, so it never +# appears in an ingress message, a shell history or a CI log. The trailing `ic` selects +# the MAINNET master key — omitting it seals against PocketIC's and the canister will +# refuse with #notSealedToThisCanister. +STRIPE_API_KEY='rk_...' scripts/seal-secret.sh api-key ic + +# 2. Where Stripe returns the buyer. Validated: https, no query, no fragment. +# Not a secret — it is the URL buyers are sent to — so it is set directly. +icp canister call backend set_stripe_origin '("https://")' -e ic --identity + +# 3. The webhook signing secret (DESIGN §7). Sealed the same way; rotation is +# the webhook-secret section above. +STRIPE_WEBHOOK_SECRET='whsec_...' scripts/seal-secret.sh webhook-secret ic + +# 4. The price tiles. ⚠️ REQUIRED for a usable page, whatever the canister accepts: +# with an empty list the buy view renders no tiles AND no custom field, because +# `renderTiers` returns early and the custom tile is built after that return. See `docs/OPERATE.md` +# step 4. Do not register a $100 preset; that is the ceiling and the custom field's job. icp canister call backend set_card_tiers \ - '(vec { record { id = "t10"; usdCents = 1_000; paymentLinkUrl = "https://buy.stripe.com/…" } })' \ + '(vec { record { id = "t10"; usdCents = 1_000 : nat } })' \ + -e ic --identity +``` + +⚠️ **Use a restricted key, not an `sk_`.** A leaked write-sessions key can create +sessions that pay *you*; one that can issue refunds is a materially worse thing +to leak. Stripe's IP and ASN allowlists are unusable here — a subnet's replicas +have many changing addresses. + +⚠️ **Neither secret can be read back out, even by a controller.** `stripe_api_key_status` +and `webhook_secret_status` report a generation counter and a set timestamp, which is +how you confirm a rotation landed without ever exposing the value. `seal-secret.sh` prints +the relevant status after each successful set. + +⚠️ **Put the values in `scripts/.local-dev.env` or export them — never on the command +line.** `seal-secret.sh` reads them from the environment on purpose. A secret typed as an +argument lands in shell history, in `ps` output and in CI logs, which would reopen the +exposure sealing exists to close. The examples above show the variable inline for brevity; +in a real session, export it or use the file. + +**Provisioning the two secrets is what OPENS the rail** (§5b of `docs/STRIPE.md` +for why capability rather than declaration), so do them last. Rotating either +closes the rail until both are valid again — which is a deliberate ordering +property, not an outage: no API key means no payable session, no webhook secret +means a buyer can pay and cannot be credited. + +⚠️ **Changing the origin later is a user-visible migration, not a config tweak**: +Internet Identity derives a principal *per origin*, so existing buyers get new +principals and cannot see their old orders. + +**Record the Stripe API version the account is on, and treat changing it as a +code change.** Webhook payload shapes follow the account default, so an +account-level upgrade silently changes what `Json.mo` parses — a class of +breakage no test here can see, because the fixtures were captured under the old +version. + +### Tier registration + +Validation is atomic — non-empty unique ids, non-zero amounts, every amount +within `[minPurchaseUsdCents, maxPurchaseUsdCents]` (the admission-gate section), or the whole call +rejects and the live tier list is untouched. `card_tiers` is the public query the +frontend renders. + +Note the §3 invariant: a tier's *cycle* quantity is locked per-order at creation +time from the cached rate pair, so changing tier prices never reprices existing +orders. The paid amount must **equal** the quoted one — an order +delivers what it locked, or it delivers nothing and files a refund obligation. + +### The settings that must stay off + +The whole model rests on one invariant: **the session's `amount_total` equals the +order's `usdCents`.** The canister reads `data.object.amount_total`, which Stripe +defines as the total *after discounts and taxes*, and refuses anything else. + +Per-order sessions removed most of the ways that can break: the canister sends +`price_data` inline, `payment_method_types[]=card`, no promo codes, no adjustable +quantity, and `adaptive_pricing[enabled]=false` explicitly. **The authoritative +list of settings that would move the total is in the code, next to +`Session.createBody`** — that is where someone adding a Stripe feature will see +it. Two things remain account-level and are therefore yours to keep off: + +| Setting | Must be | If enabled | +|---|---|---| +| **Automatic tax** (account default) | **off** | raises `amount_total`; the payment is refused as a mismatch, so nothing is delivered — but every order fails until it is turned off | +| **Adaptive pricing** (Dashboard toggle) | pinned off by the request | currently harmless to `amount_total` for this shape; the request pins it anyway, and it is proof Stripe adds Dashboard-side amount changers over time | + +Plus the two already in `docs/OPERATE.md`: **USD** (any other currency is refused as +`#unattributed`, a refund obligation) and **card-only** +(delayed methods are handled, but they make money-in asynchronous). + +**A mismatch is not silent.** A gateway that repriced from the order's own snapshot +would deliver a different quantity with the audit log showing an ordinary completed +purchase and no alert anywhere. This one delivers nothing and files an `#unattributed` +whose detail names both figures — so an amount-moving setting shows up as a queue entry +on the first order, not as a slow drift in what buyers receive. + +⚠️ **Test-mode and live-mode keys are different objects.** Going live means a +live-mode restricted key and a live-mode webhook secret, both re-provisioned +against the mainnet canister. Once `set_expected_livemode '(opt true)'` is set, a +stray test-mode event is refused and tagged `stripe.livemodeMismatch` (the monitoring section alerts +on it), so this fails closed: the symptom of getting it wrong is that nobody can +buy anything, not lost money. + +### What the app does with no presets + +**An empty preset list is NOT the rail's off switch.** With custom amounts it stops +nothing: a buyer can order any amount between the floor and the ceiling without a +preset, so an empty list means only that no tiles are shown. `create_order` still answers +`#unknownTier` for a `#tier` id that is not registered. + +**The switch is both Stripe secrets being provisioned**, which is derived from +capability rather than declared: no API key means no payable session, no webhook +secret means a buyer can pay and cannot be credited. Neither state can complete a +purchase, so neither accepts one. `railsLive` is where that lives, and it also +gates the rate-refresh timer — so a gateway with presets and no API key no longer +pays for XRC calls it cannot use. + +To take the rail down deliberately, there is no lever short of rotating a secret to +a value Stripe rejects. That is a gap worth naming rather than working around; +`can_purchase` and the distinguishable `#sessionUnavailable` refusal are what an +operator has instead. + +## 4. Pricing rates (§3.1) + +Two rates, both read from on-chain canisters on the same timer tick — the +**XRC** (`uf6dk-hyaaa-aaaaq-qaaaq-cai`) for USD/ICP and the **CMC** for +XDR/ICP. There is no HTTPS outcall and no settable rate source. + +```bash +icp canister call backend pricing_status '()' -e ic # public: both rates, config, last refresh +icp canister call backend quote_previews '(vec { 500 : nat })' -e ic # public: what an amount buys +icp canister call backend refresh_rates '()' -e ic --identity # force a tick now +icp canister call backend set_pricing_config \ + '(record { feeBps = 290 : nat; feeFixedCents = 30 : nat; maxAgeNs = 300_000_000_000 : nat; maxRateDeltaBps = 5_000 : nat; minRateSources = 2 : nat })' \ -e ic --identity ``` -Validation is atomic — non-empty unique ids, non-zero amounts, or the whole -call rejects and the live tier list is untouched. `card_tiers` is the public -query the frontend renders. Setting an **empty vector disables card order -creation** (the rail-level pause lever; in-flight orders are unaffected). +Defaults: 290 bps + 30¢ (Stripe's fee, recovered net-of-fees per §3), a **5-min** +staleness window, a 50% delta bound, and a 2-source minimum. + +`quote_previews` is the fastest "is the rail actually quoting?" check — it runs +the same pricing code `create_order` runs, so a `cycles = null` there is exactly +what a buyer would hit. `pricing_status` is the one command to run first for +*why*. `rates` carries both values +plus `fetchedAtNs` and the XRC `quality` (received/queried source counts and the +spread); `lastAttempt` carries `{atNs; ok; detail}` — **`detail` names the +rejecting guard**, which is what tells you whether the XRC answered at all. +Setting the config re-arms the refresh timer immediately, since the cadence is +derived from `maxAgeNs`. + +### `maxAgeNs` is a security control, not a tuning knob + +Validation **caps it at 1 h** (`#maxAgeTooLong`). Timers are deactivated by any +Wasm change, and the only thing that makes a dead timer safe is that a stale +cache **refuses to price**. A long window would let orders be quoted +indefinitely off a frozen rate. Widen it to ride out an outage only with that +trade understood — and prefer letting order creation fail closed. -Note the §3 invariant: a tier's *cycle* quantity is locked per-order at -creation time from the live forex rate; changing tier prices never reprices -existing orders. The actual paid amount is honored — a Stripe-side price -edit mid-flight reprices from the order's own creation-time snapshot, never -from a fresh rate. +### Diagnosing a stale rate -## 4. Forex (§3.1) +`create_order` never refreshes; it reads the cache and fails closed. So +persistent `rateUnavailable` is always one of: + +| `lastAttempt.detail` | Meaning | Action | +|---|---|---| +| `NotEnoughCycles` | fewer than 1 B cycles could be attached | check `cycles_status`; top up. `Gate.minCanisterCycles` must stay well above 1 B or pricing stops before the gate does | +| `RateLimited` | XRC throttling | wait; backoff already widens the interval | +| `Pending` | XRC is still collecting | resolves on its own; alert only if it persists across ticks | +| `InconsistentRatesReceived` | XRC's sources disagree beyond its own tolerance | wait it out. Never work around it | +| `CryptoBaseAssetNotFound` / `StablecoinRate*` | XRC cannot price ICP/USD right now | wait; nothing local to fix | +| `too few sources` | fewer than `minRateSources` answered | a thin market. The 2-source minimum exists because the XRC's own `InconsistentRatesReceived` **cannot fire for a single source** — do not lower it to 1 | +| `implausible rate` | outside $0.10–$10,000/ICP | a bad upstream print. Rejected as if down | +| `delta` | moved more than `maxRateDeltaBps` since the last good value | a genuine 50%+ move needs `maxRateDeltaBps` raised **once**, deliberately; otherwise it is source disagreement | +| `implied XDR/USD` | `P × 10⁸ / U` fell outside 0.5–1.2 | the two sources disagree about reality. This is the cross-check that stops us trusting the XRC alone; XDR/USD has sat in ~0.6–0.9 for decades | +| `cmc stale` | CMC rate older than 15 min | check the CMC; nothing local to fix | + +A rejected refresh **keeps the previous rate serving** until it goes stale, so a +single bad tick is invisible to buyers. The plausibility band and the implied +cross-check are not configurable. + +**A rate outage never strands a paid order.** Fulfilment uses the quantity locked +at creation, and money-out reads **no rate at all** — it transfers a +figure fixed when the order was created. An outage means *no new orders*, never a +stuck buyer, and there is no longer a rate-move-mid-delivery exposure to bound. + +## 5. The cycles reserve + +### Funding the reserve + +⚠️ **On a gateway that accepts Stripe TEST payments, populate the buyer allow-list +BEFORE you fund the reserve**. Test payments are free and unlimited, so test mode +plus an empty allow-list plus a funded reserve is a cycles faucet, and the gateway +refuses every buyer in that state rather than giving cycles away +(`refusal_counts.refusingNow.unboundedGiveaway`). Funding first is not dangerous — it +is simply the order that produces a deployment that refuses to sell until someone +notices why. ```bash -icp canister call backend forex_status -e ic # public: cached rate + config -icp canister call backend set_forex_config \ - '(record { url = "https://open.er-api.com/v6/latest/USD"; feeBps = 290; feeFixedCents = 30; maxAgeNs = 3_600_000_000_000 })' \ - -e ic --identity +# Who may buy while test payments are accepted. Controller only. +icp canister call backend add_allowed_buyer '(principal "")' +icp canister call backend allowed_buyers '()' +``` + +An **unfunded** reserve refuses every order at `Gate.solvent` anyway, before a Stripe +session is even created — so a sandbox deployment is safe to explore before either the +allow-list or the reserve exists. Only paying needs both. + +⚠️ **`icp` defaults to the LOCAL environment**, so every command in this section needs +`-n ic` (the cycles ledger) or `-e ic` (a canister) to act on mainnet. Omitted, they +succeed against a local network and report a balance that has nothing to do with the one +being funded. + +```bash +# The reserve IS the gateway's own cycles-ledger account. +icp cycles transfer 100t # -n ic on mainnet + +# ⚠️ REQUIRED after every top-up. Without it the balance is real and unsellable. +icp canister call backend refresh_reserve '()' + +# What the gateway will actually sell: floor - promised = availableToSell. +icp canister call backend reserve_status '()' + +# Read the truth from the ledger — anyone can, including the frontend. +icp canister call um5iw-rqaaa-aaaaq-qaaba-cai icrc1_balance_of \ + '(record { owner = principal ""; subaccount = null })' ``` -Defaults: `open.er-api.com` (keyless — a keyed API would be a second -node-provider-readable secret), 290 bps + 30¢ (Stripe's fee, recovered -net-of-fees per §3), 1 h staleness window. Validation: `feeBps < 10_000`, -positive window, https-only. - -**Failure mode is fail-closed and self-healing**: a stale cache triggers a -lazy refresh on the next `create_order`; if the refresh fails (or another -is already in flight), creation answers `rateUnavailable` and **no money -moved** — there is nothing to clean up. Persistent `rateUnavailable` means -the rate source is down or blocked: check `forex_status.rate` timestamp, -try the URL yourself, and point `url` at another USD-base source whose JSON -carries an `"XDR"` key if the outage persists. The plausibility band -(0.1–10 XDR/USD) and coarse rounding are not configurable — a source -serving implausible values is rejected as if down. - -## 5. Treasury: float, burn cap, holds (§5.3) - -**Status (public):** `treasury_status` returns -`{config; burnedInWindowE8s; lastObservedFloat; lowFloat; heldOrders}`. -The float observation is as-of `atNs` (it refreshes as a side effect of -every mint pre-gate); `refresh_float` (admin) forces a fresh ledger read. - -**Float refill** is a plain ICP transfer to the backend's default account — -no method call needed; the next pre-gate or `refresh_float` observes it, -and held orders resume on the next sweep (≤ 1 sweep interval) or -immediately via `process_order`. - -**Burn cap** — the §7 blast-radius bound. Per rolling window, the gateway -refuses to *start* mints once the window's started-mint total would exceed -`burnCapE8s`. Consumption commits before the ledger call and is **never -refunded on failure** (over-counting pauses early — the fail-safe -direction), and the cap is checked **before** float sufficiency (a -leaked-secret drain has a full float). +⚠️ **`reserveFloor` is a maintained lower bound, not the balance**. +Solvency is decided synchronously against it, so admission needs no ledger call — +which also means the floor only learns about incoming cycles by looking. It rises on +`refresh_reserve` and on the hourly sweep; it falls when the gateway itself transfers +out. **The ledger reading 100 T while `availableToSell` reads 0 is the expected +appearance of a top-up nobody observed**, and `reserveObservedAtNs` is how you tell +that from a genuinely spent reserve. + +An observation is adopted only across a **quiet window** — no delivery in flight — +so a reconcile during a busy sweep is *skipped*, audited as +`reserve.reconcileSkipped`, and retried. That is a delay, never a loss: a stale floor +under-sells and can never over-sell. + +Delivery is one `icrc1_transfer` out of that account, and the buyer receives +`lockedCycles − fee`, where the fee is the **stored** one (the ledger +reports its own fee on `#BadFee`, so the copy self-corrects and delivery needs no +`icrc1_fee` round trip). **Nothing writes that stored fee but the ledger itself.** +An admin lever for it existed briefly and was deleted as self-justifying: the only +state it fixed was one it could create, and its own typo silently shorted buyers. If +the ledger's fee ever exceeds an order's locked quantity, delivery stalls loudly on +`delivery.feeExceedsOrder` and the answer is a redeploy — at that fee the rail cannot +sell anyway. The ledger charges its fee **on top of** the amount, +so a delivery moves the reserve by exactly `lockedCycles` — which is why the +promise tally has no separate fee term. + +⚠️ **Nothing creates cycles here.** Refills are `icp cycles transfer` from outside. + +**`withdraw_reserve` exists because a funded mainnet reserve is real money in a ledger +account**, and without the lever there is nothing to retrieve it. *"An over-funded +reserve costs nothing"* holds on a local network and nowhere else. + +It is controller-only and refused while **any** promise-holder exists, so nothing can +be owed to a buyer when it runs. It grants a controller no capability they lack: a +controller can already move the reserve by upgrading the canister. **A +decommissioning lever, not an incident one** — see the three-step evacuation in the +suspected-leak section above. + +⚠️ **THREE balances, and confusing them is the most common local-setup failure.** +The reserve is not special — it is just the backend canister's account on the cycles +ledger, funded by a plain transfer to the canister's own principal. + +| balance | what it is | read it with | fund it with | +|---|---|---|---| +| **gas** | what the canister spends to *run*, gated by `minCanisterCycles` | `icp canister status backend` | `icp canister top-up` | +| **the reserve** (stock) | what the canister *sells* — its cycles-ledger account | `reserve_status`, or `icp cycles balance --of-principal ` | `icp cycles transfer ` | +| **your own account** | what funds both | `icp cycles balance` | mint from ICP | + +An unfunded reserve looks like orders that pay and then never deliver. + +⚠️ **A failed top-up reports the SENDER's balance, under a message about the reserve.** +`icp cycles transfer` answers *"insufficient funds. balance: N"* where `N` is **your** +cycles-ledger balance — not the reserve's. The two can differ by orders of magnitude +(measured during a reinstall: reserve 675 T, sender 53 T), so reading `N` as the reserve's +sends you to fund something that is already full. + +⚠️ **The reserve survives a canister reinstall; the floor does not.** The account belongs +to the canister's principal and the cycles ledger is a different canister, so a +`--mode reinstall` leaves the stock intact and resets `reserveFloor` to 0. The fix is +`refresh_reserve`, not a transfer. + +## 5a. Admission gate: who is allowed to start an order + +Order creation is refused before any quote when fulfilment is already +impossible. This is separate from, and in addition to, the solvency check in +the reserve section — the point is to refuse *before* the customer pays Stripe. ```bash -icp canister call backend set_treasury_config \ - '(record { burnCapE8s = 50_000_000_000; burnWindowNs = 86_400_000_000_000; maxHoldNs = 259_200_000_000_000; lowFloatThresholdE8s = 20_000_000_000 })' \ +icp canister call backend lifecycle_config '()' -e ic # public: gate AND delivery bounds +icp canister call backend can_purchase '(500 : nat)' -e ic # public: would this be admitted? +# ⚠️ Read the CURRENT config and change one field. The record is whole-value: every +# field you type replaces the live one, so a pasted example silently re-bases the +# levers you did not mean to touch. This example restates the defaults below. +icp canister call backend set_gate_config \ + '(record { maxOpenOrdersPerPrincipal = 1 : nat; minCanisterCycles = 5_000_000_000_000 : nat; + maxPurchaseUsdCents = 10_000 : nat; minPurchaseUsdCents = 1_000 : nat })' \ -e ic --identity ``` -**Sizing guidance**: the cap is the most ICP a fully-leaked webhook secret -can drain per window before you notice. Set it to expected legitimate -volume per window plus modest headroom — a too-small cap merely *delays* -real orders (they hold, then resume when the window rolls); a too-large -cap is unbounded loss. Start tight. +| Lever | Default | What it protects | Sizing | +|---|---|---|---| +| `maxOpenOrdersPerPrincipal` | **1** | Unbounded state growth. Abandoned orders are the only thing a user can create for free, so this is the real bound. Nothing sweeps them away (the order-expiry section): a slot frees when Stripe expires the session, when the buyer cancels, or via `expire_order`. | **1 is a product choice and it is felt.** A buyer who abandons a checkout cannot start another until that session expires (~35 min) — including whoever is demoing this. Raise it for power users; must be > 0, and 0 is rejected as config. | +| `minCanisterCycles` | 5 T | **This canister's own gas.** Below it the gate stops admitting NEW orders. It does not gate delivery, cancellation or the webhook, so a paid order is still delivered below the floor. | Sized against a gas **drain**, not against freezing: freezing is ~149x further down (~34 B, 30 days of idle burn), so at 5 T sales close with over a year of runway in hand. It is the only bound on order flooding from rotating principals, and on a revoked Stripe key retrying its session outcall at ~220 M a try. Lowering it toward the freezing threshold removes that bound. `0` disables the check. | +| `maxPurchaseUsdCents` | **10 000 (\$100)** | Operator typo in a tier, and the webhook's upward repricing path. **It IS the per-order reserve exposure** — the main lever against reserve griefing. | Set just above your largest tier. `set_card_tiers` rejects any tier above it, and the webhook refuses to deliver against a payment above it. | +| `minPurchaseUsdCents` | **1 000 (\$10)** | A purchase too small to be worth an outcall and a reserve hold — and one that does not buy what a buyer came for. | Two independent floors hold it at \$10: the 30¢ fixed fee is 9.0% of \$5 against 5.9% of \$10, and \$5 buys 3.313 T against the **4.0 T** two default-funded canisters need, so it fails on the second one. `docs/BUYER-COST-MODEL.md` carries the model, and `test/buyer-cost.test.mo` pins it. | + +**All four deliberately default to non-zero**, unlike the tier list. A limit where 0 +would brick the canister rather than protect it has to ship armed. **The tier list is +no longer the rail's on/off switch** — that is "both Stripe secrets +provisioned", and an empty tier list stops no purchase the canister can see (the presets-and-keys section). + +**This table's Default column is pinned to the code.** It was wrong in two of four +rows for long enough that the `set_gate_config` example above pasted a \$1 000 ceiling +and a cap of 20 — an operator following `docs/OPERATE.md`'s Mode 3 step 9 to "review the admission gate" would +have re-based the exposure this ceiling exists to bound. `test/gate.test.mo` now fails +with the lever that moved, beside the example it has to match. + +`can_purchase` returns the same decision `create_order` would make, so it is +both the frontend's button-gating call and the operator's "would a purchase go +through right now?" check. Two operational gotchas: + +- ⚠️ **`can_purchase` does NOT cover solvency, and cannot.** It is a query, and + reading the reserve is what the gate does synchronously inside `create_order`. So a + green `can_purchase` alongside `reserve_status.availableToSell = 0` is not a + contradiction — it is the split working. Check both. +- **An unobserved top-up is the most likely reason the rail goes quiet.** + `reserveFloor` only rises when the canister looks, so a funded reserve sells + nothing until `refresh_reserve` runs (the hourly sweep does it too). + `reserve_status.reserveObservedAtNs` is how you tell that from a spent reserve. + +## 5b. Order expiry — Stripe owns the clock -**Levers:** +```bash +icp canister call backend reserve_status '()' -e ic # public counters +``` -- **Pause all minting**: `set_treasury_config` with `burnCapE8s = 0` - (the fail-closed default doubles as the pause switch). Money-in keeps - working; everything holds in `awaitingTreasury`. -- **Manual override / resume after a legitimate burst**: - `reset_burn_window` clears the window's consumption (returns the e8s - cleared, audited). Held orders resume on the next sweep — the pre-gate - stays the single decision point. -- **Held too long**: an order held past `maxHoldNs` (default 72 h) - escalates to the error queue as `stuckMint{stage = "treasuryWaitExceeded"}` - — position is *certain* (fiat in, nothing minted): refund in the Stripe - Dashboard, then `resolve_error` (§6). +**There is no retention config, no TTL and no sweep.** There is no +`Retention.mo`: an order's deadline is its Checkout Session's `expires_at` +(~35 min, above Stripe's 30-minute floor), stored on the order, and the only +*event* that moves an order to `expired` is Stripe's `checkout.session.expired`. +A buyer freeing their own open-order slot uses `cancel_order` (owner-scoped), +which produces `cancelled` — a separate status. -`lowFloat` (threshold crossed, or armed-but-never-observed) drives the -frontend's soft gate and an audit-log alert on the crossing. It never -blocks mints by itself — the hard float check is in the pre-gate. +⚠️ **Three things reach `expired`, not one, and the difference decides your remedy.** +This section said "the only thing" for long enough that the monitoring section's own P1 row contradicted it: -## 6. Error queue — triage (§4.1) +| | reaches `expired` | when it does not | +|---|---|---| +| `checkout.session.expired` | the normal path; releases the promise | never sent if the event is not subscribed (`docs/OPERATE.md`, Mode 2) | +| `cancel_order` (owner) | produces `cancelled`, also releasing | expires the session at Stripe first, so a paid race wins | +| `expire_order` (admin) | asks Stripe to expire, then settles | refuses `#sessionNotOpen` once the session has *already* expired or completed at Stripe — which is exactly the missed-event case below | + +`expire_order` is therefore the manual release for an order whose session is **still +open** at Stripe, and for the residue class with no `stripeSessionId` at all (expired +with no outcall). It is **not** the remedy for a missed expiry event. + +| Status | Payable? | +|---|---| +| `created` | yes, until the session's own `expiresAtNs` | +| `expired` | **no** | +| `cancelled` | **no** | + +⚠️ **A missed `checkout.session.expired` leaves the order visibly `created` past +its `expiresAtNs`, and that is deliberate.** A sweep as a backstop was specified +and then rejected: it would flip the order to `expired` while its reserve promise +stayed held, so a broken order would look like a correctly expired one and the +reserve would leak silently. The stuck order IS the detection signal (the reserve's +predicate 1) — treat "created, past `expiresAtNs`" as an alert, not as noise. +⚠️ **And it costs RESERVE CAPACITY.** A `created` order holds its +promise from the moment it exists (the gate admitted it against capacity), and the +only things that release one are `checkout.session.expired` and the buyer's own +`cancel_order` — `abandon_order` refuses a `created` order by design, since no money +was taken. So a missed expiry webhook strands `lockedCycles` of sellable reserve +until someone acts, and `reserve_status.promisedTotal` climbing while `openOrders` +also climbs is what it looks like. + +**The lever for THIS case is off-chain: resend `checkout.session.expired` from the +Stripe Dashboard** (the monitoring section's P2 row) — because the session has genuinely expired there, so +`expire_order` refuses it (table above). So the remedy exists but is **gated on +noticing**: nothing on-chain surfaces the stranded order, which is the observability gap +the ranked fixes describe. Bounded per incident by the purchase +ceiling, and unbounded only in aggregate against a failure that Stripe itself retries +for ~3 days first. + +**Nothing exposes it yet**: order reads are owner-scoped and `reserve_status` +carries only counts, so the monitoring section records this as a gap with an interim signal rather +than as an alert you can wire. The admin order listing is what closes it. + +⚠️ **A payment arriving against an expired or cancelled order cannot be +converted** — there is no `#expired → #paid` edge. It answers 200, the status does not +move, and an `#unattributed` entry is filed carrying the payment intent. +**Refund it in Stripe** — there is no attribution rescue, so there is no other +remedy at all. + +The webhook-lost-for-three-days problem that made the old 48 h TTL awkward is +gone with it: the session and the order now die together, so a session that can +still be paid always belongs to an order that can still accept it. + +Nothing deletes an order. The record and its `client_reference_id` survive +forever, which is what keeps a late payment *attributable*, and therefore +refundable rather than a mystery charge. + +### The outcall cost, and the one field that moves it + +`create_order` now spends the canister's own cycles on an HTTPS outcall, so +`minCanisterCycles` is more load-bearing than before: it is the floor that closes +the rail before the gas runs out. + +The cost is **computed exactly** by `ic0.cost_http_request` — `Call.httpRequest` +attaches precisely that and never a buffer, because attached cycles are reserved +for the call's duration and a margin therefore caps how many outcalls can be in +flight. There is nothing to measure. What decides the number is +**`max_response_bytes`**, currently **16,384** (`Session.maxResponseBytes`): + +| Call | `max_response_bytes` | n = 13 (application subnets, and the local network) | n = 7 (the confidential subnet, our target) | +|---|---|---|---| +| create a session (`create_order`) | 16,384 | **≈ 220 M cycles** (~$0.0003) | **≈ 118 M cycles** | +| expire a session (`cancel_order`, `expire_order`) | 16,384 | ≈ 220 M | ≈ 118 M | +| **retrieve a session** (the recovery sweep) | **32,768** | **≈ 390 M cycles** | **≈ 207 M cycles** | + +Work with the 13-node figure: it is the conservative one, and the local network +prices on it, so local runs *overstate* production cost. At 20 T gas with a 5 T +floor, the headroom depends on **whether webhooks are working**, and the two cases +are worth keeping apart: + +| | cost per abandoned order | creations before the rail closes (n=13) | (n=7) | +|---|---|---|---| +| **Webhooks healthy** — the ordinary case | one create, ≈222 M | **≈68,000** | ≈128,000 | +| **Webhooks failing** — a missed expiry event per order | create + retrieve, ≈612 M | **≈24,000** | ≈46,000 | + +**The retrieve is NOT on the abuse path in normal operation, and the grace is +what keeps it off.** Stripe fires `checkout.session.expired` within seconds of the +deadline, so an abandoned order is already `#expired` about half an hour before the +sweep would look at it — `expiryCheckDue` tests the status first. So a buyer, or an +attacker, abandoning orders costs the gateway one create outcall each, exactly as +in the normal case. The second row is the *degraded* case: it needs the webhook path broken +as well, which is a different incident with its own P1 rows in the monitoring section. + +**These four figures are computed from the formula above, not carried forward.** At +`max_response_bytes` 16,384 and 32,768 with a 15 T spendable balance they are 67,685 / +128,413 healthy and 24,515 / 46,236 degraded — recompute rather than reuse if a cap +changes, because a stale performance number reads exactly like a measured one. (The +previous ≈127,000 here was slightly low for n=7.) + +Either way the shape is unchanged — this is **availability, not memory**, it closes +the rail via `minCanisterCycles` rather than freezing the canister, and it takes +hours of sustained paid-for abuse. And one thing improved in the same change: the +per-principal *instantaneous* strand fell from 20 open orders to **1**, a 20× +reduction in what a single identity can tie up at once. + +**The retrieve's cap is deliberately double the others', so its call costs +roughly double.** A *completed* session carries `customer_details`, a resolved +`payment_intent` and `total_details` that a freshly created one does not, and an +over-cap response fails the call outright rather than truncating. Two consequences +worth knowing before tuning it down: the sweep only retrieves for orders that are +**already stranded**, so in normal operation this line is zero calls per day; and +the stranded population is **correlated** — one unprovisioned webhook secret +strands every order in its window at once — which is why the sweep caps retrieves +per pass (`Recovery.maxRetrievesPerPass`) and resumes rather than draining a +backlog in one go. + +**The cap counts response HEADERS, not just the body, and it is checked +twice** — once on the raw response, once on the transform's Candid-encoded +output. Three distinct rejects, and the middle one misleads: +`Header size exceeds specified response size limit` (headers alone), +`Http body exceeds size limit of ` (**prints the full cap, not the remainder +left after headers, so the body that failed can be well under ``**), and +`Transformed http response exceeds limit`. Raising the cap fixes all three; +stripping headers in the transform fixes only the last. + +⚠️ **`No consensus could be reached` means the transform, not Stripe.** It is the +signature of a per-request value not being stripped, it takes the whole rail down +rather than degrading it, and **no test suite in this repo can catch it** — the +PocketIC suite mocks outcalls, verified by mutation. `Session.classifyFailure` +labels it in the audit log for exactly that reason. + +### Growth + +Growth is bounded at its source, not by deletion: +`maxOpenOrdersPerPrincipal` (the admission-gate section) bounds what a user can create for free, and the +reserve bounds legitimate volume — nobody can buy more than it holds. An order is a +few hundred bytes, so a +million is a few hundred MB — and a million orders is millions of dollars of +volume. If store size ever genuinely binds, archive to a separate canister; +deleting a financial record is not the answer. + +Monitor `reserve_status.openOrders` — climbing while `delivered` orders do not +is the signature of order-creation abuse, and the lever is +`maxOpenOrdersPerPrincipal` (the admission-gate section). `totalOrders` and `paidIntentsIndexed` should +grow together and never diverge. + +## 6. Obligations — triage (§4.1) ```bash -icp canister call backend error_queue -e ic --identity -icp canister call backend resolve_error '(42)' -e ic --identity -icp canister call backend mint_journal '("")' -e ic --identity +icp canister call backend orphans '(null, 50 : nat)' -e ic --identity +icp canister call backend resolve_orphan '(42)' -e ic --identity +icp canister call backend delivery_journal '("")' -e ic --identity ``` -The queue is the **operator worklist** — resolution lives on the entry and -never transitions an order (`errorQueue` status is terminal). Bounded at -1,000 entries; eviction prefers oldest-resolved and audit-logs anything -dropped — keep it near-empty so nothing unresolved is ever at risk. -`charge.refunded` webhooks auto-resolve Type 1 entries by payment_intent; -everything else is manual. +The queue is the **operator worklist** — resolving an entry lives on the entry and +never transitions the order. The order's own status says whether anything is +still owed, which is why the escalated and abandoned states are separate: + +| Status | Meaning | The order's promise | +|---|---|---| +| `NeedsReview` | a money position nobody knows the outcome of — typically a transfer past the ledger's ~24 h dedup window. **Check the ledger.** | **still held** | +| `Abandoned` | you ended it, having refunded by hand. Terminal. | **released** | + +**How an order GETS to `NeedsReview`**, since the triage depends on it and "it +escalated" is not one thing: -| Kind / stage | Money position | Action | +| Route | Money position | What you do | |---|---|---| -| `#duplicate {orderId; paymentRef}` (Type 1) | Fiat in twice for one order; first payment minted, second did not | Refund `paymentRef` in the Stripe Dashboard (search by payment_intent). The `charge.refunded` webhook auto-resolves the entry; `resolve_error` is the fallback. | -| `#unattributed {claimedRef; paymentRef}` (Type 1) | Fiat in, no resolvable order (bad/missing `client_reference_id`, owner/rail/currency mismatch, below-fee-floor payment) | Inspect the session in Stripe by `paymentRef`. If you can identify the customer and they want the cycles, there is no attach-payment-to-order lever — refund and have them re-order. Refund → auto-resolve (or `resolve_error`). | -| `#undeliverable {orderId; cycles}` (Type 2) | Cycles minted, forward *cleanly rejected* — the cycles sit in the backend's own cycle balance | Fix the destination problem (e.g. the target canister was deleted/frozen). There is no automatic re-forward lever; either deliver manually from operator funds (`icp canister top-up --amount …` for a canister destination, a cycles-ledger transfer for an account destination — the stranded balance reimburses you) or refund the fiat in Stripe. Then `resolve_error`. | -| `#stuckMint{stage="treasuryWaitExceeded"}` | Certain: fiat in, nothing minted | Refund in the Stripe Dashboard → `resolve_error`. | -| `#stuckMint{stage="staleIntent"}` | Uncertain: ICP transfer intent aged past the 24 h ledger dedup window with no recorded block — the original transfer's fate is unknowable, auto-replay risks double-spend (§5.1) | Read `mint_journal(orderId)` for the intent (amount, `created_at_time`, CMC top-up subaccount). Check the ICP ledger for a matching transfer from the backend account. **Executed** → the ICP sits at the CMC under the backend's top-up subaccount; call `notify_top_up` on the CMC with the found block index (anyone may notify; if the CMC refuses an old block, contact DFINITY ops — the ICP is parked, not lost) and reconcile. **Not executed** → fiat in, nothing moved: refund in Stripe. Either way `resolve_error`; never rebuild a fresh intent. | -| `#stuckMint{stage="retriesExhausted"}` | ICP transferred to the CMC (block recorded), `notify_top_up` failed 25 times | Almost certainly a prolonged CMC outage. The block index is in `mint_journal`; the order is terminal (`process_order` will not retry an escalated order) — notify the CMC manually with the journaled block once the outage clears, or contact DFINITY ops. The ICP is parked at the CMC top-up subaccount, not lost. | -| `#stuckMint{stage="ambiguousForward"}` | Cycles minted; forward may or may not have reached the destination (died between the pre-forward marker and delivery) | Check the destination: canister cycle balance delta / cycles-ledger account balance vs `mint_journal.cyclesMinted`. **Arrived** → done, `resolve_error`. **Not arrived** → cycles are in the backend's balance; deliver manually as for Type 2. Never re-forwarded automatically — double delivery is the risk being avoided. | -| `#stuckMint{stage="missingJournal"}` | Order status implies money-out state the journal doesn't have | Invariant breach — should be unreachable. Reconstruct from `audit_log` + ledgers; treat as a bug, file it. | -| `#stuckMint{stage="stalePullIntent"}` (ck-USDC) | Uncertain, money-IN: a `icrc2_transfer_from` pull intent aged past 24 h with no recorded block; the order deliberately stays `created` and further claims are blocked | See §7 below — this one has dedicated levers. | +| the intent aged past the ledger's ~24 h dedup window, or the ledger answered `#TooOld` (the same case, told to us) | **unknown** — a replay is no longer protected | establish the fate on the ledger; the order id is in the transfer's **memo** | +| §5.3's 72 h max-wait, on an order where **nothing was ever sent** | **certain** — fiat in, nothing moved | refund in the Stripe Dashboard | +| `journalInconsistent` | unreachable guard | if this ever fires, `lockedCycles` acquired a second writer — a much bigger problem than one order | + +**So `NeedsReview` is NOT always an unknown position.** `delivery_journal(orderId)` and +the queue entry's `detail` say which — `terminationFor` derives it from the journal +rather than the status, precisely because the status cannot tell these apart. Reaching +the *unknown* case at all takes a ~day-long cycles-ledger outage with an hourly sweep +and buyer kicks hammering it throughout: treat it as expected-never, not routine. -## 7. ck-USDC rail (§6.2) +`NeedsReview` has exactly **two** exits, and both are your finding rather than the +gateway's: -**Enable / bound the rail** (default `maxUsdCents = 0` = disabled; also the -rail-level pause lever): +| You established, on the ledger | Call | Result | +|---|---|---| +| the transfer **did** land — the buyer has the cycles | `record_delivered '("", )'` then `resolve_orphan '()'` | `Delivered`, with the block recorded in the journal | +| it did **not**, and you refunded the fiat by hand | `abandon_order '("", "")'` then `resolve_orphan '()'` | `Abandoned`, reason in the audit trail | + +⚠️ **Neither lever closes the queue entry — `resolve_orphan` is the last step, always.** +Resolving lives on the entry and never transitions an order, and the reverse holds too: +moving the order does not resolve the entry. The `#deliveryStuck` entry that +brought you here stays open until you close it, which is deliberate (an obligation +must not disappear because a status changed) but means a finished order can sit behind +an open worklist item if you stop after the first command. + +⚠️ **You cannot `abandon_order` a `Paid` order whose delivery is still outstanding** +The lever refuses and names `pending_deliveries`, because abandoning an +unknown position releases the promise and files a refund while the transfer may +already have landed — the buyer would keep the cycles and get the refund. It is a +wait, not a block: the ~24 h fuse moves such an order to `NeedsReview`, which is this +table, where establishing the fate first is the documented procedure. + +**`record_delivered` exists because its absence made the record lie**. +Until it did, `abandon_order` was the only exit, so an order whose cycles the buyer +demonstrably held could only be filed as abandoned — auditing a refund that never +happened. The block index is required: it is the evidence that you looked, and the +order id is in the transfer's **memo**, so finding it is a ledger search rather than +a reconstruction. Nothing automatic reaches `Delivered` from `NeedsReview`, because +re-driving an unknown money position is the double-delivery this status prevents. + +⚠️ **Never treat `NeedsReview` as finished.** It is the status that still owes +cycles; `Abandoned` and `Delivered` are the ones that do not. +**Only a *full* `charge.refunded` auto-resolves an entry** — Stripe fires +the same event for partial refunds, so the canister compares `amount_refunded` +against the charge's `amount`. A partial refund leaves the entry open and audits +`stripe.refundPartial`; finish the refund in the Dashboard (or close the entry by +hand once reconciled). Everything here is manual: the self-resolving cases are no longer entries at all. +A delivery running late is a *reading* — `delayed_deliveries` — not an obligation. + +**Nothing is ever evicted.** The former soft cap of 1,000 is gone as a +parameter: an unresolved entry is an open obligation — usually someone's money — so +the list **grows rather than dropping one**. That makes `orphan_depth` a real alarm +instead of a saturating gauge: a depth climbing past ~1,000 means +unresolved work is accumulating faster than it is being cleared, and no amount +of ignoring it can lose an obligation. + +Paginate with a cursor rather than fetching the whole queue: ```bash -icp canister call backend set_ck_usdc_config \ - '(record { minUsdCents = 100; maxUsdCents = 100_000; feeBps = 0; feeFixedCents = 0; ledgerFeeUnits = 10_000 })' \ - -e ic --identity +icp canister call backend orphan_depth '()' -e ic # public: {total; unresolved} +icp canister call backend orphans_unresolved '(null, 50)' -e ic --identity +icp canister call backend orphans '(opt (120 : nat), 50)' -e ic --identity +icp canister call backend resolve_orphan '(137 : nat)' -e ic --identity ``` -`ledgerFeeUnits` must match the real ck-USDC ledger fee (10_000 units -today — confirm with `icrc1_fee` on `xevnm-gaaaa-aaaar-qafnq-cai` before -changing). The fee formula defaults 0/0: there is no structural processor -fee on this rail; the operator absorbs off-chain conversion cost per §3. - -**`stalePullIntent` procedure** (the §6.2/§5.1 escalation — order stays -`created`, claims blocked, queued once): - -1. `ck_usdc_pull '("")'` — read the journaled intent: amount, - `created_at_time`, and the memo (the order id's UTF-8 — every pull is - ledger-greppable by it). -2. Check the ck-USDC ledger for a transaction matching the intent (the - user's account → backend, that amount, that memo). -3. **No transaction** (nothing ever moved): `reset_ck_usdc_pull '("")'` - clears the intent; the user simply claims again. The method refuses - (returns `false`) if a block *is* recorded — it structurally cannot - create a double-debit. -4. **Transaction executed** (user was debited, credit never recorded): do - NOT reset — a fresh intent would debit them twice. Refund the pulled - amount with `withdraw_ck_usdc` to the user's account, then - `resolve_error` the queue entry and tell the user to re-order. - -**Withdraw lever** — both the refund tool above and the §6.2 hold-ckUSDC -treasury posture (pulled ck-USDC accrues in the backend's ledger account; -periodically withdraw → convert to ICP off-chain → refill the float): +`orphans_unresolved` is the worklist; pass the last id returned as +`afterId` to page forward. Page size is capped at 200. -```bash -icp canister call backend withdraw_ck_usdc \ - '(record { owner = principal ""; subaccount = null }, 1_000_000_000)' \ - -e ic --identity +**Two columns decide everything: the money position, and whether a refund can settle +it on its own.** The second is `Orphans.refundResolvable` — true only where the +remedy is exactly "refund the fiat", so the `charge.refunded` webhook can close the +entry without a human deciding anything. + +| Kind | Refund settles it? | Money position | Action | +|---|---|---|---| +| `#duplicate {orderId; paymentRef}` | ✅ **yes**, automatically | Fiat in twice for one order; the second payment delivered nothing | Refund `paymentRef` in the Stripe Dashboard (search by payment_intent). The `charge.refunded` webhook auto-resolves the entry; `resolve_orphan` is the fallback. | +| `#unattributed {claimedRef; paymentRef}` | ✅ **yes**, automatically | Fiat in, and no order that can accept it: a bad or missing `client_reference_id`, an owner/rail/currency mismatch, **a paid amount that is not the one the order asked Stripe for**, or a payment against a `cancelled` or `expired` order — the common producer. The entry's `detail` says which | Inspect the session in Stripe by `paymentRef`, then **refund** → auto-resolve (or `resolve_orphan`). This is the only remedy, whatever the order's status. If the detail says the amount is not the quoted one, refunding is not the end of it: the session carried our own figure, so something in the Stripe configuration moved the total — check the forbidden-settings list in `docs/STRIPE.md` before the next order, because it will recur. | +| `#deliveryStuck {stage}` (on the order) | ❌ **no** — see the stage table below | **Depends entirely on `stage`.** One of them means the buyer may already hold their cycles, so a blind refund pays twice | Read `stage` first, then follow its row | +| `#refundAfterDelivery {paymentRef; cycles; refundedCents; fullRefund}` (on the order) | ❌ **no, and never** | **A loss, not a recoverable position**: the fiat was refunded or charged back *after* the cycles were credited. Cycles cannot be clawed back | Nothing to recover on-chain. Reconcile in the Dashboard by `paymentRef` to see whether this was your own refund (a support decision) or a dispute (a fraud signal). For repeated disputes, tighten Stripe Radar and lower the per-purchase ceiling (the admission-gate section). **Nothing auto-resolves it, deliberately: the refund is the event that created it**, so resolving on that event would close the entry with the loss unrecorded | +| `#paidNotCredited {orderId; paymentRef; sessionId}` | ❌ **no — and a refund alone makes it worse** | **The buyer paid and this gateway never credited them.** Found by the recovery sweep asking Stripe about a `#created` order past its deadline, once Stripe has had longer than its ~3-day redelivery window to hand us the `completed` event it owed. The order is still `Created`, so it still holds reserve capacity — correctly, because the cycles are genuinely owed | **RESEND FIRST, ALWAYS.** Find the event in the Stripe Dashboard (search by `paymentRef`) and resend `checkout.session.completed`. That credits the order through the normal path, delivers the cycles, and **closes this entry automatically**.

**Refunding instead does not settle it, and leaves no way out.** The refund returns the money and leaves the order in `Created` holding capacity, and a *complete* session never fires `checkout.session.expired`, so nothing is left that can release it — `expire_order` correctly refuses (Stripe reports the session not-open), and `abandon_order` cannot act on `Created`. If you have already refunded: resend anyway to move the order to `Paid`, then `abandon_order`. **The buyer keeps cycles they were refunded for** — that is the cost of refunding first, and it is why the order of operations is the whole procedure.

The entry does **not** auto-resolve on `charge.refunded`, deliberately: refunding settles the money and leaves the order broken, so auto-closing would delete the only worklist item pointing at the stranded capacity | +| `#unprocessable {eventId; field}` | ❌ no — the position is unknown | A verified Stripe event was missing a required field, so the canister could not tell whether money moved | Look the `eventId` up in the Dashboard. **Paid** → refund. **Not paid** → nothing happened; `resolve_orphan`. Then find the configuration that produced it: the canister controls every field it sends, so a missing one points at an account-level API-version change (`docs/OPERATE.md` pins it) and will recur until fixed. Resolve only after establishing the money position — once resolved, a later resend is allowed to file again | + +### `#deliveryStuck`'s stages — read `stage`, never the kind + +**`stage` is the money position.** It is derived from the *journal*, not the status, +because one status covers several positions. These five are the whole vocabulary +`Delivery.terminationFor` and the escalate route can emit — pinned by +`test/delivery.test.mo`, so a stage that reaches you without a row here is a bug in +the code rather than a gap in this table. + +| `stage` | Money position | Action | +|---|---|---| +| `staleIntent` | **UNKNOWN.** A transfer was issued, no block was recorded, and the intent is past the ledger's ~24 h dedup window — so a replay is no longer protected and re-sending could pay twice | **Establish its fate on the cycles ledger**, matching the order id in the transfer's **memo**, the `created_at_time` and the amount from `delivery_journal(orderId)`. **Executed** → the buyer has them: `record_delivered '("", )'`, then `resolve_orphan`. **Not executed** → fiat in, nothing delivered: refund in Stripe, `abandon_order`, then `resolve_orphan`. **Never rebuild the intent** — past the window a rebuilt one pays twice | +| `landedNotRecorded` | **Certain, and in the buyer's favour**: the transfer landed (the block is in the entry and the journal) and the order never moved to delivered. **The buyer HAS their cycles** | Confirm the block on the cycles ledger, then `record_delivered '("", )'` → `resolve_orphan`. **Do NOT re-send.** Should be unreachable — the block and the transition commit in one synchronous block — so if you are reading this, file it as a bug too | +| `deliveryWaitExceeded` | **Certain**: fiat in, **nothing was ever sent** — no transfer was attempted before the 72 h bound | Refund in the Stripe Dashboard, `abandon_order '("", "")'`, then `resolve_orphan` | +| `transferRejected` | The cycles ledger refused the call definitively, so nothing moved | Read the `detail` for the ledger's reason. `#InsufficientFunds` → the reserve is short: top it up and `refresh_reserve`, then re-drive with `process_order`. Fiat is in and nothing was delivered, so refunding is always a valid resolution | +| `journalInconsistent` | **An invariant breach, not a money position** — the intent's amount exceeds the order's locked quantity, which cannot happen because the amount was derived by subtracting a fee from it | **File it as a bug**: `lockedCycles` has acquired a second writer, which is a larger problem than one order. Establish the transfer's fate on the ledger before re-sending anything | +| `missingJournal` / `notInFlight` | **Also invariant breaches.** The order's status implies money-out work the journal cannot support, or the drive loop escalated a status that has no delivery in flight | Reconstruct from `audit_log` and the cycles ledger; treat as a bug and file it | + +### An unattributed payment has exactly one remedy: refund — and it is usually not "unattributable" + +⚠️ **Read the entry's `detail`, not its kind.** `#unattributed` is one variant +covering two very different situations, and the common one is the +second: + +| | What it means | How common now | +|---|---|---| +| Genuinely unattributable | no order can be named: missing, malformed, or unresolvable `client_reference_id`, wrong currency | **should not happen** — the canister sets that field itself through the API, so treat one as a bug to find (or as a session someone created outside the app) | +| Attributable but unpayable | the entry names the order; we refuse to credit it — a lowered ceiling, an amount that is not the quoted one, a cancelled or expired order | the normal producer | + +The second kind needs no hunting in the Dashboard: the order id is in the detail. +What it needs is fixing the cause, or the next order fails the same way. + + +`attach_payment` — the admin lever that credited a payment the canister never +saw — **does not exist**, along with the failure it existed for. Under +Payment Links the *frontend* appended `client_reference_id` to the URL, so a +buyer could strip it, bookmark a bare link or hand-edit it, and misattribution +was the dominant failure. The canister now sets that field itself through the +Checkout Sessions API: there is no URL parameter to touch, so the class is gone +by construction. + +What remains is refunding, in the Stripe Dashboard, by `paymentRef`. There is no +path that turns an unattributed payment into cycles for that buyer, whether or not we +know which order it was for — that is +the deliberate cost of the deletion, and it matches the decision that this app +does not model refunds. A refund auto-resolves the entry (a partial one leaves it +open, carrying the remainder). + +If you find yourself wanting the lever back, the thing to check first is +whether attribution is broken — nothing but the canister writes +`client_reference_id`, so a payment that cannot be attributed is a bug worth +finding, not a routine occurrence to be papered over. + +### Closing an order-bound problem + +Four of the six kinds live on the order rather than in this list, and they +are closed with `resolve_problem` rather than `resolve_orphan`: + +```sh +⚠️ **`admin_orders` is ordered by order ID, NOT by time, and the last argument is a page +size.** Order ids are `raw_rand` hex, so id order is arbitrary — **the first page is not +the most recent orders.** The admin listing filters by time range, and this is where +that expectation forms, so: to look at recent orders, set `createdFromNs` rather than +reading page one. The third argument to each call is `limit`, and `null` in the second +position means "from the beginning"; pass the returned `nextCursor` back to continue. + +**Sorting by time is deliberately not offered.** It would mean materialising the whole +filtered set before sorting, which is an **unbounded scan**, and a +time index would be one more piece of derived state the daily reconcile has to adjudicate. + +# Read ONE order, whoever owns it. Every such read is audited, hit or miss. +icp canister call backend admin_order '("")' + +# See what is outstanding, and on which orders +icp canister call backend problem_depth '()' # the number to alert on +icp canister call backend admin_orders '(record { status = null; owner = null; createdFromNs = null; createdToNs = null; withUnresolvedProblems = true }, null, 50)' + +# Read ONE order's receipt, whoever owns it. Audited, like admin_order. +icp canister call backend admin_receipt '("")' + +# Close one. The second argument is a VARIANT, not a string — a misspelled +# tag is now refused by the decoder instead of matching nothing. The third selects +# WHICH problem, by payment reference. +icp canister call backend resolve_problem '("", variant { duplicate }, opt "pi_...")' + +# `deliveryStuck` can only ever have one per order, so null is always right: +icp canister call backend resolve_problem '("", variant { deliveryStuck }, null)' ``` -Returns the block index. The transfer carries **no `created_at_time`** — -this is an attended lever, so on a timeout/ambiguous failure **check the -ledger before retrying** (the backend's balance is public on the ck-USDC -ledger; no query method needed, and that's deliberate). +The four tags are `duplicate`, `deliveryStuck`, `refundAfterDelivery` and +`paidNotCredited` — the same four `admin_order` reports on the order's own problems. + +⚠️ **Passing `null` when the order has several problems of that kind is REFUSED, and +the refusal carries the references as data** (`#ambiguous`, with a `candidates` list — +typed, rather than a sentence to read them out of). A buyer who pays three times files three +`#duplicate` problems, and closing "the duplicate" would mark settled a payment you +have not refunded. Refunding one and closing another is the mistake this refusal +exists to prevent — the error message is the disambiguation step, not an obstacle. + +**A resolved problem stays on the order.** Nothing drops, so the worklist filter +stops listing the order while its history remains readable. "It disappeared" is not +what success looks like here. + +| kind tag | ref needed? | what closing it means | +|---|---|---| +| `duplicate` | **yes** when several exist | you refunded that specific second payment in Stripe | +| `deliveryStuck` | no — one per order | you established the money position and acted (the triage section's stage table) | +| `refundAfterDelivery` | **yes** when several exist | you reconciled the recorded loss; the cycles are not recoverable | +| `paidNotCredited` | **yes** when several exist | normally closes ITSELF on the resend — closing it by hand says you gave up on crediting the buyer | -## 8. Recovery timer & manual kicks (§5.2) +## 7. Recovery timer & manual kicks (§5.2) -The recurring sweep is the backstop for every detached mint kick that dies: -it re-drives all orders in `paid`/`minting`/`icpAtCmc`/`awaitingTreasury`. +The recurring sweep is the backstop for every detached delivery kick that dies: +it re-drives every order in `paid`, which is the one status with delivery outstanding. It re-arms **automatically on every upgrade** (transient initializer — a deploy can never leave recovery dead). ```bash -icp canister call backend recovery_status -e ic # public +icp canister call backend recovery_status '()' -e ic # public icp canister call backend set_recovery_interval '(3_600_000_000_000)' -e ic --identity icp canister call backend process_order '("")' -e ic --identity ``` - Interval validation pins cadence ≤ 6 h (ledger dedup window ÷ 4 — a stuck transfer must get several replay attempts while its intent still - dedups). Default 1 h; re-arms immediately on change. + dedups). Default **15 min** (`Recovery.defaultIntervalNs`); re-arms immediately on + change. - `recovery_status.lastSweep` not advancing past ~2 intervals = the timer is wedged — an upgrade re-arms it, but investigate first. +- The sweep also **reconciles the per-status tallies once a day** and reports the + result on `recovery_status.lastCountReconcile`. The tallies are maintained + incrementally so the admission-gate queries stay O(1); the reconcile is the check + that they still match. It audits **only when something moved** — a clean line every + day would bury the one that matters. `recount_orders` runs the same pass on demand. +- **Its cost is bounded by open orders, not by lifetime sales**, and that + changes what a stale reconcile means. It recounts over the non-terminal order set, + which the reserve caps, so `lastCountReconcile.ordersRead` should stay flat as sales + accumulate. If it starts tracking total orders, the bound has broken. +- ⚠️ **`drift` and `refused` are two different verdicts and demand opposite readings.** + `drift` was **raised** to the recount, so those tallies are correct again. `refused` + came out **below** the maintained tally and was **not** adopted, so those tallies are + still suspect — a recount lower than the tally is indistinguishable from an index + missing a member, and adopting it is the only way a bookkeeping bug could lower + `promised` and oversell the reserve. `recount_orders` follows the same rule; there is + deliberately no force flag. +- **One check cannot be daily, and it says so instead of pretending.** Whether + anything *outside* an index satisfies the index's predicate is the only question that + needs every order, so it runs as a rotating per-order scan, one chunk per sweep. + `recovery_status.indexScan` is its coverage: `lastCompletedCycle` is the only thing + that licenses reading a clean scan as evidence about the whole store, and + `inFlightCycle.ordersRead` against `storedOrders` says how far the current cycle has + walked. **Three states, not two** — an audit line is *verified and disagreed*, silence + with a recent `completedAtNs` is *verified clean*, and silence without one is + *unverified*, which carries the opposite response: wait for the pass rather than hunt + for a writer. +- **The rotating scan converts unbounded WORK into unbounded LATENCY, and the honest claim is + "bounded per message" rather than "bounded".** The daily pass verifies only the inside + direction of each index. Detecting the outside direction — an order that holds a + promise and is not indexed — takes up to one full cycle, and the cycle grows + **linearly in stored orders**: `⌈storedOrders ÷ chunkSize⌉ × sweep interval`. At the + 15-minute default and 2,000 per chunk that is ~192,000 orders a day, so 365k is + covered in about two days and 3.65M in about nineteen. This is the right trade — work + that traps is fatal, latency that grows is degradable **and observable** — but it is a + trade, and `indexScan.expectedFullCycleNs` is where you read the current value rather + than assume this paragraph is still accurate. +- **`set_recovery_interval` is a lever on that latency, and its name does not say + so.** The scan rides the sweep, so coarsening the cadence to the §5.1 ceiling of 6 h — + **24× the default** — takes the same 365k store to ~46 days per cycle. The + `recovery.intervalSet` audit line now names the resulting window, so the consequence + is recorded next to the cause. Do not retune the cadence to save cycles without + reading it: the finding it delays is `orders.unindexedHolders`, which is **P1**. +- The sweep also **reconciles the reserve floor against the cycles ledger once an + hour**, which is how a top-up becomes sellable without an operator call. + `recovery_status.lastReserveReconcileAttemptNs` is the attempt clock and + `reserve_status.reserveObservedAtNs` the success one; the two diverging means the + read is failing or every attempt landed while a delivery was in flight. Both + under-sell, never over-sell. `refresh_reserve` is the same reconcile on demand and + is what you call right after `icp cycles transfer`. - `process_order` is the safe-to-spam manual kick for one order (per-order single-flight; `#inFlight` just means it's already being - driven). Use it to resume a specific held order immediately after a - float refill or cap change instead of waiting for the sweep. - -## 9. Monitoring cadence - -Daily (or alerting on, if you wire these queries up externally): - -- `error_queue` — unresolved count should be **zero**; anything else is - the §6 worklist. -- `treasury_status` — `lowFloat` false, `heldOrders` 0 (or transiently - small), `burnedInWindowE8s` tracking expected volume (a jump = §2 leak - procedure), `lastObservedFloat.atNs` recent. -- `recovery_status` — `lastSweep.atNs` within ~2 intervals. -- `forex_status` — rate timestamp within `maxAgeNs` of recent order - activity (it only refreshes on demand; staleness with no orders is - normal). -- `audit_log` — gaps in `seq` mean the 4,096-entry ring dropped events - (read it more often or treat as a volume signal); the log is the - operational trail, the order store + error queue are the records of - money. -- Off-chain: Stripe Dashboard event deliveries (a stretch of failed - deliveries = the secret got out of sync or the gateway is unhealthy — - Stripe retries non-2xx for days, so nothing is lost while you fix it). - -## 10. Confidential-subnet checklist (§7, §11.1) - -The webhook secret is plaintext canister state. SEV-SNP is the intended -confidentiality layer; **the burn cap is the always-on backstop and launch -does not block on SEV**. Before relying on a confidential subnet for the + driven). Use it to retry one order the moment its cause is fixed — the reserve + refunded, the cycles ledger back — instead of waiting for the sweep. + ⚠️ **It is admin *or* the order's own owner**, so a buyer's page + refresh heals their own stuck delivery in seconds rather than at sweep cadence. It + does **not** make the sweep optional: the sweep is the guarantee (we took the money, + so we deliver whether or not the buyer comes back), this is the latency fix. + +## 8. Monitoring plan + +Every safety mechanism in this system is a **number someone has to look at**. The +2 h delay alert, the obligation queue, the rate-refresh liveness — none of them page +anybody. An alert nobody receives is not an alert, so wire this before taking +money. + +### The whole alerting layer needs no credentials + +These are public queries, so a monitor can poll them anonymously — no controller +key on a monitoring box: + + + +`can_purchase` · `card_tiers` · `cycles_status` · `delivery_stats` · `expected_livemode` · +`health` · +`admin_status` · `lifecycle_config` · `operator_summary` · `orphan_depth` · +`pricing_status` · `problem_depth` · +`quote_previews` · `recovery_status` · `refusal_counts` · `reserve_status` · +`stripe_origin` + + + +The ones worth a monitor: `health`, `cycles_status`, `reserve_status`, +`pricing_status`, `recovery_status`, `orphan_depth`, `problem_depth` (open +obligations — the count the triage section works from) and `refusal_counts`. + +`can_purchase` is also callable anonymously and is worth special mention: the +anonymous principal owns no orders, so `tooManyOpenOrders` can never trip for it. +That makes **anonymous `can_purchase '()'` a pure global-health +probe** — it answers with the reason the rail would refuse a sale, and every reason +it can give is actionable (`canisterCyclesLow`, `amountAboveMax`, +`amountBelowMin`, `tooManyOpenOrders`). ⚠️ **It cannot see solvency** — that is +decided synchronously inside `create_order` — so pair it with +`reserve_status.availableToSell`. + +### The reserve is the one metric anyone can poll — including you + +The reserve is an account on the cycles ledger, so `icrc1_balance_of` answers for +free, from any identity, with no cooperation from this canister. That is the +transparency thesis working: the number an operator monitors is the same number a +buyer can check. + +What the canister adds is the part only it knows — **how much of that balance is +already promised**. `reserve_status` reports `reserveFloor − promisedTotal = +availableToSell`, and the three figures together separate the causes of a refusal: + +| Reading | Means | +|---|---| +| ledger balance high, `reserveFloor` low | a top-up nobody observed → `refresh_reserve` | +| `reserveFloor` fine, `promisedTotal` high | genuinely committed to live orders → wait or raise the reserve | +| `availableToSell` 0 with both low | the reserve is spent → fund it | + +**`reserveObservedAtNs` is what makes the first row diagnosable**, and +`recovery_status.lastReserveReconcileAttemptNs` is its pair: an attempt clock newer +than the success clock means the hourly reconcile is running and *not adopting* — +either the ledger read is failing or every attempt landed while a delivery was in +flight. Both under-sell rather than over-sell, so it explains refusals and is never +a loss. + +### Metric table + +Severity: **P1** = wake someone; **P2** = same working day; **P3** = review weekly. + +| Metric | Alert when | Sev | Action | +|---|---|---|---| +| `pricing_status.lastAttempt.ok` | false on two consecutive ticks | **P1** | the pricing-rates section — order creation stops once the cache passes `maxAgeNs`. `detail` names the failing guard | +| `pricing_status.rates.fetchedAtNs` | older than `maxAgeNs` | **P1** | the rail has stopped selling. Timer dead or every tick rejected | +| `cycles_status.balance` | below 3× `minCanisterCycles` | **P1** | top up. At zero the canister is **uninstalled** and money-bearing state is lost. Note the XRC needs 1 B attached per refresh, so pricing dies before the gate does | +| anonymous `can_purchase` | returns `#err` | **P1** | the rail is refusing sales; the reason says which lever | +| `orphan_depth.unresolved` | `> 0` | **P2** | triage. Depth climbing past 1,000 means work is accumulating faster than it clears | +| `reserve_status.promisedTotal` | climbing while deliveries do not complete | **P2** | money in, nothing delivered — those orders are on the clock toward the 72 h bound. `pending_deliveries` says which and why | +| `recovery_status.lastSweep.atNs` | older than 2 intervals | **P2** | the sweep timer is not running; nothing recovers while it is dead | +| `recovery_status.lastCountReconcile.drift` | non-empty | **P2** | a per-status tally had diverged and was **raised** to the recount. The counts are correct again; the bookkeeping bug that moved them is not fixed. They gate admission and they short-circuit the sweeps, so an under-counted `Paid` reads as zero to the recovery sweep and money-out silently stops | +| `recovery_status.lastCountReconcile.refused` | non-empty | **P2** | **The opposite reading to the row above, and the tallies are still suspect.** The recount came out **below** the maintained tally, which is indistinguishable from the non-terminal index missing a member — so it was refused and the maintained value stands. That over-refuses rather than overselling, which is why refusing is right. Two causes with one response, *find the writer*: either the index lost a member or a tally gained an adjustment. `orders.unindexedHolders` from the rotating scan tells you which | +| `orders.unindexedHolders` in the audit log | any occurrence | **P1** | ⚠️ **The one bookkeeping error nothing else can see, and it is on the money side.** An order held a promise and was missing from the non-terminal index — and if `promised` was missing its cycles too, the two agreed with each other, so the daily reconcile reported nothing while `reserve_status.availableToSell` read HIGHER than the truth. The reserve was oversellable. The scan added the order and the next reconcile raises `promised`, so the exposure closes on its own; what does not close is the writer that set a status outside `Orders.create` and `Orders.commitTransition`. Find it. Then check `reserve_status.availableToSell` against the ledger before selling more | +| `orders.staleHolders` in the audit log | any occurrence | **P2** | the reverse direction: a **terminal** order was still in the non-terminal index, so `promised` may have been holding cycles that were already released — over-refusing, not overselling. Dropped on sight, because the order's own status is the authority. Same writer to find as the row above | +| `orders.problemIndexDrift` / `orders.unindexedProblems` in the audit log | any occurrence | **P2** | **Not a data problem — a code problem.** The unresolved-problems index is maintained by exactly two functions, `Orders.fileProblem` and `Orders.resolveProblems`. Either tag means something else wrote `order.problems` directly. **The repair is not the fix**: find the writer. `problemIndexDrift` is an id in the index with no unresolved problem — the worklist showed an obligation that was already closed. `unindexedProblems` is the worse direction: an order carried an unresolved obligation the worklist did **not** show and `resolveByPaymentRef` could not reach | +| `orders.expiredWentBackwards` in the audit log | any occurrence | **P2** | the `Expired` tally fell, which the transition matrix makes impossible — `created → expired` is its only inbound edge and it has no outbound one. A bookkeeping breach in `Orders.bump`. **Reported once per decrease, not daily**, so a second occurrence means it fell again. `Expired` is an operator metric that nothing decides on, so this is a bug report rather than an exposure | +| `orders.expiredOverflow` in the audit log | any occurrence | **P2** | the `Expired` tally plus the non-terminal order count exceeds the number of orders in the store. Those two sets are disjoint subsets of it, so this is arithmetically impossible and `Expired` is over-counted. Same class as the row above — observability, not money | +| `recovery_status.indexScan.lastCompletedCycle` | empty, or `completedAtNs` older than a small multiple of `indexScan.expectedFullCycleNs` | **P2** | **Without this the clean-scan rows above mean nothing.** The rotating scan verifies the one property that needs every order, so it can only speak for what it has visited: silence plus a recent `completedAtNs` is *verified clean*, silence with no completed cycle is *unverified*. **Compare against `indexScan.expectedFullCycleNs`, not against a remembered number** — it is computed from the live store size and the live sweep cadence, and `set_recovery_interval` can move it by 24×. Empty long past that, with `inFlightCycle.ordersRead` frozen, means a chunk is trapping — the cursor does not advance, so the next sweep retries the same chunk rather than skipping forward | +| `recovery_status.lastCountReconcile.atNs` | older than ~48 h while `lastSweep` advances, or materially older than `lastCountReconcileAttemptNs` | **P3** | the daily reconcile is failing. It runs in its own message, so it cannot take the sweep down with it — money-out is unaffected — but the tallies are now **unverified**, not known-good. Written only on success, and the cadence is claimed by the sweep, so a reconcile that traps retries daily rather than every tick. `recount_orders` is the on-demand repair and will show the same failure if it is a real one | +| `reserve_status.openOrders` | climbing while `delivered` does not | **P3** | order-creation abuse; lever is `maxOpenOrdersPerPrincipal` (the admission-gate section) | +| `refusal_counts.refusingNow.reserveShort` | true | **P1** | the rail is refusing sales for want of reserve. Exactly one `gate.startedRefusing` line marks when it began — the counter says how many buyers have been turned away since. Fund the reserve (the reserve section); it clears on the next successful admission | +| `refusal_counts.refusingNow.canisterCyclesLow` | true | **P1** | the gate is refusing on its own gas. Same shape as above, different lever: top up the canister (`cycles_status`). **Do not expect a corroborating `pricing_status` alert, and do not read its absence as a false positive.** At the 5 T default the gate closes while the balance is still ~5000x the 1 B a rate call must attach, so pricing keeps working long after sales stop. Pricing alerting *too* means the floor has been set near `0`, which is a second finding. Sales are closed but delivery is not: orders already paid for keep being delivered, so this is revenue stopping, not money going missing | +| `refusal_counts.refusingNow.railClosed` | true | **P1** | ⚠️ **The rail is not provisioned, so every purchase is being refused before the gate is even consulted.** Expected on a fresh deployment — `docs/OPERATE.md` provisions the secrets last — and an incident at any other time. `stripe_api_key_status().isSet` and `stripe_origin()` say which half is missing. One `gate.startedRefusing` line marks when it began; `counts.railClosed` says how many buyers hit it since | +| `refusal_counts.refusingNow.stripeApiFailing` | true | **P1** | ⚠️ **The key is present but Stripe is refusing it — rotated or revoked without updating the canister.** Distinct from `railClosed`: that one says *provision the key*, this one says *rotate it*. `sessionConfig` cannot detect it, so every purchase reaches the outcall, 401s, and is refused. ⚠️ **Each attempt also commits an order and expires it**, and the open-order cap does not bound that because the record is not `Created` — so the order table grows while this is true. Set a fresh restricted key (the presets-and-keys section); it clears on the next successful session | +| `refusal_counts.counts.amountBelowMin` | climbing | **P3** | **Two very different causes and the counter cannot tell them apart.** Either the buy UI is offering an amount the gate refuses — a bug, and every affected buyer sees a dead end — or someone is probing the cheapest free refusal there is (one cent needs no order, no payment, no prior state). Compare against `reserve_status.openOrders`: climbing refusals with flat order creation is probing, climbing alongside real traffic is the UI | +| `refusal_counts.counts.amountAboveMax` | climbing | **P3** | buyers are asking for more than the per-order ceiling allows. If it is sustained the ceiling is mispriced for demand, not misconfigured — raising it raises per-order reserve exposure (the admission-gate section), so treat it as a pricing decision | +| `refusal_counts.counts.tooManyOpenOrders` | climbing | **P3** | buyers hitting the one-open-order cap. Expected in normal use — a buyer who abandons a checkout and retries meets it — so alert on the **rate**, not the total | +| `refusal_counts.counts.railClosed` | climbing while `refusingNow.railClosed` is **false** | **P2** | **Should be unreachable.** `sessionConfig` can only fail with a missing key or origin, both of which latch — so a climbing counter with a clear flag means it failed some other way, and the mapping in `Main.mo`'s `railClosureCondition` needs re-reading against the current `Session.Error` | +| `reserve_status.availableToSell` | 0, or far below `reserveFloor` − `promisedTotal` as you expect it | **P2** | the gateway is refusing sales. Three causes and the same query separates them: the reserve is genuinely spent (`reserveFloor` low), it is committed to live orders (`promisedTotal` high), or **the floor has not observed a top-up** (`reserveObservedAtNs` old). The last is the common one and the lever is `refresh_reserve` | +| `reserve_status.reserveObservedAtNs` | materially older than `recovery_status.lastReserveReconcileAttemptNs` | **P3** | the hourly reserve reconcile is attempting and not adopting: either the ledger read is failing (`reserve.observeFailed` in the audit log) or every attempt lands while a delivery is in flight (`reserve.reconcileSkipped`). Under-sells rather than over-sells, so it explains refusals; it is not a loss | +| `delivery.feeChanged` in the audit log | on **every** delivery rather than once | **P3** | the stored cycles-ledger fee is stale, so every order pays one rejected call before its transfer lands. Self-correcting by design — the first `#BadFee` persists the ledger's value — so a *repeating* tag means the correction is not sticking (an upgrade reverting the stored value, or the ledger's fee moving repeatedly). **This is the ONLY detector for a stored fee that will not stick**, since nothing but the ledger writes that value and the persistence itself is untested (`docs/TEST-COVERAGE.md`). Each occurrence costs one rejected call, never a wrong debit — the buyer still gets the quoted amount and the reserve absorbs the real fee. If it repeats, redeploy rather than looking for a lever; there is none, deliberately | +| `refusal_counts.refusingNow.stripeApiFailing` true, with `gate.startedRefusing` naming **retrieve REFUSED** | any occurrence | **P1** | **The restricted key cannot read Checkout Sessions, so stranded capacity can never be released automatically.** The recovery sweep's retrieve is 401/403ing. Fix the key's permission (Checkout Sessions = **Write**, which is the level that also grants read — the presets-and-keys section) and rotate it in; nothing else recovers. Until it is fixed, `expire_order` is the manual release.

**A per-occurrence `stripe.retrieveUnauthorized` tag was rejected for this.** It would fire **once per stranded order per hourly pass** — up to ~240 permanent lines a day for one unfixed problem once the ring was gone. Our own cadence bounded a *rate*, and a rate against an unfixed persistent condition is unbounded over time. It is now the same latched condition as a failing session *create* or *expire*, because a 401 on any of the three is one incident with one lever: **rotate the key** | +| buyers report that cancelling does nothing, or `admin_orders` shows no order has ever reached `cancelled` | any occurrence | **P2** | **`cancel_order`'s "Stripe would not close the payment session" answer has three causes and the buyer-facing arm deliberately records none of them.** Two are normal and settle themselves (the payment won the race, or the session had already expired); the third is a malformed expire request from this canister, which leaves the order `#created` and payable while every cancel fails the same way. That is what makes one manual run diagnostic: `expire_order '("")'` takes the identical path, is admin-authenticated, and audits Stripe's body verbatim as `order.expireRaced`. Read that line. A session-state refusal there means the two normal causes; anything else is ours to fix. There is no counter for this yet — `Gate.RefusalCounts` cannot gain a field without an upgrade-incompatible stable change — so the detection is this row, not a metric | +| `stripe.retrieveFailed` in the audit log | repeatedly for the same order | **P3** | Stripe is unreachable or answering non-200 for the session read. Distinct from the row above on purpose: **"Stripe refused the read" and "Stripe is down" are different actions.** Transient failures retry hourly and need nothing; a persistent one means the outcall path is broken, so check `pricing_status` (the same egress) before suspecting the key | +| `stripe.paidAwaitingEvent` in the audit log | any occurrence | **P2** | a buyer paid and Stripe has not delivered `checkout.session.completed`. **Not yet an obligation** — Stripe redelivers for ~3 days and the entry is deliberately withheld until then — but it IS the support signal: the buyer's own page renders expired from `expiresAtNs`, so expect a contact the same hour. If it is one order, resend the event from the Dashboard now rather than waiting. If it is many, the webhook endpoint is broken: check the secret and the subscribed event list (the presets-and-keys section) | +| `stripe.paidNotCredited` in the audit log | any occurrence | **P1** | a buyer paid, Stripe has given up redelivering, and the obligation is now filed on the order itself. Follow the `#paidNotCredited` triage row — **resend first, always** | +| `stripe.retrieveUnreadable` in the audit log | any occurrence | **P3** | Stripe answered the session read in a shape the classifier does not recognise, so the sweep did nothing (fail-safe). Capacity stays held until it is understood. Most likely an API-version change; `docs/OPERATE.md` pins the version, so this points at an account-level change | +| `reserve.unexplainedShortfall` in the audit log | any occurrence | **P1** | the ledger holds LESS than the floor's lower bound, which the design says is impossible — no allowance exists and `withdraw` is unused. Treat as a bookkeeping breach: stop selling (`set_gate_config` with a high `minPurchaseUsdCents`, or pause), reconcile the journal against the ledger, and find the outflow before funding anything | +| an order still `created` past its own `expiresAtNs` | any | **P2** | a `checkout.session.expired` was missed. Nothing sweeps it (the order-expiry section, deliberately — a sweep would hide a held reserve): resend the event from the Stripe Dashboard. Query it with `admin_orders '(record { status = opt variant { created }; owner = null; createdFromNs = null; createdToNs = null; withUnresolvedProblems = false }, null, 200)'` and compare each `expiresAtNs` against now | +| `pricing_status.xrcCanisterId` | anything other than `uf6dk-hyaaa-aaaaq-qaaaq-cai` | **P1** | **on mainnet this must be the real Exchange Rate Canister.** The id is resolved from a `PUBLIC_CANISTER_ID:xrc` canister environment variable so a local network can point at a mock; a mainnet canister reporting any other id is pricing real sales off something that is not the market. Only a controller can inject it, so this reads as either a misconfigured deploy or a compromised controller. **There is no burn cap to pull — it went with the ICP mint path.** To stop new orders while investigating, `set_gate_config` with `minCanisterCycles` above the canister's current balance: the gate refuses every creation. It does **not** stop delivery of an order already paid, or the webhook. **`null` is not a pass** — it means no refresh has reached the XRC call at all (expected for seconds after an install or upgrade, since the value is transient). Do **not** wait on `lastAttempt` becoming non-null: that field is persistent, so it survives the upgrade and is already set while this one is still null. Re-read until `lastAttempt.atNs` post-dates the deploy. A *failing* refresh never shows null here — the id is recorded when the call is constructed, so a rejected call reads as a non-null id plus `lastAttempt.ok = false` | +| `pricing_status.rates.quality.receivedRates` | drops to `minRateSources` | **P3** | thin market — a price from 2 sources is not one from 12 | +| `health` | unreachable | **P1** | canister stopped, frozen, or out of cycles | + +**One row above needs a controller key and a comparison you make yourself, and +saying so is the point.** "An order still `created` past its own `expiresAtNs`" is +the reserve's detection predicate 1 — the signal that exists *because* there is no +sweep that would have hidden a held reserve. + +`admin_orders` answers it: filter on `created` and read each record's +`expiresAtNs`. What it is **not** is a threshold something can alert on — the +predicate is a comparison against the clock, so there is no field to watch. The +public interim signal remains `reserve_status.openOrders` **staying non-zero and +static well past ~40 minutes** (the session lifetime is ~35), cross-checked against +expired sessions in Stripe; a cron that can hold the controller key should page the +filtered listing and do the comparison itself. + +⚠️ **`get_order` / `list_orders` / `receipt` stay owner-scoped**, and `admin_order` / +`admin_orders` / `admin_receipt` are the controller-side reads that answer the same +questions across principals. `reserve_status` returns counts, not records, so it can +never tell a fresh `created` order from one that lapsed an hour ago. + +### Needs a controller key + +- **`audit_log` tags** worth alerting on: `stripe.livemodeMismatch` (real money may + be landing in the wrong Stripe account), `stripe.creditedElsewhere`, + `stripe.unprocessable` / `stripe.unhandledType` (a Dashboard config producing + events this gateway cannot use — it will recur until changed), + `orders.countDrift` and `orders.countRecountLow` (the status tallies were wrong, and + whether the pass could repair them; see the table above), + `orders.unindexedHolders` (**P1** — the reserve may have been oversellable), + `stripe.refundPartial` (an obligation deliberately left open), and `delivery.stuck`. +- **`orphans_unresolved`** for the entries themselves. `orphan_depth` is + public, so **alert on the public depth and only fetch details when it fires** — + that keeps the key out of the polling loop. +- ⚠️ **The audit log drops nothing, so a gap in `seq` is not a signal** — do not alert + on one. `seq` is a never-reused ordering and nothing else. What the log IS, though, is + *telemetry*: the order store, delivery journal and orphan list are the records of + money, and an order's own `problems` array is where its obligations live. + +### Off-chain + +- **Stripe Dashboard → event deliveries.** A run of failures means the secret is + out of sync or the gateway is unhealthy. Stripe retries non-2xx for ~3 days, so + transient failures lose nothing — but a *permanent* 4xx can get the endpoint + disabled, which is why verified-but-unprocessable events are acked 200 (triage). +- **Stripe payouts.** No on-chain signal, so the Dashboard is the only view. + **Disputes DO produce one**: `charge.dispute.created` is parsed and audited as + `stripe.disputeCreated` + (`rails/Card.mo`), carrying the intent and the amount. It is audit-only — the cycles are + delivered and irreversible while the card network pulls the fiat back — so the Dashboard + is still where it is resolved, but the operator learns it happened from the audit log. + +### Do you need a dashboard? + +**Alerting first, dashboard second** — and the order matters. The failure modes +here are slow (a 2 h alert window, a 72 h terminate bound), so what you need is +something that reaches a human at 03:00, not a page someone visits. A cron job +polling the public queries and posting to Slack/PagerDuty covers the entire table +above except the audit tags, and needs no credentials. + +A dashboard earns its place afterwards, for the things alerts are bad at: reserve +drawdown over time, order volume, delivered-vs-open ratios, rate quality. Everything +it needs is a public query — `reserve_status` and the cycles ledger between them — so +a read-only operator page is straightforward. It is a convenience, not a control. + +## 9. Confidential-subnet checklist (§7, §11.1) + +The webhook secret is plaintext canister state, and SEV-SNP is the intended +confidentiality layer. ⚠️ **There is no cap standing behind it any more**: a forged +webhook delivers from the reserve, so **the reserve balance is the blast radius** and +sizing it is the always-on control (the webhook-secret section). Launch does not block on SEV, but that +trade is now "size the reserve to what a leak could cost", not "the cap bounds it". Before relying on a confidential subnet for the secret, verify — in this order, hardest first: -- [ ] **Checkpoint/state-sync confidentiality**: SEV-SNP protects RAM, but - canister state is checkpointed to disk and state-synced between nodes. - Confirm with DFINITY that both paths are encrypted on the target subnet — - **if they aren't, a plaintext secret leaks there and SEV buys nothing** - (§7: "verify this hardest"). +- [x] **Checkpoint/state-sync confidentiality** — **confirmed encrypted on the + target subnet** (owner, 2026-09-11). SEV-SNP protects RAM; canister state is also + checkpointed to disk and state-synced between nodes, and both of those paths are + confidential here too. This was §7's "verify this hardest" item, because either + one in the clear would have leaked the plaintext secret and SEV would have bought + nothing. - [ ] **Attestation coverage**: every replica in the subnet runs attested SEV-SNP (one unattested node = one node provider who can read the secret). @@ -358,41 +1195,100 @@ secret, verify — in this order, hardest first: history; trust shifts to AMD, not math). - [ ] **Provisioning channel**: ingress still TLS-terminates at the boundary node. Unless an attestation-tied confidential provisioning - channel exists, follow §2's rotate-after-provisioning rule even on the + channel exists, follow the webhook-secret section's rotate-after-provisioning rule even on the confidential subnet. - [ ] **Migration**: moving subnets is a canister migration — re-verify the module hash after (RELEASE.md gate) and rotate the secret (it transited infrastructure during the move). -Until all boxes tick: the secret lives plaintext on a normal subnet, and -the protections are exactly (a) the burn cap sized tight (§5) and -(b) accountable node providers. That is the documented, accepted §7 -posture — the loss is bounded, detectable, recoverable. +Until all boxes tick: the secret lives plaintext on a normal subnet, and the +protections are exactly (a) **the reserve sized to what a leak could cost** (the webhook-secret and reserve sections) +and (b) accountable node providers. That is the documented, accepted §7 posture — the +loss is bounded by the reserve balance, detectable in the audit log and the order +store, and recoverable only to the extent that fiat was never taken for the forged +orders. Related §11.1 note for future rails: the four Base seams (Owner variant, route table, edge-captured ownership, per-rail expiry) are binding on code changes, not operations — but any new rail lands with its own runbook -section, its own dedup set, and its own go-live checklist entry here. +section, its own dedup set, and its own entry in `docs/OPERATE.md`'s Mode 3. -## 11. Upgrades & releases +## 10. Upgrades & releases `RELEASE.md` end to end: reproducible container build → publish `MODULE-HASHES.txt` → `icp deploy -e ic --mode upgrade` → **gate on `icp canister status` matching the published hash**. Operational notes the release doc doesn't cover: -- Upgrades are **safe mid-flight by design** (§5.1): in-flight mints - resume from the persisted journal via the re-armed timer; an upgrade - mid-money-movement degrades to a recoverable stage (`staleIntent` / - `ambiguousForward` worst case), never a double-spend. No drain/pause is - required before upgrading — but an idle moment is still politer to - users. -- Persistent state (orders, journals, dedup sets, configs, secret, cap - consumption) survives upgrades via orthogonal persistence. **Transient - knobs reset on upgrade**: error-queue capacity (1,000), audit-log - capacity (4,096), HTTP body cap (64 KiB), and both single-flight guards - — that reset is deliberate (a guard stuck by an upgrade can't deadlock - anything). +- **Stop the canister before upgrading. This is mandatory, not advisory.** + The IC rejects an upgrade while the canister has outstanding message + callbacks: + + ``` + canister_pre_upgrade attempted with outstanding message callbacks + (try stopping the canister before upgrade) + ``` + + So the procedure is always: + + ```bash + icp canister stop backend -e ic --identity + icp deploy -e ic --mode upgrade + icp canister start backend -e ic --identity + ``` + + `icp deploy --mode upgrade` sets the `wasm_memory_persistence = keep` + option that enhanced orthogonal persistence requires; a hand-rolled + `install_code` without it is rejected with *"Enhanced orthogonal + persistence requires the `wasm_memory_persistence` upgrade option"* — and + `replace` would discard every order, journal, and dedup set. + +- **Stopping drains in-flight calls, it does not drop them.** The canister + enters `Stopping`, the IC delivers the replies to its outstanding calls, + and only once every call context is closed does it reach `Stopped`. That + is why the stop-first procedure is also the *safe* one: an in-flight delivery + completes before the upgrade happens, so a controlled + upgrade cannot strand money. Verified by `test/integration` scenarios 12 + and 13. + + Consequence: `staleIntent` is **not** reachable through a controlled upgrade — + an in-flight transfer settles before the upgrade happens. It covers genuine + faults: a ledger that never replies, a subnet incident, running out of cycles + mid-call. The triage section's rules apply when it appears. + +- **A call that never replies blocks the stop, and therefore the upgrade.** + If `icp canister stop` hangs, the canister is waiting on an outstanding + call. Check `recovery_status.sweepInFlight` and the audit log for a stage + that keeps retrying; the money path is journalled at every step, so + waiting is safe. + +- **Locally, a stable-shape change is a reinstall, not a migration.** A new + field, a removed variant tag or a changed config record makes the upgrade trap + in `register_stable_type` — enhanced orthogonal persistence refuses to + reinterpret the existing memory. On a local network the answer is: + + ```bash + icp deploy --mode reinstall --yes + ./scripts/local-dev-seed.sh + ``` + + which wipes local orders, the audit log and the delivery journal, and takes + seconds. `scripts/e2e-local.sh` detects the trap and does it automatically. + + **This is a development lever and has no mainnet counterpart.** `reinstall` + discards every order, journal and dedup set, so on mainnet a shape change needs + the mops migration chain (section 1.1 item 1) — which is why no migration file is + written before the schema settles: every file replays forever on a fresh install. + +- In-flight deliveries resume from the persisted journal via the re-armed timer + (§5.1), so an interrupted money movement degrades to a recoverable stage, + never a double-spend. +- Persistent state (orders, their problems, journals, dedup sets, configs, + secret) survives upgrades via orthogonal persistence. **Transient knobs reset + on upgrade**: the HTTP body cap (64 KiB), the single-flight guards, and the + index-scan cursor's in-flight cycle — that reset is deliberate (a guard stuck + by an upgrade cannot deadlock anything). **There is no error-queue capacity and no + audit-log ring**, so neither is a knob to re-check after a deploy. - After every upgrade: `health`, `recovery_status` (timer re-armed), `webhook_secret_status.generation` unchanged, one test order end-to-end if the change touched money paths. diff --git a/afk-ralph.sh b/afk-ralph.sh deleted file mode 100755 index 2e71c45..0000000 --- a/afk-ralph.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/bin/bash -# -# afk-ralph.sh — drive an autonomous Claude agent through a PRD, one task per iteration. -# -# Runs `claude-cq` inside an `sbx` sandbox up to times. Each iteration, -# the agent reads progress.txt, implements the highest-priority task, runs tests, -# updates the PRD, appends to progress.txt, and commits. The loop stops early when -# the agent reports the PRD is complete by emitting COMPLETE. -# -# Usage: ./afk-ralph.sh -# -set -e - -if [ -z "$1" ]; then - echo "Usage: $0 " - exit 1 -fi - -# For some reason setting the token with `sbx secret set -g github -t "$(gh auth token)"` -# doesn't seem to work -export GH_TOKEN=$(gh auth token) - -for ((i=1; i<=$1; i++)); do - echo "Running loop $i of $1" - - if claude -p "/usage" | grep -q "100%"; then - echo "Usage limit reached" - exit 1 - fi - - result=$(sbx run claude-cyclepay -- --permission-mode acceptEdits -p "@progress.txt \ - 1. Find the highest-priority task and implement it. \ - 2. Run your tests and type checks. \ - 3. Update the PRD with what was done. \ - 4. Append your progress to progress.txt. \ - 5. Commit your changes. \ - 6. Push to origin. \ - ONLY WORK ON A SINGLE TASK. \ - If the PRD is complete, output COMPLETE.") - - echo "$result" - - if [[ "$result" == *"COMPLETE"* ]]; then - echo "PRD complete after $i iterations." - exit 0 - fi -done diff --git a/deployed/backend.most b/deployed/backend.most new file mode 100644 index 0000000..11cdaa8 --- /dev/null +++ b/deployed/backend.most @@ -0,0 +1,282 @@ +// Version: 1.0.0 +type Account__101895817 = {owner : Principal; subaccount : ?Blob}; +type Config__571484609 = + { + divisor : Nat; + feeBps : Nat; + feeFixedCents : Nat; + maxAgeNs : Int; + maxRateDeltaBps : Nat; + minRateSources : Nat + }; +type Config__862746442 = {alertAfterNs : Int; maxHoldNs : Int}; +type Config__877538123 = + { + maxOpenOrdersPerPrincipal : Nat; + maxPurchaseUsdCents : Nat; + minCanisterCycles : Nat; + minPurchaseUsdCents : Nat + }; +type Data__265875254 = {var count : Nat__58649523; kvs : [var ?(K, V)]}; +type Data__859131645 = {var count : Nat__58649523; elements : [var ?T]}; +type Destination__693442483 = {#cyclesLedgerAccount : Account__101895817}; +type Drift__505569657 = {is : Nat; status : Text; was : Nat}; +type Entry__161555541 = + { + createdAtNs : Int; + detail : Text; + id : Nat; + kind : Kind__893817772; + rail : Rail__555097943; + resolvedAtNs : ?Int + }; +type Event__282987190 = {atNs : Int; detail : Text; seq : Nat; tag : Text}; +type ExpiredBy__606996773 = {#sessionExpired; #sessionFailed}; +type Internal__161573640 = + {children : [var ?Node__430031793]; data : Data__265875254}; +type Internal__778364379 = + {children : [var ?Node__1045371124]; data : Data__859131645}; +type JournalEntry__248943069 = + { + blockIndex : ?Nat; + createdAtNs : Int; + cyclesDelivered : ?Nat; + destination : Destination__693442483; + lastError : ?Text; + orderId : OrderId__370787278; + retries : Nat; + status : OrderStatus__644516197; + transferIntent : ?TransferIntent__612776172; + updatedAtNs : Int + }; +type Kind__893817772 = + { + #unattributed : {claimedRef : Text; paymentRef : Text}; + #unprocessable : {eventId : Text; field : Text} + }; +type Leaf__1045638083 = {data : Data__265875254}; +type Leaf__776840865 = {data : Data__859131645}; +type Map__13625999 = + {var root : Node__430031793; var size : Nat__58649523}; +type Map__386906547 = Map__670138087; +type Map__670138087 = Map__13625999; +type Nat__58649523 = Nat__920180692; +type Nat__920180692 = Nat; +type Node__1045371124 = + {#internal : Internal__778364379; #leaf : Leaf__776840865}; +type Node__430031793 = + {#internal : Internal__161573640; #leaf : Leaf__1045638083}; +type Node__671611173 = + { + var next : ?Node__671611173; + var previous : ?Node__671611173; + value : T + }; +type OrderId__370787278 = Text; +type OrderStatus__644516197 = + { + #abandoned; + #cancelled; + #created; + #delivered; + #expired; + #needsReview; + #paid + }; +type Order__859278382 = + { + abandonedReason : ?Text; + createdAtNs : Int; + delayedAtNs : ?Int; + destination : Destination__693442483; + expiredBy : ?ExpiredBy__606996773; + expiresAtNs : ?Int; + id : OrderId__370787278; + lockedCycles : Nat; + owner : Owner__1066618498; + paidUsdCents : ?Nat; + pricing : Pricing__738330320; + problems : [Problem__103489883]; + rail : Rail__555097943; + status : OrderStatus__644516197; + stripeSessionId : ?Text; + stripeSessionUrl : ?Text; + updatedAtNs : Int + }; +type Owner__1066618498 = {#ii : Principal}; +type Pricing__738330320 = + { + feeBps : Nat; + feeFixedCents : Nat; + rateQueriedSources : Nat; + rateReceivedRates : Nat; + rateStandardDeviation : Nat; + ratesFetchedAtNs : Int; + usdCents : Nat; + usdPerIcpMicros : Nat; + xdrPermyriadPerIcp : Nat + }; +type ProblemKind__103121566 = + { + #deliveryStuck : {stage : Text}; + #duplicate : {paymentRef : Text}; + #paidNotCredited : {paymentRef : Text; sessionId : Text}; + #refundAfterDelivery : + { + cycles : Nat; + fullRefund : Bool; + paymentRef : Text; + refundedCents : Nat + } + }; +type Problem__103489883 = + { + detail : Text; + filedAtNs : Int; + kind : ProblemKind__103121566; + resolvedAtNs : ?Int + }; +type Quality__631647963 = + {queriedSources : Nat; receivedRates : Nat; standardDeviation : Nat}; +type Queue__571493 = Queue__594378947; +type Queue__594378947 = + { + var back : ?Node__671611173; + var front : ?Node__671611173; + var size : Nat__58649523 + }; +type RailStateLatch__373298532 = + { + canisterCyclesLow : Bool; + railClosed : Bool; + reserveShort : Bool; + stripeApiFailing : Bool; + unboundedGiveaway : Bool + }; +type Rail__555097943 = {#card}; +type Rates__1063802498 = + { + fetchedAtNs : Int; + quality : Quality__631647963; + usdPerIcpMicros : Nat; + xdrPermyriadPerIcp : Nat + }; +type RefusalCounts__727691946 = + { + amountAboveMax : Nat; + amountBelowMin : Nat; + buyerNotAllowed : Nat; + canisterCyclesLow : Nat; + railClosed : Nat; + reserveShort : Nat; + stripeApiFailed : Nat; + tooManyOpenOrders : Nat; + unboundedGiveaway : Nat + }; +type Set__195681557 = Set__29061695; +type Set__29061695 = + {var root : Node__1045371124; var size : Nat__58649523}; +type Tier__1004729360 = {id : Text; usdCents : Nat}; +type TransferIntent__612776172 = + { + amountCycles : Nat; + createdAtTimeNs : Nat64; + memo : Blob; + to : Account__101895817 + }; +actor { + stable adminPrincipals : + {var root : Node__1045371124; var size : Nat__58649523}; + stable allowedBuyers : + {var root : Node__1045371124; var size : Nat__58649523}; + stable auditLog : + {events : Queue__571493; var nextSeq : Nat}; + stable cancelRequests : + { + var root : Node__1045371124; + var size : Nat__58649523 + }; + stable dedup : + { + stripeEvents : Map__386906547; + stripeIntents : Map__386906547 + }; + stable deliveryJournal : + { + var root : Node__430031793; + var size : Nat__58649523 + }; + stable deliveryState : {var config : Config__862746442}; + stable gateState : + { + var config : Config__877538123; + var latch : RailStateLatch__373298532; + var refusals : RefusalCounts__727691946 + }; + stable orderStore : + { + counts : Map__386906547; + var deliveredCycles : Nat; + var deliveredNullPaid : Nat; + var deliveredOrders : Nat; + var deliveredUsdCents : Nat; + var expiredHighWater : Nat; + orders : Map__386906547; + principalsToOrders : + Map__386906547>; + promiseHolders : Set__195681557; + var promised : Nat; + var tallySaturations : Nat; + unresolvedProblems : Set__195681557 + }; + stable orphanStore : + {entries : Map__386906547; var nextId : Nat}; + stable paidIntents : + { + var root : Node__430031793; + var size : Nat__58649523 + }; + stable pricingState : + { + var config : Config__571484609; + var lastAttempt : ?{atNs : Int; detail : Text; ok : Bool} + }; + stable rateCache : {var rates : ?Rates__1063802498}; + stable recoveryState : + { + var indexScanCursor : ?OrderId__370787278; + var indexScanCycle : + {ordersRead : Nat; repairs : Nat; startedAtNs : Int}; + var lastCountReconcile : + ?{ + atNs : Int; + drift : [Drift__505569657]; + ordersRead : Nat; + refused : [Drift__505569657] + }; + var lastCountReconcileAttemptNs : Int; + var lastIndexScanCycle : + ?{ + completedAtNs : Int; + ordersRead : Nat; + repairs : Nat; + startedAtNs : Int + }; + var lastReserveReconcileAttemptNs : Int; + var lastSweep : ?{atNs : Int; pending : Nat}; + var sweepIntervalNs : Nat + }; + stable reserveState : + { + var cyclesLedgerFee : Nat; + var floor : Nat; + var observedAtNs : ?Int; + var outflowsIssued : Nat + }; + stable stripeApiKey : + {var generation : Nat; var secret : ?Blob; var setAtNs : ?Int}; + stable stripeState : {var expectLivemode : ?Bool; var origin : ?Text}; + stable tierState : {var cards : [Tier__1004729360]}; + stable webhookSecret : + {var generation : Nat; var secret : ?Blob; var setAtNs : ?Int} +}; diff --git a/design-docs/ARCHITECTURE_ANALYSIS.md b/design-docs/ARCHITECTURE_ANALYSIS.md deleted file mode 100644 index 1b8176f..0000000 --- a/design-docs/ARCHITECTURE_ANALYSIS.md +++ /dev/null @@ -1,368 +0,0 @@ -# Cycle.Express — Architecture Analysis & Comparison - -> A walkthrough of how Cycle.Express works, the failure scenarios it does not -> account for, how it compares to the larger CyclePay system, and what a -> fully on-chain architecture would take. Captured from a design review session. - ---- - -## 1. Overview: how Cycle.Express works - -**One line:** Cycle.Express lets developers buy Internet Computer *cycles* (the -gas that powers canisters) with **fiat via Stripe** — no cryptocurrency required. -You give it a canister ID, pay with a card, and it tops up your canister. - -The defining trait: **the entire app — frontend, backend, and the Stripe -webhook endpoint — lives inside a single IC canister.** No separate web server, -no database. The canister *is* the website and the API. - -### Components - -**Backend (Motoko) — `src/backend/`** - -| File | Role | -|------|------| -| `Main.mo` (615 lines) | Actor class `CycleExpress`. Holds all state, the HTTP server, the transaction processor, the persistent log, and asset-store endpoints. | -| `Util.mo` | Parsing Stripe webhook JSON, client/session IDs, Stripe HMAC signature verification, dynamic cycle-price calculation. | -| `Hmac.mo` | SHA-256 HMAC (Stripe signs webhooks with HMAC-SHA256). | -| `Account.mo` | ICRC-1 account encode/decode. Vendored from dfinity/ICRC-1. | -| `CyclesLedger.mo` | Auto-generated Candid bindings for the Cycles Ledger canister. | - -The backend embeds **`mo:server`** (Motoko Http Server), which lets a canister -serve HTTP and host static assets — the trick that makes one canister act as -both client and server. - -**Frontend (JS + SCSS) — `src/frontend/`** — vanilla-JS single-page app with a -3-step wizard (Input → Payment → Receipt), bundled by Webpack into `dist/` and -uploaded into the canister as assets. - -**Docs & assets** — Markdown converted to HTML by `pandoc`; `.well-known/ic-domains` -registers the custom domain `cycle.express`. - -**External actors** — Stripe (payment gateway), the IC Management Canister -(`aaaaa-aa`, for plain canister top-ups), and the Cycles Ledger -(`um5iw-...`, for ICRC deposits with a subaccount). - -### Init parameters & economic model - -The canister is instantiated with (`Main.mo:30-35`): - -- `prodKey` / `testKey` — Stripe **webhook signing secrets** (for `/checkout` and `/test-checkout`). -- `margin` — profit margin (percent). -- `defaultPrice` — fallback cycles-per-USD when there is no deposit history. - -**Pricing is dynamic** (`Util.calculatePrice`): the canister tracks `deposits` -(cycles bought + USD cost) and `shippings` (cycles sold + USD earned); the -current price = remaining cycles ÷ remaining margin-adjusted cost. A -`MIN_CYCLE_RESERVE` of 100T and a flat `FEE_USD = 30¢` apply. - -### Data flow — payment lifecycle - -1. **Load** — browser hits the canister; `mo:server` serves the SPA from the in-canister asset store. -2. **Validate recipient** — JS validates the canister/account ID; for a plain canister it does a `read_state` call directly against the IC (`icp0.io`). -3. **Fetch price** — SPA calls `GET /status` → `{ normal: { cyclesPerUsd } }`. -4. **Create payment link** — SPA builds a Stripe link embedding `client_reference_id = _-`; QR code shown. `-` is the **session ID** used for polling. -5. **Pay on Stripe** — card entered on Stripe's hosted page; the canister never sees card data. -6. **Webhook** — Stripe POSTs `checkout.session.completed` to `POST /checkout`; the handler verifies the HMAC over `timestamp.body` using `prodKey`, appends the body to the persistent log, and calls `processNextLogs()`. -7. **Process** — `processNextLogs()` parses new log entries; `"paid"` entries enqueue `(client, amount)` to `pending`, then `pump()` deposits cycles (Management canister for plain canisters, Cycles Ledger for subaccounts), moving items to `processed`/`failed`. -8. **Poll** — SPA polls `GET /status?sessionId=...` every 4s; `lookupSession()` matches by `(timestamp, nonce)`. -9. **Receipt** — on `done` the SPA shows the receipt and stops polling. - -**Why a persistent log sits in the middle:** the canister records the raw webhook -to an append-only log in stable `Region` memory, then processes it separately. -This decouples receipt-of-payment from delivery-of-cycles: webhooks get a fast -200, processing can be retried, and the log is the durable source of truth that -survives upgrades. - -### Trust & admin model - -- Creator-only methods (`assert(caller == creator)`): `stats`, `view_logs`, `deposit`, `resetAuthorized`, `pumpFailed`, `resetProcessed`, `invalidate_cache`, log-processing helpers. -- Asset methods authorized via `mo:server`'s authorization list (seeded `[creator]`). -- Webhook authenticity rests entirely on the Stripe HMAC secret. -- Verifiability: deployed Wasm hash + frontend assets can be checked against the GitHub release. - ---- - -## 2. Failure scenarios not accounted for - -Ordered by severity. The code itself flags some (`Main.mo:399` has a literal TODO). - -### A. Double-deposit / missing idempotency (operator fund loss) - -No dedup anywhere. The code admits it: - -```motoko -// Main.mo:399 -// TODO: -// 1. Reject expired requests -// 2. Dedup non-expired requests -``` - -1. **Stripe at-least-once delivery.** Stripe may deliver the same event more than once; you must dedup on event ID. Here every valid webhook is appended to the log (`Main.mo:413`) and `process()` (`326`) blindly enqueues any `"paid"` entry → a duplicate delivery is a second deposit. -2. **Replay.** No expiry check (TODO #1), no event dedup (TODO #2). A captured signed body can be re-POSTed. The full `stripe-signature` header is logged on every request (`Main.mo:383`), so replay material lives in `view_logs`. -3. **Ambiguous inter-canister call.** If `ledger.deposit` / `mgmt.deposit_cycles` succeeds on the callee but the reply is lost/traps (`Main.mo:269/274`), the `catch` records `failed` (`288-292`) — cycles gone, but `pumpFailed()` re-deposits. -4. **`resetProcessed()` is a loaded gun** (`Main.mo:219`). Sets `processedCount := 0` → the next `processNextLogs()` re-enqueues *every historical paid webhook* → mass double-deposit. - -### B. Payment taken, cycles never delivered (user fund loss) - -- **`InsufficientCycles`** (`Main.mo:256-261`): user already paid; canister out of cycles; goes to `failed`; **no automated refund**. -- **Malformed body silently dropped.** `process()` `case _ {}` (`Main.mo:334`) discards unparseable webhooks — not even recorded in `failed`. `lookupSession` returns `404` forever and the SPA polls indefinitely. Paid, no cycles, no trace except the raw log. -- **Recipient deleted between validation and payment** → `mgmt.deposit_cycles` rejects → `failed`, manual refund. - -### C. Liveness / deadlock - -- **`pumping` flag survives upgrade as `true`.** `pumping` is a `stable var` (`Main.mo:237`). If the canister is upgraded mid-`pump()`, the flag stays `true`, `postupgrade` only prunes the cache (`607-609`), and there is **no `resetPumping` method** → the processor is permanently wedged (`240`). -- **Head-of-line blocking.** Both `InsufficientCycles` (`260`) and any transient `catch` (`292`) do `break LOOP`, halting the entire `pending` queue rather than skipping the one bad item. - -### D. Unbounded growth - -- The Region log only appends (`Main.mo:100`), never pruned. The `processed` queue is capped at 333 (`285`) but the log and the **`failed` queue have no cap** (`208`). - -### E. Price / quote inconsistency - -- No price lock per session. The user sees `cyclesPerUsd` at quote time (`index.js:254`); `pump()` uses `currentPrice()` at processing time (`Main.mo:255`). An operator `deposit()` in between shifts the delivered amount. - -### F. Security / privacy - -- **`processNextLogs()` is `public` with no caller check** (`Main.mo:351`). Anyone can invoke it. Limited impact (only logged webhooks are parsed), but an unintended external mutator. -- **Guessable session IDs leak info.** `sessionId = timestamp-<8-digit Math.random nonce>` (`index.js:31`). Guessing one lets anyone query `/status?sessionId=` and learn another user's recipient, amount, and cycles (`Main.mo:442-452`). - -### G. Business-logic assumptions - -- **Stripe fee modeled as flat $0.30** (`FEE_USD = 30`, `Main.mo:206`); the real fee is ~2.9% + 30¢. -- **Currency assumed USD cents.** `extractAmount` reads `amount_subtotal` (`Util.mo:144`) with no currency check. - -**Recurring theme:** the system is *at-least-once at the Stripe boundary but -treats everything downstream as exactly-once.* The highest-leverage fix is an -idempotency key (dedup on the Stripe event ID or the `(timestamp, nonce)` -session), which closes most of section A. Second is a `resetPumping` path (or -resetting `pumping` in `postupgrade`) to remove the deadlock in C. - ---- - -## 3. What happens if the user closes their browser? - -**The user still gets their cycles** — delivery is driven entirely by the -Stripe→canister webhook, not by the browser. What's lost is the *ability to -track or confirm* the purchase. - -- **The session lives only in the browser.** `sessionId` is a module-level JS var, regenerated each page load and **never persisted** (`index.js:27, 30`); only the dark-mode flag is in `localStorage`. The canister has no record of a session until the webhook arrives. -- **Closed before paying:** nothing happened server-side. Pure abandonment. -- **Closed with Stripe window open / payment in progress:** Stripe checkout is a separate window; payment can still complete → webhook fires → cycles deposited. -- **Closed after paying, before the receipt:** the webhook is server-to-server; the whole log-driven processor runs with zero browser involvement. Cycles arrive regardless. - -**What's lost:** the confirmation is unrecoverable from the UI — on reopening, -`sessionId` is `null` and a fresh one is minted; the completed payment sits in -`processed` keyed by the *old* `(timestamp, nonce)`, and `lookupSession` only -finds it with that original id. No "look up my past purchase" flow, no email, -no account. The only persisted handle is whatever the user copied — which is why -the error UI says *"record your session id … and contact support"* -(`index.js:449-453`). - -**The genuinely bad combination:** browser-close coinciding with a delivery -failure (silent drop at `Main.mo:334`, or `InsufficientCycles` at `256`). Then -cycles were not delivered, the payment went through, and the user has also lost -their only tracking key. Recovery requires the operator scanning `view_logs`. - ---- - -## 4. How can a canister handle the POST from Stripe? - -A canister has no TCP socket or listener. The bridge is the **IC HTTP gateway** -(boundary nodes) plus a standard canister interface. - -1. **DNS + boundary node.** `cycle.express` resolves to IC boundary nodes. The boundary node maps the host to the canister via `/.well-known/ic-domains` (which contains `cycle.express`). -2. **HTTP → canister call.** The boundary node packages the raw request into a Candid `HttpRequest` (`{ method; url; headers; body }`) and calls the canister's standard methods (`Main.mo:589, 594`): - ```motoko - public query func http_request(req : HttpRequest) : async HttpResponse { ... }; - public func http_request_update(req : HttpRequest) : async HttpResponse { ... }; - ``` -3. **Query first, then "upgrade" to an update.** The gateway tries `http_request` (a query) first — cheap, no consensus, but **cannot durably change state**. The webhook needs to mutate state (log, process), so the handler returns `upgrade = ?true` (`Main.mo:421-426`). The gateway then re-issues the identical request to `http_request_update` (a real update through consensus), where `add_log(...)` and `processNextLogs()` actually persist. -4. **`mo:server` routes.** `http_request`/`http_request_update` hand off to the embedded `mo:server`; route registrations like `server.post("/checkout", ...)` (`Main.mo:431`) map method+path to the handler — Express-style, but fed by the gateway's Candid record instead of a socket. - -The punchline: **the canister never "listens" for Stripe.** The boundary node -accepts the HTTPS POST and converts it into an `http_request_update` call that -consensus executes. The `upgrade = ?true` flag is what lets a read-only query -path escalate into a state-changing update. - ---- - -## 5. Comparison with CyclePay - -`/Users/raymond/dev/dfinity/cyclepay` solves the same problem (fiat/crypto → IC -cycles) but sits at the opposite end of the architecture spectrum. - -| | **cycle-express** | **cyclepay** | -|---|---|---| -| Philosophy | Radical on-chain minimalism | Production hybrid (off-chain orchestrator + on-chain trust anchor) | -| What runs it | **One** Motoko canister does everything | Node/Hono server + Postgres + Rust canister + Astro asset canister + budget canister | -| Size | ~1,600 lines backend, 3 files | `lib.rs` 2,217 lines; ~60 server files; ~40 test files; RUNBOOK | -| Payment rails | Stripe card only | Stripe card, USDC on Base (x402), ck-USDC on ICP (ICRC-2) | -| Identity | None — anonymous, ephemeral session id | Internet Identity, teams/projects, delegation chains, GitHub OIDC, agent API | -| Cycle sourcing | Operator pre-buys cycles; canister sells from its **own balance** at a margin | Canister holds **ICP**, mints on demand via **CMC** at protocol rate; zero markup | -| Webhook target | Stripe → IC gateway → **canister directly** | Stripe → **Railway Node server** → canister | -| Correctness | Best-effort, manual support | Idempotency at 3 layers + journaling + recovery + auto-refund + reconcilers | - -### Core divergence: where the logic lives - -- **cycle-express** is the purest "everything on-chain" — one canister is the website, HTTP server, Stripe webhook endpoint, transaction processor, and cycle inventory. Stripe POSTs *directly* to the canister via the gateway. Anyone can verify the Wasm hash. -- **cyclepay** is a deliberate **hybrid**. The Node server + Postgres holds all business/payment orchestration; the Rust canister is small in scope — only the trust-critical, irreversible ICP→cycles step via the CMC, done with paranoid care. Stripe's webhook hits the Node server (HMAC-verified by the Stripe SDK, `webhooks.ts:163`), which then calls the canister's `fulfill_order` as an authorized principal. - -### How cycles are sourced - -- **cycle-express** holds a literal **cycle inventory** — `pump()` does `Cycles.add()` from the canister's own balance (`Main.mo:268`). Can run dry (`InsufficientCycles`). -- **cyclepay** holds **ICP** and mints per-order through the **CMC**: `derive_icp_e8s_from_usd_cents` (`lib.rs:1466`) computes the ICP at protocol XDR rate, transfers ICP to the CMC with a `TPUP`/`MINT` memo, then `notify_top_up` / `notify_mint_cycles`. Zero markup, no inventory to deplete. The canister **ignores the server-supplied rate** (`lib.rs:704`) — the off-chain server is not trusted as price oracle. - -### cyclepay is, almost line-for-line, "cycle-express with every failure fixed" - -| cycle-express gap | cyclepay's answer | -|---|---| -| No idempotency / replay / Stripe duplicate | Three layers: event id (`webhooks.ts:183`), payment-intent in Postgres (`:208`), `PROCESSED_INTENTS` in canister (`lib.rs:733`) | -| Ambiguous inter-canister call | Journaling state machine (`Pending→IcpTransferred→CyclesDelivered`), idempotent on `block_index` (`lib.rs:947`) | -| Payment taken, cycles not delivered | Automatic refund, write-before-call (`order-fulfillment.ts:203`), refund reconcilers | -| `pumping` deadlocks across upgrade | `RECOVERY_IN_PROGRESS` guard + timer-based recovery re-armed in `post_upgrade` (`lib.rs:580`) | -| Silent drop of malformed payloads | Durable order rows; "order not yet visible" → 503 so Stripe redelivers (`webhooks.ts:220-229`) | -| Unbounded log growth | Scheduled idempotency-table cleanup (`index.ts:545`); bounded journal, not append-only log | -| Price not locked / quote drift | On-chain rate derivation; `RATE_STALE` long-backoff then auto-refund (`order-fulfillment.ts:60-88`) | -| Guessable session-id disclosure | Real auth (II principals, delegation verification), per-principal authorization | -| Stripe fee modeled as flat $0.30 | Actual 2.9% + $0.30 netting (`order-fulfillment.ts:18`) | -| No amount verification | Amount-mismatch check on every webhook branch (`webhooks.ts:243, 326, 610`) | - -It also adds chargeback/dispute handling, async (bank-transfer) payment states, -subscription auto-refill with monthly caps, GitHub OIDC trust policies, treasury -rebalancing, and reconcilers as belt-and-suspenders. - -### Trade-offs - -- **cycle-express wins on:** verifiability & trust minimization (one auditable Wasm, no off-chain server/DB/wallet), operational simplicity, pedagogical clarity. -- **cyclepay wins on:** correctness under failure, capability (3 rails, identity, subscriptions, CI/agent), scalability (mint-on-demand vs. cycle inventory) — at the cost of re-introducing centralized trust (Railway server, Postgres, ICP hot wallet). The Rust canister is the minimal on-chain trust anchor that keeps the off-chain pieces "dumb." - -**Summary:** cycle-express asks *"how much can we do with only a canister?"*; -cyclepay asks *"what's the minimum that must be on-chain so the rest can be -safely off-chain?"* - ---- - -## 6. Options for a fully on-chain architecture - -### The fiat asterisk - -Stripe is irreducibly off-chain and centralized — you can't put Visa settlement -on a blockchain. For the **card rail**, "fully on-chain" can only mean *"the -canister is the only thing the operator runs"* — not *"no trusted third party."* -The **crypto rails (ck-USDC, USDC-on-Base) can be made genuinely trust-minimized -end to end.** - -### The key insight from cycle-express - -cycle-express is already fully on-chain — and it pulls this off by doing **only -inbound** Stripe communication. The payment page is a *static Stripe Payment -Link* (`index.js:40`); the canister never calls Stripe's API. That single choice -is what makes one canister possible — and also why it **can't refund, create -dynamic sessions, or do off-session charges** (all require *outbound* calls). So -the real question is: *can a canister make the outbound calls cyclepay needs?* - -### Component → on-chain primitive - -| cyclepay off-chain piece | On-chain replacement | Feasibility | -|---|---|---| -| Node orchestration server | Canister HTTP server | ✅ Proven (cycle-express) | -| Postgres | `StableBTreeMap` / stable memory | ✅ Proven (cyclepay canister) | -| Inbound Stripe webhook | Canister HTTP endpoint + on-chain HMAC | ✅ Proven | -| **Outbound Stripe** (session/refund/charge) | **HTTPS outcalls** + Stripe idempotency keys + `transform` fn | ⚠️ Feasible, caveats | -| Stripe secret storage | **vetKeys (vetKD)** for on-chain secrets | ⚠️ Emerging | -| USDC-on-Base hot wallet | **Threshold ECDSA (chain-key)** + **EVM RPC canister** | ✅ More decentralizable than today | -| ck-USDC pull (ICRC-2) | Canister calls `icrc2_transfer_from` directly | ✅ Already on-chain | -| ICP → cycles (CMC) | Already on-chain | ✅ | -| Treasury rebalance (DEX) | On-chain DEX canister calls | ✅ | -| Background workers | **Canister timers** (`ic_cdk_timers`) | ✅ Proven | -| II / delegation auth | Client crypto + in-canister verification | ✅ | -| GitHub OIDC verify | HTTPS outcall for JWKS + in-canister JWT verify | ⚠️ Feasible | - -The crypto rails can be made fully on-chain *and more trustless than today* -(threshold ECDSA custodies Base USDC — no operator key; ck-USDC is already pure -ICRC-2). Ironically the "advanced" rails are the easiest to decentralize. - -### Genuinely hard edges - -1. **Secret management** is the sharpest blocker for the card rail. Outbound Stripe needs `sk_live` in canister state, which node providers can read. **vetKeys** is the intended fix; it's newer. -2. **HTTPS outcalls for outbound POSTs** have three corners: replication multiplication (every replica calls → use Stripe **idempotency keys**), determinism (need a `transform` fn), and reachability/cost (IPv6 requirement, response-size limits, per-call cycles). -3. **Re-implementing Stripe flows** over raw HTTP instead of using the SDK = more code, more audit surface. -4. **Regulatory** — even fully on-chain, the operator has PCI/KYC obligations for fiat. - -### The emerging sweet spot - -Keep the *crypto* rails fully on-chain and trustless (threshold ECDSA + ck-USDC, -no hot wallet), and accept a thin off-chain shim *only* for the Stripe card rail -where the fiat trust root lives anyway. - ---- - -## 7. Does the credit card hit your servers? - -**No.** With Stripe Checkout, Payment Links, or embedded Elements, the card PAN -goes **browser → Stripe**, never through your backend: - -1. You create a Checkout Session (server→Stripe call, no card) — or use a static Payment Link. -2. Customer enters the card on Stripe's domain (or Stripe-hosted iframes). Card data goes straight to Stripe. -3. Stripe charges, redirects to your `success_url`, and sends a server-to-server **webhook**. -4. Your endpoint verifies the signature and fulfills. - -Because the card is offloaded to Stripe, you're in minimal PCI scope (SAQ A). -**No cardholder data is ever on your server — or on-chain.** - -### Refining the "on-chain secret" concern - -The risk was never card data — it's the **Stripe bearer credential** in -node-provider-readable canister state. *Which* secret depends on direction: - -- **cycle-express (inbound-only):** holds only the **webhook signing secret** (`prodKey`/`testKey`). If a node provider reads it, they could **forge "paid" webhooks → mint free cycles** — bad, but bounded to this service; the Stripe account is untouched. -- **cyclepay (outbound):** holds the full **secret API key** (`STRIPE_SECRET_KEY`). Leak = full account compromise (charge saved cards, refund to self, read PII). That's the one you really don't want in readable canister memory — a key reason cyclepay keeps Stripe off-chain. - -**Net:** the cleanest fully-on-chain card design receives webhooks on-chain but -*avoids holding `sk_live`* — by using static Payment Links and forgoing -programmatic refunds — or protects the secret with vetKeys before trusting it to -canister memory. - ---- - -## 8. What about an SEV-SNP (confidential computing) subnet? - -AMD SEV-SNP encrypts a VM's memory with keys in the CPU's secure processor, -inaccessible to the host, plus integrity protection and attestation. On the IC -this targets exactly our threat: today a node provider with host access can -inspect a replica's RAM and read canister state. A confidential subnet would -encrypt that memory at the hardware level — arguably **the enabling technology -for a fully on-chain card rail**, making it defensible to hold `sk_live` in -canister state and do outbound Stripe from the canister. - -### Caveats — "raises the bar a lot," not "solved" - -1. **Replication** — the secret is plaintext inside *every* replica's enclave. Security now rests on SEV-SNP being unbroken *and* every node running attested SEV-SNP. -2. **Memory encryption ≠ state-at-rest encryption.** SEV-SNP protects RAM; canister state is also **checkpointed to disk** and **state-synced** to other nodes. Confirm those are covered, or a secret in stable memory can leak through the checkpoint path. *(Check this hardest.)* -3. **Secret injection** — setting the key via an update call means the argument transits the boundary node and is processed by nodes; you need a confidential, attestation-tied provisioning channel. -4. **Trust shifts to AMD + attestation**, and SEV-SNP has a published-CVE history (cache/fault-injection/voltage-glitch attacks). Hardware confidentiality, not a math guarantee. -5. **Maturity** — Gen-2 IC nodes have SEV-SNP-capable hardware and confidential replicas are the IC's direction, but verify current production status (which subnets; whether checkpoints/state-sync are covered) before betting a live key on it. - -### SEV-SNP vs vetKeys (complementary) - -| | **SEV-SNP (hardware)** | **vetKeys (cryptographic)** | -|---|---|---| -| Mechanism | Encrypts replica RAM; host can't read | Secret encrypted; only the canister can derive the key | -| Code change | None — transparent | You design encrypt-at-rest + derive-on-use | -| Covers state-at-rest? | Only if disk/state-sync also confidential | Yes — ciphertext safe in stable memory *and* checkpoints | -| Trust root | AMD CPU + attestation; hardware side-channels | Threshold crypto + subnet not colluding above threshold | -| Granularity | Subnet-wide, all-or-nothing | Per-secret, any subnet | - -vetKeys protects the secret *at rest and during provisioning*; SEV-SNP protects -the *whole runtime* transparently but leans on hardware. Strongest posture: both. - -### Net effect - -- **Without confidential computing:** keep `sk_live` outbound off-chain, or go inbound-only (only a webhook signing secret on-chain), or use vetKeys for the at-rest secret. -- **With an attested SEV-SNP subnet you trust (and confirmed checkpoint/state-sync confidentiality):** holding `sk_live` in the canister becomes defensible — the missing piece for a genuinely fully on-chain card rail. - -It still doesn't remove the irreducible bits: Stripe/Visa remain the off-chain -fiat trust root, and you still need HTTPS outcalls (idempotency keys + transform -+ IPv6/cost) for outbound calls. SEV-SNP solves the *confidentiality* of the -credential, not the *existence* of the off-chain dependency. diff --git a/design-docs/NEW_ONCHAIN_APP_DESIGN.md b/design-docs/NEW_ONCHAIN_APP_DESIGN.md deleted file mode 100644 index 9a97f73..0000000 --- a/design-docs/NEW_ONCHAIN_APP_DESIGN.md +++ /dev/null @@ -1,187 +0,0 @@ -# Fully On-Chain Cycles Gateway — Design Spec (v1) - -> Greenfield design for a fully on-chain "buy cycles with fiat or stablecoins" -> service. Successor in spirit to Cycle.Express, with the capability surface of -> CyclePay but run as a single verifiable canister. See `ARCHITECTURE_ANALYSIS.md` -> for the reasoning that led here. - -## Locked decisions - -| Decision | Choice | Consequence | -|---|---|---| -| Rails in v1 | **Card + ck-USDC + USDC-on-Base (x402)** | Full parity; Base rail is the hard part | -| Card rail | **Inbound-only** | No `sk_live` on-chain; cycles-ledger credit instead of fiat refunds | -| Language | **Motoko** | Clean HTTP serving via `mo:server`; thinner EVM tooling | -| Topology | **Single backend + asset canister** | One verifiable Wasm; ECDSA key not isolated (future hardening) | - -**Zero stored plaintext secrets** except the Stripe *webhook signing* secret → -no vetKeys / SEV-SNP dependency for v1. - -**Where the risk concentrates:** the Base/x402 rail (threshold ECDSA + EVM RPC + -EIP-3009/keccak/RLP in Motoko) and the unisolated signing key. - ---- - -## Topology - -``` -┌──────────────────────┐ ┌────────────────────────────────────────────┐ -│ Frontend asset │ │ Backend canister (Motoko) │ -│ canister │──HTTP─▶│ mo:server : serve SPA + API + webhooks │ -│ (Astro/JS, II login) │ │ Orders : durable state machine (stable) │ -└──────────────────────┘ │ Rails : card / ck-USDC / Base │ - │ Mint : CMC notify_top_up / mint │ - Stripe ──webhook──────────▶ Auth : II / delegation / OIDC │ - (inbound, HMAC on-chain) │ Timers : recovery + subscriptions │ - └──┬─────┬───────┬──────────┬──────────┬───────┘ - ICP ledger CMC ck-USDC mgmt(aaaaa-aa) │ - (ICRC-2) │ - ┌──────────────────────▼─────┐ - │ IC mgmt sign_with_ecdsa + │ - │ EVM RPC canister (Base RPC) │ - └─────────────────────────────┘ -``` - ---- - -## Core: one Order, one state machine - -Every payment on every rail becomes a durable `Order` in stable storage, keyed -by a generated `order_id`, owned by a **principal** (Internet Identity), driven -by an idempotent journal: - -``` -Created ─▶ Paid ─▶ Minting ─▶ IcpAtCMC ─▶ Delivered - │ │ │ - ├─Expired ├─AmountMismatch └─ recovery timer resumes (idempotent on block_index) - │ │ - └─────────┴─▶ Failed ─▶ CyclesCredited (ledger fallback) [or Refunded, future] -``` - -- **Money-in differs per rail; money-out is unified** (all converge on the CMC mint). -- **Idempotency:** dedup keys per rail in stable sets — - - Card: Stripe `event.id` + `payment_intent` - - ck-USDC: ICRC-2 transfer `block_index` - - Base: on-chain tx hash -- **Recovery:** `recurringTimer` sweeps stuck `Minting`/`IcpAtCMC` entries and - resumes; CMC notify is idempotent on `block_index`, so resume can't double-mint. - Timer re-armed in `postupgrade`. No `pumping`-style deadlock. - -### Data model (stable) - -- `orders : Map` -- `journal : Map` (status, icp block_index, cycles_minted, retries, timestamps, destination) -- `processedStripeEvents : Set`, `processedIntents : Set` -- `processedCkUsdcBlocks : Set`, `processedBaseTx : Set` -- `subscriptions : Map` -- `auditLog : append-bounded log` (queryable by principal) -- `principalsToOrders : Map` (order history → fixes the lost-receipt UX) - -Use a stable map library (`mo:map`) or Region-backed ordered maps; **bound** the -audit log and any failure lists (no unbounded append-only growth). - ---- - -## Money-in rails - -### 1. Card (Stripe) — inbound-only - -- Frontend uses a **static Stripe Payment Link** with `client_reference_id = _`. -- Stripe POSTs `checkout.session.completed` to the canister's `/webhook/stripe` (via the IC HTTP gateway + `upgrade=?true`). -- On-chain **HMAC-SHA256** verification of `timestamp.body` against the webhook signing secret. -- Amount-mismatch check vs. the order; dedup on event id + payment intent → `Order = Paid`. -- **No outbound Stripe calls. No `sk_live`.** Terminal failures credit cycles to the user's Cycles-Ledger balance rather than refunding fiat. - -### 2. ck-USDC (ICRC-2) — pure in-canister, no secrets - -- User calls `icrc2_approve` granting the backend an allowance for `amount + fee`. -- Backend `icrc2_transfer_from` pulls ck-USDC → `Order = Paid`. Dedup on `block_index`. -- Convert ck-USDC → ICP (DEX call) or hold; then mint. Auto-refill can reuse the standing allowance — **no card-on-file needed**. - -### 3. USDC on Base (x402) — threshold ECDSA + EVM RPC *(hardest)* - -- First `/x402/topup` returns `402` with payment requirements; user signs **EIP-3009 `transferWithAuthorization`**, reposts with `X-PAYMENT`. -- Canister verifies the signed authorization, then submits the `transferWithAuthorization` on Base: - - derive the canister's Base address from its **threshold-ECDSA** public key (IC mgmt `ecdsa_public_key`) - - build + RLP-encode + keccak-hash the EVM tx, **`sign_with_ecdsa`**, broadcast via the **EVM RPC canister** - - verify settlement via `eth_getTransactionReceipt` (EVM RPC) → `Order = Paid`. Dedup on tx hash. -- **Motoko effort:** keccak256, RLP, secp256k1 pubkey→address, EIP-3009 digest. Pull in `mops` crypto packages; budget the most time here. - ---- - -## Money-out: unified CMC mint - -- Backend holds an **ICP** balance (operator-funded). -- Derive ICP e8s from USD/stablecoin amount at the protocol XDR rate (query CMC); margin optional on top. -- `icrc1_transfer` ICP → CMC subaccount (memo `TPUP` for canister, `MINT` for ledger) → `notify_top_up` / `notify_mint_cycles`. -- Journal transitions `Minting → IcpAtCMC → Delivered`; recovery resumes on `IcpAtCMC`. - -## Failure handling / refunds - -- Retries with backoff; long-backoff class for CMC rate-stale. -- Terminal failure → **credit cycles to the user's Cycles-Ledger balance** (on-chain native, no Stripe). Real fiat refund deferred (would require the outbound/`sk_live` path). - -## Auth & identity - -- **Internet Identity** sign-in; per-principal order ownership and history. -- **Delegation-chain** verification in-canister for agent/CLI bearer-token access. -- **GitHub OIDC** CI top-ups: HTTPS outcall for JWKS + in-canister JWT verify + trust policies (repo/branch/workflow → principal) with monthly spend caps. - -## Background jobs (timers) - -- `recurringTimer`: journal recovery sweep; subscription auto-refill (scan thresholds, mint when low — funded by ck-USDC allowance or ICP). -- Optional: hot-balance alerts surfaced via a query method / audit log. - ---- - -## Tech stack & layout - -- **Motoko**, `mo:server` + `mo:assets` (HTTP + asset hosting), `mo:base` `Timer`. -- Candid bindings: ICP ledger, CMC, ck-USDC (ICRC-1/2), EVM RPC canister, IC management (ECDSA). -- `mops` packages: HMAC/SHA-256, keccak256, RLP, secp256k1, JSON, hex. -- Build via `vessel`/`mops` + `moc`; assets via `icx-asset`; reproducible release like cycle-express. -- Tests: **PocketIC** integration (rails, recovery, idempotency, mint). - -``` -src/backend/ - Main.mo # actor: HTTP routes, order API, timers, upgrade hooks - Orders.mo # Order + JournalEntry types, state machine, stable store - Idempotency.mo # per-rail dedup sets - rails/Card.mo # Stripe webhook verify (HMAC) + parse - rails/CkUsdc.mo # ICRC-2 approve/transfer_from flow - rails/Base.mo # EIP-3009 verify, EVM tx build/sign/broadcast, receipt - Cmc.mo # ICP transfer + notify_top_up / notify_mint_cycles - Ecdsa.mo # threshold ECDSA: address derivation + signing - EvmRpc.mo # EVM RPC canister bindings + helpers - Auth.mo # II / delegation / OIDC verification - Util.mo, Hmac.mo, Account.mo -src/frontend/ # Astro/JS SPA, II login, multi-rail UI, order history -``` - ---- - -## Comparison to CyclePay (with these choices) - -| Dimension | This app | CyclePay | -|---|---|---| -| Operated infra | **1 backend canister** + assets | Node + Postgres + Rust canister + assets | -| Trust model | IC consensus only | + Railway + Postgres + CDP hot wallet | -| Card rail | Inbound on-chain, no `sk_live`, no fiat refund | Outbound Stripe SDK (refunds, off-session) | -| ck-USDC | In-canister ICRC-2 | Server-orchestrated | -| Base/USDC | **Threshold ECDSA, no hot wallet** | Off-chain CDP wallet | -| Correctness | Journal + per-rail idempotency + timer recovery | Journal + 3-layer idempotency + reconcilers | -| Refunds | Cycles-ledger credit | Automatic fiat refunds | -| Subscriptions | Timer + ck-USDC allowance | Stripe off-session charges | -| Secrets on-chain | Only Stripe webhook signing secret | n/a (server holds `sk_live`) | -| Verifiability | **Single auditable Wasm** | Trust operator servers | -| Reporting | KV stable storage | SQL | -| Maturity risk | Motoko EVM/ECDSA path is newer | Battle-tested stack | - ---- - -## Open risks / decisions to revisit - -1. **Base rail in Motoko** — keccak/RLP/secp256k1/EIP-3009 maturity; biggest unknown. Consider isolating the signing wallet into its own canister once it works. -2. **No fiat refunds in v1** — cycles-ledger credit must be clearly communicated to card users. -3. **KV aggregation** — monthly spend caps / reconciliation are more awkward than SQL; design query methods deliberately. -4. **HTTPS outcall use** is limited to GitHub OIDC JWKS in v1 (no outbound Stripe) — keeps cost/determinism surface small. diff --git a/design-docs/ONCHAIN_GATEWAY_SPEC.md b/design-docs/ONCHAIN_GATEWAY_SPEC.md deleted file mode 100644 index 3116de6..0000000 --- a/design-docs/ONCHAIN_GATEWAY_SPEC.md +++ /dev/null @@ -1,442 +0,0 @@ -# Fully On-Chain Cycles Gateway — Spec v2 (decision record) - -> Supersedes the open questions in `NEW_ONCHAIN_APP_DESIGN.md`. This document is -> the **decision record** from a grilling session: every branch we resolved, the -> choice, and the rationale. Where we **deviated** from the original design doc, -> it is called out explicitly (⚠️ **Override**). Read `ARCHITECTURE_ANALYSIS.md` -> for the reasoning that led to the original design, and `NEW_ONCHAIN_APP_DESIGN.md` -> for the locked rail/topology decisions that still stand. - ---- - -## 0. What this is - -A fully on-chain "buy cycles with fiat or stablecoins" service: a single -verifiable Motoko backend canister + an asset canister. **Two rails in scope — -Card (Stripe, inbound) and ck-USDC (ICRC-2)** — converging on a unified CMC -mint. (USDC-on-Base/x402 is **deferred**, see §11.) Successor in spirit to -Cycle.Express with CyclePay's capability surface, but run as one auditable Wasm. - -**Tooling:** `icp-cli` (never `dfx`), `mops` for Motoko deps, Motoko with -`persistent actor` + orthogonal persistence. - ---- - -## 1. Product scope & sequencing - -| # | Decision | Rationale | -|---|---|---| -| 1 | **v1 = production money-handler.** Full idempotency + recovery on day one; every rail must be safe to lose funds on. | Highest correctness bar; this is real money, not a demo. | -| 2 | ⚠️ **Rails ship sequenced, each GA'd independently:** **Card → ck-USDC.** **Base/x402 dropped from scope for now** (see §11). | Card first for revenue; ck-USDC is pure on-chain. Base deferred — unclear consumer (a generic x402 agent wouldn't use II, a different model) and it carried *all* the EVM-crypto risk. Order state machine stays rail-pluggable so Base can return later. | -| 3 | **Scope = human, one-shot, II-authenticated purchases only.** | Tightest audit surface. | -| 3a | ⚠️ **Override — OUT OF SCOPE entirely:** subscriptions, auto-refill, GitHub OIDC CI top-ups, delegation/agent HTTP API, **Base/x402 rail**. | The original doc listed these. Subscriptions/auto-refill are impossible on card (need outbound `sk_live`) and only ever rode on a ck-USDC standing allowance, which is also cut. OIDC/delegation cut for scope. Base/x402 deferred (§11). **Consequence: the product makes _almost_ no outbound HTTPS** (see §3 forex exception). | -| 4 | **Destinations:** any canister (`notify_top_up`, `TPUP` memo) **or** a cycles-ledger account (`notify_mint_cycles`, `MINT` memo). | Full parity with cycle-express's two delivery modes. Note asymmetry: only canister destinations can become `Undeliverable`. | - ---- - -## 2. Identity & ownership - -- **Internet Identity required for all purchases**, every rail. -- This does **not** sacrifice anonymity: II is pseudonymous (passkey, no PII) and - hands out a **per-origin principal** unlinkable to the user's identity on other - dapps. Destinations are arbitrary (you can fund any canister). So "anonymous - top-up" is preserved in spirit; login only governs **ownership/history**, not - what you may fund. -- **Single ownership model:** order keyed to owner principal; query authz is - `caller == order.owner`; `principalsToOrders` for history (fixes the - lost-receipt problem). No bearer-secret order handles. -- ⚠️ **`Owner` is a single-case variant from day one** — `type Owner = { #ii : - Principal }` — even though II is the only case in scope. Rationale: if - Base/x402 returns, its consumer is an agent owning orders by EVM address; - adding `#evmAddress : Blob` to a stable variant is a **migration-free, - compatible extension**, whereas widening a bare `Principal` field would force - a stable-state migration plus an audit of every authz site. The compiler then - makes every authz check pattern-match instead of silently assuming Principal. - (See §11 "Seams kept for Base".) -- **Order IDs:** random, `raw_rand`-derived (not a monotonic counter) — the ID - sits in the public `client_reference_id`, so randomness avoids enumeration and - leaking order volume. It is **not** a bearer secret (authz is `caller == owner`). - ---- - -## 3. Economics & pricing - -- ⚠️ **At-cost, net-of-fees.** `locked_rate` applies to the **net** amount - received (gross − Stripe fee), so no structural per-order loss. The original - doc said "margin optional"; we chose at-cost. The fee is computed from a - **configurable fee formula** (≈2.9% + $0.30); the operator absorbs the small - variance on international/FX cards (the canister can't fetch Stripe's actual - fee without `sk_live`). -- **Lock the cycle _quantity_ at order creation** (not just a rate). At mint - time, compute whatever ICP is needed to mint that many cycles at the - then-current CMC rate; the **operator absorbs ICP-cost movement.** This makes - "no quote drift" literally true. -- **Card amounts: fixed tiers**, one permanent static Payment Link each. The - paid amount is structurally pinned by which tier link was used, giving - deterministic amount-match. (We deliver on the **actual** paid amount, so a - mismatched claim is honored at what was really paid.) - -### 3.1 Forex rate subsystem (the one outbound-HTTPS exception) - -USD→cycles needs a USD↔XDR forex input. CMC provides only ICP↔XDR. There is no -on-chain XDR/USD oracle, and we chose a **direct HTTPS outcall** over XRC. - -- ⚠️ **This reintroduces outbound HTTPS** (a `transform` fn, ×replica cost, - IPv6 requirement). Accepted deliberately. The XRC inter-canister option was - rejected; the manual-constant option was rejected. -- **Determinism:** the live rate is the hard case for consensus (each replica - samples a different value). Mitigated by a **coarse-rounding `transform`** so - replicas converge. Rounding *reduces, never eliminates*, boundary-split - failures. -- **Cadence:** cache `{rate, ts}` in stable state; **lazy refresh** — orders - read the cache; only a **stale** cache triggers an outcall. **Single-flight** - guard so a burst doesn't fire concurrent outcalls. **In-call retry cap** - (boundary-splits often clear on retry). -- ⚠️ **Fail-closed:** if a stale-triggered refresh fails (consensus or API - down), **block order creation** ("rate temporarily unavailable, retry") rather - than price on a stale rate. Safe because this happens at order *creation*, - before any money moves. **Extended outage ⇒ no new orders, but in-flight paid - orders still fulfill** (fulfillment uses the locked cycle quantity, not a fresh - rate). -- **Keyless, IPv6-reachable source** required — a keyed API would add a *second* - node-provider-readable secret and break the "one secret" property. - ---- - -## 4. Core: one Order, one state machine - -``` -Created ──(never paid; advisory)──▶ Expired - │ │ - │ (webhook verified, deduped, │ (late real payment) - │ amount honored) ▼ - └──────────────▶ Paid ◀────────────┘ - │ - [enough ICP float?] - ├─ yes ─▶ Minting ─▶ IcpAtCMC ─▶ Delivered - └─ no ─▶ AwaitingTreasury ─(refill)─▶ Minting - └─(max-wait exceeded)─▶ ErrorQueue -``` - -- **Money-in differs per rail; money-out is unified** (all converge on CMC mint - from the shared ICP float). -- **`Created`** locks the cycle quantity (see §3). **Expiry is advisory only** — - a genuine first payment always follows the happy path at the locked quantity; - expiry only affects the live polling/QR UX. - -### 4.1 Error queue — exactly two types - -| Type | Trigger | State at queueing | Resolution | -|---|---|---|---| -| **Type 1 `{Duplicate \| Unattributed}`** | Genuine 2nd/distinct payment for an already-handled order, OR `client_reference_id` resolves to no order. | **Fiat exists, nothing minted** (dedup gates the mint). | ⚠️ **Operator refunds in the Stripe Dashboard** (manual, off-chain, no `sk_live`). The `charge.refunded` webhook auto-marks the entry resolved. | -| **Type 2 `{Undeliverable}`** | ICP→cycles minted, but delivery failed (e.g. target canister deleted). | **Cycles exist, in the app canister's own balance.** | Operator refunds or re-delivers; undeliverable cycles subsidize the canister's own gas in the meantime. | - -⚠️ **Override of the original doc**, which credited the *user's* cycles-ledger -balance and deferred fiat refunds. We do the opposite: **no automatic user -credit**; off-happy-path money is **human-resolved**, and the operator **does** -issue manual fiat refunds. - -**Key invariants:** -- **Dedup gates the mint** — never mint for a payment that can't be cleanly - attributed to an open, undelivered order. This keeps Type 1 "fiat-only." -- **Stripe dedup ≠ double-pay protection.** `event.id`/`payment_intent` dedup - only catches Stripe *redelivering one event* (at-least-once delivery). A user - genuinely paying twice produces two distinct events/payment_intents = two real - payments → the second is Type 1. -- A permanent Payment Link is **always live**; `client_reference_id` is an - attacker-editable URL param → **claimed, not trusted.** Every dollar that - arrives must resolve to delivery, Type 1, or Type 2. - -### 4.2 Data model (stable, persistent actor) - -- `orders : Map` -- `journal : Map` (status, transfer intent, block_index, cycles, retries, timestamps, destination) -- per-rail dedup: `processedStripeEvents`, `processedIntents`, `processedCkUsdcBlocks : Set` (a `processedBaseTx` set joins if/when Base returns, §11) -- `errorQueue` (Type 1 + Type 2, bounded) -- `principalsToOrders : Map` -- `auditLog` — **bounded ring buffer** - -**Retention:** keep financial records (orders, journal, crypto `block_index`/tx -dedup — small, last for years); ring-buffer the audit log (hard cap); prune -Stripe dedup after **~7 days** (Stripe redelivers ≤3d). Archival pipeline -deferred. - -**Persistence:** `persistent actor` + orthogonal persistence; shape changes via -explicit `(with migration = ...)` functions (see migrating-motoko skills). - ---- - -## 5. Money-out: unified CMC mint + recovery - -- All rails mint from **one operator-funded ICP float.** Each rail's money-in - accrues as **revenue**; operator converts revenue→ICP off-chain and refills the - float. -- **Delivery pattern: mint-to-self-then-forward.** Mint cycles to the app - canister first, then forward to the destination. A failed forward (deleted - target) leaves cycles in the app balance (→ Type 2) rather than failing - atomically inside CMC — this is what makes the Type-2 fallback possible. -- **No pre-validation of the destination canister** (decided): a bad/deleted - `Canister` destination just becomes a Type-2 the operator resolves. - `CyclesLedgerAccount` destinations essentially never fail, so users wanting - guaranteed delivery can use those. -- Flow: `icrc1_transfer` ICP → CMC subaccount (memo `TPUP`/`MINT`) → record - `block_index` → `notify_top_up`/`notify_mint_cycles`. Idempotent on `block_index`. -- **Rate derivation** mirrors CyclePay's `derive_icp_e8s_from_usd_cents`: CMC - ICP/XDR rate with a **staleness guard** (CyclePay used 15 min, post-incident); - combined with the §3.1 USD/XDR. - -### 5.1 Ambiguous-transfer protection (the hard correctness corner) - -⚠️ **Write-intent-before-call + `created_at_time` ledger dedup:** -1. Persist deterministic transfer args `{created_at_time, amount, CMC target, memo}` **before** transferring. -2. Execute `icrc1_transfer`; on success persist `block_index`. -3. On recovery, **replay the identical transfer** — the ledger either performs it - once or returns `Duplicate{block_index}`. Either way the `block_index` is - recovered with **no double-spend.** -- **Bounded by the ledger's ~24h dedup window** → recovery cadence **≪ 24h**. - An intent older than the window **without** a known `block_index` must **not** - be auto-replayed → escalate to the error queue. - -### 5.2 Recovery timer - -- `recurringTimer` sweeps stuck `Minting` / `IcpAtCMC` / `AwaitingTreasury`. -- **Single-flight** `RECOVERY_IN_PROGRESS` guard; **re-armed in `postupgrade`** - (no `pumping`-style deadlock). -- `notify_*` is idempotent on `block_index`, so resume can't double-mint. - -### 5.3 Treasury management + ICP burn cap - -- **`AwaitingTreasury`**: a Paid order the float can't yet fund sits here; the - recovery timer retries until refill; **soft UI gate** disables tiers when the - float is low; **balance alerts** via a query method + audit log; a **max-wait - bound** escalates to the error queue (operator refunds). -- ⚠️ **Per-period ICP burn cap** (decided): a hard, operator-configured ceiling on - total ICP the canister will convert to cycles within a rolling window. This is - the **primary blast-radius bound** if the webhook secret leaks (forged "paid" - webhooks can't drain more than the cap before detection + rotation) — and it - doubles as a safety limit against any mint-path bug. When the cap is hit, new - mints pause (orders sit in `AwaitingTreasury`-style hold) + alert; resets next - window or on manual override. Independent of SEV — works on any subnet. - ---- - -## 6. Rails - -### 6.0 Ingress — two paths (decided) - -HTTP requests reach a canister via the IC boundary node, which packages them into -a Candid `HttpRequest` and calls `http_request` (query) → if the handler returns -`upgrade = ?true`, it re-issues to `http_request_update` (update, through -consensus). **Crucially, HTTP requests arrive as the _anonymous_ principal** — -the boundary node does not propagate Internet Identity. So HTTP routes can only be -authenticated by their **payload**, never by `caller == owner`. This forces two -ingress paths: - -- **Candid calls (agent-js, II-authenticated)** — the entire app API: create - order (locks rate, captures owner), query status, order history, admin / - error-queue resolution, **and the ck-USDC `approve`/pull flow**. Caller = the - real II principal. -- **HTTP route (anonymous, payload-authed)** — **exactly one: `POST /webhook/stripe`**, - authenticated by the HMAC. Stripe can't make Candid calls, so this is - irreducible; nothing else needs HTTP ingress now that Base/x402 is deferred. - -⚠️ **Hand-rolled `Http.mo` on `mo:core`, not `mo:server`** (decided): `mo:server` -carries the deprecated `mo:base` and asset-store / caching / certification -machinery we don't need (assets live on a separate canister; this canister only -POSTs). With a single POST route that **always** returns `upgrade = ?true`, -response certification is moot (the gateway discards the query response and re-runs -the update through consensus). The minimal surface: parse `HttpRequest`, -**case-insensitive header lookup** (for `Stripe-Signature`), strip the query -string, match the one path, body-size guard, sane status codes. - -### 6.1 Card (Stripe) — inbound-only, no `sk_live` - -- Static Payment Link per tier; URL carries `client_reference_id = _`. -- Stripe POSTs `checkout.session.completed` to `/webhook/stripe` (IC gateway + - `upgrade=?true`). Also handle `charge.refunded` for Type-1 reconciliation. -- On-chain **HMAC-SHA256** verification of `timestamp.body` against the webhook - signing secret; timestamp-window replay guard; dedup on `event.id` + - `payment_intent`; amount honored on actual paid value → `Paid`. - -### 6.2 ck-USDC (ICRC-2) — pure in-canister - -- `icrc2_approve` → backend `icrc2_transfer_from` (amount + fee). Dedup on - `block_index`. Amount-short → mismatch handling. -- ⚠️ **Hold ck-USDC, mint from the shared ICP float** (not per-order DEX). Operator - converts accrued ck-USDC→ICP off-chain to refill. Consistent with the card rail. - -### 6.3 USDC on Base (x402) — ⚠️ DEFERRED / OUT OF SCOPE FOR NOW - -Dropped from current requirements (see §11 for the full rationale + the prior -design work that's parked there). In short: unclear consumer (a generic x402 -client is an agent, which wouldn't use II — a different ownership model), and it -carried *all* the EVM-crypto risk (keccak/RLP/ABI/EIP-712/`ecrecover`, threshold -ECDSA, EVM RPC, ETH gas tank). The order state machine remains rail-pluggable so -it can return cleanly later. - ---- - -## 7. Security & trust - -- ⚠️ **Webhook signing secret: SEV-SNP for confidentiality + ICP burn cap for - blast-radius** (reverses the earlier "pull vetKeys in" decision — **no vetKeys - at all**). The only stored secret; HMAC is symmetric, so "verify" = "forge" → it - must be kept confidential. Stored **plaintext** in canister state; protected by - the hardware, not by cryptography in the canister. Set via an admin method. - - **Why this is acceptable (the documented tradeoff):** even in the worst case - where the secret leaks, an attacker can only forge "paid" webhooks → mint free - cycles at operator expense, and a **per-period ICP burn cap** (see §5.3) bounds - how much can be drained before detection + secret rotation. Stripe account/PII - are untouched (no `sk_live`); forged orders are detectable via off-chain - reconciliation; the secret is rotatable. So the loss is **bounded, detectable, - and recoverable** regardless of whether SEV holds. - - **SEV-SNP caveats that MUST be tracked (per `ARCHITECTURE_ANALYSIS.md` §8 — - documenting honestly, not assuming "safe"):** - - **Confidentiality rests on hardware + attestation**, not math: SEV-SNP - unbroken *and* every replica in the subnet running attested SEV-SNP. Trust - shifts to AMD; SEV-SNP has a published side-channel CVE history. - - ⚠️ **Memory encryption ≠ state-at-rest encryption.** SEV-SNP protects RAM, but - canister state is also **checkpointed to disk and state-synced** between nodes. - **If checkpoints/state-sync are not also confidential on the target subnet, a - plaintext secret leaks through that path and SEV buys nothing.** *Verify this - hardest before relying on it.* - - **Provisioning exposure:** setting the secret via an update call means the - argument **transits the boundary node** (TLS-terminated there) and is processed - as an ingress message — exposed at provisioning unless an attestation-tied - confidential channel is used. - - **Maturity / availability:** depends on an attested SEV-SNP confidential subnet - being production-ready and the canister actually deployed there. **If no such - subnet is available when M1 ships, the secret is plaintext on a normal subnet - and the ICP burn cap + accountable node providers are the *only* protections.** - The burn cap is the robust, available-today backstop; SEV is the confidentiality - layer that may or may not be available — design so launch doesn't *block* on it. -- **Governance:** ⚠️ **flat controller allowlist, equal privileges** — any - controller can upgrade, withdraw funds/cycles, provision/rotate the secret, - resolve error-queue entries, set tiers, adjust forex params, pause, edit the - controller set. Admin authz = caller ∈ controllers. Honest trust model: "trust - the operator set; any one can upgrade-then-drain." M-of-N (note: IC controllers - are OR-semantics, so true M-of-N upgrades need a multisig-canister controller) - and SNS are the documented hardening path. -- **Base key isolation:** n/a while Base/x402 is deferred (§11). No threshold-ECDSA - signing key exists in scope; the only secret in the system is the plaintext - Stripe webhook signing secret (§7, first bullet). - ---- - -## 8. Verifiability (the thesis) - -- **Docker-pinned reproducible build:** `moc` pinned in `mops.toml [toolchain]`, - recipe versions pinned in `icp.yaml` (icp-cli rejects unpinned), `mops.lock` - committed, `ic-wasm` for deterministic optimization + metadata. -- Each tagged release **publishes the expected module hash**; anyone verifies via - `icp canister status ` and diffs. -- Frontend via `@dfinity/asset-canister` recipe — **certified assets**, committed - `.did`, reproducible asset build. -- External security audit **not gated** for v1 (easy to add per rail GA later). - ---- - -## 9. Tech stack & layout - -- **Motoko** (`persistent actor`, `mo:core` 2.0.0+), **hand-rolled `Http.mo`** - (not `mo:server`, see §6.0) + separate asset canister, `Timer`. -- Candid bindings: ICP ledger, CMC, ck-USDC (ICRC-1/2), cycles ledger. - (No vetKD / IC-management ECDSA — neither vetKeys nor Base in scope.) -- `mops` packages: HMAC/SHA-256, JSON, hex. (No EVM crypto — Base deferred.) -- `icp-cli` build/deploy; reproducible release. -- Tests — **go-live bar** (decided): **unit tests wherever logic is isolable** - (HMAC, fee math, rate derivation, parsers, state-machine transitions, dedup) - **+ a full PocketIC integration suite green before each rail's go-live**. - PocketIC suite covers: happy path, duplicate/replay, ambiguous-transfer - recovery, `AwaitingTreasury`, error queue Type1/Type2, forex fail-closed, - upgrade-mid-flight, `postupgrade` re-arm. External simulation: real ledger/CMC - Wasms; **crafted HMAC-signed Stripe webhooks**; **mocked HTTPS outcall** for - forex; PocketIC time-control for timers. - -``` -src/backend/ - Main.mo # actor: http_request(_update), order Candid API, timer, migration hooks - Http.mo # hand-rolled minimal HTTP: parse, case-insensitive headers, one route - Orders.mo # Order + JournalEntry types, state machine, stable store - Idempotency.mo # per-rail dedup sets + retention/pruning - ErrorQueue.mo # Type1/Type2 entries, bounded - rails/Card.mo # Stripe webhook HMAC verify + parse + charge.refunded - rails/CkUsdc.mo# ICRC-2 approve/transfer_from flow (Candid) - Cmc.mo # ICP transfer (write-intent + created_at_time) + notify_* - Secret.mo # webhook secret: admin-set, plaintext (SEV-protected), rotation - Forex.mo # cached rate + lazy refresh outcall + transform - Treasury.mo # ICP float, AwaitingTreasury, alerts - Auth.mo # II ownership + controller allowlist - Util.mo, Hmac.mo, Account.mo -src/frontend/ # Astro/JS SPA, II login, Card + ck-USDC UI, order history -``` - -(Base-rail modules — `rails/Base.mo`, `Ecdsa.mo`, `EvmRpc.mo` — and EVM `mops` -crypto are parked until/unless Base returns; see §11.) - ---- - -## 10. Resolved since v2 draft - -- **Ingress (decided):** two-path model — II Candid for the whole app API + - ck-USDC; **one anonymous HTTP route** (`/webhook/stripe`). **Hand-rolled - `Http.mo` on `mo:core`**, not `mo:server` (§6.0). -- **Base/x402 dropped from scope for now** (§6.3, §11) — removed all EVM crypto, - threshold ECDSA, EVM RPC, ETH gas tank, and the EVM-address ownership variant; - **single II ownership restored**. -- **vetKeys dropped** (§7) — webhook secret kept confidential by **SEV-SNP** - (hardware) with documented caveats, and blast-radius bounded by a **per-period - ICP burn cap** (§5.3). No vetKD dependency. -- **Frontend:** polling order-status by `order_id` + order-history view; **no - destination pre-validation** (Type-2 is the catch-all). M1 builds the shell + - Card flow + history + rail selector; M2 adds the ck-USDC panel. -- **Go-live test bar:** unit-where-possible + full PocketIC suite per rail (§9). -- **Seams kept for Base (§11.1, binding on M1):** `Owner` single-case variant - (§2), `Http.mo` route table, ownership captured at the API edge, expiry - semantics per-rail. Keeps a future Base return additive, not a refactor. - -## 11. Deferred / future (non-blocking) - -- **Base/x402 rail** — the original 3rd rail, dropped (§6.3). Open question to - resolve before it returns: **who is the consumer?** A generic x402 client is an - agent, which wouldn't use II — implying a different (EVM-address) ownership - model. Parked work if it returns: EVM crypto (`mops` keccak/RLP/ABI/EIP-712/ - `ecrecover`), threshold ECDSA, EVM RPC, ETH gas tank, hot→cold sweeping, - validation via test vectors + differential + Base Sepolia e2e. - -### 11.1 Seams kept for Base (binding on M1 implementation) - -Base is out of scope, but M1 must not bake in assumptions that would have to be -*unwound* (rather than added to) if it returns. Most of the architecture is -already rail-agnostic — money-out (`Paid` → float → CMC mint), recovery, the -burn cap, and per-rail dedup sets are all additive. Four seams are deliberate -and **binding** on the M1 implementation: - -1. **`Owner` variant (§2):** `type Owner = { #ii : Principal }` from day one. - Adding `#evmAddress : Blob` later is a compatible, migration-free variant - extension; a bare `Principal` field would mean a stable-state migration and - an audit of every authz site. -2. **`Http.mo` routes via a table, not a hardcoded path.** "Exactly one route" - (§6.0) is *policy*, not architecture: dispatch off a `[(method, path, - handler)]` table with one entry. x402 is also anonymous, payload-authed HTTP - — it adds rows, not a restructure. Likewise `upgrade = ?true` is a per-route - flag that happens to be true everywhere in M1. -3. **Ownership capture stays at the API edge.** `Orders.createOrder` takes the - owner as a *parameter*; it never reads `msg.caller` itself. Card/ck-USDC - orders are created by an II Candid call, but a Base order would be created by - an anonymous HTTP request whose owner comes from the verified EIP-3009 - signature. -4. **Expiry semantics are per-rail money-in behavior — do not unify into the - core.** "Expiry is advisory only" (§4) is a *Card* property (a late genuine - payment is still honored). Base would need **enforced** expiry - (`validBefore`, reorg/confirmation windows). The state machine owns - transitions, not expiry policy. -- **Confidential-subnet verification** — confirm an attested SEV-SNP subnet is - available and that **checkpoint/state-sync are also confidential** (§7); deploy - there. Until then the ICP burn cap + accountable providers carry the secret. -- **M-of-N / SNS governance** (true M-of-N upgrades need a multisig-canister - controller, since IC controllers are OR-semantics). -- **Retention/archival pipeline** once order volume warrants. -- **External security audit** (mint/idempotency + secret-handling paths). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..5d5283c --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,101 @@ +# Architecture + +One Motoko backend canister and one certified-assets frontend canister. **There is no +server** — no API tier, no database, no queue, no cron host. Pricing, payment +orchestration, delivery and the audit trail all execute on-chain. + +The diagram is the point of this page: where the trust boundaries are, and which of the +two cycle pots a delivery spends from. + +```mermaid +flowchart TB + BUYER["Buyer
browser + card"] + II["Internet Identity
principal per origin,
derived from the FRONTEND canister id"] + FE["frontend canister
certified assets · no server"] + BE["backend canister
Motoko · pricing · orders
delivery · audit trail"] + XRC["Exchange Rate Canister
uf6dk-hyaaa-aaaaq-qaaaq-cai
usdPerIcpMicros"] + CMC["Cycles Minting Canister
rkp4c-7iaaa-aaaaa-aaaca-cai
xdrPermyriadPerIcp"] + CL["Cycles Ledger
um5iw-rqaaa-aaaaq-qaaba-cai"] + STRIPE["Stripe
hosted Checkout + API"] + + BUYER -->|"1 · sign in"| II + II -.->|"principal"| FE + BUYER -->|"2 · pick an amount"| FE + FE -->|"3 · quote_previews
public query, the same code
that locks the price"| BE + XRC -.->|"on a timer · no UNAUTHENTICATED
caller can drive this spend"| BE + CMC -.->|"inter-canister · no HTTPS"| BE + FE -->|"4 · create_order
locks the quote,
reserves the cycles"| BE + BE ==>|"5 · HTTPS outcall
create Checkout Session
Idempotency-Key = order id"| STRIPE + BUYER -->|"6 · pays on Stripe's page
card details never touch this system"| STRIPE + STRIPE ==>|"7 · POST /webhook/stripe
⚠️ anonymous caller
http_request_update — an UPDATE
call, so it goes through consensus"| BE + BE ==>|"8 · icrc1_transfer
out of the gateway's OWN
cycles-ledger account"| CL + CL -->|"9 · spendable cycles"| BUYER + + classDef ours fill:#1b4965,stroke:#62b6cb,color:#fff + classDef ext fill:#5a3e2b,stroke:#c98f5a,color:#fff + class FE,BE ours + class STRIPE ext +``` + +`==>` is a trust boundary being crossed. `-.->` is an on-chain read with no off-chain +dependency. The **XRC, CMC and cycles-ledger ids are protocol constants** and are in the +code (`Xrc.mo`, `Cmc.mo`, `Delivery.mo`); this deployment's own two ids are in +`.icp/data/mappings/ic.ids.json`, deliberately not written here — a diagram that names +one deployment is wrong for every other one. + +## The four things the diagram exists to make unmissable + +**1. The webhook's caller is anonymous, and the HMAC is the entire trust root.** The +boundary node terminates TLS, so nothing about the transport authenticates Stripe. +`HMAC-SHA256` over `timestamp.body` with the signing secret is all of it — whoever holds +that secret can sign a completed-payment event for an order they created and be delivered +cycles having paid nothing. That is why the reserve balance is the blast radius, and why +sizing it is a security decision ([`RUNBOOK.md`](../RUNBOOK.md)'s webhook-secret section). + +**2. Step 7 is an UPDATE call.** `http_request_update`, not `http_request` — so the +webhook goes through consensus and can write state. The name suggests otherwise, and it +is the reason the handler can deliver at all. + +**3. Pricing crosses no trust boundary.** Both rates come from *canisters* — the XRC for +USD/ICP, the CMC for XDR/ICP — read on a timer. ⚠️ **"Never on demand" would be wrong**: +`refresh_rates` exists as an operator lever, `requireAdmin`. What holds is that no +*unauthenticated* caller can drive the XRC spend. There are exactly **three** HTTPS outcalls +in the whole system and all three go to Stripe: create a session, expire a session, and +retrieve one (the recovery sweep). "A canister that talks to a payment processor" makes +people assume a price oracle over HTTP; there isn't one. + +⚠️ **ICP cancels out of the price.** Both rates are *per ICP*, so their quotient is XDR per +USD and the ICP price drops out entirely. A cycle is XDR-pegged, and the gateway carries +no ICP exposure per order — it holds no ICP at all. + +**4. There are two cycle pots, and confusing them is this system's most common +operational error.** + +| | what it is | funded by | spent on | +|---|---|---|---| +| **the reserve** | the backend's own **cycles-ledger account** | `icp cycles transfer ` | delivery to buyers, via `icrc1_transfer` | +| **the canister's gas** | the backend canister's own cycle balance | `icp canister top-up backend --amount` | execution, the three outcalls, 1 B per XRC rate refresh | + +`icp canister top-up` does **not** touch the reserve. And a funded reserve is not a +*sellable* one until `refresh_reserve` runs: solvency is decided against a maintained +lower bound that starts at zero and only rises by observation. + +## One invariant enforced by a type, not a check + +`src/backend/Delivery.mo` declares the cycles-ledger interface the canister may call, and +**`icrc2_approve` and the ledger's `withdraw` are absent from it**. They therefore cannot +be called, which is what makes `Reserve.mo`'s floor a valid lower bound: the balance +cannot fall except when this canister transfers out. `scripts/test-all.sh` fails the gate +on a declaration that widens that interface. + +That shape — an invariant held by an omission plus a gate step, rather than by a runtime +guard — is worth knowing before changing anything in that file. + +## Where to go next + +| | | +|---|---| +| **why** it is built this way | [`docs/DESIGN.md`](./DESIGN.md) — the decision record, and what the `§N` comments in the code point at | +| the Card rail in full detail | [`docs/STRIPE.md`](./STRIPE.md) — ingress, signature verification, attribution, dedup, the order lifecycle, refunds | +| running or deploying it | [`docs/OPERATE.md`](./OPERATE.md) — one procedure per mode | +| operating it when something breaks | [`RUNBOOK.md`](../RUNBOOK.md) — entered by symptom | diff --git a/docs/BUYER-COST-MODEL.md b/docs/BUYER-COST-MODEL.md new file mode 100644 index 0000000..6b60bf0 --- /dev/null +++ b/docs/BUYER-COST-MODEL.md @@ -0,0 +1,208 @@ +# What a purchase actually buys + +**The scenario:** someone buys cycles here to create and deploy **two canisters** — a +backend and a frontend — and run them for **two weeks to a month**, syncing assets and +upgrading the backend wasm **about three times a day**, on a **13-node** application subnet. + +**The answer:** they need **4.0 T upfront** and consume **~1.34 T** over the month. The +upfront figure decides sufficiency: **$10 covers it 1.7×, $5 does not cover it at all.** + +## Upfront and consumed are different numbers + +⚠️ **Compare a purchase against `upfront`, not against `consumed`.** They differ by 3× here, +and only the first decides whether a buyer can get started. + +``` +UPFRONT icp canister create --cycles default 2.000 T per canister + × 2 canisters 4.000 T ← the gate + of which protocol creation fee 1.000 T consumed + of which lands as canister balance 3.000 T still the buyer's + +CONSUMED protocol creation fee, 2 canisters 1.000 T + 90 deploys (3/day × 30 days) 0.321 T + storage, 50 MB across both, 30 days 0.015 T + ─────── + 1.337 T ($1.84) +``` + +Only the total carries a USD figure: the parts are rounded to three decimals and summing +them gives 1.336, while the exact total is 1_336_571_944_520 cycles. + +⚠️ **`--cycles` defaults to `2000000000000`, documented only in `icp canister create +--help`.** `icp deploy` inherits it silently, so a buyer who never reads that flag still +needs 2 T per canister. It is the largest number in the model. Those cycles are not *spent* +— they land in the canister as its balance and remain the buyer's — but they must be held to +create the canister at all. + +Delivered cycles are directly spendable on this: delivery is an `icrc1_transfer` on the +**cycles ledger**, and `icp canister create` funds from the cycles ledger by default — +`--with-icp` is the alternative that routes through the CMC instead. + +Two weeks rather than a month consumes ~1.16 T; the upfront figure does not move, because +creation does not scale with duration. After a month the two canisters still hold about +**2.664 T** between them, separate from whatever is unspent in the buyer's wallet. + +## Where the figures come from + +Documented **13-node application subnet** rates — +: + +| | 13-node cost | +|---|---| +| Create a canister | 500,000,000,000 (0.5 T) | +| Storage | 127,000 per GiB per second | +| Ingress message | 1,200,000 base + **2,000 per byte** | +| Update call execution | 5,000,000 base + 1 per instruction | + +Sizes are this repo's own artifacts, as a stand-in for "a small app": the backend wasm is +**1,165,653 bytes** and the built frontend is **612,831 bytes** across 8 files. + +⚠️ **Price cycles off the REAL XDR/USD rate.** `Pricing.mo` computes +`cycles = netCents × xdrPermyriadPerIcp / usdPerIcpMicros`, so **ICP cancels** — it is an +intermediate unit, not an input to how many cycles a dollar buys. What remains is XDR/USD, +and cycles mint at 1 T = 1 XDR. The §3 **test vector** (3.5 XDR/ICP ÷ $4.55/ICP = 0.769 +XDR/USD) is a fixture running ~5.6% generous, and `test/browser`'s baselines are generated +from it — so agreement with a baseline says nothing about the real figure. Figures below use +the IMF rate for 2026-09-10: **1 XDR = $1.373470**, $1.00 = 0.728083 XDR. + +⚠️ **Two subnets that are NOT the buyer's.** + +- **This gateway's own canister** targets a 7-node confidential subnet, where these + costs scale by 7/13 ≈ 0.54. That affects *our* gas, not a buyer's app. +- **A local `icp network` prices differently.** Solving two observations of this repo's + backend (7.7 MB → 1,061,634,457 idle cycles/day; 336.7 MB → 9,465,058,545) gives 317,500 + cycles/GiB/s and a flat 10,000 cycles/s per canister — 2.5× the documented storage rate, + plus a baseline the mainnet tables do not list. Local burn overstates a buyer's bill. + +## What each tier buys + +Using the canister's own integer fee arithmetic (`feeBps` 290, `feeFixedCents` 30): + +| tier | net | cycles | covers the 4.0 T upfront? | spare | +|---|---|---|---|---| +| **$5.00** | $4.55 | **3.313 T** | **NO** — short 0.69 T | — | +| $10.00 | $9.41 | **6.851 T** | yes, 1.7× | +2.851 T | +| $20.00 | $19.12 | 13.921 T | yes, 3.5× | +9.921 T | +| $50.00 | $48.25 | 35.130 T | yes, 8.8× | +31.130 T | + +⚠️ **`Pricing.feeCents` CEILINGS the basis-point part** — `(gross × bps + 9_999) / 10_000` — +so $5 nets 455¢ rather than the 456¢ a floored reading gives. $5 is the only tier where the +two differ. + +`test/buyer-cost.test.mo` asserts every figure in this table against `Pricing.feeCents` and +`Pricing.cyclesForCents`, and compares the fee constants to `Pricing.defaultConfig()`. So a +**formula** change fails there with the tier that moved, and a **configuration** change +fails the comparison — which is the only place in the repo those two constants are pinned. + +At $10 the buyer holds ~5.5 T across wallet and canisters after the first month — roughly +sixteen further months of the same three-deploys-a-day pattern. + +## Why $10 rather than $5 + +The floor has **two independent justifications**, and both hold: + +1. **Sufficiency.** $5 buys 3.313 T against a 4.0 T upfront requirement. A $5 buyer creates + the first canister and fails on the second, holding 1.31 T against a 2 T request — a + failure that surfaces from `icp deploy`, with nothing pointing back at the purchase being + too small. +2. **Fee share.** The fixed 30¢ is regressive: **9.0%** of a $5 purchase against **5.9%** of + a $10 one, so the buyer's effective price rises from **$1.46/T to $1.51/T**, which is + visible on the receipt. + +A $5 floor works **only** if the buyer tunes `--cycles` down — at ~600 B each (protocol fee +plus freezing reserve plus slack) the upfront need falls to ~1.2 T and $5 clears it 2.8×. +That is a real path and not the default one. + +⚠️ **There is no operator loss at either tier.** `Pricing` derives cycles from **netCents**, +so Stripe's cut is recovered *from the buyer*, not absorbed. Unrecovered per-order cost is +the HTTPS outcall (~220 M cycles at 13 nodes) plus the ledger transfer fee, ~320 M cycles +≈ **$0.0004**. Per-order overhead does not scale with purchase size, which is what +`Gate.mo`'s "worth an outcall and a reserve hold" refers to. + +## The cost shape, and the one thing that breaks it + +**Storage is a rounding error** at small-app scale — 50 MB across both canisters for a month +is 2 cents, a full gibibyte-month is $0.45. Count creations and uploads, not storage. + +**The recurring cost is upload bytes.** At 2,000 cycles/byte every megabyte costs 2 G, so a +deploy costs 3.57 G and tracks *artifact size* rather than deploy frequency — the two +update-call base fees are 10 M of that 3,570 M. The 90-full-syncs figure is a worst case: +the asset sync uploads only changed files. + +⚠️ **`install_code` execution is the only term that can dominate, and it is not measured +here.** At 1 cycle per instruction: + +| instructions per upgrade | per deploy | 90 deploys | consumed, 30d | +|---|---|---|---| +| base only (modelled) | 3.57 G | 0.321 T | 1.337 T | +| 100 M — light | 3.67 G | 0.330 T | 1.346 T | +| 1 B — moderate | 4.57 G | 0.411 T | 1.427 T | +| 10 B — heavy | 13.57 G | 1.221 T | 2.237 T | +| **200 B — the install limit** | 203.57 G | **18.321 T** | **19.337 T** | + +The realistic band for a small app is light-to-moderate, under 10% of the total. The limit +row is the shape of the only failure mode: instruction-heavy upgrades three times a day can +exceed a $10 purchase, and nothing in the storage or upload arithmetic hints at it. + +### The freezing threshold + +Each canister retains 30 days of its own idle cost (`freezing_threshold = 2_592_000` +seconds) and stops executing rather than spending into it. At the documented 13-node +storage rate, idle cost is storage cost, so the reserve is a function of stored data: + +| canister memory | idle | reserve locked | +|---|---|---| +| 1 MB | 0.010 B/day | **0.31 B cycles** | +| 10 MB | 0.102 B/day | **3.07 B cycles** | +| 50 MB | 0.511 B/day | **15.33 B cycles** | +| 100 MB | 1.022 B/day | **30.66 B cycles** | +| 500 MB | 5.110 B/day | **153.29 B cycles** | +| 1024 MB | 10.464 B/day | **313.93 B cycles** | + +⚠️ **Locked rather than spendable, and it grows with stored data** — but at small-app scale +it is under 0.1 T per canister, well inside the 2 T each one is created with. It changes no +verdict here; it matters when a canister's balance is being run down deliberately, because +"the balance says 0.1 T" and "0.1 T is available" are different claims. + +⚠️ Do not read these off a local network. The flat per-canister baseline there dominates at +small sizes — a 1.3 MB canister shows a reserve **67× larger** than this table gives. + +### Sensitivity + +The documented storage rate is presented as already scaled to 13 nodes. If it were per-node +instead, storage is 13× higher — 0.199 T for the same 50 MB, a 1.520 T consumed total. The +verdict is unchanged at every tier, because storage is the smallest term either way. + +## Reproducing this + +```python +CLI_DEFAULT, CREATE_FEE = 2_000_000_000_000, 500_000_000_000 +STORAGE, IB, IBY, UB = 127_000, 1_200_000, 2_000, 5_000_000 +XDR_PER_USD = 0.728083 # IMF — check it, it moves +WASM, ASSETS, DAYS, PER_DAY = 1_165_653, 612_831, 30, 3 +CANISTERS = 2 + +deploy = (IB + IBY*WASM + UB) + (IB + IBY*ASSETS + UB) +storage = STORAGE * (50e6 / 2**30) * 86400 * DAYS +upfront = CANISTERS * CLI_DEFAULT +consumed = CANISTERS * CREATE_FEE + deploy*DAYS*PER_DAY + storage + +def buys(gross_cents): # mirrors Pricing.feeCents — note the CEILING + net = gross_cents - ((gross_cents*290 + 9_999)//10_000 + 30) + return net/100 * XDR_PER_USD + +print("upfront %.3f T" % (upfront/1e12)) +print("consumed %.3f T" % (consumed/1e12)) +for tier in (500, 1000): + print("$%.2f -> %.3f T covers upfront: %s" + % (tier/100, buys(tier), buys(tier) >= upfront/1e12)) +``` + +Swap `WASM`/`ASSETS` for the app being priced, `CANISTERS` for its shape, and add an +instruction estimate to `deploy` if its upgrades do real work on install. + +⚠️ **`buys()` is a transcription of `Pricing.feeCents`, so running this checks the cost side +and NOT whether the tier figures match the canister.** The authority for those is +`test/buyer-cost.test.mo`, which calls `Pricing.feeCents` and `Pricing.cyclesForCents` +directly. Change a fee constant and this snippet keeps agreeing with itself; that suite +fails with the tier that moved. diff --git a/docs/DEMO-PLAYBOOK.md b/docs/DEMO-PLAYBOOK.md new file mode 100644 index 0000000..f43f86c --- /dev/null +++ b/docs/DEMO-PLAYBOOK.md @@ -0,0 +1,107 @@ +# Demo playbook + +A speaker's outline for a recorded walkthrough of the **payment flow**. Setup is already +done on whatever deployment you show, so it is narrated, not performed. + +## Before recording + +- ⚠️ **No number is written in this file** — read them off the screen. An earlier version + put the reserve balance and the order count in prose and both were wrong within three + days, on the two screens the demo points at. + ```bash + icp canister call backend pricing_status '()' -e ic # divisor: 1 is live, >1 simulation + icp canister call backend reserve_status '()' -e ic # availableToSell, totalOrders + ``` +- ⚠️ `unset STRIPE_API_KEY STRIPE_WEBHOOK_SECRET`, and keep `scripts/.local-dev.env` off + screen. The canister cannot leak them; your terminal can. +- ⚠️ **One open order per principal.** Cancel every rehearsal order, or the next one is + refused until Stripe expires the session (~35 min). + +## Goal + +- Let a customer **buy cycles with a credit card** — from a card to spendable cycles, + without solving crypto onboarding first. No wallet, no exchange account, no ICP to hold. + +## The application + +- Runs **entirely on ICP**: one Motoko backend canister, one certified-assets frontend. + No server. +- **No external dependency but Stripe.** Pricing reads two *canisters*, not an HTTP + oracle. +- **Both canisters run on a confidential SEV-SNP subnet.** + +## Roles + +### Operator + +- **Funds the cycles reserve** the gateway sells from — the canister's own account on the + cycles ledger, not its gas balance. +- **Provisions the two Stripe secrets**, sealed with vetKeys — encrypted offline to a key + derived from the canister's id, so no plaintext reaches an ingress message, a shell + history or a log. +- **Allow-lists customers** while the gateway takes sandbox payments. + +### Customer + +- Creates an order, pays on Stripe's hosted page. +- Receives cycles on the cycles ledger, **to the principal the signed-in page shows**. + +### Backend canister + +- Quotes and **locks the cycle quantity**, and reserves it so a paid order can always be + delivered. +- Verifies the webhook, then **delivers cycles** — one `icrc1_transfer` out of its own + cycles-ledger account. +- Writes an audit line for every Stripe event it acts on. + +## The demo + +Open the page, show the balance and the completed-order count on screen, then narrate one +live purchase: + +1. **Pick the amount.** The quote shows the cycles and the processing fee before + committing. The USD→cycles rate comes from two on-chain sources: **USD/ICP** from the + Exchange Rate Canister and **XDR/ICP** from the CMC. +2. **Create the order.** Locks the quote, reserves the cycles, and makes an **HTTPS + outcall** to Stripe for a Checkout Session. Stripe's deadline is ~35 minutes. +3. **Pay.** Card details go to Stripe's page and never touch this system. +4. **Stripe calls the webhook** — `http_request_update`, an *update* call, so it goes + through consensus. +5. **The canister verifies the HMAC** against the Stripe signing secret, then **transfers + the cycles** to the customer's principal. + +- ⚠️ **Step 5 moves cycles, never fiat.** The money stays at Stripe; this canister + custodies none of it. +- ⚠️ **The HMAC is the entire trust root.** The endpoint is public and the caller is + anonymous — the boundary node terminates TLS, so nothing about the transport + authenticates Stripe. Whoever holds that signing secret gets cycles for free, which is + why the reserve balance is the blast radius and why the secret is sealed rather than + pasted. +- ⚠️ **ICP cancels out of the price.** Both rates are *per ICP*, so a cycle is XDR-pegged + and the gateway holds no ICP. Cross-checkable against the IMF's published SDR rate. + +## Considerations + +- **Running out of cycles.** The gate refuses the order **before the customer pays**, so + no money is taken for cycles that cannot be delivered. A promise is held from creation, + so a paid order is always deliverable. +- **An expired session.** Stripe tells the canister, the order is marked expired, and the + reserved cycles go back to what is sellable. +- **A dispute.** One audit line — *reconcile in Stripe; cycles cannot be recovered* — and + no automated response, deliberately: the cycles are delivered and irreversible while the + card network pulls the fiat back. The operator sees it happened and reconciles in the + Dashboard. A **refund** is different: it files an obligation on the order. +- **Anything unexpected.** Every Stripe event the canister acts on is audited, so an + order in a state nobody expected is visible to the operator — who can check the + Dashboard and refund if that is the right answer. +- **This is a demo.** You must be **allow-listed** — sandbox payments are free, so an open + list on a funded gateway would be a faucet, and it refuses to sell in that state rather + than warning. Cycles are divided by the divisor, quote and delivery alike. + +## Try it + +- Send me **the principal the signed-in page shows you** and I will allow-list it. + Internet Identity derives one per origin, so a principal copied from another app is a + different account. +- The rest is in the repo: [`docs/ARCHITECTURE.md`](./ARCHITECTURE.md) for the diagram, + [`docs/VERIFY.md`](./VERIFY.md) for what anyone can check and what they cannot. diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 0000000..c79d7a1 --- /dev/null +++ b/docs/DESIGN.md @@ -0,0 +1,730 @@ +# Design decisions — the `§N` record + +**The decision record for the cycles gateway.** Code comments say what the code *does*; +this says *why it is that way*. When those disagree, the code is right and this file is a +bug — fix it in the same change. + +⚠️ **This file is load-bearing and enforced.** `scripts/check-design-sections.py` runs in +the verification gate and fails if a `§N` cited in the backend or the tests has no section +here, or if a section here is cited by nothing. It cannot check whether a section is +*true* — that is the obligation below. + +> ## The obligation, for agents and humans +> +> **Change the behaviour, change this file in the same commit.** Not afterwards. +> +> Its predecessor was a 697-line spec that accumulated **67** mentions of architecture +> three issues had deleted, while `Main.mo` still cited it by section number. It was not +> abandoned — it was updated less often than the code. The rule that would have saved it +> is the one above. +> +> ⚠️ **Keep it lean, and delete rather than annotate.** A section describing something +> that no longer exists must be *removed*, not marked historical. History belongs in +> commit messages and GitHub issues, which are dated and attributable. Every paragraph +> here must be a decision someone could otherwise get wrong. + +--- + +## §1 — Scope and sequencing + +One rail: **Card, via Stripe**. Cycles are sold at cost from a pre-funded cycles reserve. +The build is done; what is left before real money is `docs/OPERATE.md`'s Mode 3. + +## §2 — Identity and ownership + +**Internet Identity for every purchase.** It costs no anonymity: II is pseudonymous and +issues a **per-origin principal** unlinkable to the same user elsewhere. Destinations are +arbitrary — you may fund any canister — so sign-in governs *ownership and history*, not +what you may buy. It exists to fix the lost-receipt problem. + +⚠️ **The DERIVATION origin is irreversible after the first real purchase; the serving +domain is not.** II derives the principal *from* an origin, so changing that origin gives +every returning buyer a different principal: they cannot see their old orders, and the +cycles behind them are unreachable. Which is why the derivation origin is pinned to the +**frontend canister id** (`config.ts`) rather than to a domain — the canister id is the one +identifier a domain change cannot alter, so the domain stays a reversible decision. + +Authz is `caller == order.owner`. **Order ids are random (`raw_rand`), not a counter** — +the id travels in the public `client_reference_id`, so randomness avoids enumeration and +avoids leaking order volume. It is **not** a bearer secret; there are no secret order +handles. + +## §3 — Economics + +**At cost, net of fees.** The rate applies to the **net** amount received (gross minus the +Stripe fee, from a configurable formula), so there is no structural per-order loss. The +operator absorbs the variance on international and FX cards, because reading Stripe's +actual fee needs a key scope we refuse to hold (§7). + +⚠️ **What is locked at creation is the cycle QUANTITY, and it is immutable afterwards.** +The reserve tally is `Σ lockedCycles` over non-terminal orders, so anything that rewrote +that field would break the tally silently. This is what makes "no quote drift" literally +rather than approximately true. + +⚠️ **Cycles are priced in XDR, not ICP.** Per-order ICP exposure cancels; the operator's +ICP risk sits in topping up the reserve, not in any individual sale. + +### §3.1 — Rates + +``` +cycles = netCents × xdrPermyriadPerIcp × 10¹² / usdPerIcpMicros +``` + +⚠️ **ICP is an intermediate unit, not a position.** The gateway never holds, buys or +spends ICP — it hands over cycles it already owns. ICP appears in the arithmetic only +because both on-chain rate sources are denominated in it, and it **cancels**: +`(USD/ICP) ÷ (XDR/ICP)` is USD/XDR. Do not read the formula as a purchase of ICP. + +⚠️ **Why derive rather than price off a market USD/XDR rate.** A cycle is *defined* in XDR +by the protocol, so the only question is where USD/XDR comes from. There is no on-chain +USD/XDR oracle, and the two rates used here are both on-chain, independently governed and +time-aligned (read on the same tick, so no timestamp reconciliation is needed). A market +USD/XDR rate would break even only when it happened to equal the protocol-implied one; any +gap becomes a **systematic bias on every order**, priced against a number the protocol does +not use. + +The operator's remaining exposure is **inventory**, not per-order: cycles are sold at +today's implied USD/XDR and were funded into the reserve at whatever held when they were +bought. When to refill is the operator's decision. + +Both inputs come from on-chain sources: **XRC** for USD/ICP and the **CMC** for +XDR/cycles. A refresh that fails leaves the previous rates standing, and orders stop being +quotable once the cache passes its staleness window — **refusing to sell is the safe +direction**; selling at a stale rate is not. + +## §4 — One order, one state machine + +```mermaid +stateDiagram-v2 + [*] --> Created + Created --> Cancelled: the buyer gave up + Created --> Expired: Stripe's deadline passed — unpayable + Created --> Paid: webhook, verified + Paid --> Delivered: icrc1_transfer landed + Paid --> NeedsReview: fate unknowable, or 72 h + Paid --> Abandoned: operator gave up + NeedsReview --> Delivered: operator read the ledger + NeedsReview --> Abandoned: operator refunded by hand + Cancelled --> [*] + Expired --> [*] + Delivered --> [*] + Abandoned --> [*] +``` + +⚠️ **Illegal transitions are ABSENT from the matrix, not guarded at runtime.** +`Expired → Paid` and `Cancelled → Paid` do not exist, and that absence *is* the guarantee +that a late payment cannot be honoured. A runtime check is something someone has to +remember; a missing edge is not. + +⚠️ **Stripe owns the deadline.** The session expires, its webhook tells us, and `Expired` +is terminal. An earlier design made expiry advisory — a late genuine payment was honoured +— and that stopped being affordable once a `Created` order held reserve capacity. + +### §4.1 — Money positions needing a human + +Two structures, because they answer different questions: + +- **Problems live on the order** (`Order.problems`). A problem earns its place only if it + holds information that exists nowhere else **and** an action nobody has taken yet. +- **Orphans** are payments that cannot be attributed to any order — there is nothing to + attach them to, so they keep a narrow list of their own. + +Nothing drops from either. + +**`NeedsReview` — every route to it, because a census beats a claim.** The status means +*"we can no longer ask safely"*, and it is reachable with the money position both unknown +and known: + +*Unknown position* — a human must establish what happened: + +1. **A stale transfer intent**: the intent is past the ledger's ~24 h dedup window, so a + replay is no longer protected against double-paying. Reaching it takes a day-long + ledger outage with an hourly sweep hammering it throughout — **expected never**, and + documented as such rather than as a routine branch. +2. **The ledger's own escalating answers.** ⚠️ Not a second cause: a too-old rejection *is* + case 1 told to us by the ledger instead of derived from our clock. Same position, + different messenger. + +*Known position* — nothing to establish, and this is the correction to a tidier claim of +"one trigger": + +3. **The max-wait bound (§5.3)** fires on an order paid too long ago *whatever* the + reason, including one where **nothing was ever sent** — position certain, instruction + "refund in the Stripe Dashboard". So read any "one trigger" claim as scoped to the + *unknown-position* routes. + +*Unreachable guard*: + +4. **The intent's amount exceeding the order's locked quantity**, which cannot happen + because the amount was derived by subtracting a fee from that very quantity. ⚠️ If it + ever fires it is not a counter-example to the census — it means `lockedCycles` acquired + a second writer (§3), which is a much larger problem than one escalated order. + +⚠️ **An escalation records the CAUSE and the MONEY POSITION separately, because they can +legitimately disagree.** The cause is why we stopped trying; the position is what a +transfer did or did not do. A stale intent stops the driver for one reason while the +position depends on whether a block was recorded — and *"establish its fate, never +rebuild"* is the right action regardless of why we stopped. The runbook's triage is +organised by position, because that is what determines the action; the cause is what +identifies the incident. + +### §4.2 — Data model + +One `persistent actor`. Orders are never deleted, which is what makes every index over +them a projection that can be rebuilt rather than a second source of truth. + +### §4.3 — Cancellation is attributed, not raced + +`Cancelled` and `Expired` are both terminal and unpayable, so they differ in exactly one +thing: **who decided**. The buyer needs that difference — being told their order expired +when they cancelled it is the defect `expiredBy` exists to prevent — and it is where that is +recorded. + +⚠️ **The write is racy by construction and that is not a bug to remove.** Cancelling +expires the session at Stripe first, so Stripe fires `checkout.session.expired` +immediately, and three writers can reach the order before the cancel is recorded: that +webhook, the recovery sweep (§5.2), and the admin expire. + +⚠️ **So the buyer's INTENT is recorded before the outcall, and whoever wins reads it.** +Not a lock. A lock needs every writer to remember a guard, and the first attempt at this +proved the point twice: a transient set the webhook's module could not see, then an +attribution the sweep and the admin expire used while the webhook — the writer that +actually wins — went on without it. `Orders.settleUnpayable` owns the +`Cancelled`-versus-`Expired` decision, so the race stops mattering instead of being +prevented. + +Two consequences worth stating, because both look like oversights: + +- The intent is **stable** and deliberately not cleared in a `finally`. A trap or an + upgrade mid-cancel is precisely the window in which another writer settles the order, + which is when the intent has to survive. +- It is **not** pruned "when the order goes terminal". Membership implies `Created`, and + it is removed at each of that status's three exits — the enumeration is in + `Main.cancelRequests`, and it is what bounds the set. + +## §5 — Money-out + +**One transfer from the cycles reserve**, and one transition (`Paid → Delivered`) that +performs it. One edge means one place a double-spend could live. + +⚠️ **`icrc1_transfer` is the only declared way out**, enforced by a gate step that greps +the whole backend for a second one. The reserve floor is a *lower bound* only while that +holds: any other outflow makes the balance fall in a way the floor cannot see, and the +gate would then admit sales against cycles that already left. + +### §5.1 — Ambiguous transfers + +The corner that makes a delivery retryable with no risk of paying twice: + +1. Persist the deterministic transfer arguments (`created_at_time`, amount, target, memo) + **before** transferring. +2. Execute; on success persist the `block_index`. +3. On recovery, **replay the identical transfer.** The ledger either performs it once or + answers `Duplicate { block_index }` — either way the block index is recovered. + +⚠️ **Bounded by the ledger's ~24 h dedup window**, so the recovery cadence must stay well +inside it (enforced: cadence ≤ window ÷ 4). An intent older than the window with no known +block index must **not** be auto-replayed — it escalates to `NeedsReview`, whose whole +meaning is "the money position is unknown; a human must read the ledger". + +### §5.4 — The reserve floor: why the gate needs no ledger call + +The admission gate decides against a **maintained lower bound** on the reserve balance, +synchronously, with no ledger read. That is sound because of one asymmetry in who can move +the balance: + +- it can only **decrease** when we transfer out, and every such outflow decrements this + floor by `amount + fee` **before** issuing its transfer. ⚠️ **There are TWO destination + classes, and "one outflow" is about the mechanism, not the recipient** — `icrc1_transfer` + is the only way out, but it serves both: + - **delivery**, to a buyer's own account, bounded by that order's promise, which the gate + already admitted against this floor; + - **withdrawal**, to a controller, guarded on there being **no promise-holder at + all** — so nothing can be owed to a buyer when it runs — and refused again after its + own balance read, because the floor is still full across that await. + + The asymmetry holds for either class for the same reason: the decrement precedes the + transfer, so the floor is never optimistic about a transfer in flight. +- it can only **increase** when someone tops the account up, which we cannot see without + asking — and which is always positive. + +So every unobserved change is in our favour, and a floor maintained from our own outflows +is never optimistic. Deciding against it can refuse a sale the reserve could have covered; +it can never admit one it cannot. + +⚠️ **This replaced an awaited balance read on the order-creation path**, which is where a +whole class of bug lived: the awaited value was correct when computed and historical when +used, and pairing it with a live tally made the available figure optimistic by a full +order. The decision still reads **two** numbers — the floor and the promise tally — but +both are now *maintained*, so what is gone is the awaited-versus-live pairing, not the +second operand. + +**Three rules keep it a bound:** + +1. **A fresh observation is adopted only in a quiet window, and a shortfall is shouted + about.** "Quiet" means nothing was in flight before the read, nothing after it, and + nothing was issued in between — see below for why that condition is the whole safety + property. The ledger holding *less* than the floor means an outflow we did not cause, + which the asymmetry says is impossible. +2. **A transfer decrements the floor when it is ISSUED, not when it settles, by + `amount + the fee for this attempt`.** The only way the balance can surprise us + downward is one of our own transfers landing without our learning it did — our reply + callback traps, so the debit stands while our bookkeeping rolls back. Assuming the + debit at issue time makes that case exact instead of optimistic. + + ⚠️ **The fee is in the FLOOR decrement but not in the promise tally**, and the + asymmetry is deliberate: the ledger charges its fee on top of the amount, so an + outflow moves the balance by `amount + fee` while what an order *promises* is the + amount alone (§3). Adding a fee term to the tally double-counts. +3. **A definitively-failed transfer credits the floor back**, `#Duplicate` included — that + answer says *this* call moved nothing, so its decrement was never a real debit even + though an earlier attempt's was. A `#BadFee` re-issue therefore credits back + `amount + fee` and decrements `amount + the corrected fee`; if the re-issue then fails + with no reply, the **larger** decrement stands, which is correctly pessimistic. + + ⚠️ **A call that failed with no reply is NOT credited back.** It says nothing about + whether the ledger acted, and rule 2 exists for exactly that case. + +⚠️ **Adoption is conditional, and the condition is the whole safety property.** Adopting a +read that was taken before an outflow erases that outflow's decrement while the transfer +still debits. So a balance is adopted only when nothing was in flight before the read, +nothing after it, and nothing was issued in between. Skipping is cheap — the sweep tries +again; a top-up waits but is never lost. + +**The quiet-window predicate is a conservative superset of "in flight".** It counts any +journalled intent with no recorded block on an order still `Paid` — which includes a +delivery *parked between retries*, when nothing is in the air at all. That is deliberate: +a transfer issued before a balance read can land after it, and the journal cannot +distinguish "awaiting a reply" from "failed, waiting for the next sweep". Tracking true +in-flight state would need a counter incremented before the await, which leaks upward +permanently if a reply callback traps — trading bounded pessimism for unbounded. + +**It is evaluated over the promise index, never over the journal.** The journal gains an +entry per paid order and loses none, so a walk over it costs whatever the gateway has ever +sold; the orders holding a promise are bounded by flow — at most `floor / smallest +order` can hold one at a time, whatever the gateway's history. Reading the index is +*complete*, not merely cheaper: a transfer is only ever issued from `Paid`, `Paid` holds +the promise, and the index is maintained on that same promise predicate at the one site +that writes a status. The three exits from `Paid` preserve it — `Delivered` records the +block in the same patch, `NeedsReview` is still non-terminal and still indexed, and +abandonment is **refused while a transfer is open**. That refusal therefore holds up the +quiet window as well as the double-payout it was added to prevent. + +⚠️ The direction of error matters here and it is not the usual one: this count coming out +**low** means the window reads quiet while a transfer is in flight, which is the +*oversell* direction. Completeness is the property to protect, which is why it is argued +from construction above rather than repaired by a recount. A **stale** index member is +harmless by contrast — its order no longer reads `Paid`, so it does not count. + +**The status comes from the order, not from the journal's copy of it.** The two are +allowed to disagree, and a predicate that mixes them is a predicate with two sources of +truth; the journal entry supplies only the transfer intent and the block index. + +⚠️ **Escalated orders must be excluded from it, or one escalation freezes the reserve for +the life of the canister.** An escalated order keeps the intent-without-block shape +*forever*, so without the `Paid` clause every reconcile skips, every manual refresh skips, +and top-ups silently stop registering — the rail slowly closing with no lever. Excluding +them is sound because they have no outstanding callback: escalation is decided either +before a call is made or after its response arrived, never with one in flight. Their +pessimism is not lost — the promise tally still holds them. + +**The cost, and why it is not a deadlock.** While a delivery is retrying, a top-up is not +adopted, so *new sales* are refused against cycles the ledger already holds. Deliveries +never consult the floor, so delivery itself is unaffected: a dry reserve fails with +insufficient funds, the operator tops up, the retry succeeds *because the ledger has the +cycles regardless of our floor*, and the next reconcile adopts. The remaining pessimistic +case is a ledger outage, where refusing to sell is the correct posture anyway. + +⚠️ **The floor and the promise tally overlap while a transfer is in flight, deliberately.** +The floor drops at issue; the promise is released one response later at `Delivered`. In +between the same order is subtracted twice, so the available figure reads a full order low +for the length of one ledger call. **Do not close the gap by moving either end**: releasing +the promise at issue frees capacity for a sale while the transfer consuming it is +unresolved, and decrementing the floor at settle time lets the balance surprise us +downward. Both ends sit on the pessimistic side of the same unknown. + +### §5.2 — Recovery + +A recurring timer sweeps orders with money-out work outstanding, single-flight, re-armed +after upgrade. Bookkeeping checks run **detached in their own message**: a check must +never be able to stop orders from delivering. + +### §5.3 — Delivery time bounds + +Two: an **alert** threshold (the delivery is late; a human should look) and a **max-wait** +bound (the position is now unresolvable automatically, so it escalates). Both are operator +configurable. + +## §6 — Rails + +### §6.0 — Ingress + +Two paths, and the webhook **cannot** be caller-authenticated: Stripe is the caller, and +it authenticates by signing the payload. So exactly one anonymous, payload-authed HTTP +route exists, and it verifies an HMAC before trusting anything in the body. + +### §6.1 — Card + +Inbound-only in the sense that matters: the canister holds a **restricted** key scoped to +Checkout Sessions, never an `sk_`. It can create sessions and read them back; it cannot +refund, read customers, or reach the account. + +## §7 — Security and trust + +⚠️ **Both secrets are plaintext canister state, and that is at-rest only — provisioning +is sealed (§7.3).** HMAC is symmetric, so *verify = forge*: anything that can check a +signature can forge one, and encrypting the stored blob would only move the problem to the +key that decrypts it. The plaintext therefore has to exist in memory at verification time, +which is why at-rest confidentiality is a subnet property rather than something this +canister can solve. + +⚠️ **The two exposures are separate and were routinely conflated.** *In transit* — a +secret arriving as an ingress argument, seen by the TLS-terminating boundary node and +anything reading a shell history or CI log — is closed, by vetKD sealing. *At rest* — the +value living in replicated, checkpointed canister memory — is not closed **by sealing**, +and cannot be; it is closed by the confidential subnet instead, whose checkpoint and +state-sync paths are confirmed confidential. Sealing +works through why the obvious fix (store ciphertext, derive per use) fails on both +economics and mechanism: at ~26 B cycles per derivation it would cost roughly 3.5 cents +per webhook on an at-cost rail, and a checkpoint captures the heap, so a cached derived +key sits in the same checkpoint as the ciphertext it opens. + +⚠️ **The blast radius is the reserve balance, and sizing it is the control.** A forged +"paid" webhook delivers from the reserve. An earlier design bounded this with a per-period +ICP burn cap; there is no cap now, so the trade is "size the reserve to what a leak could +cost", not "the cap bounds it". SEV-SNP is the intended confidentiality layer and launch +does not block on it — `RUNBOOK.md` §9 is the verification checklist, hardest item first. + +⚠️ **A leaked API key can only create sessions that pay us.** That asymmetry is why the +key's *scope* matters more than its storage. A restricted key scoped to Checkout Sessions += Write can create sessions and read them back (which the recovery sweep needs); an +unrestricted key able to issue refunds would be materially worse to leak. + +⚠️ **The reserve is a STOCK, not a rate, and that is a better bound than a per-period cap +in two ways** — worth stating because "no rate limit" reads as weaker. A cap resets, so a +patient attacker drains it again every period and the operator's total loss is unbounded +over time; the reserve, once empty, refuses further deliveries and cannot be drained again +until a human funds it. And the drain is *visible* in a value the gateway already reports: +the floor only moves down when this gateway issues a transfer, so cycles leaving faster +than orders arrive is exactly the discrepancy `reserve_status` exposes. + +⚠️ **And the one way a stock is worse, stated because an argument that lists only its own +advantages is advocacy.** A cap spreads a loss across periods and so bounds how *fast* it +can happen; a stock can go in a single burst between the leak and its rotation. The design +accepts that and controls it by **sizing** — keep in the account what you are willing to +lose in one go — and by refunding the reserve only after the secret is dead. Both are +procedure rather than mechanism, which is exactly why they are written down (`docs/OPERATE.md`, +§2). + +**Rotation needs no dual-secret window on our side.** While a rolled Stripe secret's +predecessor is still live, Stripe sends one signature per active secret and verification +accepts any single match, so swapping the stored blob at any point during the overlap +never drops a delivery. + +**Governance is a flat controller allowlist with equal privileges.** Any controller can +upgrade, rotate either secret, resolve obligations, set tiers and adjust pricing. The +honest trust model is "trust the operator set; any one of them can upgrade and then +drain". There is deliberately **no** method that moves money (§5). True M-of-N needs a +multisig *canister* as controller, since IC controllers are OR-semantics. + +### §3.2 — Who owns which number + +⚠️ **The canister reports what only it knows; the ledger owns what it owns.** The reserve +balance is a free query on the cycles ledger that anyone can call, so this canister never +mirrors it — what it adds is the part nobody else can compute, how much of that balance is +already promised. The same split decides what a quote discloses: the cycles-ledger transfer +fee is the ledger's number and the *operator's* cost, so it is absorbed rather than shown +as a line in the buyer's price, and the frontend reads it from the ledger directly. + +⚠️ **A stored copy of someone else's number is only acceptable where a wrong value is +self-correcting and cheap.** The delivery path stores the ledger's fee because a wrong one +costs exactly one rejected transfer and the rejection carries the correct value. A quote +has no such correction — nothing checks the number a buyer was shown — so a stored fee +there would buy only the staleness. + +### §7.1 — A refused operator lever answers with a tag, not a sentence + +`expire_order`, `abandon_order` and `record_delivered` return a variant `Err` +(`Orders.ExpireError` / `AbandonError` / `RecordDeliveredError`), so a caller can tell +one refusal from another without matching on prose — `reviewing-motoko` T4, and the +shape `create_order` already used on the money-in path. + +⚠️ **The sentences those methods used to return are gone from the wire, deliberately, +and this table is where they went.** moc emits no docs for variant arms, so the `.did` +carries bare tags, and the audit log is not an alternative home for two reasons that are +not the admissibility rule: an audit line is a record for the operator **later**, not a +response to the caller **now**, so it cannot be a refusal's message; and `AuditLog.mo`'s +own caveat — a bound on the *rate* is not a bound *over time* against an unfixed +condition — makes one line per refused admin attempt the wrong shape for a log that +never prunes. ⚠️ **These refusals are not inadmissible.** `expire_order` already audits +two of its own (`order.expireRaced`, `order.expireFailed`): both are admin-gated and +follow a real outcall, and what the rule excludes is a *pre-commit refusal line fed by a +free caller*, which is a different thing. So the guidance lives here and in the arm docs +in `Orders.mo`, and a console can render per-case copy off the tag. + +⚠️ **What an operator loses is advisory only: the refusal IS the guard.** Misreading +`#deliveryOutstanding` cannot cause the double payout, because the lever has already +refused. That is what makes the trade acceptable while there is no console. + +| Tag | What it means | What to do | +|---|---|---| +| `#notFound` | No such order id | Check the id | +| `#notCreated` (expire) | Only a `#created` order can be expired; carries the status seen | A paid order delivers or escalates; `abandon_order` is for a paid one you have refunded | +| `#sessionNotOpen` | Stripe says the session is settled or already expired | Nothing changed. Wait — the webhook or the sweep resolves it on Stripe's answer, the only authority on which of the two happened | +| `#stripeUnauthorized` | 401/403 | Rotate the key: the restricted key needs **Write** on Checkout Sessions. The latch has already notified | +| `#stripeFailed` | Carries Stripe's own wording as `detail` | Read the detail; it is not paraphrased | +| `#movedInFlight` (expire) | A second tab settled the order during the outcall | Nothing changed. Re-read the order | +| `#notAbandonable` | Only a paid or under-review order can be abandoned | Carries the status seen | +| `#deliveryOutstanding` | ⚠️ The transfer is outstanding, so whether the cycles moved is **unknown** | **A wait, not a refusal.** Check `pending_deliveries`. Either it settles and needs no refund, or the ~24 h dedup window escalates it to `#needsReview`, where the ledger is the source of truth and the order id is in the transfer's memo | +| `#reasonRequired` | `abandon_order` was called with an empty reason | The audit trail must record *why* alongside *who* | +| `#notUnderReview` | Only an under-review order can be recorded as delivered | A live order delivers on its own | +| `#transitionRefused` | The state machine refused the transition | Re-read the order; something moved it | + +`cancel_order`'s errors are typed too — §7.2 is the rule that let its copy move to the frontend, and what that rule refuses. + +### §7.2 — Facts stay in the canister; copy may leave it + +⚠️ **The rule, and it is what decides each case rather than a preference for one layer:** +a refusal's copy may live in the frontend **provided its payload carries every fact the +sentence asserted.** Where the copy *is* the facts, it stays on-chain. + +`cancel_order` satisfies it: `#notCancellable` and `#settledInFlight` carry the +status their sentences name, `#sessionNotClosed` is deliberately one tag for three +indistinguishable causes (§4.3) — which is exactly what its sentence said — and +nothing else in the seven asserted a fact beyond "this happened". + +⚠️ **What is given up, stated because it is real.** With prose from the canister, the +sentence a buyer reads is composed on-chain. With tags, whoever deploys the frontend +controls the explanation. That is acceptable here and only here because the FACTS remain +independently checkable: `get_order` and `receipt` carry the status and the figures, so a +buyer misled by page copy can verify the actual state without trusting the page. Apply +this rule to `receipt` and the answer flips — there the copy is the facts, and it stays. + +`scripts/check-typed-errors.py` enforces the typed half; the payload half is a review +question, because no check can tell whether a variant carries what its sentence claims. + +### §7.3 — Sealed provisioning, and an unaudited dependency on the money path + +Both secrets are provisioned as **ciphertext**: the operator derives this canister's vetKD +public key offline and encrypts to it, and only this canister can obtain the matching +private key. `set_stripe_api_key` and `set_webhook_secret` take a `blob`, decrypt at set +time, and store the plaintext. The full mechanism — why offline derivation, why one +identity for both secrets, why the key is cached transiently — is in `src/backend/Sealed.mo`. + +⚠️ **Decryption happens at SET time, which is what makes a wrong seal a provisioning error +rather than an outage.** A ciphertext sealed to the wrong key fails in front of whoever is +seeding it, with the store untouched, instead of being accepted and found unreadable at the +first webhook. `Secret.set`'s length floor then applies to the decrypted value, not to the +envelope. + +⚠️ **The decrypting code is EXPERIMENTAL and UNAUDITED, and the reason that is acceptable +here is an asymmetry — not a judgement that the code is fine.** `mo:ic-vetkeys` has no +BLS12-381, so `vendor/icp-seeding-secrets-poc` (a git submodule, pinned by commit) supplies +the curve as `ic-bls12-381` and the vetKD layer on it. ICDevs' `bls12-381` on mops is a +separate implementation of the same curve that nobody has run against `ic_bls12_381`'s +vectors, so it is not a drop-in replacement. The division of labour is what matters: + +| step | performed by | audited | +|---|---|---| +| derive the public key | `@icp-sdk/vetkeys` (client) | its curve, `@noble/curves`, is | +| **encrypt the secret** | `@icp-sdk/vetkeys` (client) | its curve, `@noble/curves`, is | +| decrypt in-canister | the pinned Motoko port | **no** | + +`@icp-sdk/vetkeys` is DFINITY's own library and makes no audit claim of its own; the +BLS12-381 and hash-to-curve it builds on come from `@noble/curves`, which Cure53 audited in +2024 with both in scope. The secret's confidentiality *in transit* rests entirely on the +ciphertext, which that client code produced, and on the vetKD protocol. The unaudited half +only **opens** that ciphertext, after it has already crossed the boundary node safely — so +a bug there fails provisioning closed (an error, nothing stored) rather than weakening +anything in flight. Set against the alternative it replaced, a live `rk_...` in an ingress +argument, this cannot be worse. + +⚠️ **Three limits of that argument, stated because an asymmetry is easy to over-claim.** +First, `VetKey.decryptAndVerify`'s verification step *is* security-relevant: if it were +vacuous, a forged vetKD reply would be accepted — though only the subnet serving the key +could forge one, and it can read canister memory regardless, so it is not new exposure. +Second, the argument is architectural; nobody here has reviewed the field arithmetic. What +stands in for that is `scripts/check-crypto-vectors.sh`, which runs the port's 108 vectors +— generated from `ic_bls12_381` and `ic-vetkeys`, DFINITY's Rust implementations, which are +themselves unaudited — in this project's gate under this project's toolchain. That is not an +audit and does not pretend to be. Third, the port's point decompression does not check +membership of the prime-order subgroup, which `ic_bls12_381` does. Every point it +decompresses here comes from a controller (the ciphertext — both provisioning endpoints are +`requireController`) or from the subnet (the vetKD reply and public key), and both can +already read canister memory, so the gap admits no one new. + +⚠️ **The acceptance is DECIDED, and it is conditional on a check — not on the argument +above.** Unaudited crypto on this path was accepted for this repository by its owner. What +makes that safe to have decided is not the asymmetry, which is reasoning, but +`scripts/check-crypto-vectors.sh`: 108 vectors from the Rust reference, run in this +project's gate under **this project's** toolchain pins. Upstream pins its own `moc` and ours +moves independently, so that combination is tested nowhere else. + +**So the check is load-bearing for the decision, and the failure mode is silent.** If it is +ever deleted, or starts skipping, or stops collecting vectors, the acceptance rests on +prose alone and nothing says so. The count floor and the abort-on-missing-submodule guard +in that script exist for exactly that reason — a vacuous pass there is worse than a red +gate. ⚠️ A **`moc` upgrade is the change most likely to surface this** (see the +endpoint-doc inversion below): if a bump makes the vendored packages fail to compile, the +tempting fix is to skip their suites and move on, which quietly removes the only thing +standing behind this section. + +**Deletion criterion:** when `mo:ic-vetkeys` ships BLS12-381 — or a published curve is shown +to agree with `ic_bls12_381` on the port's vectors — the submodule and both path +dependencies go, and this section becomes a note about what used to be here. + +## §8 — Verifiability + +The thesis: **the number an operator monitors is the number a buyer can check.** The +reserve is an account on the cycles ledger, so anyone can read its balance without this +canister's cooperation. What the canister adds is the part only it knows — how much of +that balance is already promised. + +## §9 — Layout and the test bar + +One Motoko backend canister plus a static asset canister, a hand-rolled `Http.mo` rather +than a framework, and Candid bindings for the ledgers this canister actually calls. +Modules take their dependencies as records, which is why the whole ingestion path is +unit-testable with no IC environment. + +Inside the backend: `Main.mo` owns state and composes, the public endpoints live in +`mixins/` split by feature, and the domain logic is flat stateless modules — `Orders`, +`Delivery`, `Gate`, `Reserve`, `Pricing`, `Receipts`, `rails/Card`. §9.1 has the rules +the mixin layer rests on. + +⚠️ **The go-live bar is PocketIC, not the unit suites.** Unit tests wherever logic is +isolable — HMAC, fee and rate arithmetic, parsers, state-machine transitions, dedup — and +a PocketIC scenario for everything that needs a replica: upgrades mid-delivery, ledger +outages, real HTTP ingress, the §5.1 replay contract. A change is not done on a build or a +unit pass alone. `test/integration/README.md` maps the scenarios to the items here. + +### §9.1 — Endpoints live in mixins, and how state reaches them + +`main.mo` owns state and composes; the public endpoints live in `mixins/`, split by +feature. That is the `writing-motoko` architecture, and a public method in the +composition root is `reviewing-motoko` A1. + +⚠️ **`include` evaluates its arguments ONCE and passes them by value.** That single fact +decides the shape of every mixin here: + +- **A record or collection passes directly.** `Set`, `Map`, `Orders.Store`, + `Secret.Store` and the seven `*State` records are heap objects, so the mixin and the actor + share one and writes go through. +- **A bare `var` must NOT be passed.** The mixin would receive a snapshot from install + time: its writes would land on a copy and its reads would never move. This is silent — + it compiles, and the getter simply answers the initial value forever. + +⚠️ **So mutable state is grouped into SEVEN subsystem records rather than passed field +by field.** `reserveState`, `gateState`, `pricingState`, `stripeState`, `tierState`, +`deliveryState`, `recoveryState`. Grouping is also what A6 asks for: a mixin receives the +slice it uses. The alternative — an accessor closure per field — needs no shape change, +and it is what the three TRANSIENT fields still use, for a sharper reason: they exist to +answer "has this happened since the canister started", which a value frozen at include +time answers wrongly and confidently. For the 21 grouped `var` fields it would have put +plumbing at every include site to work around by-value semantics. + +⚠️ **Two counts in this section have different denominators, so each is stated with its +instrument.** The seven records hold **21 `var` fields** between them — that is the +number every claim in this section is about. `deployed/backend.most` separately declares +**19 top-level stable names**, of which seven are those records. The two figures move +independently, so a bare number here is how an edit lands on the quantity nobody +measured. + +⚠️ **`webhookPaidOrder` uses a TAKE-ONCE accessor**, not a get/set pair: the dispatcher +sets it and the mixin consumes it in the same message, so reading and clearing as one +operation is what stops a stale value from triggering a second delivery kick — on the one +route that is unauthenticated by necessity. + +⚠️ **An immutable transient value passes directly.** `routes`, `maxRequestBodyBytes` and +the `cyclesLedger` actor reference are transient `let`s built during initialisation, which +is when `include` evaluates its arguments, and none of them ever changes. The rule above +is about MUTABLE state; a snapshot of an immutable value is the value. + +⚠️⚠️ **Grouping also closed those fields to future extension, and THAT cost outlives the +one-time drop below.** Before the split each of those 21 was an actor-level `var`, and +adding another was free. Now a new field inside any `*State` record needs the migration +chain this project has never had (§11) — measured both ways on the branch that +introduced them: + +| Change | `mops check` | +|---|---| +| a field added to `reserveState` | ✗ *"expected field … is missing … Write an explicit migration function"* | +| a new **actor-level** stable `var` | ✓ *"Stable compatibility check passed"* | + +⚠️ **So the cheap route still exists and this rule must not hide it:** new mutable state +that a mixin needs can be **a new actor-level stable `var` plus an accessor pair at the +include site** — which is exactly what `stripeState.origin`'s predecessor did, and what +the transient fields still do. Grouping is the default because it reads better and +enforces A6; it is not the only option, and reaching for it by reflex is how a routine +field addition turns into a migration. + +⚠️ **Grouping moved the stable shape, and that was a deliberate call, taken once.** +Twenty-one stable variables were dropped rather than migrated, across two grouping passes — one per `var` field, all seven records. Legitimate only because +there is no deployment whose data matters: pre-launch, with no migration chain (§11 / +§11), reinstall is the documented loop. `scripts/check-stable-promotion.sh` refuses such +a promotion unless `--accept-reinstall` is passed, and prints which variables are lost — +so the decision is stated rather than absorbed. **After the first deployment worth +keeping, this is no longer available** and a change of this shape needs the chain. + +⚠️ **Two costs of mixins, both measured rather than assumed:** + +1. **~~`moc` does not emit doc comments for mixin members~~ — FIXED in moc 1.16.0, and + this entry is kept to record that it reversed.** On 1.15.1 a relocated endpoint lost its + documentation from `backend.did` and from the generated TypeScript bindings, ~6 lines + per endpoint. 1.16.0 emits them: measured on this project as 0 → **613** doc lines + inside the service block, all 62 endpoints' published blocks byte-equal to their source + blocks. + + This entry said the cost "reverses on its own if moc changes", and it did — but not + neutrally. `scripts/check-endpoint-docs.py` had grown a second check that asserted the + `.did` documented *no* endpoint, sound only while the docs were being dropped, whose + remedy (*make it `//`*) would now delete a deliberately published doc. It was replaced + by a direct comparison of published against written. ⚠️ **The lesson worth keeping: a + compiler defect that a check is built on is a dependency, and the fix is a breaking + change to the check.** +2. **Only imports may precede a `mixin` block** (M0228), so a type the mixin's interface + needs cannot be declared above the block in the same file — it goes inside the block, + or in a module. + + ⚠️ **What actually matters is the NAME, not the location, and an earlier version of + this rule said "never moved to `Types.mo`" — absolute beyond its own reasoning.** + Candid derives a type's published name from its declaration, so a move that keeps the + name keeps the interface: `Amount` lives in `Types.mo` so `Purchase.plan` can + name it, and `type Amount` is byte-identical in `backend.did` before and after. A + RENAME is what moves the interface. The acceptance test below is what tells the two + apart, so relocate freely and let it decide. + +⚠️ **The acceptance test for a relocation is `scripts/check-did-signatures.sh`**: the +Candid signatures must be identical with doc comments stripped. It has already caught +what review would not — **Candid records argument names**, so a mixin parameter that +forces an endpoint's parameter to be renamed moves the published signature. + +⚠️ **Three more invariants belong to the same pass, because a textual rename breaks all +of them silently:** the audit tag literals (RUNBOOK §8 alerts on exact text, and one +rename hit 34 of them), that every endpoint still carries a doc, and that each guarded +method still calls the guard its tier declares. `check-endpoint-docs.py`, +`check-admin-tiers.py` and a tag diff cover those; run them per group, not once at the +end. + +## §11 — Deferred + +A second rail, M-of-N or SNS governance, an external audit of the delivery and +secret-handling paths, and archival once volume warrants. Events this gateway does not +subscribe to (disputes, for instance) are acked rather than refused, so a Dashboard +configuration cannot disable the endpoint. + +### §11.1 — Seams that are binding + +A second rail should *add* rather than force an unwind. Four seams are deliberate, and +each is stated at the code it constrains: + +1. **`Owner` stays a one-case variant** (`{ #ii : Principal }`). Adding a case to a stable + variant is migration-free; widening a bare `Principal` is a stable-state migration plus + an audit of every authz site. The variant also makes the compiler force every authz + check to pattern-match. +2. **HTTP dispatch goes through a route *table*.** "Exactly one route" is policy, not + architecture. +3. **Ownership is captured at the API edge.** `Orders.create` takes the owner as a + parameter and never reads `msg.caller`. +4. **Expiry is per-rail money-in policy, not core behaviour.** The state machine owns + transitions, not expiry policy — §4's reversal is why this seam matters. diff --git a/docs/OPERATE.md b/docs/OPERATE.md new file mode 100644 index 0000000..1f09281 --- /dev/null +++ b/docs/OPERATE.md @@ -0,0 +1,1012 @@ +# Operating the gateway + +Setup, in every mode it runs in. `RUNBOOK.md` is the other half — day-2 operations, +entered by symptom, once something is already running. + +## The three modes, and the fourth cell + +Two settings decide the mode, and they are independent, so four combinations are +representable: + +| `expected_livemode` | `divisor` | mode | what bounds a giveaway | +|---|---|---|---| +| `?false` | 1 | **local plain** — what `scripts/local-dev-seed.sh` sets | nothing needed; the cycles are local | +| `?false` | >1 | **mainnet simulation** | the buyer allow-list | +| `?true` | 1 | **mainnet production** | real money | +| `?true` | >1 | **refused by design** — `#simulationDivisorSet` / `#divisorNeedsSandbox` | — | + +⚠️ **Row 1 is representable on MAINNET, and that is the sharp edge.** `?false` with +`divisor = 1` on mainnet means free Stripe-sandbox payments delivering **full-value** +cycles out of a funded reserve. `Gate`'s `#unboundedGiveaway` guard keys on *accepting +test payments + an empty allow-list + a funded reserve* — **it never looks at the +divisor**. So the only thing between that configuration and giving real cycles away is +who is on the allow-list. Both halves of that are enforced; nothing but this table puts +them on one page. + +⚠️ **The divisor must be set BEFORE the first order.** `set_pricing_config` refuses a +divisor change once any order is stored (`#divisorChangeWithOrders`), because every +earlier receipt would recompute against the new value. The only way back is +`icp deploy --mode reinstall`, which discards the stored orders along with everything +else in canister memory. + +⚠️ **Reinstall is available on mainnet too — what makes it unacceptable is REAL orders, +not the network.** On a simulation gateway the stored orders are test orders, so +reinstalling to `divisor = 1` is the ordinary route to production, and it is much +cheaper than a fresh canister because the canister id does not change: + +| survives a reinstall | because | +|---|---| +| the Stripe webhook URL | it names the canister id | +| both sealed secrets' **ciphertexts** | the seal derives from the master key, the canister id and a fixed context — nothing from canister state, so re-send the same blobs | +| the reserve's cycles | the account belongs to the canister's principal and lives on the cycles ledger, a different canister | + +What has to be redone: `refresh_reserve` (the floor resets to 0 while the stock is +intact), the buyer allow-list, and re-sending the two sealed blobs. Orders, receipts, +the audit log, the journals and the dedup sets are gone — which is why a gateway +holding orders **someone paid for** has no way back. + +**Local runs in plain mode only.** A simulation divisor is settable locally too, but it +exists for mainnet simulation, where the arithmetic and its guards are documented — see +Mode 2. Keeping one local path means the local procedure has one shape. + +## Prerequisites + +- Node.js ≥ 22 +- `mops` — `npm i -g ic-mops` (the Motoko compiler version is pinned in + `mops.toml [toolchain]`; mops resolves it automatically) +- `icp` CLI — `npm i -g @icp-sdk/icp-cli @icp-sdk/ic-wasm` + +This project uses **`icp-cli`, never `dfx`**. Project configuration lives in +`icp.yaml`; Motoko dependencies in `mops.toml` / `mops.lock`. + +⚠️ **Clone with submodules.** The backend decrypts its sealed secrets using a +BLS12-381 implementation pinned as a git submodule, resolved by `mops` as a path +dependency — so without it nothing compiles: + +```sh +git clone --recurse-submodules https://github.com/marc0olo/cyclepay +# already cloned: +git submodule update --init --recursive +``` + +That code is **experimental and unaudited**; `docs/DESIGN.md` §7.3 explains what it is +trusted with, what it is not, and the deletion criterion. + +## Mode 1 — local + +### Run the app locally, from nothing + +Six steps, in this order. Steps 1–4 need nothing from Stripe; 5–6 are for clicking +through a real payment. + +```sh +# 1. dependencies and a local replica +git submodule update --init --recursive # the pinned crypto, first time only +mops install +icp network start -d # PocketIC, gateway on :8000 + +# 2. deploy (backend, frontend, local XRC mock) +icp deploy + +# 3. make the gateway sellable — NOT optional, see below +scripts/local-dev-seed.sh + +# 3b. OPTIONAL — simulation mode: real Stripe test payments, cycles scaled down. +# ⚠️ MUST come before the first order. A divisor change is refused once any +# order is stored, and the only way back is `icp deploy --mode reinstall`. +# Requires the seed's `expected_livemode = ?false` (guard: `?false` exactly, +# and `null` — the fresh-install default — is refused), so run it after step 3. +icp canister call backend set_pricing_config '(record { + feeBps = 290 : nat; divisor = 1_000 : nat; minRateSources = 2 : nat; + feeFixedCents = 30 : nat; maxAgeNs = 300_000_000_000 : int; + maxRateDeltaBps = 5_000 : nat })' +# At divisor 1_000 a $10 purchase quotes ~7.24 G cycles instead of ~7.24 T. +# The arithmetic and the ceiling that scales with minPurchaseUsdCents are in +# "The simulation arithmetic" under Mode 2 below. It is framed for mainnet +# against the Stripe sandbox; the arithmetic is identical locally. The ordered +# mainnet procedure, custom domain included, is Mode 2 itself. + +# 4. allow-list yourself as a buyer +# Open http://frontend.local.localhost:8000/ , sign in with Internet Identity, +# copy the principal the page shows, then: +icp canister call backend add_allowed_buyer '(principal "")' + +# 5. one-time: your restricted Stripe key (see below for the required scope) +cat > scripts/.local-dev.env <<'ENV' +STRIPE_API_KEY=rk_test_... +ENV +scripts/local-dev-seed.sh # re-run: it provisions the key + +# 6. in a SECOND terminal: webhook secret + forwarder, and leave it running +scripts/stripe-dev.sh +``` + +Then buy: pick an amount, pay with `4242 4242 4242 4242`, and the order walks +`created → paid → delivered`. + +**Which script owns what**, because re-running the wrong one fixes nothing: + +| | owns | after a `--mode reinstall` | +|---|---|---| +| `local-dev-seed.sh` | tiers, the cycles reserve, the CMC + XRC rates, the delivery timeline, the canister's own gas, the Stripe **API key** | re-run it | +| step 4 | the buyer allow-list | redo it — the principal is wiped | +| `stripe-dev.sh` | expected livemode, the **webhook signing secret**, forwarding | re-run it | + +⚠️ **Run the seed before `stripe-dev.sh`.** The latter refuses to start if the gateway +cannot price, which is how it says "seed first" rather than half-configuring. + +⚠️ **Step 4 is the one nobody guesses.** With an empty allow-list every purchase +refuses with `unboundedGiveaway` — the faucet guard, not a misconfiguration. The +seed prints the exact command and does not treat it as a failure. + +⚠️ **Step 5 needs a restricted key** (`rk_...`) with **Checkout Sessions = Write** and +everything else None. Write is the level that also grants the read the recovery sweep +needs. No Payment Links exist to configure: the canister creates a Checkout +Session per order through the API and sets `client_reference_id` on it. + +⚠️ **Do NOT `export STRIPE_API_KEY`** into a shell where you run the Stripe CLI. The CLI +prefers it over your `stripe login` credential, and opening a CLI session needs a +permission a restricted key correctly lacks — `stripe listen` then fails with +*more_permissions_required*, naming your key. Keeping it in `scripts/.local-dev.env` +avoids this; our scripts strip the variable before calling the CLI, a command you type +yourself is not protected. + +#### Why the seed is not optional + +A freshly deployed gateway is fail-closed on five axes at once, which looks like a +broken app rather than a safe one: + +| What you see | Why | +|---|---| +| "No amounts are configured yet" | No presets registered. That is **not** a paused rail — a custom amount still works; the seed registers the tiles | +| "No exchange rate available yet" | Pricing needs the CMC rate, which only NNS governance can set — the seed reaches it through the local PocketIC control API | +| "temporarily unavailable while the gateway is topped up" | `minCanisterCycles` is 5 T and `icp deploy` creates the canister with less. This is the canister's own **gas**, not the cycles it sells; the seed tops up rather than lowering the floor | +| Orders paid but never delivered | The **cycles reserve** is empty — delivery transfers from the gateway's own cycles-ledger account | +| `unboundedGiveaway` on every purchase | The buyer allow-list is empty (step 4) | + +The seed verifies a **$10** purchase is admitted before reporting success, and queries +the CMC for what it actually stored rather than trusting the `Ok` reply — PocketIC +returning 200 only means the message was delivered. + +⚠️ **The CMC rate goes stale in 15 minutes.** `scripts/local-dev-seed.sh --rate-only` +re-arms it without redoing the rest. + +⚠️ **A stale rate shows as `cycles = null` in `quote_previews` — at ANY divisor.** That is +the same symptom as a scaled amount too small to clear the ledger fee, so re-arm with +`--rate-only` before concluding the divisor is too large. Both readings are available and +only one is usually true. + +⚠️ **If the local identity runs out of cycles** — `Insufficient cycles` from a top-up — +either convert more or restart the network: + +```sh +icp cycles mint --icp 5 # ~17.5 T; ICP is pre-seeded on local principals +icp network stop && icp network start -d # worst case: reseeds principals, wipes state +``` + +Re-running the seed is otherwise free: it skips the 20 T top-up when the canister +already clears its floor with headroom. + +### Verify the deployment wiring + +```sh +scripts/e2e-local.sh # 20 checks against a real local network +``` + +Covers what the canister suites structurally cannot: the deploy pipeline, the +`PUBLIC_CANISTER_ID:xrc` override, the `ic_env` cookie, local Internet Identity, +a signed webhook through the real gateway (and unsigned/bad-MAC bodies refused), +and that `icp.yaml`'s `ic` environment excludes the local-only mock. + +### Backend iteration loop + +```sh +mops check -- -Werror # typecheck + lint; -Werror makes M0145 a build failure +mops build # compile, and regenerate the committed .did +mops test # the Motoko unit suites +``` + +The three IC brand typefaces are **vendored** into the bundle rather than linked from +Google Fonts: a page that takes card details should not send every visitor's IP to a +third party on load. `scripts/fetch-fonts.sh` re-vendors them — the committed `.woff2` +files are its output. + +### Frontend iteration with hot reload + +Needs the network up and the backend deployed: the Vite dev server shells out to +`icp` to simulate the `ic_env` cookie the asset canister sets in production. + +```sh +icp network start -d && icp deploy +npm --prefix src/frontend run dev +``` + +TypeScript bindings are regenerated from the committed Candid interface +(`src/backend/dist/backend.did`) by the `icpBindgen` Vite plugin on every +dev/build run. Change the backend API, run `mops build` to refresh the `.did`, +and the frontend picks it up — or fails to typecheck, which is the point. + +### After a change to the stable shape + +A new field on `Order`, a removed variant tag, a changed config record: the +upgrade **traps in `register_stable_type`** because enhanced orthogonal +persistence refuses to reinterpret the existing memory. Locally that is not a +migration problem, it is a two-command problem: + +```sh +icp deploy --mode reinstall --yes +./scripts/local-dev-seed.sh +``` + +`scripts/e2e-local.sh` detects the trap and does this for you. Do **not** add a +mops migration file to avoid it — the app holds no data anyone needs, and every +migration replays forever on a fresh install. The chain is a go-live +prerequisite, and `RUNBOOK.md` section 1.1 item 1 carries the three facts that decide +how it gets written. + +Reinstalling wipes local orders, the audit log and the delivery journal. That is +expected: re-seed, and restart a manual run from the top. + +⚠️ **It also wipes the Stripe webhook secret, and `local-dev-seed.sh` does not +put it back** — only `scripts/stripe-dev.sh` does, because the secret belongs to +a `stripe listen` session rather than to the deployment. So after a reinstall, +re-run `scripts/stripe-dev.sh` before paying anything. + +Skipping it costs more than it looks: an unprovisioned secret makes the canister +drop every event it cannot verify, so a real payment produces **no order +movement, no error-queue entry and no audit line at all**. Check it in one call +rather than guessing: + +```sh +icp canister call backend webhook_secret_status '()' # isSet must be true +``` + +### Stopping + +```sh +icp network stop +``` + +### Testing the Stripe rail locally + +Against a **Stripe sandbox account**, with the real Stripe CLI forwarding real +signed webhooks into a local replica: + +```sh +brew install stripe/stripe-cli/stripe +stripe login # choose a SANDBOX account, never a live one + +icp network start -d && icp deploy backend +scripts/stripe-dev.sh # bootstraps dev config, wires the signing secret, forwards +``` + +Then, in another terminal, `stripe trigger checkout.session.completed`. See +`docs/STRIPE.md` §15 for the happy-path walkthrough and the two gotchas +(`stripe trigger` sends no `client_reference_id`; the canister checks the +signature timestamp against its own clock). + +## Mode 2 — mainnet simulation + +⚠️ **This is not Mode 3 with different values.** Mode 3 deploys a production gateway: +live Stripe key, `expected_livemode = ?true`, real cycles delivered. This deploys a +gateway that takes **real charges in Stripe's sandbox** and delivers a divisor'd fraction +of the cycles, so two of Mode 3's steps are not merely different here, they are *refused* +— `divisor > 1` requires `expected_livemode == ?false` exactly, and the two guards are +mutual (see The simulation arithmetic below). + +Target of this procedure: `https://cyclepay.raymondk.co`, on the confidential subnet +`re2t4-faa75-v3vhk-kdmdr-uyrkl-aik2l-ixd6u-p3fyr-zlfkc-6c5af-zae`, divisor 1000. + +### What needs no configuration + +| | why | +|---|---| +| the Exchange Rate Canister | `icp.yaml`'s `ic` environment lists `[backend, frontend]` only, so the local XRC mock is never created on mainnet and its id is never injected. The backend then falls back to the real XRC. **Verify — but not until a rate call has happened, see below:** `pricing_status.xrcCanisterId` must read `uf6dk-hyaaa-aaaaq-qaaaq-cai` | +| the Cycles Minting Canister | `rkp4c-7iaaa-aaaaa-aaaca-cai` is compiled in and is the same principal on mainnet and PocketIC | +| a webhook forwarder | `scripts/stripe-dev.sh` exists only because Stripe cannot reach localhost. On mainnet Stripe posts straight to the canister | +| the CSP | `connect-src` already admits `https://icp-api.io`, which is where a custom-domain page sends its canister calls | + +### The order is one-way in four places + +``` +expected_livemode = ?false <- ?false EXACTLY; null is the fresh-install default and is refused + | +set_pricing_config divisor=1000 <- refused once ANY order is stored. Reinstall is the only way back + | +add_allowed_buyer <- MUST precede funding the reserve + | +icp cycles transfer -> refresh_reserve +``` + +And the fifth, outside the canister: **the derivation origin decides who every buyer is.** +It is pinned to the frontend canister's own origin (`src/frontend/src/config.ts`), which is +what makes this test domain and whatever production domain is chosen yield the **same** +principals. Changing that pin after the first sign-in strands every account. + +### Cycles + +```bash +# ⚠️ `-n ic` on BOTH. Without it these act on the local network, where `mint` has no CMC +# to convert against and `balance` reports a local balance that has nothing to do with +# what you are about to spend. Exactly one of --icp / --cycles is required. +icp cycles mint --icp 5 -n ic # or --cycles 8600b, which solves for the ICP needed +icp cycles balance -n ic +``` + +⚠️ **Every row is cash to mint, and a component sits under its parent rather than beside +it.** Mixing targets ("to 5 T") with increments ("+0.2 T") is how a budget stops being +checkable: an earlier version of this table listed the creation fee next to the 4.0 T it +comes out of, and totalled 7.7 T while its own rows summed to 8.5 T. + +⚠️ **Measured on this subnet, not derived.** An earlier version scaled the creation fee +by 7/13 with the rest of the per-node costs and budgeted ~269 B per canister. The real +deployment consumed **505.9 B each**: the creation fee is the 500 B flat figure and does +**not** scale with subnet size. That understated the total by half a trillion. + +``` + 4.000 T icp deploy, two canisters at the 2 T default + of which creation fees 1.012 T consumed (505.9 B each, MEASURED) + of which lands as balance 2.988 T 1.494 T per canister ++ 3.506 T top the backend up to its own-gas floor + 5 T floor minus the 1.494 T it actually holds. COMPUTE THIS, see below ++ 0.200 T the sellable reserve, a SEPARATE pot on the cycles ledger + at divisor 1000 a $10 purchase locks ~7.24 G, so ~27 test purchases ++ 1.000 T slack for one reinstall, because the divisor is one-way once an order exists + ──────── + 8.706 T to mint +``` + +| threshold | value | what happens below it | +|---|---|---| +| `Gate.Config.minCanisterCycles` | 5 T | the gate admits **no orders**. Compared against the raw `Cycles.balance()`, so the freezing threshold does not eat into it | +| the reserve floor | anything | `#reserveShort`, and it stays 0 until `refresh_reserve` observes a top-up | + +⚠️ **4.5 T is not enough, and the shortfall is invisible until the first order**: the two +canisters get created, the frontend serves, and every `create_order` is refused because the +backend sits under its own-gas floor. 5 ICP mints comfortably past 8.6 T at current rates; +check with `icp cycles balance -n ic` before starting rather than after step 1. + +⚠️ **`pricing_status` reads `xrcCanisterId = null`, `rates = null`, `lastAttempt = null` +on a fresh deploy, and all three are CORRECT.** Do not read them as a broken rate path. + +- `xrcCanisterId` is **null until an XRC call has actually resolved it** (`Main.mo`), and + deliberately so: defaulting it to the mainnet id would make the one signal that detects + a mainnet deploy wrongly pointed at a mock read *all-clear* during exactly the window an + operator checks a fresh deploy. +- No XRC call has happened because the rate timer returns early while no rail is selling + (`rateTimerJob`: `if (not railsLive()) return`). A dark gateway spends nothing, and the + rail is not live until both Stripe secrets and the return origin are set (steps 4 and 5). + +So the XRC verification cannot pass before step 5. To check it earlier, force a tick: +`refresh_rates` is an admin method that calls the refresh **directly and bypasses the +`railsLive` guard**, which is what makes it the right lever here and after any deploy. + +```bash +icp canister call backend refresh_rates '()' -e ic --identity +icp canister call backend pricing_status '()' -e ic # xrcCanisterId = uf6dk-hyaaa-aaaaq-qaaaq-cai +``` + +⚠️ And `xrcCanisterId` is **transient**: it is null again after every upgrade until the +next rate call. A null reading after a redeploy means "not yet asked", never "misconfigured". + +### 1. Create and install on the confidential subnet + +```bash +# From a green main. The -Werror gate runs on `mops check`, not on `icp deploy` (see Mode 3). +bash scripts/test-all.sh + +icp deploy -e ic \ + --subnet re2t4-faa75-v3vhk-kdmdr-uyrkl-aik2l-ixd6u-p3fyr-zlfkc-6c5af-zae + +icp canister status backend -e ic -i # note both ids +icp canister status frontend -e ic -i + +# The backend's own gas, to the floor. This is NOT the reserve. +# +# ⚠️ **Read the balance and compute the difference; do not paste a figure.** What +# creation leaves behind is not something to assume -- this step said `--amount 3400b`, +# which lands at 4.894 T against a 5 T floor and leaves the gate refusing every order +# after an operator has "done the step". Off by 106 B, invisible until the first order. +icp canister status backend -e ic | grep -i cycles # e.g. 1_494_093_400_599 +# top-up = 5_000_000_000_000 - that, rounded up. For the figure above: 3506b. +icp canister top-up backend --amount 3506b -e ic +icp canister status backend -e ic | grep -i cycles # must now read >= 5_000_000_000_000 +``` + +⚠️ **`--subnet` on the deploy, not on a later create.** A canister already created on the +default application subnet cannot be moved; the only fix is to delete it and start over, +and on mainnet that means new canister ids — which means a new derivation origin and a +new principal for anyone who signed in. + +Raise the freezing threshold once the deployment is real. 30 days is thin for +money-bearing state, and for a sandbox it is a judgement call rather than a rule: + +```bash +icp canister settings update backend --freezing-threshold 7776000 -e ic # 90 days +``` + +### 2. The custom domain + +Two files ship in the frontend already — `src/frontend/public/.well-known/ic-domains` and +`ii-alternative-origins` — so the canister serves both after step 1. What remains is DNS +and the registration call. + +| record | host | value | +|---|---|---| +| CNAME | `cyclepay.raymondk.co` | `cyclepay.raymondk.co.icp1.io` | +| TXT | `_canister-id.cyclepay.raymondk.co` | the **frontend** canister id | +| CNAME | `_acme-challenge.cyclepay.raymondk.co` | `_acme-challenge.cyclepay.raymondk.co.icp2.io` | + +⚠️ **Turn off the DNS provider's own TLS.** Cloudflare's Universal SSL and equivalents +interfere with the ACME challenge the boundary nodes run, and can leave stale +`_acme-challenge` TXT records that do not appear in the dashboard. Check with +`dig TXT _acme-challenge.cyclepay.raymondk.co` — there should be no TXT records, only the +CNAME. + +```bash +curl -sL "https://icp.net/custom-domains/v1/cyclepay.raymondk.co/validate" | jq +curl -sL -X POST "https://icp.net/custom-domains/v1/cyclepay.raymondk.co" | jq +curl -sL "https://icp.net/custom-domains/v1/cyclepay.raymondk.co" | jq '.data.registration_status' +``` + +Poll until `registered`, then give the gateways a few minutes. + +⚠️ **`.well-known/ii-alternative-origins` is what makes the domain usable at all.** +Internet Identity fetches it from the derivation origin and refuses to derive for a +serving origin the file does not list. Adding a second serving domain later means editing +that file **and** redeploying before the domain goes live. + +### 3. Declare the mode, then the divisor + +```bash +# ?false EXACTLY. `null` accepts either mode and is what a fresh install has. +icp canister call backend set_expected_livemode '(opt false)' -e ic --identity +icp canister call backend expected_livemode '()' -e ic + +# Simulation. MUST come before the first order. +icp canister call backend set_pricing_config '(record { + feeBps = 290 : nat; feeFixedCents = 30 : nat; maxAgeNs = 300_000_000_000 : int; + maxRateDeltaBps = 5_000 : nat; minRateSources = 2 : nat; divisor = 1_000 : nat })' \ + -e ic --identity + +icp canister call backend pricing_status '()' -e ic # divisor = 1_000, xrcCanisterId = uf6dk-... +``` + +⚠️ **The divisor's ceiling scales with `minPurchaseUsdCents`, not with the purchase.** At +the shipped $10 floor, divisor 1000 leaves 7.24 G — 72x the flat 100 M ledger deposit fee, +so it is accepted. Lower the floor for a demo and the same divisor is refused +(`#simulationScaleTooSmall`): that is the guard working, not a bug. + +### 4. The secrets, sealed, without any local script + +Both Stripe secrets are **encrypted to the canister before they are sent**, so the +plaintext never appears in an ingress message, a shell history or a CI log. The mainnet +path differs from the local one only in the `ic` argument, which selects the **mainnet +vetKD master key** — and that choice is derived from the environment rather than typed, +because mainnet and a local network both call their key `key_1` and sealing against the +wrong one produces a ciphertext nobody can ever open. + +You do **not** set these up front. Each `read` waits for you to paste the value: + +```bash +# `read -rs` takes one line from the terminal into the variable: -s so nothing echoes, +# -r so a backslash stays a backslash. The `printf` is the prompt -- `read` prints none +# of its own, so without it the terminal just looks hung. +# +# ⚠️ **This is the point, not ceremony.** `STRIPE_API_KEY=rk_... scripts/seal-secret.sh` +# would put the key in ~/.zsh_history; typed into `read`, the only thing history records +# is `read -rs STRIPE_API_KEY`. `export` is what lets the script's child process see it. +# +# ⚠️ `read -rsp "prompt: " VAR` is the BASH idiom and FAILS in zsh, where -p means "read +# from the coprocess" (`zsh:read:1: -p: no coprocess`). Prompt with printf in both. +printf 'Stripe restricted key (rk_...): '; read -rs STRIPE_API_KEY +printf '\n%s chars captured\n' "${#STRIPE_API_KEY}" +export STRIPE_API_KEY +scripts/seal-secret.sh api-key ic + +printf 'Stripe webhook signing secret (whsec_...): '; read -rs STRIPE_WEBHOOK_SECRET +printf '\n%s chars captured\n' "${#STRIPE_WEBHOOK_SECRET}" +export STRIPE_WEBHOOK_SECRET +scripts/seal-secret.sh webhook-secret ic + +# ⚠️ Not optional. An exported key stays readable by every later child process of this +# shell -- including the Stripe CLI, which prefers STRIPE_API_KEY over its own session +# and cannot use a restricted key (`more_permissions_required`). +unset STRIPE_API_KEY STRIPE_WEBHOOK_SECRET +``` + +⚠️ **The length echo is the confirmation, and it exists because there was none.** `read -rs` +shows nothing as you type, so a paste that silently fails is indistinguishable from one that +worked. Check the number against the key you hold before running the seal. + +`scripts/.local-dev.env` is the other way the script finds these, and it is read **only for +a local environment**: it holds sandbox values, and a mainnet key does not belong in the +repo tree even gitignored. + +⚠️ **That scoping is a fix, not a convention.** The file used to be consulted for any +environment whenever the variable was empty, so an empty paste during a mainnet +provisioning sealed the **sandbox** key to the **mainnet** canister, and printed the same +byte count, the same `isSet = true` and the same `generation = 1`. Both keys are 107 +characters, so even the length disclosure could not separate them. The script now refuses a +set-but-empty value outright and never reads the file for a named environment. + +⚠️ **Use a SANDBOX restricted key (`rk_test_...`), Checkout Sessions = Write, everything +else None.** Write is the level that also grants the read the recovery sweep needs. +Never an unrestricted `sk_`: a leaked write-sessions key can only create sessions that pay +*us*, while one that can issue refunds is materially worse. + +⚠️ **Do NOT export `STRIPE_API_KEY` into a shell where the Stripe CLI runs.** The CLI +prefers that variable over its own `stripe login`, and a restricted key cannot open a CLI +session (`more_permissions_required`). + +If you would rather run the two steps by hand — a different machine, or wanting each step +visible — that is all the script does: + +```bash +BACKEND=$(icp canister status backend -e ic -i) + +# 1. Seal, OFFLINE. The public key is computed from a master key shipped in +# @icp-sdk/vetkeys plus the canister id: no network call, no identity, nothing to +# trust. Anyone may seal a secret TO the canister; only it can open one. +CYCLEPAY_SEAL_SECRET="$STRIPE_API_KEY" npm --prefix scripts/seal run --silent seal -- \ + --canister "$BACKEND" --source mainnet --out /tmp/sealed.arg + +# 2. Send it. This is the only step that involves your identity. +icp canister call backend set_stripe_api_key --args-file /tmp/sealed.arg -e ic +rm -f /tmp/sealed.arg + +icp canister call backend stripe_api_key_status '()' -e ic # isSet = true, generation = 1 +``` + +`--source mainnet` has no default, on purpose. A `#notSealedToThisCanister` refusal means +the ciphertext was sealed against the other network's master key — the failure this +arrangement exists to make loud instead of silent. + +### 5. Stripe: where events arrive, and where the buyer comes back + +Two different URLs, and only the second one is the custom domain. + +```bash +# Where the BUYER returns after paying. Validated: https, no query, no fragment. +# success_url becomes `/#/order/`, and the app routes on the hash, so the +# certified-assets canister needs no _redirects rule for it. +icp canister call backend set_stripe_origin '("https://cyclepay.raymondk.co")' \ + -e ic --identity +icp canister call backend stripe_origin '()' -e ic +``` + +In the Stripe **sandbox** dashboard, add a webhook destination pointing at the **backend +canister**, not the domain: + +``` +https://.icp.net/webhook/stripe +``` + +| event | what it does here | omitting it | +|---|---|---| +| `checkout.session.completed` | the delivery path | nothing is ever delivered | +| **`checkout.session.expired`** | the **only** *event* that moves an order to `#expired` and releases its reserve promise (`RUNBOOK.md`'s order-expiry section) | ⚠️ orders sit `#created` past their deadline forever. That section makes a stuck `#created` order the detection signal for a broken one, so a missing subscription manufactures false alarms in the signal the design relies on. ⚠️ **And the obvious lever does not recover it:** `expire_order` asks Stripe first and refuses with `#sessionNotOpen` once the session has really expired there, which is precisely this case. Subscribe, then **resend the event from the Stripe Dashboard** so the order moves through its normal path | +| `charge.refunded` | resolves an `#unattributed` obligation, and files one for a late payment | a refund settles the money and leaves the worklist item open | +| `charge.dispute.created` | one audit line: *reconcile in Stripe; cycles cannot be recovered* | the dispute leaves no trace in the trail | +| `checkout.session.async_payment_succeeded` | the delivery path, for a delayed method | **cannot fire today** — `createBody` pins `payment_method_types[]=card` and cards settle synchronously. Subscribe anyway: if that pin is ever removed, an unsubscribed success is fiat in with nothing delivered and nothing on the worklist | +| `checkout.session.async_payment_failed` | one audit line: *will never pay* | same, and same reason to subscribe | + +⚠️ **Subscribe to all six.** The first three are load-bearing, the next one is the audit +trail, and the last two are free insurance against a change to the payment-method pin. + +⚠️ The signing secret that destination shows you is the one step 4 provisions. Until it is +set the route answers 503 and Stripe retries. + +### 6. Allow-list, then fund the reserve + +⚠️ **This order, for the reason `RUNBOOK.md`'s reserve section gives**: sandbox payments are free and unlimited, so +test mode plus an empty allow-list plus a funded reserve is a cycles faucet, and the +gateway refuses every buyer in that state (`#unboundedGiveaway`). + +```bash +# Sign in at https://cyclepay.raymondk.co, copy the principal the page shows. +# ⚠️ That principal is derived from the pinned derivation origin, so it is the same one +# you would get at the canister URL -- and a principal copied from any OTHER deployment +# of this app is not. +icp canister call backend add_allowed_buyer '(principal "")' -e ic --identity +icp canister call backend allowed_buyers '()' -e ic + +icp cycles transfer 200b -n ic +icp canister call backend refresh_reserve '()' -e ic --identity +icp canister call backend reserve_status '()' -e ic # availableToSell > 0 +``` + +⚠️ **`refresh_reserve` is not optional and its absence is silent**: the ledger holds +the cycles, `availableToSell` stays 0, and every buyer is refused with +`#reserveShort`. Read `reserve_status` back — that is what the third line is for. + +### 6a. The price tiles + +⚠️ **This step was missing from this procedure entirely**, which is how a deployment +gets a buy page with no way to buy: `renderTiers` returns early on an empty list and +the custom-amount tile is built *after* that return, so an unconfigured gateway offers +neither. The canister accepts a custom amount; the page never asks for one. Mode 3's step 4 +carries the same warning, and this procedure is the one that produced the live +deployment. + +```bash +icp canister call backend set_card_tiers \ + '(vec { record { id = "t10"; usdCents = 1_000 : nat }; + record { id = "t20"; usdCents = 2_000 : nat }; + record { id = "t50"; usdCents = 5_000 : nat } })' \ + -e ic --identity +icp canister call backend card_tiers '()' -e ic # three tiles +``` + +Every tier must sit inside the gate's bounds (`RUNBOOK.md`'s admission-gate section) or the whole vector is refused. No +`$100` preset: that is the ceiling, and it is what the custom field is for. + +### 7. Verify before spending a card + +```bash +icp canister call backend health '()' -e ic # true +icp canister call backend pricing_status '()' -e ic # ok, divisor 1_000, real XRC id +icp canister call backend quote_previews '(vec { 1_000 : nat })' -e ic +icp canister call backend refusal_counts '()' -e ic # every refusingNow flag false +icp canister call backend expected_livemode '()' -e ic # opt false +curl -sI https://cyclepay.raymondk.co # HTTP/2 200 +curl -sL https://cyclepay.raymondk.co/.well-known/ic-domains +``` + +⚠️ **Verify IDENTITY too — it is the only one-way item in this list**, and the one nothing +above touches. Two checks, before step 6 allow-lists a principal and funds a reserve +against it: + +```bash +# 1. Internet Identity reads this CROSS-ORIGIN from the derivation origin. Without the +# CORS header it cannot read the file, cannot validate the domain, and refuses to +# derive -- which presents as sign-in failing only on the custom domain. +curl -sI https://.icp.net/.well-known/ii-alternative-origins +# expect: content-type: application/json AND access-control-allow-origin: * +``` + +2. **Sign in at `https://cyclepay.raymondk.co` and at + `https://.icp.net`, and confirm the page shows the same + principal.** Thirty seconds, and it turns this section's central claim from prose into + an observation. If they differ, stop: the derivation origin is not in effect, and + anything allow-listed from here is allow-listed for an identity that will not come + back. + +⚠️ **`availableToSell` stays in REAL cycles while quotes are scaled**, so it can read +200 G while $10 buys 7.24 G. Arithmetically right, and startling without this sentence. + +⚠️ **A stale rate shows as `cycles = null` in `quote_previews` at ANY divisor.** Read +`pricing_status` before concluding the divisor is wrong, and force a tick rather than +waiting for the timer -- a fresh install before its first refresh is exactly when this +appears: + +```bash +icp canister call backend refresh_rates '()' -e ic --identity +icp canister call backend pricing_status '()' -e ic # ok = true, fetchedAt recent +``` + +### The simulation arithmetic + +**One number is the whole switch: `pricing_status().config.divisor`.** `1` is +production; anything greater is simulation, and the mode signal, the banner and the +receipt's extra terms all key off that same value. + +This section is the mechanism and the guards. The ordered deployment procedure -- the +confidential subnet, the custom domain, sealed secrets on mainnet, and the four one-way +steps -- is **`RUNBOOK.md` section 1a**. There is deliberately no second +boolean — one that disagreed with the divisor would let two places answer "are we +simulating?" differently. + +| what | where | why there | +|---|---|---| +| the scale | `Pricing.quote` | the **single** derivation of a cycle quantity, one caller — so the quote, `lockedCycles`, the promise tally, the floor decrement and the transfer are all the same scaled number | +| who may buy | the buyer allow-list | the only bound on the **total** given away; the divisor bounds only the per-order loss | +| the ceiling | `Pricing.quote` again | the cycles-ledger deposit fee is **flat**, so an over-scaled order cannot clear it | + +⚠️ **Do NOT scale at delivery.** The reserve floor decrements at *issue* by the full +locked amount (§5.4 rule 2), so a scaled transfer against an unscaled decrement would +surface as an unexplained shortfall on every reconcile — the one signal that means an +outflow we did not cause. + +⚠️ **The Stripe fee is taken BEFORE the divisor and the ledger fee AFTER it**, and the +asymmetry reports what each third party actually took: the buyer really is charged the +gross and Stripe really keeps its cut, so those are real dollars; the ledger really +charges a flat fee to accept whatever deposit arrives. Where the division sits in the +formula does not matter for correctness — `floor(floor(a/b)/d) == floor(a/(b*d))` for +positive integers — so it is written as one division after the rate conversion, and +`checkReceipt` divides at the same point. + +### The four guards, and what each one prevents + +| guard | prevents | +|---|---| +| `divisor > 1` requires `expected_livemode == ?false` **exactly** | real money in, scaled cycles out. ⚠️ `?false` exactly, not "not live": `null` means *either mode* and accepts live payments, **and `null` is the default** | +| `set_expected_livemode` refuses anything but `?false` while `divisor > 1` | the same state reached from the other direction. Mutual, so neither order of operations gets there | +| a divisor **change** is refused while any order is stored | a global divisor with earlier receipts recomputing against the new one, each reporting a mismatch. Reinstall to change it; refusing is the safe direction | +| a scaled quote must clear the ledger fee **ten times over** | the one delivery state with no recovery lever: a fee above a whole order's locked quantity means nothing reaches the ledger, so no `#BadFee` ever arrives to correct the stored copy | + +⚠️ **The divisor's ceiling scales with `minPurchaseUsdCents`, not with the amount being +bought**, because the guard asks whether the *smallest purchase this gateway sells* +still clears the fee. At the shipped $10 floor a divisor of 1,000 leaves 7.24 G (72x the +100 M fee); at a $1 floor the same divisor leaves 515 M and is **refused**. An operator +who lowers the floor for a demo and then cannot set the divisor is seeing the guard work. + +### The faucet, and the one ordering rule + +⚠️ **Stripe test payments are free and unlimited** — `4242 4242 4242 4242` pays any +session, for anyone who reaches the page. So test mode plus an empty allow-list plus a +funded reserve is a cycles faucet, and the gateway **refuses to sell** in that state +rather than warning about it (`#unboundedGiveaway`, a rail condition with its own +counter and `refusingNow` flag). + +> **The allow-list must exist and be populated before the reserve is funded.** + +An **unfunded** reserve refuses every order structurally at `Gate.solvent`, before a +Stripe session is even created — which is what makes a no-code sandbox deployment safe +to explore, and why only the happy path waits on the allow-list. The canister cannot +enforce the rule at funding time: the reserve arrives as an ICRC transfer *to* its +ledger account, which it has no ability to refuse. It enforces it at the **sale** +instead, which is the operation that gives cycles away. + +⚠️ **An empty list therefore means two different things**, and that is the design: it +does not filter per buyer while the floor is zero (nothing can be sold anyway), and it +refuses everyone the moment the floor is not. + +### What the buyer sees + +A sentence, not a badge — that a real charge happens in Stripe's sandbox and a fraction +of the cycles is delivered. And the receipt shows **both legs**: `checkReceipt`'s +`recomputed` stays the *unscaled* quantity, recomputed from the two rate inputs the +order carries, so a simulation receipt states what production would have locked, the +divisor, the locked quantity, and the ledger fee — four numbers that reconcile. + +⚠️ **`availableToSell` stays in REAL cycles while quotes are scaled** (it is +`reserveFloor - promised`, and only `promised` is scaled), so it can read 775 T while +$10 buys 7 G. Arithmetically right, and startling without a word of explanation. + +⚠️ **A refusal from the ledger-fee guard names the simulation, not payment processing.** +`#tierBelowFees` says fees would exceed the amount, which is true for its own cause and +false for this one — the amount is fine and the operator's divisor scaled the cycles +below the deposit fee. `#simulationScaleTooSmall` is a separate variant for that reason. + +## Mode 3 — mainnet production + +⚠️ **Before any of this**, work `docs/SANDBOX-TESTPLAN.md` to green. Every Stripe +payload in the automated suites is hand-crafted; that plan is the only thing that +verifies the real wire format, and its closing section lists what remains open +even after a clean run. + +⚠️ **Deploy only from a green `main`.** The `-Werror` gate that makes a +non-exhaustive match (M0145) a build failure runs on `mops check` in +`scripts/test-all.sh` and in CI — **not** on `mops build` or `icp deploy`, which +compile the same code without it. `mops test` passes `--hide-warnings` and moc +refuses that together with `-Werror`, so gate-side is the only place it can live. +A direct-deploy hotfix therefore bypasses it entirely: a new `Owner` case, or any +other non-exhaustive match, would ship and trap at runtime — on the webhook path +that is a 5xx Stripe retries for ~3 days. + +Everything money-touching **fails closed by default** — a freshly deployed +gateway accepts no orders and delivers nothing until each lever below is +consciously set. + +⚠️ **A simulation gateway is not promoted in place — it is reinstalled.** The divisor +is refused once any order is stored (Mode 2), so a gateway that has taken even one test +order cannot simply be switched: `set_expected_livemode '(opt true)'` is refused while +the divisor is above 1, and the divisor cannot move while orders are stored. **Reinstall +breaks that deadlock and keeps the canister id** — so the webhook URL, both sealed +ciphertexts and the reserve's cycles all survive; see Mode 2's table above for what does +not. A fresh canister is the expensive option and is not required. + +Either way, buyer identity is unaffected: principals derive from the **frontend** +canister id, which is why the derivation origin is pinned there. + +### What is not a command + +Six prerequisites that no step below can perform: a decision, a piece of code, or +off-chain work. Each was a separate open issue until 2026-09-14 and is here instead, +because a go-live prerequisite filed somewhere else is one that gets discovered +missing at go-live. The closed issues hold the reasoning; what is kept here is what a +deployment turns on. + +**1. The migration chain, before there is data worth keeping**. `Main.mo` is a +`persistent actor` with **inline initializers** and there is no +`src/backend/migrations/`, so an incompatible stable-shape change has exactly one +remedy — `icp deploy --mode reinstall` — and on a canister holding real orders, +journals and dedup sets that is not a remedy. The incompatibility itself is caught +early: `[canisters.backend.check-stable]` compares the actor against the committed +`deployed/backend.most` inside `mops check`. Three facts to write it against: + +- Write it **after** the last schema-affecting change. The init migration must + enumerate every stable field, so writing it earlier means writing it again. +- Adding a stable `var` to the actor needs **no** migration. A field on an existing + stable record (`Order`, `Orders.Store`, `Gate.RefusalCounts`) does, and fails + `mops check` with *"Write an explicit migration function"*. +- ⚠️ **The baseline only helps while it is current.** Promote it with `mops deployed` + after every deploy: adding a field is *compatible*, so a stale baseline keeps the + gate green while it has stopped describing the actor. Measured — `cancelRequests` + shipped that way. + +Read the `migrating-motoko-actors` skill first. No `preupgrade`/`postupgrade`, no +`(with migration = ...)`. + +**2. An alert someone actually receives**. `RUNBOOK.md`'s monitoring section is a complete monitoring plan — +metric, threshold, severity, action — and nothing runs it. The whole P1 set polls +**public queries**, so the alerting layer needs no key. It is done when those metrics +reach a human out of hours **and someone has tripped one deliberately and watched it +arrive**; the failure modes here are slow (a 2 h delay alert, a 72 h terminate bound), +so what is needed is something that wakes a person, not a dashboard someone visits. + +**3. The claim, and the legal surface of being official**. +*"At cost"* must hold **net of card processing** or become a visible fee line — the +fee is real (≈2.9% + $0.30) and the buyer pays it. Beside it: imprint, terms, privacy +and contact; invoices a developer can expense, and the VAT position; a refund +**procedure**, because the canister deliberately models no refunds (`RUNBOOK.md`'s triage section) so refunding +is an operator action; and a staffed rotation for the obligation queue, which that same section +currently describes without anyone being on the hook for it. + +⚠️ **The serving domain is not irreversible, and this reverses an earlier claim.** II +derives a principal per origin, but the derivation origin is pinned to the **frontend +canister id** (`config.ts`), so a test domain and whatever production domain is chosen +yield the *same* principals. What would be one-way is making a custom domain itself the +derivation origin — which this deployment does not do. + +**4. `stripe_origin` must be https and non-loopback once livemode is `?true`**. +`Session.validateOrigin` accepts `http://` for loopback hosts, and nothing refuses the +pair `expected_livemode = ?true` with `stripe_origin = http://localhost:8000` — a live +gateway returning paying buyers to their own machine. So **read `stripe_origin` back +after setting either one** (steps 5 and 8 below). + +The permanent fix is a refusal on both setters, like the divisor's — but ⚠️ **key each +refusal on the BAD VALUE, not on the pair.** `set_stripe_origin` refuses a *loopback* +origin while livemode is `?true`; `set_expected_livemode(?true)` refuses while the +*stored* origin is loopback. Phrased that way a gateway already in the bad pair can +always set a good origin and walk out. The divisor's mutual refusal is the shape to +copy and **not** the constraint: its way out is blocked by a second guard entirely +(`#divisorChangeWithOrders`), so a simulation gateway with one stored order can never +go live at all. Do not add a second lockout to a money-handling setter. + +**5. Attestation coverage of the confidential subnet** (`RUNBOOK.md`'s confidential-subnet checklist). Checkpoints and +state-sync **are** confirmed confidential on the target subnet, which was the spec's +"verify this hardest" item. Attestation coverage is the box still open: one unattested +replica is one node provider who can read the webhook secret. That checklist is where it stays open. + +**6. Where the repo lives, before the "check the code" link is published.** +`RELEASE.md`'s trust story is *verify the deployed module hash against a tagged +commit*, so the repository URL is a user-facing artifact. `cyclepay` also still names +the upstream fork this repo grew from. Renaming or moving is free now and costs +redirect debt once that link is on a money page. + +### The steps + +⚠️ **Two of these are knowingly NOT done on the live simulation deployment** — step 2's +freezing threshold (still the 30-day default) and step 10's backup controller (one +principal, no second). Both are single commands and both are production prerequisites, +deferred rather than missed. Read `icp canister status backend -e ic` before assuming +either has been done on whatever deployment you are looking at. + +1. **Deploy and verify** per `RELEASE.md` — reproducible build, published module + hash, and `icp canister status` gated on matching it. + +2. **The canister's own gas, and its freezing threshold.** `minCanisterCycles` + refuses every order below the floor, and the XRC needs 1 B attached per rate + refresh. ⚠️ **Read the balance and compute the difference; never paste a figure** — + Mode 2 records what pasting one cost. Then raise the freezing threshold: this + canister holds money-bearing state, so the 30-day default is thin, and losing it + to a cycle drain destroys the order store, the journals and the dedup sets. + + ```bash + icp canister status backend -e ic # read the balance first + icp canister top-up backend --amount -e ic # --amount, not --cycles + icp canister settings update backend --freezing-threshold 7776000 -e ic # 90 days + ``` + +3. **Fund the cycles reserve, then tell the gateway to look** (`RUNBOOK.md`'s reserve section). Delivery transfers + out of the gateway's own cycles-ledger account, which is a **different pot from + step 2** — `icp canister top-up` does not touch it. + + ```bash + icp cycles transfer -n ic + icp canister call backend refresh_reserve '()' -e ic + ``` + + ⚠️ **The second call is not optional.** Solvency is decided against a maintained + lower bound that starts at zero and rises only by observation, so without it the + gateway refuses every sale with `#reserveShort{available = 0}` while the ledger + holds the full amount. + + ⚠️ **How much is a SECURITY decision before it is a working-capital one.** A forged + webhook delivers from the reserve and nothing caps that, so **the reserve balance is + the blast radius** (`RUNBOOK.md`'s webhook-secret and confidential-subnet sections). Size it to what you are willing to lose between a leak + and its detection, and top up on a cadence rather than parking months of stock in + the account. The gate's `maxPurchaseUsdCents` is the per-order exposure inside it. + +4. **Register card tiers. Not optional in practice** (`RUNBOOK.md`'s presets-and-keys section): + + ```bash + icp canister call backend set_card_tiers \ + '(vec { record { id = "t10"; usdCents = 1_000 : nat }; + record { id = "t20"; usdCents = 2_000 : nat }; + record { id = "t50"; usdCents = 5_000 : nat } })' \ + -e ic --identity + ``` + + ⚠️ **With an empty list the buy view offers NO WAY TO BUY**, and the reason is not + the one you would guess. `Amount` is `variant { custom : nat; tier : text }` and + nothing in the create path validates an amount against the tier list, so the + *backend* really does accept any amount within the gate's bounds. But `renderTiers` + returns early on an empty list, and the **custom-amount tile is built after that + return** — so no tiles and no custom field. The technically-optional reading is true + of the canister and false of the page. + + The whole-vector setter replaces the list; there is no add or remove, and `'(vec {})'` + clears it. Every tier must sit inside the gate's bounds or it is refused. No `$100` + preset: that is the ceiling, and it is what the custom field is for. + +5. **Declare the Stripe mode:** + + ```bash + icp canister call backend set_expected_livemode '(opt true)' -e ic --identity + ``` + + Until this is set, a test-mode webhook secret would deliver **real** cycles for + payments that never happened. Verify with `expected_livemode`, and re-read + `stripe_origin` (What is not a command, item 4). + +6. **Configure the Stripe webhook destination**: in the Stripe Dashboard, add + `https://.icp.net/webhook/stripe` subscribed to the events in + Mode 2's table. ⚠️ **Not just `completed` and `charge.refunded`** — + `checkout.session.expired` is the *only* thing that expires an order (`RUNBOOK.md`'s order-expiry section), and the + canister dispatches on six types in all. An unsubscribed event is not "acked and + ignored": it is never sent, so its handler never runs. + +7. **Provision the webhook signing secret** (`RUNBOOK.md`'s webhook-secret section). Until it is set the webhook route + answers 503 and Stripe retries. Sealed — it never appears in an ingress message, a + shell history or a CI log (§7.3 of `docs/STRIPE.md`; Mode 2's step 4 carries the exact + commands). + +8. **The origin, then the live API key — the pair that opens the rail** (`RUNBOOK.md`'s presets-and-keys section). + + ```bash + icp canister call backend set_stripe_origin '("https://")' -e ic --identity + # then set_stripe_api_key, sealed, per Mode 2's step 4 + ``` + + The key is **LIVE, restricted (`rk_...`), Checkout Sessions = Write, everything else + None**. Write is the level that also grants the read the recovery sweep needs to + settle an order whose expiry event never arrived (`RUNBOOK.md`'s `#paidNotCredited` triage row, and its + `stripeApiFailing` row). A sandbox key cannot be reused. + + ⚠️ **With either missing, `create_order` refuses** — provisioning both is what opens + the rail, so do it last; rotating either closes it until both are valid again. + + There are no Payment Links, Products or Prices to create: the session carries inline + `price_data`, and `amount_total == usdCents` holds because of what the session does + **not** enable — the eight settings are listed in `rails/Session.mo` beside the body + builder, and `test/session.test.mo` asserts their absence. Historically a mismatch + there failed silently and delivered a different cycle quantity; it now delivers + nothing and files a refund obligation, so a wrong amount is visible on the first + order rather than as drift. + +9. **Review the admission gate** (`RUNBOOK.md`'s admission-gate section). The defaults are non-zero and usable, but + `maxPurchaseUsdCents` should sit just above your largest tier, and + `minCanisterCycles` should suit how closely you monitor this canister. + +10. **Add a backup controller.** A single controller identity with no backup means a + lost key makes the canister permanently un-upgradeable; there is no recovery path + (`RUNBOOK.md`'s operating model covers the trust model this implies). + +11. **Wire monitoring (`RUNBOOK.md`'s monitoring section) before announcing the service**, not after — and confirm an + alert arrives (What is not a command, item 2). + +12. **Smoke-check the public surface**: `pricing_status` — both rates populated and + `lastAttempt.ok` true. The rate timer warms itself on install, so this should hold + within seconds; if it does not, `lastAttempt.detail` names the failing guard (`RUNBOOK.md`'s pricing-rates section) + and **no order can be created until it clears** (creation answers + `rateUnavailable`, by design). Then `reserve_status`, `recovery_status` (sweep + timer armed), `cycles_status` (balance above `minCanisterCycles`), `card_tiers`, + and `can_purchase '()'` — the last should answer `ok` + before you announce anything. + +13. **Buy one thing on the deployed site with a real card.** Nothing short of a live + purchase exercises the key, the origin, the return URL and the webhook secret + together. diff --git a/docs/SANDBOX-TESTPLAN.md b/docs/SANDBOX-TESTPLAN.md new file mode 100644 index 0000000..c105b3a --- /dev/null +++ b/docs/SANDBOX-TESTPLAN.md @@ -0,0 +1,771 @@ +# Stripe sandbox test plan (manual, human-in-the-browser) + +Most Stripe payloads in the automated suites are hand-crafted JSON written from the +API docs, so those suites prove the canister behaves as designed against *our* idea +of Stripe rather than against Stripe. This plan closes that gap and captures real +fixtures while doing it. + +**Read §"What this cannot tell you" at the end before treating a green run as +go-live approval.** It is not one. + +## Status: run against reserve delivery on 2026-08-28 + +⚠️ **Read the dates before trusting any row.** A row here is a record of what a run on a +given day did and did not observe, and the mechanism under it may have changed since. A +row that was true when written and is now false reads exactly like a current gap, and the +cost of that confusion is re-running a whole plan that was in fact verified. The dates in +the heading are what make each row checkable. + +**What the 2026-08-28 run recorded**, all of it against `icrc1_transfer` out of the +reserve, with no ICP or CMC in the path: + +| Flow | Result | +|---|---| +| $20 → paid | delivered | +| $13, custom amount → paid | delivered | +| $10 → cancelled by the buyer | released; the **open-order cap of 1** refused a second order while one was open | +| $10 → left to expire | expired **by Stripe** (`stripe.sessionExpired`), promise released **exactly**: `promisedTotal` 7,238,461,538,461 → 0 | +| full refund of a delivered order | `#refundAfterDelivery`, `refundedCents = 1300`, `fullRefund = true`, correctly **not** auto-resolved | + +Stripe fired `checkout.session.expired` within ~2 s of `expires_at`, and the session +TTL is **35 minutes** (`Session.requestedLifetimeSeconds = 2_100`), not 30 — Stripe's +floor is evaluated against *its* clock on arrival, so sitting exactly on it fails every +session creation. + +**What the 2026-08-13 run recorded** (kept only for the Stripe-side figures; its +delivery half was a CMC mint against an ICP float and is no longer evidence): two +purchases, $5 and $20, both credited to the buyer's cycles-ledger account and spendable +— `icp cycles balance --of-principal ` → **18,207,492,307,692**. + +**What is owed before go-live:** + +| Gap | Where | +|---|---| +| ⚠️ **The buyer's cycles-ledger balance, read from the LEDGER, on a reserve delivery.** The 08-28 run confirmed the order view said `delivered` and an audit line recorded a block index — which is *the canister's own account* of the transfer, not an independent observation of the destination. The 08-13 run did read the balance directly, but under the mint path. **One command, not a re-run:** `icp cycles balance --of-principal ` before and after | group H below | +| The CLI handoff — `icp identity link web` was never run, so "the cycles are reachable from the CLI" is still unproven. ⚠️ Doubly unproven since the page gained the id.ai prerequisite and the set-default step: without either, the commands run as the wrong identity against an empty balance | group H below, H4 | +| Fixture capture — real payloads committed; the parity test in `test/integration/src/fixtures.spec.ts` compares them against the crafted builder | group I | +| Async payment methods | ⚠️ **Not applicable** — `payment_method_types[]=card` is pinned, so a delayed method is unreachable | +| Disputes | group G | +| Anything live-mode | not local-testable by construction | + +--- + +## Two places to run it, and both do the whole good path + +**Either works end to end. Neither needs mainnet or real money.** + +| | PocketIC suite | local `icp network` | +|---|---|---| +| Cycles ledger + CMC (rate only) | ✅ real wasms | ✅ seeded | +| XRC | ✅ pinned `xrc_mock` | ✅ the `xrc` canister in `icp.yaml` | +| Fresh CMC rate | ✅ built in | ✅ via the PocketIC API (below) | +| Real Stripe events | ✅ `makeLive()` + `stripe listen` | ✅ `stripe listen` at the gateway | +| Time travel / outage injection | ✅ first-class | ✅ via the PocketIC API | +| **Frontend in a browser** | ❌ no `ic_env` cookie | ✅ **only here** | +| Repeatable, scripted, in CI | ✅ | ❌ manual | + +Pick the **PocketIC suite** for anything you want to keep (it is committed, scripted +and runs in CI). Pick a **local network** when you need the real UI in a browser, +which is the one thing PocketIC cannot serve. + +⚠️ Earlier versions of this file claimed a local network could not do this — twice, +both wrong. What makes it work: `icp network start` runs **PocketIC**, and its +control API is reachable. `lsof` on the launcher shows two listening ports — the HTTP +gateway that `icp network status` reports, and the PocketIC API alongside it. So a +local network has the two powers that look like they should be missing: **arbitrary +sender impersonation** and **time control**. + +⚠️ **That is not a supported `icp` interface.** It depends on the launcher being +PocketIC and on that port being open, and either could change between releases. The +committed suites deliberately do not rely on it — it is a manual-testing convenience. + +### The whole flow, in order, in a browser + +This is **the** procedure. Everything below it in this file is either a group of +scenarios to work through or a by-hand reference for one of these steps. + +What you have to supply: a **Stripe sandbox account** with the Stripe CLI logged in +to it, and a **restricted API key** (`rk_...`) with **Checkout Sessions = Write** +and everything else None. Write is the level that also grants read, which the recovery +sweep needs in order to retrieve a session — **measured, not inferred: a `rk_` +key scoped to Checkout Sessions = Write returns HTTP 200 on +`GET /v1/checkout/sessions/{id}`.** It is worth stating as a measurement because the +failure mode is invisible to the test suite: PocketIC answers the sweep's outcall +itself, so a key that could create sessions but not read them would 401 in production +with every scenario green. `Main.mo` carries a `stripe.retrieveUnauthorized` audit tag +for that case. No Payment Links, no Products, no Prices — the canister creates a +session per order through the API. Nothing else, and no mainnet. + +**How to create the key, and the go-live ordering, is RUNBOOK §3.** +Get it right here rather than at go-live: the four settings that break +`amount_total == tier.usdCents` produce no error, just a different cycle quantity, +so a sandbox run with automatic tax left on would "pass" while proving the wrong +thing. + +```sh +# 0. Prerequisites, once. +brew install stripe/stripe-cli/stripe jq +stripe login # a SANDBOX account, never live +npm --prefix test/integration run fetch:wasm # the sha256-pinned xrc mock +npm --prefix test/browser ci # only if you also want the suites + +# 1. A sellable local gateway. Put STRIPE_API_KEY=rk_... in scripts/.local-dev.env +# (gitignored, sourced by the seed) so you set it once rather than per shell, +# and so it never lands in your shell history. Without it the seed sets a +# placeholder: everything except paying works, and create_order fails with a +# real Stripe 401 rather than at a config check. +# +# ⚠️ Keep it in that file rather than `export`ing it. The durable reason: the seed +# sources the file into its own process, so the key never reaches your shell history, +# `ps` or a terminal transcript. +# ⚠️ The CLI hazard, stated precisely — this used to overclaim it. The Stripe CLI +# prefers STRIPE_API_KEY over its own `stripe login`, and a restricted key cannot open +# a CLI session, so a `stripe` command that sees it fails 403 +# "more_permissions_required". **This repo's own scripts are immune** — every `stripe` +# invocation is wrapped `env -u STRIPE_API_KEY` (stripe-dev.sh, capture-stripe-fixtures.sh), +# so the forwarder is unaffected. What is exposed is a `stripe` command you type +# yourself; `capture-stripe-fixtures.sh` prints one for you to run. +# Nothing to configure in the Dashboard: the session carries inline price_data +# with a fixed unit_amount and quantity 1, and none of the settings that could +# move amount_total is enabled — src/backend/rails/Session.mo lists all eight +# next to the body builder, and test/session.test.mo asserts their absence. +# Nothing else to configure: the STRIPE_LINK_* variables went with +# Tier.paymentLinkUrl. +icp network start -d +icp deploy +scripts/local-dev-seed.sh + +# 2. Wire Stripe, in its own terminal. It asserts the gateway can price before it +# starts, and leaves the forwarder running until Ctrl-C. +scripts/stripe-dev.sh +``` + +⚠️ **The CMC rate has a 15-minute fuse, and it is the single most likely thing to +derail this run.** The seed arms a rate; 15 minutes later the gateway cannot price, +`create_order` is refused, and the symptom is *not* a rate error — `scripts/stripe-dev.sh` +fails its preflight with **"the gateway cannot price a $10 purchase"**, which reads like a +configuration problem. That is exactly how this procedure failed when it was last walked. + +The fix takes two seconds and needs no reinstall: + +```sh +scripts/local-dev-seed.sh --rate-only +``` + +So: **re-arm immediately before you pay**, not when you started. Any pause — reading this +file, registering a passkey, a phone call between creating the order and opening Checkout — +can outlast the rate. Two things soften it, and knowing which is which saves confusion: + +- An order that **already exists** keeps its locked quantity forever; a stale rate only + refuses *new* orders. So if you got the order created, paying still works. +- A run that sits in the browser for half an hour will fail at **create**, not at pay. + +⚠️ And the reason it cannot simply be longer: `Pricing.mo` refuses a CMC rate older than +15 minutes **by design** — a stale rate misprices an order, and the local CMC's rate is +stamped 2021 until something sets it. The fuse is the guard working, not a rough edge. + +Then, in the browser at the frontend URL `icp deploy` printed +(`http://frontend.local.localhost:8000/` with the default gateway port): + +1. **Click "Get cycles".** The landing page has one route into the buy form, and + the form asks nothing about where the cycles go: they go to the account of the + principal you sign in as, and the gateway refuses any other destination. + That is what makes steps 6 and 7 meaningful. +2. **Sign in.** You get **local** Internet Identity automatically — + `http://id.ai.localhost:8000`, deployed by `ii: true` in `icp.yaml`, chosen by + `auth.ts` because the origin ends in `.localhost`. Nothing to configure, and a + production origin cannot take that branch. Register a passkey; it is throwaway + and local to this network, which is wiped by `icp network stop`. +3. **Pick an amount and create the order.** The rate is locked here, not at payment. +4. **Pay.** "Pay with card ↗" opens the order's own Checkout Session — the + canister created it and set `client_reference_id` on it through the API, + so there is no link to configure and no parameter to append. Card + `4242 4242 4242 4242`, any future expiry, any CVC. **You have 35 minutes**, + enforced by Stripe; the button disappears at the deadline. +5. **Watch the page.** Leave the order tab open. The webhook arrives at the + forwarder, delivery runs, and the page reaches **delivered** on its own 3 s poll + with the guided tour leading. It should never need a reload. + + **If nothing happens**, work these three in order — each has produced a silent + stall in a real run: + + | Check | Command | Wrong looks like | + |---|---|---| + | the secret is provisioned | `icp canister call backend webhook_secret_status '()'` | `isSet = false` — every event is dropped unverified, with no audit line and no error-queue entry | + | the forwarder is up | `pgrep -fl "stripe listen"` | nothing — Stripe delivered to a closed door, and a resend is delivered **once**, so resending before the forwarder is up wastes it | + | the CMC rate is fresh | `icp canister call backend audit_log_recent '(null, 25 : nat)' \| grep rates.refresh` | `rates.refreshFailed: cmc rate is stale or zero` — re-arm with `./scripts/local-dev-seed.sh --rate-only`. ⚠️ A stale rate fails at **order creation**, not at delivery: delivery reads no rate at all, so an order already `#paid` delivers regardless | + + To replay a payment the canister missed, resend the **`checkout.session.completed`** + event — not `charge.updated`, `charge.succeeded` or `payment_intent.succeeded`, + which Stripe lists just as prominently and none of which this canister acts on + (see `Card.mo` for the four types it handles). A resent event of the wrong type + logs `stripe.unhandledType` and changes nothing: + + ```sh + stripe events list --limit 25 # find the checkout.session.completed + stripe events resend + ``` + + `audit_log` is the diagnostic that separates these: a verified-but-unactionable + event, a bad signature and an event that never arrived all look identical from + the UI, and all three read differently there. +6. **Follow the CLI page** at `#/cli` — from the delivered order's next-step link, or + from the dashboard. It is five numbered steps and the order is load-bearing: + + ```sh + # Step 1 is a SETTING, not a command: turn CLI access on at https://id.ai → + # settings. Without it the next command's sign-in page says "CLI access not + # enabled" and never returns an identity. + icp identity link web cyclepay-id --app # 2 — bare domain, port included + icp identity default cyclepay-id # 3 — what makes the rest act as it + icp identity principal # 4 — must equal the page's principal + icp cycles balance # 4 — must equal the page's figure + icp deploy -e ic # 5 — the buyer's own project + ``` + + **The principal must match.** A mismatch means the `--app` value did not match this + origin, and the balance will look empty. ⚠️ **No `--identity` flag on the two verify + commands, deliberately** — step 3 made it the default, and passing the flag would + verify an identity that `icp deploy` will not use. +7. **Prove the cycles exist.** + + ```sh + icp cycles balance --of-principal # from any identity + ``` + + This is the end of the flow the product promises, and the one thing no suite in + this repo proves. + +⚠️ **The CMC rate expires 15 minutes after seeding** and new orders are then refused +(an existing order is unaffected — its rate is locked). Refresh with +`scripts/local-dev-seed.sh --rate-only`; it needs no restart and nothing else to be +re-done. + +### The same setup by hand, if you want to see each lever + +```sh +icp network start -d +icp deploy +scripts/local-dev-seed.sh +``` + +`icp deploy` leaves a gateway that is **fail-closed on several axes at once** — no +tiers, no CMC rate, no Stripe secrets and an empty cycles reserve — plus one that +catches everyone: `minCanisterCycles` defaults to 5 T while `icp deploy` creates the +canister with less, so the admission gate refuses every purchase with "temporarily +unavailable while the gateway is topped up". That reads as a reserve problem and is +really about the canister's own **gas** — two different pots, and this is the error +that teaches the difference. The seed script tops the canister up +(`icp canister top-up backend --amount 20t`) and leaves the 5 T floor in place, so +local development exercises the same gate mainnet does. + +The seed script sets all five and verifies a $5 purchase is admitted before it +reports success. **The CMC rate goes stale after 15 minutes** (`cmcRateMaxAgeNs` is +a security control, not a tuning knob); `scripts/local-dev-seed.sh --rate-only` +refreshes just that. + +No restart is needed after seeding, and none of it survives a `--mode reinstall`. + +### Every lever the seed script pulls, spelled out + +Reference, not a second procedure: `scripts/local-dev-seed.sh` does all of this and +verifies the outcome of each step rather than the exit code. Read it when a step +fails and you need to know which lever to inspect, or when configuring something +that is not a local network. + +```sh +npm --prefix test/integration run fetch:wasm # the pinned xrc_mock +icp network start -d && icp deploy # backend + xrc + frontend + +# The admission gate checks the canister's OWN cycles before pricing, and a fresh +# deploy sits under the 5 T floor. +icp canister top-up backend --amount 20t + +# Config. The webhook secret is SEALED (§7.3) — `set_webhook_secret` takes a ciphertext +# blob, so it cannot be set with a quoted string. No trailing environment argument here: +# the default is `local`, which is the PocketIC master key this network uses. +# The 16-byte minimum applies to the decrypted value. +STRIPE_WEBHOOK_SECRET='whsec_local_test_1234567890' scripts/seal-secret.sh webhook-secret +icp canister call backend set_card_tiers \ + '(vec { record { id = "t10"; usdCents = 1_000 : nat } })' +icp canister call backend set_delivery_config \ + '(record { alertAfterNs = 7_200_000_000_000 : int; maxHoldNs = 259_200_000_000_000 : int })' +icp canister call backend set_expected_livemode '(opt false)' + +# Fund the reserve: the cycles the gateway will SELL, in its own cycles-ledger +# account. ⚠️ This is not the canister's gas — that is the top-up above. Nothing +# creates these cycles; you transfer cycles you already have, which is also the +# mainnet procedure. +BACKEND_ID=$(icp canister status backend --json | jq -r '.id') +icp cycles transfer 100t "$BACKEND_ID" +# Then let the gateway observe what arrived. Until it does, the admission gate has +# no reason to believe it can deliver and refuses with `#reserveShort`. +icp canister call backend refresh_reserve '()' +icp canister call backend reserve_status '()' # availableToSell > 0 + +# Give the CMC a current rate (next section), then: +icp canister call backend refresh_rates '()' +icp canister call backend pricing_status '()' --query # expect ok = true +``` + +Create an order and open the `stripeSessionUrl` on the returned order — that is +the payment page. The canister creates a **Checkout Session per order** and sets +`client_reference_id` through the API, so there is no Payment Link to +configure and no URL parameter to append. Pay with `4242 4242 4242 4242` and watch +`get_order` reach `delivered`. `process_order` kicks the **delivery** without +waiting for the sweep — callable as the order's own owner as well as admin since +and `pending_deliveries` (admin) shows anything still outstanding +before the 2 h queue alert would. `delivery_journal` and `receipt` then carry the real +cycles-ledger block index and the delivered quantity. + +⚠️ **Fund the reserve AND call `refresh_reserve` before creating an order**, or +every purchase is refused with `#reserveShort{available = 0}` against a funded +account: solvency is decided against a lower bound that only rises by observation +`reserve_status.availableToSell` is the figure to check. + +⚠️ **One thing that looks like a bug and is not:** + +- **The session expires 35 minutes after creation**, enforced by Stripe. Past that + the pay button disappears — the UI renders expiry from `expiresAtNs`, not from + the status, so it goes even before the `checkout.session.expired` webhook lands. + +The post-payment redirect **does** land now: the seed points the origin at +`http://frontend.local.localhost:`, which `Session.validateOrigin` accepts because +the host is loopback. A caller-supplied `success_url` remains deliberately impossible — +it would be an open redirect Stripe renders after a real payment. + +#### Giving the local CMC a current rate + +**Why this is needed — and it is not "nobody updates the rate".** PocketIC has no +API for setting the conversion rate (`IcpFeaturesConfig` has exactly one variant, +`DefaultConfig`). It solves the problem a different way, documented on the +`cyclesMinting` feature: + +> Deploys the NNS cycles minting canister, sets ICP/XDR conversion rate… **If +> enabled, the default timestamp of a PocketIC instance is set to 10 May 2021 +> 10:00:01 AM CEST (the smallest value that is strictly larger than the default +> timestamp hard-coded in the CMC state).** + +So the 2021 timestamp is not stale seeded data — **PocketIC deliberately pins the +instance clock to 2021 so the CMC's hard-coded rate is fresh.** That alignment is +why the committed integration suite works. + +A local `icp network` breaks it by running at **real wall-clock time** (verified: +instance time reads as today while the CMC rate still reads 2021-05-10). Against the +15-minute staleness guard the rate is then permanently stale, and pricing reports +`"cmc rate is stale or zero"`. + +Two ways to restore the alignment: + +- **Move the rate forward** (below). Keeps real time, so real Stripe signatures + verify. This is what you want. +- Move the *clock* back to 2021. Also works, and needs no impersonation — but then + every real Stripe signature is rejected, because the canister checks the signature + timestamp against its own clock with a ±300 s tolerance. It trades the CMC problem + for the Stripe problem. + +⚠️ **Impersonation itself is a first-class PocketIC feature, not a hack** — the +committed suite does exactly the same thing through `gw.cmcAsGovernance`, a +sender-scoped actor. The only unsupported part on a local network is *reaching* the +control API, because icp-cli does not publish its port. + +⚠️ **The CMC requires a strictly greater timestamp** than the one it holds +("Proposed conversion rate must have greater timestamp"), so a second call inside the +same second fails. `setCmcRate` in the harness nudges time by 1 s first. + +⚠️ **The PocketIC port is dynamic even with a fixed gateway port.** With +`gateway.port: 8000` the launcher listens on 8000 *and* an OS-picked port — the +latter is the control API. Discover it: + +```sh +PID=$(pgrep -f 'pocket-ic --ttl' | head -1) +lsof -nP -iTCP -sTCP:LISTEN -a -p "$PID" | awk 'NR>1{print $9}' | grep -v ':8000$' +``` + +```js +// run from test/integration/ so the imports resolve +import { Principal } from '@icp-sdk/core/principal'; +import { IDL } from '@icp-sdk/core/candid'; + +// Find the PocketIC port — it is the OTHER port the launcher listens on: +// lsof -nP -iTCP -sTCP:LISTEN -a -p "$(pgrep -f 'pocket-ic --ttl')" +const PIC = 'http://127.0.0.1:/instances/0'; +const b64 = (u8) => Buffer.from(u8).toString('base64'); +const Arg = IDL.Record({ + data_source: IDL.Text, timestamp_seconds: IDL.Nat64, xdr_permyriad_per_icp: IDL.Nat64, +}); +const t = await fetch(`${PIC}/read/get_time`).then((r) => r.json()); +const payload = new Uint8Array(IDL.encode([Arg], [{ + data_source: 'local-dev', + timestamp_seconds: BigInt(Math.floor(Number(t.nanos_since_epoch) / 1e9)), + xdr_permyriad_per_icp: 35_000n, +}])); +await fetch(`${PIC}/update/submit_ingress_message`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + sender: b64(Principal.fromText('rrkah-fqaaa-aaaaa-aaaaq-cai').toUint8Array()), + canister_id: b64(Principal.fromText('rkp4c-7iaaa-aaaaa-aaaca-cai').toUint8Array()), + method: 'set_icp_xdr_conversion_rate', + payload: b64(payload), + effective_principal: { None: null }, + }), +}); +``` + +`/update/set_time`, `/update/set_certified_time` and `/update/tick` on the same +instance give time travel, so the 2 h alert and 72 h terminate are reachable locally +too. +### One command: `npm --prefix test/integration run sandbox` + +Boots PocketIC with everything, aligns the clock, bootstraps dev config, goes live, +prints the webhook URL, and stays up until Ctrl-C. + +```sh +stripe login # a SANDBOX account, never live +STRIPE_WEBHOOK_SECRET="$(stripe listen --print-secret)" \ +STRIPE_API_KEY=rk_... \ + npm --prefix test/integration run sandbox +# then, second terminal: +stripe listen --forward-to '' +``` + +⚠️ **Both keys are prefixed onto one command, not exported — and that matters.** The +Stripe CLI prefers `STRIPE_API_KEY` from the environment over its own `stripe login` +credential, and the restricted key cannot open a CLI session (`stripecli_session_write` +is not among its permissions, correctly). Export it and the second command above fails +with *"more_permissions_required"* naming your `rk_` key. Prefixing scopes it to the +process that needs it. + +⚠️ **`STRIPE_API_KEY` is what makes an order possible, not just a payment.** The rail +creates a Checkout Session per order, so `create_order` itself calls Stripe — without +a key the harness boots and says so, and everything except ordering works. Use a +**restricted** key (`rk_...`) with **Checkout Sessions = Write** (which includes the +read the sweep needs) and everything else None: a leaked key at that scope can only +create sessions that pay *us*, while an unrestricted `sk_` can issue refunds. + +⚠️ **It lowers `minPurchaseUsdCents` to $1**, because its whole numeric vector is the +$5 at-cost case (500¢ − 45¢ fee = 455¢ = exactly one ICP at $4.55 = 3.5 T cycles) and +the gate's real floor is $10. That is a dev value like the 2-minute alert, not a +mainnet one. + +Everything is fake money and real plumbing: a real cycles ledger, a real CMC rate, real +cycles delivery, genuine signed Stripe events over a genuine HTTP gateway. + +### The setup, if you are writing your own spec + +```ts +await pic.setTime(new Date()); // so real Stripe signatures verify (±300 s) +await pic.setCertifiedTime(new Date()); +await setXrcRate(gw); await setCmcRate(gw); // working price inputs +const port = await pic.makeLive(); // real HTTP gateway +``` + +Then point the Stripe CLI at it: + +```sh +stripe login # SANDBOX account, never live +stripe listen --forward-to "http://127.0.0.1:/webhook/stripe?canisterId=" +``` + +`src/live-gateway.spec.ts` already does the canister half and logs the exact URL — +run it, copy the line, and drive real events at it. Call `pic.stopLive()` when you +need time control back for the delay/terminate scenarios. + +⚠️ **Two gotchas.** `makeLive` enables auto-progress, which is incompatible with +`advanceTime` — hence the separate spec file, and `stopLive()` before any +time-travel. And the clock alignment is not optional: a PocketIC instance starts +years away from now, so without `setTime` every genuine Stripe signature is +rejected with a 400. + +### What still needs somewhere else + +| Need | Where | Why | +|---|---|---| +| Completing a **hosted Checkout page** | a browser | Stripe deliberately has no headless path. `stripe trigger --override checkout_session:client_reference_id=` gets you a real *signed event* with the right reference, which covers attribution without the UI | +| **Frontend click-through** (group H) | ✅ a **local network** — see the walkthrough above | the asset canister serves the `ic_env` cookie the page needs; PocketIC does not | +| Live-mode behaviour: Radar, 3DS, payouts, disputes, account restrictions | mainnet + Stripe **live**, tight caps | unmockable | + +Group H runs against a **local network**, not mainnet: the asset canister serves the +`ic_env` cookie the page reads for the backend id and root key. + +Sign-in there uses **local** Internet Identity, and it does so automatically — +`identityProvider()` in `auth.ts` derives `http://id.ai.localhost:/authorize` +from the page's own origin whenever the hostname ends in `.localhost`, and mainnet +`https://id.ai` otherwise. There is no environment variable to set and no way for a +production origin to take the local branch. (`VITE_II_URL` overrides both, and +exists for the PocketIC sandbox script.) + +The frontend's *state logic* is covered headlessly by `main.test.ts` (jsdom) and its +*rendering* by `test/browser/` in Chromium, so what a human adds here is the real +login, the real Checkout page, the deployed-cookie path, and whether the CLI page's +commands land on the right principal. + +**Nothing in this plan requires a mainnet deploy.** Only live-mode Stripe behaviour +does — Radar, 3DS, payouts, account restrictions — and that is a separate decision +from verifying the build. + +Everything else — signatures, attribution, amounts, dedup, refunds, async methods, +event types, livemode, **and full delivery** — runs in PocketIC. + +### Verification commands used throughout + +Against PocketIC these are actor calls from the spec rather than `icp canister +call`; the method names and shapes are the same. Shown as CLI for readability, and +they work verbatim against a deployed canister. + +```sh +icp canister call backend audit_log_recent '(null, 25 : nat)' +icp canister call backend orphans_unresolved '(null, 50)' +icp canister call backend get_order '("")' +icp canister call backend order_for_payment '("pi_...")' +icp canister call backend delivery_journal '("")' +icp canister call backend receipt '("")' # owner identity only +``` + +--- + +## A. Signature and transport + +| # | Scenario | How | Expect | +|---|---|---|---| +| A1 | Valid signature accepted | any real forwarded event | `200`; event appears in `audit_log` | +| A2 | Tampered body rejected | `stripe listen` + edit the body in a replayed `curl` with the original signature | `400`, nothing in state | +| A3 | Missing signature header | `curl` the route with no `Stripe-Signature` | `400` | +| A4 | **Unprovisioned secret → Stripe retries and later succeeds** | deploy fresh, do **not** set the secret, pay; then set the secret and wait for Stripe's retry | first delivery `503`; the retry delivers. Proves the retry contract we rely on | +| A5 | Secret rotation overlap | `set_webhook_secret` with a new value while a delivery is in flight | no lost event; `webhook_secret_status.generation` increments | +| A6 | Clock drift rejected | skew the host clock >5 min, deliver | `400`. Restore the clock afterwards | + +## B. Attribution (claimed, not trusted) + +| # | Scenario | How | Expect | +|---|---|---|---| +| B1 | Happy path | open the order's `stripeSessionUrl`, card `4242 4242 4242 4242` | order → `#paid`; → `#delivered` | +| B2 | No reference | `stripe trigger checkout.session.completed` | `200`; `#unattributed`, `claimedRef` empty | +| B3 | Forged owner | hand-edit the ref to another principal, same order id | `#unattributed` — "claimed owner does not match" | +| B4 | Malformed reference | ref = `garbage` | `#unattributed` — "malformed" | +| B5 | Payment for an **expired** order | there is no TTL to shorten — open the order's session URL, expire that session in the Stripe Dashboard so `checkout.session.expired` arrives, then pay a *previously opened* copy of the page | `200`; order **stays `Expired`**, `#unattributed` whose detail says "cannot be paid". **Refund it in Stripe.** Not honoured — expiry is terminal | +| B6 | Payment for a **cancelled** order | `cancel_order`, then pay a page you opened before cancelling | `200`; order **stays `Cancelled`**, the same refundable obligation. The buyer's decision wins; the money is refundable, never converted against it. ⚠️ Hard to reach on purpose: cancel expires the session on Stripe *first*, so the payment usually cannot start at all | +| B7 | There is no rescue lever | — | there is no attribution rescue. For B5 and B6 the only remedy is a refund in Stripe, which auto-resolves the entry | + +## C. Amount honouring + +⚠️ **Three of these five rows are optional once the fixtures are captured, and one should be dropped.** +They were written when crafted JSON was our only description of Stripe. The parity test in +`test/integration/src/fixtures.spec.ts` now compares a **recorded real** session against the +crafted builder on exactly the fields this group questions — `amount_total`, `currency`, +`payment_status`, `payment_intent` — so the shape question is automated and the crafted +guard tests inherit its credibility. + +| row | now | why | +|---|---|---| +| **C1** | **required** | the happy path; covered by any real purchase | +| **C4** | **required** | reachable with no tampering, and nothing else covers the ceiling's one live case | +| C2, C5 | optional | the field question is automated; a hand-made session adds the HTTP path and ordering, not the shape | +| C3 | **drop** | its own row says it is not a separate outcome — it *is* C2 | + +A manual plan ages against the automation built beside it. The rows to re-read first are the +ones whose justification is about **what we cannot otherwise know**, because that is the +claim automation invalidates. + +| # | Scenario | How | Expect | +|---|---|---|---| +⚠️ **The canister sets the amount on the session**, so C2 and +C3 can no longer be produced through the app at all — which is the point of the +change, and is why they are listed as *unreachable* rather than dropped. To +exercise the mismatch branch you have to create a session outside the app (a +hand-made `POST /v1/checkout/sessions` at a different `unit_amount`, carrying an +order's `client_reference_id`) — worth doing once, because it is the branch that +used to deliver silently. + +| # | Scenario | How | Expect | +|---|---|---|---| +| C1 | Exact quoted amount | pay the order's own session | `lockedCycles` verbatim; `paidUsdCents == pricing.usdCents` | +| C2 | **Different amount** | not reachable through the app — hand-make a session at another `unit_amount` with the order's reference | `200`; **nothing delivered**, order stays `Created`, a refundable obligation naming both figures | +| C3 | Below the fee floor | same, at e.g. $0.31 | the same obligation as C2 — "below the floor" is not a separate outcome, it is just a different amount | +| C4 | Above the per-purchase ceiling | lower `maxPurchaseUsdCents` below an existing order's amount, then pay that order's session | A refundable obligation, **nothing delivered**. This is the ceiling's one reachable case, and it needs no tampering | +| C5 | Wrong currency | not reachable through the app — the request pins `usd`; hand-make a EUR session | `#unattributed` — "unexpected currency" | + +## D. Dedup and replay + +| # | Scenario | How | Expect | +|---|---|---|---| +| D1 | Resend one event | `stripe events resend ` — the event id is on the Dashboard's event page. ⚠️ **NOT the Dashboard's own "Resend"**: that acts on a delivery attempt to a *registered* endpoint, and this plan's only transport is `stripe listen`, which is a live subscription with no endpoint and no attempt record. On a sandbox run there is no Resend button to find. (RUNBOOK §6's Dashboard instruction is for a deployment, which does have a registered endpoint.) | `200 duplicate event`; **no** second credit | +| D2 | Two genuine payments | pay the same link twice (two intents) | second → `#duplicate` | +| D3 | Same intent, new event id | resend after >7 days if you can arrange it, else trust D1 | `200 already credited`, `stripe.replayedAfterPruning` | +| D4 | Credited elsewhere | not reachable through the app — nothing but the webhook writes an attribution. To force it, deliver a hand-made `completed` for order Y carrying an intent already credited to order X | nothing delivered; `stripe.creditedElsewhere` + a `#duplicate` naming both | + +## E. Refunds — the highest-value group + +These exercise the bug that survived three review rounds. **Use real Stripe +refunds, not crafted events** — the whole point is confirming Stripe's +`amount`/`amount_refunded` semantics match what the code assumes. + +| # | Scenario | How | Expect | +|---|---|---|---| +| E1 | Full refund of an unattributed payment | B2, then refund it fully in the Dashboard | the entry **auto-resolves** | +| E2 | **Partial refund** | refund e.g. $1 of a $5 charge | entry stays **OPEN**; `stripe.refundPartial`; **no** `refundUnmatched` line | +| E3 | Partial then completed | refund the remaining $4 | entry now resolves | +| E4 | Two partials summing to full | $2 then $3 | resolves on the second — Stripe's `amount_refunded` is cumulative | +| E5 | Refund **after delivery** | deliver an order, then refund | `#refundAfterDelivery` with `refundedCents` and `fullRefund` set; never auto-resolves | +| E6 | Refund of an escalated order | force an escalation, then refund | `stripe.refundOfEscalated`, not "pipeline may be mid-flight" | + +## F. Async / delayed payment methods + +The code assumes `checkout.session.completed` with `payment_status != "paid"`, +then `checkout.session.async_payment_succeeded`. **Verify Stripe really sends +that sequence** — a unit test previously encoded a wrong assumption here. + +| # | Scenario | How | Expect | +|---|---|---|---| +| F1 | Delayed method settles | enable a delayed method (SEPA debit / `customer_balance`) on a test link and pay | first event `200 ignored`, order stays `#created`, `stripe.unpaidSession`; on settlement → `#paid` | +| F2 | Delayed method fails | trigger `async_payment_failed` | order stays payable; intent not consumed | +| F3 | Out-of-order arrival | if you can force settlement before `completed` | still delivers exactly once | + +## G. Event types and configuration + +| # | Scenario | How | Expect | +|---|---|---|---| +| G1 | Unhandled type | subscribe `payment_intent.succeeded`, trigger it | `200 ignored` + `stripe.unhandledType`. **Never** 4xx. ⚠️ This case used `charge.dispute.created`, which the canister now **handles** (`stripe.disputeCreated`, audit-only) — so it stopped exercising the unhandled path and would fail on its own expectation. It needs a type the dispatcher genuinely does not know | +| G2 | **No `payment_intent`** | a 100%-off promo code, or a subscription-mode link | `200` + `#unprocessable`; a resend does **not** duplicate it | +| G3 | Livemode mismatch | point a **test** secret at a canister set to `opt true` | nothing delivered; `stripe.livemodeMismatch`; no obligation queued | +| G4 | Live-on-test | the reverse | nothing delivered, but an obligation **is** queued, keeping the real reference | +| G5 | Mode unset | clear it with `set_expected_livemode '(null)'`, pay | delivers, plus `stripe.livemodeUnset` on every payment | + +## H. Frontend — only what a machine cannot do + +**Most of this group is now automated.** `main.ts` has a jsdom suite against the +real `index.html` body, and `test/browser/` drives a production build in Chromium +with committed screenshot baselines. Re-checking those by hand is wasted time; what +is left below is the part no suite can reach. + +Automated, and where — do **not** repeat these manually: + +| Was | Now covered by | +|---|---| +| H1 tier buttons show a cycle estimate, not a tier id | `main.test.ts` | +| H2 the estimate names what lands, and the deposit fee is disclosed once | `main.test.ts` + `layout.spec.ts` | +| the form offers no destination question, and none of its old inputs exist | `layout.spec.ts` (`toHaveCount(0)`, so `display:none` cannot satisfy it) | +| a crafted order for someone else's account, or a non-default subaccount, is refused | `gateway.spec.ts` scenario 61 — the canister's refusal, which no UI test can show | +| H3 fee breakdown accounts for every cent, "operator margin: none" | `main.test.ts` | +| H5 a moved quote asks for confirmation, and the second click goes through | `main.test.ts` | +| H6 cancel appears only pre-payment | `main.test.ts` | +| H7 the receipt recomputes and reports a match | `main.test.ts` + `delivered.spec.ts` | +| the delivered view, the CLI page and its five commands, unknown order ids | `delivered.spec.ts`, through a fixture that replaces only the backend. ⚠️ The step strip, the collapsed facts and "buy again" are **gone**, not covered — asserted absent rather than hidden | +| paint: same-colour text, occlusion, opacity | five screenshot baselines, zero tolerance | + +Still needs a human, and this is the list to work: + +| # | Scenario | Expect | 2026-08-13 | +|---|---|---|---| +| H1 | **Real sign-in**, local Internet Identity at `http://id.ai.localhost:8000` | a passkey registers and the header shows a shortened principal. No suite can drive a passkey | ✅ | +| H2 | **The deployed asset canister**, not a static build | the page reads its backend id and root key from the real `ic_env` cookie and prices from the real canister. The browser suite serves `dist-fixtures` over a static server, so this path is only ever exercised by hand | ✅ | +| H3 | **The real Stripe hosted Checkout page** | Stripe has no headless path; `stripe trigger` gets you a signed event but never the page | ✅ | +| H4 | **The CLI page's commands actually work** | five steps at `#/cli`, reached from `#order-next-link` on a delivered order or from the dashboard. Run them in order and stop at the first that does not do what the page says. ⚠️ **Step 1 is a prerequisite, not advice:** turn CLI access on at [id.ai](https://id.ai) → settings, or step 2's sign-in page answers "CLI access not enabled" and never returns an identity. Then `icp identity link web cyclepay-id --app `, `icp identity default cyclepay-id`, `icp identity principal` (must equal the principal beside it), `icp cycles balance` (must equal the figure beside it), `icp deploy -e ic`. ⚠️ **No `--identity` flag on the verify commands, and that is the point** — step 2 made it the default, and passing the flag would verify an identity the later commands do not use | ❌ **not run** — supersedes the `dev`-identity version of this row, which named an identity and a route that no longer exist | +| H5 | **The cycles are really there** | `icp cycles balance --of-principal ` shows the delivered quantity | ✅ 18.2 T for two orders | +| H6 | Order history across a **real** sign-out and sign-in | the table repopulates; a reopened order still shows its timeline | not run | +| H7 | Typography, hierarchy, and the italic rule | `brand-lint.sh` covers banned characters, vocabulary and hardcoded colour. The rest needs eyes | not run | + +**H4 is the one that still matters most.** H5 proves the cycles exist at the +buyer's principal; H4 is what proves a buyer can *become* that principal from the +CLI and spend them. Until it passes, the last step of the product's promise — +"link the CLI, deploy" — is unverified end to end. + +⚠️ **Two of its steps are unverifiable by any suite, which is why it is here and not +automated.** The id.ai CLI-access switch is a setting in someone else's product, and the +delegation the link command returns needs a real browser sign-in. The page's own +assertions cover only that the right commands are rendered, in order, with the values +this page shows. + +## I. Fixture capture — do this while you are in there + +Save the **raw request bodies** (Dashboard → event → the JSON) and commit them as +integration fixtures: + +- `checkout.session.completed` — paid, with a reference +- `checkout.session.completed` — `payment_status: unpaid` +- `checkout.session.async_payment_succeeded` +- `charge.refunded` — full **and** partial +- a `payment_intent: null` session (G2) +- `charge.dispute.created` + +Then assert the real bodies parse to the same values as the crafted ones. That +converts "our reading of the docs" into "the actual wire format", permanently. + +--- + +## How this relates to `RUNBOOK.md` + +They answer different questions and neither replaces the other: + +| | This plan | `RUNBOOK.md` | +|---|---|---| +| Question | *Does this build behave correctly?* | *How do I operate a live system?* | +| When | **once**, before the rail carries money — and again after material changes to it | continuously, especially during incidents | +| Audience | whoever is validating the build | the on-call operator | +| Output | a green run, plus captured fixtures | a resolved incident | + +The plan is a **precondition** to `docs/OPERATE.md`'s Mode 3: that procedure assumes +the rail has already been shown to work. + +⚠️ **Configuration is not duplicated, and the two scripts own different halves.** +`scripts/stripe-dev.sh` sets only the two Stripe-side values: `expected_livemode = +false`, and the forwarding session's signing secret. `scripts/local-dev-seed.sh` owns +the money levers, and it sets the **mainnet** delivery timeline (2 h alert, 72 h max +hold) rather than dev values — so on a local `icp network` the delay path needs two +hours of patience or a hand-called `set_delivery_config`. +⚠️ **Only the PocketIC harness shortens it**, to a 2-minute alert (`sandbox.ts`), +which is why that is the harness to reach for when you want to *see* a delay alert. +Neither script sets an order TTL: the deadline is the Stripe session's own, and +nothing local shortens it. `docs/OPERATE.md`'s Mode 3 +is the sole authority for go-live configuration. + +Where this plan states an *expected outcome* (a queued obligation, a `#unprocessable`, +a `stripe.refundPartial`), `RUNBOOK.md` §6 states what to *do* about it. That makes +the run a useful check on the runbook itself: if a scenario here produces an entry +whose §6 row is missing, wrong, or unfollowable, the runbook is the thing to fix — +that is exactly how the "failed 25 times" and missing `transferRejected` rows were +found. + +## What this cannot tell you + +**A green run on this plan is not go-live approval.** It closes the biggest +*unknown*, not the remaining *known* gaps: + +1. **Sandbox ≠ live.** No real Radar rules, no real 3DS challenges, no payout + mechanics, no account-restriction behaviour. Only a mainnet deploy in **live** + mode shows those, and disputes cannot be rehearsed at all. +2. **Disputes produce no on-chain signal.** Only `charge.refunded` is subscribed. + A lost chargeback is invisible to the canister — accepted, documented, and + managed by Dashboard vigilance. +3. **The SEV-SNP question is RESOLVED** (owner, 2026-09-11). Memory encryption + protects RAM, and checkpoint-to-disk and state-sync between nodes are confidential + on the target subnet as well — which was the part that mattered, since either in + the clear would have leaked the webhook secret and made SEV worthless. It was the + single most consequential open item and no Stripe test could touch it. + `RUNBOOK.md` §9 carries the checklist; **attestation coverage** of every replica + is the sub-item still open there. +4. **Flat controller allowlist.** Any one controller can upgrade-then-drain. An + honest trust model, but it is the model you would launch with. +5. ~~**The reproducible-build gate has never run against a real deployment.**~~ **Closed:** + `v0.1.0-beta.1` was built in the pinned container, installed from that artifact, and the + install gated on the canister reporting the hash that was built. ⚠️ What remains is that + `backend.wasm` depends on the build architecture, so a hash is only comparable against a + rebuild on the architecture the release names — `docs/VERIFY.md` carries that limit. +6. **No monitoring exists.** Every safety mechanism here is a number someone has + to go and look at; an alert nobody receives is not an alert. **`RUNBOOK.md` §8 + owns the plan** — metric table with thresholds and severities, and the reason the + **reserve balance** has to be *pushed* rather than polled. Wire it before taking + money. +7. **No external security audit** (spec §8 explicitly does not gate v1 on one). +8. **The deferred tail**, all real and none money-losing: gate-config/tier + cross-check, `let #ii(owner)` trap, counts-map reconcile, the once-per-order + alert keeping its first stall text. + +### The honest bar + +Phases A and B green ⇒ **the Stripe integration works as coded, and money-out +works against real NNS canisters.** That is the engineering bar. + +Production also needs items 3 and 6 resolved — secret confidentiality and +monitoring — plus a deliberate decision to accept 1, 2, 4 and 7. Those are +judgement calls for the operator, not tests that can pass. diff --git a/docs/STRIPE.md b/docs/STRIPE.md new file mode 100644 index 0000000..660bf29 --- /dev/null +++ b/docs/STRIPE.md @@ -0,0 +1,993 @@ +# The Stripe (Card) rail, end to end + +How fiat becomes cycles. Written from the code — every claim names the module it +came from, so you can check it. (Module names, not line numbers: a stale line +number is worse than no line number, and they drift on every edit.) The *why* behind each +decision — and what the `§N` shorthand in those comments points at — is +`docs/DESIGN.md`. + +- [1. The one-sentence version](#1-the-one-sentence-version) +- [2. What the canister calls Stripe for, and what it still cannot do](#2-what-the-canister-calls-stripe-for-and-what-it-still-cannot-do) +- [3. The full happy path](#3-the-full-happy-path) +- [4. Ingress: two paths, and why the webhook can't be caller-authenticated](#4-ingress-two-paths-and-why-the-webhook-cant-be-caller-authenticated) +- [5. Signature verification](#5-signature-verification) +- [6. Attribution: claimed, not trusted](#6-attribution-claimed-not-trusted) +- [7. Dedup: two layers, and what they do not protect against](#7-dedup-two-layers-and-what-they-do-not-protect-against) +- [8. Amount honouring — an equality check](#8-amount-honouring--an-equality-check) +- [8a. What the buyer sees before paying](#8a-what-the-buyer-sees-before-paying) +- [9. Admission: the pre-creation gate](#9-admission-the-pre-creation-gate) +- [10. Order lifecycle — Stripe owns the deadline](#10-order-lifecycle--stripe-owns-the-deadline) +- [11. Refunds, disputes, and what is not automated](#11-refunds-disputes-and-what-is-not-automated) +- [12. Every failure and its money position](#12-every-failure-and-its-money-position) +- [13. The two secrets](#13-the-two-secrets) +- [14. Operator surface](#14-operator-surface) +- [15. Local development against a Stripe sandbox](#15-local-development-against-a-stripe-sandbox) + +--- + +## 1. The one-sentence version + +A user picks a preset or types an amount; the canister creates a **Stripe +Checkout Session for that one order** over an HTTPS outcall, setting +`client_reference_id = _` itself; the buyer pays Stripe +directly on that session's URL; and Stripe POSTs a signed +`checkout.session.completed` webhook back, which the canister verifies by HMAC +on-chain, attributes to the order, and **transfers** the cycle quantity locked at +order creation out of the gateway's own cycles-ledger account. **Nothing is created +on demand**: the gateway sells cycles it already holds, so a delivery either moves +them or fails and retries. + +## 2. What the canister calls Stripe for, and what it still cannot do + +The rail is **not inbound-only**: the canister holds an API key and talks to us +and never the reverse. It now makes outbound calls, and **all three are Checkout +Sessions calls**: create one for an order, expire one when the buyer cancels, and +retrieve one when the recovery sweep needs to settle an order whose expiry event never +arrived. Over HTTPS outcalls, using a **restricted** key (`rk_...`) with +**Checkout Sessions = Write** — the level that also grants the read the retrieve needs — +and every other permission None. + +That scope is the whole of the change. Everything the inbound-only design bought +still holds, because the key cannot do anything else: + +| Consequence | Detail | +|---|---| +| A leaked key creates sessions that pay **us** | It cannot issue refunds, read customers, touch payouts, or reach the account. That asymmetry is why the scope matters more than the storage: an `sk_` would be a materially worse thing to leak (§13). | +| Refunds are still manual | The canister structurally cannot issue one. Every refund is a human action in the Stripe Dashboard (§11). | +| No subscriptions or auto-refill | Both require charging a stored payment method. Explicitly out of scope. | +| Payment amounts are pinned by **us**, not by a link's configuration | The session carries inline `price_data` with the amount the order quoted, so `amount_total == usdCents` is a property of what the request does *not* enable rather than of what an operator remembered to switch off in a Dashboard. The list is in the code beside `Session.createBody`. | + +**Two things follow from making any outbound call at all**, and both are new +failure surfaces rather than incidental details: + +- **A transform is now load-bearing.** Every replica makes the request, so the + responses must agree byte for byte; Stripe returns a unique `request-id` per + HTTP request, so `Session.strip` discards **all** headers. A per-request value + left in place takes the rail down with `No consensus could be reached` — and no + suite here can catch it, because PocketIC mocks outcalls (verified by mutation). +- **`Idempotency-Key = orderId` does two jobs.** The obvious one is that Stripe + will not create a second session for a retried request. The load-bearing one is + that without it each replica would create a *distinct* session, so consensus + could never be reached at all and no transform could repair it. + +Pricing remains inter-canister: the Exchange Rate Canister for USD/ICP and the +CMC for XDR/ICP (§8). There is no operator-settable rate source to audit. + +## 3. The full happy path + +```mermaid +sequenceDiagram + autonumber + actor Operator + actor Buyer + participant FE as Frontend canister + participant BE as Backend canister + participant Stripe + participant CL as Cycles Ledger + + Operator->>BE: set_stripe_api_key + set_stripe_origin
(+ optional price tiles) + Note over Operator,BE: No Dashboard objects exist for this rail.
Provisioning both secrets is what OPENS it. + + Buyer->>FE: Internet Identity login + FE->>BE: create_order(amount) + Note over BE: rail provisioned? — Secret.mo
admission gate — Gate.mo
quote locks the CYCLE QUANTITY — Pricing.mo (cached XRC+CMC)
raw_rand order id — Orders.mo + + BE->>Stripe: HTTPS OUTCALL POST /v1/checkout/sessions + Note over BE,Stripe: Idempotency-Key = orderId — rails/Session.mo
inline price_data · expires_at ≈ 35 min
client_reference_id set BY US, never by the buyer + Stripe-->>BE: session id + hosted URL + BE-->>FE: #35;created + stripeSessionUrl + expiresAtNs (Stripe's own) + + FE->>Buyer: opens order.stripeSessionUrl + Buyer->>Stripe: pays + Note over Buyer,Stripe: Card data never touches this system. + + par Stripe notifies the canister + Stripe->>BE: POST /webhook/stripe (ANONYMOUS caller) + Note over BE: http_request → upgrade = ?true
http_request_update → route table — mixins/Webhook.mo + Note over BE: Card.handleWebhook — Card.mo
1. secret provisioned? → else 503, Stripe retries
2. HMAC verify + ±300 s → else 400
3. parse event → else 400
4. dedup on event.id → else 200 "duplicate event"
5. dedup on payment_intent → else 200 "duplicate payment intent"
6. attribute the reference → else #35;unattributed (refund)
7. ceiling + amount honoured → else #35;unattributed (refund) + BE->>BE: markPaid → #35;paid, paidIntents[intent] = orderId + and The buyer comes back + Stripe->>Buyer: redirect to success_url + Buyer->>FE: lands on the order page (success_url) + loop every 3 s until delivered + FE->>BE: get_order(id) + end + end + Note over BE,FE: These two are INDEPENDENT with no ordering guarantee —
the buyer routinely arrives before the webhook does, which is
why the page polls rather than rendering a final state. + + BE->>CL: one icrc1_transfer out of the reserve + alt transfer lands + CL-->>BE: block index → #35;delivered + else retriable, or no reply + Note over BE: stays #35;paid — the sweep replays the SAME intent + else fate unknowable, or 72 h elapsed + Note over BE: #35;needsReview — an operator establishes the money position + end +``` + +Money-out is rail-agnostic from `#paid` onward — the code is keyed by +`Types.Rail`, which is a single-case variant today. + +## 4. Ingress: two paths, and why the webhook can't be caller-authenticated + +HTTP requests reach a canister through the IC HTTP gateway, which packages them +into a Candid `HttpRequest`. **They arrive as the anonymous principal** — the +gateway does not and cannot propagate Internet Identity. So an HTTP route can +only ever be authenticated by its *payload*, never by `caller`. + +That forces exactly two ingress paths: + +| Path | Auth | What uses it | +|---|---|---| +| **Candid calls** | `caller` is the real II principal | The whole app API: `create_order`, `get_order`, `list_orders`, every admin method | +| **One HTTP route** | HMAC over the payload | `POST /webhook/stripe` only | + +`Http.mo` dispatches off a route *table* with a per-route `upgrade` flag +(`mixins/Webhook.mo`). The query half returns `upgrade = ?true` **without running the +handler** — the gateway discards that response and re-issues the request to +`http_request_update` through consensus, so response certification is moot. + +Two details that matter: + +- `http_request_update` is **callable directly via Candid by anyone**, so the + dispatcher re-applies every guard rather than trusting that the query half ran + first (`mixins/Webhook.mo`). +- `Http.pathOf` strips the query string before matching (`Http.mo`), so a + gateway URL carrying `?canisterId=…` still routes — which is what makes §15's + local setup work. + +Guards on the route, all asserted in `test/integration/src/gateway.spec.ts` +scenario 05: unknown path → 404, wrong method → 405 with a lowercase `allow` +header, body over 64 KiB → 413 (checked *before* the upgrade decision, so +oversized payloads never pay for consensus). + +## 5. Signature verification + +`Card.verify` (`Card.mo`) implements Stripe's scheme exactly: + +1. **Parse `Stripe-Signature`** (`Card.mo`) — comma-separated `key=value` + elements, e.g. `t=1492774577,v1=5257a8…,v0=6ffbb5…`. Per Stripe's reference + parsers: only the first `t=` counts, unknown schemes (`v0=`) are ignored, and + unparseable elements are skipped. **Multiple `v1=` values are collected** — + Stripe sends one per active secret during a rotation overlap, and any single + match verifies. That is what makes rotation zero-downtime with only one + secret stored. +2. **Timestamp window** — `|now − t| > 300 s` → reject (`Card.mo` for the + tolerance). `t` is signed into the payload, so it cannot be forged to defeat + this. Stripe re-signs on every retry with a fresh `t`, so retries are never + rejected by the window. +3. **MAC** over `"." ++ raw_body` (`Card.mo`). The body must be the + **exact bytes received** — re-serialised JSON will not verify. +4. **Constant-time compare** (`Hmac.mo`) — accumulates XOR across all bytes + instead of returning at the first difference. A short-circuiting compare + would be a timing oracle that leaks the expected MAC byte by byte and lets an + attacker forge "paid" webhooks *without* the secret. + +Header names are matched case-insensitively (`Http.mo`) because proxies +re-case them. + +Only after verification is the body parsed, and it is parsed as a **tree** +(`Json.mo`), never scanned for substrings (`Card.mo`) — the JSON is authentic +Stripe output, but string values inside it still carry user-influenced content. + +The canister subscribes to exactly two event types. Anything else is +acknowledged 200 and dropped, so an extra subscription in the Dashboard does not +look like a delivery failure. + +## 6. Attribution: claimed, not trusted + +`client_reference_id` used to be a **URL parameter the frontend appended**, on a +permanent link anyone could pay with any reference they liked. The +canister sets it through the API and there is no URL parameter to touch — which +removes the dominant attribution failure, and removed `attach_payment` with it +(§12). + +The posture does not change: **claimed, not trusted.** Every dollar that arrives +must still resolve to delivery or to a refund obligation — never a silent accept — +because the field arrives in a webhook body and a webhook body is data. It is a +**pointer, not a credential**, which is what made it safe to expose at all. The order id is 16 bytes of `raw_rand`, so a reference cannot be guessed; and +forging one gains nothing, because the claimed principal must equal the order's +stored owner, so the best an attacker achieves is paying for somebody else's order. + +The value is appended **by the frontend, per order** — it is never configured +anywhere. The Stripe Dashboard's URL-parameter dialog can bake a static one into a +link, which would break attribution for every buyer; RUNBOOK §3 says to leave it +empty. + +`handleCheckout` (`Card.mo`) re-derives and checks everything: + +| Check | Failure | +|---|---| +| reference present | `#unattributed` — "missing client_reference_id" | +| parses as `_` | `#unattributed` — "malformed" | +| order exists | `#unattributed` — "no order X". Orders are never deleted (§10), so this means the reference never resolved: a forged, mistyped, or stripped URL parameter | +| claimed principal **matches the stored owner** | `#unattributed` — "claimed owner does not match" | +| order's rail is `#card` | `#unattributed` — "is not a card order" | +| status is `#created` | `#duplicate` otherwise | +| currency is `usd` | `#unattributed` — "unexpected currency" | +| amount within the ceiling | `#unattributed` — "exceeds the per-purchase ceiling" | + +Note the owner check: the reference *claims* a principal, and it must equal the +order's actual owner. `Orders.parseClientReferenceId` compares principal text +rather than calling `Principal.fromText`, because that traps on garbage and a +trapped webhook is a 5xx that Stripe would retry forever (`Orders.mo`). + +Refund obligations are always answered **HTTP 200**. The payment *is* handled — by +the operator's refund queue rather than by delivery — and a non-2xx would make +Stripe redeliver an event that has already been routed (`Card.mo`). + +The stored `claimedRef` is length-capped at 128 bytes +(`Orphans.maxClaimedRefBytes`) so an attacker cannot stuff arbitrary data +into admin-visible stable state one webhook at a time. + +## 7. Dedup: two layers, and what they do not protect against + +In order (`Card.mo`): + +1. **`event.id`** — catches Stripe *redelivering one event*. Stripe's delivery is + at-least-once and it retries for ~3 days. +2. **`payment_intent`** — one delivery per payment, even across distinct event + deliveries that reference the same intent. + +Both live in `Idempotency.mo` and are pruned after ~7 days on the webhook path +(Stripe stops retrying well before that). + +**Stripe dedup is not double-pay protection.** A user who genuinely pays twice +produces two distinct `event.id`s *and* two distinct `payment_intent`s — two real +payments. The second one passes both dedup layers, finds the order already past +`#created`, and becomes `#duplicate`: fiat exists, nothing extra was +delivered, operator refunds. This is the single most important thing to understand +about the rail, and it is why **dedup gates delivery** rather than gating the +webhook. + +## 8. Amount honouring — an equality check + +The order stores a **pricing snapshot** at creation (`Types.Pricing`): the gross +cents, **both rate inputs** (`usdPerIcpMicros` from the XRC, `xdrPermyriadPerIcp` +from the CMC) with the XRC quality signal, and the fee formula. `Card.mo` then +decides one thing: + +- `amount_total` **equals** the order's `usdCents` → deliver `lockedCycles` + verbatim. +- Anything else → **a refund obligation, nothing delivered**, with both figures in the detail. +- Above the per-purchase ceiling → the same, checked first (see below). + +**Why this used to be a computation.** A fixed Payment Link could legitimately be +paid for an amount the order was not created for — the buyer chose the *link*, not +the order — so a mismatch was repriced from the order's own snapshot and +delivered. A per-order session carries the amount **we** set, so a mismatch now +means a Stripe feature that moves the total is enabled. That is an operator +problem to see, not a buyer's choice to honour, and repricing it would deliver +against it silently: the audit log would show an ordinary completed purchase. + +Two consequences worth naming, because things downstream depend on them: + +- **`lockedCycles` is immutable after creation.** Nothing on the money-in path + writes it. The promise tally is exact rather than conservative because of that. +- **`#belowFeeFloor` and `#unusableSnapshot` are gone**, not merely unreachable — + both existed only to bound repricing. `#aboveCeiling` stays as defence in depth, + and it is still reachable without any tampering: an order created under a + higher ceiling matches its own quote after the ceiling is lowered. + +**What was actually paid is stored on the order** (`paidUsdCents`), not only in +the audit log. ⚠️ **The log no longer drops anything**, but the division still holds: a fact about +money cannot live only there — "what did this buyer pay?" has to be answerable +from state for the life of the canister. It can now only equal `usdCents`, and it +is stored anyway: "what Stripe said" and "what we asked for" being the same is +worth being able to check rather than assume. + +## 8a. What the buyer sees before paying + +Three guarantees, in the order they matter. + +### The cycle quantity is shown before anything is committed + +`quote_previews(amounts)` is a **public query** returning, per amount, the fee, +the net, and the cycle quantity — plus the rate pair it used. The preset grid is +one round trip, and a typed custom amount is priced through the same query rather +than in the client. + +⚠️ **It no longer discloses the cycles-ledger fee**. A query cannot +`await icrc1_fee`, so disclosing it meant the backend storing a copy and +correcting it whenever a transfer came back `#BadFee` — a stable field, a +correction path and a staleness class, for one number the caller can read itself. +The frontend asks the ledger directly (`actor.ts`) and shows `cycles − fee`. + +⚠️ **It calls the same `quoteCents` that `create_order` calls.** Not the same +formula reimplemented — the same function. A client computing its own estimate +would be one refactor away from displaying a number the gateway does not honour, +and the buyer would have no way to tell which side was wrong. + +Being a query, it costs nothing and needs no login: you can see exactly what your +money buys before signing in. It takes no cap on the input array, deliberately — +work is constant per element the caller already had to transmit, so the only +thing a cap would buy is silent truncation. + +### The rate is locked at creation, and the lock is enforced + +`create_order(amount, destination, minCycles)` takes an **optional minimum**. +If the current rate no longer clears it, the call returns +`#quoteChanged {quoted; minimum}` and **creates nothing** — no half-finished +order to clean up — carrying what the amount buys now so the client can show a +real figure rather than a bare failure. + +⚠️ **Why this must be in the update and not the client.** A client-side re-check +is a *query*; the creation is an *update*. Two messages, so the rate can refresh +between them. Pinning the expectation inside the same call that locks the price +is the only way to close that window, and it makes the guarantee hold for every +caller rather than for one polite frontend. + +⚠️ **A minimum, not an equality.** A move in the buyer's favour passes through +and they keep the extra cycles. The guard can only ever protect the buyer, never +the operator. Passing `null` opts out. + +The **tolerance is the client's choice, not the backend's** — the backend enforces +exactly the minimum it is handed, so a script that needs an exact quantity can +pin one. The frontend's policy is **5%**: rates refresh on a timer, so an exact +match would bounce purchases over moves too small to care about, and every bounce +is a buyer wondering whether their card was charged. Inside tolerance the order +is created and the UI **states the actual locked figure**, including when it +drifted in the buyer's favour. Silence would be the surprise. + +Once locked, nothing re-reads a rate: money-out uses the stored snapshot, so +market movement after creation changes nothing, however long the buyer takes to +pay (§10 — even a year). + +### One destination: the buyer's own account + +Cycles go to the **signed-in principal's own cycles-ledger account**, default +subaccount, and `create_order` refuses anything else with +`#destinationNotOwned`. That is a property of the canister, not of the frontend — +a hand-crafted call reaches the same method. + +So "the cycles come to you" needs no field, no question and no validation on the +page. A buyer funding a canister transfers on afterwards from the CLI, and pays +that second fee themselves. + +### The cycles-ledger deposit fee is disclosed, not absorbed + +Delivery loses **100 M cycles** to the ledger's deposit fee, on every order. The +amount tiles show what lands; the note under the destination names the fee once. + +⚠️ **Deliberately not grossed up into the price.** Covering the fee by sending +extra cycles would let anyone drain the operator by opening orders. The cycles +come out of a **finite reserve** the operator funded, so the drain has a hard +floor and hits paying buyers as a refused sale — `#reserveShort`. Disclosure is +the honest fix, and the cheap one: the fee is stated once and nobody's order is +denied to pay for someone else's griefing. + +### The order is the record; the audit log is the trail + +**Every fact about an order's money lives on the order**: its status, what +the buyer actually paid, why it expired (`expiredBy`), when its rates were read +(`pricing.ratesFetchedAtNs`), and the Stripe session it is paid +through and the deadline Stripe set for it. + +`audit_log` is the *operational trail*: alerts and dedup drops. It answers +"what was happening around then", never "what happened to this order". That +division is about where a fact belongs, not about the buffer being bounded — the +removes the ring and the division still holds. + +**A refund is the one money fact not on the order.** It lives in Stripe, where it +was issued, plus the unresolved `#refundAfterDelivery` entry, which the queue +never evicts. A `refundedUsdCents` field was considered and rejected: the app +does not model refunds, and a manual Stripe refund is an out-of-band operator +action. It becomes a field when there is real money to reconcile. + +### Order statuses, and what each one owes + +| Status | Payable? | Owes cycles? | +|---|---|---| +| `#created` | yes | not yet — the promise is held against the reserve | +| `#cancelled` | **no** — the buyer gave up, and `#cancelled → #paid` is absent from the matrix | no | +| `#expired` | **no** | no | +| `#paid` | already paid | **yes** — one transfer out of the reserve away | +| `#delivered` | — | settled | +| `#needsReview` | — | **yes** — outcome unknown, a human checks the ledger | +| `#abandoned` | — | no — the operator ended it, having refunded by hand | + +A payment that arrives for a `#cancelled` or `#expired` order is real money the +gateway cannot deliver against. It is filed as an `#unattributed` obligation carrying +the payment intent, and the operator refunds it in Stripe. Cancellation expires the +Stripe session first, so the window is narrow — but a payment already in flight can +still land in it. + +### Afterwards: a receipt the buyer can check + +`receipt(orderId)` is owner-scoped (§14) and returns both rate inputs, so the +price recomputes on the buyer's machine from canisters they query themselves. See +spec §8 — a gateway confirming its own arithmetic proves nothing. + +## 9. Admission: the pre-creation gate + +`create_order` refuses before quoting when fulfilment is already impossible +(`mixins/Buying.mo` → `Gate.mo`). Checked cheapest-first, before any pricing work: + +| Check | Refusal | Meaning | +|---|---|---| +| amount ≤ `maxPurchaseUsdCents` | `#amountAboveMax` | permanent — the user must change the amount | +| open `#created` orders < `maxOpenOrdersPerPrincipal` | `#tooManyOpenOrders` | the caller must finish or abandon one | +| amount ≥ `minPurchaseUsdCents` | `#amountBelowMin` | permanent — the user must change the amount | +| `Cycles.balance()` ≥ `minCanisterCycles` | `#canisterCyclesLow` | the canister's own gas is low | +| `reserveFloor − promised ≥ lockedCycles` | `#reserveShort` | the reserve cannot cover this order on top of what it already owes. Carries both figures, so a smaller amount may still succeed | + +Two things worth being precise about: + +- **The two "cycles" are different pots.** `minCanisterCycles` is *this canister's own + gas* — if it runs out the canister freezes and is then uninstalled. The **reserve** + is what it sells to customers. Different failure modes, different fixes, and + confusing them is the most common local-setup mistake. +- **`#reserveShort` is the only refusal a smaller amount can fix**, which is why it + carries both figures. The others are permanent for that request (`#amountAboveMax`, + `#amountBelowMin`), about the caller (`#tooManyOpenOrders`), or operational + (`#canisterCyclesLow`). +- ⚠️ **`can_purchase` cannot answer the solvency half.** It is a query, and solvency is + decided synchronously inside `create_order` against the maintained reserve floor. A + green `can_purchase` with `availableToSell = 0` is the split working, not a bug. + +### What a refusal records + +⚠️ **A refusal writes no audit line.** It increments a counter, readable through the +public `refusal_counts` query, and RUNBOOK §8 carries a row per counter with the +response. Refusals are free to attempt — `#amountBelowMin` needs no prior state at +all, so one cent from any principal reaches it with no order and no payment — and the +audit log is the one structure here whose growth is **not** attacker-priced (orders +are bounded by the open-order cap and the reserve; orphan entries each require a real +payment to exist). ⚠️ **A line per attempt was harmless only while the log was a +4,096-entry ring, and there is no ring**, which is why the refusal lines had to go +first and why the admission rule was applied to **every** tag rather than to the two +paths that prompted it. (That pass covered the whole population; the count is +deliberately not written here, because a number in prose that nothing checks drifts — +`grep -c` on the `audit(`/`auditAdmin(` call sites is the answer that cannot be stale.) + +**The exception is the transition, and it falls out of the distinction above.** The +*operational* conditions are facts about the **gateway**, so entering one writes +exactly one `gate.startedRefusing` line and `refusal_counts.refusingNow` stays true +until the next successful admission. The permanent-for-that-request and +about-the-caller reasons write nothing: nothing about the gateway changed, so there is +no transition to record. + +⚠️ **There are three such conditions, and the third is not in the table above.** +`#reserveShort` and `#canisterCyclesLow` are gate reasons; **rail closure is not.** +An unprovisioned API key or origin is refused *before* the gate — the order is +caller, destination, **rail**, tier, admission — so while the rail is closed **100% of +attempts never reach `admit` at all**, and a counter set covering only the table above +would record nothing. That window is not hypothetical: `docs/OPERATE.md` provisions the +secrets last, so a freshly deployed gateway sits in exactly this state by design. It +gets its own `refusal_counts.counts.railClosed` counter, its own +`refusingNow.railClosed` flag, and the same announce-once semantics. + +⚠️ **Latched per condition, not globally.** A single "was admitting, now refusing" +flag is reset by any legitimate success, so the next refusal announces again — a +smaller copy of the same leak. `test/gate.test.mo` pins this with a case that a global +flag fails: rail refuses → a sub-minimum request is refused → rail refuses again, and +only the first announces. + +The gate refuses *before* the user pays. The authoritative check is the one inside +`create_order`, in the same synchronous block as the hold it takes. + +`can_purchase(usdCents)` is a public query that returns the same decision, so the +frontend can disable the button with a real reason instead of failing at submit. + +⚠️ **Two of the gate's reasons sit at opposite ends of `admit`, and the positions are +the same argument.** `#unboundedGiveaway` is a fact about the *gateway*, so nothing +about one request may shadow it: checked late, a sub-minimum amount arriving while the +gateway is a faucet would report `#amountBelowMin`, the condition would never latch, +and `refusingNow` would claim we are admitting. `#buyerNotAllowed` is a fact about one +*principal*, so it must shadow nothing about the gateway: checked early, an unlisted +caller answered that to `can_purchase` and hid the gas floor, the short reserve and the +faucet behind it. + +⚠️ **The anonymous principal is exempt from the buyer allow-list**, and the exemption +cannot widen anything — `create_order` rejects `#anonymous` before the gate. What it +preserves is `can_purchase` as a gateway probe: the frontend calls it before sign-in, +and the answer to "can this anonymous caller buy" is decided by `#anonymous`, not by a +list. + +## 9a. Simulation mode: mainnet against the Stripe sandbox + +**One number is the whole switch: `pricing_status().config.divisor`.** `1` is +production; anything greater is simulation. + +The arithmetic, the four guards, the faucet-and-ordering rule and what the buyer sees +now live in **`docs/OPERATE.md`, "The simulation arithmetic"**, beside the procedure +that sets them — this section described a mode whose setup was written out in three +other places. + +## 10. Order lifecycle — Stripe owns the deadline + +**Orders are never deleted, and nothing sweeps them.** There is no `Retention.mo` +entirely: there is no TTL, no band, no cursor. An order's deadline is its +session's `expires_at` (~35 min; Stripe's floor is 30), stored on the order as +`expiresAtNs`, and the only thing that moves it to `#expired` is Stripe's +`checkout.session.expired`. + +| Status | Payable? | Moved there by | Record | +|---|---|---|---| +| `#created` | yes, until its own `expiresAtNs` | `create_order` | kept forever | +| `#expired` | **no** | `checkout.session.expired`, or a failed session creation | kept forever | +| `#cancelled` | **no** | the buyer, via `cancel_order` | kept forever | + +⚠️ **A missed expiry event leaves the order visibly `#created` past its deadline, +and that is the design.** A sweep as a backstop was specified and rejected: it +would flip the order while its reserve promise stayed held, so a broken order +would look like a correctly expired one and the reserve would leak silently. The +stuck order **is** the detection signal. + +**Expiry is terminal.** There is no `#expired → #paid` edge, so `Card.handleWebhook` +admits `#created` alone — the only guard, now that there is no +`attach_payment` and its sibling guard. Expiry deletes nothing, but it is a real +deadline, and it frees the buyer's open-order slot. + +**The "we lost track" worry, resolved by not losing track.** A session dies at +its own `expires_at`, but Stripe can still deliver a late `completed` for one +that was paid just before it, and an event can be resent from the Dashboard years +later. Because the record is still there, that payment is +**attributable** — the gateway can say whose it was and which order it names. It +is therefore **refunded**, not delivered: an `#unattributed` obligation is +filed carrying the payment intent, and a `charge.refunded` resolves it. + +That is a deliberate narrowing, and the alternative is what it buys: honouring a late +payment at the locked quantity for the life of the canister would mean an order that can +be paid long after the buyer, the operator and the rate have all moved on. What is kept +instead is the half that matters — **the record is never deleted, so a late payment is +never a mystery charge.** The window itself is narrow: the session and the order die +together, so a session that can still be paid always belongs to an order that can still +accept it. + +Deleting records past a horizon — even with a tombstone set, so a late payment +could at least be *diagnosed* before being refunded — would contradict the +standard applied everywhere else here: unresolved obligations are never evicted, +and money facts live on permanent records. It would also create orphans, +`paidIntents` entries pointing at records that no longer exist. + +### A buyer can give up on an unpaid order + +`cancel_order(id)` is owner-scoped and marks a `#created` order **`#cancelled`** — +its own status, so a reload does not tell a buyer who cancelled that +their order expired. It is idempotent, and it is refused for a paid order: that one +is going to deliver, and offering a cancel would promise something untrue. + +⚠️ **It exists because the open-order cap counts unpaid orders.** The gate's +refusal tells the user to pay or abandon one, and `abandon_order` is admin-only — +so without this, someone who opened the cap's worth of checkouts and finished none +would be locked out until their sessions expired, reading advice they could not +follow. + +⚠️ **A payment racing a cancellation is refunded, not converted.** `#cancelled → +#paid` is absent from the transition matrix, and that absence is the guarantee. +So a payment already in flight when the buyer clicked cancel does **not** deliver: +it lands as a refund obligation carrying the payment intent, which a Stripe refund +resolves. The buyer's decision wins, and their money is recorded and refundable +rather than silently kept or converted against it. + +An `#expired` order is not payable, so an in-flight payment does not deliver +against it. The window itself is closed: `cancel_order` expires the +Stripe session **first**, and marks the order only if that succeeded, so the race +stops being possible rather than merely recorded. The audit trail carries +`order.cancelled` either way. + +**Growth is bounded at its source, which is why no sweep was ever needed to bound +it.** `Gate.maxOpenOrdersPerPrincipal` (default 20) bounds the records a user can +create for free — abandoned orders are the only ones that cost them nothing — and +the reserve bounds legitimate volume — nobody buys more than it holds. An order is a few hundred bytes, so a +million is a few hundred MB, and a million orders is millions of dollars of +volume. If store size ever genuinely binds, archival to a separate canister +preserves the record; deletion does not. + +## 11. Refunds, disputes, and what is not automated + +**Refunds are always manual, in the Stripe Dashboard.** No `sk_live` means the +canister cannot issue one. What the canister does is *react* to +`charge.refunded` (`Card.mo`). + +⚠️ **Only a *full* refund settles an obligation.** Stripe fires `charge.refunded` +for **any** refund, and the event carries a *charge*: `amount` is the charge total +and `amount_refunded` is the cumulative amount returned. A refund is complete only +when the second reaches the first (`Card.isFullRefund`). + +A partial refund therefore leaves the entry **open** and audits +`stripe.refundPartial`. Reading it as settled would auto-resolve a $500 +obligation on a $5 courtesy refund, and the unrefunded $495 would exist nowhere +but the audit ring — which drops. `#refundAfterDelivery` likewise records +`refundedCents` and `fullRefund`, because a partial refund after delivery is a +partial loss and reconciliation happens by amount. + +⚠️ **An unattributed payment can now only be refunded** — including the ones where we +know exactly whose it is. `attach_payment`, which credited the order the buyer +meant, is gone along with the failure it existed for: the canister +sets `client_reference_id` through the API, so there is no URL parameter for a +buyer to strip or edit. + +That leaves the `#unattributed` variant named for a case it rarely covers. What +still reaches it is mostly **attributable but unpayable** — the entry names the +order and we refuse to credit it anyway: + +- the per-purchase ceiling was lowered under an existing order (needs nothing to + go wrong: the order still matches its own quote); +- `amount_total` is not the quoted amount, which means an account-level setting + is moving the total (§8); +- the order is `#cancelled` or `#expired`. + +The genuinely unattributable cases — no reference, a malformed one, one naming no +order, a non-USD session — are unreachable through this app. Reaching one means a +session created outside it, a reinstalled order store, or a bug. This is the one thing +the deletion makes *harder* rather than simpler, and it was decided knowing that: +a refund makes the customer start over, and a customer who paid and received +nothing may file a chargeback, which costs more than the refund. The trade is +accepted because the failure it answered is now unreachable by construction — so +if you see one, treat it as a bug to find rather than a queue to work. + +1. Resolve every unresolved refund-settleable entry carrying that `payment_intent`. This + closes the normal loop: duplicate/unattributed payment → operator refunds → + entry auto-resolves. +2. If nothing resolved, look the intent up in `paidIntents` (the + `payment_intent → orderId` index, written at `markPaid`): + - **order is `#delivered`** → queue `#refundAfterDelivery` and audit + `stripe.refundAfterDelivery`. Fiat went back to the payer and the cycles are + irreversibly gone. This is a **recorded loss**, not a recovery flow. + - **order is paid but not yet delivered** → audit + `stripe.refundBeforeDelivery`. Money-out may still be mid-flight, so this is + a race for the operator to inspect. + - **intent unknown** → audit `stripe.refundUnmatched`. Benign: operators may + refund payments that never queued. + +`#refundAfterDelivery` deliberately does **not** expose a `paymentRef` to +`resolveByPaymentRef` (`Orphans.paymentRefOf`), because the refund is what +created the entry — auto-resolving on it would close the loss the instant it was +recorded. Only a human closes that one. + +### Disputes and chargebacks are not handled on-chain + +`charge.dispute.created` is **not subscribed**, by decision. The canister cannot +claw back cycles already forwarded to the buyer's account, so no amount of +on-chain plumbing changes the outcome. Chargeback risk is managed where it can +be: **Stripe Radar rules and 3DS** on the Stripe side, and the **per-purchase +ceiling** plus the **reserve size** on ours. + +Note that the reserve size does *not* bound chargeback losses — the payments are +real and individually legitimate. Sizing the per-purchase ceiling is the lever +that limits exposure per fraudulent transaction. + +## 12. Every failure and its money position + +`RUNBOOK.md` §6 is the authoritative operator triage table. This is the +rail-specific summary: + +| Outcome | HTTP | Money position | Resolution | +|---|---|---|---| +| Secret not provisioned | 503 | nothing happened | provision; Stripe retries | +| Missing/bad signature, stale `t` | 400 | nothing happened | none — not from Stripe | +| Unparseable body | 400 | nothing happened | visible in the Dashboard's delivery log | +| Unhandled event type | 200 | nothing happened | audited `stripe.unhandledType` — an unsubscribed type arriving is a Dashboard-config surprise worth seeing | +| Verified but unprocessable (e.g. no `payment_intent`) | **200** | unknown — inspect in Stripe | queued `#unprocessable`; see below | +| `checkout.session.async_payment_succeeded` | 200 | **fiat in** | delivers exactly like `completed` | +| `checkout.session.async_payment_failed` | 200 | nothing happened | audited; the order stays payable | +| livemode mismatch | 200 | depends which way | audited `stripe.livemodeMismatch`; a *live* payment on a test-configured gateway is queued as an obligation | +| Redelivered `event.id` / `payment_intent` | 200 | already handled | none | +| `payment_status ≠ paid` | 200 | no money yet | audited `stripe.unpaidSession`; the request asks for card-only, so check the account for a delayed method enabled at that level | +| `#unattributed` | 200 | **fiat in, nothing delivered** | refund in Stripe — the only remedy. Includes an `amount_total` that is not the quoted one, which additionally means a Stripe setting is moving the total (§8) | +| `#duplicate` | 200 | **fiat in ×2, delivered ×1** | refund the second charge | +| `#deliveryStuck` | 200 | **uncertain** — see the stage | per-stage rules in RUNBOOK §6 | +| `#refundAfterDelivery` | 200 | **fiat out, cycles out** — a loss | reconcile; consider restricting the payer | + +**The buyer is never left waiting indefinitely.** A paid order that cannot progress +alerts the operator at 2 h, while the position is still fully recoverable, and +terminates at 72 h. ⚠️ **Both bounds are TIME, deliberately, and there is no retry +budget** — a replay of a journalled delivery intent is provably safe (byte-identical +args, the ledger deduplicates), so capping attempts would convert a recoverable state +into a manual one while a *count* bounds nothing an operator can reason about. + +The terminal escalation names the stage, and the stage is derived from the **delivery +journal** rather than the order status: the status says where the order stopped, +the journal says where the money is, and only the money position determines the +recovery. `RUNBOOK.md` §5 carries the full mapping. + +⚠️ The clocks are **per state**, not per order — each transition resets the age — +so total time to resolution can exceed 72 h even though no single state does. The +2 h alert is what an operator works against. + +### Why a verified event is never answered with a 4xx + +Once the MAC verifies, the event genuinely came from Stripe. If we then cannot +process it — a session with no `payment_intent`, reachable via a subscription-mode +link or a 100%-off promo code — parsing will fail **identically on every retry**, +for Stripe's full ~3-day horizon. Stripe warns about, and can **disable**, an +endpoint that keeps failing; a disabled endpoint then loses every *legitimate* +webhook after it. Trading a permanent outage for a refusal that can never succeed +is strictly worse, so these are acked 200 and queued as `#unprocessable`. + +Non-2xx is reserved for the two cases where retrying is exactly what we want: +input we cannot authenticate (400) and an unprovisioned secret (503). + +⚠️ `RUNBOOK.md`'s "nothing is lost while you fix it" holds for *transient* +failures. A permanent one used to be the dangerous case; it is now acked. + +### Async payment methods + +A session can offer delayed methods. Ours asks for `payment_method_types[]=card` +so it does not — but an account-level setting or a future Stripe default could +still produce one, and the handler is what makes that safe rather than a support +ticket. The sequence is: +`checkout.session.completed` with `payment_status != "paid"` (no money yet), then +`checkout.session.async_payment_succeeded` when it settles. + +Both parse into the same shape and run the same handler, so a delayed payment +delivers correctly. Handling only `completed` would mean fiat arriving with nothing +delivered and nothing on the worklist. The request pins card-only anyway — this +handles the case, it does not make it desirable. + +### Test-mode/live-mode confusion + +`set_expected_livemode(?Bool)` declares which Stripe world this gateway serves. +A test-mode signing secret pasted into a canister holding a funded reserve would +otherwise deliver real cycles for payments that never happened, and the secret is the +only thing separating the two. Unset by default so a sandbox works without +configuration; the go-live checklist sets it to `?true`. + +## 13. The two secrets + +There are **two**: the webhook **signing secret** (`whsec_…`) and the +Stripe **API key** (`rk_…`). Both are stored **plaintext in canister state, by +design**, through the same `Secret.mo` store. + +HMAC is symmetric, so for the signing secret *verify = forge*: anything that can +check a signature can forge one. Encrypting the stored blob would only move the +problem to a key the canister also needs at verify time, so it buys nothing. The +API key must be sent to Stripe on every session creation, so the same applies. + +⚠️ **What a leak of each one buys an attacker is different, and it is the +argument for the key's scope:** + +| Leaked | What it enables | +|---|---| +| Webhook signing secret | forge "paid" events → deliver cycles at operator expense. **Bounded by the reserve balance and nothing else**, detectable against Stripe's event log, recovered by rotation. | +| A restricted `rk_` key with Checkout Sessions = Write | create sessions that pay **us**, and read sessions. Annoying, not a loss. | +| An unrestricted `sk_` (**do not use one**) | refunds, payouts, customer data — the whole account. This is why the scope, not the storage, is the control that matters. | + +What actually protects it: + +| Layer | Status | +|---|---| +| **SEV-SNP confidential subnet** | The deployment target. Confidentiality rests on hardware and attestation, not on cryptography in the canister. | +| **Checkpoint / state-sync confidentiality** | **Confirmed confidential on the target subnet** (owner, 2026-09-11). This was the spec's "verify this hardest" item, because memory encryption alone does not cover state written to disk or synced between nodes -- had either path been in the clear, a plaintext secret would leak there and SEV would buy nothing. It is closed. | +| **Provisioning channel** | Known-exposed: the argument to `set_webhook_secret` / `set_stripe_api_key` transits the TLS-terminating boundary node as ordinary ingress. Treat the first set over any untrusted path as burned and rotate. | +| **Reserve size** | The always-on control, independent of SEV. A forger drains at most what the reserve holds, so it is sized to what a leak could cost. | + +Interface (`mixins/Secrets.mo`): + +- `set_webhook_secret(text)` — controller-only, traps otherwise. Rejects under + 16 bytes and leaves a working secret untouched on rejection, so a + fat-fingered rotation cannot brick the webhook. Pass the **whole `whsec_…` + string**, prefix included — that is the HMAC key. +- `set_stripe_api_key(text)` / `set_stripe_origin(text)` — same posture. The + origin is validated at set time: https, no query, no fragment. +- `webhook_secret_status()` / `stripe_api_key_status()` — `{isSet, generation, setAtNs}`. **There is no + read-back path, not even for controllers.** `generation` increments per + successful set, which is how ops confirms a rotation landed. + +If the signing secret leaks, an attacker can forge "paid" webhooks and drain +cycles at operator expense — bounded by the reserve balance, detectable by reconciling +against Stripe's event log (forged payments have no matching `payment_intent` +there), and recoverable by rotation. Customers and cardholder data are untouched +either way; with a restricted key, so is the Stripe account. See `RUNBOOK.md` §2 +for the leak procedure — **cap to 0 first**, then rotate. + +Rotation needs no dual-secret window here: while a rolled Stripe signing secret's +predecessor is still live, Stripe sends one `v1=` per active secret and +`Card.verify` accepts any single match, so swapping the stored blob at any point +during the overlap never drops a delivery. + +> This is a deliberate departure from `canister-security` pitfall 9 ("never store +> secrets in canister state"). The reasoning above is the justification; do not +> "fix" it without reading it. + +## 14. Operator surface + +Card-rail levers, all controller-gated (`Auth.mo`, flat allowlist, equal +privileges — any controller can do any of this): + + + +| Method | Purpose | +|---|---| +| `set_stripe_api_key` | provision / rotate the restricted `rk_` key (§13) | +| `set_stripe_origin` | where Stripe returns the buyer; https, no query, no fragment | +| `set_webhook_secret` | provision / rotate the signing secret (§13) | +| `webhook_secret_status` / `stripe_api_key_status` | confirm a rotation landed, without reading either secret back | +| `set_card_tiers` | register the preset amounts. An empty vector shows no tiles and does **not** disable the rail — the switch is both Stripe secrets | +| `set_gate_config` | open-order cap, own-cycles floor, per-purchase ceiling | +| `set_pricing_config` | fee formula, staleness window (capped at 1 h), delta bound, minimum rate sources, and the **simulation divisor**. Three divisor guards live here: it is accepted only while `expected_livemode` is exactly `?false`, it cannot CHANGE while any order is stored (reinstall to change it), and it is refused if it would scale the *smallest purchase this gateway sells* below ten times the cycles-ledger deposit fee | +| `refresh_rates` | force a rate tick now instead of waiting for the timer | +| `set_delivery_config` | the two delivery time bounds: alert-after (2 h) and max hold (72 h). Read them back with `lifecycle_config` | +| `orphans` / `resolve_orphan` | the operator worklist | +| `order_for_payment` | reconciliation: Stripe charge → order it funded | +| `add_admin` / `remove_admin` / `admins` | grant, revoke and list the CASES tier. ⚠️ Controller only, and controllers are not listed — they pass the admin guard without being granted, so an empty list does not mean nobody can act | +| `add_allowed_buyer` / `remove_allowed_buyer` / `allowed_buyers` | who may buy while this gateway accepts free Stripe **test** payments. ⚠️ Controller only: the list is the only bound on the *total* given away, where the divisor bounds only the per-order loss. **An empty list does not mean "everyone"** — with a funded reserve and test payments accepted it means the gateway refuses every buyer (`unboundedGiveaway`), because that combination is a cycles faucet. At `expected_livemode == ?true` the list has no effect at all | +| `delivery_journal` | money-out record for one order | +| `audit_log` / `audit_log_recent` | operational trail, **paginated** both ways: `audit_log` walks oldest-first (`afterSeq`), `audit_log_recent` newest-first (`beforeSeq`) for the console. ⚠️ `nextCursor` means the opposite in each. ⚠️ **Nothing drops, so there are no gaps in `seq`** — it is a never-reused ordering, not a drop detector | +| `abandon_order` | void an unpaid order, with the reason recorded in the audit trail | +| `record_delivered` | record that an escalated order's cycles DID reach the buyer, evidenced by the ledger block | +| `pending_deliveries` | every delivery with work outstanding right now, self-clearing — the live view the 2 h queue alert cannot give | +| `refresh_reserve` | observe the reserve balance now — **required after a top-up**, or the gateway sells nothing | +| `withdraw_reserve` | return the reserve to the caller. ⚠️ Controller only, and the **second destination class** for the one outflow — every other transfer goes to a buyer's own account for an order they paid for. Refused while **any** promise-holder exists, so nothing can be owed to a buyer. A **decommissioning** lever, not an incident one: during a forged-webhook drain the forged orders are open, so it refuses — see RUNBOOK's three-step evacuation | +| `recount_orders` | run the tally reconcile now instead of waiting for the daily one. ⚠️ **Not a stronger repair than the timer's** — same bounded pass, same one-directional rule, so a recount *below* the maintained tally is refused rather than adopted. No force flag, deliberately | +| `admin_order` / `admin_orders` | read any order, and list with filters + a cursor — the controller-side counterpart to the owner-scoped reads below | +| `admin_receipt` | one order's full receipt for any principal. An **update**, not a query, so the read is audited | +| `delayed_deliveries` | orders past the 2 h alert threshold, paginated — the worklist between "delivering normally" and "escalated" | +| `resolve_problem` | close one obligation on one order. ⚠️ Takes `(orderId, tag, paymentRef)` — a **triple**, because an order can carry several problems of one kind and an earlier version closed all of them at once. `tag` is a variant (`variant { duplicate }`), not text | +| `orphans_unresolved` | the open subset of the orphan list — payments that could not be attributed to any order | +| `expire_order` | release a stranded `#created` order's reserve capacity by hand, when Stripe's expiry event never arrived | +| `set_recovery_interval` | sweep cadence; bounded above at a quarter of the ledger's dedup window | +| `set_expected_livemode` | pin test-vs-live so a mismatched webhook is refused rather than honoured | + + + +⚠️ **This table is the whole admin surface, and there is deliberately no lever that +moves money.** No method mints, transfers on demand, refunds, or withdraws — funding +the reserve is `icp cycles transfer` from the operator's own identity, outside the +canister. `record_delivered` records a *fact about the ledger*, it does not send. + +⚠️ **"The whole surface" is now CHECKED, because it was wrong.** This table claimed +completeness while missing **11 of 29** admin methods — the order-problem and +admin-read surfaces, `expire_order`, and the two secret-status queries §13 tells you to call +to confirm a rotation. A list of plausible method names reads as complete, so nothing +short of comparing it against the interface could tell. `scripts/check-doc-surface.py` +runs in the gate and diffs the marked blocks here against the committed `.did` plus +the guards in `Main.mo` and `mixins/`. ⚠️ It compares **names only** — a stale *description* is still on a +human, which is how `recount_orders` kept describing a pass that no longer exists. + +⚠️ **`delivery_stats` is public and anonymous** — cumulative delivered orders, +cycles and USD, plus the rail's current refusal state, for a landing page that cannot ask +for a login. Nothing in it identifies a buyer, an order or a payment, and that is its +admission test: **do not add a most-recent-order or largest-purchase field**, each of which +re-identifies through timing or amount even with no name attached. Its `refusingNow` is the +same latch `refusal_counts` reports, reused rather than re-derived. + +Public queries (transparency is the product thesis): + + + +`can_purchase` · `card_tiers` · `cycles_status` · `delivery_stats` · `expected_livemode` · +`health` · +`admin_status` · `lifecycle_config` · `operator_summary` · `orphan_depth` · +`pricing_status` · `problem_depth` · +`quote_previews` · `recovery_status` · `refusal_counts` · `reserve_status` · +`stripe_origin` + + + +**Owner-scoped, not admin-scoped:** + + + +`get_order` · `list_orders` · `cancel_order` · `receipt` · `process_order` + + + +They answer only for `caller == order.owner` — not even a +controller can read someone else's receipt. (`process_order` is the one that also +accepts an admin: it is a safe-to-spam delivery kick, so the owner of a stuck order can +retry it themselves.) The receipt returns the paid amount, +the cycles-ledger block the delivery landed in, the cycles delivered, and both rate inputs +with their XRC quality signal, so a buyer can recompute +`netCents × xdrPermyriadPerIcp × 10¹² / usdPerIcpMicros` from canisters they +query themselves and confirm they were charged correctly. + +### Stripe setup + +**There are no Dashboard objects to create for this rail** — no Products, no +Prices, no Payment Links. The session carries inline `price_data`, so the amount +is pinned by the request rather than by a link's configuration. + +1. Create a **restricted API key** (`rk_...`) with **Checkout Sessions = Write** and + every other permission None, and provision it with `set_stripe_api_key`. Not an + `sk_`: §13 covers why the scope matters more than the storage. + ⚠️ **Write, not Read** — Stripe's permissions are per-resource and escalating, so + Write covers both the session the rail creates and the session the recovery sweep + retrieves to settle a stranded order. A read-only key cannot create sessions; + a key without the read leaves stranded capacity unreleasable. +2. `set_stripe_origin` — where Stripe returns the buyer, and the origin the + session's `success_url`/`cancel_url` are built from. +3. Create a webhook endpoint pointing at + `https://.icp.net/webhook/stripe`, subscribed to + **`checkout.session.completed`**, **`checkout.session.expired`**, + **`charge.refunded`**, **`charge.dispute.created`** and both + **`checkout.session.async_payment_succeeded`** / **`_failed`** -- six in all, + which is every type `Card.handleWebhook` dispatches on. The async pair cannot + fire while `createBody` pins `payment_method_types[]=card`; subscribing costs + nothing and covers the day that pin changes. + ⚠️ `checkout.session.expired` is not optional: it is the *only* thing that + expires an order and releases its reserve promise (§10). +4. Copy the endpoint's signing secret into `set_webhook_secret`. Provisioning the + key and this secret is what **opens** the rail, so do it last. +5. Register price tiles with `set_card_tiers`. ⚠️ **Not optional in practice**: the + canister accepts `Amount = variant { custom; tier }` and validates neither against + the tile list, but `renderTiers` returns early on an empty list and the custom-amount + tile is built *after* that return -- so a gateway with no tiles offers no way to buy + at all. `docs/OPERATE.md`'s Mode 3, step 4, carries the command. +6. **Fund the reserve** with `icp cycles transfer t -n ic`, then + `refresh_reserve` so the gate has an observation. Until it does, every order is + refused with `#reserveShort` — the reserve is the stock being sold, and nothing + in the canister can create it. +7. Optionally tune the delivery bounds with `set_delivery_config`; the defaults + (2 h alert, 72 h max hold) are the intended production values. +8. Record the account's **Stripe API version** and treat changing it as a code + change: webhook payload shapes follow the account default. + +## 15. Local development against a Stripe sandbox + +`scripts/stripe-dev.sh` automates this; the mechanics are below. + +📋 **For the go-live verification pass, follow `docs/SANDBOX-TESTPLAN.md`** — the +enumerated scenario list (signatures, attribution, amounts, dedup, refunds +including the partial-refund case, async methods, the frontend) plus what a green +run does *not* prove. + +The precondition that makes it work: `Http.pathOf` strips the query string +(`Http.mo`), so the local gateway's `?canisterId=…` parameter does not break +route matching. + +```sh +brew install stripe/stripe-cli/stripe # once +stripe login # once, pick a SANDBOX account + +icp network start -d +icp deploy +scripts/stripe-dev.sh # prints the forward URL + wires the secret +``` + +`stripe listen` prints a **signing secret for the forwarding session** +(`whsec_…`), which is what must go into `set_webhook_secret` — it is not the same +as the Dashboard endpoint's secret. + +```sh +stripe listen --forward-to "http://127.0.0.1:/webhook/stripe?canisterId=" +stripe trigger checkout.session.completed +``` + +This exercises the genuine path: real Stripe signatures, real event JSON, real +retry behaviour on non-2xx. + +⚠️ **Two setup steps this script does not do**, without which `create_order` fails: +top up the backend past the admission gate's own-cycles floor, and give the local CMC +a current rate (its seeded rate is stamped 2021 and only governance may change it). +Both are in `docs/SANDBOX-TESTPLAN.md`, which has a verified end-to-end walkthrough — +a local network *can* run the whole good path, including the real cycles-ledger +delivery, with local cycles and no mainnet. + +Two further caveats: + +- `stripe trigger` builds a *synthetic* session with **no + `client_reference_id`**, so it lands as `#unattributed` — which is + itself a useful test. To exercise the happy path, create a real order through + the app and pay the URL it returns (`order.stripeSessionUrl`) with test card + `4242 4242 4242 4242`. There is nothing to append: the canister already set the + reference on that session. +- The canister compares the signature timestamp against **its own clock**. A + local replica whose time has drifted more than 300 s from real time will + reject every live webhook with a 400. + +For deterministic coverage without Stripe in the loop, the PocketIC suite crafts +its own HMAC-signed payloads (`test/integration/src/harness.ts`) — that is the +go-live bar, and it does not need network access or an account. diff --git a/docs/TEST-COVERAGE.md b/docs/TEST-COVERAGE.md new file mode 100644 index 0000000..711fbfc --- /dev/null +++ b/docs/TEST-COVERAGE.md @@ -0,0 +1,346 @@ +# What is tested, how, and what is not + +One place to answer "is X covered?". Run everything with `scripts/test-all.sh`. + +## Running them + +`scripts/test-all.sh` runs the whole gate, fail-fast; `--fast` skips PocketIC. Two suites +need a one-time install, and one needs a specific kernel: + +```sh +npm --prefix test/browser ci # first run only +npx --prefix test/browser playwright install chromium # first run only +cd test/integration && npm ci # first run only +``` + +Per suite, when you want one rather than the gate: + +```sh +mops test # Motoko unit +npm --prefix src/frontend run test # frontend, vitest (pure + jsdom) +npm --prefix src/frontend run typecheck +npm --prefix test/browser test # Playwright, Chromium +npm --prefix test/integration test # PocketIC +``` + +⚠️ **`npm test`, never `npx vitest run`, in `test/integration`.** The `pretest` hook +fetches the sha256-pinned ledger wasms and rebuilds the backend; skipping it tests a +stale wasm and passes. + +⚠️ **PocketIC needs a 4 KiB-page kernel.** macOS and x86_64 Linux are fine; the replica +cannot run inside an arm64 Linux guest with 16 KiB pages (an Apple-Silicon Docker VM, for +instance). Node ≥ 20.11 and `mops` on `PATH`. + +## The automated suites + +Counts are deliberately absent: they drifted in three separate documents over one +week of work, and a wrong number is worse than no number. Run +`scripts/test-all.sh` for the live figures, and see `test/integration/README.md` +for the scenario map by id — ids are stable, counts are not. + +| Suite | What it covers | How | +|---|---|---| +| **Motoko unit** (`test/*.test.mo`) | pure logic: HMAC, the Stripe signature scheme, JSON parsing, fee/rate arithmetic, the §4 state machine, dedup, order-bound problems and the orphan list, every money position `Delivery.terminationFor` can report, `stageOf`'s resume decisions, and reserve solvency | `mops test`. No IC environment — every module takes its dependencies as a record (`Card.Deps`), which is why the whole ingestion path is unit-testable | +| **Frontend pure** (`format.test.ts`) | status mapping, cycle/USD formatting, the §3 pricing vector, slippage flooring, deposit-fee subtraction, receipt verification, every error-message mapping | `vitest` | +| **Frontend DOM** (`main.test.ts`) | the real `index.html` body in jsdom with a stubbed backend: tier estimates, fee split, the single route into the buy form, the destination it sends (read from the session, never the form), the acknowledge-then-confirm quote flow, cancel visibility, the receipt render, the view machine (including the poll's own arrival at `delivered`, under fake timers) | `vitest` + jsdom | +| **Browser** (`test/browser/*.spec.ts`) | what jsdom is structurally blind to: the cascade, layout, reachability, and — via committed screenshot baselines — paint. Runs against a production build served statically, with an unreachable gateway by default and a canned one where a spec needs answers | `npm --prefix test/browser test` (Playwright, Chromium) | +| **PocketIC** (`test/integration/src/*.spec.ts`) | end-to-end against the **real** ICP ledger, CMC and cycles ledger, plus a sha256-pinned XRC mock at the mainnet id | `npm --prefix test/integration test` | + +### What makes the PocketIC suite the real bar + +- **Real NNS wasms**, at their mainnet ids, deployed by `icpFeatures`. +- **Real HMAC-signed Stripe payloads**, signed by an independent Node + implementation — so it is not testing our signer against our verifier. +- **Time control**: the 2 h delay alert and the 72 h terminate bound are reachable + in seconds; the ledger's 24 h dedup window likewise. +- **HTTPS outcalls are PARKED, not performed**. Every `create_order` and + `cancel_order` blocks on one, and the suite answers it — which for the request + *shape* is better coverage than a live call, because the exact bytes the + canister built can be read back (scenario 63 does). Three things it cannot tell + you, and the third was measured rather than assumed: + - the real cycle cost; + - whether `max_response_bytes` is big enough for a real Stripe response; + - **whether the transform strips enough for consensus.** pic-js can answer with + one response per replica, which looks like a way to test this — a scenario + was written on that basis, then `Session.strip` was mutated to leak every + header and the whole suite still passed. The mock does not enforce consensus + the way a subnet does. That failure is first observable against real Stripe, + where it takes the rail down; `Session.classifyFailure` labels it so the audit + log points at the transform. +- **Failure injection**: the NNS canisters can be *stopped* (`stopNns`, + impersonating NNS root) to force real outages. The one that matters is the + **cycles ledger**, and stopping it is how scenarios 11, 33, 35, 47 and 80 hold an + order undelivered. It is the only injectable cause: delivery reads no + rate and asks no other canister, so nothing else can stall it. + ⚠️ Read balances **before** stopping and put the stop in `try`/`finally`: a throw + in between leaves the ledger stopped for the rest of the run. See the coupling + note in `test/integration/README.md`. +- **Real HTTP**: `pic.makeLive()` serves an actual gateway, so scenario 55 proves + the webhook route over genuine HTTP rather than a Candid call to + `http_request_update`. +- **Upgrade-mid-flight**: real stop→upgrade(EOP keep)→start inside the §5.1 + ambiguity windows, asserting exactly-one ledger debit. + +## Coverage by concern + +| Concern | Where | Note | +|---|---|---| +| Signature verification (rotation overlap, both-direction window, constant-time compare) | unit + PocketIC | externally pinned vectors | +| Attribution (claimed-not-trusted, owner mismatch, malformed, expired, cancelled) | unit + PocketIC | | +| Amount honouring (exact → delivers; any other amount → a refundable obligation, delivering nothing; ceiling; currency) | unit + PocketIC | the mismatch branch is mutation-checked: disabling the equality check fails the suite | +| Dedup / replay (event id, payment intent, post-prune resend, credited-elsewhere) | unit + PocketIC | | +| Refunds (full, partial, cumulative partials, after delivery, of an escalated order) | unit + PocketIC | | +| Async payment methods (settle, fail, out-of-order) | unit + PocketIC | | +| Pricing guards (plausibility, delta, source count, implied XDR/USD, staleness) | unit + PocketIC | every guard rejects in isolation | +| Money-out: **one transfer** out of the reserve, exactly-once, journal replay | PocketIC | incl. across real upgrades | +| Outages: the **cycles ledger** down — delivery retries, strands nothing, then delivers | PocketIC | 11, 33, 35, 47 | +| Escalation → the right money position and instruction | unit (all 8 arms) + PocketIC | see the gap below | +| Delay alerts, and **which** of the two terminal bounds fires | PocketIC | 47 (alert cleared on escalation), 80 (24 h dedup window), 35 (72 h max hold) | +| Buyer never stuck: cancel, expiry, late payment, quote pinning | PocketIC | | +| Frontend state machine and reactions | jsdom (`main.test.ts`) | real `index.html` body, stubbed backend | +| Frontend **rendering**: cascade, layout, reachability, paint | browser (`test/browser`) | screenshot baselines for paint; see below for what is still uncovered | + +## What is not covered, and why + +### 1. The frontend against a real backend, and real Internet Identity + +Two layers cover the frontend, and the split is deliberate. + +`main.test.ts` runs the **real `index.html` body** in jsdom with a stubbed backend, +so a renamed id fails the test. The stub is not a weakness: the backend's behaviour +is already proven by the PocketIC suite, and a stub is the only way to drive a +`#quoteChanged`, a delivered receipt, or a poll transition on demand. + +`test/browser` runs a **production build in Chromium**, because jsdom has neither a +cascade nor a layout and is therefore blind to a whole class of bug that has shipped +here twice: `el.hidden` reads `true` for an element a class selector is keeping on +screen. It covers reachability (can a person actually get from here to the next +step), and — through committed `toHaveScreenshot()` baselines — paint, which no +assertion reaches. The pulse in the hero figure painting *over* the step numbers was +found that way, after every visibility, opacity and font assertion passed. + +A **test-only fixture hook** (`src/frontend/src/fixtures.ts`) is what makes the +post-purchase surfaces reachable at all. It replaces the backend and nothing else, +so sign-in, routing, the view machine and the 3 s poll are the app's own code; it is +absent from a production build (`__FIXTURES__` is a `define`d literal, and +`scripts/test-all.sh` greps the shipping bundle to keep that checked rather than +claimed). Before it existed the delivered view could only be photographed by +injecting DOM state, which is how it shipped broken twice. + +**Both of those were done by hand on 2026-08-13** against a local network and a +Stripe sandbox, and no mainnet deploy was needed: real Internet Identity login (the +local II the network deploys), the deployed asset canister's real `ic_env` cookie, +the real hosted Checkout page, a genuinely signed webhook, and cycles credited to the +buyer's account. Twice, with no open obligations and nothing held. The figures are in +`docs/SANDBOX-TESTPLAN.md`. + +⚠️ **That run predates the current money-out path, so read it as evidence about the +browser and Stripe halves only.** It delivered by minting through the CMC against an +ICP float; money-out is now a single cycles-ledger transfer out of a funded reserve, +and the ICP machinery is deleted. What the run still evidences — sign-in, the hosted +Checkout page, a real signed webhook reaching the canister, the frontend's poll to a +delivered view — is untouched by that change. What it no longer evidences is delivery +itself. **A fresh manual run is owed before go-live**, and it is the reason +`docs/SANDBOX-TESTPLAN.md` still carries a procedure rather than only a record. + +**Three things that run did not close**, and no suite closes either: + +- **The CLI handoff.** `icp identity link web` was never run, so "the cycles are + reachable from the CLI" — the last step the product promises — is unproven. Group + H4. ⚠️ **Two of its five steps are unreachable by any suite here**, which is why no + amount of frontend testing closes it: the id.ai CLI-access switch is a setting in + another product, and the delegation the link command returns needs a real browser + sign-in. What the suites do cover is that the page renders the right commands, in + order, against the values it is itself showing. +- **Real Stripe payload capture.** 3 of 8 fixtures are committed; five integration + tests stay skipped until the rest are captured (group I). The suite prints + which are missing on every run rather than hiding it. +- **Refunds, async payment methods, disputes, and anything live-mode.** Groups E, F, + G, and a separate decision respectively. + +### 1b. Mutations that ARE caught — run, not assumed + +The rows below this one record gaps. These record the opposite, because "no test catches +it" is only meaningful next to a list of what does. Each was applied to the tree and the +suite run: + +| Mutation | Caught by | +|---|---| +| drop the `#paid` clause from `unsettledDelivery` (the escalation-freeze bug, which shipped into the branch once) | scenarios **73** and **76** — 76 by design, 73 because a frozen reconcile stops adopting a top-up | +| credit the floor back on `#delivered` — a **real** debit, i.e. breaking rule 2/3's asymmetry in the optimistic direction | an `afterEach` hook asserting `reserveFloor ≤ reserveBalance`, which fails at scenario **06** — the first delivery in the suite. ⚠️ **The history of this row is the useful part.** The bound was originally one assertion inside 73: enough to catch this mutation, and not enough to be the safety net it was described as, since it checked history up to 73 and nothing after. Widening it to three scenarios fixed three instances; the hook fixes the class, and moved the catch 60+ scenarios earlier — onto the scenario that *causes* the optimism rather than a distant one. Every scenario anyone adds is now born checkpointed | +| `openEntry` recording a hardcoded status instead of the order's | `test/cmc.test.mo`'s coupling test, added after the bug — and scenario **75**, which found it originally by asserting a set was non-empty | + +### 1c. ⚠️ The comment purge — the one change in this project with NO artifact + +Recorded here because this is the document whose job is saying what nothing checks, and +because a green gate on a large comment deletion is the most inviting false signal in the +repo. + +**Every other sweep in this project has a verifier.** A wrong tally fails a unit test; a +stale `.did` fails step 4; a doc claiming a method that does not exist fails step 5; a +`§N` with no section fails step 6. **Deleting a comment has none.** No compiler error, no +red test, and the diff shows what left but not what was *lost* — the reviewer sees the +removal and has nothing to compare it against. The gate passing on such a change proves +**nothing was broken, not that nothing was lost.** + +⚠️ **And the risk is not symmetric with leaving them.** Pass 1 found a 49-line block +analysing the interleavings of an `await` that no longer exists, which asserted *"the fix +is in `admitOrder`: the tally is snapshotted and the decision uses `max(snapshot, +live)`"* — `admitOrder` is synchronous and contains neither. A reader who checks wastes an +hour; a reader who trusts it reasons about the next change believing a protection exists +that does not. It also found a 12-line doc block that had drifted 200 lines onto the wrong +function. **At 50% comments nobody reads closely enough to catch either**, so volume is +what produced both. + +**The mitigation is procedural, in `AGENTS.md`, because no check can substitute:** + +| | rule | what it buys | +|---|---|---| +| 1 | **Name the mistake the comment prevents.** No nameable mistake, not a keeper | turns "feels useful" into a claim that can be disputed | +| 2 | **Never delete without a destination** — `docs/DESIGN.md` or a rule rewritten at the site | a removal with *no* destination is the only case a reviewer cannot check, so it is the category to scrutinise | +| 3 | **The commit message carries the mapping** — what moved where | the sole recoverable record if a judgement turns out wrong | + +⚠️ **State the glob wherever a count is recorded.** Every "backend" figure in this +series was `src/backend/*.mo`, which **excludes `src/backend/rails/`** — 1,509 lines and +642 comment lines that were not in the first four passes. The ratio happened to be +identical (45% either way), but the absolute numbers differ by ~1,500 lines and the next +person will compare against them. Same population-scope class as the sweeps themselves, +arriving one last time in the measurement of the cleanup. + +⚠️ **State the METHOD too, because two correct methods disagree.** The line counts in +this series used `text.split("\n")`, which yields a trailing empty element for a +newline-terminated file — so they read **one line higher per file** than `wc -l`. Measured: +23 files, delta exactly 23 (10,265 vs 10,242). Neither is wrong; they count different +things. One notch finer than "state the glob", and the same lesson. + +⚠️ **Check a measurement against a bound you already know.** A block-detector reported +"129 blocks of ≥18 lines" in a file with 1,800 comment lines — arithmetically impossible, +and caught by that multiplication rather than by re-reading the code. Correct figure: 20. +When a measurement produces a number, test it against something already known before +reporting it. + +⚠️ **Read pass rates with suspicion.** `Reserve.mo` (a small pure module, comments mostly +invariant arguments — the category that *stays*) and the first two `Main.mo` blocks (both +documenting deleted code) mislead in **opposite** directions. `Main.mo` alone has 53 +blocks of ≥12 comment lines; no rate should be projected from a handful. + +### 1d. ⚠️ Outcall CONSENSUS is not testable anywhere before mainnet + +Recorded because it was described in review as part of the manual sandbox run's residual, +and that is wrong in a way that changes where you look for it. + +**A local network is a single replica.** HTTPS-outcall consensus is agreement between the +replicas of a subnet on the transformed response — so with one replica there is nothing to +agree, and the property is **never exercised locally, by any run, manual or automated**. No +local procedure could ever have covered it, and a green local run is not weak evidence +about it; it is *no* evidence. + +PocketIC does not cover it either: it mocks the outcall, so the transform runs but no +consensus step exists. + +⚠️ **It is therefore first tested on the first mainnet outcall**, and the failure mode is +`No consensus could be reached. Replicas had different responses.` + +⚠️ **TWO mechanisms carry it, and this is the only place recording that before mainnet — so +name both, or someone reads the second as dedup-only and removes it.** + +1. **The transform strips every response header** (`rails/Session.mo`), which is the + documented source of per-replica variation in the *response*. +2. **`Idempotency-Key = orderId` on the session create** does double duty. Every replica + performs the outcall independently, so without the key each creates a **distinct + session with a distinct `id` and `url`** — one order spawns many sessions, and + consensus becomes unreachable. ⚠️ **No transform can repair that: a transform strips + variation, it cannot invent agreement.** Removing the key as "redundant with our own + dedup" would break consensus, not just dedup. + +Both are arguments, not test results, and should be read as such until a mainnet outcall +has succeeded. + +### 2. Structural limits in PocketIC — verified, not assumed + +| Not covered | Why | +|---|---| +| **the reserve decision pairing a stale balance with a live tally** | ⚠️ **Verified by mutation that nothing caught it — and then the design removed the defect rather than testing it.** With the awaited-balance design still in place, replacing `Reserve.promisedForDecision` (since deleted) by a live-only read left the entire suite green: catching it needed a delivery continuation scheduled inside `create_order`'s balance-read gap, and PocketIC gives no way to force that ordering. The decision is now synchronous against `reserveFloor` — a maintained lower bound moved only by our own outflows — so there are no two values to pair. Scenario 72 stays as a guard on the invariant (`promisedTotal ≤ balance`), and the row stays because it is the record of how the untestable bug was closed: by deleting the pairing, not by covering it | +| **the reserve floor adopting a balance across an in-flight outflow** | The floor's soundness rests on adoption happening only in a quiet window, and the window is established across an `await` — so reproducing the unsafe interleaving needs a transfer issued inside a reconcile's balance-read gap, which PocketIC cannot schedule on demand. What **is** covered: `test/reserve.test.mo` pins that a non-quiet observation is refused, and scenario 76 covers the failure this produced in practice — one escalated order making the window permanently unsatisfiable, which is the direction that actually shipped into the branch. ⚠️ **And the untestable direction is exactly where a second bug hid**: `Delivery.openEntry` wrote a hardcoded status instead of the order's, so the predicate matched nothing and the window was *always* satisfied. Nothing here caught it and scenario 76 passed vacuously — it was found by a test written for `pending_deliveries`, and the guard is now a unit test on the coupling (`test/delivery.test.mo`) plus comments at both ends. Read this row as: the interleaving is untestable, so the predicate's INPUTS have to be pinned instead | +| **the delivery replay sending the intent's ORIGINAL fee** | ⚠️ **Verified by mutation that nothing catches this.** Re-reading `icrc1_fee()` on the replay path passes every Motoko assertion and the whole PocketIC suite: the unit tests pin the arithmetic (`locked - amount` recovers the fee), and the integration suite runs against a real cycles ledger whose fee has never moved. Catching it needs a fee change *inside* the 24 h dedup window. It matters because if the ledger's dedup key includes the fee, a replay after a fee change is a distinct transaction and the buyer is paid twice — so the code comment is the guard, and this row exists so its absence is not mistaken for coverage | +| **the stored ledger fee PERSISTING after `#BadFee`** | Staging it needs the stored fee to differ from the ledger's, and there is no `set_cycles_ledger_fee` lever to make them differ — deliberately, since the only state such a lever fixes is one it can create, and a typo in it shorts buyers — the PocketIC cycles ledger's fee never moves. ⚠️ **Deliberate trade**: shipping an admin money lever to production so a test can stage a state is worse than the gap. The gap is one line (`cyclesLedgerFee := expected`) whose failure is **loud** — the fee does not stick, so `delivery.feeChanged` fires on every delivery instead of once, which RUNBOOK §8 carries as a P3 row. The ledger's report is still unit-pinned (`interpretTransfer(#Err(#BadFee))`), and scenario 74 was deleted with the lever rather than left asserting a mechanism it could no longer reach | +| a **trapping** daily reconcile | the reconcile is detached into its own message precisely so a trap cannot stop the sweep, but nothing can inject that trap: it would take an order store large enough to exhaust the instruction limit. What *is* covered is that the detached message runs, commits, and is cadence-gated in both directions (scenario 58); the isolation itself rests on the message boundary, not on a test | +| `stageOf`'s `#escalate` arm wiring | **No longer a gap — scenario 80 covers it**, and the row stays to record why it was one. It cited a retry budget of 2,000 sweeps as the thing that made the arm impractical to reach; there is no retry budget, and the arm's reachable reason is `staleIntent` at ~24 h, which `advanceTime` reaches in one line. The gap was a stale premise, not a real ceiling. ⚠️ **Two bounds terminate a stuck delivery and 35/47 reach only the rarer one**: both jump ~80 h at once, so no sweep lands in the 24 h–72 h window where the dedup bound fires. 80 sweeps at 25 h and asserts the stage is `staleIntent`, which is what pins the cause the runbook triages by | + +### 3. The deployment layer — covered separately by `scripts/e2e-local.sh` + +The PocketIC suite installs wasms directly and **never runs `icp deploy`**, so +nothing it does exercises the deploy pipeline. `scripts/e2e-local.sh` covers that +against a real local network (`icp network start`), and is not part of +`scripts/test-all.sh` because it needs one running: + +| What only this can prove | Why the PocketIC suite cannot | +|---|---| +| the deploy pipeline: recipes, `shrink: true`, embedded `candid:service`, the Candid compatibility check | it installs wasms directly | +| the **`PUBLIC_CANISTER_ID:xrc` override branch** | in PocketIC the mock sits *at* the mainnet id, so no env var is injected and only the fallback branch ever runs | +| the asset canister and its `ic_env` cookie (`ic_root_key` + the canister ids) | there is no asset canister and no HTTP gateway serving it | +| local Internet Identity responding | `ii: true` is a network feature, not a canister one | +| `icp.yaml` environment scoping — that `ic` lists only `backend` and `frontend`, keeping the mock off mainnet structurally | it is a config fact, not a canister behaviour | + +It also reports two conditions loudly rather than papering over them, because both +look identical from inside a deploy and only one is safe: a **breaking Candid +change** against the deployed canister, and a **stable-shape change** whose upgrade +traps in `register_stable_type` (EOP refusing to reinterpret existing memory). +Locally it retries with `--yes` and `--mode reinstall`; on mainnet those are a +migration expression and a deliberate decision. + +Two gotchas it encodes, both found by running it: + +- The **xrc mock keeps its canned response in heap and sets it from `init_args` at + install time**, so a routine `icp deploy` upgrades it and every later rate call + traps with "Response has not been set". The script reinstalls it first. +- **`rates` stays null until the local gateway is seeded.** Caching a pair needs a + fresh CMC rate as well as the XRC, and the CMC's rate is settable only by NNS + governance. `scripts/local-dev-seed.sh` does that through the PocketIC control + API (see docs/SANDBOX-TESTPLAN.md), so local orders **can** be priced — the smoke + test just does not depend on it, because that control port is not a supported + `icp` interface. + +### 4. Things only a real Stripe account can show + +Covered by `docs/SANDBOX-TESTPLAN.md`, not by any suite: + +- **The real wire format.** Every payload in the repo is hand-crafted JSON written + from the API docs. The plan's fixture-capture step converts that into recorded + reality — until then, the suites prove the canister matches *our reading* of + Stripe. +- Completing a **hosted Checkout page** (no headless path exists). +- Live-mode behaviour: Radar, 3DS, payouts, account restrictions. +- **Disputes.** Only `charge.refunded` is subscribed, so a chargeback produces no + on-chain signal at all — accepted and documented, managed in the Dashboard. + +### 5. No coverage measurement + +There is no instrumentation — no `c8`/istanbul for TypeScript, nothing for Motoko. +The tables above are a qualitative map, deliberately: an unmeasured percentage +would be worse than an honest inventory. If a number is ever wanted, `c8` on the +frontend would be the cheapest place to start, and it would immediately report +`main.ts` at roughly zero. + +## Continuous integration + +`.github/workflows/mops-test.yml` runs three jobs, all **verified green** on +`ubuntu-latest`: + +- **motoko** — lint, unit suites, and that the committed `.did` is current +- **frontend** — build (regenerates bindings) → typecheck → tests +- **integration** — typecheck + the PocketIC suite + +Triggers: `push` on `main`/`master`, `pull_request`, and `workflow_dispatch`. Note +that a push to a **feature branch matches none of the first two** — open a PR or +dispatch it manually. + +The `integration` job needs a 4 KiB-page host, which `ubuntu-latest` satisfies (the +IC replica hard-asserts 4096-byte pages). + +⚠️ **`npm ci` mismatches in `test/integration` cannot be reproduced on macOS.** The +first run of this job failed on Linux-only optional dependencies (`@emnapi/*`, pulled +in through vitest's rolldown wasm fallback) that a macOS resolve never records — so a +local `npm ci` passes while CI fails. If that job fails on install, delete +`test/integration/package-lock.json` and `node_modules` and resolve fresh; an +incremental `npm install` only narrows the gap. CI is the only test for it. diff --git a/docs/VERIFY.md b/docs/VERIFY.md new file mode 100644 index 0000000..ffea619 --- /dev/null +++ b/docs/VERIFY.md @@ -0,0 +1,162 @@ +# Verify it yourself + +The gateway is live in **simulation mode** on mainnet — , +frontend `shy4u-4qaaa-aaaay-aadhq-cai`, backend `saz2a-riaaa-aaaay-aadha-cai`. + +Two separate questions, with different answers. + +## "Is the page I am using built from this repo?" — yes, check it + +The canister publishes a **state hash**: one SHA-256 over every asset it serves — each +one's bytes in every encoding, its `content_type` and its response headers — plus the +redirect rules in match order. Compute the same number from your own build and compare. +Equal means the canister serves exactly that build, down to the CSP. + +You need `icp`, Node, and a Rust toolchain. The Rust part is not avoidable and is the +same for both routes below: the hash is defined by the certified-assets project's own +preparation code, so computing it means running that project's verifier, which is built +once from source and then cached. + +```bash +git clone --recurse-submodules https://github.com/marc0olo/cyclepay && cd cyclepay +git checkout vX.Y.Z # the release the deployment published +npm --prefix src/frontend ci && npm --prefix src/frontend run build +scripts/check-frontend-hash.py -e ic +``` + +That script only arranges the steps: it reads the pinned certified-assets release out of +`icp.yaml`, builds the matching verifier, refuses to compare unless the canister reports +running that release, and diffs the two numbers. Run the steps yourself instead if you +would rather execute none of our code — it is the same verifier either way: + +```bash +cargo install --git https://github.com/dfinity/certified-assets \ + --tag v0.3.3 --locked state-hash-cli # the release icp.yaml pins +state-hash src/frontend/dist +icp canister call shy4u-4qaaa-aaaay-aadhq-cai state_hash '()' -n ic -o hex | tail -c 65 +``` + +The two numbers must match. No result is quoted on this page: run it, and the answer is +as fresh as your terminal. + +⚠️ **Check out the tag, not `main`.** The deployment is a release and `main` moves on +after it, so a `main` build reports a mismatch that means nothing. One changed HTML +comment is enough to change the hash. + +⚠️ **Build the verifier, never download one.** A hash is worth only as much as the thing +that computed it, so both routes build it from a tag of the certified-assets source. +`--locked` is part of that: without it `cargo install` re-resolves dependencies, and a +newer `brotli` patch emits different bytes for the same input, which changes the hash. + +⚠️ **Do not check the frontend's module hash instead.** The `@dfinity/static-site` recipe +installs a pre-built certified-assets wasm, so that hash describes the recipe, not the +page. The page lives in canister state. + +## "Is the backend module built from this repo?" — compare it to a release + +Every release publishes the module hashes its container build produced. Rebuild the tag +yourself and compare three things — your build, the release notes, and the canister: + +```bash +git checkout vX.Y.Z +scripts/reproducible-build.sh vX.Y.Z # writes release/MODULE-HASHES.txt +icp canister status saz2a-riaaa-aaaay-aadha-cai -n ic -p +gh release view vX.Y.Z # the published hashes +``` + +All three must agree, including the `# build arch:` line — the same commit produces +different bytes on different platforms, so a hash without its architecture cannot be +compared. [`RELEASE.md`](../RELEASE.md) is the procedure that keeps them in step: it +installs the artifact the container built rather than rebuilding on the host, and fails +unless the canister reports the hash it built. + +**Status: the running module is `v0.1.0-beta.1`'s published build.** It was installed +from the container artifact whose hashes that release publishes, and the install gated on +the canister reporting them — so the three comparisons above agree, and you can re-check +that yourself with the commands rather than take it from this page. No hash is restated +here: a figure written into prose goes stale, and those commands read the live ones. + +## What anyone can check right now + +No identity, no permissions, nothing installed but `icp`. + +**The price you are shown is produced by the code that locks it.** `quote_previews` is a +public query running the same pricing path as order creation, and it returns both rate +inputs so the arithmetic is yours to redo: + +```bash +icp canister call backend quote_previews '(vec { 1_000 : nat })' -e ic +``` + +**Pricing crosses no trust boundary.** Both rates come from canisters — the Exchange Rate +Canister for USD/ICP, the CMC for XDR/ICP — read on a timer. There are exactly three +HTTPS outcalls in the system and all three go to Stripe (create, expire and retrieve a +Checkout Session). See [`docs/ARCHITECTURE.md`](./ARCHITECTURE.md). + +**The operational state is public**, so solvency is checkable against the cycles ledger +without this canister's cooperation: + +```bash +icp canister call backend reserve_status '()' -e ic # floor, promised, available +icp canister call backend pricing_status '()' -e ic # both rates, the divisor +icp canister call backend lifecycle_config '()' -e ic # the gate's bounds +icp canister call backend cycles_status '()' -e ic +icp canister call backend recovery_status '()' -e ic +icp canister call backend health '()' -e ic +``` + +**The interface cannot drift from the source.** `mops build` regenerates the committed +`src/backend/dist/backend.did`; a gate step fails on drift; and the recipe embeds *that +file* as the canister's `candid:service` metadata. + +**Which means the running canister names its own commit.** The interface it publishes is a +file in this repo, so you can find which tree it was built from without any published +hash — including on a deployment that was never released: + +```bash +icp canister metadata backend candid:service -e ic > /tmp/deployed.did +git show :src/backend/dist/backend.did | diff - /tmp/deployed.did +``` + +An exact match (bar one trailing newline the metadata fetch adds) means the deployed +interface is that ref's. It narrows the commit rather than proving the bytes — two commits +that do not touch the interface share it — but it needs nothing published and no +cooperation from us. + +**The reserve floor is enforced by an omission in a type.** `src/backend/Delivery.mo` +declares the cycles-ledger interface the canister may call, and `icrc2_approve` and the +ledger's `withdraw` are absent — so they cannot be called, which is what makes the floor +a valid lower bound. `scripts/test-all.sh` fails on a declaration that widens it. + +**Frontend responses are certified per response**, on top of the state-hash check above: +each carries `IC-Certificate` over the asset tree and the gateway rejects a response whose +certificate does not verify. There is no uncertified raw mode to switch off. + +**Every suite is in the repo and one command runs them all**: `scripts/test-all.sh`, with +[`docs/TEST-COVERAGE.md`](./TEST-COVERAGE.md) stating what is *not* covered and why. + +## What a buyer can check that a visitor cannot + +`receipt(orderId)` is **owner-scoped** (`caller == order.owner`), so it is a buyer's +affordance. It returns both rate inputs and the delivery block index, so a buyer can +recompute their own price and confirm the transfer on the cycles ledger independently. + +## The limits, in the same breath + +- **The published hash is reproducible on one architecture.** `backend.wasm` depends on + it, so the build is pinned to `--platform linux/amd64` and the release records the + architecture it ran on — `x86_64`, as `uname -m` reports it inside that container. + Rebuilding elsewhere gives different bytes and proves nothing. +- **The hashes are published by us.** The reproducible build is what makes that not + require trust — anyone can produce the same bytes — but nobody else counter-signs them. +- **Any single controller can upgrade and drain.** IC controllers are OR-semantics; the + hardening path is a multisig canister as sole controller. See + [`docs/SANDBOX-TESTPLAN.md`](./SANDBOX-TESTPLAN.md). +- **The webhook secret is plaintext canister state**, protected by the confidential + subnet rather than by cryptography. HMAC is symmetric, so a canister that can verify + can also forge; encrypting it would only move the problem to a key the canister must + also hold. Checkpoint and state-sync confidentiality are confirmed on the target + subnet; **attestation coverage of every replica is not** — `RUNBOOK.md`'s + confidential-subnet checklist carries it. +- **A purchase requires an allow-listed principal** while test payments are accepted, so + a visitor cannot exercise the buying path end to end on the live gateway. diff --git a/docs/agents/deleted-vocabulary.md b/docs/agents/deleted-vocabulary.md new file mode 100644 index 0000000..975ea66 --- /dev/null +++ b/docs/agents/deleted-vocabulary.md @@ -0,0 +1,132 @@ +# Deleted-mechanism vocabulary — the sweep list + +**When you delete a mechanism, add its vocabulary here. When you sweep, start from this +file.** + +⚠️ **This exists because the failure is *reconstructed from memory each sweep*, not +*nobody is checking*.** Three consecutive sweeps in this repo were run with a term list +assembled from the mechanisms someone happened to remember naming, and a reviewer found +the population incomplete each time. The clearest case: `ring`, `capacity`, `float`, +`treasury` and `burn cap` were swept, and **`evict`** — the *verb* for what the deleted +mechanism did — was not. Five survivors, including a second dangling `AddResult.evicted` +**118 lines from the one that had just been fixed**, and an operator-facing RUNBOOK line +stating a cap that no longer exists as a parameter. + +This is `AGENTS.md`'s population rule applied one level up. That rule says *a rule +introduced to fix specific instances is not applied until it has been run against the full +population once* — and a sweep scoped to remembered terms is that rule failing at the level +of **terms** rather than **sites**. + +## What to add, when you delete something + +Three kinds of term, because missing any one of them has already cost a sweep: + +1. **The noun** — what the thing was called (`ring`, `treasury`). +2. ⚠️ **The verb** — what it *did* (`evict`, `mint`, `reprice`). This is the one that gets + forgotten, and prose cites verbs more often than nouns. +3. ⚠️ **The names of deleted variants and fields** (`#deliveryDelayed`, + `AddResult.evicted`). These hide in tables and doc comments that a code-shaped grep + never reads — two of them were **live operator triage rows** for entries that can never + appear. + +## Why it scans the DIFF, prints, and does not fail on a hit + +**A fixed target can be enforced; a judgement call can only be surfaced.** Most hits are +*correct*: + +- `mint` is legitimate: the CMC is literally the Cycles **Minting** Canister. +- `retention` is legitimate: `Idempotency.mo` has real dedup-key retention. +- *"Nothing is ever evicted (#37)"* is legitimate — a statement of the end state. + +So the step prints its hits and exits zero. A check that fires on correct code teaches +people to ignore it, and this project has rejected that trade before: widening a lint +pattern flagged a script's deliberate interpolation, and it was reverted. + +⚠️ **What it DOES fail on is an undeterminable base ref**, because that is the didn't-run +case. A scan that silently had nothing to scan reads exactly like a clean one. + +⚠️ **An empty diff is a pass, not an abort** — unlike every other gate step, where empty +input means the step is aimed at nothing. Here it means the change added no lines, which +is a true answer to the question asked. + +⚠️ **Scoped to added lines, and keeping no counts, for two reasons that are not +convenience.** A count cannot distinguish "removed two legitimate uses, added one stale +claim" from "removed one" — offsetting moves net out, so a prose-purging change that also +introduces a stale claim reads clean. And a count needs a population, so it needs globs, +and a glob list goes stale in silence. Added lines have neither hole: every added line in +the tree is in scope, wherever it lives, and a stale claim is a stale claim regardless of +what else the change did. + +## ⚠️ Three row types, and they are cleared differently + +A future sweeper treating every row the same will clear the second kind by **rewording** +when the check it needs is **whether anything now calls it**. + +1. **Dead vocabulary** (most rows). The term should appear only in end-state or historical + statements. Clear a new hit by fixing the prose. +2. ⚠️ **A tripwire on a LIVE name** — `icrc1_fee`. The concept still exists; what must + never happen is a *call*. Its absence from the ledger's service type is the guard behind + the fee-derivation rule (§5.1), the one rule here **no test can catch**. Clear a new hit + only by confirming it is still not a call. +3. ⚠️ **A live name colliding with a deleted one** — `#abandoned` is a live order + **status**; only the queue *kind* of that name went. A bare count for this row is + actively misleading, and high is normal. + +## Known-collision terms + +⚠️ **These four are live names, and their hits are almost always correct.** The scan prints +them in a separate section BELOW its main list, because the value of a print-only step is +entirely a reader's willingness to look at it — and a real hit buried among three +predictable ones is something to triage rather than something to see. + +Measured on #86: of four hits, three were these and the fourth was the defect. + +- `\bmint` — the CMC is the Cycles **Minting** Canister, a live dependency. +- `\bretention\b` — `Idempotency.mo` prunes dedup keys on a real retention window. +- `#abandoned` — a live order **status**; only the deleted queue *kind* shared the name. +- `icrc1_fee` — a live ledger method. Row type 2: the hit is fine, a *call* is not. + +⚠️ **A term listed here is de-emphasised, never skipped.** If this section is renamed or +emptied, every term prints in the main list — the failure direction is more prominence, +not less. + +## Running it + +`scripts/sweep-vocabulary.py` scans the lines your change adds, against the terms below, +and prints what it finds with the disposition alongside. It is the LAST gate step — placed +there deliberately, so its output sits immediately above the summary, where it gets read — +so a normal run reports it for you. Run it directly with `--base ` to scan against +something other than the default branch. + +⚠️ **The script excludes THIS file.** Every term appears here by construction, so +including it would report the question as the answer. That is not an exemption — it is the +difference between scanning the corpus and scanning the list. + +⚠️ **A disposition is durable; a count was not.** These rows say what the word meant, that +the thing is gone, and what makes a new use wrong. That stays true as the code moves. The +numbers that used to sit here did not, and nothing ran to catch them expiring. + +| term | disposition | +|---|---| +| `ring buffer` | AGENTS.md, describing this very failure class | +| `\bring\b` | all end-state statements — "#37 removed the ring", "the capacity parameter and the eviction loop are gone" | +| `4,?096` | all stating the ring is gone; one is PocketIC's 4096-**byte page** requirement, unrelated | +| `\bfloat\b` | all "there is no float" statements | +| `treasury` | AGENTS.md, listing what was deleted | +| `burn cap` | DESIGN.md §7 contrasts the old cap against today's reserve-sizing argument; the others list it as deleted | +| `ck-?USDC` | AGENTS.md scope statement | +| `\bmint` | ⚠️ **all legitimate** — the Cycles **Minting** Canister is a live dependency, and `Cmc.mo` says "nothing here mints, and nothing may" | +| `\bretention\b` | ⚠️ **all legitimate** — `Idempotency.mo` prunes dedup keys on a real retention window | +| `\bevict` | all end-state statements after the #77 follow-up sweep | +| `error.?queue` | historical references to a *status* split (#34) plus RUNBOOK's account of it; the structure references are cleared | +| `Payment Link` | all "#33 deleted this" statements | +| `attach_payment` | all "#33 deleted this" statements | +| `Retention.mo` | both naming it as deleted | +| `icrc1_fee` | ⚠️ **all "no longer awaits" / "never await" — none calls it.** Its absence from the ledger's service type is a load-bearing guard (§5.1); if a hit ever becomes a call, that guard is gone | +| `delayedAlerts` | all "not the `delayedAlerts` map returning" prohibitions | +| `#deliveryDelayed` | naming what replaced it (`delayed_deliveries`). ⚠️ Was a **live triage row** in RUNBOOK §6 until #77's follow-up | +| `#abandoned` | ⚠️ **legitimate — this is a live order STATUS.** Only the deleted *queue kind* of the same name was removed, which is why a bare count is not evidence here | +| `#icpAtCmc` | clean | +| `AwaitingTreasury` | clean | +| `promisedForDecision` | TEST-COVERAGE, recording the defect its deletion closed | +| `order_stats` | clean | \ No newline at end of file diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md index cce77ec..01700e7 100644 --- a/docs/agents/issue-tracker.md +++ b/docs/agents/issue-tracker.md @@ -1,17 +1,44 @@ # Issue tracker: GitHub -Issues and PRDs for this repo live as GitHub issues. Use the `gh` CLI for all operations. +**GitHub Issues is the single source of truth for task and progress tracking.** +`PRD.md` was deleted 2026-09-09 (frozen June, and describing an architecture #33/#35/#36 +removed). The M1 scope lives in issue #1. + +Use **`gh-axi`** for issue and PR operations; it is the wrapper this project expects, and +its output is compact enough to read without `jq` gymnastics. Plain `gh api` is the escape +hatch when you need a raw field. The repo is inferred from `git remote -v`. ## Conventions -- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies. -- **Read an issue**: `gh issue view --comments`, filtering comments by `jq` and also fetching labels. -- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters. -- **Comment on an issue**: `gh issue comment --body "..."` -- **Apply / remove labels**: `gh issue edit --add-label "..."` / `--remove-label "..."` -- **Close**: `gh issue close --comment "..."` +- **Create**: `gh-axi issue create --title "..." --body-file ` +- **Read**: `gh-axi issue view --comments` (add `--full` for a long body — the + default truncates, and a truncated body reads exactly like a short one) +- **List**: `gh-axi issue list --state open` (`--label`, `--state`, `--limit`) +- **Comment**: `gh-axi issue comment --body-file ` +- **Labels**: `gh-axi issue edit --add-label ...` / `--remove-label ...` +- **Close**: `gh-axi issue close --reason completed --comment "..."` + +⚠️ **Pass bodies as `--body-file`, not `--body "..."`.** A multi-line body written inline +goes through the shell, and an unquoted heredoc containing backticks **executes them** — +that destroyed #12's body once (53k characters replaced by a mangled 101k). `scripts/` +carries a gate step (`check-heredocs.sh`) that refuses the unquoted form. + +## ⚠️ Rewriting an existing issue BODY goes through the script + +```bash +scripts/issue-body.py get 12 /tmp/i12.md # fetch, verified against the API's own count +# ...edit /tmp/i12.md... +scripts/issue-body.py put 12 /tmp/i12.md # write, then RE-FETCH to prove it stored +scripts/issue-body.py diff 12 /tmp/i12.md # is the remote still what this file says? +``` + +`put` verifies by reading back, because GitHub accepting the request is **not** evidence +the stored body is right: the corrupting write was accepted cleanly. Every guard exits +non-zero, and the abort is the feature. -Infer the repo from `git remote -v` — `gh` does this automatically when run inside a clone. +**Adding a comment needs none of this** — it is additive and cannot destroy a body. Prefer +a comment whenever the content is additive; reach for a body rewrite only when the issue's +own statement of the problem has changed. ## When a skill says "publish to the issue tracker" @@ -19,4 +46,4 @@ Create a GitHub issue. ## When a skill says "fetch the relevant ticket" -Run `gh issue view --comments`. +`gh-axi issue view --comments --full`. diff --git a/icp.yaml b/icp.yaml index 39fa4ce..0cc627a 100644 --- a/icp.yaml +++ b/icp.yaml @@ -1,21 +1,77 @@ canisters: - name: backend recipe: - type: "@dfinity/motoko@v4.1.0" + # v5 delegates compilation to `mops build`, so `main` and `candid` moved to + # mops.toml `[canisters.backend]` — this recipe takes only metadata/shrink/compress. + # The canister name here must match the `[canisters]` key there. + type: "@dfinity/motoko@v5.1.0" configuration: - main: src/backend/Main.mo - # The committed interface (spec §8) doubles as the embedded - # candid:service metadata — what `mops build` regenerates, the - # frontend bindgens from, and the deployed canister reports are - # provably the same bytes. - candid: src/backend/dist/backend.did # ic-wasm deterministic optimize (spec §8). shrink: true + # DFINITY's published XRC mock, deployed locally so `create_order` can price. + # A local network has no Exchange Rate Canister, so without this the gateway + # correctly refuses every order with `rateUnavailable`. + # + # `icp deploy` stamps `PUBLIC_CANISTER_ID:xrc` into every canister's settings, and + # the backend reads it lazily (see Xrc.canisterIdEnvVar), defaulting to the real + # mainnet XRC when it is absent. + # + # It cannot reach mainnet: the `ic` environment below lists only backend and + # frontend, so `icp deploy -e ic` never creates or installs this canister and + # never injects its id. `pricing_status.xrcCanisterId` is the belt-and-braces + # check — on mainnet it must read uf6dk-hyaaa-aaaaq-qaaaq-cai. + - name: xrc + recipe: + type: "@dfinity/prebuilt@v2.1.0" + configuration: + # Fetched and hash-verified by test/integration's pretest + # (scripts/fetch-wasms.mjs) — the same binary and the same pin the PocketIC + # suite installs, so local and CI price off identical code. Run + # `npm --prefix test/integration run fetch:wasm` if it is missing. + path: test/integration/wasm/xrc_mock.wasm.gz + sha256: 037d1137e587fd4086311242d5b8ec9cae50eeba29e98cbcb661ae11e9658d11 + # The mock's response is fixed at INSTALL time (it traps without an init arg), + # so this is the rate every local order prices from. $4.55/ICP at 9 decimals — + # the same vector the PocketIC suite pins, so local and CI agree. To simulate a + # different rate, edit this and `icp deploy --mode reinstall xrc`. + # + # ⚠️ It keeps the response in heap only, so a routine `icp deploy` **upgrades** + # this canister and the response is lost — every later rate call then traps with + # "Response has not been set" and the backend reports a fetch failure that looks + # like a bug in the rate path. Reinstall it after any deploy that touched it; + # `scripts/e2e-local.sh` does that unconditionally. + init_args: + value: >- + (record { response = variant { ExchangeRate = record { + base_asset = null; + quote_asset = null; + metadata = opt record { + decimals = 9 : nat32; + base_asset_num_received_rates = 5 : nat64; + base_asset_num_queried_sources = 6 : nat64; + quote_asset_num_received_rates = 5 : nat64; + quote_asset_num_queried_sources = 6 : nat64; + standard_deviation = 0 : nat64; + forex_timestamp = null; + }; + rate = 4_550_000_000 : nat64; + } } }) + - name: frontend recipe: - # v2.2.1+ required: icp-cli 0.3.x dropped the `assets` sync step the - # v2.1.0 recipe emitted (plugin-based sync replaced it). - type: "@dfinity/asset-canister@v2.2.1" + # The certified-assets canister, the recommended path for a frontend. + # + # ⚠️ **Response certification is NOT the gain — both canisters certify.** + # Measured by redeploying the legacy recipe: it served `IC-Certificate` + # too. What actually changes: no raw-access mode to switch off (the legacy + # canister needed `allow_raw_access: false` for that), automatic clean-URL + # canonicalisation, `_headers`/`_redirects` instead of `.ic-assets.json5`, + # and built-in access protection. + # + # ⚠️ Configured by `_headers` / `_redirects` in `dir`, NOT `.ic-assets.json5`, + # which this canister does not read — and would not even upload, since the + # sync plugin skips dotfiles. + type: "@dfinity/static-site@v0.3.3" configuration: dir: src/frontend/dist build: @@ -25,5 +81,32 @@ canisters: networks: - name: local mode: managed + # Deploys the II canisters with the network, so sign-in works fully offline and + # can be automated. The default (no flag) is to use mainnet II even locally — + # pocket-ic trusts mainnet subnet signatures — but that needs the real II UI and + # a real passkey, which a headless test cannot drive. + ii: true gateway: - port: 0 # OS picks a free port — avoids collisions across worktrees/projects + # Fixed rather than OS-picked, so every local URL is predictable: the app at + # http://frontend.local.localhost:8000, Internet Identity at + # http://id.ai.localhost:8000, and the webhook route at + # http://127.0.0.1:8000/webhook/stripe?canisterId=. A browser test can + # hardcode those instead of discovering them, and the II skill's documented + # URL works verbatim. + # + # Trade-off accepted: two projects cannot run local networks at once. Stop the + # other one (`icp network stop`) rather than changing this — predictability is + # worth more here than parallel networks. + port: 8000 + +environments: + # Mainnet deploys the product and nothing else. Listing the canisters explicitly + # is what keeps the local-only `xrc` mock off mainnet structurally, rather than + # relying on whoever runs the deploy remembering not to: an unlisted canister is + # never created, never installed, and its `PUBLIC_CANISTER_ID:` is never injected, + # so the backend falls back to the real Exchange Rate Canister. + # + # The RUNBOOK's `-e ic` commands resolve against this. + - name: ic + network: ic + canisters: [backend, frontend] diff --git a/mops.lock b/mops.lock index dc17122..a823ce3 100644 --- a/mops.lock +++ b/mops.lock @@ -1,147 +1,247 @@ { "version": 3, - "mopsTomlDepsHash": "90561807e204018fde58fca4a3ac1a9eb6f8d9736ba1a1bbd8cc134b3c46933c", + "mopsTomlDepsHash": "7a063cb98749a977b63b09f87b878ee464f6aecd603d875033450d491f5efd5e", + "localDepsHash": "b9761d43453b62517a2c0779f580818788ee682a07464d5261d9f447dafa2f2e", "deps": { - "core": "2.5.0", - "sha2": "0.2.1", - "ic": "4.0.0", + "base": "0.14.5", + "core": "2.6.1", + "ic": "4.2.0", + "ic-bls12-381": "./vendor/icp-seeding-secrets-poc/motoko/bls12-381", + "iterext": "https://github.com/timohanke/motoko-iterext#v2.0.0", + "sealed-secrets-vetkeys": "./vendor/icp-seeding-secrets-poc/motoko/vetkeys", + "sha2": "0.2.5", + "sha3": "0.1.1", "test": "2.1.2" }, + "graph": { + "base@0.14.5": {}, + "base@0.8.2": {}, + "core@2.0.0": {}, + "core@2.5.0": {}, + "core@2.6.1": {}, + "ic@4.2.0": { + "core": "2.5.0" + }, + "sha2@0.2.5": { + "core": "2.5.0" + }, + "sha3@0.1.1": { + "base": "0.8.2", + "iterext": "https://github.com/timohanke/motoko-iterext#v2.0.0" + }, + "test@2.1.2": { + "core": "2.0.0" + } + }, + "github": { + "iterext": { + "resolved": "6c05ff069e00eabdd2fc700d7457878d72dafaa9", + "hash": "9c0ddd55b0932d27a29add2c1e2f0a1a3abc5561aeaf21babefc065ccb191d0a" + } + }, "hashes": { - "core@2.5.0": { - "core@2.5.0/src/Blob.mo": "25d7c07e9786f11ceb8611a32a83b8491769580024054086e4548607a67afbe4", - "core@2.5.0/LICENSE": "840e3d57a38a8061f55d04470fbd58b9345326fa04ea10ee42add5c6e3b2aa08", - "core@2.5.0/README.md": "9fce230bdc675d2493d65493c295ffce47bda7b56a80d23a389ca95d117dcb39", - "core@2.5.0/NOTICE": "d62ca4ef9e2c9df064115c339013593cf0769e2b9dc78fa4b3211222541c7f83", - "core@2.5.0/mops.toml": "5d34e80a4636956c22bedeeb1c00de33eb1ebfc32a2c2bdd260ce5045fce84ef", - "core@2.5.0/src/Array.mo": "7400fd0e6d2550ddb6d01833f67a69109c617032ac445275aabc7c7d188caf8d", - "core@2.5.0/src/Base64.mo": "34bedbad18db06770823e86668a5968893124bfa6d60d8df6b8d9a9a8e15e441", - "core@2.5.0/src/Bool.mo": "3b606a6631f28c774e3055bc860201e56260fd32fdc5e8dc00d901506be95db5", - "core@2.5.0/src/Error.mo": "2112280ea5300ae94ff9188c3bbd75a7db87f0c6eb65c2ac5bf04bc27c851d98", - "core@2.5.0/src/CallerAttributes.mo": "7ac5ea306f7e4393a7830a1dd522583d3388b8dd4667db132ee77ba9ae131f66", - "core@2.5.0/src/Debug.mo": "526c3f9d94e4a682917febb7b0021831a7cc345a2aa63e2499ddeda33cf63b3e", - "core@2.5.0/src/Cycles.mo": "9f5c1fceea47f05c43f22a1c25e8753fb7e552e36106d27bceef24b1310f4cbc", - "core@2.5.0/src/Char.mo": "ecc2550a6c70ccbb204d216bd18a422d2fcfbb4668a89ac49aab059972902041", - "core@2.5.0/src/Float.mo": "d8b0afa56911ce8da99e26c8b0f2f149cfdea04da864805ee1af44b67ce047b7", - "core@2.5.0/src/CertifiedData.mo": "5af3182d77ab7aab2384d1f663a8cd992bbd6652aacff29c443314cf2186328f", - "core@2.5.0/src/Int32.mo": "41d42155e753ef9fc1301c8823f255134bf2202d4a74ef33e7b7b54a8fc53af4", - "core@2.5.0/src/Int16.mo": "78c72aafbc039e2c0becc824caa4181158206759d6859e83077db360bc8568c3", - "core@2.5.0/src/Float32.mo": "8716c0504acb010c2f358a22896f917835377ebf16e4977e1776a26dae1d615c", - "core@2.5.0/src/Func.mo": "535991ccca8d551d48129a0b7df03ea7bd6ebf3d74b1b572259d10ce709d7d46", - "core@2.5.0/src/Int.mo": "03886964690ff75ecd4170decd67653c517dadc65d9957fc644d03e9c1cf9abb", - "core@2.5.0/src/InternetComputer.mo": "969951411beb5caf7d60ba6d5e906a5c70a3b466cee42e1135d1cd3d791cb3a1", - "core@2.5.0/src/Int64.mo": "f1fda85af9f20b47bcbfda6eaef08c6eee11cab6e5fe3eadb2d9b1c2bcef2e65", - "core@2.5.0/src/Int8.mo": "361af007cfc333937c6bb8e238304db1a7f07a310ec3321a85b89d627f16990f", - "core@2.5.0/src/Map.mo": "71c1dd738868479cc9770470706f6f222ac9d7b4a2743f59b33b16b31fd98ac7", - "core@2.5.0/src/List.mo": "263c61ad591bc94f5adfc27517122ce060b9f50425b52e4e01c2d0ed1ced029d", - "core@2.5.0/src/Nat16.mo": "2b04622ab82be11147130dce8968195c0d4a09b37de8f57d16580673ca780f78", - "core@2.5.0/src/Nat64.mo": "b7ea9f45a35f82c81771c1c36d99d73fdbd9e21f5b0c10e832685f7327246c5e", - "core@2.5.0/src/Nat32.mo": "e3630af6551ae2a80f3809870f55d137c07f68092940c8d9255395504e4e81c8", - "core@2.5.0/src/Iter.mo": "3627fbf52a392bfa76c6d6803c584b1556add077d0ad214d55802e17acf4d773", - "core@2.5.0/src/Nat.mo": "78dab3e33310b766ee9066095a3e6acb1884a8dac32ae9a7cd560637d1e4ea4a", - "core@2.5.0/src/Nat8.mo": "e45a6ae2a55ee6ff890d556dc289816b1bdf352d52d4b3ce54e57ae795deffc2", - "core@2.5.0/src/Option.mo": "aeec94cd6954e7b8b5463a5b15abddfb56c5322cb9b9e7ac782092d5c33a8b38", - "core@2.5.0/src/Region.mo": "741a6fae4f61e42fa0a938b00646515edfc96527341a3a2c3936d436c973798b", - "core@2.5.0/src/Result.mo": "0932722ab0187524c3c11539ccf2a1b9453438a9f3a042dde94e45d9c56a32c5", - "core@2.5.0/src/Principal.mo": "2246c14f55c5bc6968d97b551d9083522bb7a35bfac6f10f4dbcaa7ba53fbb3f", - "core@2.5.0/src/Random.mo": "f5d48c30297b579a068af8cd8c6f4f986605e492b1b65424329a5171d1480e97", - "core@2.5.0/src/Order.mo": "6c21e905e1e8c2b40cd50438218230c207d0bbec0d2514779edd76f9f84a812c", - "core@2.5.0/src/PriorityQueue.mo": "37b9906d1d5a61e520027fd2e48a77facb28ddc760d8129447d6902e037ef7ce", - "core@2.5.0/src/Queue.mo": "807ae8b4bdcaef6e37c57b65a93a04c0c5ed72f77e315e9916f5a25f62ce424f", - "core@2.5.0/src/Runtime.mo": "d221bd16b45cb717cb4ef4fd698fa8632a415aecbbb5a6fdc8b5cd4bc50a9e56", - "core@2.5.0/src/Stack.mo": "ed3c98fae4164ab4f934752f9adc2a92098af8f6d9fc71b16a54867cdb8787f8", - "core@2.5.0/src/Text.mo": "c23d3f2970234a9553d33aaed7907684d16b220e2cea95ebce02b25c3215e787", - "core@2.5.0/src/Types.mo": "00dc0132996b3bb3eaf5cc4413be78ca860137ef26f5b472b0e7b3ef68f6652f", - "core@2.5.0/src/Tuples.mo": "0156c1fc21fffedd71d173e0d1b1e7a9a19726cbc49ec1cf49029d6d93bc7955", - "core@2.5.0/src/Timer.mo": "389c247eed600fc8195ad1d9872f98fcc92faad96335c9734cf5265d573612e2", - "core@2.5.0/src/Set.mo": "d64fed0989da152d0a4baa81719bef548ad3f461367d5d434d14e98cb18281f0", - "core@2.5.0/src/Time.mo": "fcffd56cbeb21c3d8613973f3f1ea382babcf122ea93c9689149f0101c5f3e96", - "core@2.5.0/src/WeakReference.mo": "06e58a6d67bf67c4b31ff871416d0cf6d6e34bfb409ce73d83372ef6b74de3cf", - "core@2.5.0/src/VarArray.mo": "289e68fe216caa39d7d4829b73ccc03ed6e5e2e1d07079e3baa83b55651d1dd8", - "core@2.5.0/src/internal/BTreeHelper.mo": "20a475baf18f924ee0297fc234469fa0985cdaa31c85ce002b5a921383b0534d", - "core@2.5.0/src/pure/List.mo": "b4fb45cf75410461fb1501a316d2384d1be63fee1fccf11ed1d29677e5e2393c", - "core@2.5.0/src/internal/PRNG.mo": "8a93e87baa2b12f613c82394a4a05b425e44a695bbace4ca36f9a1f78be26442", - "core@2.5.0/src/internal/SortHelper.mo": "fff4f2eec5c59a3db13e46eeb69152bb1b98931f45cb58ac481439a0cd0d10bb", - "core@2.5.0/src/pure/Map.mo": "e9868fc4dd5e8e6272382e33241c59f8664e3de2e618cbf62fa8b20beabecfab", - "core@2.5.0/src/pure/Queue.mo": "ed0bd0968457ea3bb24c5367f0dbdb159cfe950d0e2666224985e57a784fae3b", - "core@2.5.0/src/pure/RealTimeQueue.mo": "af133ae61c69ce4e00db5e9db53ddeccad84373fef82a156c85360c2426349dd", - "core@2.5.0/src/pure/Set.mo": "f0155b1d548cbc8889fcff75ef52f0cab7998b034621e4faac8d3b79d7bbac07" + "base@0.14.5": { + "base@0.14.5/LICENSE": "166bd8e8cf7790087d1fd18a9fa4d060cc0d0b3e5ab30689aa5f3a59a93386bf", + "base@0.14.5/NOTICE": "3960a8d25fa5fc909325817b08b36c1146970930ca15b6352f8ea6db803cab47", + "base@0.14.5/README.md": "f45244a47229456939321014b550e3360b4f8c4d0fd60aa60bbba7e9eacbc8de", + "base@0.14.5/mops.toml": "59ea83dffe62422ab986848a78aed520b41f0a2352695bc65344c856138198b9", + "base@0.14.5/src/Array.mo": "b8b182ec7522daf79160a00e34a7ea558369c32c2b67a6ed339fa2e56ce60835", + "base@0.14.5/src/AssocList.mo": "07a92db12c36ba96baf3fb45ac76662407ca5d6cee7aedb01fa8ed6d9ee98cf4", + "base@0.14.5/src/Blob.mo": "9d4b10667080778bca66cb534f1e0a1477e5247a970170f4ba19e7c58cc4939d", + "base@0.14.5/src/Bool.mo": "65faf01dc6e3609ed2f729b813811506951adcce71c081fc6edcdbae4c8b1169", + "base@0.14.5/src/Buffer.mo": "7ad75dec25ea37e964c536d6bb86ed90cc48047feedb57c9ded359bea49045f7", + "base@0.14.5/src/CertifiedData.mo": "51a205989529f6b4fe788f78be0de2da1cb96f6532aac8accccbf4421cce3d06", + "base@0.14.5/src/Char.mo": "dc34cfe3482b92134e5604629377b4236367f2f31d8a82b81084ca5cadf31c2e", + "base@0.14.5/src/Debug.mo": "b1c97f53827eb8f5476c663fba8d4ec54a69980c15f7b9c74335d9bf8be0a973", + "base@0.14.5/src/Deque.mo": "39da68fb8f19fb497aa3db769ffe2cb740cab54e6d82264052de505edac9b17b", + "base@0.14.5/src/Error.mo": "a5045ea30d25ac7d731a18ddae6a60fd28790971881dfe6e8560f212d95df614", + "base@0.14.5/src/ExperimentalCycles.mo": "baec79c136832746e2719f7d045926c445e88ff1a87a98fb7ad367554fd5e665", + "base@0.14.5/src/ExperimentalInternetComputer.mo": "00ffec3506f3bd66a3ed72e70d2c98e24c0d98be2750d35b8109e00d3aee453d", + "base@0.14.5/src/ExperimentalStableMemory.mo": "70330ae4d113e15d1d211c8ea229bc0a6fc6c014f5fece58aea0de90c82e4fff", + "base@0.14.5/src/Float.mo": "36d61a5e1ca108ae28a168d507c6d99b7e5fc11be600f687ea6bcd7fe3db84fe", + "base@0.14.5/src/Func.mo": "bea8bcb92707a255814e0a7d79c238e490860c38001438612948704027f05bec", + "base@0.14.5/src/Hash.mo": "99d7add2ed6e82cf482593ac85edd231d507ef542608cd42fec1cc6d764906e9", + "base@0.14.5/src/HashMap.mo": "639c3377687ef59c22ac8df7ef5b26cf6c9689a356ea1ac9fbe215819db59adf", + "base@0.14.5/src/Heap.mo": "517b746ca16d80b9b7cf3fc6c2883854d4cb240bd494a888fb65ac02441f769b", + "base@0.14.5/src/Int.mo": "3dc2fe075ca553d92cb8178f3216a591233376ffb591d42a8f08bd86c7b5cea6", + "base@0.14.5/src/Int16.mo": "586617af6cef687bd05cd29cd0c785b366ad5ad2b871151f909de76671b755c3", + "base@0.14.5/src/Int32.mo": "bcb4775ef68e0b2b5b872771abcda8eaef4e0a126562b0877e91b3a1ebaaffd6", + "base@0.14.5/src/Int64.mo": "7cfa15c6a8b777fb5f67e841da3d0960a5ef43c66d46acdf354e8a7dfac57d2d", + "base@0.14.5/src/Int8.mo": "dfdaf280fe2a1b7524cf82116ccdeea5c70cb27046b8addd3c3d9f387b17f594", + "base@0.14.5/src/Iter.mo": "140291f243dfcfdbedb54a264cce76b7481eeb8767224a41fa9951beaf067b67", + "base@0.14.5/src/IterType.mo": "1b6361f1b7ee81d1974719c8464c8c574aaee992c37c3c7b6c39f107a58af01c", + "base@0.14.5/src/List.mo": "d13e77eb05b4edcca55e7cc5ee7ce5018c5a137a7a67708e8eb54967c38673fb", + "base@0.14.5/src/Nat.mo": "d2d24f2b42365b3a54c8bb3cf467b94d1ec7f3271a7d0f671986c04a39a8b844", + "base@0.14.5/src/Nat16.mo": "42483fe26ec90fcfc5d90e82df945ea128d10145dada4ee8f68ee85e62f17841", + "base@0.14.5/src/Nat32.mo": "7919cf0598707ae3628d70caa3bb44c04e3bd940752b85481f4f071cde89b66a", + "base@0.14.5/src/Nat64.mo": "dce2861f9674d6301fb2b98475460e85b3d245537c52e3c7a90fac257e5c96f0", + "base@0.14.5/src/Nat8.mo": "b9266d0b5e284288beeca990963cb7a8c9492d69771c656d0501507a613802ba", + "base@0.14.5/src/None.mo": "b204a4964519117e6fa8ee82da5653376988cf618fc397205dfc8ea58f60d2d1", + "base@0.14.5/src/Option.mo": "72c9ddc183b6248375e0f96efd33881a130a465f97e6a3a553ffe8f16bb5fba7", + "base@0.14.5/src/Order.mo": "d8c76128271612469d052520f10af726556df78688729f9856b62ea292e0f43f", + "base@0.14.5/src/OrderedMap.mo": "d743491a4dca60fb446144617a1e813019ec1c9c345281144bc5103061fb2eb0", + "base@0.14.5/src/OrderedSet.mo": "44d4fd4e3b9587124996daebf52ec81cb83a350d02b56f1ab097f1ad3fef0e41", + "base@0.14.5/src/Prelude.mo": "7b4ea84cb683203c68d6135b524ca5587a1b23436d6733e0aaf723d36be4aa85", + "base@0.14.5/src/Principal.mo": "5e50eba952b7de62450264a2ece0af6edadd0b6dd4c8cfd2e432ca66022f70ab", + "base@0.14.5/src/RBTree.mo": "3e5766dbdc21e358f407718bd9a210af5a5dfb7a10843fe0e09b005fd3f4a07a", + "base@0.14.5/src/Random.mo": "15f6fd50d10a9eb401c3f18f19874fb334fec309e94499a46a0a876037dca266", + "base@0.14.5/src/Region.mo": "6ca3019aed15ad442a3a794000ac245b2bcbd846033966e446dc1a29986dbf0b", + "base@0.14.5/src/Result.mo": "4aa00198ee36ea3b60519f9c350e1c17b14b6ffa005b6d4edcff36a0a206f3af", + "base@0.14.5/src/Stack.mo": "4b2681e9ed562b02840b7a46b58e66b695a725f31b94f6c9952e95e81b703b84", + "base@0.14.5/src/Text.mo": "d21beb556b69c6a21bb2ba87830b132c4f46ec2f97abb2c34187198dc5a58818", + "base@0.14.5/src/Time.mo": "4e2a6126d0a2b0b862735adbde93620a70cb388af25a87c7b853650abd0743ab", + "base@0.14.5/src/Timer.mo": "e6302739637537d10fd84b6d0c7d79ac85f791d62d0e61090f9b93be8b8b8ced", + "base@0.14.5/src/Trie.mo": "63c0fbe6d48808630c7edf51d66140b8b2a43a734e425ab854234126e242cb07", + "base@0.14.5/src/TrieMap.mo": "6d69d887006cd1dff09331473f258141bccb251a04a3a8221b65720a4bf237ac", + "base@0.14.5/src/TrieSet.mo": "29b3314f43fad714fc7b68cc3b0dde00a7d8b02b6431afd7fe12304dc14f9612" + }, + "core@2.6.1": { + "core@2.6.1/LICENSE": "840e3d57a38a8061f55d04470fbd58b9345326fa04ea10ee42add5c6e3b2aa08", + "core@2.6.1/NOTICE": "d62ca4ef9e2c9df064115c339013593cf0769e2b9dc78fa4b3211222541c7f83", + "core@2.6.1/README.md": "c5e314d907a91e02cb18c86966bae402e6143329510b2a38b665df3b3dbaca61", + "core@2.6.1/mops.toml": "63ffb60fb927b3c31fe28df6ed9c958f7d0a2b644f12f7173c210f43c268569d", + "core@2.6.1/src/Array.mo": "59e4440bc7ed2988d9ff0be1ec091cd4c994ce9d713e282f88a57d295598338a", + "core@2.6.1/src/Base64.mo": "0d70a221a21a1bfb7ad79da30271f6e1f892a4148f8c844975b108c2e052ea3f", + "core@2.6.1/src/Blob.mo": "f5085a7be3a9ed3e9bf11cbf8001146c9586acc45a7ee13513c5e8f97cad561b", + "core@2.6.1/src/Bool.mo": "3b606a6631f28c774e3055bc860201e56260fd32fdc5e8dc00d901506be95db5", + "core@2.6.1/src/CallerAttributes.mo": "7ac5ea306f7e4393a7830a1dd522583d3388b8dd4667db132ee77ba9ae131f66", + "core@2.6.1/src/CertifiedData.mo": "87fa7b145507d63aab702030bc797f9c5c45ec796b84bc573d70448ceabaec9a", + "core@2.6.1/src/Char.mo": "954caba4a5e2e714912e9a6981f4fea08865780c98797b36333cff6b0c68f220", + "core@2.6.1/src/Cycles.mo": "9f5c1fceea47f05c43f22a1c25e8753fb7e552e36106d27bceef24b1310f4cbc", + "core@2.6.1/src/Debug.mo": "526c3f9d94e4a682917febb7b0021831a7cc345a2aa63e2499ddeda33cf63b3e", + "core@2.6.1/src/Error.mo": "2112280ea5300ae94ff9188c3bbd75a7db87f0c6eb65c2ac5bf04bc27c851d98", + "core@2.6.1/src/Float.mo": "5cedfd9b42f79d6a4c5d984dd895542b70b1b23afc704486595d6247d81e2438", + "core@2.6.1/src/Float32.mo": "f18868603519d69289e049367f12fda160480dc44bc917b911b29a8cf84cef50", + "core@2.6.1/src/Func.mo": "30b6c3d6c7938a4ab968c8916f9e66e6dd4d3f209b4f2f967f2d6a7b61e3380c", + "core@2.6.1/src/Int.mo": "2233228b585ece0af237b3b8ce9f8c4cc65014bae733192a8cff69c37df35e2c", + "core@2.6.1/src/Int16.mo": "a6b33d80f900e8f94bf317727fb6d4777191d0304430cd9e08af34ada00ca339", + "core@2.6.1/src/Int32.mo": "06a69a5fdb5224b5f6184bd35c1d1bd756fc197527ca9262bd19ea3b304ebb7e", + "core@2.6.1/src/Int64.mo": "e3c3a0355f3c0ea3c8c70d9bc6e300b19a318a71ae0e475cfe4012d0b41d0fce", + "core@2.6.1/src/Int8.mo": "e29df82bf43aca0c657c8c1c6e77c72605cf64b774a42f267369f9001b1620b1", + "core@2.6.1/src/InternetComputer.mo": "969951411beb5caf7d60ba6d5e906a5c70a3b466cee42e1135d1cd3d791cb3a1", + "core@2.6.1/src/Iter.mo": "463f3b12d0d462ba55e84b77cffa8a1d76f04695144f966338aaccb4960dedc5", + "core@2.6.1/src/List.mo": "c84280433ed0df65b18f28d56a37e64a465a128e8d596903ecb16347837e1f9c", + "core@2.6.1/src/Map.mo": "125a036b4fffb32a99830df565ae4d095dc02d2c0e0f8a26205872a65bf628b8", + "core@2.6.1/src/Nat.mo": "e28b0dcbc6cb5f532e386124a8384ab84d5f524134267615c336fb70f186d8cb", + "core@2.6.1/src/Nat16.mo": "ff3682d1b233721065d9157e4587e8cbd100b882033621d01d1b47cb67ce68fa", + "core@2.6.1/src/Nat32.mo": "6886e8148867a10fa58f94b31cd6de84322cc274cc06b758ddc1784445648259", + "core@2.6.1/src/Nat64.mo": "fc295f4fb2542b8777e81e7b7fc6f1624a3ee67b52db5c756664fe59a93cfdb7", + "core@2.6.1/src/Nat8.mo": "c08a40665121fd2b26764bc1cde3ac19ee075d4416a0091ddae446d539ee94a9", + "core@2.6.1/src/Option.mo": "aeec94cd6954e7b8b5463a5b15abddfb56c5322cb9b9e7ac782092d5c33a8b38", + "core@2.6.1/src/Order.mo": "6c21e905e1e8c2b40cd50438218230c207d0bbec0d2514779edd76f9f84a812c", + "core@2.6.1/src/Principal.mo": "181f4c5f5b6cc8c65d202490a1c7d771f9a462ae1c9fb13cb8915e5feb9f5c71", + "core@2.6.1/src/PriorityQueue.mo": "e8c02d636e669fbf4ce3dc3b3e1a8d3003ef6d15e989035423e84aa2571cc4b9", + "core@2.6.1/src/Queue.mo": "52a4276d862e4c26c3f41c09ffe07803c3740118dea3a84c8f086827b65b5399", + "core@2.6.1/src/Random.mo": "b60f8a72134bab5f527da3c929e63f4ade612ad0799fba576c218f73cbdfad88", + "core@2.6.1/src/Region.mo": "23ab562d605c80fae3ca10f49df2fc1201d558eee01adf7ddd8d3ada60d7eca9", + "core@2.6.1/src/Result.mo": "fbf78ffb539258703fa529ec622111cd5962e2aa4d76943bc99e079416b404c3", + "core@2.6.1/src/Runtime.mo": "d221bd16b45cb717cb4ef4fd698fa8632a415aecbbb5a6fdc8b5cd4bc50a9e56", + "core@2.6.1/src/Set.mo": "04cde46bb20c3b770a18a330c0449881e92d1948b17fd7d28ce95f9d5ba1d740", + "core@2.6.1/src/Stack.mo": "8c1146ff41f7f7a4ed775367e22c1c4e711c0a3f0a4335ddc0858730c8214313", + "core@2.6.1/src/Text.mo": "54ebeb2a1e507ab18e9c37214b2cd15e3255d04523c088deeaf1d24a757ffbd9", + "core@2.6.1/src/Time.mo": "fcffd56cbeb21c3d8613973f3f1ea382babcf122ea93c9689149f0101c5f3e96", + "core@2.6.1/src/Timer.mo": "477fe4f6a015a839000ce7a6a871877b3f32c6589850dab2b7e7e872d661b225", + "core@2.6.1/src/Tuples.mo": "0156c1fc21fffedd71d173e0d1b1e7a9a19726cbc49ec1cf49029d6d93bc7955", + "core@2.6.1/src/Types.mo": "00dc0132996b3bb3eaf5cc4413be78ca860137ef26f5b472b0e7b3ef68f6652f", + "core@2.6.1/src/VarArray.mo": "2c35be7e04000139460258b08a1ded7047f46b6aef9f4af3c8c6075bd889ebbe", + "core@2.6.1/src/WeakReference.mo": "06e58a6d67bf67c4b31ff871416d0cf6d6e34bfb409ce73d83372ef6b74de3cf", + "core@2.6.1/src/internal/BTreeHelper.mo": "305e10c7e7c740976b60b4b8d63145131ec3d9ade066aac68a1f631981a308f4", + "core@2.6.1/src/internal/PRNG.mo": "8a93e87baa2b12f613c82394a4a05b425e44a695bbace4ca36f9a1f78be26442", + "core@2.6.1/src/internal/SortHelper.mo": "fff4f2eec5c59a3db13e46eeb69152bb1b98931f45cb58ac481439a0cd0d10bb", + "core@2.6.1/src/pure/List.mo": "4c73dc64c6c6042a6f740ed8a699f0917d8e9ae2d5488867b403137c8d1d1a7f", + "core@2.6.1/src/pure/Map.mo": "1d1c054ef2b86ffa66ada38053760bd2a9b936665e1448db3f4a3e4d48eca05e", + "core@2.6.1/src/pure/Queue.mo": "129647e8a3dd65404f8ad28fc8f299fd4586bc32802ad9ede9c1f21249d4e54b", + "core@2.6.1/src/pure/RealTimeQueue.mo": "60e1f118e1fd728c64ac769396b2441a3e9be5208051139b3dd652125a81a0cb", + "core@2.6.1/src/pure/Set.mo": "c8f6f193a4b42b2df65ce8c05d610ccbcdc8befddc72acd44445e7290224373e" }, - "sha2@0.2.1": { - "sha2@0.2.1/src/Sha512.mo": "5dea538f9eeb71d4c37a216ebb882795350b173df3921984a5215f9fda9fd119", - "sha2@0.2.1/src/sha256/types.mo": "ef2fc9f7179e7895b7398fbae20bd374e14bd68325e56c297a348d3b6f9030cf", - "sha2@0.2.1/mops.toml": "0f414d7d69d0188171a89711006b39655f786989bb18a03fafd4f12ea0266819", - "sha2@0.2.1/NOTICE": "263c783b30440460b9a046b117b686dcc24e6fe912d8f78013ab00836de79b69", - "sha2@0.2.1/LICENSE": "ab81130a0a5f64eb71f0c0f84ff643fdd13b0d3d24c5e741b73768760d9f62dc", - "sha2@0.2.1/README.md": "75757d165ac006d13cac1e24efb7da4452f686b127a016ea6985eb81cec73bc1", - "sha2@0.2.1/src/Sha256.mo": "1434200240131a0fd53084c8b3863f42bc3e151ea7dc85150185edae46c1f376", - "sha2@0.2.1/src/sha512/process_block.mo": "4cbba34d75a7b868e582fa17c212455ab73192ec62411b5819e67ea8a1fbfe91", - "sha2@0.2.1/src/sha256/digest/lib.mo": "e4d41035c8e41e5b8c3ce1d853c4fc22f7a1214045805d2d5f24e3f3f6406978", - "sha2@0.2.1/src/sha512/types.mo": "885efe7fcd464446a689184d5723d4fafbebee08fee449096f5fbc847f7498e9", - "sha2@0.2.1/src/sha256/buffer/lib.mo": "3b8464a58d1063264270aa48cf4717ce2804400e3254572f5e00c9f82d9d4967", - "sha2@0.2.1/src/sha512/constants.mo": "4e18cf2b0b74cfe961dedc756f6e2461e05c5b6eb69ac9aec92cea00cad73b97", - "sha2@0.2.1/src/sha512/digest/lib.mo": "a2fbea6e9430fd2f8bb5b1188813e276876fffcac57f01826bb2a56b69f1dbac", - "sha2@0.2.1/src/sha256/state/lib.mo": "a1fbc57fc2f55a6895ff4bf122291fb5ac357a645d42f9470b9b5dc4a4ddca85", - "sha2@0.2.1/src/sha512/whole_blocks/accessor.mo": "43d22931e4a7ea2ea06c43cf9558e3fcd1a37f405f4d6585d8cd1dec4956a86a", - "sha2@0.2.1/src/sha512/whole_blocks/reader.mo": "ea5621981c5404abe9b9506230d5985c1e4bf047a09bdd0fa76ab7f753b0be01", - "sha2@0.2.1/src/sha512/whole_blocks/array.mo": "41e476db604b0d81876316ea844a4ebf420aa032b8fcc1412de92af337083631", - "sha2@0.2.1/src/sha512/whole_blocks/iter.mo": "b10ab448ead77fd3858924c963d65bda1a17bc42b9fccbf183746611a6aa3d3e", - "sha2@0.2.1/src/sha512/whole_blocks/blob.mo": "944ad1b09e3e1e19f478f6b1d7e339897c870ce0ae574c43c970d6df3b3667ba", - "sha2@0.2.1/src/sha512/write/array.mo": "0ff7bf6469e4f93053a28ea3e4a43833eb43e5f1390ccf5aaff6034d2ce2bba3", - "sha2@0.2.1/src/sha512/write/accessor.mo": "e87edabac0da4781ca0c73cd0bd6ef6b0798b5449c4fb19f170d4fab748ee85c", - "sha2@0.2.1/src/sha512/write/blob.mo": "f16f8081322ee88fc16ccc6412bafb5e2519d3060bea1503284601ef431ea7b1", - "sha2@0.2.1/src/sha512/whole_blocks/varArray.mo": "22bf05c4bfc24f79c3d48c99258148ceb6ca09e9e000dcb6b86b5e075e4a7766", - "sha2@0.2.1/src/sha512/write/lib.mo": "9aa271369a4a5bbb560e9c6fdc5d3ba4e16bfcf2d342f04ff155915e89cb66ba", - "sha2@0.2.1/src/sha512/write/iter.mo": "e06f0562813537871da60d3a965599e6ad3dc915ff8905ca05a5768313856063", - "sha2@0.2.1/src/sha512/write/byte.mo": "37e9e349a9c666cb6e9f273fafa2a91a92a05b962094c1d7aab1d6a1ad9affdb", - "sha2@0.2.1/src/sha512/write/reader.mo": "cc3a5027d00aca8c7f761cb939cec91e496369006c52f36d67855d71717a7b2c", - "sha2@0.2.1/src/sha256/state/process/msg_buffer.mo": "0dd2efe4686f6f99fd4d4e9403bfa662ce0e4558b3bb75ab982f24729e436502", - "sha2@0.2.1/src/sha256/state/process/blocks/accessor.mo": "0e4aa600469b13cda78d9814a1c71da30234631906f74805527b833334dbd147", - "sha2@0.2.1/src/sha512/write/varArray.mo": "8aa1ddc8ee1b6669f86a0fac9a0fa545b1402cb7cc9bf162e51f27b4f03d3905", - "sha2@0.2.1/src/sha256/state/process/constants.mo": "326a2be008dfe08c8719b681d46ecd06091511961873444b19d3332e6b77f7ab", - "sha2@0.2.1/src/sha256/state/process/blocks/reader.mo": "e00d7b5f8df00307296b038d715792dea6d4cd874c91a1f38a2c07109cc7f507", - "sha2@0.2.1/src/sha256/state/process/blocks/varArray.mo": "c2671d9f101f2bfea564703f89cc6f0f2e74b897805f53952aa850d271d7fc83", - "sha2@0.2.1/src/sha256/state/process/blocks/iter.mo": "4001f759362c8096d0b5e56fed6594c9f6736c2e2790f6c3f047c873660ebe5f", - "sha2@0.2.1/src/sha256/state/process/blocks/array.mo": "af83641e36a46959895844b84872536429ab70a915bf9b8a9e3a4a6ddfeb5f89", - "sha2@0.2.1/src/sha256/state/process/blocks/blob.mo": "d834ead0f15ee63feacf90bcc600984dd838b52ff8aa91d522472485d916977c" + "ic@4.2.0": { + "ic@4.2.0/LICENSE": "ec415b57f8143746b567c94bfad243f5342f3edf110c94641a1a60e02722db55", + "ic@4.2.0/README.md": "4aacc06321244e54f741e9d826f3d374144a4b32372cb58dddaedcaad2e12bc3", + "ic@4.2.0/mops.toml": "90a4087e4c1fb5ca9b976d824370bca632d860191eedd0461ac85bdbc4169f55", + "ic@4.2.0/src/Call.mo": "b4a3572e5617d0b1eb696450344b65e99504f844794e7ddf1cff664fcfeb2994", + "ic@4.2.0/src/Types.mo": "3d145437f9b8c6a661266ec659c9522c4b9001b50954945816e746f48028c9fc", + "ic@4.2.0/src/lib.mo": "9d9ec84a85eb25d191c1cae99cb841d9b6e0cf0f9bc27c3f3805ac9673d7bef1" }, - "ic@4.0.0": { - "ic@4.0.0/README.md": "7686aefce0c535d18ee2768e98660575563daa53b00d2c1041170032c232784c", - "ic@4.0.0/mops.toml": "7a6bb3d74b1ec5539b9bb88e0a0a0ba09e8c46e00b38cb5feda009d6b80723df", - "ic@4.0.0/src/Call.mo": "b4a3572e5617d0b1eb696450344b65e99504f844794e7ddf1cff664fcfeb2994", - "ic@4.0.0/src/lib.mo": "9d9ec84a85eb25d191c1cae99cb841d9b6e0cf0f9bc27c3f3805ac9673d7bef1", - "ic@4.0.0/LICENSE": "ec415b57f8143746b567c94bfad243f5342f3edf110c94641a1a60e02722db55", - "ic@4.0.0/src/Types.mo": "373c3a18e072058aecaaec99385a08f95746c7e86cc9694f9f23caa982c2ad6c" + "sha2@0.2.5": { + "sha2@0.2.5/LICENSE": "ab81130a0a5f64eb71f0c0f84ff643fdd13b0d3d24c5e741b73768760d9f62dc", + "sha2@0.2.5/NOTICE": "263c783b30440460b9a046b117b686dcc24e6fe912d8f78013ab00836de79b69", + "sha2@0.2.5/README.md": "e90038080d0aa7450a6c3d7507e6943be6d1bd02cfbbbd3dfda50797d1e2104c", + "sha2@0.2.5/examples/Merkle.mo": "8b22324c056e564b75711e046203873608203ade7f754d1181605d3e2c4d4a13", + "sha2@0.2.5/examples/NFold.mo": "6865e355a7e08d0fbc70501251fcb5a0c7a509d79eb7eccbd72de61e28ec4945", + "sha2@0.2.5/mops.toml": "71c8abf548ff55ae220c0c333515be1a3003bc167a0762829797beeec73ef78b", + "sha2@0.2.5/src/Sha256.mo": "76ec5683c22c86245d5156400617cb9a5e9f289f680bf27989a665d4794bf548", + "sha2@0.2.5/src/Sha512.mo": "805ccfbc23f2dd13b1ee2dff586b2572d6b9a6b26f4e866c1fb6bac0c26420a8", + "sha2@0.2.5/src/sha256/buffer/lib.mo": "d87dccc5c4802c1d402a69e5448fb22d7bc173a916cc49362bb1123510fff928", + "sha2@0.2.5/src/sha256/digest/lib.mo": "a4270166623d43b222b552eabf0366258aca8410a64922d7b3fb8889dd7d1312", + "sha2@0.2.5/src/sha256/state/lib.mo": "35be2bba9635a6e3641b8715ea08d2c3175b571732fbaf5e85e51a1ae4a4ea6f", + "sha2@0.2.5/src/sha256/state/process/blocks/accessor.mo": "0e4aa600469b13cda78d9814a1c71da30234631906f74805527b833334dbd147", + "sha2@0.2.5/src/sha256/state/process/blocks/array.mo": "af83641e36a46959895844b84872536429ab70a915bf9b8a9e3a4a6ddfeb5f89", + "sha2@0.2.5/src/sha256/state/process/blocks/blob.mo": "d834ead0f15ee63feacf90bcc600984dd838b52ff8aa91d522472485d916977c", + "sha2@0.2.5/src/sha256/state/process/blocks/iter.mo": "4001f759362c8096d0b5e56fed6594c9f6736c2e2790f6c3f047c873660ebe5f", + "sha2@0.2.5/src/sha256/state/process/blocks/leaf.mo": "3625e9ab03e474d711c429493c7808c0627570cf01b9344eae1158e0e3d1f127", + "sha2@0.2.5/src/sha256/state/process/blocks/merge.mo": "3a665665a830e36ad2a894b847e70d0a24ef9d69dbd4beb776e35b7676618198", + "sha2@0.2.5/src/sha256/state/process/blocks/reader.mo": "e00d7b5f8df00307296b038d715792dea6d4cd874c91a1f38a2c07109cc7f507", + "sha2@0.2.5/src/sha256/state/process/blocks/varArray.mo": "c2671d9f101f2bfea564703f89cc6f0f2e74b897805f53952aa850d271d7fc83", + "sha2@0.2.5/src/sha256/state/process/constants.mo": "326a2be008dfe08c8719b681d46ecd06091511961873444b19d3332e6b77f7ab", + "sha2@0.2.5/src/sha256/state/process/fold.mo": "9d060771d02a36f33d500667a816d4dfbbbd0699a610163242a90a16613b89a1", + "sha2@0.2.5/src/sha256/state/process/msg_buffer.mo": "0dd2efe4686f6f99fd4d4e9403bfa662ce0e4558b3bb75ab982f24729e436502", + "sha2@0.2.5/src/sha256/state/process/padding.mo": "ac48f6c1dafe55a4516daf8e6c5b76e3347942beb0896cd9a50bc63d9f34ba23", + "sha2@0.2.5/src/sha256/types.mo": "ef2fc9f7179e7895b7398fbae20bd374e14bd68325e56c297a348d3b6f9030cf", + "sha2@0.2.5/src/sha512/constants.mo": "4e18cf2b0b74cfe961dedc756f6e2461e05c5b6eb69ac9aec92cea00cad73b97", + "sha2@0.2.5/src/sha512/digest/lib.mo": "a0dc389cfa080e3b8c4d4aa3022866562434223a954d7c08ccc826d66080588b", + "sha2@0.2.5/src/sha512/padding.mo": "ddcd9fe1b212de8120fd66fc9b6db1699626b1244d05dcb2c9bffff44fc25930", + "sha2@0.2.5/src/sha512/process_block.mo": "4cbba34d75a7b868e582fa17c212455ab73192ec62411b5819e67ea8a1fbfe91", + "sha2@0.2.5/src/sha512/types.mo": "885efe7fcd464446a689184d5723d4fafbebee08fee449096f5fbc847f7498e9", + "sha2@0.2.5/src/sha512/whole_blocks/accessor.mo": "43d22931e4a7ea2ea06c43cf9558e3fcd1a37f405f4d6585d8cd1dec4956a86a", + "sha2@0.2.5/src/sha512/whole_blocks/array.mo": "41e476db604b0d81876316ea844a4ebf420aa032b8fcc1412de92af337083631", + "sha2@0.2.5/src/sha512/whole_blocks/blob.mo": "944ad1b09e3e1e19f478f6b1d7e339897c870ce0ae574c43c970d6df3b3667ba", + "sha2@0.2.5/src/sha512/whole_blocks/iter.mo": "b10ab448ead77fd3858924c963d65bda1a17bc42b9fccbf183746611a6aa3d3e", + "sha2@0.2.5/src/sha512/whole_blocks/reader.mo": "ea5621981c5404abe9b9506230d5985c1e4bf047a09bdd0fa76ab7f753b0be01", + "sha2@0.2.5/src/sha512/whole_blocks/varArray.mo": "22bf05c4bfc24f79c3d48c99258148ceb6ca09e9e000dcb6b86b5e075e4a7766", + "sha2@0.2.5/src/sha512/write/accessor.mo": "e87edabac0da4781ca0c73cd0bd6ef6b0798b5449c4fb19f170d4fab748ee85c", + "sha2@0.2.5/src/sha512/write/array.mo": "0ff7bf6469e4f93053a28ea3e4a43833eb43e5f1390ccf5aaff6034d2ce2bba3", + "sha2@0.2.5/src/sha512/write/blob.mo": "2d031c57963f265a54307956d0c8cf05555cfc391f3536d7c39f1f48dfcfd2f6", + "sha2@0.2.5/src/sha512/write/byte.mo": "37e9e349a9c666cb6e9f273fafa2a91a92a05b962094c1d7aab1d6a1ad9affdb", + "sha2@0.2.5/src/sha512/write/iter.mo": "e06f0562813537871da60d3a965599e6ad3dc915ff8905ca05a5768313856063", + "sha2@0.2.5/src/sha512/write/lib.mo": "9aa271369a4a5bbb560e9c6fdc5d3ba4e16bfcf2d342f04ff155915e89cb66ba", + "sha2@0.2.5/src/sha512/write/reader.mo": "cc3a5027d00aca8c7f761cb939cec91e496369006c52f36d67855d71717a7b2c", + "sha2@0.2.5/src/sha512/write/varArray.mo": "8aa1ddc8ee1b6669f86a0fac9a0fa545b1402cb7cc9bf162e51f27b4f03d3905" + }, + "sha3@0.1.1": { + "sha3@0.1.1/LICENSE": "d8d6d16630a5c26202efdc264fb926154c8c5a19115aff6ee5b21a4defd3d430", + "sha3@0.1.1/README.md": "80674416dba195c8838a08dc92c0d6f5d03f650fb8be946341acc2b78a5ff058", + "sha3@0.1.1/mops.toml": "4428621816bdfc3439fba82a9fac23fd306ec13b0473b050d8db46b1fefe9595", + "sha3@0.1.1/src/lib.mo": "2d5c2314b44572b52efaf68d2d9867e5aae3482c9be4c82001821f6472757243" }, "test@2.1.2": { "test@2.1.2/LICENSE": "ec415b57f8143746b567c94bfad243f5342f3edf110c94641a1a60e02722db55", - "test@2.1.2/src/utils.mo": "6b81172d908c2fb759597373b811e22a987d4cd69f1aa69615da252bed6e7466", - "test@2.1.2/src/lib.mo": "e4559fbc3bcd229fb99de7dcdd68d56319ae8bc27509d6f3ad6d13c514915a39", - "test@2.1.2/src/async.mo": "79b433a5d32184d0cad289f0b7ee6cd2256534689a015430efc43e3510c2217a", "test@2.1.2/README.md": "991a9ea896698d7ef1e7e55d038c7580d68182b3d30f78886304e374c64fbc01", - "test@2.1.2/src/expect/async.mo": "c1518bd82ef8c2def86bc6b5fc1df1691eab9935adacc413cab4fe9f1eda6f4a", "test@2.1.2/mops.toml": "83e5024af6a5558f18a41b3ab8b60291f65e8470c0375c7e880896ccb02edf14", + "test@2.1.2/src/async.mo": "79b433a5d32184d0cad289f0b7ee6cd2256534689a015430efc43e3510c2217a", + "test@2.1.2/src/expect/async.mo": "c1518bd82ef8c2def86bc6b5fc1df1691eab9935adacc413cab4fe9f1eda6f4a", "test@2.1.2/src/expect/expect-array.mo": "f5e6808b28c5948a40e11def8f5534bcacef1211a0b6f02e63e8653e5ee5e6aa", - "test@2.1.2/src/expect/expect-char.mo": "7464e454a815e76e9d56989d59b8eda8781dd7dd28019ada3c513b764a5ff863", - "test@2.1.2/src/expect/expect-int16.mo": "15dfb2b13e74819d9f02927708c8f7cbbb8e0b13c0a9b249d843b16c3bcf4659", "test@2.1.2/src/expect/expect-blob.mo": "944d92855573f5056d10cab2ee67f5ca153a792de9aa5074ccb0da7417a77ceb", - "test@2.1.2/src/expect/expect-int.mo": "67837bcbd681bc180a8c528defab5153d9f8aaf4998b91ca3f787e714011cdf6", - "test@2.1.2/src/expect/expect-call.mo": "9aeeb5d56f753586e685fd3a7544b4a895e9e2a1f01f2c3423c66f87059512eb", "test@2.1.2/src/expect/expect-bool.mo": "dd663249dd67d5ff5241a8ecb9b16e016abe24f0570e5aac8b912fc312e4a5d4", + "test@2.1.2/src/expect/expect-call.mo": "9aeeb5d56f753586e685fd3a7544b4a895e9e2a1f01f2c3423c66f87059512eb", + "test@2.1.2/src/expect/expect-char.mo": "7464e454a815e76e9d56989d59b8eda8781dd7dd28019ada3c513b764a5ff863", + "test@2.1.2/src/expect/expect-int.mo": "67837bcbd681bc180a8c528defab5153d9f8aaf4998b91ca3f787e714011cdf6", + "test@2.1.2/src/expect/expect-int16.mo": "15dfb2b13e74819d9f02927708c8f7cbbb8e0b13c0a9b249d843b16c3bcf4659", "test@2.1.2/src/expect/expect-int32.mo": "577cc992737c99f07df7eb71462bb99f0b9b34727a4edf31dea88a7aff2bd48a", "test@2.1.2/src/expect/expect-int64.mo": "c3d0025bca7dcda18872b5ea59baad9f485b33647a7118af45a12700793f3c19", "test@2.1.2/src/expect/expect-int8.mo": "a88f864f471413fd4fe8300b828805a9e95c42278d55aa4351526d3c4f3c1e2c", - "test@2.1.2/src/expect/expect-nat8.mo": "2b5145da40599eb01ef5fe6807162cc9b2a5fbe5044fd4003ebdf6bf26cbf4b6", + "test@2.1.2/src/expect/expect-nat.mo": "0f9cefc54f429a48a5ec1eb2f26bb73f771b6ca165fac300bf7228633429b049", "test@2.1.2/src/expect/expect-nat16.mo": "eff67a91dc8eed0478d3558c1900257802c5c6c527d13f4ef6726ebd53492d6a", "test@2.1.2/src/expect/expect-nat32.mo": "3ea76a0c53c806fad57d768ca79702c65faaf85b69ad61e49688b90d9a32094b", - "test@2.1.2/src/expect/expect-nat.mo": "0f9cefc54f429a48a5ec1eb2f26bb73f771b6ca165fac300bf7228633429b049", "test@2.1.2/src/expect/expect-nat64.mo": "13536637dbb595bceec51db56c7cfc39221a9d5b5ea23baee66d19d6103f600f", + "test@2.1.2/src/expect/expect-nat8.mo": "2b5145da40599eb01ef5fe6807162cc9b2a5fbe5044fd4003ebdf6bf26cbf4b6", "test@2.1.2/src/expect/expect-option.mo": "d5f2bce86a6f5c8e0bdbe1a72f1cbb80b01d3822b73da90239b84d91cb0af5d6", "test@2.1.2/src/expect/expect-principal.mo": "93842843aa02bde6cdb94ee4daa35ed95d4b2da87bc228689b9cc8c5d9bdc178", + "test@2.1.2/src/expect/expect-result.mo": "3c03969dc0f08c80fa003ab23760a933f47ac928834734faca0f62fe940d3ab9", "test@2.1.2/src/expect/expect-text.mo": "ffb21d802a48411ed45555e82edd5b5a82c91eb37b88c5d8d061880bab897735", "test@2.1.2/src/expect/lib.mo": "b74da3928ed8c23c31b5979059f0ac549cd7f09e7a29af9b105d8f9e32e192ed", "test@2.1.2/src/expect/utils.mo": "22e364d2ec8285c09ace83cb99b3257ba66a87cfe0312f6623ab0ab35ce47009", - "test@2.1.2/src/expect/expect-result.mo": "3c03969dc0f08c80fa003ab23760a933f47ac928834734faca0f62fe940d3ab9" + "test@2.1.2/src/lib.mo": "e4559fbc3bcd229fb99de7dcdd68d56319ae8bc27509d6f3ad6d13c514915a39", + "test@2.1.2/src/utils.mo": "6b81172d908c2fb759597373b811e22a987d4cd69f1aa69615da252bed6e7466" } } } \ No newline at end of file diff --git a/mops.toml b/mops.toml index 22fa88c..cfeddef 100644 --- a/mops.toml +++ b/mops.toml @@ -1,20 +1,93 @@ [dependencies] -core = "2.5.0" -sha2 = "0.2.1" -ic = "4.0.0" +core = "2.6.1" +# SHA-256 for the Stripe webhook signature — `Hmac.mo` imports `mo:sha2/Sha256`. +# ⚠️ Named because the alias contains DIGITS: an import scan whose pattern was +# `[A-Za-z]+` reported this dependency as unused, twice. +sha2 = "0.2.5" +# `Call.httpRequest` for the Stripe session outcall — `Main.mo` and `rails/Session.mo` +# import it. It attaches the exact `ic0.cost_http_request` price, which is why +# hand-attaching cycles is never the answer here. +ic = "4.2.0" +# ── vetKD sealing ──────────────────────────────────────────────────────── +# +# BLS12-381 and the vetKD layer on it, so `Sealed.mo` can decrypt a secret that +# was encrypted to this canister's derived public key. They are path +# dependencies into the `vendor/icp-seeding-secrets-poc` submodule, pinned by its commit. +# +# ⚠️ **The `./` prefix is load-bearing.** Without it mops reads the string as a +# REGISTRY name and fails with `Package '...@vendor/icp-seeding-secrets-poc/...' not found`, which +# reads like a missing package rather than a malformed path. Measured. +# +# ⚠️ **EXPERIMENTAL, UNAUDITED.** `mo:ic-vetkeys` has no BLS12-381, and the one +# on mops (ICDevs' `bls12-381`) is not tested against `ic_bls12_381`'s vectors. +# What this code does and does not carry is `docs/DESIGN.md` §7.3: it DECRYPTS, +# and the encrypting is done client-side by `@icp-sdk/vetkeys`, so a bug here +# fails provisioning closed rather than weakening a ciphertext already on the +# wire. Delete both when `mo:ic-vetkeys` ships the curve. +ic-bls12-381 = "./vendor/icp-seeding-secrets-poc/motoko/bls12-381" +sealed-secrets-vetkeys = "./vendor/icp-seeding-secrets-poc/motoko/vetkeys" +# SHAKE256 and the field-tower hashing the two packages above need. +sha3 = "0.1.1" +# ⚠️ **Not imported by any file in this project, and NOT removable.** It arrives +# transitively via sha3, and with no floor mops resolves 0.8.2 — which predates +# moc 1.15's system-capability types and fails to type-check inside +# `ExperimentalCycles.mo` before any of our own code is reached. `mo:base` is +# deprecated and this is the one reason it appears here. Measured: removing the +# pin reproduces `M0096` in a dependency. +# +# ⚠️ **Same trap as the zero-use-but-load-bearing IMPORTS noted above, one layer up.** +# There the absence broke receiver resolution; here it breaks version resolution. Neither +# leaves a `Module.` reference to warn whoever is tidying, and this one is worse in that +# the failure surfaces inside a package nobody here wrote. +base = "0.14.5" [toolchain] -moc = "1.9.0" -lintoko = "0.10.0" +moc = "1.16.0" +lintoko = "0.11.0" [dev-dependencies] test = "2.1.2" [moc] +# -Werror with no allow-list. The project compiles warning-free, so every entry +# an allow-list would carry is a warning nobody has to think about again — and an +# unexplained allow-list recreates the problem the flag is meant to solve. +# +# The reason this exists: `Types.Owner` is a deliberate single-case variant (the +# §11.1.1 Base seam), and `Types.isOwnedBy` used to document that adding a case +# "makes this a compile error". It did not — a non-exhaustive match is M0145, a +# *warning*, and the build passed with five authz sites silently left to trap at +# runtime. On the webhook path that trap is a 5xx, which Stripe retries for ~3 +# days, so a routine type extension would have presented as an outage. +# +# The -W list turns ON three style lints that are off by default. `-Werror` is +# NOT set here: `mops test` passes `--hide-warnings`, and moc refuses the two +# together ("--hide-warnings and -Werror together do not make sense"), which fails +# every test file. It is applied where it belongs instead — `mops check -- -Werror` +# in scripts/test-all.sh and in CI, the two places that gate a merge. args = [ "-W=M0223,M0236,M0237" ] [canisters.backend] main = "src/backend/Main.mo" +# ⚠️ **Moved here from `icp.yaml` when the recipe went to v5.** The Motoko recipe now +# delegates to `mops build`, and its only configuration is metadata/shrink/compress — the +# main file and the Candid path live in this file. Keeping the committed `.did` named here +# preserves the §8 property: what `mops build` regenerates, what the frontend bindgens +# from, and what the deployed canister reports as `candid:service` are the same bytes. +candid = "src/backend/dist/backend.did" + +# ⚠️ **The baseline is COMMITTED, and it must not be the build's own output.** +# `mops build` writes `src/backend/dist/backend.most`, which `.gitignore` excludes along +# with the rest of `dist/` — so pointing this at that path compares an artifact against a +# fresh regeneration of itself. Measured before configuring it: `mops check-stable +# src/backend/dist/backend.most backend` PASSED on the very `List` → `Set` change that +# this baseline rejects. A check that cannot fail. +# +# `deployed/backend.most` is promoted by `mops deployed` after a deliberate schema change +# and committed in the same PR. See AGENTS.md for the procedure and for why the gate +# aborts when it is missing rather than reporting a clean check. +[canisters.backend.check-stable] +path = "deployed/backend.most" [build] outputDir = "src/backend/dist" diff --git a/progress.txt b/progress.txt deleted file mode 100644 index 45347e1..0000000 --- a/progress.txt +++ /dev/null @@ -1,678 +0,0 @@ -# Progress log — one entry per loop iteration, newest last. -# -# FROZEN (2026-06-10) — historical artifact. Tracking moved to GitHub -# Issues (the source of truth; see docs/agents/issue-tracker.md). Do not -# append entries here — comment on the relevant issue instead. - -## 2026-06-10 — Iteration 1 — Task 0: Scaffold (DONE) -No PRD or progress.txt existed; bootstrapped both. Created PRD.md with the full -prioritized task breakdown (tasks 0–18, M0–M3) derived from -design-docs/ONCHAIN_GATEWAY_SPEC.md v2.1. Scaffolded the buildable project: -- mops.toml: moc 1.9.0 + lintoko 0.10.0 pinned, core 2.5.0, test 2.1.2 (dev), - style warnings -W=M0223,M0236,M0237, [canisters.backend], [build] --release. - NOTE: deliberately NOT using --default-persistent-actors; actors write - `persistent` explicitly so the icp-cli recipe (which ignores [moc].args) - compiles identically. -- icp.yaml: backend via @dfinity/motoko@v4.1.0 recipe; local network with - gateway port 0 (avoids port collisions). -- src/backend/Main.mo: minimal `persistent actor CyclesGateway` + health query. -- test/smoke.test.mo: harness smoke test. -- .gitignore: .icp/cache/, src/backend/dist/. mops.lock committed (spec §8). -Toolchain installed in sandbox: npm -g ic-mops @icp-sdk/icp-cli @icp-sdk/ic-wasm -(+ apt libdbus-1-3 for icp-cli). Verified: mops check ✓, mops test ✓ (1 pass), -mops build ✓, icp build ✓. -NEXT: Task 1 — Types.mo + Orders.mo (Owner variant seam, order state machine, -locked cycle quantity, transition unit tests). - -## 2026-06-10 — Iteration 2 — Task 1: Core types + Order state machine (DONE) -- src/backend/Types.mo: Owner = {#ii : Principal} (binding seam §11.1.1) + - isOwnedBy (pattern-match authz), Rail {#card; #ckUsdc}, Account (ICRC-1), - Destination {#canister; #cyclesLedgerAccount}, OrderStatus (8 states), - statusToText, Order (immutable record, lockedCycles quantity per §3), - TransferIntent (§5.1 write-intent args), JournalEntry (§4.2). -- src/backend/Orders.mo: Store {orders; principalsToOrders}; isLegalTransition - = §4 diagram, 11 legal edges (incl. Minting→ErrorQueue stale-intent §5.1 and - IcpAtCMC→ErrorQueue failed-forward Type 2 §4.1); Delivered/ErrorQueue - terminal; pure transition() → updated copy; create() takes owner as a - PARAMETER (seam §11.1.3), locks cycle quantity, rejects duplicate id; - applyTransition, get/getOwned (caller==owner authz), ordersFor history. - Expiry policy NOT in the core (seam §11.1.4 — per-rail, lands with rails). -- test/orders.test.mo: exhaustive 64-pair matrix as data from the spec table, - count pinned to 11, terminality check, transition/copy semantics, dup-id, - authz, history ordering. 80 tests green. -- Gotchas hit: extension methods need the module imported even for type - aliases — Map implicit compare needs `import Text`, iterator - .filterMap needs `import Iter` and BOTH type args (). -- Verified: mops check ✓ (lint clean), mops test ✓ (80), mops build ✓, - icp build ✓. PRD task 1 → ☑, changelog updated. -NEXT: Task 2 — Idempotency.mo + ErrorQueue.mo (per-rail dedup sets w/ 7-day -Stripe pruning, bounded Type1/Type2 error queue, audit-log ring buffer, tests). -## 2026-06-10 — Iteration 3 — Task 2: Idempotency + ErrorQueue + AuditLog (DONE) -- src/backend/Idempotency.mo: stripeEvents/stripeIntents Map - (first-seen ns; NOT refreshed on replay so day-6 redelivery still prunes - day 7), ckUsdcBlocks Set never pruned (§4.2 retention); record* → Bool - (false = duplicate, ack-and-drop); pruneStripe (cutoff: age >= 7d, constant - 604_800_000_000_000 — module-level lets must be STATIC, M0014 rejects - 7*24*60*60*1e9 arithmetic). -- src/backend/ErrorQueue.mo: Kind = #duplicate{orderId;paymentRef} | - #unattributed{claimedRef;paymentRef} (Type 1) | #undeliverable{orderId; - cycles} (Type 2) — payloads make types structural; isType1/paymentRefOf; - Store {entries: Map; var nextId} (monotonic id = age order); - add(capacity) evicts oldest-resolved-first, oldest-unresolved last resort, - returns evicted[] for caller to audit-log; resolve (notFound/ - alreadyResolved), resolveByPaymentRef = charge.refunded auto-resolve - (skips resolved + Type 2); unresolved/all/get/size. Capacity is a call-site - param — config lands in Main.mo (task 6). -- src/backend/AuditLog.mo: Queue-backed ring buffer (pushBack/popFront O(1)), - hard cap, monotonic never-reused seq → readers detect drops. -- Tests: idempotency (11), errorqueue (10), auditlog (3) — boundary at - exactly-7d pruned / 7d-1ns kept, namespace independence, eviction - preference, id non-reuse, auto-resolve idempotence. 105 total green. -- Gotchas: M0014 (static module lets); Set.insert/Map.insert deprecated M0235 - → use contains+add; .toArray() on iter needs `import Iter` (again). -- Verified: mops check ✓, mops test ✓ (105), mops build ✓, icp build ✓. -NEXT: Task 3 — HMAC-SHA256 + Stripe signature verify (constant-time compare, -Stripe-Signature header parse, timestamp window; known-vector tests). -## 2026-06-10 — Iteration 4 — Task 3: HMAC-SHA256 + Stripe signature (DONE) -- Dep: sha2@0.2.1 (research-ag) — matches core 2.5.0 exactly. REJECTED the - `hmac` mops pkg (edjCase): pins core@1.0.0 + sha2@0.1.6, would put two core - versions in the build. HMAC is ~25 lines over a SHA-256 digest, hand-rolled. -- src/backend/Hmac.mo: RFC 2104 (key > 64B → hashed, zero-pad, ipad/opad), - multi-part message arg so Card can MAC "." + body without a copy; - constantTimeEqual = XOR-OR over all bytes (compare is on the forge path: - HMAC verify == forge), early return on length mismatch only. -- src/backend/Util.mo: hex codec (lowercase encode, either-case decode, - null on odd len / bad char). -- src/backend/rails/Card.mo: parseSignatureHeader (Stripe reference-parser - semantics: first t= wins, v0=/junk ignored, v1= filtered to valid 32-byte - hex; null if no usable t/v1), signedPayloadMac("."), verify = - parse → ABSOLUTE window |now−t| ≤ tolerance (default 300s; t is signed so - it can't be forged to beat the window) → constant-time compare vs EVERY v1 - (rotation). Errors: #malformedHeader / #timestampOutsideWindow{t,now} / - #signatureMismatch. -- Tests pinned to EXTERNAL vectors (python hmac, computed this iteration, in - shell history not committed): RFC 4231 TC1–4,6,7; empty/64B/65B key - boundaries; crafted Stripe header vector + rotated-secret second v1. - Window boundary at exactly ±tolerance vs +1s. 18 card + 20 hmac = 38 new, - 143 total green. -- Gotchas: none at compile (first-try clean — parenthesize variant payloads - `#tag({...})`); `mops test` only prints per-FILE ✓ — run - `mops test ` to see per-test counts. -NEXT: Task 4 — hand-rolled Http.mo (HttpRequest parse, case-insensitive -header lookup, query-string strip, body-size guard, route table w/ upgrade -flag, POST /webhook/stripe entry; parser+routing unit tests). -## 2026-06-10 — Iteration 5 — Task 4: Hand-rolled Http.mo (DONE) -- src/backend/Http.mo: gateway Request/Response (certificate_version omitted, - Candid subtyping drops it); pathOf (strip from first '?'); headerValue - (ASCII-fold names only, first match wins — core Text has NO toLower, hand- - rolled via Char.fromNat32(+32)); dispatch off [Route] table w/ per-route - upgrade flag (seam §11.1.2). 404 unknown path / 405 + Allow header (methods - on that path, table order) / 413 over body cap BEFORE the upgrade decision - (oversize never reaches consensus). Query half: upgrade route → ?true w/o - running handler; non-upgrade route runs in the query. Update half re-applies - all guards (http_request_update is directly Candid-callable). -- src/backend/Main.mo: http_request(_update) wired; one-entry route table - POST /webhook/stripe upgrade=true; handler = 503 stub (Stripe retries - non-2xx) until tasks 5+8. routes AND 64 KiB cap are `transient` — closures - aren't stable; persistent let would freeze first-deploy config forever. -- test/http.test.mo: 25 tests — pathOf edges (first-? wins, empty url), - header case/dup/missing, at-cap vs over-cap boundary, handler-not-run on - query upgrade (stub returns 299; seeing 200 proves skip), echo handler - proves update dispatch passes the req, exact-case method match, 405 Allow - content. 168 total green. -- Gotchas: mo:core Text has no toLower/toUpper (ASCII fold by hand); lint - M0236 wants dot notation even for Text.encodeUtf8 → "x".encodeUtf8(). -- Verified: mops check ✓, mops test ✓ (168), mops build ✓, icp build ✓, - generated .did matches HTTP-gateway protocol. -NEXT: Task 5 — Auth.mo + Secret.mo (controller allowlist, reject anonymous -on user API, admin-set/rotate webhook secret w/ SEV-SNP posture note; tests). -## 2026-06-10 — Iteration 6 — Task 5: Auth + Secret (DONE) -- src/backend/Auth.mo: §7 flat allowlist = the CANISTER CONTROLLER SET - (OR-semantics, equal privileges; edits via canister settings, M-of-N = - multisig-canister controller). checkAdmin(caller, isController) — the - controller check is an INJECTED PREDICATE so the module unit-tests without - an IC env; Main passes Principal.isController (exists in core 2.5.0, - works in query context). Anonymous rejected BEFORE the predicate. - checkUser = II user-API gate (wired in task 6; webhook route stays - anonymous-by-design, payload-authed). -- src/backend/Secret.mo: plaintext-by-design store (SEV-SNP posture doc'd - on the module: checkpoints/state-sync caveat, boundary-node provisioning - exposure, burn-cap backstop). Holds UTF-8 of the FULL whsec_ string - (prefix included = HMAC key per Stripe reference verifiers) + setAtNs + - generation (ops confirm rotation w/o read-back); set rejects <16 bytes, - store untouched on #err. No dual-secret window: Stripe sends one v1= per - active secret during overlap, Card.verify accepts any match. -- Main.mo: persistent webhookSecret; requireAdmin TRAPS; set_webhook_secret - (Text→Result<(), SetError>); webhook_secret_status query (admin-gated, - never returns the secret). Webhook handler stays 503 stub until task 8. -- Tests: auth (6) + secret (7) = 13 new, 181 total green. Boundary at - exactly-16 vs 15 bytes; anonymous-beats-predicate; rejected rotation - preserves working secret. -- Gotchas: core 2.5.0 Principal has NO fromBlob — test principals must be - valid fromText strings ("aaaaa-aa", "2ibo7-dia" both fine). -- Verified: mops check ✓, mops test ✓ (181), mops build ✓, icp build ✓, - .did gains exactly the two admin methods. -NEXT: Task 6 — Order Candid API in Main.mo (create_order w/ raw_rand id + -client_reference_id "_", get_order/history authz, -fixed card tiers config; wires Auth.checkUser + Orders store). -## 2026-06-10 — Iteration 7 — Task 6: Order Candid API (DONE) -- src/backend/Tiers.mo: fixed card tiers = operator config {id; usdCents; - paymentLinkUrl} (links made in Stripe Dashboard, registered via admin - call); validate (unique non-empty ids, non-zero cents) + find. -- src/backend/Orders.mo: idFromEntropy (first 16 raw_rand bytes → 32-char - lowercase hex, null if <16B = broken source); clientReferenceId = - "_" (principal is [a-z0-9-], id is hex → single '_' - splits unambiguously; task 8 parses it claimed-not-trusted). -- src/backend/Main.mo: persistent orderStore + cardTiers (empty by default — - no invented prices); create_order = checkUser → tier find → quote → - raw_rand loop (≤3 re-draws on dup id) → Orders.create with #ii(caller) - owner at the API edge (seam §11.1.3); tier+quote read BEFORE the await so - config swaps can't mix pricings. PRICING SEAM FAILS CLOSED: transient - quoteCyclesForUsdCents stub returns null until Forex (task 7) → - #rateUnavailable (§3.1 — same answer as a real stale-rate outage). - get_order/list_orders (getOwned/ordersFor; anonymous owns nothing, no - existence leak), set_card_tiers (admin, atomic validate), card_tiers query. -- Tests: tiers (7) + orders id/clientRef suites (6) = 13 new, 194 total. -- Gotchas: `let ? ... else assert false` doesn't compile — else branch must - DIVERGE; use `else Runtime.trap(...)`. Dot-notation lint (M0236) fires on - Array.sliceToArray/find too: `entropy.toArray().sliceToArray(0, n)`, - `tiers.find(...)` (module import still required). -- Verified: mops check ✓ (lint clean), mops test ✓ (194), mops build ✓, - icp build ✓, .did gains exactly the 5 order/tier methods. -NEXT: Task 7 — Forex.mo (§3.1): USD↔XDR HTTPS outcall w/ coarse-rounding -transform, stable {rate, ts} cache, lazy refresh, single-flight, retry cap, -fail-closed wiring into quoteCyclesForUsdCents; fee formula (≈2.9% + $0.30) -net-of-fees pricing; mocked-outcall unit tests. -## 2026-06-10 — Iteration 8 — Task 7: Forex subsystem (DONE) -- Dep: ic@4.0.0 (caffeinelabs) — shares core@2.5.0 exactly (no version - split); Call.httpRequest auto-attaches outcall cycles (ic0 cost API). -- src/backend/Forex.mo (pure half — unit-testable w/ mocked Blob bodies): - rate = XDR-per-USD micros (1 XDR = 1T cycles ⇒ cycles = net·micros·1e4); - default source open.er-api.com/v6/latest/USD (keyless, USD-base); - extractXdrPerUsdMicros (first "XDR" key wins, >6 frac digits truncated); - coarseRound to 1_000 micros (~0.14%); plausibility band 0.1–10 XDR/USD - (decimal-point bug at source can't mint free/1000x orders); transformBody - → canonical micros text, ANY failure → empty body (consensus on failure - too); parseCanonicalMicros re-applies band; fee = ⌈gross·bps/1e4⌉+fixed - (ceil: overestimate fee, never over-deliver; net>0); quote = one snapshot - of config+rate → #ok/#stale/#unpriceable; hostOf for Host header. -- Main.mo: persistent forexCache + forexConfig (290bps/30¢/1h, admin - set_forex_config validated atomically, public forex_status); TRANSIENT - single-flight flag (persistent flag stuck by mid-outcall upgrade would - deadlock forever) w/ try/finally reset; retry cap 3; max_response_bytes - 16k (charged on ceiling); forex_transform query; create_order quotes - cache → #stale → refresh → requote → fail-closed #rateUnavailable; new - #tierBelowFees error (config problem ≠ rate outage). -- Tests: 43 new (parser matrix, band/round boundaries incl. round-into-band, - fee vectors cross-checked w/ python, exactly-at-window staleness, quote, - config validation). 237 total green. -- Gotchas: core Char has NO toNat — use c.toNat32().toNat(); encodeUtf8 dot - notation in TESTS needs `import Text` too (M0072, same old story); - try/finally works fine in moc 1.9 for the single-flight reset. -- Verified: mops check ✓ (lint clean), mops test ✓ (237), mops build ✓, - icp build ✓, .did gains forex_status/forex_transform/set_forex_config. -NEXT: Task 8 — Stripe webhook ingestion (rails/Card.mo complete): parse -checkout.session.completed + charge.refunded JSON, claimed-not-trusted -client_reference_id resolution, event.id+payment_intent dedup, honor actual -paid amount → Paid; unmatched/dup → Type 1; refund auto-resolve; crafted -signed-payload tests. -## 2026-06-10 — Iteration 9 — Task 8: Stripe webhook ingestion (DONE) -RESUMED IN-FLIGHT WORK: a previous run left the full implementation -uncommitted (Json.mo, Card.mo ingestion, Main.mo wiring, Pricing snapshot, -markPaid, parseClientReferenceId) but stopped before tests/PRD/commit. -This iteration reviewed that code (matches spec §4.1/§6.1), fixed the two -test files its signature changes broke, and wrote all tests. -- src/backend/Json.mo (was untracked): strict JSON tree parser — mops json - pkg rejected (deps deprecated base; same split-rejection as hmac task 3). - Tree-parse not key-scan: verified bodies still carry attacker-influenced - STRING VALUES. Raw number lexemes, integer-only asNat; lone surrogates/ - control chars/trailing garbage fail; depth cap 64. -- Card.parseEvent: checkoutCompleted{eventId;paymentIntent;clientReferenceId - (JSON null = absent);amountTotalCents;currency;paid} | chargeRefunded | - unhandled (acked); missing required field → 400 (subscription-mode null - payment_intent stays visible in Stripe dashboard). -- Card.handleWebhook (sync end-to-end, Deps-injected stores): secret null → - 503; verify → prune → route. Checkout: event.id dedup → payment_status - (unpaid acks WITHOUT consuming intent) → intent dedup → attribution - (parseClientReferenceId: claimed principal compared as TEXT — fromText on - garbage traps → 5xx retry loop; owner/rail/currency checked) → Type 1 - #unattributed on failure (200 — handled BY THE OPERATOR); fresh intent on - handled order → Type 1 #duplicate; actual amount honored: tier match = - locked qty verbatim, mismatch repriced from order.pricing snapshot - (Types.Pricing captured at create; Orders.markPaid swaps honored qty), - below-fee-floor → Type 1. charge.refunded auto-resolves by intent. -- Forex.netCents widened to {feeBps;feeFixedCents}; Forex.quote #ok now - {cycles; xdrPerUsdMicros} so Main.quoteTier persists snapshot+quote from - one epoch. Main.mo: persistent dedup/errorQueue/auditLog + error_queue/ - resolve_error/audit_log admin methods. -- Tests: json (19), webhook (30, end-to-end over SIGNED crafted payloads — - signedPayloadMac is pinned to external vectors in card.test.mo so this - isn't self-agreement), orders +9 (parseClientReferenceId, markPaid); - fixed forex.test.mo quote shape + orders.test.mo create arity. 295 green. -- Gotchas: none new — first-try clean compile on all three new test files - (the iteration-7 lesson list held: parenthesized variant payloads, module - imports for dot-notation, record-update `{ x with field = v }` syntax). -- Verified: mops check ✓ (lint clean), mops test ✓ (295), mops build ✓, - icp build ✓, .did gains exactly error_queue/resolve_error/audit_log. -NEXT: Task 9 — CMC mint pipeline (Cmc.mo, §5/§5.1): candid bindings for ICP -ledger + CMC, write-intent-before-call w/ created_at_time dedup, -icrc1_transfer → block_index → notify_top_up, mint-to-self-then-forward, -failed forward → Type 2, CMC rate staleness guard; intent/replay unit tests. -## 2026-06-10 — Iteration 10 — Task 9: CMC mint pipeline (DONE) -- src/backend/Cmc.mo (pure half, unit-testable): ledger/CMC/cycles-ledger - Candid types; topUpSubaccount (len-prefixed principal, 32B) + TPUP memo - (LE 0x50555054) pinned to python-computed vectors; e8s = ceil(cycles / - xdr_permyriad_per_icp) (1 e8s mints exactly `permyriad` cycles; round UP - so the mint covers the locked qty, dust is operator-side); freshCmcRate - 15-min staleness guard (age >= window = stale, house convention) + zero- - rate refusal; buildIntent/transferArgs = deterministic §5.1 write-intent - (replay is bit-identical BY CONSTRUCTION — first attempt and recovery are - the same #replayTransfer stage); interpretTransfer (#Ok AND #Duplicate - both recover block_index; retriable = nothing-recorded vs escalate = - replay-can-never-succeed); interpretNotify; journal Map + patch helper - (no-op on missing id — never trap mid-money-flow); stageOf = the WHOLE - §5.1/§5.2 resume decision as one pure function (incl. maxRetries cap for - the notify loop the 24h window doesn't bound). -- DECISION: one notify path — mint-to-self-then-forward via notify_top_up - (self)+TPUP for BOTH destination kinds; notify_mint_cycles/MINT unused - (would strand value in the cycles-LEDGER balance, splitting Type 2's - "cycles in app balance" invariant). Forward: #canister → ic.deposit_cycles - w/ attached cycles; #cyclesLedgerAccount → cycles-ledger deposit. -- DECISION: forward is AT-MOST-ONCE — cyclesMinted doubles as a pre-forward - marker committed before the forward await; death mid-forward resumes as - #ambiguousForward → escalate (operator checks destination), never auto- - re-forward. Failed (not ambiguous) forward = clean Type 2 (deposit - rejection auto-refunds attached cycles to app balance). -- ErrorQueue.Kind += #stuckMint{orderId; stage} (§5.1 escalations: stale - intent/CMC rejection/ambiguous forward) — neither Type 1 nor Type 2, no - paymentRef so charge.refunded auto-resolve skips it. -- Main.mo: persistent mintJournal; transient per-order single-flight Set + - maxMintRetries 25; driveMint loop = stageOf-dispatched (begin → rate fetch - → intent+#minting in ONE sync block → fall through to replayTransfer → - block+#icpAtCmc sync → notify → marker → forward → #delivered); - sweepMintable over #paid/#minting/#icpAtCmc (task 11 timer reuses); - webhook kick = `ignore async` detached self-message after dispatch so - Stripe's ack never waits on ledger/CMC; admin process_order + mint_journal. -- Tests: cmc (37: pinned vectors, boundaries at exactly-window and - exactly-24h, full interpret matrices, journal patch, 13-case stageOf incl. - ambiguity-beats-retries) + errorqueue (+2 stuckMint). 334 total green. -- Gotchas: param named `self` on a module func makes lint M0236 demand dot - notation at the CALL site — rename the param; `ignore async {...}` inside - an async public func is the fire-and-forget kick (state already committed - when the self-message runs); Map/Text imports needed in TESTS for - journal.get dot notation (same M0072 story as always). -- Verified: mops check ✓ (lint clean), mops test ✓ (334), mops build ✓, - icp build ✓, .did gains exactly process_order/mint_journal. -NEXT: Task 10 — Treasury.mo (§5.3): ICP float accounting (icrc1_balance_of), -Paid → AwaitingTreasury pre-gate when float short, max-wait → error queue, -low-float soft gate + balance-alert query, per-period rolling ICP burn cap -w/ pause + manual override; unit tests for cap window math. -## 2026-06-10 — Iteration 11 — Task 10: Treasury + burn cap (DONE) -- src/backend/Treasury.mo (pure half, §5.3): Queue-backed rolling burn - ledger (time-ordered; burnedInWindow = age < window, recordBurn prunes - front); gate() = the pre-gate, CAP CHECKED BEFORE FLOAT (blast-radius - bound must hold even with a full float; leaked-secret drains have one), - proceed iff burned+mint <= cap AND float >= mint+fee; holdStage = - max-wait (age >= bound escalates); lowFloat/lowFloatSignal (threshold 0 - = disarmed; armed-but-unobserved = low); validateConfig. -- DECISION: default burnCapE8s = 0 — FAIL CLOSED, every mint holds until - the operator sizes the bound (empty-tiers stance: a default cap in ICP - is an invented money decision). Cap 0 doubles as the pause lever. - Defaults: 24h window, 72h max hold, alert disarmed. -- DECISION: cap consumption commits in the SAME §5.1 sync block as the - intent (after transition, before transfer await) and is never refunded - on failure — over-counting is the fail-safe direction. -- DECISION: max-wait escalation reuses #stuckMint{stage="treasuryWait - Exceeded"} (no new ErrorQueue.Kind; doc widened — position is certain - here: fiat in, nothing minted, operator refunds in Stripe Dashboard). -- Main.mo: driveMint intercepts #awaitingTreasury BEFORE Cmc.stageOf - (stageOf + its 13-case test matrix untouched) — in-window holds retry - #begin so the pre-gate is the single decision point; #begin gained the - balance_of await + observeFloat + gate (re-read status #paid OR - #awaitingTreasury after awaits; held #paid → #awaitingTreasury w/ audit, - re-hold silent so updatedAtNs keeps the max-wait clock); sweepMintable - += #awaitingTreasury; persistent treasuryConfig/burnLedger/ - lastFloatObservation; observeFloat audits only the CROSSING into low; - admin set_treasury_config/reset_burn_window/refresh_float + public - treasury_status (frontend soft-gates tiers off lowFloat). -- Tests: treasury (16) — window boundary at exactly-24h, front-prune, - exact cap/float boundaries, cap-beats-float pin, window-roll frees cap, - max-wait boundary, signal matrix. 350 total green. -- Gotcha: own test bug, not code — at t=25h a t=1h burn is EXACTLY 24h old - → pruned (age >= window). First-try clean compile + lint otherwise. -- Verified: mops check ✓ (lint clean), mops test ✓ (350), mops build ✓, - icp build ✓, .did gains exactly the 4 treasury methods. -NEXT: Task 11 — Recovery timer (§5.2): recurringTimer sweep reusing -sweepMintable, single-flight guard, re-arm in postupgrade (transient timer -id), expiry sweep for stale #created orders if in scope; timer-logic tests. -## 2026-06-10 — Iteration 12 — Task 11: Recovery timer (DONE) -- src/backend/Recovery.mo (pure half): validateInterval = cadence bound - interval <= ledgerDedupWindow/4 (§5.1 "cadence ≪ 24h" pinned to ≥4 sweeps - per window — one sweep/window burns the only replay chance on one - transient failure); defaultIntervalNs 1h (25 maxMintRetries × 1h > 24h, - pinned by test); isSweepable extracts sweepMintable's inline status - filter (#paid/#minting/#icpAtCmc/#awaitingTreasury; #created waits on - the user — expiry stays per-rail per seam §11.1.4, NOT in scope here). -- Main.mo: persistent recoverySweepIntervalNs (operator cadence survives - upgrades); transient recoverySweepInFlight single-flight (persistent - flag stuck by mid-sweep upgrade = the §5.2 pumping deadlock) w/ - try/finally; persistent lastRecoverySweep {atNs; pending} liveness - observation; recoveryTimerId = TRANSIENT INITIALIZER calling - Timer.recurringTimer — EOP re-runs transient initializers on - every upgrade, so this IS the postupgrade re-arm (no system func - needed; IC drops timers on upgrade so nothing stale to cancel). - Webhook kick deliberately bypasses the sweep flag (in-flight sweep - enumerated pending before the new order turned #paid; guarded kick - could cost a paying user a full interval). 24h stale-intent guard - needed zero new code — Cmc.stageOf (task 9) already escalates. - Admin set_recovery_interval (validated vs Cmc.ledgerDedupWindowNs, - cancelTimer + re-arm immediately, audited) + public recovery_status. -- Tests: recovery (8) — zero/min/exactly-window÷4 vs +1ns boundaries, - default-legal-vs-real-window, 25×default>24h retry-budget pin, - sweepable matrix pinned exhaustive 4-of-8. 358 total green. -- Gotchas: none — first-try clean compile AND lint. Note import order in - Main.mo is case-INsensitive by path ("Recovery" sorts before - "rails/Card"); shared (non-query) funcs have capability so - set_recovery_interval can re-arm without tricks. -- Verified: mops check ✓ (lint clean), mops test ✓ (358), mops build ✓, - icp build ✓, .did gains exactly set_recovery_interval/recovery_status. -NEXT: Task 12 — PocketIC integration suite, the Card go-live bar (§9): -real ledger/CMC Wasms, crafted HMAC-signed webhooks, mocked forex outcall, -time control; happy path, duplicate/replay, ambiguous-transfer recovery, -AwaitingTreasury, Type 1/Type 2, forex fail-closed, upgrade-mid-flight, -postupgrade timer re-arm. -## 2026-06-10 — Iteration 13 — Task 12: PocketIC suite (IMPLEMENTED; run pending 4K-page host) -- test/integration/: TS suite, vitest + @dfinity/pic 0.22 (bundles pocket-ic - server 14). KEY DISCOVERY: server-14 `icpFeatures` deploys the REAL NNS - ICP ledger + CMC + cycles ledger at mainnet IDs (and funds the anonymous - principal 1B ICP = our operator float source) — no wasm downloads, no - init-arg candid, satisfies §9 "real Wasms" with zero drift surface. - CMC rate driven via governance-impersonated set_icp_xdr_conversion_rate - (hidden update, caller==governance check only — PocketIC allows any - sender); MUST re-set after every advanceTime >15min (Cmc.cmcRateMaxAgeNs). -- Files: src/idl.ts (hand-transcribed backend.did factory + icrc1/cmc - subsets), src/types.ts (typed candid mirrors), src/harness.ts (setup, - governance/operator helpers, node:crypto Stripe signer, outcall mock via - deferred-actor submit→mock→resume, tick-stepped status polling), - src/gateway.spec.ts (15 sequential scenarios, one shared instance). -- Scenario highlights: §3 pricing vector asserted through the real - forex_transform; exactly-one-float-debit invariant on the happy path AND - after upgrade-mid-transfer replay (§5.1: tick until #minting commits, - upgrade kills the callback, advanceTime(1h) fires the re-armed transient - timer, replay recovers block via dedup — float delta == e8s+fee ONCE); - upgrade-mid-forward caught between cyclesMinted marker and delivery → - #ambiguousForward, never re-forwards; Type 2 via forward to the app - subnet's range-top canister id (never allocated); cycles-ledger delivery - arm against the real cycles ledger; treasury hold (cap-0 default), resume, - 73h max-wait escalation; full dedup/Type-1/refund-auto-resolve matrix. -- BLOCKER (environment, not code): IC replica memory tracker asserts - PAGE_SIZE==4096; this sandbox VM is 16K-page aarch64 → pocket-ic crashes - at instance creation (verified; jemalloc warning is benign, the - memory_tracker assert is not). No KVM. qemu-user x86_64: pocket-ic - segfaults in jemalloc init — guest mmap returns bit-47-set addresses - (host-VA passthrough), noncanonical for x86_64 userspace; -R reserved-VA - workaround fatally conflicts with vsyscall page alloc. Dead end, abandoned. -- CI route: wrote .github/workflows/integration.yml but the sandbox's OAuth - token LACKS `workflow` scope (push rejected) → parked at - test/integration/ci/integration.yml w/ install instructions in README. -- Verified here: tsc --noEmit clean, vitest lists all 15, mops check ✓, - mops test ✓ (358), mops build ✓, icp build ✓. PRD task 12 → ◐. -NEXT: run the suite on a 4K-page host (operator: `cd test/integration && -npm ci && npm test` on the Mac, or git mv ci/integration.yml into -.github/workflows/ and push w/ a workflow-scoped token), fix whatever the -live run reveals, flip task 12 to ☑; then task 13 (frontend M1). -## 2026-06-10 — Iteration 14 — Task 13: Frontend M1 (DONE) -Task 12 stays ◐ (blocked on 4K-page host, see memory) — task 13 was the -highest-priority actionable item. -- src/frontend/: Vite + vanilla-TS SPA. DECISION: Astro rejected (SSG - machinery around the same Vite for one interactive page — the - mo:server/hmac/json dependency stance). deps: @icp-sdk/auth ^7, core ^5.3; - dev: bindgen ^0.3, vite 6, vitest 3, @types/node. -- Bindings: @icp-sdk/bindgen Vite plugin regenerates src/bindings/backend.ts - from the COMMITTED backend.did every build (typed actor can't drift). - .gitignore now `src/backend/dist/*` + `!…/backend.did` (spec §8 committed - .did; mops build refreshes it → self-maintaining). NOTE bindgen wrapper - shapes: variants are `__kind__`-discriminated unions, opt fields are - `?: T` (undefined, NOT null) — but top-level opt RETURNS are `T | null`; - OrderStatus is a TS string enum. Frontend types derived structurally from - the actor (Awaited>), immune to bindgen export naming. -- II: @icp-sdk/auth 7.x, `new AuthClient({identityProvider: - "https://id.ai/authorize"})` — /authorize REQUIRED in 7.x (URL verbatim); - mainnet II works against local replicas (icp-cli ≥0.2.4 trusts mainnet - sigs) so no env branching. ic_env cookie via safeGetCanisterEnv for - canister id + rootKey (never fetchRootKey — MITM). createActor takes - {agentOptions}, NEVER {agent} (silent anonymous downgrade). -- UI: tier picker (card_tiers), lowFloat soft-gate notice (treasury_status), - both Destination arms (canister / cycles-ledger acct + optional hex - subaccount, left-padded to 32B), create_order error→message map - (rateUnavailable = "nothing was charged", §3.1), Payment Link = - tier.paymentLinkUrl + client_reference_id (links are session-local — the - backend stores none; reopened created-orders show status, no pay button), - 3s get_order polling → 4-step timeline, exactly delivered/errorQueue stop - the poll (pinned), expired stays pollable (§4 advisory), list_orders - history w/ row-click re-poll, rail tabs = §11.1 seam (ck-USDC disabled). -- .ic-assets.json5: allow_raw_access false, standard security_policy, - immutable assets/ caching, SPA aliasing. -- icp.yaml: frontend canister via @dfinity/asset-canister@v2.2.1 — v2.1.0 - (the skill's version) PANICS on icp-cli 0.3.2: its `assets` sync step - type was removed; 2.2.1 uses the plugin-based sync. -- vite.config.ts gotcha: vitest runs with command==="serve" → dev-server - config (which shells out to `icp network status`) must ALSO gate on - mode !== "test". -- Tests: 21 vitest (terminal-set pin, step monotonicity, link ?/& cases, - cycles/USD formatting incl. §3 vector 3_353_350_000_000 → "3.353 T", - subaccount hex matrix, error-message distinctness). tsc --noEmit strict - clean; vite build ✓; mops check/test (358)/build ✓; icp build ✓ both - canisters. Live II popup + certified serving need a browser + deployed - canister → dev-host verification alongside task 12's suite. -NEXT: task 14 — ck-USDC rail (rails/CkUsdc.mo, §6.2): ICRC-1/2 bindings, -icrc2_transfer_from pull after user approve, block_index dedup, amount-short -mismatch handling, hold-ckUSDC treasury posture; unit tests. -## 2026-06-10 — Iteration 15 — Task 14: ck-USDC rail (DONE) -Task 12 stays ◐ (blocked on 4K-page host, see memory) — task 14 was the -highest-priority actionable item. -- src/backend/rails/CkUsdc.mo (pure half): ICRC-1/2 types (self-contained, - no Cmc import); ledger xevnm-gaaaa-aaaar-qafnq-cai pinned; 1¢ = 10^4 units - (6 decimals + 1:1 peg ⇒ usdCents pricing exact on money-in); buildPullIntent - = §5.1 write-intent-before-call, MONEY-IN edition (transfer_from debits the - USER): created_at_time frozen, memo = orderId UTF-8 (exactly the 32-byte - ICRC-1 bound, ledger-tx→order audit link); transferFromArgs pure projection - → replay bit-identical; interpretPull encodes the DEDUP-FIRST rule - (ic-icrc1 checks created_at_time dedup before balance/allowance, so - InsufficientAllowance/Funds/BadFee PROVE nothing ever moved → #drop intent, - clean user error = §6.2 amount-short handling; TooOld = #uncertain, - escalate; TemporarilyUnavailable/CreatedInFuture/GenericError = #retry); - pull journal (financial record, never pruned) w/ escalatedAtNs once-only - mark; claimStage = whole resume decision pure (#created/#expired claimable, - block-beats-escalation #recoverBlock heal, exactly-window escalates). -- Main.mo: create_ck_usdc_order(usdCents, destination) — NO tiers (nothing - structural pins the amount like a Payment Link; canister pulls exact - price); operator bounds FAIL CLOSED (maxUsdCents=0 default = rail - disabled); rail fee formula default 0/0 (no processor fee; conversion cost - operator-absorbed §3), admin-settable. claim_ck_usdc_order: owner-only (no - existence leak), wrongRail guard, transient pullsInFlight single-flight, - driveClaim: stage → pull → creditPull (dedup recordCkUsdcBlock + journal - block + #paid ONE sync block) → detached processMint kick. Stale intent → - #stuckMint{stage="stalePullIntent"}, order STAYS #created (no legal edge, - position maybe-nothing-happened); ops levers: ck_usdc_pull (admin query), - reset_ck_usdc_pull (refuses if block recorded), withdraw_ck_usdc (hold- - ckUSDC posture: accrued ckUSDC → operator → off-chain →ICP float refill). - Refactor: quoteCents (per-rail fee over shared rate cache) + - quoteWithRefresh + createOrderWithFreshId; card create_order delegates. -- Tests: ckusdc (32) — pinned ledger id/window/units, config+amount boundary - matrices, intent/wire-form vectors (32B memo), interpretPull matrix, - journal patch + no-op-on-missing, claimStage all-8-status pin + window - boundary + once-only escalation. 390 total green. -- Gotchas: `transient` is a moc 1.9 KEYWORD — illegal as a variant tag - (#transient → syntax error M0001); renamed #retryable. `ignore async {}` - needs send capability — NOT available in a plain (non-async*) private - func; inline the detached kick into the async* driver instead of a helper. -- Verified: mops check ✓ (lint clean), mops test ✓ (390), mops build ✓, - icp build ✓ both canisters; .did gains exactly the 7 ck_usdc methods; - frontend rebuilt against new committed .did (tsc strict + vite build + 21 - vitest ✓ — bindgen regenerates, M2 UI is task 16). -NEXT: Task 15 — PocketIC suite, ck-USDC go-live bar: deploy a local ICRC-2 -ledger as ck-USDC, approve/pull happy path, dedup/replay, amount-short, -stale-intent escalation, treasury interplay (same 4K-page host constraint -as task 12 — implement + typecheck here, execute on dev host/CI). -## 2026-06-10 — Iteration 16 — Task 15: PocketIC ck-USDC bar (IMPLEMENTED; run pending 4K-page host) -RESUMED IN-FLIGHT WORK: previous session left harness.ts/idl.ts/types.ts -ck-USDC halves uncommitted (icrc2 factory, ledger-init encoder, CkLedger -install/approve helpers, fiduciary subnet in setup) but no spec file, no -wasm fetch, no package wiring. Reviewed the in-flight IDL field-by-field -against the committed backend.did (exact match) and kept it. -- KEY MECHANISM: PocketIC's fiduciary subnet mirrors mainnet's canister - range, so setupCanister({targetCanisterId}) installs the REAL - ic-icrc1-ledger at exactly xevnm-gaaaa-aaaar-qafnq-cai — the id CkUsdc.mo - pins. Wasm from pinned release ledger-suite-icrc-2026-03-09, sha256 - 354dd6ec… verified by new scripts/fetch-ledger-wasm.mjs (binary - gitignored, pin committed — §8 stance); init args verified against the - wasm's own candid:service metadata via ic-wasm. pretest = fetch + mops - build, so dev-host/CI `npm test` runs both bars unchanged. -- src/ckusdc.spec.ts: 11 scenarios, own instance. Fail-closed rail config - (default-disabled, gated, validated, public) + bounds-before-quote; - §3 pricing twin (500¢ @ 0/0 formula → 3.685T locked, end-to-end through - the real transform); claim guards (anon/non-owner/unknown/wrongRail/ - no-approval); §6.2 amount-short BOTH arms (allowance short → required - carried, intent dropped, order claimable; balance short ditto) then - approve→pull → Paid w/ exact ledger accounting (debit/credit/allowance- - spent/32B memo); §5.1 money-IN replay — upgrade mid-pull after intent - journals, orphaned transfer_from executes, re-claim replays identical - args → ledger #Duplicate → debited EXACTLY once (balances pinned); - stale-intent via stopCanister on the ledger (deterministic never-executed - pull) + 24h jump → once-only stalePullIntent escalation, order stays - Created, reset_ck_usdc_pull reopens (refusal-on-settled also pinned) → - claim → delivered; treasury interplay (cap-0 hold → size → process_order - → delivered, one float debit, ceil-dust 25_000 cycles operator-side); - withdraw lever (3-pull accrual − fee, over-withdraw → err); trail (tags, - seq, zero open entries after operator resolve). -- Gotchas: vitest fileParallelism:false runs spec files sequentially — - ckusdc gets its own instance, no card-state interference. Actor typing: - spec-local poorUser actor needs `Actor` from @dfinity/pic - (createActor's ReturnType isn't generic-bound). GitHub release URL 302 ≠ - exists-check: probe with -L download + gunzip -t + wasm magic. -- Verified: fetch script ✓ (sha256 match), tsc --noEmit ✓, vitest lists 26 - (15 card + 11 ck), mops check ✓, mops test ✓ (390), mops build ✓, - icp build ✓, .did unchanged. PRD task 15 → ◐ (same actually-ran bar as - task 12). Also committing the user's afk-ralph.sh tweak (usage guard + - push step) found in the working tree. -NEXT: task 16 — Frontend M2: ck-USDC panel (amount input within public -ck_usdc_config bounds, create → icrc2_approve via wallet/ledger → claim -flow w/ claim-error→action mapping, rail tab enable); then tasks 17–18 -(reproducible build + runbook). Tasks 12/15 still need the dev-host/CI run. -## 2026-06-10 — Iteration 17 — Task 16: Frontend M2 ck-USDC panel (DONE) -Tasks 12/15 stay ◐ (4K-page host, see memory) — task 16 was the highest- -priority actionable item. -- index.html restructured: rail tabs are REAL tabs now — per-rail panels - (Card: tiers; ck: amount input + bounds line + fail-closed "not enabled" - notice off maxUsdCents==0) feeding ONE shared destination form + submit; - lowFloat notice hoisted above tabs (treasury hold is rail-agnostic); - active-order gains ck-pay-area (Approve/Claim buttons + flow status) and - a Rail row; history gains a Rail column. -- src/ledger.ts: hand-rolled minimal ICRC-2 actor (raw IDL — external - canister, no committed .did for bindgen; 3 methods: balance_of/allowance/ - approve), ledger id pinned = CkUsdc.mo's mainnet xevnm-…-cai (PocketIC - fiduciary subnet hosts it at the same id → no env branching); actor.ts - agentOptions extracted+shared (ic_env host/rootKey); identity REQUIRED - (anonymous approve burns fee from the shared anonymous account). -- Flow: create_ck_usdc_order → "1·Approve" (icrc2_approve, spender=backend, - amount=approveUnits, II identity) auto-chains into claim; "2·Claim" - standalone for approved-earlier/retry. RECONSTRUCTABLE cross-session - (unlike card pay links): units = pricing.usdCents·10^4 + public config - ledgerFee; claim's insufficientAllowance.required is ledger-authoritative - and overrides per-order (ckRequiredUnits map). Per-order busy guard; - renderCkPay re-renders on poll ticks w/o clobbering flow status. -- format.ts (pure, tested): ckUnitsForCents (10^4 pin), formatCkUsdcUnits - (6dp, ≥2 shown), parseUsdAmount (strict: optional $, ≤2dp, no guessing), - createCkOrderErrorMessage (bounds carry $ values), claimErrorInfo §6.2 - action mapping (amount-short → approve/fund + "nothing was charged"; - retryable/inFlight → retry; staleIntent/badFee/ledgerRejected/ - notClaimable/wrongRail/notFound/anonymous → "none", pinned ALL-7 sweep; - staleIntent says "not charged twice" §5.1), approveErrorMessage (raw - one-key variant records). -- Tests: +17 vitest (38 total). Verified: tsc --noEmit strict ✓, vite - build ✓, mops check ✓, mops test ✓ (390), mops build ✓, icp build ✓ - both canisters, committed .did unchanged (bindings regen identical). -- Gotcha: bindgen surfaces PAYLOAD-LESS variants (Rail, OrderStatus) as TS - string enums, NOT __kind__ unions — compare via (x as unknown as string). -NEXT: task 17 — Reproducible build + release (spec §8): Docker-pinned -build, ic-wasm deterministic optimize + metadata, committed .did (already -self-maintaining), published expected module hash per tagged release, -asset-canister certified frontend. Tasks 12/15 still need the dev-host/CI -run (live browser approve→claim rides the same session). -## 2026-06-10 — Iteration 18 — Task 17: Reproducible build + release (DONE) -Tasks 12/15 stay ◐ (4K-page host, see memory) — task 17 was the highest- -priority actionable item. -- icp.yaml backend recipe += shrink: true (ic-wasm deterministic optimize, - 877K → 834K; ALL metadata sections survive incl. motoko:stable-types + - enhanced-orthogonal-persistence — checked, upgrades depend on them) and - candid: src/backend/dist/backend.did (committed .did IS the embedded - candid:service — bindgen input and deployed interface provably same bytes; - verified by diffing the embedded section). NO compress: on-chain module - hash stays directly sha256sum-comparable. -- Dockerfile.release: node:22.22.1-bookworm-slim BY DIGEST (multi-arch - manifest list — per-platform resolve, caveat doc'd), libdbus-1-3 (icp-cli - needs it), npm -g ic-mops@2.13.2 @icp-sdk/icp-cli@0.3.2 - @icp-sdk/ic-wasm@0.9.11; moc 1.9.0 rides the committed mops.toml; final - `FROM scratch` stage so `docker build --output type=local` writes exactly - the artifacts. -- scripts/release-build.sh (inner; rm -rf artifact cache FIRST — determinism - re-earned every build, never assumed) → backend.wasm/frontend.wasm/ - backend.did/MODULE-HASHES.txt; scripts/reproducible-build.sh (host - wrapper): `git archive | docker build -` — committed-tree-only - context, dirty-tree warning; hashes are a pure function of the ref. -- RELEASE.md: pin table, cut-a-release (tag → build → publish - MODULE-HASHES.txt → deploy → GATE on icp canister status == published - hash), verify-a-release (`icp canister status -n ic --public` works for - non-controllers + dashboard), frontend posture (module hash = pinned - recipe wasm; asset CONTENT verified per-response by certified assets + - allow_raw_access:false; dist/ rebuildable from lockfile), caveats stated - (arch independence verified arm64-only; recipe tags not content-addressed). -- VERIFIED REPRODUCIBILITY EMPIRICALLY: warm-cache rebuild, clean-cache - rebuild, and the full container build (fresh toolchain npm install, fresh - mops/moc downloads) all byte-identical: backend 2cd20d4e…, frontend - 04e565b3… . Native == container on this host. -- Gotchas: `docker build -` + git-archive tar needs Dockerfile.release IN - the context (committed — test pre-commit used a hand-built tar of - git ls-files + new files); `icp canister status` exists in 0.3.2 w/ - --public state-tree fallback for non-controllers (don't guess dfx names). -- Verified: mops check ✓, mops test ✓ (390), mops build ✓, icp build ✓ both - canisters, committed .did unchanged, container build E2E ✓. -NEXT: task 18 — Ops runbook (secret provisioning/rotation, error-queue -resolution incl. Stripe Dashboard refunds, float refill, burn-cap sizing/ -override, ck-USDC levers, confidential-subnet checklist §7/§11.1). Tasks -12/15 still need the dev-host/CI run. -## 2026-06-10 — Iteration 19 — Task 18: Ops runbook (DONE) -Tasks 12/15 stay ◐ (4K-page host, see memory) — task 18 was the last -buildable task. -- RUNBOOK.md: written from the CODE, not the spec — every method/arg/ - default/capacity checked against Main.mo, every CLI command checked - against installed icp-cli 0.3.2 (`icp canister settings update - --add-controller`, NOT dfx's update-settings; `icp canister top-up`, - NOT deposit-cycles — both were wrong in the first draft and caught by - running --help). -- Sections: operating model (controllers = §7 allowlist, units table); - go-live checklist = exactly the fail-closed defaults (secret, tiers, - cap 0, ck max 0, alert, webhook endpoint w/ the two event types); - secret provisioning/rotation (generation counter = no-read-back - confirmation, Stripe overlap, boundary-node exposure → rotate-after- - provision, leak procedure: cap-to-0 FIRST then rotate then reconcile - then reset_burn_window); tiers/forex; treasury (cap sizing guidance: - too-small delays, too-large is unbounded — start tight); ERROR-QUEUE - TRIAGE TABLE — every Kind + every stuckMint stage → money position → - action incl. the never-rules (never rebuild stale intent, never - re-forward ambiguous); ck-USDC levers (stalePullIntent procedure off - ledger memo = orderId, reset refusal semantics, withdraw as refund + - float-conversion loop); recovery/manual kicks; monitoring cadence - (healthy reads of the 5 status queries, audit seq gaps = drops); - §7/§11.1 confidential-subnet checklist hardest-first (checkpoint/ - state-sync confidentiality before attestation before maturity); - upgrade ops notes (mid-flight safe by §5.1; transient knobs reset by - design). -- Verified: mops check ✓ (lint clean), mops test ✓ (18 files/390), - mops build ✓, icp build ✓, .did untouched. PRD task 18 → ☑. -NEXT: PRD is complete except tasks 12/15 — both fully implemented + -typechecked, blocked ONLY on executing PocketIC on a 4K-page host -(operator: `cd test/integration && npm ci && npm test` on the Mac, or -git mv test/integration/ci/integration.yml → .github/workflows/ and push -w/ a workflow-scoped token). Nothing further is actionable in-sandbox. diff --git a/scripts/_github_anchor.py b/scripts/_github_anchor.py new file mode 100755 index 0000000..63de238 --- /dev/null +++ b/scripts/_github_anchor.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""GitHub's heading-anchor rule, in one place. + +⚠️ **One implementation, imported, not restated.** Two scripts need it — +`check-doc-links.py` to verify the anchors written in the docs, `release-notes.py` to +build the link into `CHANGELOG.md` at a tag — and a second copy is the same class as a +restated figure: identical today, silently divergent later. The release-notes copy is the +one nothing would catch, because `release/NOTES.md` is build output that no gate step ever +reads. + +The rule: lowercase, drop everything that is not a word character, space or hyphen, then +spaces to hyphens. Two consequences that look like bugs and are not — an em dash is +dropped and leaves the spaces either side, so `Mode 1 — local` is `mode-1--local` with two +hyphens; and a version's dots vanish, so `0.1.0-beta.1` is `010-beta1`. The second was +verified against GitHub's rendered page, not derived. + +⚠️ Leading underscore: this is a module, not a gate step. `scripts/test-all.sh` and +`.github/workflows/mops-test.yml` name their checks explicitly, so nothing tries to run it. + +⚠️ **Being importable costs a trap: `rm -rf scripts/__pycache__` before trusting a +mutation test on this file.** CPython invalidates a `.pyc` on (mtime, size), and editing +one character here changes neither the size nor — within the same second — the mtime. A +mutation of this module and its restore both ran against stale bytecode once, which +reported a passing self-test over a broken rule and then a broken one over the fixed +rule. The other `scripts/` checks are run as programs and never cached. +""" + +import re + + +def anchor(heading: str) -> str: + s = heading.strip().lower() + s = re.sub(r"[^\w\s-]", "", s) + return s.replace(" ", "-") + + +def self_test() -> None: + """Unconditional at both call sites: a wrong rule fails every link or none, and both + read as "the check ran".""" + assert anchor("Mode 1 — local") == "mode-1--local" + assert anchor("Mode 2 — mainnet simulation") == "mode-2--mainnet-simulation" + assert anchor("What is pinned") == "what-is-pinned" + assert anchor("`state_hash` is a fingerprint, not a check") == "state_hash-is-a-fingerprint-not-a-check" + assert anchor("1. The one-sentence version") == "1-the-one-sentence-version" + # ⚠️ **Do not drop this as redundant with the heading cases above.** It is the only + # thing protecting a PUBLISHED url: `release-notes.py` builds + # `…/blob/vX.Y.Z/CHANGELOG.md#` from a version string, and nothing in the + # gate exercises that path — `release/NOTES.md` is build output no check reads. The + # heading cases are exercised against real links by `check-doc-links.py`; this one is + # exercised by nothing else. Verified against GitHub's rendered page. + assert anchor("0.1.0-beta.1") == "010-beta1" diff --git a/scripts/_recipe_pin.py b/scripts/_recipe_pin.py new file mode 100644 index 0000000..7a108ae --- /dev/null +++ b/scripts/_recipe_pin.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""The `@dfinity/static-site` recipe pin in `icp.yaml`, read in one place. + +⚠️ **One implementation, imported, not restated.** Two scripts need it — +`check-frontend-hash.py` to pick the verifier that computes the frontend state hash, and +`release-notes.py` to publish the certified-assets release that hash was computed under — +and the two must agree or the notes pair a number with the wrong release, which reproduces +for nobody. A second copy is the same class as a restated figure: identical today, +silently divergent later. Same reason [`_github_anchor.py`](_github_anchor.py) exists. + +The pin IS the canister version: the recipe and the canister ship as a version-locked +pair, so the recipe a project pins is the release its canister runs. + +⚠️ Leading underscore: this is a module, not a gate step. `scripts/test-all.sh` and +`.github/workflows/mops-test.yml` name their checks explicitly, so nothing tries to run it. + +⚠️ **Being importable costs a trap: `rm -rf scripts/__pycache__` before trusting a +mutation test on this file** — CPython invalidates a `.pyc` on (mtime, size), and editing +one character changes neither within the same second. See `_github_anchor.py`, where that +reported a passing self-test over a broken rule. +""" + +import re +import sys +from pathlib import Path + +PIN = re.compile(r'type:\s*"@dfinity/static-site@v(\d+\.\d+\.\d+)"') + + +def pin_in(text: str) -> str | None: + """The pinned certified-assets version in an `icp.yaml`'s text, or None.""" + m = PIN.search(text) + return m.group(1) if m else None + + +def pin_of_tree(tree: Path = Path(".")) -> str: + """The pin in `tree/icp.yaml`. Exits rather than returning a default: a wrong + version computes a different hash from identical files, so a guess would surface + as an unexplained mismatch.""" + path = tree / "icp.yaml" + if not path.is_file(): + sys.exit(f"error: {path} does not exist — expected a repo root") + version = pin_in(path.read_text()) + if version is None: + sys.exit(f"error: no `@dfinity/static-site@vX.Y.Z` recipe pin found in {path}") + return version + + +def self_test() -> None: + """Unconditional at every call site. The pattern reads a file another tool owns, and + its wrong-answer mode is picking a DIFFERENT recipe's version, not failing to match.""" + assert pin_in(' type: "@dfinity/static-site@v0.3.3"') == "0.3.3" + # The same file pins other recipes; matching one of those would publish a version + # that has nothing to do with the assets canister. + assert pin_in('type: "@dfinity/motoko@v5.1.0"') is None, "matched the wrong recipe" + assert pin_in('type: "@dfinity/prebuilt@v2.1.0"') is None, "matched the wrong recipe" + assert pin_in("no pin here") is None + # Quoting and spacing are the recipe author's, not ours. + assert pin_in('type:"@dfinity/static-site@v1.2.3"') == "1.2.3" + + +if __name__ == "__main__": + self_test() + print("_recipe_pin self-test passed") diff --git a/scripts/brand-lint.sh b/scripts/brand-lint.sh new file mode 100755 index 0000000..d9207cd --- /dev/null +++ b/scripts/brand-lint.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# Check user-facing copy against the Internet Computer brand guidelines (v2.30). +# +# Only the rules that are mechanically checkable. Typography, hierarchy and the +# italic rule need eyes; banned characters and banned vocabulary do not, and those +# are precisely the ones that creep back in one commit at a time. +# +# Scope is deliberately narrow: index.html, and STRING LITERALS in the frontend +# sources. Code comments, docs and commit messages are not brand surfaces and are +# not checked — the em-dash rule in particular would otherwise fire on most of +# this repo's prose, which is written for engineers, not visitors. +set -uo pipefail + +cd "$(dirname "$0")/.." +FE=src/frontend +fail=0 + +report() { + fail=1 + printf '\n\033[31m✗ %s\033[0m\n' "$1" + shift + printf '%s\n' "$@" | sed 's/^/ /' +} + +command -v perl >/dev/null 2>&1 || { + echo "perl not found; it is required for the character checks" >&2 + exit 2 +} + +# Every user-facing string literal: double-quoted, single-quoted, AND template +# literals. Template literals were the gap that mattered — three em-dashes shipped +# inside `${...}` interpolations while this script reported clean, because it only +# looked at "double quotes". +# +# perl, not `grep -P`: BSD grep has no -P, and with stderr suppressed the failure +# was silent, so the emoji check simply passed everywhere it could not run. +read -r -d '' EXTRACT_LITERALS <<'PERL' || true +while ($line = <>) { + $n++; + while ($line =~ /"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`/g) { + print "$ARGV:$n:$&\n"; + } + $n = 0 if eof; +} +PERL + +literals() { + perl -CSD -e "$EXTRACT_LITERALS" "$FE"/src/*.ts 2>/dev/null | grep -v '\.test\.ts:' +} + +# --- canary: do the checks below actually work? ------------------------------- +# A broken extractor or a regex that matches nothing reports CLEAN, and a clean +# report is indistinguishable from a passing one. That is the worst failure this +# script can have: every rule silently becomes a no-op and the gate stays green. +# It has happened twice already — `grep -P` missing on BSD with stderr suppressed, +# and template literals not being extracted at all — so the machinery is now +# checked before its findings are trusted. +canary() { + fail=1 + printf '\n\033[31m✗ brand lint is not working: %s\033[0m\n' "$1" >&2 +} + +# `grep -c`, never `grep -q`, throughout the canaries: this script runs under +# `pipefail`, and `-q` exits on its first match, which SIGPIPEs the producer and +# makes the whole pipeline report failure. A canary that fires on success is worse +# than no canary. +[ -s "$FE/index.html" ] || canary "$FE/index.html is missing or empty, so every check against it looked at nothing" + +LITERAL_COUNT="$(literals | wc -l | tr -d ' ')" +# An order of magnitude below the real count (~1000), so this fires on "the +# extractor is broken" and never on ordinary editing. +[ "${LITERAL_COUNT:-0}" -ge 100 ] || + canary "the extractor found only ${LITERAL_COUNT:-0} string literals in $FE/src/*.ts" + +# Template literals specifically: they were the gap that mattered, because three +# em-dashes shipped inside `${...}` interpolations while this script reported clean. +TEMPLATE_COUNT="$(literals | grep -c '`' || true)" +[ "${TEMPLATE_COUNT:-0}" -gt 0 ] || + canary "the extractor found no template literals, which is where the last escape happened" + +# And prove the character classes can see a planted violation. A locale problem +# makes these match nothing while still exiting 0. +EM_CANARY="$(printf 'x:1:"an em dash %s here"\n' "—" | grep -c '—' || true)" +[ "${EM_CANARY:-0}" -gt 0 ] || canary "the em-dash pattern does not match a line that contains one" + +# --- banned characters ------------------------------------------------------- +# U+2014 EM DASH: the guidelines name a colon, period, or parentheses instead. +hits="$( { grep -n '—' "$FE/index.html"; literals | grep '—'; } 2>/dev/null )" +[ -n "$hits" ] && report "em-dash (U+2014) in user-facing copy (use a colon, period, or parentheses)" "$hits" + +# Emoji and pictographs. ⚠ and ✓ count: they render as emoji on most platforms, +# and the voice is meant to be calm and factual rather than decorated. +PICTO='[\x{1F300}-\x{1FAFF}\x{2600}-\x{27BF}\x{2B00}-\x{2BFF}\x{FE0F}]' +# Canary for this class specifically: it is the one that silently passed +# everywhere it could not run. +PICTO_CANARY="$(printf 'x:1:"a rocket 🚀"\n' | perl -CSD -ne "print if /$PICTO/" | wc -l | tr -d ' ')" +[ "${PICTO_CANARY:-0}" -gt 0 ] || + canary "the pictograph class does not match a line that contains one (perl -CSD?)" +hits="$( { perl -CSD -ne "print qq(\$ARGV:\$.:\$_) if /$PICTO/" "$FE/index.html"; \ + literals | perl -CSD -ne "print if /$PICTO/"; } 2>/dev/null )" +[ -n "$hits" ] && report "emoji or pictograph in user-facing copy" "$hits" + +# --- banned vocabulary ------------------------------------------------------- +# Bare "on-chain"/"onchain" must be replaced by the app attribute it is standing +# in for (tamperproof, unstoppable, sovereign). The others have direct +# substitutions in the guidelines' table. +BANNED='on-chain|onchain|blockchain|decentrali[sz]ed|smart contract' +hits="$( { grep -nEi "$BANNED" "$FE/index.html"; literals | grep -Ei "$BANNED"; } 2>/dev/null )" +[ -n "$hits" ] && report "banned vocabulary (see the substitution table in the guidelines)" "$hits" + +# CSS with /* comments */ stripped. Without this, a comment explaining one of +# these rules trips the rule it explains — which is exactly what happened the +# first time this script ran. +# +# ⚠️ **`index.html` is deliberately NOT treated the same way: it is read whole, +# comments included.** So an HTML comment mentioning a banned glyph fails the +# pictograph check while a CSS comment doing the same does not. That asymmetry is +# intentional and it has caught a real comment in this repo's markup, but +# it reads as a bug when you hit it, so: the CSS stripping exists because CSS +# comments are where the RULES get explained, next to the properties they +# constrain. HTML comments sit inside the document a visitor is served, and +# narrowing this to "outside comments only" would need an HTML parser to be +# correct rather than a regex that can be defeated by a comment inside a string +# attribute. Reword the comment. +css_code() { + perl -0777 -pe 's{/\*.*?\*/}{}gs' "$1" | grep -n '' | sed "s|^|$1:|" +} + +# --- hardcoded colour -------------------------------------------------------- +# Everything must come from tokens.css, which is the guidelines' own review rule +# and the only thing keeping the dark theme correct. #fff is the one exception: +# on a rust or near-black fill it is that fill's contrast pair, not a theme value. +hits="$(css_code "$FE/src/styles.css" \ + | grep -E '#[0-9a-fA-F]{3,8}\b|\b(rgba?|hsla?|oklch|color-mix)\(' \ + | grep -vE '#fff\b' || true)" +[ -n "$hits" ] && report "hardcoded colour in styles.css (add a token instead)" "$hits" + +# --- the theme rule ---------------------------------------------------------- +# Dark is opt-in via data-theme only. Auto-switching from the OS preference is +# explicitly forbidden, and a media query in the stylesheet is how it comes back. +hits="$( { css_code "$FE/src/styles.css"; css_code "$FE/src/tokens.css"; } | grep 'prefers-color-scheme' || true)" +[ -n "$hits" ] && report "prefers-color-scheme in CSS (dark is opt-in via data-theme, never automatic)" "$hits" + +if [ "$fail" -eq 0 ]; then + printf '\033[32m✓ brand lint passed\033[0m\n' +else + printf '\n\033[31mbrand lint failed\033[0m\n' >&2 + exit 1 +fi diff --git a/scripts/capture-stripe-fixtures.sh b/scripts/capture-stripe-fixtures.sh new file mode 100755 index 0000000..310222f --- /dev/null +++ b/scripts/capture-stripe-fixtures.sh @@ -0,0 +1,272 @@ +#!/usr/bin/env bash +# Capture REAL Stripe event bodies as test fixtures. +# +# Why: every Stripe payload in this repo is JSON we wrote from the API docs. The +# suites therefore prove the canister matches *our reading* of Stripe, not Stripe. +# That has already bitten once — a unit test "covered" delayed-payment settlement by +# sending a second `checkout.session.completed`, an event Stripe does not send. +# +# This script listens with `stripe listen --print-json`, sorts each event into +# test/integration/fixtures/.json, and tells you which are still missing. Run +# it, perform the actions it lists, Ctrl-C. No copy-pasting from the Dashboard. +# +# Usage: +# scripts/capture-stripe-fixtures.sh # capture + forward to a local gateway +# scripts/capture-stripe-fixtures.sh --no-forward # capture only, do not deliver anywhere +# scripts/capture-stripe-fixtures.sh --status # what has been captured so far +# +# Prerequisites: +# brew install stripe/stripe-cli/stripe jq +# stripe login # choose a SANDBOX account, never live +set -euo pipefail + +cd "$(dirname "$0")/.." +FIXTURES="test/integration/fixtures" +FORWARD=1 + +for arg in "$@"; do + case "$arg" in + --no-forward) FORWARD=0 ;; + --status) FORWARD=2 ;; + -h | --help) + sed -n '2,20p' "$0" | sed 's|^# \{0,1\}||' + exit 0 + ;; + *) + echo "unknown argument: $arg" >&2 + exit 2 + ;; + esac +done + +die() { + echo "error: $*" >&2 + exit 1 +} + +# The fixtures we want, and what produces each. Kept in one place so --status and +# the closing summary cannot drift apart. +wanted() { + cat <<'LIST' +checkout.session.completed.paid|create an order in the app and pay its session URL with 4242 4242 4242 4242 +checkout.session.completed.unpaid|arrives free with 'stripe trigger checkout.session.async_payment_succeeded' +checkout.session.async_payment_succeeded|stripe trigger checkout.session.async_payment_succeeded +checkout.session.async_payment_failed|stripe trigger checkout.session.async_payment_failed +charge.refunded.full|refund a charge IN FULL in the Dashboard +charge.refunded.partial|refund PART of a charge (e.g. $1 of $5) — the case that survived 3 review rounds +charge.dispute.created|stripe trigger charge.dispute.created +LIST +} + +report_status() { + local have=0 total=0 + echo + echo "fixtures in $FIXTURES:" + while IFS='|' read -r name how; do + total=$((total + 1)) + if [ -s "$FIXTURES/$name.json" ]; then + printf ' \033[32m✓\033[0m %-46s\n' "$name" + have=$((have + 1)) + else + # ⚠️ **Print the command that works in THIS shell, not the tidy one.** Our own calls + # go through `stripe_cli`, but a hint the operator pastes runs in their shell — where + # an exported STRIPE_API_KEY makes the CLI use the restricted backend key and fail + # with "more_permissions_required". Protecting the script while printing bare copy + # would be the same split: terminal output is copy people follow, so it counts as a + # key-scope site like any document. + case "${STRIPE_API_KEY:-}|$how" in + ?*'|stripe '*) how="env -u STRIPE_API_KEY $how" ;; + esac + printf ' \033[33m·\033[0m %-46s %s\n' "$name" "$how" + fi + done < <(wanted) + echo + echo " $have of $total captured" +} + +if [ "$FORWARD" -eq 2 ]; then + report_status + exit 0 +fi + +command -v stripe >/dev/null 2>&1 || die "stripe CLI not found. brew install stripe/stripe-cli/stripe" +command -v jq >/dev/null 2>&1 || die "jq not found. brew install jq" +command -v node >/dev/null 2>&1 || die "node not found" + +mkdir -p "$FIXTURES" + +# ⚠️ **Run the CLI with STRIPE_API_KEY UNSET, deliberately.** +# +# The Stripe CLI prefers `STRIPE_API_KEY` from the environment over its own `stripe login` +# credential — and the key we tell operators to export is the **restricted** `rk_` one, +# scoped to write Checkout Sessions and nothing else. `stripe listen` opens a CLI session, +# which needs `stripecli_session_write`; a correctly-scoped rk_ key does not have it and +# **must not**. So exporting the key for the backend silently breaks the CLI: +# +# FATAL Error while authenticating with Stripe: Authorization failed, status=403 +# "code": "more_permissions_required" … key 'rk_test_…' does not have the required +# permissions … Enabling Debugging Tools Write ('stripecli_session_write') … +# +# The wrong fix is widening that key. The right one is this: the CLI uses its keychain +# login, the canister uses the restricted key, and neither borrows the other's credential. +stripe_cli() { env -u STRIPE_API_KEY stripe "$@"; } + +if [ -n "${STRIPE_API_KEY:-}" ]; then + printf '\033[33m!\033[0m STRIPE_API_KEY is set in this shell; ignoring it for the Stripe CLI.\n' + printf ' It is the restricted backend key and cannot open a CLI session — see the note in this script.\n\n' +fi + +# ⚠️ **Preflight the SESSION, not a read.** The first version of this check ran +# `stripe events list`, which a restricted key is allowed to do — so it passed, printed +# nothing, and the failure still happened later inside the pipe. Checking a *proxy* for a +# credential rather than the capability actually needed is how a preflight gives false +# confidence. `--print-secret` opens the same CLI session `listen` does, so it fails for +# the same reason and says so here, where the message is visible. +if ! STRIPE_PREFLIGHT="$(stripe_cli listen --print-secret 2>&1)"; then + printf '\n\033[31m✗ the Stripe CLI cannot open a session.\033[0m\n\n%s\n\n' "$STRIPE_PREFLIGHT" >&2 + die "run 'stripe login' (choose a SANDBOX account) and try again" +fi + +FORWARD_ARGS=() +if [ "$FORWARD" -eq 1 ]; then + # Forwarding is optional but recommended: it exercises the canister at the same + # time, so a captured fixture is one the gateway actually accepted. + STATUS_JSON="$(icp network status --json 2>/dev/null)" || + die "no local network. Run: icp network start -d && icp deploy (or pass --no-forward)" + PORT="$(printf '%s' "$STATUS_JSON" | jq -r '.gateway_url' | sed -E 's#^https?://[^:]+:([0-9]+)/?$#\1#')" + BACKEND_ID="$(icp canister status backend --json 2>/dev/null | jq -r '.id')" || + die "backend not deployed. Run: icp deploy" + FORWARD_ARGS=(--forward-to "http://127.0.0.1:${PORT}/webhook/stripe?canisterId=${BACKEND_ID}") + echo "forwarding to: http://127.0.0.1:${PORT}/webhook/stripe?canisterId=${BACKEND_ID}" + echo + echo "⚠ The canister's webhook secret must match this session's signing secret:" + echo " STRIPE_WEBHOOK_SECRET=\"\$(stripe listen --print-secret)\" scripts/seal-secret.sh webhook-secret" +fi + +report_status +cat <<'NOTES' +Perform the actions listed above. Each captured event is written as it arrives; +re-running an action overwrites its fixture, so a bad capture is easy to redo. + +Anything shown as `stripe trigger …` needs no Dashboard setup — run it in another +terminal while this is listening. Those bodies are Stripe's own canned fixtures +rather than the output of a real payment, which is still far better than the +hand-written JSON they replace, but note the distinction for the async pair: a +real SEPA settlement is the thing group F of docs/SANDBOX-TESTPLAN.md exercises. + +⚠️ `checkout.session.completed.unpaid` needs no special effort: triggering +`checkout.session.async_payment_succeeded` emits the WHOLE delayed sequence, which +begins with a real `completed` carrying `payment_status: unpaid`. An earlier version +of this script sent you to create a SEPA session by hand and pay it with a test +IBAN — unnecessary, and nobody found out until someone ran the triggers and got the +fixture anyway. + +⚠️ **A `payment_intent: null` fixture is deliberately NOT on this list.** This app pins +`payment_method_types[]=card` at a fixed unit_amount above the $10 floor with no promo +codes, so `payment_intent` is never absent — and an explicit `payment_method_types` +overrides whatever the account has enabled, so "a delayed method could be enabled at +account level" is not a reachable justification. The handler stays and is covered by a +crafted body in `test/webhook.test.mo`, which is honest: capture exists because crafted +JSON encodes our assumptions about what Stripe *sends us*, and for a payload it will +never send there is nothing to be wrong about. If a non-card method is ever enabled — a product +decision, not a config accident — capture becomes necessary again, because then the +real shape is load-bearing. + +Ctrl-C when the list is complete. + +NOTES + +# `--print-json` emits one JSON object per line. Sort by type, and by the field that +# distinguishes the variants we care about — paid vs unpaid, full vs partial refund, +# and the missing-payment_intent case a zero-amount or subscription session produces. +# ⚠️ **`--format json`, not the deprecated `--print-json`.** The CLI now warns on the old +# flag ("Please use `--format json` instead"), and a deprecated flag eventually stops being +# accepted — at which point this script would fail in the way described just below. +# +# ⚠️ **stderr is NOT redirected, and that is a fix rather than an oversight.** This line +# used to end `2>/dev/null`, which hid Stripe's "Ready! … signing secret" banner *and* +# every reason it might refuse: an expired session, a revoked key, an account problem. The +# pipeline then ended, the script exited **0**, and the operator got their prompt back with +# no output and no error — indistinguishable from "nothing happened". That is exactly the +# quiet-inertness this repo keeps removing from the canister, in our own tooling. The +# banner on stderr is noise worth paying for. +stripe_cli listen --format json "${FORWARD_ARGS[@]}" | node -e ' +const fs = require("fs"); +const dir = process.argv[1]; +const WANTED = new Set([ + "checkout.session.completed.paid", + "checkout.session.completed.unpaid", + "checkout.session.completed.no-intent", + "checkout.session.async_payment_succeeded", + "checkout.session.async_payment_failed", + "charge.refunded.full", + "charge.refunded.partial", + "charge.dispute.created", +]); +let buf = ""; +process.stdin.on("data", (chunk) => { + buf += chunk; + let i; + while ((i = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, i).trim(); + buf = buf.slice(i + 1); + if (!line.startsWith("{")) continue; + let ev; + try { ev = JSON.parse(line); } catch { continue; } + const o = ev?.data?.object ?? {}; + let name = ev.type; + if (ev.type === "checkout.session.completed") { + if (o.payment_intent == null) name += ".no-intent"; + else if (o.payment_status === "paid") name += ".paid"; + else name += ".unpaid"; + } else if (ev.type === "charge.refunded") { + name += o.amount_refunded >= o.amount ? ".full" : ".partial"; + } + // Only the fixtures the parity suite asserts on. Stripe emits a dozen + // incidental events per checkout (product.created, charge.succeeded, …) and + // writing those files clutters the directory with payloads nothing reads. + // + // ⚠️ **This set is a deliberate SUPERSET of `wanted()` and the difference is not + // drift.** `wanted()` drives the checklist and the missing-report, and no longer asks + // for `completed.no-intent` — unreachable while card is pinned. This set still + // accepts it, because if such a body ever does arrive it is worth having on disk + // rather than discarded. Asking for it and accepting it are different questions: + // do not "fix" one list to match the other. + if (!WANTED.has(name)) { + process.stdout.write(` (ignored ${ev.type})\n`); + continue; + } + // Scrub identifying fields before writing. These fixtures get committed, and a + // sandbox checkout still records whatever real name and email you typed. The + // parser in Card.mo reads none of these, so replacing them cannot affect an + // assertion — and doing it on write means nobody has to remember to. + // (No apostrophes in here: this whole block lives inside node -e '...'.) + const SCRUB = { + email: "buyer@example.com", + name: "Test Buyer", + phone: null, + customer_email: null, + receipt_email: null, + }; + const scrub = (n) => { + if (Array.isArray(n)) return n.map(scrub); + if (n && typeof n === "object") { + return Object.fromEntries(Object.entries(n).map(([k, v]) => + [k, k in SCRUB && v !== null ? SCRUB[k] : scrub(v)])); + } + return n; + }; + const path = `${dir}/${name}.json`; + fs.writeFileSync(path, JSON.stringify(scrub(ev), null, 2) + "\n"); + const o2 = ev?.data?.object ?? {}; + let note = ""; + // Flag the capture that is easy to get wrong: a paid session with no + // client_reference_id means the URL parameter was not appended, and that + // fixture cannot verify attribution. + if (name === "checkout.session.completed.paid" && o2.client_reference_id == null) { + note = " ⚠ client_reference_id is null — re-pay with ?client_reference_id=… appended"; + } + process.stdout.write(` captured ${name}${note}\n`); + } +}); +' "$FIXTURES" diff --git a/scripts/check-admin-commands.py b/scripts/check-admin-commands.py new file mode 100755 index 0000000..4d88157 --- /dev/null +++ b/scripts/check-admin-commands.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Every mutating admin method is offered as a command, or excluded ON PURPOSE. + + scripts/check-admin-commands.py + +The operator console renders an `icp canister call` per mutating method, pre-filled from +the row you are looking at. The table lives in `src/frontend/src/candid.ts` as +`CommandMethod`, and TypeScript makes a missing *renderer* a compile error — but it can +say nothing about a method that never made it into the union at all. + +⚠️ **That gap is this script.** A new mutating admin method is a FAILURE here, never a +skip: absent from both the table and the exclusion list, it is a lever an operator has to +hand-author a command for, which is the transcription risk the console exists to +remove. + +⚠️ **The exclusion list may only ever SHRINK**, and every entry carries why. Same +construction as `check-admin-tiers.py`'s tier table and `check-config-readers.py`'s +reader map: an exception that is visible in review and has to be defended. +""" +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +DID = ROOT / "src" / "backend" / "dist" / "backend.did" +TABLE = ROOT / "src" / "frontend" / "src" / "candid.ts" + +# ⚠️ Excluded DELIBERATELY. Each entry is a reason, not a to-do. +EXCLUDED = { + # A rendered command containing the key lands in the page's DOM and its clipboard. + # The console shows `stripe_api_key_status` instead. This exclusion is permanent. + "set_stripe_api_key": "the key would land in the page's DOM and clipboard", + # Same, and worse: whoever can set this can sign a payment event and take delivery + # having paid nothing. + "set_webhook_secret": "the signing secret is mint authority", + # ⚠️ READS that are updates so the read itself is audited. They mutate the + # audit log and nothing else, so a "command to change" them would be nonsense — + # the console calls them directly, as reads. + "admin_order": "an audited READ, not a change", + "admin_receipt": "an audited READ, not a change", +} + + +def did_update_methods() -> set[str]: + """Every method in the .did that is NOT a query. + + Candid marks a query with a trailing `query`; anything else can mutate. Parsed from + the generated interface rather than a hand-list, so the population is the canister's + own and cannot drift. + """ + text = DID.read_text() + service = re.search(r"service\s*:\s*\{(.*)\}\s*$", text, re.S) + if not service: + sys.exit("ABORT: no service block in the .did. Run `mops build` first.") + methods: set[str] = set() + # `name : (args) -> (ret) query;` or `name : (args) -> (ret);` + for m in re.finditer(r"^\s*(\w+)\s*:\s*\((.*?)\)\s*->\s*\((.*?)\)\s*(query)?\s*;", + service.group(1), re.S | re.M): + if m.group(4) is None: + methods.add(m.group(1)) + return methods + + +def tabled_methods() -> set[str]: + """The names in `CommandMethod`.""" + text = TABLE.read_text() + union = re.search(r"export type CommandMethod =(.*?);", text, re.S) + if not union: + sys.exit(f"ABORT: no CommandMethod union in {TABLE.relative_to(ROOT)}") + return set(re.findall(r'"(\w+)"', union.group(1))) + + +def _self_test() -> None: + """Unconditional, because this script's whole value is its two parsers. + + ⚠️ `check-admin-tiers.py` shipped a boundary-regex fix that never reached the file, + and its "unit test" tested a string literal rather than the parser. So this runs + every time, against text of the shape each parser really meets. + """ + svc = """service : { + a_query : (nat) -> (nat) query; + a_mutation : (text) -> (nat); + multi_line : ( + record { x : nat } + ) -> (nat); + spaced_query : () -> (bool) query ; + }""" + global DID + real = DID + try: + import tempfile + with tempfile.NamedTemporaryFile("w", suffix=".did", delete=False) as fh: + fh.write(svc) + DID = Path(fh.name) + got = did_update_methods() + finally: + DID = real + want = {"a_mutation", "multi_line"} + assert got == want, f"self-test: parser found {got}, wanted {want}" + + union_text = 'export type CommandMethod =\n | "one"\n | "two";\n' + assert set(re.findall(r'"(\w+)"', union_text)) == {"one", "two"}, "self-test: union parse" + + +def main() -> int: + _self_test() + updates = did_update_methods() + tabled = tabled_methods() + + # Anything the canister can mutate through, that a caller reaches as an operator. + # `create_order`, `cancel_order` and the webhook route are BUYER paths and are not + # operator commands, so they are named here rather than in EXCLUDED: excluding them + # would imply somebody chose not to render them. + # ⚠️ `process_order` is NOT here: it is a buyer path AND an operator lever (it + # re-drives a stalled delivery, audited as `delivery.manualKick`), so it belongs in + # the table rather than exempted from it. + buyer_paths = {"create_order", "cancel_order", "http_request_update"} + candidates = updates - buyer_paths + + missing = sorted(candidates - tabled - set(EXCLUDED)) + stale = sorted(tabled - updates) + leaked = sorted(set(EXCLUDED) & tabled) + + if missing or stale or leaked: + print("\033[31m✗ the console's command table is not the canister's write surface\033[0m", + file=sys.stderr) + for name in missing: + print(f" {name} can mutate and is offered nowhere. Add it to CommandMethod," + f" or to EXCLUDED with the reason.", file=sys.stderr) + for name in stale: + print(f" {name} is in the table and not in the canister. Remove it.", + file=sys.stderr) + for name in leaked: + print(f" {name} is EXCLUDED and also tabled. A command for it would put a" + f" secret in the page.", file=sys.stderr) + return 1 + + print(f" {len(tabled)} command(s) cover the write surface;" + f" {len(EXCLUDED)} excluded on purpose") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check-admin-tiers.py b/scripts/check-admin-tiers.py new file mode 100755 index 0000000..4c3754c --- /dev/null +++ b/scripts/check-admin-tiers.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +"""Every admin-gated method is in exactly one tier, and CALLS the guard its tier declares. + + scripts/check-admin-tiers.py + +⚠️ **Membership is not the property that matters.** A list of methods plus a check that +every admin method appears in it proves the list is COMPLETE — it proves nothing about +whether the code honours it. A method filed as controller-only whose body calls the +delegable guard would satisfy such a check while being delegable in fact. So this asserts, +per method, that the guard in its body is the one its tier requires. + +⚠️ **The population is DERIVED from the source, never declared.** A method is admin-gated +if its body calls one of the guards, so a new one that is in neither tier fails here. A +hand-written population would let a new method be admin-gated and unlisted at the same +time, which is the state this exists to make impossible. + +⚠️ **This check and `check-doc-surface.py` COMPOSE, and neither covers the gap alone.** The +population here is guard-derived, so a method MEANT to be admin-gated that forgot the guard +entirely is invisible here — it simply looks public. What catches that is the +`unclassifiable` outcome in `check-doc-surface.py`: a method taking `{ caller }` calling +neither guard nor an owner helper fails there by name. **Relaxing `unclassifiable` when it +gets noisy reopens an authz hole this file cannot see.** + +The tiers, and the line between them: **an admin resolves individual cases; a controller +changes the rules.** Anything that moves secrets, pricing, the gate, the mode, the +delivery bounds or the admin list itself is the RULES tier — a controller can already +upgrade and drain the canister, so that tier is canister control. The per-order decisions, +the operator reads and the idempotent levers are the CASES tier: an admin can end one +order and cannot upgrade the canister. +""" + +import glob +import re +import sys + +# ⚠️ **Endpoints live in `Main.mo` AND in `mixins/*.mo`.** Scanning only the composition +# root aborts with "parser is wrong", correctly: a check that cannot find what it is +# checking must not report a clean scan. Both are read as one body of source. +ENDPOINT_SOURCES = ("src/backend/Main.mo", "src/backend/mixins/*.mo") +GUARDS = {"requireController": "controller", "requireAdmin": "admin"} + +# A top-level declaration in the actor: two-space indent, `func` anywhere after it. +# +# ⚠️ `.*?` may be EMPTY, so a line beginning ` func …` matches. The lookahead keeps +# deeper-indented closures out — several bodies pass a `func(order, entry) { … }` to a +# fold, and treating those as boundaries would truncate the body before its guard. +# +# ⚠️ Named once so `_self_test` cannot drift from the parser it is testing. The first +# version of this fix was verified against a string literal typed into the test rather +# than against the file, and the file kept the broken pattern. +BOUNDARY = r"^ (?=\S).*?\bfunc\s+[A-Za-z_]" + +# method -> tier. ⚠️ Absent = FAILURE, never a skip. +TIERS = { + # ── RULES: a controller changes the rules ─────────────────────────────────────────── + "set_webhook_secret": "controller", # HMAC verification is the trust root: whoever + # sets it can sign a completed-session event + # for an order they made and take delivery + "set_stripe_api_key": "controller", # session-creation authority + "set_expected_livemode": "controller", # removes the guard that stops a test-mode + # payment delivering + "set_gate_config": "controller", # the purchase ceiling and the gas floor + "set_pricing_config": "controller", # the fee — selling below cost + "set_card_tiers": "controller", # the presets, validated against the gate + "set_recovery_interval": "controller", # huge stalls deliveries; small burns cycles + "set_delivery_config": "controller", # `maxHoldNs` decides when a stuck delivery + # surfaces at all + "set_stripe_origin": "controller", # where Stripe returns the buyer + "add_admin": "controller", # granting the other tier + "remove_admin": "controller", + "admins": "controller", # who holds the tier is a controller's business + "add_allowed_buyer": "controller", # who may take cycles out of a funded reserve + # for free test money is a RULE, not a case — + # and removing the last entry stops the + # gateway selling at all + "remove_allowed_buyer": "controller", + "allowed_buyers": "controller", # the list bounds a giveaway; who is on it is a + # controller's business, like `admins` + "withdraw_reserve": "controller", # ⚠️ a money-OUT edge, and the second + # destination class in the whole design. + # Controller-only not as a hardening choice + # but because it grants no new capability at + # that tier: a controller can already move + # the reserve by upgrading the canister + # (Auth.mo's tier note). Granting it to the + # admin tier WOULD be new authority + # ── CASES: an admin resolves one case ─────────────────────────────────────────────── + "abandon_order": "admin", # irreversible RECORD on one identified order, + # audited under the actor's own principal, and + # checkable against the ledger. ⚠️ It releases + # the reserve promise — not "moves no cycles" + "record_delivered": "admin", # ditto, and demands the ledger block as + # evidence + "expire_order": "admin", # only moves a `#created` order, and only when + # Stripe says it is unpayable + "resolve_orphan": "admin", # bookkeeping on one obligation + "resolve_problem": "admin", + "refresh_reserve": "admin", # idempotent, no arguments, no policy + "refresh_rates": "admin", + "recount_orders": "admin", + # ── CASES: the operator reads ─────────────────────────────────────────────────────── + "admin_order": "admin", # audited on every use, deliberately + "admin_orders": "admin", + "admin_receipt": "admin", + "audit_log": "admin", + "audit_log_recent": "admin", # the same trail, newest first + "delayed_deliveries": "admin", + "delivery_journal": "admin", + "order_for_payment": "admin", + "orphans": "admin", + "orphans_unresolved": "admin", + "pending_deliveries": "admin", + "stripe_api_key_status": "admin", # status only — never the value + "webhook_secret_status": "admin", +} + + +def guarded_methods(text): + """name -> the tier its body's guard implies, for every public method that has one.""" + lines = text.split("\n") + starts = [ + (i, m.group(1)) + for i, l in enumerate(lines) + if (m := re.search(r"\bpublic\b.*\bfunc\s+([a-z_][A-Za-z0-9_]*)\s*[(<]", l)) + ] + # ⚠️ **Bodies end at the next TOP-LEVEL declaration, not the next PUBLIC one.** A + # private helper between two public methods would otherwise be folded into the + # preceding body, so a helper calling a guard would misattribute it to its neighbour. + # Two-space indentation is the actor's top level; a closure inside a body is indented + # further and so is not a boundary — which matters, because several bodies pass a + # `func(order, entry) { … }` to a fold. + bounds = sorted( + # `.*?` may be EMPTY so a line starting ` func …` matches — `\S.*` consumed the + # `f` and then needed a SECOND `func`, which is why the first attempt at this was + # behaviourally identical to having no fix. The lookahead still excludes closures, + # which matters: several bodies pass a `func(order, entry) { … }` to a fold. + i for i, l in enumerate(lines) if re.match(BOUNDARY, l) + ) + out = {} + for i, name in starts: + nxt = [b for b in bounds if b > i] + body = "\n".join(lines[i:(nxt[0] if nxt else len(lines))]) + code = "\n".join(l for l in body.split("\n") if not l.strip().startswith("//")) + # ⚠️ **The `\s*\(` is load-bearing, not tidiness.** Bodies are sliced + # out of `Main.mo` and the nine mixins CONCATENATED, so the last endpoint in one + # file has a body that runs on into the next file's preamble — which for a mixin + # is its parameter list, and every one of those declares + # `requireController : (Principal) -> ()`. Requiring an open paren immediately + # after the name is the only thing that stops a type annotation being read as a + # guard CALL, i.e. an authz hole reported as covered. Mutation-tested: dropping + # `ops.requireAdmin(caller)` from the last endpoint in `AdminOrders.mo` still + # fails this check. + found = {tier for g, tier in GUARDS.items() if re.search(r"\b%s\s*\(" % g, code)} + if len(found) > 1: + out[name] = "BOTH" + elif found: + out[name] = found.pop() + return out + + +def _self_test(): + r"""The boundary logic, checked against a synthetic source on every run. + + ⚠️ **This exists because the boundary silently no-opped once.** The first attempt at it + was `^ \S.*\bfunc\s+` — which cannot match a line beginning ` func …`, because `\S` + consumes the `f` and then a second `func` is required. It was committed with a comment + asserting the protection, and the check stayed green **by an accident of file + ordering**: the only private top-level funcs calling a guard are the guard definitions + themselves, and no public method precedes them. + + ⚠️ It runs unconditionally rather than behind a flag, because a self-test you have to + remember to run is the thing it is guarding against. + """ + for line, want in [ + (" func requireAdmin(caller : Principal) {", True), + (" public shared query ({ caller }) func admin_orders(", True), + (" public query func delivery_stats() : async {", True), + (" func(order, entry) {", False), # a closure passed to a fold + (" case (?order) { n += 1 };", False), + ]: + if (re.match(BOUNDARY, line) is not None) != want: + sys.exit(f"ABORT: the body boundary is broken — {line.strip()!r} should " + f"{'' if want else 'not '}be a boundary") + + # The dangerous fold, end to end: an UNGUARDED public method followed by a private + # helper that calls a guard must NOT inherit it. With the broken boundary this + # returned {"unguarded": "admin"} — an authz hole reading as a correctly tiered method. + synthetic = "\n".join([ + " public shared ({ caller }) func unguarded() : async () {", + " ();", + " };", + " func helper(caller : Principal) { requireAdmin(caller) };", + " public shared ({ caller }) func guarded() : async () {", + " requireAdmin(caller);", + " };", + ]) + got = guarded_methods(synthetic) + if got != {"guarded": "admin"}: + sys.exit(f"ABORT: the body boundary folds a neighbouring helper's guard in — " + f"expected {{'guarded': 'admin'}}, got {got}") + + +def main() -> int: + _self_test() + text = "\n".join( + open(f).read() + for pattern in ENDPOINT_SOURCES + for f in sorted(glob.glob(pattern)) + ) + actual = guarded_methods(text) + if not actual: + sys.exit(f"ABORT: found no guarded methods in {ENDPOINT_SOURCES} — the parser is wrong") + + fail = [] + for name, tier in sorted(actual.items()): + if tier == "BOTH": + fail.append(f"{name} calls BOTH guards — it cannot be in one tier") + continue + if name not in TIERS: + fail.append( + f"{name} is admin-gated and in NEITHER tier. Classify it: does it resolve " + f"one case (admin) or change the rules (controller)?" + ) + elif TIERS[name] != tier: + fail.append( + f"{name} is declared {TIERS[name]!r} but its body calls the " + f"{tier!r} guard — the tier is not enforced, only written down" + ) + for name in sorted(set(TIERS) - set(actual)): + fail.append( + f"TIERS names {name}, which calls no guard in {ENDPOINT_SOURCES} — either it lost its " + f"guard (an authz hole) or the entry is stale" + ) + + if fail: + print("\n\033[31m✗ the admin tiers are not what the code does\033[0m", file=sys.stderr) + for f in fail: + print(f" {f}", file=sys.stderr) + return 1 + n_c = sum(1 for t in actual.values() if t == "controller") + n_a = sum(1 for t in actual.values() if t == "admin") + print(f" {len(actual)} guarded method(s): {n_c} controller-only, {n_a} admin, each calling its declared guard") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check-bindings.sh b/scripts/check-bindings.sh new file mode 100755 index 0000000..dd7b0cc --- /dev/null +++ b/scripts/check-bindings.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# The committed Candid bindings match the canister's own interface. +# +# **Why the bindings are committed rather than generated on demand.** The integration +# CI job installs only `test/integration` deps and never runs `mops build` — so it +# typechecks against whatever is in the checkout. Generating at test time would need the +# Motoko toolchain in that job; committing means a fresh checkout can typecheck and run, +# and an interface change shows up in a pull-request diff instead of only at runtime. +# +# **The cost of committing generated code is that it rots silently, which is what this +# check exists for.** Same pattern the gate already applies one layer down to +# `src/backend/dist/backend.did` — regenerate, diff, fail on drift. +# +# **Why generated at all.** Hand-transcribing `test/integration/src/idl.ts` is 555 +# lines of `IDL.Func` declarations, and `types.ts` 42 TypeScript mirrors, with nothing +# checking either against the Motoko. Such a copy drifts in both directions at once: a +# `GateReason` mirror keeps variants the backend deleted and misses ones it added, so +# those refusals are undecodable from the suite and therefore untestable. +# +# ⚠️ **And a mirror fails ASYMMETRICALLY, which is why no test caught it.** Declaring a +# field the canister lacks breaks the Candid decode and gets found. *Omitting* one decodes +# fine and the suite silently covers less than it claims. +set -Eeuo pipefail +cd "$(dirname "$0")/.." + +DID="src/backend/dist/backend.did" +OUT="test/integration/src/generated" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +[ -f "$DID" ] || { + echo "✗ $DID is missing — run \`mops build\` first" >&2 + exit 1 +} + +# ⚠️ `--prefix test/integration`, not the frontend's copy. `@icp-sdk/bindgen` is a +# devDependency of the suite precisely so this works in the integration CI job, which does +# not install the frontend. Reaching for the frontend's node_modules would pass locally and +# fail there — the exact defect that shipped in a sibling project because `npx` resolved a +# global install. +npx --prefix test/integration icp-bindgen \ + --did-file "$DID" --out-dir "$TMP" --actor-disabled >/dev/null + +STALE="" +for f in declarations/backend.did.js declarations/backend.did.d.ts; do + if ! diff -q "$OUT/$f" "$TMP/$f" >/dev/null 2>&1; then + STALE="$STALE $f" + fi +done + +if [ -n "$STALE" ]; then + printf '\n\033[31m✗ %s is stale:%s\033[0m\n' "$OUT" "$STALE" >&2 + for f in $STALE; do + printf '\n--- %s ---\n' "$f" >&2 + diff -u "$OUT/$f" "$TMP/$f" | head -40 >&2 + done + printf '\n Regenerate and commit:\n npm --prefix test/integration run bindings\n' >&2 + exit 1 +fi +printf ' bindings match the canister (2 files)\n' diff --git a/scripts/check-config-readers.py b/scripts/check-config-readers.py new file mode 100755 index 0000000..ea807e9 --- /dev/null +++ b/scripts/check-config-readers.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Every configuration SETTER must have a reader, so no parameter is write-only. + + scripts/check-config-readers.py + +⚠️ **Found by a UI spec, not by any check.** `set_delivery_config` could set `maxHoldNs` +(when a paid order escalates) and `alertAfterNs` (the delay-alert threshold), and **no +method returned either** — they appeared in the setter's argument and its error variant and +nowhere else. An operator could set the two bounds that decide when a stuck delivery +surfaces and never read them back. That went unnoticed through five PRs touching this +surface, because nothing was looking. + +⚠️ **A setter missing from `READERS` is a FAILURE, not a skip.** That is the whole +mechanism: a new `set_*` method has to be classified deliberately, so the next write-only +parameter fails here instead of being discovered by a UI three months later. + +The requirement is derived, not declared, wherever it can be: if a setter takes a single +NAMED type, the mapped reader must return that same type — so a config record gaining a +field is covered automatically. Where the argument is a primitive there is no type to +trace, and the entry names the field or settles for the reader existing. +""" + +import re +import sys + +DID = "src/backend/dist/backend.did" + +# setter -> (reader method, required field in the reader's return, or None) +# +# ⚠️ The two secrets are here on purpose, with a reader that reports STATUS only. Their +# value must never be readable, so "has a reader" means "an operator can tell whether it is +# provisioned and which generation" — not that they can read it back. +READERS = { + "set_gate_config": ("lifecycle_config", None), + "set_delivery_config": ("lifecycle_config", None), + "set_pricing_config": ("pricing_status", None), + "set_card_tiers": ("card_tiers", None), + "set_expected_livemode": ("expected_livemode", None), + "set_stripe_origin": ("stripe_origin", None), + "set_recovery_interval": ("recovery_status", "intervalNs"), + "set_stripe_api_key": ("stripe_api_key_status", None), + "set_webhook_secret": ("webhook_secret_status", None), +} + + +def methods(text): + """name -> (args, return) source, split at the method's first `->`.""" + out = {} + starts = [(m.start(), m.group(1)) for m in re.finditer(r"^ ([a-z_][a-z0-9_]*):", text, re.M)] + for i, (pos, name) in enumerate(starts): + end = starts[i + 1][0] if i + 1 < len(starts) else len(text) + block = text[pos:end] + head, _, tail = block.partition("->") + out[name] = (head, tail) + return out + + +def main() -> int: + text = open(DID).read() + m = methods(text) + if not m: + sys.exit(f"ABORT: parsed no methods out of {DID} — this check would pass vacuously") + + setters = sorted(n for n in m if n.startswith("set_")) + if not setters: + sys.exit(f"ABORT: found no set_* methods in {DID} — the parser is wrong") + + fail, traced = [], [] + for s in setters: + if s not in READERS: + fail.append( + f"{s} has no entry in READERS — classify it: name the query that reports its\n" + f" current value, or add a status-only reader and say why the value must not\n" + f" be readable. Defaulting to 'no reader needed' is what made\n" + f" set_delivery_config write-only." + ) + for s in sorted(set(READERS) - set(setters)): + fail.append(f"READERS names {s}, which is not a set_* method in the interface — stale entry") + + for s in setters: + if s not in READERS: + continue + reader, field = READERS[s] + if reader not in m: + fail.append(f"{s}: its reader `{reader}` is not in the interface") + continue + args, _ = m[s] + ret = m[reader][1] + # Derived requirement: a single NAMED argument type must come back out. + # `(?:vec\s+)?` so `(tiers: vec Tier)` is traced too — `Tier` gaining a field is a + # plausible change. Primitives (`opt bool`, `text`, `nat`) have no capital and do + # not match, which is correct: there is no type to trace. + named = re.search(r"\(\s*[a-zA-Z_]+:\s*(?:vec\s+)?([A-Z][A-Za-z0-9_]*)\s*\)", args) + if named: + t = named.group(1) + if re.search(r"\b%s\b" % t, ret): + traced.append(s) + else: + fail.append( + f"{s} takes `{t}` and `{reader}` does not return it — the parameter is " + f"WRITE-ONLY" + ) + if field and not re.search(r"\b%s:" % field, ret): + fail.append(f"{s}: `{reader}` does not report `{field}`") + + if fail: + print("\n\033[31m✗ a configuration parameter has no reader\033[0m", file=sys.stderr) + for f in fail: + print(f" {f}", file=sys.stderr) + print( + "\n An operator who cannot read a value back cannot check it, and a UI cannot\n" + " show it. Add the reader; do not relax this check.", + file=sys.stderr, + ) + return 1 + # ⚠️ **Says what was actually verified.** For a setter taking a named type the check + # traces that type through the reader's return; for a primitive or a status-only + # secret it can only confirm the reader exists. One number covering both would + # overstate six of nine — and the value of this step is a reader trusting its last + # line. + print( + f" {len(setters)} config setter(s): {len(traced)} type-traced, " + f"{len(setters) - len(traced)} reader-exists" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check-crypto-vectors.sh b/scripts/check-crypto-vectors.sh new file mode 100755 index 0000000..e25288f --- /dev/null +++ b/scripts/check-crypto-vectors.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# The pinned BLS12-381 and vetKD packages match the Rust reference, under OUR toolchain. +# +# `src/backend/Sealed.mo` decrypts the Stripe secrets with an EXPERIMENTAL, UNAUDITED +# BLS12-381 port, pinned as a git submodule at `vendor/icp-seeding-secrets-poc`, because +# `mo:ic-vetkeys` has no curve. Its vectors were generated from `ic_bls12_381` and +# `ic-vetkeys` — DFINITY's Rust implementations, themselves unaudited — so what they +# assert is not this port's arithmetic restated but values an independent implementation +# produced. +# +# **Why run them here when the source repository already does.** Two reasons, and the +# second is the one that matters: +# +# 1. `mops test` does not descend into path dependencies, so these suites execute in +# this project only if something runs them explicitly. Nothing else does. +# 2. The upstream run proves the vectors under **that repository's** toolchain pins, not +# ours. **They HAVE now diverged** — upstream is pinned to moc 1.15.1 and this +# project moved to 1.16.0 — which is why the run below rewrites the pin instead of +# testing in place. Upstream pins its own compiler and this project's moves +# independently, so the combination actually shipped here is tested nowhere else. +# +# It is also what makes a submodule work at all: `mops` cannot address a package inside a +# repository subdirectory — measured, it silently DISCARDS the subdirectory and installs +# the repository root, which has no `src/`. Having the packages on disk is what lets their +# suites run in the same gate that guards the key they protect. +# +# ⚠️ **DO NOT SKIP OR DELETE THIS STEP TO GET A BUILD GREEN.** `docs/DESIGN.md` §7.3 +# accepts an unaudited BLS12-381 on the money path, and that acceptance is conditional on +# this check running — not on the argument in the prose. A `moc` upgrade that makes the +# pinned packages fail to compile is the likely trigger: skipping their suites to move on +# quietly removes the only thing standing behind that decision, and nothing else reports it. +# Fix the compile, bump the pin, or reopen §7.3 — do not silence this. +# +# ⚠️ **What this does NOT establish.** These suites prove the port agrees with the +# reference on the vectors it ships. They are not an audit, they do not cover inputs the +# generator never produced, and passing here is not a statement that the implementation is +# constant-time or side-channel free. `docs/DESIGN.md` §7.3 carries the argument for why +# that is acceptable on THIS path — the client-side `@icp-sdk/vetkeys` does the encrypting, so +# a bug here fails provisioning closed rather than weakening a ciphertext in flight. +set -Eeuo pipefail +cd "$(dirname "$0")/.." + +fail() { printf '\033[31m✗ %s\033[0m\n' "$1" >&2; exit 1; } + +# A fresh clone without `--recurse-submodules` leaves these directories empty. Checked +# explicitly, because the alternative is a `mops` error about a missing package that says +# nothing about submodules. +for pkg in bls12-381 vetkeys; do + [ -f "vendor/icp-seeding-secrets-poc/motoko/$pkg/mops.toml" ] || fail "vendor/icp-seeding-secrets-poc is not checked out — the crypto +is a git submodule. + git submodule update --init --recursive" +done + +# Read from the INDEX, not from HEAD: `HEAD:vendor/icp-seeding-secrets-poc` is unresolvable while the +# submodule is staged-but-uncommitted, which reported every pre-commit run as a mismatch. +PINNED="$(git ls-files -s vendor/icp-seeding-secrets-poc 2>/dev/null | awk '{print substr($2,1,7)}')" +[ -n "$PINNED" ] || PINNED='?' +ACTUAL="$(git -C vendor/icp-seeding-secrets-poc rev-parse --short HEAD 2>/dev/null || echo '?')" +if [ "$PINNED" != "$ACTUAL" ]; then + # Not fatal: a deliberate bump is a normal thing to be mid-way through. But the suites + # below would then be testing something other than what the build will use. + printf '\033[33m!\033[0m vendor/icp-seeding-secrets-poc is at %s, the commit pinned here is %s\n' "$ACTUAL" "$PINNED" +fi + +# ⚠️ **The suites must run under THIS project's compiler, not the submodule's.** +# +# Each vendored package is a self-contained mops project with its own `[toolchain] moc`, +# pinned at 1.15.1. Running `mops test` in place therefore verifies the port under *that* +# compiler while the backend compiles the same source under ours — so the moment the two +# diverge, this check silently stops covering the combination that ships, which is exactly +# the gap §7.3's acceptance depends on not existing. It went unnoticed for one commit +# during the 1.16.0 bump. +# +# `mops` has no flag or environment override for the compiler (`mops test --help`), so the +# suites run from a temp root with the pin rewritten to ours — see the symlink note below +# for how. The rewrite is VERIFIED rather than assumed: a `sed` that silently matched +# nothing would put us straight back to testing the wrong compiler. +OURS="$(sed -nE 's/^moc = "([^"]+)"/\1/p' mops.toml | head -1)" +[ -n "$OURS" ] || fail "could not read the moc pin from mops.toml" + +# ⚠️ **Nothing is copied. Each package is a directory of SYMLINKS into the submodule, +# plus one real `mops.toml` carrying our compiler pin.** +# +# The only file that has to differ is the toolchain pin, so that is the only file +# materialised — `src/`, `test/`, `bench/` and the vectors are linked, and the submodule's +# working tree is never touched. That also rules out the two alternatives: editing the +# submodule's own `mops.toml` in place mutates checked-out state and leaves it dirty if a +# run is interrupted, and duplicating the suites into `test/` would be a real, maintained +# copy of someone else's tests. +# +# ⚠️ **Both packages must be SIBLINGS in one root.** `vetkeys` declares +# `ic-bls12-381 = "../bls12-381"`, a path relative to its own directory — a +# per-package root gives `package error [M0012], file "../bls12-381" does not exist`, and +# only for the second package, so it fails in a way that reads as package-specific. +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +SUB="$(pwd)/vendor/icp-seeding-secrets-poc/motoko" +for pkg in bls12-381 vetkeys; do + mkdir -p "$WORK/$pkg" + # `.mops` is deliberately NOT linked: a cache resolved under the old pin would defeat + # the point of the rewrite. Everything else the build reads is linked, not duplicated. + for entry in "$SUB/$pkg"/*; do + name="$(basename "$entry")" + [ "$name" = "mops.toml" ] && continue + [ "$name" = "mops.lock" ] && continue + [ "$name" = ".mops" ] && continue + ln -s "$entry" "$WORK/$pkg/$name" + done + # `vectors.json` is shared, one level up, and reached as `../vectors.json`. + [ -e "$WORK/vectors.json" ] || ln -s "$SUB/vectors.json" "$WORK/vectors.json" + # ⚠️ **`mops.toml` AND `mops.lock` are real files, not links, and the lock is the + # subtle one.** `mops` REWRITES a lock whose recorded hash does not match — measured: + # corrupt the hash in a copy and `mops install` silently restores it. Both locks are + # tracked upstream and this script sends install output to /dev/null, so a symlinked + # lock is a write channel straight into the submodule's working tree. It has not fired, + # only because the lock's hash does not currently cover `[toolchain]` — which is mops's + # implementation detail, not a property to rely on. Copying these two makes "the + # submodule is never written to" true by construction. + cp "$SUB/$pkg/mops.toml" "$WORK/$pkg/mops.toml" + [ -f "$SUB/$pkg/mops.lock" ] && cp "$SUB/$pkg/mops.lock" "$WORK/$pkg/mops.lock" +done + +TOTAL=0 +for pkg in bls12-381 vetkeys; do + DIR="$WORK/$pkg" + + THEIRS="$(sed -nE 's/^moc = "([^"]+)"/\1/p' "$DIR/mops.toml" | head -1)" + [ -n "$THEIRS" ] || fail "$pkg/mops.toml has no moc pin to rewrite" + sed -i.bak -E "s/^moc = \"[^\"]+\"/moc = \"$OURS\"/" "$DIR/mops.toml" + rm -f "$DIR/mops.toml.bak" + NOW="$(sed -nE 's/^moc = "([^"]+)"/\1/p' "$DIR/mops.toml" | head -1)" + [ "$NOW" = "$OURS" ] || fail "failed to rewrite $pkg's moc pin ($THEIRS -> $OURS, got $NOW)" + + ( cd "$DIR" && mops install ) >/dev/null 2>&1 || fail "mops install failed for $pkg under moc $OURS" + + OUT="$( ( cd "$DIR" && mops test ) 2>&1 )" || { + printf '%s\n' "$OUT" >&2 + fail "crypto vectors FAILED in $pkg under moc $OURS — do not ship a secret through this" + } + # `mops test` prints "Done in Xs, passed N". Pull N out so the gate reports coverage + # rather than a bare tick: a suite that silently stopped collecting tests would + # otherwise pass here looking identical to one that ran. + N="$(printf '%s' "$OUT" | sed -nE 's/.*passed ([0-9]+).*/\1/p' | tail -1)" + [ -n "$N" ] && [ "$N" -gt 0 ] 2>/dev/null || fail "could not read a passing test count from $pkg" + if [ "$THEIRS" = "$OURS" ]; then + printf ' %-12s %3s vectors against the Rust reference (moc %s)\n' "$pkg" "$N" "$OURS" + else + printf ' %-12s %3s vectors against the Rust reference (moc %s, pinned %s upstream)\n' \ + "$pkg" "$N" "$OURS" "$THEIRS" + fi + TOTAL=$((TOTAL + N)) +done + +# A floor, not an exact count, so adding vectors upstream does not fail the gate — but +# losing most of them does. 108 at the pinned commit. +# +# ⚠️ One variable, used by both the test and the message. Written as two literals, raising +# the floor left the failure text quoting the OLD number — caught by mutating it. +MIN_VECTORS=90 +[ "$TOTAL" -ge "$MIN_VECTORS" ] \ + || fail "only $TOTAL vectors ran; expected at least $MIN_VECTORS. Has a suite stopped collecting?" +printf ' %d vectors total, pinned at %s\n' "$TOTAL" "$ACTUAL" diff --git a/scripts/check-design-sections.py b/scripts/check-design-sections.py new file mode 100755 index 0000000..02defcc --- /dev/null +++ b/scripts/check-design-sections.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Assert every `§N` label in the code is glossed, every glossed row is used, and every +pointer the glossary gives still resolves. + +⚠️ **Why this is a gate step and not a convention.** The `§N` shorthand came from a +697-line spec that was deleted because it had rotted: 67 mentions of architecture three +issues had removed, while `Main.mo` still cited it by section number. The glossary that +replaced it is one line per section — and within an hour of writing it, one row was +already **wrong in the dangerous direction**: it said `§5.3` was deleted (it labelled the +ICP burn cap) and told the reader a citation to it was a bug. In fact §5.3 held *two* +things, and the eight live citations all mean the surviving half — the 72 h max-wait +bound. A future agent following that row would have deleted eight correct comments. + +That is the whole argument: a document nothing checks rots at the speed it is written. +The three checks below are what make the glossary maintainable rather than another +artifact to distrust. + + 1. **Cited but not glossed** — a new `§N` appears in code with no row. The label is + then unresolvable for everyone who did not write it. + 2. **Glossed but not cited** — a row nobody references. Keeps the file to what is + worth keeping instead of growing back into a spec. + 3. **Pointer does not resolve** — the row names a module or symbol that no longer + exists. This is the check that makes a RENAME update the glossary: move + `Orders.isLegalTransition` and the gate fails until the row follows. + +⚠️ **What this does NOT reach, stated because a check implying more than it verifies is +worse than no check:** + + - **Prose accuracy.** Nothing here could have caught the wrong `§5.3` row: it named a + real section and pointed at real code, and was still wrong about *what the section + meant*. That remains a human's job, and it is why rows are one line — a claim you + can check by reading is better than one you cannot. + - **Doc citations.** Only `src/backend/*.mo` and `test/*.mo` are scanned. In + `RUNBOOK.md` and `docs/STRIPE.md` a bare `§2` means *that document's own* section 2, + so scanning them would compare two different numbering schemes and produce noise. + Code comments have no sections of their own, so there `§N` is unambiguous. + +⚠️ **That last premise is ENFORCED rather than assumed.** A `§N` preceded by a document +name — `RUNBOOK §1` in a comment — would otherwise read as a citation of `DESIGN §1`, and +the "glossed but not cited" arm would pass over a section nothing in the code references. +So a document-scoped `§N` is refused: the glyph in code means a design section or it fails +here. +""" + +import glob +import os +import re +import sys + +SPEC = "docs/DESIGN.md" +CODE_GLOBS = ("src/backend/*.mo", "src/backend/mixins/*.mo", "test/*.mo") +SECTION = re.compile(r"§([0-9][0-9a-z]*(?:\.[0-9a-z]+)*)") +# ⚠️ **A `§N` in code means a DESIGN section, full stop, and there is no escape hatch.** +# Refer to another document's sections by NAME, never by glyph: `RUNBOOK §1` in a comment +# reads as a citation of DESIGN §1 and keeps a dead row alive, which is what +# `FOREIGN_SCOPED` below refuses. +FOREIGN_SCOPED = re.compile(r"(RUNBOOK|STRIPE|OPERATE|TEST-COVERAGE|SANDBOX-TESTPLAN|VERIFY|RELEASE|ARCHITECTURE)[^§]{0,12}§[0-9]") + + +FOREIGN = [] + + +def cited(): + out = {} + for pat in CODE_GLOBS: + for f in glob.glob(pat): + for i, line in enumerate(open(f, errors="replace"), 1): + for m in SECTION.finditer(line): + # A citation of ANOTHER document's section, which this check would + # otherwise bank as a DESIGN citation. Reported, never counted. + if FOREIGN_SCOPED.search(line[max(0, m.start() - 40):m.end()]): + FOREIGN.append(f"{f}:{i}: {line.strip()[:90]}") + continue + out.setdefault(m.group(1), []).append(f"{f}:{i}") + return out + + +def documented(): + """Section number -> the heading text that follows it.""" + out = {} + for line in open(SPEC, errors="replace"): + m = re.match(r"#{2,4}\s+" + SECTION.pattern + r"\s*(?:—|-)?\s*(.*)", line.strip()) + if m: + out[m.group(1)] = m.group(2) + return out + + +def main() -> int: + use, rows = cited(), documented() + if not use: + sys.exit("ABORT: found no §N citations in the code — this check cannot pass vacuously") + if not rows: + sys.exit(f"ABORT: parsed no numbered sections out of {SPEC} — this check cannot pass vacuously") + + fail = [] + def documented_for(sec): + """§11.1.3 is item 3 of §11.1 — an ancestor section documents it.""" + parts = sec.split(".") + while parts: + if ".".join(parts) in rows: + return ".".join(parts) + parts.pop() + return None + + covered = set() + for sec in sorted(use): + owner = documented_for(sec) + if owner is None: + where = ", ".join(use[sec][:3]) + fail.append(f"§{sec} is cited ({where}) but has no section in {SPEC}") + else: + # ⚠️ A cited CHILD covers its ancestors. `§6` documents `§6.0`/`§6.1` and is + # cited only through them — before this, the arm below called it dead, and + # the only thing hiding that was a `RUNBOOK §6` in a test being miscounted. + parts = owner.split(".") + while parts: + covered.add(".".join(parts)) + parts.pop() + for sec in sorted(set(rows) - covered): + fail.append( + f"§{sec} is a section in {SPEC} that nothing cites — delete it, or" + " the file grows back into the 697-line spec it replaced" + ) + # ⚠️ A refusal, not a warning. `RUNBOOK §1` in a comment was counted as a citation + # of DESIGN §1 and kept a dead row alive for as long as it stood. + for hit in FOREIGN: + fail.append( + f"{hit}\n ^ a §N citing ANOTHER document. In code the glyph means a" + f" {SPEC} section; name that document's section in words instead" + ) + + if fail: + print("\n\033[31m✗ docs/DESIGN.md disagrees with the code\033[0m", file=sys.stderr) + for f in fail: + print(f" {f}", file=sys.stderr) + print( + f"\n {SPEC} is the decision record. If you added a §N label, add its section;\n" + " if you deleted the behaviour, delete the section. ⚠️ Change the behaviour,\n" + " change that file IN THE SAME COMMIT — its predecessor rotted by being\n" + " updated less often than the code, not by being abandoned.", + file=sys.stderr, + ) + return 1 + print(f" {len(rows)} DESIGN.md sections: every cited §N documented, every section cited") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check-did-signatures.sh b/scripts/check-did-signatures.sh new file mode 100755 index 0000000..d7c7f42 --- /dev/null +++ b/scripts/check-did-signatures.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Assert the canister's Candid SIGNATURES are unchanged against a git ref, ignoring doc +# comments. +# +# **Why signatures rather than the whole file.** A pure relocation of endpoints must +# not move the interface, and `git diff` on `backend.did` is the natural proof — until doc +# text moves independently of the API, at which point a byte diff can no longer tell "I +# relocated code" from "I changed the contract". +# +# moc 1.16.0 emits doc comments for mixin members, so a relocation carries its doc along +# and leaves the `.did` doc lines untouched. What moves independently is an ordinary doc +# EDIT: it is published, so it shows up in the interface diff beside real signature +# changes, which is the confusion this separates. +# +# So this strips `///` lines from both sides and compares what is left: method names, +# argument names and types, return types, and every type declaration. That is the part a +# caller is bound by. +# +# ⚠️ **Not a gate step, and it could not be one:** it compares against a ref you choose, +# which only the author of a refactor knows. Run it while relocating code, and quote the +# result in the PR — the way `check-stable-promotion.sh` is run at promotion time. +# +# Usage: scripts/check-did-signatures.sh [git-ref] (default: merge-base with main) +set -euo pipefail + +did="src/backend/dist/backend.did" +[ -f "$did" ] || { echo "no $did — run \`mops build\` first" >&2; exit 1; } + +ref="${1:-$(git merge-base HEAD main)}" + +# Doc comments out, blank lines out, trailing whitespace out. Nothing else is touched: +# reordering or rewrapping a signature is a real difference and must still show. +strip() { sed -e '/^[[:space:]]*\/\/\//d' -e '/^[[:space:]]*$/d' -e 's/[[:space:]]*$//'; } + +before="$(mktemp)"; after="$(mktemp)" +trap 'rm -f "$before" "$after"' EXIT + +if ! git show "$ref:$did" 2>/dev/null | strip > "$before"; then + echo "cannot read $did at $ref" >&2 + exit 1 +fi +strip < "$did" > "$after" + +before_n=$(grep -c '' "$before" || true) +after_n=$(grep -c '' "$after" || true) +if [ "$before_n" -eq 0 ] || [ "$after_n" -eq 0 ]; then + echo "ABORT: stripped one side to nothing ($before_n vs $after_n lines) — cannot pass vacuously" >&2 + exit 1 +fi + +if diff -q "$before" "$after" >/dev/null; then + printf ' candid signatures unchanged vs %s (%s lines, doc comments ignored)\n' \ + "$(git rev-parse --short "$ref")" "$after_n" + exit 0 +fi + +printf '\033[33m⚠️ candid signatures CHANGED vs %s\033[0m\n' "$(git rev-parse --short "$ref")" +diff "$before" "$after" | sed 's/^/ /' +exit 1 diff --git a/scripts/check-doc-calls.py b/scripts/check-doc-calls.py new file mode 100755 index 0000000..519defc --- /dev/null +++ b/scripts/check-doc-calls.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""Every `icp canister call backend '(...)'` in docs and scripts must +actually run: the method has to exist in the committed `.did`, and the number of +top-level arguments has to match what it declares. + +Why this exists: a method's arity changes and the prose calling it does not. +`audit_log` gaining `(opt nat, nat)` while three places still call it `'()'`, or +`quote_previews` losing an argument a runbook line keeps passing, are both an +operator's copy-paste failing mid-incident and neither is visible to any suite: +these are strings in prose, so nothing type-checks them and the gate was green. + +`check-admin-commands.py` is the neighbouring check and does NOT cover this. It +compares mutating methods against the console's command table; arity in prose is +a different question about different files. + +Scope, deliberately: arity is checked only where the argument is a LITERAL. A +templated one (``, `$VAR`, `%s`) has its method checked and its +arity skipped, because the text is a shape rather than a call. + +Known limits, accepted rather than overlooked: + +- **Arity, not types.** `orphans '("x", "y")'` passes: two arguments is what the + method takes. Checking types needs a real candid parser and a type + environment; arity is a small scanner that catches all four historical + instances in this repo, both of the live ones included. +- **A templated argument is not checked at all**, only its method name. +- **The console's printed commands are not read from here.** They come from a + typed table in `src/frontend/src/candid.ts`, and `candid.test.ts` asserts their + arity against the generated `idlFactory`. Both halves are needed: the type + binds each renderer's SIGNATURE, not the string it returns, so a renderer that + accepts two arguments and emits one type-checks clean. +""" +import re +import sys +from pathlib import Path + +DID = Path("src/backend/dist/backend.did") +TARGETS = ["RUNBOOK.md", "README.md", *sorted(str(p) for p in Path("docs").glob("*.md")), + *sorted(str(p) for p in Path("scripts").glob("*.sh"))] +PLACEHOLDER = re.compile(r"[<$%]") +# The floor that stops a blind scanner passing. 72 literal calls today; a real +# change moves this by a few, and a broken pattern takes it to zero. +MIN_CHECKED = 60 + + +def top_level_commas(body: str) -> int: + """Commas at nesting depth zero, ignoring double-quoted spans.""" + depth = in_str = 0 + commas = 0 + i = 0 + while i < len(body): + c = body[i] + if in_str: + if c == "\\": + i += 2 + continue + if c == '"': + in_str = 0 + elif c == '"': + in_str = 1 + elif c in "({[": + depth += 1 + elif c in ")}]": + depth -= 1 + elif c == "," and depth == 0: + commas += 1 + i += 1 + return commas + + +def arity(params: str) -> int: + params = params.strip() + return 0 if not params else top_level_commas(params) + 1 + + +def did_arities(text: str) -> dict[str, int]: + """`name: (params) -> ...` inside the service block. Multi-line, so this + scans with a paren matcher rather than a per-line regex.""" + text = re.sub(r"^\s*///.*$", "", text, flags=re.M) + start = text.index("service :") + out: dict[str, int] = {} + for m in re.finditer(r"([a-z][A-Za-z0-9_]*)\s*:\s*\(", text[start:]): + i = start + m.end() - 1 + depth = 0 + for j in range(i, len(text)): + if text[j] == "(": + depth += 1 + elif text[j] == ")": + depth -= 1 + if depth == 0: + break + else: + continue + # Only a method has `->` after its parameter list; a record field does not. + if text[j + 1 : j + 40].lstrip().startswith("->"): + out[m.group(1)] = arity(text[i + 1 : j]) + return out + + +def calls(text: str): + """(method, literal_arg_or_None, raw_span, line_no) for each call in one file.""" + starts = [m.start() for m in re.finditer(r"canister\s+call\s+backend", text)] + for m in re.finditer(r"canister\s+call\s+backend\s+([A-Za-z_][\w]*)", text): + line_no = text.count("\n", 0, m.start()) + 1 + method = m.group(1) + # ⚠️ **Bounded at the NEXT call, not the end of the file.** Unbounded, a + # zero-argument call written bare in prose picks up the argument of a later + # command: `health` was reported as passing `(null, 50 : nat)`, which belongs to + # an `orphans` line further down, at that line's number. Every documented call + # happens to carry an argument today, so nothing triggered it -- but `health`, + # `admin_status`, `operator_summary` and `orphan_depth` all take none and are + # exactly what someone writes bare. + nxt = next((s2 for s2 in starts if s2 > m.start()), len(text)) + rest = text[m.end() : nxt] + + # ⚠️ **A quote immediately followed by `(`, and EITHER quote character.** Taking + # "the first single-quoted string" instead reported six calls in + # `scripts/local-dev-seed.sh` as having no argument at all. Two are + # double-quoted because they interpolate a shell variable + # (`set_stripe_origin "(\"${ORIGIN}\")"`); the rest are `printf` lines whose + # first quote closes the FORMAT string, so the scanner read past the argument + # entirely. Anchoring on the parenthesis finds the argument in every one. + arg = None + opening = re.search(r"['\"]\(", rest) + if opening: + ch = rest[opening.start()] + i = opening.start() + 1 + j = i + while j < len(rest): + # `\"` is an escaped quote inside a double-quoted shell word, not the end + # of it. A single-quoted shell word cannot contain its own quote at all, + # so this only ever matters for the double-quoted form. + if ch == '"' and rest[j] == "\\": + j += 2 + continue + if rest[j] == ch: + break + j += 1 + if j < len(rest): + candidate = rest[i:j].strip() + if candidate.startswith("("): + arg = candidate + yield method, arg, rest, line_no + + +def self_test() -> None: + """⚠️ **Unconditional, because every failure mode of this check is SILENT.** + + A green tick here means "every call matches", and the scanner going blind + produces exactly that tick. Breaking the `canister call backend` pattern + printed `0 literal call(s) checked` next to a pass and exited 0 -- the tick + saying the opposite of what happened. The floor in `main` catches a total + miss; this catches the subtler ones, where the scanner still finds calls but + counts their arguments wrong. + """ + cases = [ + ("()", 0), + ("(null, 50 : nat)", 2), + ("(vec { 500 : nat })", 1), + # Commas inside a record, a vec and a nested paren must not count. + ("(record { a = 1; b = vec { 1, 2, 3 } })", 1), + ('("a, b", 2)', 2), + ('(record { s = "x, y" }, opt true)', 2), + ] + for body, want in cases: + got = arity(body[1:-1]) + if got != want: + sys.exit(f"ABORT: self-test — arity({body}) gave {got}, expected {want}") + + sample = ( + "icp canister call backend health\n" + "icp canister call backend orphans '(null, 50 : nat)' -e ic\n" + # Double-quoted, because it interpolates: the shape that read as "no argument". + 'icp canister call backend set_stripe_origin "(\\"${ORIGIN}\\")" >/dev/null\n' + # A printf whose FIRST quote closes the format string, not the argument. + "printf ' icp canister call backend refresh_reserve %s\\n' \"'()'\"\n" + ) + got = [(m, a) for m, a, _, _ in calls(sample)] + want = [ + ("health", None), + ("orphans", "(null, 50 : nat)"), + ("set_stripe_origin", '(\\"${ORIGIN}\\")'), + ("refresh_reserve", "()"), + ] + if got != want: + sys.exit(f"ABORT: self-test — the scanner read {got}, expected {want}") + + +def main() -> int: + self_test() + if not DID.exists(): + sys.exit(f"ABORT: cannot read {DID} — run `mops build` first; cannot pass vacuously") + declared = did_arities(DID.read_text()) + if not declared: + sys.exit(f"ABORT: no methods found in {DID} — cannot pass vacuously") + problems, checked, templated, out_of_band, bare = [], 0, 0, 0, [] + + for name in TARGETS: + path = Path(name) + if not path.exists(): + continue + for method, arg, raw, line in calls(path.read_text()): + if PLACEHOLDER.search(method): + continue + if method not in declared: + problems.append(f"{name}:{line} {method} is not in the .did") + continue + if arg is None: + # ⚠️ **`--args-file` supplies the argument out of band**, so the call is + # complete and its arity is not checkable from the text. This is the only + # way to pass a SEALED secret: the ciphertext is a blob written by the + # sealer, and putting it on the command line is the exposure sealing + # exists to close. Found by this check flagging the mainnet-simulation procedure's own + # `set_stripe_api_key --args-file` line as argument-less. + if "--args-file" in raw: + out_of_band += 1 + continue + # A `%s`, a `$VAR` or a `` anywhere in the span means the + # argument is templated rather than missing -- a printf that prints the + # command for a human to fill in, most often. + if PLACEHOLDER.search(raw): + templated += 1 + continue + # ⚠️ RUNBOOK §0: "Always pass an explicit `'()'`". Omitting it makes + # `icp canister call` ask "Do you want to send this message? [y/N]" and + # read stdin, which hangs any script, cron job or CI step. So a bare + # call is a defect in its own right, not merely unchecked. + bare.append(f"{name}:{line} {method} is written with no argument") + continue + if PLACEHOLDER.search(arg): + templated += 1 + continue + body = arg[1:-1] if arg.endswith(")") else arg[1:] + got, want = arity(body), declared[method] + checked += 1 + if got != want: + problems.append( + f"{name}:{line} {method} takes {want} argument(s), the call passes {got}: {arg[:60]}" + ) + + print( + f"{len(declared)} methods in the .did; {checked} literal call(s) checked, " + f"{templated} templated, {out_of_band} via --args-file, {len(bare)} bare" + ) + # ⚠️ **The floor.** Without it, a scanner that matches nothing reports a pass. + if checked < MIN_CHECKED: + sys.exit( + f"ABORT: only {checked} literal call(s) checked, expected at least {MIN_CHECKED} " + "— the scanner is not finding the calls, so this cannot pass vacuously" + ) + + problems += bare + if problems: + print(f"\n\033[31m✗ {len(problems)} call(s) would fail on contact:\033[0m") + for p in problems: + print(f" {p}") + return 1 + print("\033[32m✓ every documented call matches the .did\033[0m") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check-doc-links.py b/scripts/check-doc-links.py new file mode 100755 index 0000000..c8070d6 --- /dev/null +++ b/scripts/check-doc-links.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Every relative markdown link resolves — the file AND the `#anchor`. + +⚠️ **The anchor half is the point.** A link to a file that moved is loud: GitHub 404s. +A link to a *section* that was renamed is silent — the page opens at the top and the +reader never learns they were sent somewhere specific. This repo has produced that class +repeatedly: pointers at `§10` for a checklist that was `§9`, at a "step 5" that renumbered, +and at a "commit identification above" that had been deleted. + +⚠️ **What this does NOT do: notice a link that names a section without anchoring to it.** +Writing ``docs/OPERATE.md`` — Mode 1 sends the reader to the top of a 900-line file to go +hunting, and that is worth fixing — but detecting it needs to match prose against headings, +and the version attempted here was both too eager and too weak: it flagged the Key +documents table, where a row's description IS the file's title, and it missed the real +cases, where the prose says "Mode 1" and the heading is "Mode 1 — local". A check that +noisy would be turned off. Anchors are added by reading; this verifies the ones written. + +GitHub's anchor rule lives in `scripts/_github_anchor.py` and is imported, not restated — +`release-notes.py` needs the same rule to link into `CHANGELOG.md` at a tag. +""" + +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from _github_anchor import anchor, self_test as anchor_self_test # noqa: E402 + +FILES = sorted(set(list(Path(".").glob("*.md")) + list(Path("docs").glob("*.md")))) +LINK = re.compile(r"\[(?P[^\]]*)\]\((?P[^)\s]+)\)") +HEADING = re.compile(r"^#{1,6}\s+(.*?)\s*$", re.M) +FENCE = re.compile(r"```.*?```", re.S) + + +def links_in(src: str): + """(line, href) for every markdown link outside a fenced block. + + ⚠️ **Fences are SKIPPED BY POSITION, not stripped.** Two reasons, and both were bugs + here. Searching a stripped copy for the matched text attributes every duplicate link + to the first one's line — this README has the same link twice, so it sent the author + to a line that was already correct. And matching line-by-line instead would drop a + link whose TEXT wraps across a newline, of which this README has three, silently + checking less than it claims. + """ + fences = [(m.start(), m.end()) for m in FENCE.finditer(src)] + for m in LINK.finditer(src): + if any(a <= m.start() < b for a, b in fences): + continue + yield src.count("\n", 0, m.start()) + 1, m.group("href") + + +def anchors_of(path: Path) -> set[str]: + return {anchor(h) for h in HEADING.findall(FENCE.sub("", path.read_text()))} + + +def _self_test() -> None: + """Unconditional: the anchor rule is a transcription of GitHub's, and a wrong one + would fail every link or none — both of which read as 'the check ran'.""" + anchor_self_test() + # the same link twice must report two DIFFERENT lines, and a link whose text wraps + # must still be seen at all + sample = "[a](x.md#p)\nfiller\n[a](x.md#p)\n```\n[in](a-fence.md)\n```\n[wrapped\ntext](y.md#q)\n" + got = list(links_in(sample)) + assert got == [(1, "x.md#p"), (3, "x.md#p"), (7, "y.md#q")], got + + +def main() -> int: + _self_test() + checked = 0 + bad: list[str] = [] + for f in FILES: + for line, href in links_in(f.read_text()): + if href.startswith(("http://", "https://", "mailto:")): + continue + file_part, _, frag = href.partition("#") + target = f.parent / file_part if file_part else f + if not target.exists(): + bad.append(f"{f}:{line}: link target missing — {href}") + continue + checked += 1 + if frag and frag not in anchors_of(target): + bad.append(f"{f}:{line}: no such section in {file_part or f.name} — #{frag}") + if checked == 0: + sys.exit("ABORT: resolved no relative links — this check cannot pass vacuously") + if bad: + print("\n\033[31m✗ a documentation link does not land where it says\033[0m", file=sys.stderr) + for b in bad: + print(f" {b}", file=sys.stderr) + return 1 + print(f" {checked} relative doc link(s): every file and every #anchor resolves") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check-doc-surface.py b/scripts/check-doc-surface.py new file mode 100755 index 0000000..da1f206 --- /dev/null +++ b/scripts/check-doc-surface.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 +"""Assert the docs' method lists match the canister's ACTUAL public surface. + +⚠️ **Why this is a gate step and not a review habit.** A table introduced with "this is +the whole admin surface" drifts as methods land: one in `docs/STRIPE.md` was missing +**11 of 29** admin methods, including the two secret-status queries `RUNBOOK.md` tells an +operator to call to confirm a rotation — while `RUNBOOK.md` named `webhook_status`, which +has never existed. The method is `webhook_secret_status`. + +Neither failure is visible by reading either document: a list of plausible method names +reads as complete, and a name that does not exist reads exactly like one that does. The +only thing that can tell them apart is the interface itself, so the check is mechanical. + +**Authority is the committed `.did`** (regenerated and diff-checked one step earlier in +`test-all.sh`, so by the time this runs it is known current) for *what exists*, and +`Main.mo`'s guards for *who may call it*. + +Two things are deliberately NOT enforced, because enforcing them would make the check +a nuisance rather than a tripwire: + + - **Prose descriptions.** Only names are compared. A stale *description* is a real + problem — `recount_orders` described as "rebuild… from the store" after it became + bounded — but it is not mechanically checkable, and a check that cannot see it must + not imply it can. + - **Every mention.** Only the explicitly-marked list blocks are compared. A method + named in passing mid-paragraph is prose. + +Marked blocks are delimited in the Markdown by HTML comments, so the checker never has +to guess where a list starts: + + ... + ... + ... + +Within a block, every `` `identifier` `` is taken as a claimed member. `surface:public` +blocks may omit the HTTP-gateway plumbing (`http_request`, `http_request_update`, +`transform_stripe_response`) and `create_order`, which are public but are not +*monitoring* queries — that allowance is listed explicitly below rather than inferred, +so adding a method never silently lands in it. +""" + +import glob +import re +import sys + +DID = "src/backend/dist/backend.did" +# ⚠️ **Endpoints live in `Main.mo` AND in `mixins/*.mo`.** Scanning only the composition +# root aborts with "parser is wrong", correctly: a check that cannot find what it is +# checking must not report a clean scan. Both are read as one body of source. +ENDPOINT_SOURCES = ("src/backend/Main.mo", "src/backend/mixins/*.mo") + +# Public, but not part of any documented "queries you can poll" list. Explicit, so a +# newly added method is a failure rather than something that quietly qualifies. +PUBLIC_NOT_LISTED = { + "http_request", # HTTP gateway plumbing, not an API + "http_request_update", # ditto + "transform_stripe_response", # the outcall consensus transform + "create_order", # public, but an update on the buyer path + "cancel_order", # owner-scoped in effect; listed under owner +} + +# Public, and they legitimately read `caller`. ⚠️ **Explicit because the scope detection +# below FAILS on any other method that takes the caller and does nothing it recognises +# with it** — so a new caller-aware method has to be classified deliberately, rather than +# defaulting to "public" and forcing the docs to assert it. +PUBLIC_WITH_CALLER = { + "create_order", # public update on the buyer path; the caller BECOMES the owner + "can_purchase", # the admission gate's cap is per principal, so it must read caller + # ⚠️ Ungated ON PURPOSE: an admin who is NOT yet granted has to be able to read + # their own principal and see that it is not granted. A guarded version would reject + # exactly the caller who needs the answer, and a UI could not tell "not granted" from + # "not reachable". It discloses nothing about anyone else — the answer is about + # `caller`. + "admin_status", +} + +FAIL = [] + + +def real_surface(): + names = sorted(set(re.findall(r"^ ([a-z_][a-z0-9_]*):", open(DID).read(), re.M))) + if not names: + sys.exit(f"ABORT: parsed no methods out of {DID} — the check would pass vacuously") + lines = [] + for pattern in ENDPOINT_SOURCES: + for f in sorted(glob.glob(pattern)): + lines.extend(open(f).read().split("\n")) + decls = [] + for i, l in enumerate(lines): + m = re.search(r"\bpublic\b.*\bfunc\s+([a-z_][A-Za-z0-9_]*)\s*[(<]", l) + if m: + decls.append((i, m.group(1))) + starts = sorted(i for i, _ in decls) + by = {n: i for i, n in decls} + out = {} + for n in names: + if n not in by: + sys.exit(f"ABORT: {n} is in {DID} but no public func in {ENDPOINT_SOURCES} — parser is wrong") + i = by[n] + nxt = [s for s in starts if s > i] + body = "\n".join(lines[i:(nxt[0] if nxt else len(lines))]) + # Comments mention guards they do not call; compare code only. + code = "\n".join( + l for l in body.split("\n") if not l.strip().startswith("//") + ) + # ⚠️ **Both guards mean admin scope here.** Authz has two tiers — + # `requireController` (the rules) and `requireAdmin` (individual cases) — and the + # docs' vocabulary has three blocks, public/owner/admin, which is coarser. The + # tier a method sits in is enforced by `check-admin-tiers.py`; this check only + # cares that the method is not reachable by a buyer. + # + # When the split landed, this line said `"requireAdmin" in code` and the nine + # rewired setters came out as `unclassifiable` — which is the fourth outcome doing + # its job: it failed naming them, rather than quietly filing canister-control + # methods as public. + admin = re.search(r"\brequire(?:Admin|Controller)\s*\(", code) is not None + # ⚠️ **Owner scope is detected by CONVENTION, and keeping the convention is the + # point: an owner-scoped read delegates to an `Orders` helper whose NAME says so + # — `getOwned`, `ownerPage`. If you add one, name it that way.** + # + # Enumerating literal call forms broke this TWICE, both times in the same + # direction: a regex looking only for `order.owner` misses + # `getOwned(store, id, caller)`, and one written for `owner = ?caller` misses + # `ownerPage`. Both times an owner-scoped read is reported as PUBLIC — and since + # the .did is authoritative by then, the remedy on offer is to go and assert a + # false scope in the docs. + owner = re.search(r"Orders\.\w*(?:[Oo]wned|[Oo]wner)\w*\s*\(", code) is not None + # ⚠️ **`unclassifiable` is load-bearing for a DIFFERENT check — do not relax it.** + # `check-admin-tiers.py` derives its population from the guards, so a method MEANT + # to be admin-gated that forgot the guard entirely is invisible there: it just + # looks public. This outcome is what names it. The two checks compose, and neither + # covers that gap alone. + # + # **The DEFAULT was the defect, not the pattern.** Falling through to "public" + # is what let both earlier breaks offer "go assert a false scope in the docs" as + # the remedy. A method that destructures `{ caller }` and then calls neither + # `requireAdmin` nor a recognised owner helper has been handed the caller and + # done nothing this check understands with it — so say so and fail, rather than + # guessing the least safe answer. This alone catches a renamed owner helper. + # ⚠️ A PATTERN, not the literal `"{ caller }"`. All 36 caller-taking declarations + # use that exact spelling today, so the substring worked — and a formatter + # producing `({caller})`, or a declaration wrapped across lines, would fall + # through to `public`, which is the old bad default returning silently for the + # one case it cannot see. Enumerating a form is what this check keeps getting + # wrong. + takes_caller = re.search(r"\(\s*\{[^}]*\bcaller\b", lines[i]) is not None + if admin: + out[n] = "admin" + elif owner: + out[n] = "owner" + elif takes_caller and n not in PUBLIC_WITH_CALLER: + out[n] = "unclassifiable" + else: + out[n] = "public" + return out + + +def claimed(path, kind): + text = open(path).read() + blocks = re.findall( + rf"(.*?)", text, re.S + ) + names = set() + for b in blocks: + for line in b.split("\n"): + if line.lstrip().startswith("|"): + # ⚠️ Table rows: FIRST cell only. Parsing the whole row picks up + # backticked words out of the prose in the Purpose column (`seq`, + # `limit`, `rk_`) and reports them as methods that do not exist — + # a checker crying wolf gets switched off. + cells = [c for c in line.split("|")] + cell = cells[1] if len(cells) > 1 else "" + names |= set(re.findall(r"`([a-z_][a-z0-9_]*)`", cell)) + else: + names |= set(re.findall(r"`([a-z_][a-z0-9_]*)`", line)) + return names, len(blocks) + + +def main() -> int: + surface = real_surface() + real = { + k: {n for n, v in surface.items() if v == k} for k in ("public", "owner", "admin") + } + print( + f" canister surface: {len(real['public'])} public, " + f"{len(real['owner'])} owner-scoped, {len(real['admin'])} admin" + ) + + unclassifiable = sorted(n for n, v in surface.items() if v == "unclassifiable") + if unclassifiable: + print( + "\n\033[31m✗ cannot determine the scope of: " + + ", ".join(unclassifiable) + + "\033[0m", + file=sys.stderr, + ) + print( + " Each takes `{ caller }` and calls neither `requireAdmin` nor an `Orders`\n" + " helper whose name says owner. Either route it through one, or add it to\n" + " PUBLIC_WITH_CALLER with the reason. ⚠️ Do NOT resolve this by editing a\n" + " doc: the scope is undetermined, so any block you put it in is a guess.", + file=sys.stderr, + ) + return 1 + + checked = 0 + for path in ("docs/STRIPE.md", "RUNBOOK.md"): + for kind in ("public", "owner", "admin"): + names, nblocks = claimed(path, kind) + if nblocks == 0: + continue + checked += 1 + expected = set(real[kind]) + if kind == "public": + expected -= PUBLIC_NOT_LISTED + missing = expected - names + extra = names - real[kind] + if missing: + FAIL.append( + f"{path}: the surface:{kind} block omits {len(missing)} " + f"{kind} method(s): {', '.join(sorted(missing))}" + ) + if extra: + unknown = extra - set(surface) + wrong = extra & set(surface) + if unknown: + FAIL.append( + f"{path}: the surface:{kind} block names method(s) that DO NOT " + f"EXIST: {', '.join(sorted(unknown))}" + ) + if wrong: + detail = ", ".join(f"{n} (really {surface[n]})" for n in sorted(wrong)) + FAIL.append( + f"{path}: the surface:{kind} block claims the wrong scope for: {detail}" + ) + + if checked == 0: + # ⚠️ A check with nothing to check must fail. Silently passing is how a marker + # deleted in a doc rewrite turns this into a green tick that verifies nothing. + sys.exit("ABORT: found no blocks to check — this check cannot pass vacuously") + + if FAIL: + print("\n\033[31m✗ the docs disagree with the canister's actual surface\033[0m", file=sys.stderr) + for f in FAIL: + print(f" {f}", file=sys.stderr) + print( + "\n Fix the doc, not this check. The .did is regenerated and diff-checked\n" + " one step earlier, so it is the current interface by construction.", + file=sys.stderr, + ) + return 1 + print(f" {checked} documented surface list(s) agree with the .did") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check-duplicate-bodies.py b/scripts/check-duplicate-bodies.py new file mode 100755 index 0000000..2ed0538 --- /dev/null +++ b/scripts/check-duplicate-bodies.py @@ -0,0 +1,304 @@ +#!/usr/bin/env python3 +"""Fail if two module functions have the same body. + +⚠️ **The class: one piece of arithmetic implemented twice, in two modules, both live.** +Nothing else here can see it. `check-unused-motoko.py` finds a function with no caller; +this finds two functions that both have callers and both do the same thing — where the +loss is not dead code but **divergence**, because the next correction lands on one of them. + +**The motivating class, on the money path:** a private `Reserve.deliverable` +re-implementing `Delivery.deliverableCycles` — the fee-on-top correction — while claiming +*"the same correction, so the same function"* and taking over a call site from the real +one. Delivery and withdrawal are the two outflow classes of one account, so a fee +correction diverging between them is exactly the failure `Reserve.mo`'s "two destination +classes, ONE outflow mechanism" framing exists to prevent. ⚠️ **A naive implementation +does not catch it**, for two reasons recorded on `MIN_TOKENS` and on the normalisation +steps -- each of those notes is a claim measurement contradicted, so do not relax one +without re-measuring. + +**Normalisation:** comments stripped (block comments NEST), parameters renamed +positionally, in-expression type annotations dropped, whitespace collapsed. The last two +exist because the motivating case needed them — see the notes at the code. + +⚠️ **`MIN_TOKENS` is a small floor, not a filter, and the measurement is on the constant +itself.** This tree has zero cross-module duplicate bodies at every threshold from 4 up, +so the bar excludes nothing today; it exists so a future pair of one-expression +projections cannot report as duplication. The count below the bar is printed so the +exemption stays visible rather than implied. + +⚠️ **What this does NOT reach**, stated because a check implying more than it verifies is +worse than no check: + + - **Near-duplicates.** Two bodies that differ by a LOCAL variable name, an argument + order, or a `+ 1` are distinct here. Parameter names and in-expression type + annotations ARE normalised — both were needed to see the motivating case — but + anything beyond that needs a parser, and a similarity score would need tuning against + false positives on every commit. + - **Duplication across a module and a mixin, or inside one function.** Only + `public func` bodies in `src/backend/**/*.mo` are compared, and only against each + other. + - **Whether the duplicate is the WRONG one.** It reports the pair; a human decides + which call site should survive. +""" + +import glob +import os +import re +import sys +import tempfile + +SOURCES = ("src/backend/*.mo", "src/backend/rails/*.mo", "src/backend/mixins/*.mo") + +# Smallest body worth reporting, in whitespace-separated tokens of the normalised text. +# +# ⚠️ **Measured, and the first version of this constant was a guess that made the check +# blind to its own motivating case.** It was set to 18 on the invented claim that "the +# real duplicate is 22 tokens"; the real duplicate normalises to **9**, so the check +# passed over it. And the collision the bar was supposed to prevent does not exist here: +# scanned at 4, 6, 8, 9, 10 and 12 tokens, this tree has **zero** cross-module duplicate +# bodies at every one of them. So the bar is not holding anything back — it stays only to +# keep a future pair of one-expression projections (`store.promised`, 2 tokens) from +# reporting as duplication, and it sits below the smallest real duplicate seen. +MIN_TOKENS = 6 + +FUNC = re.compile(r"^\s*public\s+func\s+([a-z_][A-Za-z0-9_]*)", re.M) + +# `name : Type` inside a parameter list. Used to rename parameters positionally so two +# copies of one rule compare equal when only their argument names differ. +PARAM = re.compile(r"([a-z_][A-Za-z0-9_]*)\s*:") + +# ` : TypeName` inside an expression — disambiguation for the compiler, not behaviour. +ANNOTATION = re.compile(r"\s*:\s*\??[A-Z][A-Za-z0-9_.]*") + + +def strip_comments(src: str) -> str: + """Motoko source with comments blanked, string bodies kept. Block comments NEST.""" + out = [] + i, n = 0, len(src) + while i < n: + c = src[i] + if c == '"': + out.append(c) + i += 1 + while i < n: + if src[i] == "\\" and i + 1 < n: + out.append(" ") + i += 2 + continue + out.append(src[i]) + if src[i] == '"': + i += 1 + break + i += 1 + continue + if c == "/" and i + 1 < n and src[i + 1] == "/": + while i < n and src[i] != "\n": + i += 1 + continue + if c == "/" and i + 1 < n and src[i + 1] == "*": + depth = 1 + i += 2 + while i < n and depth: + if src[i] == "/" and i + 1 < n and src[i + 1] == "*": + depth += 1 + i += 2 + continue + if src[i] == "*" and i + 1 < n and src[i + 1] == "/": + depth -= 1 + i += 2 + continue + if src[i] == "\n": + out.append("\n") + i += 1 + continue + out.append(c) + i += 1 + return "".join(out) + + +def bodies(path): + """(name, normalised body) for every `public func` in one file. + + The body is the text between the signature's opening `{` and its matching `}`, found + by counting braces over comment-stripped source — so a `{` inside a comment or a + string cannot end a body early. + """ + text = strip_comments(open(path).read()) + out = [] + for m in FUNC.finditer(text): + open_at = text.find("{", m.end()) + if open_at == -1: + continue + depth, i = 1, open_at + 1 + while i < len(text) and depth: + # ⚠️ **Strings are skipped, and the self-test is why this is here.** The + # stripper deliberately KEEPS string bodies (a symbol inside one is a real + # reference), so a `}` in a string literal would close the body early and + # every function after it would compare as a truncated fragment. The first + # run of the self-test caught exactly that. + if text[i] == '"': + i += 1 + while i < len(text): + if text[i] == "\\": + i += 2 + continue + if text[i] == '"': + break + i += 1 + i += 1 + continue + if text[i] == "{": + depth += 1 + elif text[i] == "}": + depth -= 1 + i += 1 + body = " ".join(text[open_at + 1 : i - 1].split()) + # ⚠️ **Parameters are renamed POSITIONALLY, and this is the whole point of the + # check.** A duplicate takes `(total, fee)` where the original takes + # `(lockedCycles, fee)` — identical arithmetic, different argument names, chosen + # naturally rather than to dodge anything. Comparing raw text calls them distinct, + # which blinds the check to exactly the instance it exists for. + # Measured before and after: raw text 9 vs 11 tokens and unequal; normalised, + # both are `if (_1 >= _0) return null; ?(_0 - _1 : Nat);` and equal. + params = PARAM.findall(" ".join(text[m.end() : open_at].split())) + for position, param in enumerate(dict.fromkeys(params)): + body = re.sub(rf"\b{re.escape(param)}\b", f"_{position}", body) + # ⚠️ **In-expression type annotations are dropped, and that is what closes the + # motivating case.** A copy writes `?(_0 - _1 : Nat)` where the original + # writes `?(_0 - _1)` — an annotation that disambiguates for the compiler and + # says nothing about behaviour, so leaving it in made two identical rules compare + # distinct. ⚠️ The cost is stated rather than hidden: two bodies that differ ONLY + # by which type they annotate (`: Nat` vs `: Int`) now collide and are reported. + # That pair is worth a human look anyway, and the report names both sites. + body = ANNOTATION.sub("", body) + body = " ".join(body.split()) + out.append((m.group(1), body)) + return out + + +def self_test(): + """⚠️ Unconditional. The brace matcher and the stripper are the instrument; if either + silently stops working, every body reads as unique and this passes over anything.""" + sample = "\n".join([ + "module {", + " public func alpha(a : Nat, b : Nat) : ?Nat {", + " if (b >= a) return null; // a comment { with a brace", + ' let s = "a } string";', + " ?(a - b : Nat);", + " };", + " public func beta(a : Nat, b : Nat) : ?Nat {", + " if (b >= a) return null;", + ' let s = "a } string";', + " ?(a - b : Nat);", + " };", + " public func gamma(x : Nat) : Nat { x + 1 };", + "};", + ]) + fd, path = tempfile.mkstemp(suffix=".mo") + with os.fdopen(fd, "w") as fh: + fh.write(sample) + got = bodies(path) + os.unlink(path) + names = [n for n, _ in got] + if names != ["alpha", "beta", "gamma"]: + sys.exit(f"ABORT: self-test parsed {names}, expected alpha, beta, gamma") + by = dict(got) + if by["alpha"] != by["beta"]: + sys.exit( + "ABORT: self-test — two identical bodies did not normalise equal:\n" + f" {by['alpha']!r}\n {by['beta']!r}" + ) + if by["alpha"] == by["gamma"]: + sys.exit("ABORT: self-test — different bodies normalised equal") + if "comment" in by["alpha"] or "} string" not in by["alpha"]: + sys.exit(f"ABORT: self-test — stripper wrong on {by['alpha']!r}") + + # ⚠️ **The case the check exists for: same rule, different parameter names.** Without + # positional renaming these compare distinct, which is how the first version of this + # script passed over the very duplicate it was written to catch. + renamed = "\n".join([ + "module {", + " public func one(lockedCycles : Nat, fee : Nat) : ?Nat {", + " if (fee >= lockedCycles) return null;", + " ?(lockedCycles - fee : Nat);", + " };", + " public func two(total : Nat, fee : Nat) : ?Nat {", + " if (fee >= total) return null;", + " ?(total - fee : Nat);", + " };", + "};", + ]) + fd, path = tempfile.mkstemp(suffix=".mo") + with os.fdopen(fd, "w") as fh: + fh.write(renamed) + pair = dict(bodies(path)) + os.unlink(path) + annotated = "\n".join([ + "module {", + " public func three(a : Nat, b : Nat) : ?Nat { ?(a - b) };", + " public func four(x : Nat, y : Nat) : ?Nat { ?(x - y : Nat) };", + "};", + ]) + fd2, path2 = tempfile.mkstemp(suffix=".mo") + with os.fdopen(fd2, "w") as fh: + fh.write(annotated) + ann = dict(bodies(path2)) + os.unlink(path2) + if ann["three"] != ann["four"]: + sys.exit( + "ABORT: self-test — an in-expression annotation kept two identical rules " + f"apart:\n {ann['three']!r}\n {ann['four']!r}" + ) + + if pair["one"] != pair["two"]: + sys.exit( + "ABORT: self-test — one rule under two parameter names did not compare equal:\n" + f" {pair['one']!r}\n {pair['two']!r}" + ) + + +def main(): + self_test() + files = [] + for pattern in SOURCES: + files.extend(sorted(glob.glob(pattern))) + if not files: + sys.exit(f"ABORT: no sources matched {SOURCES} — cannot pass vacuously") + + seen = {} + total = 0 + below = 0 + for f in files: + for name, body in bodies(f): + total += 1 + if len(body.split()) < MIN_TOKENS: + below += 1 + continue + seen.setdefault(body, []).append((name, f)) + if total == 0: + sys.exit(f"ABORT: parsed no `public func` bodies out of {len(files)} files") + + dupes = {b: w for b, w in seen.items() if len({f for _, f in w}) > 1} + if dupes: + print("\n\033[31m✗ the same function body appears in two modules\033[0m", file=sys.stderr) + for body, where in dupes.items(): + print(" " + " = ".join(f"{n} ({f})" for n, f in where), file=sys.stderr) + print(f" {body[:100]}", file=sys.stderr) + print( + "\n Call one from the other. Two live implementations of one rule means the\n" + " next correction lands on one of them — and on the money path that is a\n" + " divergence nothing else here can see. Check the import direction is\n" + " acyclic; it usually is.", + file=sys.stderr, + ) + return 1 + + print( + f" {len(seen)} module function bodies compared, none duplicated" + f" ({below} below the {MIN_TOKENS}-token bar)" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check-endpoint-docs.py b/scripts/check-endpoint-docs.py new file mode 100755 index 0000000..a1219f9 --- /dev/null +++ b/scripts/check-endpoint-docs.py @@ -0,0 +1,377 @@ +#!/usr/bin/env python3 +"""Fail if a public endpoint has no documentation. + +⚠️ **Why this is a gate step, and why it is phrased as the VICTIM rather than the +defect.** A `///` block sits above a declaration; insert or move a declaration between +them and the block silently attaches to the wrong one. In Motoko that reaches the +canister's published interface — the doc lands on the wrong method in `backend.did`, so +the interface documents the wrong contract. + +This class produced **five** separate instances in this repo before anything checked it: +`cancel_order`'s contract published on `expire_order`; `feeBreakdown`'s doc over +`feeRows`; `estimateLine`'s over `creditedSplit`; a direction warning stranded above a +function that computes no direction; and `refusal_counts` plus `orphan_depth` both glued +above `admin_order`, which the interface therefore documented with three unrelated +contracts. Every one was found by a human reading. + +⚠️ **The orphan cannot be detected; the victim can.** Adjacent `///` lines are +indistinguishable from one multi-paragraph doc, so a text heuristic over doc blocks is +hopeless — the one written for this produced 70 candidates, of which 1 was real. But a +declaration whose doc was absorbed by a neighbour has **none of its own**, which is a +fixed syntactic target and therefore gate-able. + +⚠️ **Two checks over two oracles, and neither subsumes the other.** + + 1. `undocumented()` reads the SOURCE and asks "does every endpoint have a doc". The + first version asked this of `backend.did`, the tidier oracle — until endpoints moved + into `mixin` blocks and moc dropped every one of their docs from the + interface, so the `.did` could no longer answer it. The source always could. + 2. `misattributed()` reads BOTH and asks "is the doc the interface publishes the one + written above that endpoint". Only the `.did` can answer this, because the class it + catches is invisible in source by construction. + +⚠️ **moc 1.16.0 emits mixin members' docs, which is why (2) has this shape.** Measured on +this project: 0 doc lines inside the `service` block on 1.15.1, **613 on 1.16.0**, with +all 62 endpoints' published blocks byte-equal to their source blocks. The previous version +of (2) asserted the `.did` documented *no* endpoint — sound only while the docs were being +dropped — and its remedy said *make it `//` instead of `///`*, which under 1.16.0 would +delete a deliberately published doc. Comparing the two texts replaces an absence +assertion with a direct one. + +⚠️ **What this does NOT reach**, stated because a check implying more than it verifies is +worse than no check: + + - **Whether a doc describes the declaration it sits on.** A method documented with the + WRONG contract passes: it has a doc. This catches the victim, never the thief — so it + makes the class *detectable*, not impossible. Two glued blocks over one undocumented + neighbour is the shape it sees. + - **A doc absorbed onto an endpoint from a NON-endpoint neighbour.** Invisible in + source by construction: the block sits correctly above the state declaration that + owns it, and the endpoint has its own `///`, so thief and victim both read clean. + Only the `.did` shows it — `webhookSecret`'s doc published on `get_order`, the price + tiles' on `resolve_problem` — so `misattributed()` below covers this one from the + `.did` side, which is the only side that can see it. + - **Types, fields, and private helpers.** Only `public shared` / `public query` + endpoints are checked. + - **Doc quality.** A single `///` line satisfies it. +""" + +import glob +import re +import sys + +# Every file that may declare a public endpoint: the composition root and the mixins. +SOURCES = ("src/backend/Main.mo", "src/backend/mixins/*.mo") + +ENDPOINT = re.compile( + r"^\s*public\s+(?:shared\s+)?(?:query\s+)?(?:shared\s+)?(?:query\s+)?" + r"(?:\([^)]*\)\s*)?func\s+([a-z_][A-Za-z0-9_]*)", + # ⚠️ `re.M` so `^` means line-start: without it `findall` matched nothing and the + # count went to zero. The abort guard caught that rather than reporting a clean scan, + # which is the whole reason it is there. + re.M, +) + + +def undocumented(text): + """Endpoint names with no `///` line immediately above their declaration.""" + out = [] + lines = text.split("\n") + for i, line in enumerate(lines): + m = ENDPOINT.match(line) + if not m: + continue + # Walk back over the declaration's own wrapped lines is unnecessary: the doc, if + # any, is the line directly above the `public` line. + prev = lines[i - 1].strip() if i > 0 else "" + if not prev.startswith("///"): + out.append(m.group(1)) + return out + + +DID = "src/backend/dist/backend.did" + +# A service method plus, on the line before, a doc line. +DID_METHOD = re.compile(r"^\s*([a-z_][a-z_0-9]*):\s") + + +def doc_above(lines, i): + """The `///` block immediately above line `i`, in source order, markers stripped.""" + out, j = [], i - 1 + while j >= 0 and lines[j].strip().startswith("///"): + out.append(lines[j].strip()[3:].strip()) + j -= 1 + return list(reversed(out)) + + +def published_docs(did_text): + """endpoint name -> the doc block the `.did` carries for it.""" + lines = did_text.split("\n") + service = next((i for i, l in enumerate(lines) if l.startswith("service")), None) + if service is None: + sys.exit(f"ABORT: no `service` block in {DID} — cannot pass vacuously") + out = {} + # ⚠️ **`service + 1`, and the `+ 1` is load-bearing.** `service : {` escapes + # `DID_METHOD` only because moc puts a space before the colon. Scanning from + # `service` itself would report `service` as an endpoint the day that whitespace + # changes, and the line above it is `Main.mo`'s file header, which moc emits as the + # service doc — so it would look like a misattribution nobody could connect back to + # a compiler's spacing. + for i in range(service + 1, len(lines)): + m = DID_METHOD.match(lines[i]) + if m: + doc = doc_above(lines, i) + if doc: + out[m.group(1)] = doc + return out + + +def written_docs(files): + """endpoint name -> the doc block written above its declaration in the source.""" + out = {} + for f in files: + lines = open(f).read().split("\n") + for i, line in enumerate(lines): + m = ENDPOINT.match(line) + if m: + doc = doc_above(lines, i) + if doc: + out[m.group(1)] = doc + return out + + +def misattributed(published, written): + """Endpoints whose PUBLISHED doc is not the one written above their declaration. + + ⚠️ **This replaced a check asserting the `.did` documented NO endpoint.** That + invariant held for a mechanical reason — moc did not emit doc comments for mixin + members, and every endpoint is in a mixin — so any doc that appeared in the service + block had floated there off a private declaration. **moc 1.16.0 emits mixin members' + docs**, measured at 0 -> 613 doc lines inside this project's service block, so the old + premise is gone and its remedy (*make it `//`*) would now delete a correct doc. + + The class it existed for survives, and this is the direct form of it: a doc absorbed + onto an endpoint during EMISSION is invisible in the source — the block sits correctly + above the declaration that owns it — and shows up only as a `.did` that publishes + something other than what was written. Comparing the two catches it by construction, + rather than by asserting the absence of all docs. + + ⚠️ **What this does NOT catch, and `undocumented()` does.** A SOURCE-level theft — a + declaration moved in between a `///` block and its function — relocates the doc to the + neighbour, and moc then publishes it there. `.did` and source agree, so this function + is silent; the tell is the victim, which loses its doc entirely. The two checks are + complementary and neither subsumes the other. + """ + out = [] + for name in sorted(published): + if written.get(name) != published[name]: + out.append(name) + return out + + +def self_test(): + """⚠️ Unconditional, like the other parsers here. If the scan silently stops matching + endpoints, this step passes while checking nothing.""" + sample = "\n".join([ + " /// Documented.", + " public shared ({ caller }) func alpha() : async () {};", + " public query func beta() : async Nat { 0 };", + " /// Also documented.", + " public shared query ({ caller }) func gamma() : async Nat { 0 };", + " public shared func delta() : async () {};", + " // not a doc comment", + " public func epsilon() : async () {};", + " func privateHelper() : Nat { 0 };", + ]) + got = undocumented(sample) + if got != ["beta", "delta", "epsilon"]: + sys.exit(f"ABORT: self-test expected ['beta', 'delta', 'epsilon'], got {got}") + + # ⚠️ **Both directions, because a comparison that never reports is the failure mode + # here.** The old check asserted an absence, so "no docs found" and "scanner broken" + # produced the same pass. This one compares two parsers, so BOTH have to be shown + # working: one endpoint that agrees must stay silent, one that disagrees must report. + did = "\n".join([ + "type Order = record { id : nat };", + "service : {", + " /// Fetch one order.", + " get_order: (OrderId) -> (opt Order) query;", + " /// Liveness.", + " health: () -> (Health) query;", + " undocumented_here: () -> ();", + "}", + ]) + pub = published_docs(did) + if pub != {"get_order": ["Fetch one order."], "health": ["Liveness."]}: + sys.exit(f"ABORT: self-test — published_docs parsed {pub}") + + agreeing = {"get_order": ["Fetch one order."], "health": ["Liveness."]} + if misattributed(pub, agreeing) != []: + sys.exit("ABORT: self-test — matching docs reported as misattributed") + + # A doc published on an endpoint that is not the doc written above it. + disagreeing = {"get_order": ["Cancel an order."], "health": ["Liveness."]} + if misattributed(pub, disagreeing) != ["get_order"]: + sys.exit( + "ABORT: self-test — a published doc differing from the written one was not " + f"reported: {misattributed(pub, disagreeing)}" + ) + + # An endpoint the source has no doc for at all, published with one anyway: the exact + # shape of an absorbed doc, and it must not be silently tolerated. + if misattributed(pub, {"health": ["Liveness."]}) != ["get_order"]: + sys.exit("ABORT: self-test — a published doc with no written counterpart passed") + + # ⚠️ The service line itself, spelled without moc's space, must not read as an + # endpoint — see the note in `published_docs`. This case is what pins the `+ 1`. + tight = "\n".join([ + "/// The actor's own doc, which moc DOES emit.", + "service: {", + " health: () -> (Health) query;", + "}", + ]) + if published_docs(tight) != {}: + sys.exit(f"ABORT: self-test — the service line read as an endpoint: {published_docs(tight)}") + + # `written_docs` shares `doc_above` with the above, but its ENDPOINT matcher is its + # own; a regex that stops matching would make every endpoint look undocumented in + # source and therefore misattributed, so it is exercised too. + import tempfile, os + fd, path = tempfile.mkstemp(suffix=".mo") + with os.fdopen(fd, "w") as fh: + fh.write(sample) + got_written = written_docs([path]) + os.unlink(path) + if got_written != {"alpha": ["Documented."], "gamma": ["Also documented."]}: + sys.exit(f"ABORT: self-test — written_docs parsed {got_written}") + + +def main(): + self_test() + files = [] + for pattern in SOURCES: + files.extend(sorted(glob.glob(pattern))) + if not files: + sys.exit(f"ABORT: no sources matched {SOURCES} — cannot pass vacuously") + + total = 0 + missing = [] + for f in files: + text = open(f).read() + total += len(ENDPOINT.findall(text)) + for name in undocumented(text): + missing.append((f, name)) + if total == 0: + sys.exit(f"ABORT: parsed no endpoints out of {files} — cannot pass vacuously") + + if missing: + print("\n\033[31m✗ public endpoint(s) with no documentation\033[0m", file=sys.stderr) + for f, name in missing: + print(f" {name} ({f})", file=sys.stderr) + print( + "\n Two causes, and the second is the one worth looking for:\n" + " 1. It was never documented. Write the doc.\n" + " 2. ⚠️ Its doc was ABSORBED by a neighbour — a declaration moved in\n" + " between a `///` block and the function it belonged to, so another\n" + " declaration now carries this one's contract. Look directly above\n" + " for a doc block with two unrelated summary lines, and move the\n" + " first one down.", + file=sys.stderr, + ) + return 1 + # ⚠️ Counts the files that HOLD endpoints, not the files scanned. `Main.mo` is in the + # scan list and declares none, so reporting the scan size would read as though the + # composition root still had some. + # ⚠️ **This is an architecture rule, not a SOUNDNESS precondition.** A leak scan that + # read any doc in the service block as floated off private state would hold only + # while no endpoint is declared in the composition root. + # `misattributed()` compares published against written wherever the endpoint lives, + # so it no longer depends on this. Kept because A1 is worth enforcing on its own, and + # checked first so a stray root endpoint still gets its own message. + root = "src/backend/Main.mo" + root_endpoints = len(ENDPOINT.findall(open(root).read())) if root in files else 0 + if root_endpoints: + print( + f"\n\033[31m✗ {root} declares {root_endpoints} public endpoint(s)\033[0m", + file=sys.stderr, + ) + print( + "\n The composition root holds state and `include`s, no endpoints\n" + " (`reviewing-motoko` A1). Move it to the mixin that owns the feature.", + file=sys.stderr, + ) + return 1 + + published = published_docs(open(DID).read()) + written = written_docs(files) + # ⚠️ **A FLOOR, not an emptiness test, and the difference is the whole guard.** + # `if not published` fires only when every doc disappears. Lose 30 of 62 and the + # comparison would run over the surviving 32, report "all 32 match", and pass — with + # half the interface's documentation gone and `undocumented()` blind to it, because it + # reads only the source. Same shape as `MIN_VECTORS` in check-crypto-vectors.sh. + # + # ⚠️ **A floor rather than `len(published) == total`.** `ENDPOINT` also matches a plain + # `public func` in a mixin, so equality would quietly couple this guard to "no + # non-shared public func lives in a mixin" — true at 62/62 today, and not a property + # this check should start enforcing by accident. + MIN_PUBLISHED = 55 + if len(published) < MIN_PUBLISHED: + sys.exit( + f"ABORT: {DID} documents only {len(published)} endpoint(s), expected at least" + f" {MIN_PUBLISHED}. Since moc 1.16.0 every endpoint's doc is published, so this" + " means an older compiler built the .did, or the scan stopped matching, or docs" + " were dropped in bulk — all of which compare nothing and read as a clean run." + ) + wrong = misattributed(published, written) + if wrong: + print( + "\n\033[31m✗ the .did publishes a doc that is not the one written above the" + " endpoint\033[0m", + file=sys.stderr, + ) + for name in wrong: + print(f" {name}", file=sys.stderr) + pub, wri = published[name], written.get(name) + if wri is None: + print(" written: (no doc above the declaration)", file=sys.stderr) + print(f" published: {' '.join(pub)[:96]}", file=sys.stderr) + continue + # ⚠️ **Show the first line that DIFFERS, not the first line of each.** These + # blocks routinely share a long opening paragraph, so printing each one's head + # produced two identical-looking lines under a "these differ" heading — a + # message that names the right endpoint and then shows nothing wrong with it. + at = next( + (i for i in range(max(len(pub), len(wri))) + if (pub[i] if i < len(pub) else None) != (wri[i] if i < len(wri) else None)), + None, + ) + print(f" first difference at doc line {at + 1} of {len(pub)}:", file=sys.stderr) + print(f" published: {(pub[at] if at < len(pub) else '(block ends)')[:88]}", file=sys.stderr) + print(f" written: {(wri[at] if at < len(wri) else '(block ends)')[:88]}", file=sys.stderr) + print( + "\n The doc reached the interface from something other than this endpoint's\n" + " own block — absorbed during emission, which the source cannot show: every\n" + " block sits correctly above whatever owns it and only the .did disagrees.\n" + " Compare the two texts above and move the block that belongs here.\n" + " ⚠️ Or the .did is simply STALE — run `mops build`. The gate and CI both\n" + " rebuild before this step so it cannot land there, but a direct run on a\n" + " dirty tree reports a real mismatch with a doc nobody misfiled.\n" + " ⚠️ Do NOT silence this by making the doc `//`. Since moc 1.16.0 endpoint\n" + " docs are published deliberately, so that deletes part of the interface.", + file=sys.stderr, + ) + return 1 + + # ⚠️ Counts the files that HOLD endpoints, not the files scanned. `Main.mo` is in the + # scan list and declares none, so reporting the scan size would read as though the + # composition root still had some. + holders = sum(1 for f in files if ENDPOINT.search(open(f).read())) + print( + f" {total} public endpoints across {holders} file(s): every one documented," + f" and all {len(published)} published doc(s) match their own source" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check-frontend-hash.py b/scripts/check-frontend-hash.py new file mode 100755 index 0000000..a5d7490 --- /dev/null +++ b/scripts/check-frontend-hash.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""Compare the state hash the frontend canister reports against a local `dist` build. + +This is the whole frontend check. The hash is a SHA-256 over the canister's +served-content model: every asset by key with its `content_type`, response headers +and per-encoding content hashes, plus the redirect rules in match order. Matching it +means the canister serves exactly the build in `src/frontend/dist`. + +⚠️ **Not the module hash.** The `@dfinity/static-site` recipe installs a pre-built +certified-assets wasm, so that hash describes the recipe — identical for every project +using it, and unrelated to the page anyone is served. The page lives in canister state. + +⚠️ **The verifier is version-locked to the canister, and this script enforces it.** +The hash is bound to how content is prepared — the compressor builds, `MAX_CHUNK_SIZE`, +the digest layout, and the 404/clean-URL rules the preparation synthesizes — all of +which are frozen per release. A `state-hash` from the wrong release computes a +different number from identical files, so the comparison would fail for no reason. So +the tag comes from `icp.yaml`'s recipe pin, never from a human, and the canister's own +`version()` must agree with it before any hash is compared. + +⚠️ **`--locked` is part of the version match, not a precaution.** `cargo install` +re-resolves dependencies without it, and a newer `brotli` patch emits different bytes +for the same input, so the right tag with a fresh lock still computes a different hash. + +What this does NOT prove: that each compressed encoding is an honest compression of +its identity bytes is exactly what IS proved here (the hash covers every encoding), +but only for the compressors this release links. A canister synced by some other +program that injected its own compressors matches no hash this tool computes. + +Usage: scripts/check-frontend-hash.py [-e ENV | -n NETWORK] (default: -e ic) + scripts/check-frontend-hash.py --print [TREE] (no canister; prints the hash) + +`--print` computes the hash of a tree's built `dist` and prints it, for `release.sh` to +publish. It takes a TREE ROOT rather than a `dist` path so the pin and the directory +always come from the same tree: pairing one release's `dist` with another's recipe pin +would publish a number that no verifier can reproduce. +""" +import re +import shutil +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from _recipe_pin import pin_of_tree, self_test as pin_self_test # noqa: E402 + +DIST = Path("src/frontend/dist") +REPO = "https://github.com/dfinity/certified-assets" +# Built verifiers, one directory per release, so the path itself records the version +# and a bumped pin can never silently reuse the previous binary. +CACHE = Path(".cache/state-hash") + +VERSION_REPLY = re.compile( + r"major\s*=\s*(\d+)\s*:\s*nat32;\s*minor\s*=\s*(\d+)\s*:\s*nat32;\s*patch\s*=\s*(\d+)" +) +HEX64 = re.compile(r"^[0-9a-f]{64}$") + + +def icp(method: str, args: str, net: list[str], *extra: str) -> str: + out = subprocess.run( + ["icp", "canister", "call", "frontend", method, args, *net, *extra], + capture_output=True, text=True, + ) + if out.returncode != 0: + sys.exit(f"error: `icp canister call frontend {method}` failed:\n{out.stderr.strip()}") + return out.stdout + + +def canister_version(net: list[str]) -> str: + m = VERSION_REPLY.search(icp("version", "()", net, "--query")) + if not m: + sys.exit("ABORT: could not parse `version()` — cannot confirm the verifier matches") + return ".".join(m.groups()) + + +def served_hash(net: list[str]) -> str: + """`state_hash` as 64 hex chars. `-o hex` prints the Candid-encoded reply, whose + last 32 bytes are the hash, which avoids unescaping a Candid blob by hand.""" + raw = icp("state_hash", "()", net, "-o", "hex").strip() + if len(raw) < 64: + sys.exit(f"ABORT: `state_hash` reply too short to contain a hash: {raw!r}") + return raw[-64:] + + +def verifier(version: str) -> Path: + """`state-hash` built from the pinned tag, cached per version. Built from source on + purpose: a binary someone hands you is one more thing to trust.""" + binary = CACHE / version / "bin" / "state-hash" + if binary.is_file(): + return binary + if not shutil.which("cargo"): + sys.exit("error: this check builds the verifier from source and needs cargo.\n" + " Install Rust (https://rustup.rs), then re-run.") + # ⚠️ **stderr, not stdout.** `--print` writes the hash to stdout and `release.sh` + # captures it, so a progress line on stdout lands in the published hash file. It + # only shows up on a COLD cache, which is a fresh machine and the first release + # after a recipe bump — the runs where a release is least able to absorb it. + print(f" building state-hash v{version} from {REPO} (once, then cached)", + file=sys.stderr) + out = subprocess.run( + ["cargo", "install", "--git", REPO, "--tag", f"v{version}", + "--locked", "state-hash-cli", "--root", str(CACHE / version)], + capture_output=True, text=True, + ) + if out.returncode != 0: + sys.exit(f"error: building state-hash v{version} failed:\n{out.stderr.strip()}") + return binary + + +def local_hash(binary: Path, dist: Path) -> str: + out = subprocess.run([str(binary), str(dist)], capture_output=True, text=True) + if out.returncode != 0: + sys.exit(f"error: `state-hash {dist}` failed:\n{out.stderr.strip()}") + return out.stdout.strip() + + +def _self_test() -> None: + """⚠️ Unconditional. Three parsers stand between this check and a false result, and + each reads text some other tool formats: `icp.yaml` (in `_recipe_pin`, whose own + self-test is called here), and two shapes of `icp` output. A formatting change would + otherwise surface as an unexplained hash mismatch.""" + pin_self_test() + v = VERSION_REPLY.search( + "(record { major = 0 : nat32; minor = 3 : nat32; patch = 3 : nat32 })" + ) + assert ".".join(v.groups()) == "0.3.3", "version reply parser failed" + # `-o hex` prepends the Candid envelope (`DIDL`, type table, a length byte); the + # hash is the tail, so a changed envelope width must not shift what we read. + envelope = "4449444c016d7b010020" + digest = "3241a30f2ec322a0c3b605053c36bd2293966d18628c896fcfd43243d925a864" + assert (envelope + digest)[-64:] == digest, "hex tail extraction failed" + assert HEX64.match(digest) and not HEX64.match(digest.upper()) + + +def print_only(argv: list[str]) -> int: + """The hash of one tree's built `dist`, and nothing else on stdout, so `release.sh` + can capture it. Same code path as the comparison below, so the number a release + publishes and the number a verifier checks cannot come from different logic.""" + if len(argv) > 1: + sys.exit("usage: scripts/check-frontend-hash.py --print [TREE]") + tree = Path(argv[0]) if argv else Path(".") + dist = tree / DIST + if not dist.is_dir(): + sys.exit(f"error: {dist} is missing. Build it first:\n" + f" npm --prefix {tree / 'src/frontend'} ci && " + f"npm --prefix {tree / 'src/frontend'} run build") + h = local_hash(verifier(pin_of_tree(tree)), dist) + if not HEX64.match(h): + sys.exit(f"ABORT: state-hash printed no 64-char hex hash: {h!r}") + print(h) + return 0 + + +def main() -> int: + _self_test() + if sys.argv[1:2] == ["--print"]: + return print_only(sys.argv[2:]) + net = sys.argv[1:] or ["-e", "ic"] + if not DIST.is_dir(): + sys.exit(f"error: {DIST} is missing. Build it first:\n" + f" npm --prefix src/frontend ci && npm --prefix src/frontend run build") + + pinned = pin_of_tree() + deployed = canister_version(net) + if deployed != pinned: + sys.exit(f"ABORT: the canister runs certified-assets v{deployed}, icp.yaml pins " + f"v{pinned}.\n The hash is frozen per release, so comparing across " + f"versions proves nothing.\n Deploy the pinned version, or check out " + f"the commit whose pin matches the deployment.") + print(f" certified-assets v{deployed} (canister) == v{pinned} (icp.yaml pin)") + + served = served_hash(net) + if served == "00" * 32: + sys.exit("ABORT: the canister reports 32 zero bytes, meaning it has no hash: it has " + "either never\n completed a sync, or one is running now. Re-read it once " + "`icp deploy frontend` finishes.") + + local = local_hash(verifier(pinned), DIST) + if not HEX64.match(local): + sys.exit(f"ABORT: state-hash printed no 64-char hex hash: {local!r}") + + print(f" served {served}") + print(f" built {local}") + if served != local: + print("\n\033[31m✗ the canister is not serving this build\033[0m", file=sys.stderr) + print(" Either the build is not the deployed one, or the deploy did not land.\n" + " The hash is one value over every asset, its headers and the redirect rules,\n" + " so it cannot say WHICH of those differs: rebuild from the deployed commit\n" + " and re-run, then `icp deploy frontend` if it is the deployment that is stale.", + file=sys.stderr) + return 1 + print(" ✓ the canister serves exactly this build: every asset, its headers, and the " + "redirect rules") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check-heredocs.sh b/scripts/check-heredocs.sh new file mode 100755 index 0000000..8ef7d3d --- /dev/null +++ b/scripts/check-heredocs.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# Fail on an UNQUOTED heredoc whose body contains a command substitution. +# +# **Two substrates, both live in this repo.** Markdown for a GitHub issue body inside +# `<&2 + exit 2 +fi + +if [ "$found" -ne 0 ]; then + cat >&2 <<'WHY' + +✗ an unquoted heredoc contains a command substitution — the shell will RUN it. + + Quote the tag (<<'TAG') so the body is literal text. If one line genuinely needs a + variable, keep the heredoc quoted and print that line separately — escaping the + backticks works today and re-breaks the next time someone writes prose with one. +WHY + exit 1 +fi +echo "✓ no unquoted heredoc runs its own body" diff --git a/scripts/check-issue-refs.py b/scripts/check-issue-refs.py new file mode 100755 index 0000000..5350813 --- /dev/null +++ b/scripts/check-issue-refs.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +"""Fail if a tracked file outside `docs/agents/` carries a bare `#NN` issue reference. + +An issue number reads as a pointer to a live requirement and is a pointer to a closed +argument. Worse, it *defers* the work: whatever the number stood for was never written +down in terms of the code, so the next reader has to leave the repo to find out what the +line means -- and the issue, once closed, no longer says what is true. + +⚠️ **Why this is a gate step and not a convention.** `AGENTS.md` carried the rule in +prose for one commit; this repo's own position on that arrangement is written into +`check-heredocs.sh` and `test-all.sh` -- *a rule that needs remembering is a rule that +gets skipped*. The sweep that removed 1,117 of these left three behind, and the three +shared one shape (`#NN` followed by a semicolon) that a hand-written scan pattern had +excluded to skip mermaid's `#35;` escape. A check does not have that blind spot twice. + +## What is allowed, and the exceptions may only ever SHRINK + + - **`docs/agents/`** -- the agent loop's own record rather than product. Both files + there exist to point at issues. + - **A repo-qualified reference to an EXTERNAL tracker**: `dfinity/icp-js-core#1384`. + This is the standing exception in the owner's instructions -- *"only include issues + if they refer to some bugfix or feature that we rely on"* -- and the qualified form + is what makes it checkable: it names a tracker a reader can reach, about code this + repo does not own. `src/frontend/src/ic-env.ts` is the live instance: it cites the + upstream issue as the source of a measured header claim and as where the real fix + belongs. + - **Mermaid's `#35;` escape**, inside a mermaid fence only. Outside one, `#NN;` is a + reference -- that is the exact case this check exists for. + - **Two checkers whose SUBJECT is the syntax** -- `scripts/check-mermaid.py` and this + file. Every occurrence in them is a literal inside a message, a doc line or a + self-test vector. The exemption is on the file, never on the number: `#35` is also a + real issue here, so exempting the token globally would open the hole this check + exists to close. ⚠️ **The exemption is itself checked** -- `main()` fails if an + exempt file has stopped containing a reference, so it cannot outlive its reason. + +## Untracked files are scanned, and that is not optional + +This shipped reading `git ls-files` alone, which lists only TRACKED files -- so its first +local run scanned everything except the checker being written, passed, and failed in CI +on its own self-test vectors. A green tick from a scan that never visited the file that +mattered is the exact defect this check exists to catch, so the fix is in the file list +(`--others --exclude-standard`) rather than in a rule a reader has to remember. + +## The digit bound is deliberate + +Only `#1`-`#9999` is flagged, and the match must not be followed by a word character. +That is what separates a reference from a CSS hex colour (`#868078`), an HTML numeric +entity (`↗`), and a Markdown heading anchor (`](#2-what-the-canister-calls)`). +A run of five or more digits is a colour, not an issue -- `_self_test` pins every one of +these shapes, so widening the bound has to be a deliberate edit that fails the test first. +""" + +import re +import subprocess +import sys + +# ⚠️ Checked live in `main()`, exactly like EXEMPT_FILES. `vendor/` was listed here and +# did nothing -- it is a submodule, so `git ls-files` reports one gitlink path and never +# its contents. An exemption that excludes nothing is indistinguishable from one that is +# load-bearing, until someone tests it. +EXEMPT_PREFIXES = ("docs/agents/",) +# Named rather than pattern-matched, for the reason in the docstring. Verified live in +# `main()`: an exemption whose file no longer needs it is a failure, not a leftover. +EXEMPT_FILES = ("scripts/check-mermaid.py", "scripts/check-issue-refs.py") +SKIP_SUFFIXES = ( + ".png", ".jpg", ".woff2", ".svg", ".ico", ".wasm", ".gz", ".most", ".lock", +) +# A bare reference: `#` + 1-4 digits, not part of a longer alphanumeric run. +REF = re.compile(r"#(\d{1,4})(?!\w)") +# Qualified -- `owner/repo#NN` or `repo#NN`. Allowed: an external tracker. +QUALIFIED = re.compile(r"[\w.-]+(?:/[\w.-]+)?#\d{1,4}(?!\w)") +ANCHOR = re.compile(r"\]\(#") +FENCE = re.compile(r"^\s*```\s*(\w*)") +# 5+ digits is a colour or an entity, never an issue here. Kept as an explicit floor +# rather than folded into REF so the reason survives. +MAX_DIGITS = 4 + + +def _read(path): + """The file's text, or None if it is not a readable text file. + + ⚠️ A submodule is listed by `git ls-files` as a single gitlink path (mode 160000) + that is a DIRECTORY on disk, so every caller has to tolerate that -- including the + exemption audit, which is where it first crashed. + """ + try: + return open(path, encoding="utf-8").read() + except (UnicodeDecodeError, FileNotFoundError, IsADirectoryError, PermissionError): + return None + + +def _spans(pattern, line): + return [m.span() for m in pattern.finditer(line)] + + +def refs_in(text: str, mermaid_aware: bool): + """Every bare reference, as (line number, line, token). + + Skips mermaid fences when `mermaid_aware`, because `#35;` is mermaid's escape for a + literal `#`. Nothing else is skipped by content -- a reference inside a comment, a + string or a test name is still a reference. + """ + out = [] + in_mermaid = False + for n, line in enumerate(text.split("\n"), 1): + fence = FENCE.match(line) + if fence: + in_mermaid = fence.group(1) == "mermaid" and not in_mermaid + continue + if in_mermaid: + continue + qualified = _spans(QUALIFIED, line) + anchors = _spans(ANCHOR, line) + for m in REF.finditer(line): + start = m.start() + if any(a <= start < b for a, b in qualified): + continue + if any(b - 2 <= start < b for a, b in anchors): + continue + if start > 0 and line[start - 1] == "&": + continue + out.append((n, line.strip(), m.group(0))) + return out + + +def _self_test() -> None: + flagged = lambda s, md=False: [t for _, _, t in refs_in(s, md)] + # The three the hand sweep missed: a reference followed by a semicolon. + assert flagged("tracked on issue #171; they are deferred") == ["#171"] + assert flagged("The copy left the canister in #123; the FACTS did not") == ["#123"] + # Plain references, in prose and in a test name. + assert flagged('suite("#61 refusal counters", func() {') == ["#61"] + assert flagged("// #30 PR-A: the whole money-out path") == ["#30"] + # NOT flagged: a CSS hex colour, an HTML entity, a Markdown heading anchor. + assert flagged("/* darkened from #868078, measured */") == [] + assert flagged('the reserve account ↗
') == [] + assert flagged("- [2. What it cannot do](#2-what-it-cannot-do)") == [] + # NOT flagged: an external tracker, qualified. The ic-env.ts exception. + assert flagged("The real fix belongs upstream: dfinity/icp-js-core#1384") == [] + assert flagged("tracked on icp-js-core#1384") == [] + # Mermaid's escape, inside a fence only. + assert flagged("```mermaid\nA[#35;paid]\n```", md=True) == [] + assert flagged("A[#35;paid]", md=True) == ["#35"] + # The digit bound, pinned so widening it is deliberate. + assert MAX_DIGITS == 4 and flagged("#12345 is not an issue") == [] + + +def main() -> int: + _self_test() + # ⚠️ **`--others --exclude-standard` is load-bearing, not thoroughness.** A plain + # `git ls-files` lists only TRACKED files, so a brand-new module -- precisely where a + # `#NN` gets introduced -- is invisible until it is staged. This check shipped that + # way for one commit and was silent on itself for exactly that reason. `--others` + # adds untracked files and `--exclude-standard` keeps build output out, so the answer + # no longer depends on whether anyone remembered to `git add` first. + files = subprocess.run( + ["git", "ls-files", "--others", "--exclude-standard", "--cached"], + capture_output=True, text=True, check=True, + ).stdout.split() + scanned = 0 + findings = [] + for path in files: + if path.startswith(EXEMPT_PREFIXES) or path.endswith(SKIP_SUFFIXES): + continue + if path in EXEMPT_FILES: + continue + if "-snapshots" in path: + continue + text = _read(path) + if text is None: + continue + scanned += 1 + for n, line, token in refs_in(text, mermaid_aware=path.endswith(".md")): + findings.append((path, n, line, token)) + + # ⚠️ Vacuity floor. A check that reports a clean scan over nothing is worse than no + # check: this one's whole job is to be green, so "0 findings" has to be backed by + # evidence that it looked. The floor is well under the real count (~250 files). + # ⚠️ An exemption that is no longer needed must be DELETED, not left standing: it is + # a live hole in the check, and the only signal it has stopped being justified is + # that what it covers has stopped containing a reference. So that is asserted, for + # the prefixes as well as the files -- a prefix covering nothing is the `vendor/` + # case, which sat here unnoticed because nothing checked it. + for prefix in EXEMPT_PREFIXES: + covered = [p for p in files if p.startswith(prefix)] + if not covered: + print( + f" check-issue-refs: exempt prefix {prefix} covers no file -- delete" + " the exemption.", + file=sys.stderr, + ) + return 1 + texts = [(p, _read(p)) for p in covered] + if not any( + refs_in(t, p.endswith(".md")) for p, t in texts if t is not None + ): + print( + f" check-issue-refs: exempt prefix {prefix} covers no file carrying a" + " reference -- delete the exemption rather than leaving a hole.", + file=sys.stderr, + ) + return 1 + + for path in EXEMPT_FILES: + text = _read(path) + if text is None: + print( + f" check-issue-refs: exempt file {path} is missing or unreadable --" + " delete the exemption.", + file=sys.stderr, + ) + return 1 + if not refs_in(text, mermaid_aware=path.endswith(".md")): + print( + f" check-issue-refs: {path} is exempt but carries no reference --" + " delete the exemption rather than leaving a hole.", + file=sys.stderr, + ) + return 1 + + if scanned < 100: + print( + f" check-issue-refs: only {scanned} file(s) scanned -- refusing to report" + " a clean scan. Is `git ls-files` working?", + file=sys.stderr, + ) + return 1 + + if findings: + print( + f" {len(findings)} bare issue reference(s) outside docs/agents/:", + file=sys.stderr, + ) + for path, n, line, token in findings: + print(f" {path}:{n} {token} {line[:100]}", file=sys.stderr) + print( + "\n State the constraint in terms of the code as it is now, or drop the" + "\n line. An external tracker stays if it is qualified" + " (`owner/repo#NN`).", + file=sys.stderr, + ) + return 1 + + print(f" {scanned} files scanned: no bare issue reference outside docs/agents/") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check-main-doc-comments.py b/scripts/check-main-doc-comments.py new file mode 100755 index 0000000..5f38380 --- /dev/null +++ b/scripts/check-main-doc-comments.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Fail if `Main.mo` uses `///` anywhere but its leading block. + +⚠️ **A `///` on a declaration candid does not emit cannot land anywhere legitimate — it +is either dropped or LEAKED onto an unrelated endpoint.** That is not a style rule, it is +the whole failure mode: moc assigns doc comments to emitted declarations by position, and +the composition root emits nothing except the service itself (A1 — no public methods +here). So a doc block on a state `let`, a private helper, or a `transient var` has no +destination, and the compiler gives it the nearest one it can find. + +**Four live instances existed before this check.** `webhookSecret`'s doc was published on +`get_order`, the price tiles' on `resolve_problem`, `rateRefreshFailures`' on +`set_recovery_interval`, and `allowedBuyers`' on `withdraw_reserve` — so the interface +told a reader that `get_order` was the Stripe webhook signing key. It reached the +generated TypeScript too, which is what shows on hover in an editor. + +⚠️ **`check-endpoint-docs.py`'s `leaked_docs()` detects the same class from the `.did`, +and this check exists because detecting it is the wrong end.** The leak is POSITIONAL: +measured, inserting a single declaration into `Main.mo` shifts two FURTHER endpoints into +leaking, from private-state docs unrelated to them. So the +detector fires on edits that did not cause it, with "find the block whose text matches" +as the recurring fix. Refusing the input at the source means the diagnostic names the +line responsible and needs no rebuild to fire. `leaked_docs()` stays as the backstop for +whatever this cannot see. + +**The one legitimate `///`: the file's leading block.** moc emits it as the service +documentation — verified in `backend.did`, where it sits directly above `service : {`, +not at the top of the file. In the source it sits above the first `import` rather than +above `persistent actor`, which is why this check is positional rather than looking for +the actor line. ⚠️ That placement is what `published_docs()`'s `service + 1` accounts for: +the emitted doc is permanently the line before the service line. + +⚠️ **This ban is now BELT with little left to brace, and it is kept by choice.** +It exists because a `///` on a private state declaration can alias onto an unrelated +endpoint — four live instances once, fixed in a128982. + +**Measured against a128982^, the commit that still had all four**, building the identical +source under both compilers: + +| endpoint | 1.15.1 published | 1.16.0 published | +|---|---|---| +| `get_order` | the webhook secret's §7 doc | its own §2 authz doc | +| `resolve_problem` | "The price tiles, as one record." | its own §4.1/§7 doc | +| `set_recovery_interval` | `rateRefreshFailures`' backoff doc | its own cadence doc | +| `withdraw_reserve` | `allowedBuyers`' doc | its own doc | + +28 service-block doc lines with all four stolen, versus 600 with none. **So aliasing was a +symptom of the drop** — a neighbour's doc filled an empty slot, and 1.16.0 leaves no empty +slots. Strong evidence rather than proof: four known positions, not all positions. + +⚠️ **An earlier version of this block claimed the question "was not measured".** That came +from probing with the wrong instrument — a marker doc on a private `var`, which leaked +under neither compiler and so proved nothing either way. The reproducer was the four known +positions all along. + +The ban therefore prevents a class that appears fixed, while +`check-endpoint-docs.py`'s `misattributed()` would *catch* it if it recurred. It is kept +because the evidence is strong rather than exhaustive, and its only cost is LSP hover on +742 actor-private blocks — worth revisiting as its own change, not as a rider on a +compiler bump. + +⚠️ **What this does NOT reach**, stated because a check implying more than it verifies is +worse than no check: + + - **Any other file.** Modules and mixins are unaffected: a `///` on a module's `public + func` is the right convention, and since moc 1.16.0 a mixin member's doc is PUBLISHED + on its own endpoint — which is the point of writing it. Only the composition root has + a service to leak ONTO. + - **Whether the comment is any good.** `//` and `///` are equally unpublished here, so + this is purely about where moc may send the text. + - ⚠️ **The cost this does impose, stated rather than waved away.** `///` is what a + Motoko LSP surfaces on hover, and `//` is not — so the 742 converted blocks lose + hover, and only that. Nothing *publishes* them (no `mo-doc` or `mops docs` here, and + `backend.did` never carried the text), and they document actor-private declarations, + so anyone reading them is already in the file. If moc stops aliasing docs onto + unrelated endpoints, converting back is one `sed` — which is how this direction was + proven in the first place. +""" + +import re +import sys + +MAIN = "src/backend/Main.mo" + + +def offenders(text): + """Line numbers (1-based) of `///` lines outside the file's leading block.""" + lines = text.split("\n") + # The leading block: the run of `///` lines at the very top of the file. + lead = 0 + while lead < len(lines) and lines[lead].startswith("///"): + lead += 1 + return [i + 1 for i in range(lead, len(lines)) if lines[i].lstrip().startswith("///")] + + +def self_test(): + """⚠️ Unconditional, like the other parsers here. A scan that silently stops matching + passes while checking nothing.""" + sample = "\n".join([ + "/// The service doc. Emitted by candid.", + "/// Second line of it.", + 'import Array "mo:core/Array";', + "persistent actor {", + " /// Leaks onto whichever endpoint moc aliases it to.", + " let webhookSecret = Secret.emptyStore();", + " // Fine: not a doc comment.", + " let admins = Set.empty();", + "};", + ]) + got = offenders(sample) + if got != [5]: + sys.exit(f"ABORT: self-test expected [5], got {got}") + # A file with no leading block must not treat its first `///` as the exempt one. + if offenders("persistent actor {\n /// nope\n};") != [2]: + sys.exit("ABORT: self-test — a `///` with no leading block should be reported") + + +def main(): + self_test() + try: + text = open(MAIN).read() + except OSError as e: + sys.exit(f"ABORT: cannot read {MAIN} ({e}) — cannot pass vacuously") + if "///" not in text: + sys.exit( + f"ABORT: {MAIN} has no `///` at all, not even its service doc block — that " + "block is emitted into backend.did and should not have been removed" + ) + + bad = offenders(text) + if bad: + lines = text.split("\n") + print( + f"\n\033[31m✗ {MAIN} uses `///` outside its leading block " + f"({len(bad)} line(s))\033[0m", + file=sys.stderr, + ) + for n in bad[:10]: + print(f" {MAIN}:{n}: {lines[n - 1].strip()[:76]}", file=sys.stderr) + if len(bad) > 10: + print(f" … and {len(bad) - 10} more", file=sys.stderr) + print( + "\n Use `//`. Candid emits nothing for a declaration in the composition\n" + " root, so a `///` here has no destination and moc gives it the nearest\n" + " one it can find — an unrelated endpoint in the service block, which the\n" + " interface then documents with the wrong contract. `//` says the same\n" + " thing to a reader and cannot leak.\n" + "\n The leading block is the exception: moc emits it as the service doc.", + file=sys.stderr, + ) + return 1 + + print(f" {MAIN}: `///` only where candid emits it — the service doc block") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check-markers.py b/scripts/check-markers.py new file mode 100755 index 0000000..65adfcf --- /dev/null +++ b/scripts/check-markers.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""Keep the ⚠️ marker rare: no marker in a test name, and the population may only fall. + +The marker means **stop: doing the obvious thing here breaks something you will not +notice**. That only works while it is rare, and the two failure modes are not +symmetrical -- an unread warning on the money path costs more than a missing one, because +its presence is what a reviewer trusts. At 2,164 across 139 files it marked nothing. + +Whether a given marker earns its glyph is a judgement no regex makes: *can the next +editor act on this the wrong way?* **"Do not add a force flag to `refresh_reserve`"** +earns it, **"this count going LOW is the oversell direction"** does not. `AGENTS.md` +carries that rule. This file owns only the two things a check can own. + +## 1. Never in a test, suite, describe or it NAME + +Fully decidable, and 183 instances existed. The name already states the property the test +defends; the glyph adds nothing to it and prints on every run, in every runner's output, +where it is decoration rather than a warning to an editor. + +## 2. The population may only FALL + +`MARKER_CEILING` is the tree-wide total and the check fails on any disagreement. Adding a +genuine new trap is a one-line edit here, with the reason in the commit -- which is the +point: the count cannot move quietly in either direction, and every cleanup pass ratchets +it down visibly in the diff. `PUBLISHED_CEILING` does the same for the markers that reach +API callers through the generated `.did` (see below). Same mechanism as +`check-issue-refs.py`'s exemption audit: make the thing shrink-only rather than trusting +a convention. + +⚠️ **A per-block cap was tried here and REMOVED, because it claimed a guarantee it did +not deliver.** It counted markers in contiguous comment-line runs, and measured against +this tree: + + - a blank line between them defeated it, changing nothing a reader experiences; + - trailing comments on code lines were never counted at all; + - it saw 97% of markers in code, 60% in shell and Python, and **4% in Markdown** -- + so `RUNBOOK.md`, the file where a false warning costs an operator the most, sat + almost entirely outside it. + +A ceiling has none of those properties: it counts every shape, in every file type, and +cannot be satisfied by reformatting. + +## What about the markers PUBLISHED into the .did? + +Endpoint `///` docs are copied into `src/backend/dist/backend.did` and from there into the +generated TypeScript, so a marker there reaches whoever calls the API. Whether that is +right depends on who the warning is addressed to: *"uncertified query answers, and +nothing may be wired to decide on them"* belongs in the published doc, and *"declared +inside the mixin body, not above it"* is addressed to the next editor of this repo and +delivered to someone who cannot act on it. No regex separates those, so the rule here is +only the ratchet -- the count may fall, never rise. +""" + +import re +import subprocess +import sys + +MARKER = "⚠" +DID = "src/backend/dist/backend.did" +GENERATED = ( + "src/backend/dist/", + "test/integration/src/generated/", + "src/frontend/src/bindings/", +) +SKIP_SUFFIXES = (".png", ".jpg", ".woff2", ".svg", ".ico", ".wasm", ".gz", ".most", ".lock") +# ⚠️ **Exempt from the NAME rule only, because the shapes it forbids are its own test +# vectors.** Its markers still count toward the ceiling, so the number this prints is the +# whole tree's and matches what any other tool counting the tree will get. Audited in +# `main()`: the exemption fails if this file stops carrying a vector. +NAME_EXEMPT_FILES = ("scripts/check-markers.py",) + +# ⚠️ **These may only ever FALL.** Raising one is a deliberate edit whose reason belongs +# in the commit message; if you are lowering one, you are doing the intended thing. +MARKER_CEILING = 1810 +PUBLISHED_CEILING = 40 + +# A marker inside the STRING argument of a test declaration. Deliberately not anchored to +# the line start: `test.skip(`, `it.each(` and an indented call all have to match. +IN_NAME = re.compile(r"\b(?:test|suite|describe|it)\b[\w.]*\s*\(\s*[\"'`][^\"'`]*" + MARKER) + + +def names_in(text: str): + return [ + (n, line.strip()) + for n, line in enumerate(text.split("\n"), 1) + if IN_NAME.search(line) + ] + + +def _self_test() -> None: + # A marker in a test name, however the call is spelled. + assert names_in('test("⚠️ the pay note cannot outlive the pay button", async () => {') + assert names_in(' suite("⚠️ divisor 1 is byte-identical", func() {') + assert names_in("describe('⚠️ real vs crafted', () => {") + assert names_in('test.skip("⚠️ pending", () => {})') + # ...and NOT a marker in the body, which is where it belongs. + assert not names_in('test("the pay note", () => {\n // ⚠️ By id, not by position\n') + assert not names_in("// ⚠️ **A test name is not the place**, but this line is fine") + # The ceilings are counts, not thresholds with slack: an exact-match check is what + # makes the ratchet visible in a diff. + assert isinstance(MARKER_CEILING, int) and isinstance(PUBLISHED_CEILING, int) + + +def main() -> int: + _self_test() + files = subprocess.run( + ["git", "ls-files", "--others", "--exclude-standard", "--cached"], + capture_output=True, text=True, check=True, + ).stdout.split() + + scanned = 0 + total = 0 + in_names = [] + for path in files: + if path.startswith(GENERATED) or path.endswith(SKIP_SUFFIXES): + continue + try: + text = open(path, encoding="utf-8").read() + except (UnicodeDecodeError, FileNotFoundError, IsADirectoryError, PermissionError): + continue + scanned += 1 + total += text.count(MARKER) + if path in NAME_EXEMPT_FILES: + continue + for n, line in names_in(text): + in_names.append((path, n, line)) + + try: + published = open(DID, encoding="utf-8").read().count(MARKER) + except FileNotFoundError: + print(f" check-markers: {DID} is missing -- run `mops build`.", file=sys.stderr) + return 1 + + # ⚠️ An exemption must die with its reason. The only signal this one has is that the + # exempt file no longer carries the shape it was exempted for. + for path in NAME_EXEMPT_FILES: + try: + text = open(path, encoding="utf-8").read() + except (FileNotFoundError, IsADirectoryError, PermissionError): + print( + f" check-markers: exempt file {path} is missing -- delete the" + " exemption.", + file=sys.stderr, + ) + return 1 + if not names_in(text): + print( + f" check-markers: {path} is exempt from the name rule but carries no" + " test-name vector -- delete the exemption rather than leaving a hole.", + file=sys.stderr, + ) + return 1 + + # ⚠️ Vacuity floor. This check's steady state is green, so "no findings" has to be + # backed by evidence that it looked. + if scanned < 100 or total == 0: + print( + f" check-markers: {scanned} file(s) and {total} marker(s) -- refusing to" + " report a clean scan over nothing.", + file=sys.stderr, + ) + return 1 + + failed = False + if in_names: + failed = True + print(f" {len(in_names)} marker(s) inside a test name:", file=sys.stderr) + for path, n, line in in_names[:20]: + print(f" {path}:{n} {line[:96]}", file=sys.stderr) + if len(in_names) > 20: + print(f" ... and {len(in_names) - 20} more", file=sys.stderr) + print( + " The name states the property; drop the glyph from it. Keep it in the" + " body if there is a trap there.", + file=sys.stderr, + ) + for label, got, ceiling, const in ( + ("markers in the tree", total, MARKER_CEILING, "MARKER_CEILING"), + (f"markers published in {DID}", published, PUBLISHED_CEILING, "PUBLISHED_CEILING"), + ): + if got > ceiling: + failed = True + print( + f"\n {got} {label}, ceiling {ceiling}. A marker was ADDED: it has to be" + f"\n a trap the next editor can act on wrongly, and then {const} moves" + f"\n to {got} in this commit with the reason in the message.", + file=sys.stderr, + ) + elif got < ceiling: + failed = True + print( + f"\n {got} {label}, ceiling {ceiling} — the population FELL, which is" + f"\n the intended direction. Ratchet it: set {const} = {got}.", + file=sys.stderr, + ) + if failed: + return 1 + + print( + f" {total} markers across {scanned} files, {published} of them published in the" + " .did: at the ceiling, none in a test name" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check-mermaid.py b/scripts/check-mermaid.py new file mode 100755 index 0000000..9443618 --- /dev/null +++ b/scripts/check-mermaid.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Refuse the two mermaid label bugs that fail SILENTLY on GitHub. + +⚠️ **Parsing is not the check.** Both bugs below parsed clean and rendered wrong, which is +why this is a lint over the source rather than a call to mermaid's parser. Found by +rendering the diagrams in Chromium and diffing the visible text against the source: + + `markPaid → #paid, paidIntents[intent] = orderId` rendered as `markPaid →` + +1. **A bare `#` opens an HTML entity code and swallows the rest of the label.** Motoko + variant tags are written `#paid`, so every diagram describing this system walks into + it. Write `#35;paid`, which renders as `#paid`. +2. **A literal `;` ends the statement**, so the text after it becomes a syntax error or + vanishes. Use a dash. + +⚠️ **CSS colours are not this bug.** `classDef ours fill:#1b4965` is a colour, not a +label, so `classDef`/`style` lines are exempt — flagging them would make the check +unusable on any styled diagram. + +⚠️ **What this does NOT cover.** A malformed diagram that fails to PARSE is loud — GitHub +prints "Unable to render rich display" where the picture should be — so it does not need a +gate step. This covers the class that renders as a confident, quietly incomplete picture. +""" + +import re +import sys +from pathlib import Path + +FILES = sorted(set(list(Path(".").glob("*.md")) + list(Path("docs").glob("*.md")))) +BLOCK = re.compile(r"```mermaid\n(.*?)```", re.S) +EXEMPT = re.compile(r"^\s*(classDef|style|linkStyle|%%)") +BARE_HASH = re.compile(r"#(?![0-9]+;)[A-Za-z]") + + +def hazards(block: str) -> list[tuple[int, str, str]]: + out = [] + for i, line in enumerate(block.split("\n"), 1): + if EXEMPT.match(line): + continue + if BARE_HASH.search(line): + out.append((i, line.strip(), "a bare `#` swallows the rest of the label — write `#35;`")) + # `;` that is not the terminator of an `#NN;` entity + if re.search(r"(? None: + """Unconditional: this is a regex over other people's prose, and a silently + non-matching pattern turns the whole check into a green tick.""" + assert hazards(" BE->>BE: markPaid → #paid") + assert hazards(" Note over BE: stays #35;paid; the sweep replays") + assert not hazards(" BE->>BE: markPaid → #35;paid, paidIntents[intent] = orderId") + assert not hazards(" classDef ours fill:#1b4965,stroke:#62b6cb,color:#fff") + assert not hazards(" Created --> Paid: webhook, verified") + # an entity followed by a slash parses badly, but that failure is LOUD; not our job + assert not hazards(" Buyer->>FE: lands on the order page (success_url)") + + +def main() -> int: + _self_test() + blocks = 0 + bad: list[str] = [] + for f in FILES: + src = f.read_text() + for m in BLOCK.finditer(src): + blocks += 1 + base = src[: m.start(1)].count("\n") + 1 + for line_no, text, why in hazards(m.group(1)): + bad.append(f"{f}:{base + line_no - 1}: {why}\n {text[:100]}") + if blocks == 0: + sys.exit("ABORT: found no mermaid blocks — this check cannot pass vacuously") + if bad: + print("\n\033[31m✗ a mermaid label will render wrong on GitHub\033[0m", file=sys.stderr) + for b in bad: + print(f" {b}", file=sys.stderr) + return 1 + print(f" {blocks} mermaid diagram(s): no label loses text to an entity code") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check-stable-promotion.sh b/scripts/check-stable-promotion.sh new file mode 100755 index 0000000..aacc10e --- /dev/null +++ b/scripts/check-stable-promotion.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# Decide whether a `deployed/backend.most` promotion is REPRESENTATION-ONLY or a real +# shape change — before it is committed. +# +# **Why this exists.** A promotion resets the reference point the upgrade check +# compares against, so it is the one operation that can blind that check. One hole is a +# baseline left stale; the mirror image is a baseline promoted past a real change because +# the diff looked like noise. Both are invisible in a `git diff`. +# +# A compiler upgrade renumbers every type hash: moc 1.9.0 → 1.15.1 moved 91 lines of +# `deployed/backend.most` without changing one field. A real schema change lands in the +# same file, in the same shape of diff. Eyeballing cannot separate them; this can. +# +# The test is MUTUAL stable-compatibility. `moc --stable-compatible A B` asks "can a +# canister whose signature is A upgrade to B". Run both ways: +# +# ⚠️ **The load-bearing premise, written down because the whole classification rests on +# it: dropping a stable field is NOT backward-compatible.** If it were, adding a field +# would pass BOTH directions and be reported as "REPRESENTATION-ONLY, safe to promote +# without review" — precisely the misclassification this script exists to prevent. +# Measured on moc 1.15.1 with two hand-written signatures (`actor { stable a : Nat }` vs +# the same plus `stable b : Nat`): adding exits 0, dropping exits 1 with +# +# Compatibility error [M0169], stable variable `b` of the previous version cannot be +# implicitly discarded. The variable can only be dropped by an explicit migration +# function. +# +# `assert_asymmetry` below re-establishes that on every run, so a future moc that relaxed +# M0169 fails loudly here instead of silently downgrading a real field addition to +# "no review needed". A premise this script cannot survive losing is a premise it should +# not take on trust. +# +# both pass → the signatures are mutual subtypes, i.e. EQUIVALENT. The diff is pure +# renumbering and the promotion carries no shape change. +# only 1 → 2 → a real, upgrade-compatible change (a new stable field, a widened type). +# Promote deliberately, and say in the commit what moved. +# 1 → 2 fails → the change is NOT upgrade-compatible. Promoting it strands every +# deployed canister; that needs a migration chain, not a promotion. +# +# ⚠️ **`--accept-reinstall` is the one deliberate override**, and it exists because +# "not upgrade-compatible" is a legitimate answer pre-launch: with no migration chain +# and no data worth preserving, a reinstall is the documented loop and the baseline +# should then describe the NEW shape. The flag does not weaken the check — it still +# refuses silently-wrong promotions — it makes the operator say the words, and it prints +# **which stable variables are dropped**, because "what state is lost" is the reviewable +# fact in that decision. Never pass it once real data exists. +# +# Usage: scripts/check-stable-promotion.sh [canister] [--accept-reinstall] [--no-build] +set -euo pipefail + +accept_reinstall=0 +do_build=1 +args=() +for arg in "$@"; do + case "$arg" in + --accept-reinstall) accept_reinstall=1 ;; + --no-build) do_build=0 ;; + *) args+=("$arg") ;; + esac +done +set -- "${args[@]+"${args[@]}"}" + +canister="${1:-backend}" +baseline="deployed/${canister}.most" +built="src/backend/dist/${canister}.most" + +[ -f "$baseline" ] || { echo "no committed baseline at $baseline — nothing to compare" >&2; exit 1; } + +# ⚠️ **Builds first, because a STALE build reads as "nothing to promote".** `mops check` +# does not write the `.most`, so after a shape change the file on disk still describes the +# previous shape — and this script would compare the baseline against a copy of itself and +# report a clean all-clear. Measured: it did exactly that, on the change this flag was +# added for. Slow is the right trade at promotion time; `--no-build` skips it when the +# build is known current. +if [ "$do_build" = "1" ]; then + mops build >/dev/null 2>&1 || { echo "mops build failed — fix that before promoting" >&2; exit 1; } +fi +[ -f "$built" ] || { echo "no build output at $built — run \`mops build\` first" >&2; exit 1; } + +moc="$(mops toolchain bin moc)" + +# ⚠️ Unconditional, like the parser self-tests in the Python checkers. See the premise +# note above: without the asymmetry, "both directions pass" stops meaning "equivalent". +assert_asymmetry() { + local dir; dir="$(mktemp -d)" + trap 'rm -rf "$dir"' RETURN + printf 'actor {\n stable a : Nat;\n};\n' > "$dir/without.most" + printf 'actor {\n stable a : Nat;\n stable b : Nat;\n};\n' > "$dir/with.most" + + if ! "$moc" --stable-compatible "$dir/without.most" "$dir/with.most" >/dev/null 2>&1; then + echo "ABORT: this moc refuses a stable-field ADDITION, which the premise says is compatible." >&2 + echo " Every verdict below is unreliable until that is understood." >&2 + exit 1 + fi + if "$moc" --stable-compatible "$dir/with.most" "$dir/without.most" >/dev/null 2>&1; then + echo "ABORT: this moc accepts DROPPING a stable field (M0169 relaxed?)." >&2 + echo " The equivalence test this script is built on no longer holds: an added field" >&2 + echo " would now pass both directions and be reported as representation-only." >&2 + exit 1 + fi +} +assert_asymmetry + +if diff -q "$baseline" "$built" >/dev/null 2>&1; then + printf ' %s: baseline already matches the build — nothing to promote\n' "$canister" + exit 0 +fi + +changed=$(diff "$baseline" "$built" | grep -c '^[<>]' || true) + +forward=0; "$moc" --stable-compatible "$baseline" "$built" >/dev/null 2>&1 || forward=$? +reverse=0; "$moc" --stable-compatible "$built" "$baseline" >/dev/null 2>&1 || reverse=$? + +if [ "$forward" -ne 0 ]; then + diagnostic="$("$moc" --stable-compatible "$baseline" "$built" 2>&1 || true)" + # The reviewable fact: which stable variables the new shape no longer carries. + dropped="$(printf '%s\n' "$diagnostic" \ + | sed -n 's/.*stable variable `\([A-Za-z0-9_]*\)`.*cannot be implicitly discarded.*/\1/p' \ + | sort -u)" + + if [ "$accept_reinstall" = "1" ]; then + printf '\033[33m⚠️ %s: NOT upgrade-compatible — accepted as a REINSTALL\033[0m\n' "$canister" + if [ -n "$dropped" ]; then + printf ' stable state dropped by this shape change:\n' + printf '%s\n' "$dropped" | sed 's/^/ - /' + else + printf ' (no dropped variables named; the change is a type change, not a removal)\n' + printf '%s\n' "$diagnostic" | sed 's/^/ /' + fi + printf ' A deployed canister CANNOT take this as an upgrade. Reinstall, then reseed.\n' + exit 0 + fi + + printf '\033[31m✗ %s: the built signature is NOT upgrade-compatible with the baseline\033[0m\n' "$canister" >&2 + printf '%s\n' "$diagnostic" | sed 's/^/ /' >&2 + if [ -n "$dropped" ]; then + printf '\n Stable state this would drop:\n' >&2 + printf '%s\n' "$dropped" | sed 's/^/ - /' >&2 + fi + printf '\n A deployed canister cannot take this upgrade. Either write the migration\n' >&2 + printf ' , or — pre-launch only, with no\n' >&2 + printf ' data worth keeping — re-run with `--accept-reinstall` to say so deliberately.\n' >&2 + exit 1 +fi + +if [ "$reverse" -eq 0 ]; then + printf ' %s: %s changed line(s), REPRESENTATION-ONLY\n' "$canister" "$changed" + printf ' both directions of --stable-compatible pass, so the signatures are equivalent:\n' + printf ' no field was added, removed or retyped. Safe to promote without review.\n' +else + printf '\033[33m⚠️ %s: %s changed line(s), a REAL shape change (upgrade-compatible)\033[0m\n' "$canister" "$changed" + printf ' Forward compatibility holds, so a deployed canister takes the upgrade — but the\n' + printf ' signature genuinely moved. Name what moved in the commit message, and check it\n' + printf ' is the change you meant:\n\n' + diff "$baseline" "$built" | sed 's/^/ /' +fi diff --git a/scripts/check-typed-errors.py b/scripts/check-typed-errors.py new file mode 100755 index 0000000..8be079e --- /dev/null +++ b/scripts/check-typed-errors.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Fail if a public endpoint returns `Result<_, Text>`. + +An untyped error forces every caller to match on prose to tell one failure from another +(`reviewing-motoko` T4). No endpoint returns one today, and this is what keeps it that +way. + +⚠️ **Enforced rather than achieved, because achieved uniformity decays.** Three surfaces +in this repo reached "complete" and then regressed while nothing was watching — the admin +command table, the doc-surface list, and the gate/CI script pair. A rule with no check is +a rule the next endpoint walks past. + +Reads the generated `backend.did`, not the source: that is where a caller sees the error, +and it is the artifact the frontend's bindings come from. + +⚠️ **What this does NOT reach:** + + - **A variant with a `Text` payload.** `#stripeFailed : { detail : Text }` is fine and + stays fine — Stripe's own wording is a string an external system owns, which is the + case T1 leaves alone. Only `Result`'s whole error position is checked. + - **`Text` in an OK position.** A method returning text is not an error contract. + - **Whether a variant's arms are well chosen.** It sees that the error is typed, never + that the types are right. +""" + +import re +import sys + +DID = "src/backend/dist/backend.did" + +# `type Result_N = variant { err: ; ok: ... }` — the shape bindgen emits. +RESULT = re.compile( + r"^type\s+(Result[A-Za-z0-9_]*)\s*=\s*\n\s*variant\s*\{(.*?)\n\s*\};", + re.M | re.S, +) + + +def untyped(did_text): + """Result type names whose `err` position is bare `text`.""" + out = [] + for m in RESULT.finditer(did_text): + body = m.group(2) + if re.search(r"\berr\s*:\s*text\s*;", body): + out.append(m.group(1)) + return out + + +def self_test(): + """⚠️ Unconditional: if the pattern stops matching, everything reads as typed.""" + sample = "\n".join([ + "type Result_1 = ", + " variant {", + " err: text;", + " ok: Order;", + " };", + "type Result_2 = ", + " variant {", + " err: CancelOrderError;", + " ok: Order;", + " };", + "type Result_3 = ", + " variant {", + " err: record { detail: text };", + " ok;", + " };", + "service : {", + " cancel_order: (OrderId) -> (Result_2);", + " }", + ]) + got = untyped(sample) + if got != ["Result_1"]: + sys.exit(f"ABORT: self-test expected ['Result_1'], got {got}") + if not RESULT.search(sample): + sys.exit("ABORT: self-test — the Result pattern matched nothing at all") + + +def main(): + self_test() + try: + text = open(DID).read() + except OSError as e: + sys.exit(f"ABORT: cannot read {DID} ({e}) — cannot pass vacuously") + + results = RESULT.findall(text) + if not results: + sys.exit(f"ABORT: found no Result types in {DID} — cannot pass vacuously") + + bad = untyped(text) + if bad: + print("\n\033[31m✗ endpoint error type(s) are bare `text`\033[0m", file=sys.stderr) + for name in bad: + users = re.findall(rf"^\s+([a-z_0-9]+):.*\b{name}\b", text, re.M) + print(f" {name} — used by {', '.join(users) or '(no endpoint)'}", file=sys.stderr) + print( + "\n Make the error a variant with the data each case needs (T4). A caller\n" + " cannot tell one failure from another by matching on prose, and the copy a\n" + " buyer or operator reads belongs where it can change without a canister\n" + " upgrade — see docs/DESIGN.md §7.2 for what may move and what may not.", + file=sys.stderr, + ) + return 1 + + print(f" {len(results)} Result types in the interface: every error position is typed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check-unused-exports.py b/scripts/check-unused-exports.py new file mode 100755 index 0000000..3d9ff65 --- /dev/null +++ b/scripts/check-unused-exports.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +"""Fail if a frontend export's only referents are test files. + +⚠️ **Why this is a gate step.** `paymentLinkWithRef` survived past the PR that scheduled +its own deletion — its comment said *"Kept only until PR-C deletes the Payment Link +mechanism wholesale"*, PR-C shipped, and the function stayed. It was invisible for a +specific reason: **two vitest cases referenced it**, so it was neither unused (the test +imports it) nor covered (nothing ships it). The suite counted it as coverage. + +`noUnusedLocals` cannot see this — the symbol *is* used, from a test. Only the +production-vs-test distinction makes it visible, which is a fixed syntactic target and +therefore gate-able. + +⚠️ **What this does NOT reach**, because a check implying more than it verifies is worse +than no check: + + - **Motoko.** A `public func` in a module has no such detector: `-Werror` does not flag + an unused export, and the equivalent sweep would need a call graph. `Reserve.recount` + is the deliberate case — a test oracle with no production caller, documented as such — + so a Motoko version of this check would need an opt-out marker before it could exist. + - **Transitively dead code.** An export used only by another export that is itself dead + still passes. This finds leaves, not subtrees. + - **Anything outside `src/frontend/src`.** + - **Exports marked `@test-oracle`**, which are skipped. A symbol can legitimately have + no production caller: `CREATE_ORDER_ERROR_KEYS` exists so the suite iterates a list + derived from the backend's type rather than a hand-written copy of it, and deleting + it to satisfy this check would delete the guarantee. The marker has to sit in the + export's own doc comment, so the reason is next to the symbol rather than in a list + here, and the count is printed so a growing set of them is visible. + +⚠️ **Comments are stripped before the reference scan, and that is load-bearing.** The +scan is a word match over the file's text, so for a while ANY comment naming a symbol +shielded it — including the symbol's own doc block, and including a comment that says the +symbol is dead. `feeBreakdown` passed this check on two hits in its home file: the +definition, plus a sentence in a neighbour's doc saying `feeBreakdown` was built from it. +A check on dead code, blinded by prose about dead code. Stripping is done with a real +scanner rather than a regex because `"https://x"` contains `//`, and cutting there would +delete live code and report a used symbol as dead. +""" + +import glob +import os +import re +import sys + +SRC = "src/frontend/src" +# Entry points and generated code: reached by the bundler or the browser, not by an import +# this checker can see. +EXEMPT_FILES = {"main.ts", "fixtures.ts"} +EXEMPT_DIRS = ("bindings",) + + +def strip_comments(src: str) -> str: + """TS source with `//` and `/* */` comments blanked, string bodies left alone. + + Newlines are preserved so a stripped file still lines up with the original. + """ + out = [] + i, n = 0, len(src) + quote = None # the delimiter we are inside, or None + while i < n: + c = src[i] + if quote is not None: + if c == "\\" and i + 1 < n: + out.append(" ") + i += 2 + continue + out.append(c) + if c == quote: + quote = None + i += 1 + continue + if c in "\"'`": + quote = c + out.append(c) + i += 1 + continue + if c == "/" and i + 1 < n and src[i + 1] == "/": + while i < n and src[i] != "\n": + i += 1 + continue + if c == "/" and i + 1 < n and src[i + 1] == "*": + i += 2 + while i + 1 < n and not (src[i] == "*" and src[i + 1] == "/"): + if src[i] == "\n": + out.append("\n") + i += 1 + i += 2 + continue + out.append(c) + i += 1 + return "".join(out) + + +def self_test() -> None: + """⚠️ Unconditional, like `check-admin-commands.py`'s parser test. The stripper is + the instrument: if it silently stops stripping, every symbol is shielded again and + this step passes while checking nothing.""" + cases = [ + ("const a = 1; // feeBreakdown", "feeBreakdown", False), + ("/// feeBreakdown is built from this\nconst a = 1;", "feeBreakdown", False), + ("/* feeBreakdown */ const a = 1;", "feeBreakdown", False), + ('const u = "https://x/y"; const b = feeBreakdown();', "feeBreakdown", True), + ('const s = "// feeBreakdown";', "feeBreakdown", True), + ("const t = `a ${feeBreakdown()} b`;", "feeBreakdown", True), + ] + for src, name, expected in cases: + got = name in strip_comments(src) + if got != expected: + sys.exit( + f"ABORT: strip_comments self-test failed on {src!r}: " + f"expected {name!r} {'kept' if expected else 'stripped'}" + ) + # And the stripper must not eat the line structure it is scanned by. + if strip_comments('const a = 1; // x\nconst b = 2;').count("\n") != 1: + sys.exit("ABORT: strip_comments dropped a newline") + + +def is_test(path: str) -> bool: + return ".test." in os.path.basename(path) or path.endswith(".spec.ts") + + +def main() -> int: + self_test() + prod, tests = [], [] + for p in glob.glob(f"{SRC}/**/*.ts", recursive=True): + if any(f"/{d}/" in p for d in EXEMPT_DIRS): + continue + (tests if is_test(p) else prod).append(p) + if not prod: + sys.exit(f"ABORT: found no production sources under {SRC} — cannot pass vacuously") + + # Collect exported names, and where each is referenced. + exports = {} + oracles = set() + for p in prod: + if os.path.basename(p) in EXEMPT_FILES: + continue + text = open(p).read() + for m in re.finditer( + r"^export\s+(?:async\s+)?(?:function|const|let|class)\s+([A-Za-z_$][\w$]*)", + text, + re.M, + ): + exports[m.group(1)] = p + # The marker is read from the doc comment immediately above the export, so + # it cannot be claimed from somewhere else in the file. + doc = text[:m.start()].rsplit("\n\n", 1)[-1] + if "@test-oracle" in doc: + oracles.add(m.group(1)) + + if not exports: + sys.exit(f"ABORT: parsed no exports out of {SRC} — cannot pass vacuously") + + # ⚠️ Comments stripped: see the module docstring. A production REFERENCE is a call + # or an import, never a mention. + prod_text = {p: strip_comments(open(p).read()) for p in prod} + test_text = {p: open(p).read() for p in tests} + # Browser specs drive the built page rather than importing modules, but a name can + # still legitimately appear there. + for p in glob.glob("test/browser/**/*.ts", recursive=True): + test_text[p] = open(p).read() + + dead = [] + for name, home in sorted(exports.items()): + if name in oracles: + continue + word = re.compile(rf"\b{re.escape(name)}\b") + in_prod = any( + word.search(t) for p, t in prod_text.items() if p != home + ) or len(word.findall(prod_text[home])) > 1 + if in_prod: + continue + where = [p for p, t in test_text.items() if word.search(t)] + dead.append((name, home, where)) + + if dead: + print("\n\033[31m✗ frontend export(s) referenced only by tests\033[0m", file=sys.stderr) + for name, home, where in dead: + tail = f" — referenced only in {', '.join(sorted(where))}" if where else " — referenced nowhere" + print(f" {name} ({home}){tail}", file=sys.stderr) + print( + "\n Delete the export AND its tests, or wire it into the app. A symbol whose only\n" + " caller is its own test is neither used nor covered, and the suite counts it as\n" + " coverage — which is how paymentLinkWithRef survived the PR that scheduled its\n" + " own deletion.", + file=sys.stderr, + ) + return 1 + oracle_note = f", {len(oracles)} marked @test-oracle" if oracles else "" + print(f" {len(exports)} frontend exports: none referenced only by tests{oracle_note}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check-unused-motoko.py b/scripts/check-unused-motoko.py new file mode 100755 index 0000000..349d1dd --- /dev/null +++ b/scripts/check-unused-motoko.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +"""Fail if a module's `public func` has no caller outside its own tests. + +The Motoko half of `check-unused-exports.py`, which gates this class for the frontend +only. ⚠️ **The class, with a measured instance:** `Gate.configErrorToText`'s only caller +was `test/gate.test.mo` — *a `*ToText` with no production caller is dead code that reads +as a supported path* — and nothing flagged it. + +⚠️ **The compiler contributes nothing here, which is why this has to exist.** moc's +unused-identifier warning (M0194) fires only in the canister's MAIN file — never in +modules or mixins. So an entire module of dead exports compiles clean under `-Werror`. + +⚠️ **Comments are stripped before the reference scan, and that is load-bearing.** The +frontend checker learned it the hard way: it decided `in_prod` over raw text, so any +comment naming a symbol shielded it — and this codebase comments heavily. A symbol whose +only "caller" is prose about it is dead. The stripper here is Motoko's, not TypeScript's: +Motoko has no template literals, and its block comments NEST (`/* /* */ */` is one +comment), which a non-counting stripper closes too early and then eats live code. + +⚠️ **What this does NOT reach**, stated because a check implying more than it verifies is +worse than no check: + + - **`public type`, `public let`, `public class`.** Functions only. A dead type is + harmless in a way a dead function is not: it cannot be mistaken for a supported + operation, and types are legitimately referenced only in signatures. + - **Mixins and the composition root.** A mixin's `public shared` functions are + endpoints, reached by the IC and gated by `check-endpoint-docs.py` instead. + - **A function reachable only through dead code.** This is a reference scan, not a + call graph: `a` calling `b` keeps `b` alive even when nothing calls `a`. Deleting a + dead function can therefore reveal the next one; run this again after deleting. + - **Whether a used function is used WELL.** One caller in one branch counts. + +An export that legitimately has no production caller — a test oracle derived from a type +— gets an in-source `@test-oracle` marker in the doc block directly above it, and the +count is printed. A list of exemptions inside this script is where an exemption goes to +be forgotten. +""" + +import glob +import os +import re +import sys + +# Modules only: `src/backend/*.mo` and the rails. Mixins hold endpoints, and `Main.mo` +# is the composition root — both are covered by other steps. +MODULE_GLOBS = ("src/backend/*.mo", "src/backend/rails/*.mo") +CALLER_GLOBS = ("src/backend/**/*.mo",) +TEST_GLOBS = ("test/*.mo", "test/**/*.mo") +EXCLUDE = ("src/backend/Main.mo",) + +PUBLIC_FUNC = re.compile(r"^\s*public\s+func\s+([a-z_][A-Za-z0-9_]*)", re.M) + + +def strip_comments(src: str) -> str: + """Motoko source with `//` and `/* */` comments blanked, string bodies left alone. + + Newlines are preserved so a stripped file still lines up with the original. Block + comments NEST in Motoko, so the depth is counted rather than scanning for the first + `*/` — closing early would leave real code stripped and report a live symbol as dead, + which is the dangerous direction. + """ + out = [] + i, n = 0, len(src) + while i < n: + c = src[i] + if c == '"': + out.append(c) + i += 1 + while i < n: + if src[i] == "\\" and i + 1 < n: + out.append(" ") + i += 2 + continue + out.append(src[i]) + if src[i] == '"': + i += 1 + break + i += 1 + continue + if c == "/" and i + 1 < n and src[i + 1] == "/": + while i < n and src[i] != "\n": + i += 1 + continue + if c == "/" and i + 1 < n and src[i + 1] == "*": + depth = 1 + i += 2 + while i < n and depth: + if src[i] == "/" and i + 1 < n and src[i + 1] == "*": + depth += 1 + i += 2 + continue + if src[i] == "*" and i + 1 < n and src[i + 1] == "/": + depth -= 1 + i += 2 + continue + if src[i] == "\n": + out.append("\n") + i += 1 + continue + out.append(c) + i += 1 + return "".join(out) + + +def self_test(): + """⚠️ Unconditional. The stripper is the instrument: if it silently stops stripping, + every symbol is shielded by the prose about it and this step passes while checking + nothing. Both directions are tested — a comment must be stripped, a string must not.""" + cases = [ + ("let a = 1; // configErrorToText", "configErrorToText", False), + ("/// configErrorToText renders it\nlet a = 1;", "configErrorToText", False), + ("/* configErrorToText */ let a = 1;", "configErrorToText", False), + # Nesting: a non-counting stripper closes at the inner `*/` and keeps the tail. + ("/* outer /* inner */ configErrorToText */ let a = 1;", "configErrorToText", False), + ('let s = "configErrorToText";', "configErrorToText", True), + ('let u = "https://x/y"; let b = configErrorToText(e);', "configErrorToText", True), + ('let s = "// configErrorToText";', "configErrorToText", True), + ] + for src, name, expected in cases: + got = name in strip_comments(src) + if got != expected: + sys.exit( + f"ABORT: strip_comments self-test failed on {src!r}: " + f"expected {name!r} {'kept' if expected else 'stripped'}" + ) + if strip_comments("let a = 1; // x\nlet b = 2;").count("\n") != 1: + sys.exit("ABORT: strip_comments dropped a newline") + if PUBLIC_FUNC.findall(" public func foo(x : Nat) : Nat { x };") != ["foo"]: + sys.exit("ABORT: PUBLIC_FUNC stopped matching a public func") + if PUBLIC_FUNC.findall(" public shared func bar() : async () {};") != []: + sys.exit("ABORT: PUBLIC_FUNC matched a shared endpoint, which is out of scope") + + +def expand(patterns): + out = [] + for p in patterns: + out.extend(glob.glob(p, recursive=True)) + return sorted(set(out)) + + +def main(): + self_test() + + modules = [p for p in expand(MODULE_GLOBS) if p not in EXCLUDE] + if not modules: + sys.exit(f"ABORT: no modules matched {MODULE_GLOBS} — cannot pass vacuously") + + exports = {} + oracles = set() + for p in modules: + text = open(p).read() + for m in PUBLIC_FUNC.finditer(text): + exports.setdefault(m.group(1), p) + # Read from the doc block directly above, so the marker cannot be claimed + # from elsewhere in the file. + doc = text[: m.start()].rsplit("\n\n", 1)[-1] + if "@test-oracle" in doc: + oracles.add(m.group(1)) + if not exports: + sys.exit(f"ABORT: parsed no `public func` out of {len(modules)} modules — cannot pass vacuously") + + prod = {p: strip_comments(open(p).read()) for p in expand(CALLER_GLOBS)} + tests = {p: open(p).read() for p in expand(TEST_GLOBS)} + if not tests: + sys.exit(f"ABORT: no test sources matched {TEST_GLOBS} — cannot pass vacuously") + + dead = [] + for name, home in sorted(exports.items()): + if name in oracles: + continue + word = re.compile(rf"\b{re.escape(name)}\b") + # In its own file the declaration itself matches, so one hit is not a caller. + used = any(word.search(t) for p, t in prod.items() if p != home) or ( + len(word.findall(prod.get(home, ""))) > 1 + ) + if used: + continue + where = [p for p, t in tests.items() if word.search(t)] + dead.append((name, home, where)) + + if dead: + print("\n\033[31m✗ module function(s) with no caller outside their tests\033[0m", file=sys.stderr) + for name, home, where in dead: + tail = f" — only in {', '.join(sorted(where))}" if where else " — referenced nowhere" + print(f" {name} ({home}){tail}", file=sys.stderr) + print( + "\n Delete it AND its tests, or give it a production caller. A function whose\n" + " only caller is its own test is neither used nor covered, while the suite\n" + " counts it as coverage — and a `*ToText` or a validator with no caller reads\n" + " as a supported path that something is expected to use.\n" + "\n If it is legitimately a test oracle derived from a type, put\n" + " `@test-oracle` in the doc block directly above it, with the reason.", + file=sys.stderr, + ) + return 1 + + note = f", {len(oracles)} marked @test-oracle" if oracles else "" + print(f" {len(exports)} module functions: every one called from production{note}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/e2e-local.sh b/scripts/e2e-local.sh new file mode 100755 index 0000000..b47fbe4 --- /dev/null +++ b/scripts/e2e-local.sh @@ -0,0 +1,410 @@ +#!/usr/bin/env bash +# Deploy-and-wiring smoke test against a real local network. +# +# Why this exists: the PocketIC suite installs wasms directly and never runs +# `icp deploy`, so four things it covers perfectly well in-canister have **no +# automated coverage at all** at the deployment layer: +# +# 1. the deploy itself — recipe pipeline, `shrink: true`, the embedded +# candid:service metadata, the Candid compatibility check; +# 2. the `PUBLIC_CANISTER_ID:xrc` override branch — in PocketIC the mock sits +# *at* the mainnet id, so no env var is injected and only the fallback +# branch ever runs. The override executes solely on a local network; +# 3. the asset canister and its `ic_env` cookie — the frontend serving at all; +# 4. `icp.yaml` environment scoping, i.e. that the `ic` environment excludes +# the local-only `xrc` mock so it can never reach mainnet. +# +# None of these is a canister behaviour, which is exactly why the canister test +# suite misses them. A broken `icp.yaml` or a bad recipe pin would ship silently. +# +# Deliberately NOT covered: the money path. Reaching `delivered` locally needs the +# CMC rate set by impersonating governance through the PocketIC control API, which +# is not a supported `icp` interface — the port is unpublished and may change +# between releases. `scripts/local-dev-seed.sh` does exactly that when you want to +# click through the app by hand; this smoke test stays independent of it so it +# keeps working if the port moves. Every money-path assertion lives in the +# PocketIC suite, where it needs none of this. +# +# Usage: +# scripts/e2e-local.sh # start a network if needed, deploy, assert, tear down +# scripts/e2e-local.sh --keep # leave the network running afterwards +# scripts/e2e-local.sh --no-deploy # assert against what is already deployed +# +# A network this script did not start is never stopped, --keep or not. +# +# Prerequisites: icp-cli ≥ 1.2.0, jq, node, python3, curl. +set -euo pipefail + +cd "$(dirname "$0")/.." + +KEEP=0 +DEPLOY=1 +for arg in "$@"; do + case "$arg" in + --keep) KEEP=1 ;; + --no-deploy) DEPLOY=0 ;; + -h | --help) + sed -n '2,32p' "$0" | sed 's|^# \{0,1\}||' + exit 0 + ;; + *) + echo "unknown argument: $arg" >&2 + exit 2 + ;; + esac +done + +# The webhook secret this run provisions. Local-only and printed below: it exists +# to prove the signature path executes, not to protect anything. +WEBHOOK_SECRET='whsec_e2e_local_smoke_test' +XRC_WASM='test/integration/wasm/xrc_mock.wasm.gz' +STARTED_NETWORK=0 +CHECKS=0 + +step() { printf '\n\033[1m── %s\033[0m\n' "$1"; } +ok() { + CHECKS=$((CHECKS + 1)) + printf ' \033[32m✓\033[0m %s\n' "$1" +} +die() { + printf '\n\033[31m✗ %s\033[0m\n' "$1" >&2 + exit 1 +} + +# An upgrade that trapped in `register_stable_type` means the stable signature +# changed incompatibly — Motoko's enhanced orthogonal persistence refuses to +# reinterpret existing memory under a new layout, so `post_upgrade` traps rather +# than corrupting state. Nothing is wrong with the new build; it simply cannot +# inherit this canister's memory. +# +# Locally the answer is `--mode reinstall`: the network is disposable and this +# script provisions everything it asserts on. **On mainnet reinstall wipes the +# order store, the journals and the dedup sets** — there the answer is a +# migration expression, or a deliberate decision made before any real order +# exists. Printed loudly because those two situations look identical from here. +reinstall_or_die() { + local log="$1" + if ! grep -q 'Memory-incompatible program upgrade' "$log"; then + cat "$log" >&2 + die "icp deploy failed" + fi + printf '\n\033[33m⚠ STABLE-SHAPE CHANGE — this build cannot upgrade the deployed canister:\033[0m\n' + printf ' the upgrade trapped in register_stable_type (EOP memory-incompatible).\n' + printf ' Reinstalling locally, which WIPES orders, journals and dedup sets.\n' + printf ' On mainnet this needs a migration expression instead.\n\n' + icp deploy --mode reinstall --yes || { + cat "$log" >&2 + die "icp deploy failed even with --mode reinstall" + } + ok "deployed (reinstalled after an incompatible stable shape, reported above)" +} + +cleanup() { + local code=$? + if [ "$STARTED_NETWORK" -eq 1 ] && [ "$KEEP" -eq 0 ]; then + step "tearing down the network this run started" + icp network stop >/dev/null 2>&1 || true + printf ' stopped\n' + elif [ "$STARTED_NETWORK" -eq 1 ]; then + printf '\nnetwork left running (--keep). Stop it with: icp network stop\n' + fi + if [ "$code" -ne 0 ]; then + printf '\n\033[31m✗ smoke test FAILED after %d passing check(s)\033[0m\n' "$CHECKS" >&2 + fi +} +trap cleanup EXIT + +# ── 0. preflight ────────────────────────────────────────────────────────────── +step "0. preflight" +for tool in icp jq node python3 curl; do + command -v "$tool" >/dev/null 2>&1 || die "$tool not found on PATH" +done +python3 -c 'import yaml' 2>/dev/null || + die "python3 needs PyYAML for the environment-scoping check (pip3 install pyyaml)" +ok "icp $(icp --version | awk '{print $2}'), jq, node, python3+yaml, curl" + +# The xrc mock is fetched and hash-verified by test/integration's pretest, and +# icp.yaml pins the same file by sha256. Without it the deploy fails on a missing +# path rather than on anything interesting. +if [ ! -s "$XRC_WASM" ]; then + printf ' %s missing — fetching\n' "$XRC_WASM" + npm --prefix test/integration run fetch:wasm >/dev/null +fi +ok "xrc mock present" + +# ── 1. network ──────────────────────────────────────────────────────────────── +step "1. local network" +if icp network status >/dev/null 2>&1; then + ok "already running (this run will not stop it)" +else + icp network start -d >/dev/null || die "icp network start failed" + STARTED_NETWORK=1 + ok "started" +fi + +GATEWAY="$(icp network status --json | jq -r '.gateway_url' | sed 's|/$||')" +[ -n "$GATEWAY" ] || die "could not read the gateway url" +ok "gateway at $GATEWAY" + +# ── 2. deploy ───────────────────────────────────────────────────────────────── +if [ "$DEPLOY" -eq 1 ]; then + step "2. icp deploy" + # The whole pipeline: moc build, ic-wasm shrink, candid:service metadata, the + # Candid compatibility check against the *deployed* canister, the asset build + # and sync, and the PUBLIC_CANISTER_ID injection every later assertion needs. + # + # Run without --yes first, so a breaking interface change is *reported* rather + # than bypassed silently — that check is the only automated warning this repo + # gets before an incompatible upgrade. A local network is disposable, so on + # failure we surface the diff and retry with --yes; on mainnet the same output + # is a decision, not a speed bump. + DEPLOY_LOG="$(mktemp)" + # The xrc mock stores its canned response in heap only and sets it from + # `init_args` at INSTALL time, so a routine `icp deploy` upgrades it and the + # response is gone — every later rate call then traps with "Response has not + # been set". Reinstalling it first is free (it holds nothing worth keeping) and + # makes the pricing check mean something. + if icp canister status xrc >/dev/null 2>&1; then + icp deploy --mode reinstall --yes xrc >/dev/null 2>&1 || + die "could not reinstall the xrc mock" + ok "xrc mock reinstalled (its response is install-time only)" + fi + if icp deploy >"$DEPLOY_LOG" 2>&1; then + ok "deployed (interface compatible with what was already there)" + elif grep -q 'Candid interface compatibility check failed' "$DEPLOY_LOG"; then + printf '\n\033[33m⚠ BREAKING interface change vs the deployed canister:\033[0m\n' + grep -E '^Method |is not a subtype' "$DEPLOY_LOG" | sed 's|^| |' + printf ' Retrying with --yes because a local network is disposable. On\n' + printf ' mainnet, decide deliberately: existing clients may stop working.\n\n' + if icp deploy --yes >"$DEPLOY_LOG" 2>&1; then + ok "deployed (breaking change accepted locally, reported above)" + else + reinstall_or_die "$DEPLOY_LOG" + fi + elif grep -q 'Memory-incompatible program upgrade' "$DEPLOY_LOG"; then + reinstall_or_die "$DEPLOY_LOG" + else + cat "$DEPLOY_LOG" >&2 + die "icp deploy failed" + fi + rm -f "$DEPLOY_LOG" +else + step "2. icp deploy — skipped (--no-deploy)" +fi + +BACKEND_ID="$(icp canister status backend --json | jq -r '.id')" +[ "$BACKEND_ID" != "null" ] && [ -n "$BACKEND_ID" ] || die "backend not deployed" +ok "backend is $BACKEND_ID" + +# ── 3. the env-var override branch ──────────────────────────────────────────── +step "3. PUBLIC_CANISTER_ID:xrc override" +# THE assertion this script exists for. PocketIC puts the mock at the mainnet id, +# so `Runtime.envVar` returns null there and only the fallback branch is ever +# exercised. Here the id differs, so a broken lookup shows up as the mainnet id. +INJECTED="$(icp canister status backend --json | + jq -r '.settings.environment_variables[] | select(.name == "PUBLIC_CANISTER_ID:xrc") | .value')" +[ -n "$INJECTED" ] || die "icp deploy did not inject PUBLIC_CANISTER_ID:xrc" +ok "deploy injected xrc = $INJECTED" + +# `pricing_status.xrcCanisterId` reports the id an XRC call actually *resolved*, +# and is null until one has — so force a refresh rather than reading a field that +# has not been populated. (Its being null rather than defaulting to the mainnet id +# is deliberate: a detection signal must not read all-clear before it has looked.) +icp canister call backend refresh_rates '()' >/dev/null 2>&1 || true +PRICING="$(icp canister call backend pricing_status '()')" +REPORTED="$(printf '%s' "$PRICING" | + sed -n 's/.*xrcCanisterId = opt "\([^"]*\)".*/\1/p' | head -1)" +[ -n "$REPORTED" ] || + die "pricing_status.xrcCanisterId is still null after a refresh — no XRC call has + resolved an id, so the override is unverified. Full response: +$PRICING" +[ "$REPORTED" = "$INJECTED" ] || + die "backend resolved the XRC to '$REPORTED', expected the injected '$INJECTED'. + Reading the mainnet id (uf6dk-hyaaa-aaaaq-qaaaq-cai) means the env-var + override is not working and every local order would price off mainnet." +ok "backend resolved the XRC to the injected local id" + +# And the id it resolved can actually answer — a correct id that returns nothing +# prices no orders. `rates` is null until a refresh succeeds end to end. +# `rates` staying null here is EXPECTED, not a failure: a pair needs a fresh CMC +# rate as well as the XRC, and this script deliberately does not touch the +# unsupported control port that can set one. Run scripts/local-dev-seed.sh if you +# want the gateway actually sellable. +if printf '%s' "$PRICING" | grep -q 'rates = null'; then + printf ' \033[33m·\033[0m no rate pair cached. Expected: the CMC rate needs governance.\n' + printf ' Run scripts/local-dev-seed.sh to make this gateway sellable.\n' + printf ' The XRC override above is proven either way.\n' +else + ok "a rate pair is cached, priced from the local mock" +fi + +# ── 4. the asset canister and its ic_env cookie ─────────────────────────────── +step "4. frontend" +HEADERS="$(mktemp)" +FRONT_STATUS="$(curl -sS -o /dev/null -D "$HEADERS" -w '%{http_code}' \ + "http://frontend.local.localhost:${GATEWAY##*:}/" || true)" +[ "$FRONT_STATUS" = "200" ] || die "frontend returned $FRONT_STATUS, expected 200" +ok "http://frontend.local.localhost:${GATEWAY##*:}/ → 200" + +# The cookie is how the frontend learns the local root key and the canister ids +# without a build-time bake. No cookie means the app boots pointing at mainnet. +# Its contents are percent-encoded (`ic%5Froot%5Fkey`), so decode before matching — +# grepping the raw header for `ic_root_key` finds nothing even when it is there. +# ⚠️ **The security headers, asserted here because NOTHING else can see them.** They come +# from `_headers` in the asset directory, which only the deployed canister applies — the +# browser suite serves `dist` from a static server, so a `_headers` mistake is invisible +# to a green gate. The response file is already open for the cookie check below; these are +# seven greps on it. +# +# certified-assets has no default security policy (the legacy asset canister's +# `security_policy: "standard"` had no equivalent), so every one of these exists only +# because `_headers` says so — on a page that takes card details. +for header in \ + content-security-policy \ + permissions-policy \ + x-frame-options \ + referrer-policy \ + strict-transport-security \ + x-content-type-options \ + x-xss-protection +do + grep -qi "^${header}:" "$HEADERS" || + die "the frontend served no ${header} header — check src/frontend/public/_headers reached + src/frontend/dist/ and that the deploy synced it" +done +ok "all 7 security headers present (they exist only because _headers says so)" + +# ⚠️ Response certification, which BOTH canisters do — measured, not assumed: the legacy +# asset canister served these too, so this is a property carried over rather than gained. +grep -qi '^ic-certificate:' "$HEADERS" || die "no IC-Certificate on the frontend response" +ok "responses are certified (IC-Certificate present)" + +grep -qi '^set-cookie:.*ic_env' "$HEADERS" || die "no ic_env cookie on the frontend response" +COOKIE="$(grep -i '^set-cookie:.*ic_env' "$HEADERS" | head -1 | + python3 -c 'import sys, urllib.parse; print(urllib.parse.unquote(sys.stdin.read()))')" +rm -f "$HEADERS" +printf '%s' "$COOKIE" | grep -q 'ic_root_key=' || + die "the ic_env cookie carries no ic_root_key — the app would verify certificates against the mainnet key" +ok "ic_env cookie carries ic_root_key" + +# The same cookie carries the ids the app calls, so a stale one points a working +# frontend at a canister that no longer exists. +printf '%s' "$COOKIE" | grep -q "PUBLIC_CANISTER_ID:backend=${BACKEND_ID}" || + die "the ic_env cookie does not name the deployed backend ($BACKEND_ID)" +ok "ic_env cookie names the deployed backend" + +# ── 5. local Internet Identity ──────────────────────────────────────────────── +step "5. local Internet Identity" +II_STATUS="$(curl -sS -o /dev/null -w '%{http_code}' \ + "http://id.ai.localhost:${GATEWAY##*:}/" || true)" +[ "$II_STATUS" = "200" ] || + die "local II returned $II_STATUS, expected 200. Is \`ii: true\` still set on the local network in icp.yaml?" +ok "http://id.ai.localhost:${GATEWAY##*:}/ → 200" + +# ── 6. the webhook route through the real gateway ───────────────────────────── +step "6. signed webhook through the HTTP gateway" +# Sealed: the plaintext never becomes an ingress argument. The wrapper derives the +# PocketIC master key from the (default, local) environment. +STRIPE_WEBHOOK_SECRET="$WEBHOOK_SECRET" scripts/seal-secret.sh webhook-secret >/dev/null || + die "sealed set_webhook_secret failed (are you the controller?)" +ok "webhook secret provisioned, sealed" + +# A `charge.dispute.created` body on purpose: it is a type the canister does not +# subscribe to, so it verifies, acks 200, and files **nothing**. That proves the +# route, the signature check and the secret without leaving an obligation behind +# on the local canister — which a payment-shaped body would. +# The signature covers the body BYTE FOR BYTE, so node writes the body to a file +# and curl sends that same file. Piping it through a text filter is what breaks +# this: `jq -r` appends a newline, and a one-byte difference is a 400 that looks +# exactly like a broken verifier. +BODY_FILE="$(mktemp)" +SIG_HEADER="$(node -e ' +const crypto = require("crypto"); +const fs = require("fs"); +const [secret, bodyFile] = process.argv.slice(1); +const body = JSON.stringify({ + id: "evt_e2e_local_smoke", + type: "charge.dispute.created", + livemode: false, + data: { object: { id: "dp_smoke" } }, +}); +fs.writeFileSync(bodyFile, body); +const t = Math.floor(Date.now() / 1000); +const sig = crypto.createHmac("sha256", secret).update(`${t}.${body}`).digest("hex"); +process.stdout.write(`t=${t},v1=${sig}`); +' "$WEBHOOK_SECRET" "$BODY_FILE")" + +WEBHOOK_STATUS="$(curl -sS -o /dev/null -w '%{http_code}' \ + -X POST "${GATEWAY}/webhook/stripe?canisterId=${BACKEND_ID}" \ + -H 'content-type: application/json' \ + -H "stripe-signature: $SIG_HEADER" \ + --data-binary "@$BODY_FILE" || true)" +rm -f "$BODY_FILE" +[ "$WEBHOOK_STATUS" = "200" ] || + die "the signed webhook returned $WEBHOOK_STATUS, expected 200. + 400 means signature verification rejected a body we signed correctly; + 503 means the secret did not stick." +ok "POST ${GATEWAY}/webhook/stripe → 200" + +# An unsigned body must NOT be accepted — otherwise the 200 above proves nothing. +# This one has no Stripe-Signature header at all, so it covers the missing-header +# branch. +UNSIGNED_STATUS="$(printf '{}' | curl -sS -o /dev/null -w '%{http_code}' \ + -X POST "${GATEWAY}/webhook/stripe?canisterId=${BACKEND_ID}" \ + -H 'content-type: application/json' --data-binary @- || true)" +[ "$UNSIGNED_STATUS" = "400" ] || + die "an unsigned body returned $UNSIGNED_STATUS, expected 400 — the route is not verifying signatures" +ok "a body with no signature header is refused 400" + +# And a WELL-FORMED header whose MAC does not verify — a different branch, and the +# one that actually happens in production: a rotated secret, or a webhook endpoint +# configured against a different Stripe account than the one paying. The timestamp is current on purpose, so a refusal +# is attributable to the MAC and not to the ±300 s tolerance window. +BAD_SIG_STATUS="$(curl -sS -o /dev/null -w '%{http_code}' \ + -X POST "${GATEWAY}/webhook/stripe?canisterId=${BACKEND_ID}" \ + -H 'content-type: application/json' \ + -H "stripe-signature: t=$(date +%s),v1=$(printf 'f%.0s' $(seq 64))" \ + --data-binary '{"id":"evt_bad_sig","type":"charge.dispute.created"}' || true)" +[ "$BAD_SIG_STATUS" = "400" ] || + die "a well-formed header with a non-verifying v1= returned $BAD_SIG_STATUS, expected 400. + 200 here would mean the MAC is not actually being checked — the signed-200 + above would then prove only that the route exists." +ok "a well-formed header with a bad MAC is refused 400" + +# ── 7. environment scoping keeps the mock off mainnet ───────────────────────── +step "7. the ic environment excludes the local-only xrc mock" +# Structural, not procedural: an unlisted canister is never created, never +# installed, and its PUBLIC_CANISTER_ID is never injected — so a mainnet backend +# falls back to the real Exchange Rate Canister no matter who runs the deploy. +IC_CANISTERS="$(icp project show | python3 -c ' +import sys, yaml +# `icp project show` emits custom tags (e.g. `!Text` on init args), which +# safe_load refuses outright. Ignore unknown tags rather than whitelisting them: +# this only reads the canister list, and the tag set is the CLIs to change. +yaml.SafeLoader.add_multi_constructor("", lambda loader, suffix, node: None) +doc = yaml.safe_load(sys.stdin) +env = (doc.get("environments") or {}).get("ic") +if env is None: + sys.exit("no `ic` environment in the effective configuration") +canisters = env.get("canisters") +if not canisters: + sys.exit("the `ic` environment lists no canisters — it would deploy EVERYTHING, mock included") +print(" ".join(sorted(canisters))) +')" || die "$IC_CANISTERS" +[ "$IC_CANISTERS" = "backend frontend" ] || + die "the ic environment lists [$IC_CANISTERS], expected [backend frontend]. + Anything more risks deploying the XRC mock to mainnet." +ok "ic environment lists exactly: $IC_CANISTERS" + +# ── 8. the deployed wasm is the committed interface ─────────────────────────── +step "8. deployed module matches a local build" +# `icp deploy` runs a Candid compatibility check, but only against the .did in +# the repo — it cannot notice that the .did itself is stale. This is the same +# check scripts/test-all.sh makes, repeated here because a deploy is exactly when +# a stale interface becomes a live one. +mops build >/dev/null 2>&1 || die "mops build failed" +git diff --quiet -- src/backend/dist/backend.did || + die "src/backend/dist/backend.did is out of date — the deployed candid:service metadata is stale" +ok "committed .did is current" + +printf '\n\033[32m✓ %d checks passed\033[0m\n' "$CHECKS" diff --git a/scripts/fetch-fonts.sh b/scripts/fetch-fonts.sh new file mode 100755 index 0000000..5885ba6 --- /dev/null +++ b/scripts/fetch-fonts.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Vendor the three IC brand typefaces into the frontend bundle. +# +# Why self-hosted rather than a Google Fonts , which is what the reference +# implementations (skills./mcp./docs.internetcomputer.org) use: this page takes +# card details. A stylesheet from fonts.googleapis.com plus font files from +# fonts.gstatic.com send every visitor's IP to a third party on page load, before +# any consent surface exists, on a property intended to be operated by the DFINITY +# Foundation. Self-hosting removes the request entirely. It renders identically — +# the same files, served from the asset canister. +# +# Latin subset only. The UI is English; the other subsets Google ships (cyrillic, +# greek, vietnamese, latin-ext) would roughly quadruple the byte count for glyphs +# nothing renders. +# +# Regenerate after changing the weight set: +# scripts/fetch-fonts.sh +# +# Writes src/frontend/public/fonts/*.woff2 and src/frontend/src/fonts.css, both +# committed so a clean checkout builds without network access. +set -euo pipefail + +cd "$(dirname "$0")/../src/frontend" + +# The weights the brand guidelines specify, and no others: +# Newsreader 380/400/500 + italics — editorial display and body +# Inter 400/500/600 — UI chrome, eyebrows, nav, buttons +# JetBrains Mono 400/500 — technical readouts, numerals, code +FAMILIES="family=Inter:wght@400;500;600\ +&family=JetBrains+Mono:wght@400;500\ +&family=Newsreader:ital,wght@0,380;0,400;0,500;1,380;1,400;1,500" + +# A browser UA is required: with curl's default UA Google returns TTF rather than +# woff2, which is ~3x the bytes and loses the subsetting. +UA="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36" + +mkdir -p public/fonts +TMP_CSS="$(mktemp)" +trap 'rm -f "$TMP_CSS"' EXIT + +curl -fsS -A "$UA" "https://fonts.googleapis.com/css2?${FAMILIES}&display=swap" -o "$TMP_CSS" + +# Emit " " for the latin block of each face, then fetch each with curl +# (python's SSL trust store is not configured on macOS by default). +python3 - "$TMP_CSS" <<'PY' > /tmp/font-manifest.txt +import re, sys, pathlib +css = pathlib.Path(sys.argv[1]).read_text() +for subset, block in re.findall(r'/\*\s*([\w-]+)\s*\*/\s*(@font-face\s*\{.*?\})', css, re.S): + if subset != 'latin': + continue + fam = re.search(r"font-family: '([^']+)'", block).group(1) + wt = re.search(r'font-weight: (\d+)', block).group(1) + st = re.search(r'font-style: (\w+)', block).group(1) + url = re.search(r'url\((https://[^)]+\.woff2)\)', block).group(1) + slug = f"{fam.lower().replace(' ', '-')}-{wt}{'-italic' if st == 'italic' else ''}.woff2" + print(f"{fam}\t{wt}\t{st}\t{slug}\t{url}") +PY + +[ -s /tmp/font-manifest.txt ] || { + echo "no latin faces parsed — did the Google Fonts CSS format change?" >&2 + exit 1 +} + +# Every one of these families ships as a VARIABLE font, and Google serves the same +# file for each requested weight — downloading per weight fetched identical bytes +# three times over (562 KB for what is really 197 KB). Collapse each family+style +# to one file declared with a weight RANGE, which is how a variable face is meant +# to be registered anyway. +rm -f public/fonts/*.woff2 +python3 - <<'MANIFEST' > /tmp/font-faces.txt +import collections, pathlib, subprocess, sys + +rows = [l.split('\t') for l in pathlib.Path('/tmp/font-manifest.txt').read_text().splitlines()] +groups = collections.defaultdict(list) +for fam, wt, st, slug, url in rows: + groups[(fam, st)].append((int(wt), url)) + +for (fam, st), entries in sorted(groups.items()): + entries.sort() + urls = {u for _, u in entries} + # One URL across every weight means one variable file. Anything else means + # Google started serving static instances and collapsing would drop weights. + assert len(urls) == 1, f'{fam} {st}: {len(urls)} distinct files, cannot collapse' + slug = f"{fam.lower().replace(' ', '-')}{'-italic' if st == 'italic' else ''}.woff2" + dest = pathlib.Path('public/fonts') / slug + subprocess.run(['curl', '-fsS', entries[0][1], '-o', str(dest)], check=True) + lo, hi = entries[0][0], entries[-1][0] + weight = f'{lo}' if lo == hi else f'{lo} {hi}' + print(f' {fam:16} {st:7} {weight:9} {dest.stat().st_size / 1024:6.1f} KB', file=sys.stderr) + print(f"@font-face {{\n font-family: '{fam}';\n font-style: {st};\n" + f" font-weight: {weight};\n font-display: swap;\n" + f" src: url('/fonts/{slug}') format('woff2');\n}}\n") +MANIFEST + +{ + echo "/* Self-hosted brand faces — generated by scripts/fetch-fonts.sh, do not edit." + echo " Latin subset only, one variable file per family and style." + echo + echo " Self-hosted rather than fetched from Google so that no visitor IP reaches a" + echo " third party from a page that takes card details. */" + echo + cat /tmp/font-faces.txt +} > src/fonts.css + +printf '\n %s files, %s KB total\n' \ + "$(ls public/fonts/*.woff2 | wc -l | tr -d ' ')" \ + "$(echo "scale=1; $(cat public/fonts/*.woff2 | wc -c) / 1024" | bc)" +rm -f /tmp/font-manifest.txt /tmp/font-faces.txt diff --git a/scripts/issue-body.py b/scripts/issue-body.py new file mode 100755 index 0000000..662ee61 --- /dev/null +++ b/scripts/issue-body.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Fetch and write a GitHub issue body byte-exactly, verifying the round trip. + + scripts/issue-body.py get # fetch, verify length, write + scripts/issue-body.py put # write back, then re-fetch and diff + scripts/issue-body.py diff # is the remote still what this file says? + +⚠️ **Why this is a versioned script and not a snippet.** + +Markdown built inside an *unquoted* heredoc lets the shell run the backticks in the text; +that has put a mangled 101k-char body live in place of a 53k one here. It was recoverable +only because a pre-edit fetch existed **with its length checked against the API's own +count** — which is what made the restore trustworthy rather than hopeful. + +A tool that lives in a session scratchpad does not persist, so the next agent to touch a +large issue body has to re-derive it from prose. **A rule whose tool is gone is a rule +that gets skipped** — so the tool lives here, next to every other rule in this repo that +graduated from a comment into something executable (`check-heredocs.sh`, +`brand-lint.sh`, `test-all.sh`). + +⚠️ **The abort is the feature.** Every failure mode here is silent: a truncated write +looks like a successful one, and a body that lost 40k characters still renders as a page +of Markdown. So each step compares against the API's own count and exits non-zero on any +mismatch rather than reporting what it hoped happened. + +⚠️ **`put` verifies by re-fetching, not by trusting the write.** GitHub accepting the +request is not evidence the stored body matches the file — the corruption above was +accepted cleanly. Only a read-back proves it. + +Requires `gh` (authenticated). `GH_REPO`, or `--repo owner/name`, overrides the default. +""" + +import argparse +import json +import subprocess +import sys + +DEFAULT_REPO = "marc0olo/cyclepay" + + +def die(msg: str) -> "None": + sys.exit(f"ABORT: {msg}") + + +def api(repo: str, number: str, *extra: str) -> dict: + proc = subprocess.run( + ["gh", "api", f"repos/{repo}/issues/{number}", *extra], + capture_output=True, + text=True, + ) + if proc.returncode != 0: + # ⚠️ Print the real stderr. A swallowed reason paired with a guessed cause is + # worse than no diagnosis — the reader chases the guess. + die(f"gh api failed for {repo}#{number}:\n{proc.stderr.strip()}") + return json.loads(proc.stdout) + + +def read_remote(repo: str, number: str) -> str: + body = api(repo, number).get("body") + if body is None: + die(f"{repo}#{number} has no body (deleted, or a permissions problem)") + return body + + +def write_local(path: str, body: str) -> None: + # newline="" on both sides: Python's universal-newline translation would silently + # rewrite CRLF and make a byte-exact comparison impossible to trust. + with open(path, "w", newline="") as fh: + fh.write(body) + back = open(path, newline="").read() + if back != body: + die(f"wrote {len(back)} chars, API body is {len(body)} — do not edit {path}") + + +def cmd_get(args) -> int: + body = read_remote(args.repo, args.number) + write_local(args.file, body) + print(f"ok: {args.repo}#{args.number} body = {len(body)} chars -> {args.file}") + return 0 + + +def cmd_diff(args) -> int: + remote = read_remote(args.repo, args.number) + local = open(args.file, newline="").read() + if remote == local: + print(f"ok: {args.repo}#{args.number} matches {args.file} ({len(local)} chars)") + return 0 + print( + f"DIFFERS: {args.repo}#{args.number} is {len(remote)} chars, " + f"{args.file} is {len(local)}", + file=sys.stderr, + ) + return 1 + + +def cmd_put(args) -> int: + local = open(args.file, newline="").read() + if not local.strip(): + die(f"{args.file} is empty or whitespace — refusing to blank a body") + before = read_remote(args.repo, args.number) + print(f"remote before: {len(before)} chars; sending {len(local)}") + # --input with a JSON file, so nothing in the body is ever interpreted by a shell. + proc = subprocess.run( + [ + "gh", "api", "--method", "PATCH", + f"repos/{args.repo}/issues/{args.number}", + "--input", "-", + ], + input=json.dumps({"body": local}), + capture_output=True, + text=True, + ) + if proc.returncode != 0: + die(f"PATCH failed, body unchanged:\n{proc.stderr.strip()}") + after = read_remote(args.repo, args.number) + if after != local: + die( + f"read-back MISMATCH: sent {len(local)} chars, remote now holds {len(after)}." + f" The previous body was {len(before)} chars — restore from your pre-edit copy." + ) + print(f"ok: {args.repo}#{args.number} verified byte-identical ({len(after)} chars)") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Byte-exact GitHub issue body round trips.", + epilog="The abort is the feature; see the module docstring.", + ) + parser.add_argument("action", choices=("get", "put", "diff")) + parser.add_argument("number") + parser.add_argument("file") + parser.add_argument("--repo", default=DEFAULT_REPO) + args = parser.parse_args() + return {"get": cmd_get, "put": cmd_put, "diff": cmd_diff}[args.action](args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/local-dev-seed.sh b/scripts/local-dev-seed.sh new file mode 100755 index 0000000..77cd020 --- /dev/null +++ b/scripts/local-dev-seed.sh @@ -0,0 +1,635 @@ +#!/usr/bin/env bash +# Make a local network's gateway actually usable: rate, tiers, delivery timeline, +# and a funded cycles reserve. +# +# A freshly deployed gateway is **fail-closed by design** — no tiers, an empty +# cycles reserve, and (locally) no exchange rate. That is correct for production and +# indistinguishable from "broken" when you are trying to click through the app, +# where it shows as "No amounts are configured yet" and "No exchange rate +# available yet". +# +# ## The part that is not obvious: the CMC rate +# +# Pricing needs BOTH rates. The XRC comes from the local mock (icp.yaml), but the +# CMC's conversion rate is only settable by NNS **governance**, and no local +# identity is governance. Docs in this repo previously called that a hard local +# blocker. +# +# It is not: a local `icp network` IS a PocketIC instance, and PocketIC's control +# API — on a second port of the same process — can submit an ingress message from +# an arbitrary sender. That is what this script does. +# +# ⚠️ That control port is **not a supported `icp` interface.** It is unpublished +# and may move or disappear between icp-cli releases. This script discovers it +# from the running process rather than assuming, and fails with an explanation +# rather than a stack trace. Nothing in CI or the test gate depends on it; the +# PocketIC suite drives its own instance through the supported pic-js API. +# +# ## The rate goes stale after 15 minutes +# +# `Cmc.cmcRateMaxAgeNs` is a security control, not a tuning knob. Re-run this +# script (or just `--rate-only`) whenever the app starts refusing orders again. +# +# Usage: +# scripts/local-dev-seed.sh # everything +# scripts/local-dev-seed.sh --rate-only # just refresh the CMC rate +set -euo pipefail + +cd "$(dirname "$0")/.." + +RATE_ONLY=0 +[ "${1:-}" = "--rate-only" ] && RATE_ONLY=1 +if [ -n "${1:-}" ] && [ "$1" != "--rate-only" ]; then + echo "usage: $0 [--rate-only]" >&2 + exit 2 +fi + +XDR_PERMYRIAD=35000 # 3.5 XDR/ICP, matching the PocketIC suite's vector + +ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; } +step() { printf '\n\033[1m── %s\033[0m\n' "$1"; } +die() { + printf '\n\033[31m✗ %s\033[0m\n' "$1" >&2 + exit 1 +} + +icp network status >/dev/null 2>&1 || die "no local network. Run: icp network start -d && icp deploy" + +# ── the PocketIC control port ──────────────────────────────────────────────── +step "PocketIC control API" +# Same process as the gateway, second listening port. Discovered, never assumed. +GATEWAY_PORT="$(icp network status --json | jq -r '.gateway_url' | sed -E 's#.*:([0-9]+)/?$#\1#')" +[ -n "$GATEWAY_PORT" ] || die "could not read a gateway port from icp network status" +# The process SERVING THIS GATEWAY, not just any pocket-ic. `pgrep -f pocket-ic | +# head -1` picked whichever instance started first, so with a second local network +# running anywhere on the machine — another project, or a stale one — the CMC +# message went to the wrong instance and this script failed with "the CMC did not +# take the rate" while the rate had in fact been set on someone else's network. +PIC_PID="$(lsof -nP -iTCP:"$GATEWAY_PORT" -sTCP:LISTEN -t 2>/dev/null | head -1 || true)" +[ -n "$PIC_PID" ] || die "nothing is listening on the gateway port :$GATEWAY_PORT. Run: icp network start -d" +CONTROL_PORT="$(lsof -nP -iTCP -sTCP:LISTEN -a -p "$PIC_PID" 2>/dev/null | + awk '{print $9}' | grep -oE '[0-9]+$' | grep -v "^${GATEWAY_PORT}$" | head -1 || true)" +[ -n "$CONTROL_PORT" ] || die "could not find the PocketIC control port beside the gateway on :$GATEWAY_PORT. + This port is unpublished and may have changed in a newer icp-cli. Everything + below except the CMC rate can still be done by hand — see RUNBOOK §§3-5." +ok "control API on 127.0.0.1:$CONTROL_PORT (gateway on :$GATEWAY_PORT)" + +# ── the XRC mock ───────────────────────────────────────────────────────────── +step "XRC mock" +# It keeps its canned response in HEAP and sets it from `init_args` at INSTALL +# time, so any routine `icp deploy` upgrades it and the response is gone. Every +# later rate fetch then fails with "Response has not been set", which surfaces as +# "No exchange rate available" — indistinguishable from the CMC problem below and +# the reason this script exists at all. Reinstalling is free: it holds nothing. +icp deploy --mode reinstall --yes xrc >/dev/null 2>&1 || + die "could not reinstall the xrc mock. Is it in icp.yaml and fetched? Try: + npm --prefix test/integration run fetch:wasm" +ok "xrc mock reinstalled (its response does not survive an upgrade)" + +# ── the CMC rate ───────────────────────────────────────────────────────────── +step "CMC conversion rate (as governance)" +# node needs @icp-sdk/core on its resolution path, which lives in test/integration. +( cd test/integration && node --input-type=module -e " +import { IDL } from '@icp-sdk/core/candid'; +import { Principal } from '@icp-sdk/core/principal'; +const CONTROL = 'http://127.0.0.1:${CONTROL_PORT}'; +const Payload = IDL.Record({ + data_source: IDL.Text, timestamp_seconds: IDL.Nat64, xdr_permyriad_per_icp: IDL.Nat64, + reason: IDL.Opt(IDL.Variant({ OldRate: IDL.Null, DivergedRate: IDL.Null, EnableAutomaticExchangeRateUpdates: IDL.Null })), +}); +const b64 = (u8) => Buffer.from(u8).toString('base64'); +const body = { + sender: b64(Principal.fromText('rrkah-fqaaa-aaaaa-aaaaq-cai').toUint8Array()), + canister_id: b64(Principal.fromText('rkp4c-7iaaa-aaaaa-aaaca-cai').toUint8Array()), + method: 'set_icp_xdr_conversion_rate', + payload: b64(new Uint8Array(IDL.encode([Payload], [{ + data_source: 'local-dev-seed', + timestamp_seconds: BigInt(Math.floor(Date.now() / 1000)), + xdr_permyriad_per_icp: ${XDR_PERMYRIAD}n, + reason: [], + }]))), + effective_principal: 'None', +}; +// PocketIC answers 409 with the operation currently in flight: this is a LIVE +// network with its own auto-tick loop, so a request from outside routinely +// races it. pic-js retries internally; a hand-rolled client has to as well, or +// it works once and then fails for a reason that looks like a bad endpoint. +const post = async (p, payload) => { + for (let attempt = 0; attempt < 40; attempt += 1) { + const r = await fetch(CONTROL + '/instances/0' + p, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload), + }); + const text = await r.text(); + if (r.status !== 409) return { status: r.status, body: text }; + await new Promise((res) => setTimeout(res, 150)); + } + return { status: 409, body: 'still busy after 40 attempts' }; +}; +const s = await post('/update/submit_ingress_message', body); +if (s.status !== 200) { console.error('submit failed', s.status, s.body.slice(0,300)); process.exit(1); } +const a = await post('/update/await_ingress_message', JSON.parse(s.body).Ok); +if (a.status !== 200) { console.error('await failed', a.status, a.body.slice(0,300)); process.exit(1); } +// DECODE the CMC's reply. HTTP 200 only means PocketIC delivered the message; +// the CMC can still answer Err, and treating 200 as success reported a rate that +// was never set and left the gateway unable to price. + +" ) || die "could not set the CMC rate through the PocketIC control API on :$CONTROL_PORT" +# HTTP 200 only means PocketIC delivered the message; the CMC can still refuse it. +# Ask the CMC what it now holds instead of decoding the reply — this checks the +# thing we actually depend on, and it caught a fresh network where the rate never +# landed and the CMC was still serving its hardcoded 2021 default. +STORED="$(icp canister call rkp4c-7iaaa-aaaaa-aaaca-cai get_icp_xdr_conversion_rate '()' 2>/dev/null | + grep -oE 'xdr_permyriad_per_icp = [0-9_]+' | tr -d '_' | grep -oE '[0-9]+$' || echo 0)" +STAMP="$(icp canister call rkp4c-7iaaa-aaaaa-aaaca-cai get_icp_xdr_conversion_rate '()' 2>/dev/null | + grep -oE 'timestamp_seconds = [0-9_]+' | tr -d '_' | grep -oE '[0-9]+$' || echo 0)" +if [ "$STORED" != "$XDR_PERMYRIAD" ]; then + die "the CMC did not take the rate: it still reports $STORED permyriad, stamped $STAMP + (its hardcoded default is 35200 stamped 1620633601, i.e. 10 May 2021). + The submit reached PocketIC, so this is the CMC refusing the proposal rather + than a transport problem." +fi +ok "CMC rate set to ${XDR_PERMYRIAD} permyriad XDR/ICP (stamped $STAMP)" + +icp canister call backend refresh_rates '()' >/dev/null 2>&1 || true + +# Assert a real QUOTE, not the presence of a cached pair. `rates = opt` survives a +# failed refresh and a pair too old to use, so grepping for it reported success on +# a gateway that refused every purchase — the same mistake the rate line in the UI +# was making. +# $10, the smallest amount the gate now admits — quoting below the floor would +# "work" here (quote_previews is a pure quote and does not gate) and then be +# refused at create_order, which is a confusing thing for a seed to report as OK. +QUOTE="$(icp canister call backend quote_previews '(vec { 1_000 : nat })' 2>&1)" +if ! printf '%s' "$QUOTE" | grep -q 'cycles = opt'; then + printf '\n\033[31m✗ the gateway still cannot price a $10 purchase.\033[0m\n' >&2 + icp canister call backend pricing_status '()' 2>&1 | grep -E 'ok = |detail = ' >&2 + exit 1 +fi +ok "a \$10 purchase quotes (both XRC and CMC answered)" + +if [ "$RATE_ONLY" -eq 1 ]; then + printf '\n\033[32m✓ rate refreshed\033[0m — it goes stale again in 15 minutes.\n' + exit 0 +fi + +# ── local dev config ───────────────────────────────────────────────────────── +# `scripts/.local-dev.env` is gitignored, so a value set there is set once instead +# of exported into every new shell — and it stays out of shell history, `ps` and +# any terminal transcript, which matters for `STRIPE_API_KEY`. +# ⚠️ The only variable anything reads from it is STRIPE_API_KEY. +DEV_ENV_FILE="scripts/.local-dev.env" +if [ -f "$DEV_ENV_FILE" ]; then + BEFORE_KEY="${STRIPE_API_KEY:-}" + # shellcheck disable=SC1090 + . "$DEV_ENV_FILE" + # The environment wins over the file, so a one-off export still overrides it. + [ -z "$BEFORE_KEY" ] || STRIPE_API_KEY="$BEFORE_KEY" + ok "read local dev config from $DEV_ENV_FILE" +fi + +# ── presets ────────────────────────────────────────────────────────────────── +step "card presets" +# ⚠️ An empty list is NO LONGER a pause lever: a custom amount is orderable +# without any preset, so an empty list just shows no tiles. The rail's switch is +# both Stripe secrets being provisioned. +# +# The presets are $10 / $20 / $50. ⚠️ **Nothing below $10**: the gate's floor is $10, and +# registering a cheaper tier is refused as `belowFloor`. +icp canister call backend set_card_tiers \ + '(vec { record { id = "t10"; usdCents = 1_000 : nat }; + record { id = "t20"; usdCents = 2_000 : nat }; + record { id = "t50"; usdCents = 5_000 : nat } })' \ + >/dev/null || die "set_card_tiers failed" +ok "3 presets (\$10 / \$20 / \$50); any amount from \$10 to \$100 is orderable" + +# ── delivery timeline ──────────────────────────────────────────────────────── +step "delivery timeline" +# The gateway ships with a 2 h alert and a 72 h terminate bound, which is what you +# want locally too — an order that cannot deliver should end up on the worklist +# rather than retrying in silence. Set explicitly so the seed states the numbers a +# reader will see in `orphans_unresolved`. +icp canister call backend set_delivery_config \ + '(record { maxHoldNs = 259_200_000_000_000 : int; + alertAfterNs = 7_200_000_000_000 : int })' \ + >/dev/null || die "set_delivery_config failed" +ok "2 h alert, 72 h max hold" + + +# ── the admission gate ─────────────────────────────────────────────────────── +# ── Stripe API key + return origin ───────────────────────────────────── +step "Stripe session config" +# ⚠️ **`STRIPE_API_KEY` belongs in `scripts/.local-dev.env`, not on a command +# line.** That file is gitignored and is sourced above, so the key never appears +# in your shell history, in `ps` output, or in a terminal transcript. An +# `export`-then-run also works and wins over the file, but it leaves the value +# where something can read it back. +# +# echo 'STRIPE_API_KEY=rk_test_...' >> scripts/.local-dev.env +# The rail is live only when BOTH the API key and the webhook secret are +# provisioned. This step does the KEY and the ORIGIN; `scripts/stripe-dev.sh` +# does the webhook secret, because that one belongs to a `stripe listen` session +# rather than to the deployment. +# +# ⚠️ A reinstall wipes both secrets and this script only restores the key, so +# after `--mode reinstall` you still need `scripts/stripe-dev.sh` before paying. +if [ -n "${STRIPE_API_KEY:-}" ]; then + # Sealed. `seal-secret.sh` reads STRIPE_API_KEY itself, from the environment or + # from scripts/.local-dev.env, so the value is never passed as an argument. + scripts/seal-secret.sh api-key >/dev/null \ + || die "sealed set_stripe_api_key was refused (too short, or not a controller?)" + ok "Stripe API key provisioned from STRIPE_API_KEY, sealed" +else + # A placeholder, deliberately: it lets every non-paying path work — browsing, + # signing in, quoting — while `create_order` fails at the outcall with a real + # Stripe 401 rather than at a config check. That is a better local default than + # refusing to create orders at all, and the failure names itself. + # Sealed like the real thing, so the placeholder path exercises the same code — + # a local default that skipped sealing would leave the decrypt path untested until the + # first operator with a real key. + STRIPE_API_KEY='rk_test_PLACEHOLDER_set_STRIPE_API_KEY_to_create_sessions' \ + scripts/seal-secret.sh api-key >/dev/null \ + || die "sealed set_stripe_api_key was refused" + # ⚠️ **Say the TRUE reason, and scope the hazard correctly.** An earlier version of this + # message asserted that exporting the key makes `stripe listen` die with + # "more_permissions_required". It does not: every `stripe` invocation in this repo's own + # scripts is wrapped `env -u STRIPE_API_KEY` (stripe-dev.sh:156/164/282, + # capture-stripe-fixtures.sh:78/112), so our forwarder is immune. The real exposure is a + # `stripe` command an operator types by hand — `capture-stripe-fixtures.sh` prints one + # for exactly that — and the durable reason for the file is the one below. + printf ' \033[33m!\033[0m placeholder API key set — no real Checkout Session can be created.\n' + printf ' Put it in the FILE, not your shell:\n' + printf ' echo '"'"'STRIPE_API_KEY=rk_...'"'"' >> %s\n' "$DEV_ENV_FILE" + printf ' Restricted key, Checkout Sessions = Write, everything else None.\n' + printf ' Why the file: it is sourced into this process, so the key stays out of your\n' + printf ' shell history, `ps` and any terminal transcript.\n' + printf ' ⚠️ If you DO export it, a `stripe` command you type yourself picks it up and\n' + printf ' fails 403 "more_permissions_required" — a restricted key cannot open a CLI\n' + printf ' session. This repo'"'"'s own scripts strip it and are unaffected.\n' +fi + +# The origin Stripe returns the buyer to: the LOCAL gateway, so the post-payment +# redirect lands on the page you are clicking through. +# +# This was `https://.icp.net` because the canister refused non-https — a +# mainnet-shaped URL for a canister that only exists locally, so paying dead-ended the +# browser. `Session.validateOrigin` now accepts http for loopback hosts, which is what +# Stripe's own quickstart uses (`success_url` is a browser redirect target; Stripe never +# fetches it). Delivery never depended on this — the webhook does that work — but the +# redirect did. +# +# ⚠️ Not a chosen domain, just the one that works locally. +ORIGIN="http://frontend.local.localhost:${GATEWAY_PORT}" +icp canister call backend set_stripe_origin "(\"${ORIGIN}\")" >/dev/null \ + || die "set_stripe_origin refused ${ORIGIN} — https, or loopback http, with no query or fragment" +ok "return origin set to ${ORIGIN}" + +step "admission gate" +# The one that is genuinely confusing: `minCanisterCycles` defaults to 5 T, and +# `icp deploy` creates the canister with less. So a freshly deployed local gateway +# refuses EVERY purchase with "temporarily unavailable while the gateway is +# topped up" — which reads as a problem with what it sells and is actually about +# the canister's own gas. +# +# Fix the CONDITION, not the gate: top the canister up, which is exactly what you +# would do on mainnet. `icp canister top-up` (icp-cli 1.2.0) does this; it is not +# `icp cycles transfer`, which credits the cycles LEDGER and is a different thing. +# +# This used to lower `minCanisterCycles` to 0.5 T instead. That works, and it is +# the wrong lever twice over: it moves a safety floor to accommodate an +# under-funded canister, and it means local development never exercises a gate +# that is load-bearing on mainnet. +CYCLES_TOP_UP=20t +# ⚠️ **stderr is NOT redirected, and that is the fix rather than an oversight.** This +# line used to end `>/dev/null 2>&1` with a `die` that asserted "needs icp-cli 1.2.0 or +# newer" — a guess that was WRONG on a machine running 1.3.0, where the real error was +# `Insufficient cycles. Requested: 20_000_000_000_000, balance: 13997200000000` after a +# few reinstalls had spent the local identity's balance. Hiding the reason and printing +# a hardcoded cause sends the operator to upgrade a tool that was already current. +# +# Same fault as `capture-stripe-fixtures.sh`'s preflight before it was fixed: a swallowed +# stderr plus a guessed diagnosis is worse than no diagnosis, because it is believed. +# ⚠️ **Skipped when the canister already clears the floor with room, because this script +# is meant to be RE-RUN.** The top-up is 20 T every time, so re-seeding drained the local +# identity by 20 T a run — two runs and `icp cycles mint` was needed again. Nothing about +# a canister at 200 T needs another 20. +NEED_TOP_UP=1 +PRE_CYCLES="$(icp canister call backend cycles_status '()' 2>/dev/null || true)" +PRE_BALANCE="$(printf '%s' "$PRE_CYCLES" | grep -oE 'balance = [0-9_]+' | tr -d '_' | grep -oE '[0-9]+$' || echo 0)" +PRE_FLOOR="$(printf '%s' "$PRE_CYCLES" | grep -oE 'floor = [0-9_]+' | tr -d '_' | grep -oE '[0-9]+$' || echo 0)" +# Twice the floor: enough headroom that a purchase is admitted and the next run is a no-op. +if [ "${PRE_BALANCE:-0}" -gt "$(( ${PRE_FLOOR:-0} * 2 ))" ] && [ "${PRE_FLOOR:-0}" -gt 0 ]; then + NEED_TOP_UP=0 + ok "canister holds $((PRE_BALANCE / 1000000000000)) T against a $((PRE_FLOOR / 1000000000000)) T floor; no top-up needed" +fi +if [ "$NEED_TOP_UP" -eq 1 ] && ! TOP_UP_OUT="$(icp canister top-up backend --amount "$CYCLES_TOP_UP" 2>&1)"; then + printf '\n%s\n\n' "$TOP_UP_OUT" >&2 + die "could not top up the backend canister with $CYCLES_TOP_UP cycles — the reason is printed above. + If it says Insufficient cycles, the local identity is spent: \`icp cycles mint --icp 5\` + converts more, or stop and start the network for freshly seeded principals." +fi + +step "buyer allow-list" +# ⚠️ **A local gateway with a funded reserve and no allow-list REFUSES every +# buyer**, and the reason is not obvious from the refusal alone. The gate calls +# that state `unboundedGiveaway`: Stripe test payments are free and unlimited, so +# accepting them against a funded reserve with nobody listed is a cycles faucet. +# The canister refuses rather than warns. +# +# The catch locally: the buyer is a browser identity from the local Internet +# Identity, and its principal does not exist until someone signs in — so this +# script cannot know it. Two ways through: +# +# 1. Pass it in: BUYER_PRINCIPAL= scripts/local-dev-seed.sh +# 2. Sign in first, then run the command this step prints. +# +# Declaring the sandbox mode is separate and unconditional: a local deployment +# uses a Stripe TEST key, so `?false` is simply true here. It is also what makes a +# simulation divisor settable at all (`set_pricing_config` refuses one unless the +# mode is exactly `?false`), and it turns the webhook's livemode gate from +# "accept either" into a real check. +icp canister call backend set_expected_livemode '(opt false)' >/dev/null \ + || die "set_expected_livemode was refused" +ok "declared test mode (expect_livemode = ?false)" + +if [ -n "${BUYER_PRINCIPAL:-}" ]; then + ALLOW_OUT="$(icp canister call backend add_allowed_buyer "(principal \"${BUYER_PRINCIPAL}\")" 2>&1)" || true + case "$ALLOW_OUT" in + *already*) ok "buyer already allow-listed: $BUYER_PRINCIPAL" ;; + *ok*) ok "allow-listed buyer $BUYER_PRINCIPAL" ;; + *) printf ' \033[33m!\033[0m add_allowed_buyer was refused: %s\n' "$ALLOW_OUT" ;; + esac +else + printf ' \033[33m!\033[0m no BUYER_PRINCIPAL given, so NOBODY can buy yet.\n' + printf ' Sign in at the local frontend, copy the principal it shows, then:\n' + printf ' icp canister call backend add_allowed_buyer %s\n' "'(principal \"\")'" + printf ' Until then every create_order refuses with unboundedGiveaway.\n' +fi + +step "cycles reserve" +# Delivery is a TRANSFER out of the gateway's own cycles-ledger account. So the +# gateway needs cycles in that account, and it is a different pot from the gas balance +# topped up above — the comment there spells out the distinction, and this is the +# other half of it. +# +# `icp cycles transfer` is the mainnet procedure too: nothing creates cycles here, and +# there is deliberately no `mint_reserve` — it would mean holding ICP and an +# ICP-ledger dependency for one operator convenience. +# +# ⚠️ **Read the id here, where it is used.** Reading it in an earlier section that a +# later change deletes leaves this line on an unbound variable, which `set -u` turns +# into a hard stop three quarters of the way through the seed — after rates and tiers +# are already configured. That has happened; nothing catches it but running the seed. +BACKEND_ID="$(icp canister status backend --json | jq -r '.id')" +[ -n "$BACKEND_ID" ] && [ "$BACKEND_ID" != "null" ] || + die "could not read the backend canister id — is the network up and the canister deployed?" +RESERVE_TOP_UP=100t +# ⚠️ **Capture the reason, do not discard it.** This was `>/dev/null 2>&1`, so the +# warning below could only say "could not fund the cycles reserve" — and the actual +# message (`Insufficient cycles. Requested: 100_000_000_000_000, balance: …`) is the +# one thing that tells an operator whether to mint more or fix something. Third +# instance of the swallowed-reason pattern in this file, which is the file whose whole +# job is printing guidance. +RESERVE_OUT="" +RESERVE_TOPPED_UP=1 +# ⚠️ Initialised HERE, not only inside the failure branch. It is read further down, and +# being bound today depends on the `&&` conjunct order short-circuiting — reorder that +# chain and `set -u` hard-stops three quarters of the way through the seed, which is the +# hazard this file already documents above. +RESERVE_OBSERVED=0 +RESERVE_OBSERVED_BALANCE="" +if RESERVE_OUT="$(icp cycles transfer "$RESERVE_TOP_UP" "$BACKEND_ID" 2>&1)"; then + # ⚠️ **A funded reserve is not a SELLABLE reserve until the gateway looks.** Solvency + # is decided against a maintained lower bound on this account, and that bound only ever + # rises by observation: it starts at zero on a fresh install, and a + # transfer into the account is invisible to it. Without this call the seed produces + # a gateway that refuses every purchase with `#reserveShort{available = 0}` while + # the ledger holds 100 T — and nothing fails, compiles differently, or says why. + # + # The hourly sweep would eventually pick it up. "Eventually" is the wrong answer + # for a script whose whole job is to hand over a working gateway. + if ! icp canister call backend refresh_reserve '()' >/dev/null 2>&1; then + printf ' \033[33m!\033[0m funded the reserve but refresh_reserve failed.\n' + printf ' The gateway will refuse every sale until it observes the balance:\n' + printf ' icp canister call backend refresh_reserve %s\n' "'()'" + fi + echo "reserve: $RESERVE_TOP_UP cycles in the gateway's own ledger account, observed" +else + printf ' \033[33m!\033[0m could not TOP UP the cycles reserve by %s:\n' "$RESERVE_TOP_UP" + printf ' %s\n' "$RESERVE_OUT" + # ⚠️ **Name whose balance that is.** `icp cycles transfer` reports "insufficient + # funds. balance: N" for the SENDER — this identity's cycles-ledger account — but it + # prints under a heading about the reserve, so a reader takes N for the reserve's own + # balance. The two are wildly different (the reserve held 675 T while the sender had + # 53 T), and the confusion is structural: there are THREE balances here and the + # documentation names two. + printf ' ⚠️ that "balance:" is THIS IDENTITY'"'"'s cycles-ledger account (the sender),\n' + printf ' not the reserve and not the canister'"'"'s gas. Three balances:\n' + printf ' sender : icp cycles balance\n' + printf ' reserve : icp cycles balance --of-principal %s\n' "$BACKEND_ID" + printf ' gas : icp canister status backend\n' + # ⚠️ **Observe anyway — a failed top-up does NOT mean the account is empty.** The + # gateway's ledger account survives a canister reinstall (it is a separate canister), + # so after the documented reinstall-and-reseed loop it usually still holds the whole + # reserve while the floor has been reset to zero. Skipping the observation here left + # `availableToSell = 0` with 675 T sitting in the account, and the die below then + # blamed observation while this branch blamed funding. Neither was actionable. + RESERVE_TOPPED_UP=0 + # ⚠️ **Capture this failure too — fourth instance of the swallowed-reason pattern in + # this file, and the one that invalidates the die below.** That die tells the operator + # the account "is empty or unreachable, not merely unobserved", which is only + # established if the observation actually ran. A stopped canister, a renamed method or a + # network blip means "merely unobserved" is exactly what is happening — and the reader + # has been told it is not, with no trace that we even tried. + if OBSERVE_OUT="$(icp canister call backend refresh_reserve '()' 2>&1)"; then + RESERVE_OBSERVED=1 + # ⚠️ **Exit zero does NOT mean the floor was adopted.** `refresh_reserve` returns the + # ledger balance it read whether or not it adopted it: `reconcileReserve` returns early + # without touching the floor when the quiet window is unsatisfied, audits + # `reserve.reconcileSkipped`, and the call still succeeds. One escalated order makes + # that window unsatisfiable for the life of the canister, so this is a reachable state + # and not a hypothetical. Keep the returned figure — non-zero with nothing to sell is + # "observed but not adopted", a different diagnosis and a different fix from "empty". + RESERVE_OBSERVED_BALANCE="$(printf '%s' "$OBSERVE_OUT" | tr -d '_' | grep -oE '[0-9]+' | head -1 || true)" + printf ' observed the account anyway — if it already held cycles, the floor is now set.\n' + else + printf ' \033[33mand refresh_reserve ALSO failed, so the floor is not observed either:\033[0m\n' + printf ' %s\n' "$OBSERVE_OUT" + fi + printf ' If the account really is empty, orders will be created and PAID and then\n' + printf ' retry delivery forever. Fund it by hand, then observe:\n' + printf ' icp cycles transfer %s %s\n' "$RESERVE_TOP_UP" "$BACKEND_ID" + printf ' icp canister call backend refresh_reserve %s\n' "'()'" +fi + +step "admission gate" +# A $10 floor and a $100 ceiling. One pair governs presets AND custom amounts — do not +# add a custom-amount-specific limit. +# +# ⚠️ **One open order per principal, the shipped value — not a dev convenience.** It used +# to be 3 here, which meant local runs never exercised the product decision: a buyer who +# wants another order cancels the one they have. Testing that loop matters more than the +# convenience of holding three orders open, and a tester who wants the old behaviour can +# call `set_gate_config` themselves. +# +# It is only safe because `Orders.openOrderCount` stops counting an order past its own +# deadline: at a cap of 1 without that, one missed expiry webhook locks a buyer out +# permanently rather than for the session's 35 minutes. +icp canister call backend set_gate_config \ + '(record { maxOpenOrdersPerPrincipal = 1 : nat; + maxPurchaseUsdCents = 10_000 : nat; + minPurchaseUsdCents = 1_000 : nat; + minCanisterCycles = 5_000_000_000_000 : nat })' \ + >/dev/null || die "set_gate_config failed" + +# Re-read the balance and compare it to the floor the gate now holds, rather than +# trusting the top-up's exit code. +CYCLES="$(icp canister call backend cycles_status '()' 2>/dev/null)" +BALANCE="$(printf '%s' "$CYCLES" | grep -oE 'balance = [0-9_]+' | tr -d '_' | grep -oE '[0-9]+$' || echo 0)" +FLOOR="$(printf '%s' "$CYCLES" | grep -oE 'floor = [0-9_]+' | tr -d '_' | grep -oE '[0-9]+$' || echo 0)" +if [ "${BALANCE:-0}" -le "${FLOOR:-0}" ]; then + die "the canister still holds ${BALANCE:-0} cycles against a ${FLOOR:-0} floor, so + every purchase is still refused. The top-up reported success, so check: + icp canister call backend cycles_status '()'" +fi +ok "cycles floor kept at $((FLOOR / 1000000000000)) T; canister holds $((BALANCE / 1000000000000)) T" + +# ⚠️ **`unboundedGiveaway` is not a seeding failure, and reporting it as one sent two +# readers hunting a misconfiguration that was not there.** It is the faucet guard +# saying the allow-list is empty — the one step this script cannot do for you, because +# the principal it needs is the one your browser signs in with. Every other refusal here +# IS a seeding failure and still dies. +VERDICT="$(icp canister call backend can_purchase '(1_000 : nat)' 2>&1)" +case "$VERDICT" in + *"variant { ok }"*) + ok "a \$10 purchase is admitted — the gateway is sellable" + ;; + *unboundedGiveaway*) + printf ' \033[33m!\033[0m seeding is complete, and NOBODY can buy yet: the allow-list is empty.\n' + printf ' Sign in at http://frontend.local.localhost:%s/ , copy the principal it\n' "$GATEWAY_PORT" + printf ' shows, then run:\n' + printf ' icp canister call backend add_allowed_buyer '"'"'(principal "")'"'"'\n' + ;; + *) + die "the gateway refuses a \$10 purchase for a reason seeding should have fixed: + $VERDICT" + ;; +esac + +# ⚠️ `can_purchase` above does NOT cover solvency, and cannot: it is a query, and +# reading the reserve is what the gate does synchronously inside `create_order`. So +# the reserve is verified separately, against the same figure the gate decides on — +# otherwise a missing `refresh_reserve` sails past every check in this script and +# surfaces as a refusal on the first real order. +# ⚠️ **Two faults here, and the second is worse than a swallowed reason.** This read +# used to be `2>/dev/null` with `|| echo 0` on the parse — so ANY failure became +# `AVAILABLE=0` and was reported as the one cause below. A wrong VALUE substituted for +# an error is worse than a hidden message, because the message that follows is +# confident and specific. +# +# The die's cause is the dominant one and stays. But the same swallow hid "the method +# does not exist" — which is exactly what a rename produces, and this file has just been +# through one — plus "the canister is not deployed" and "the network is down". So the +# call is checked separately from the parse, and each says which happened. +if ! RESERVE="$(icp canister call backend reserve_status '()' 2>&1)"; then + printf '\n%s\n\n' "$RESERVE" >&2 + die "could not read reserve_status — the reason is printed above. A missing method means + this script is older than the canister it is seeding; anything else is the network + or the deployment." +fi +AVAILABLE="$(printf '%s' "$RESERVE" | grep -oE 'availableToSell = [0-9_]+' | tr -d '_' | grep -oE '[0-9]+$' || true)" +if [ -z "$AVAILABLE" ]; then + printf '\n%s\n\n' "$RESERVE" >&2 + die "reserve_status answered but had no availableToSell field — the response is above. + The shape changed and this script did not." +fi +# ⚠️ **Three outcomes, not two, because the claim each makes is different.** Asserting +# "not merely unobserved" is only sound when the observation ran; if it did not, that is +# precisely what is happening. +if [ "$AVAILABLE" -eq 0 ] && [ "$RESERVE_TOPPED_UP" -eq 0 ] && [ "$RESERVE_OBSERVED" -eq 0 ]; then + die "the gateway will sell 0 cycles, and BOTH the top-up and the observation failed — + so this says nothing about whether the account holds cycles. Both errors are printed + above; fix the reachable one first, then: + icp canister call backend refresh_reserve '()' + icp canister call backend reserve_status '()'" +fi +if [ "$AVAILABLE" -eq 0 ] && [ "$RESERVE_TOPPED_UP" -eq 0 ] && + [ -n "$RESERVE_OBSERVED_BALANCE" ] && [ "$RESERVE_OBSERVED_BALANCE" -gt 0 ]; then + # ⚠️ Observed a NON-ZERO balance and still nothing to sell: the read succeeded and the + # floor was not adopted. "Fund it" is the useless answer here — the account is full. + die "the gateway will sell 0 cycles, and the account holds $RESERVE_OBSERVED_BALANCE. + So the balance was OBSERVED and NOT ADOPTED — the quiet window is unsatisfied, which + one escalated order can make permanent. Do not fund it; that changes nothing. Look for + reserve.reconcileSkipped in the audit log and an order stuck with an unsettled + delivery: + icp canister call backend pending_deliveries '()' + icp canister call backend audit_log '(null, 50 : nat)'" +fi +if [ "$AVAILABLE" -eq 0 ] && [ "$RESERVE_TOPPED_UP" -eq 0 ] && [ -z "$RESERVE_OBSERVED_BALANCE" ]; then + # ⚠️ **The observation succeeded but its output carried no number, so it establishes + # nothing about the account.** Falling through to the "empty" message below would be the + # same claim-not-established defect this whole section exists to remove, in the sliver + # where the parse — not the call — is what failed. + die "the gateway will sell 0 cycles. The top-up failed and the observation SUCCEEDED but + printed no balance this script could read, so whether the account holds cycles is + UNKNOWN — do not act on either answer. Its output was: + $OBSERVE_OUT + Read the balance directly, then decide: + icp cycles balance --of-principal $BACKEND_ID" +fi +if [ "$AVAILABLE" -eq 0 ] && [ "$RESERVE_TOPPED_UP" -eq 0 ]; then + # The observation ran and parsed a zero, so the account really is empty. + die "the gateway will sell 0 cycles. The top-up FAILED and the observation read an EMPTY + account, so it is genuinely empty rather than unobserved. Read the transfer error above + (⚠️ its \"balance:\" is the SENDER's), fund the account, then: + icp cycles transfer $RESERVE_TOP_UP $BACKEND_ID + icp canister call backend refresh_reserve '()'" +fi +if [ "$AVAILABLE" -eq 0 ]; then + # ⚠️ The top-up ran, so the account holds cycles and the floor did not take them. + # "Run refresh_reserve" is only the fix if the observation was never made — and the + # seed just made it. If it skipped, running it again skips again. + die "the gateway will sell 0 cycles even though the top-up SUCCEEDED. The floor did not + take the balance, and the seed already ran the observation — so re-running it is not + the fix unless the first call errored. Check which: + icp canister call backend reserve_status '()' # reserveObservedAtNs recent? + icp canister call backend pending_deliveries '()' # an unsettled delivery freezes + icp canister call backend audit_log '(null, 50 : nat)' # reserve.reconcileSkipped + An unsettled delivery makes the quiet window unsatisfiable; settle or escalate it." +fi +ok "reserve floor observed; $((AVAILABLE / 1000000000000)) T available to sell" + +printf '\n\033[32m✓ local gateway seeded\033[0m\n' +cat <")' + Or re-run this script with BUYER_PRINCIPAL=. Without it the + gateway refuses every buyer with unboundedGiveaway, because a funded + reserve plus free Stripe test payments plus an empty list is a faucet. + - PAYING needs BOTH Stripe secrets. There are no Payment Links any more: + the canister creates a Checkout Session per order and sets + client_reference_id on it through the API, so nothing has to be + configured in the Dashboard. + + 1. A restricted API key (rk_...) with Checkout Sessions = Write (which + includes the read the recovery sweep needs) and everything else None. + Put it in scripts/.local-dev.env (gitignored, sourced by this script) + rather than on a command line, then re-run. + 2. A signed webhook to deliver: scripts/stripe-dev.sh starts the + forwarder and provisions the signing secret from that session. + + Check both with stripe_api_key_status and webhook_secret_status. + + One thing in a paying run that looks like a bug and is not: + - The session expires 35 minutes after creation, enforced by Stripe. The + pay button disappears at the deadline, and it goes before the + checkout.session.expired webhook lands, because the UI renders expiry + from expiresAtNs rather than from the status. + + ⚠️ The CMC rate goes stale in 15 minutes. Re-run with --rate-only. +NOTES diff --git a/scripts/release-build.sh b/scripts/release-build.sh index 310b27a..412dece 100755 --- a/scripts/release-build.sh +++ b/scripts/release-build.sh @@ -13,7 +13,15 @@ out="${1:-release}" # Build from a clean artifact cache — determinism is asserted, not assumed. rm -rf .icp/cache/artifacts -icp build +# ⚠️ **Name the two release canisters.** A bare `icp build` builds every canister in the +# LOCAL environment, which includes the `xrc` mock — and that mock's wasm is a fetched, +# gitignored artifact (`test/integration/wasm/`), so it is absent from the `git archive` +# context this whole procedure is built on. A bare build therefore fails inside the +# container, on every commit, with `failed to read wasm file`. Measured: that is why +# `docs/SANDBOX-TESTPLAN.md` could say the reproducible-build gate had never run. +# The `ic` environment declares exactly `[backend, frontend]`; naming them here keeps +# that true without depending on `-e ic`, which would want network access. +icp build backend frontend mkdir -p "$out" cp .icp/cache/artifacts/backend "$out/backend.wasm" @@ -21,7 +29,15 @@ cp .icp/cache/artifacts/frontend "$out/frontend.wasm" # The committed interface ships alongside the module it is embedded in. cp src/backend/dist/backend.did "$out/backend.did" -(cd "$out" && sha256sum backend.wasm frontend.wasm backend.did > MODULE-HASHES.txt) +# ⚠️ **The architecture goes IN the file, because `backend.wasm` depends on it.** The same +# commit gives three different hashes on darwin/arm64 native, linux/arm64 container and +# linux/amd64 container, so a hash without its build architecture cannot be compared. +# `uname -m` is read here rather than passed in, so it reports where the build really +# happened. A `#` comment is safe: `sha256sum -c` and `shasum -c` both skip it. +(cd "$out" && { + printf '# build arch: %s\n' "$(uname -m)" + sha256sum backend.wasm frontend.wasm backend.did +} > MODULE-HASHES.txt) echo echo "== expected module hashes (publish MODULE-HASHES.txt with the release) ==" diff --git a/scripts/release-notes.py b/scripts/release-notes.py new file mode 100755 index 0000000..1d571dc --- /dev/null +++ b/scripts/release-notes.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""Render `release/NOTES.md` — the release body — from the build output. + +The notes are **generic and the same shape every release**: what changed is a link to the +CHANGELOG at this tag, not a copy of it. Embedding it made the entry half the page and +put the release-specific prose above the hashes, which are what a release page is for. + +⚠️ **`MODULE-HASHES.txt` is NOT reformatted.** A verifier rebuilds the tag and diffs their +own `MODULE-HASHES.txt` against the published one, and `shasum -c` reads that exact +format, so the notes carry it **verbatim** in a code block. The table above it is for +people; the block is what tooling and a diff use. Turning the artifact itself into a +table would break both. + +⚠️ **The notes say which hash is worth checking.** `frontend.wasm` is the pinned recipe's +pre-built certified-assets canister — the same module for every project that uses it — so +comparing it proves nothing about the page anyone is served. Someone who matches it and +concludes the frontend is verified has verified the recipe. The notes point that reader at +the frontend's STATE hash instead, which is the one that describes the page. + +⚠️ **The verify instructions are generic, and must stay that way.** The same rendered +text ships with every release, including tags cut before a given helper existed, so it +offers the repo's check and the bare verifier side by side and lets the checked-out tree +decide which is available. Naming the release a script arrived in would make every future +set of notes carry a fact about the past that nothing checks. + +⚠️ **The frontend state hash is published, and it has to be.** The procedure tells a +verifier to build a TAG, so they need a number that fixes what that tag produced. +Without it the only thing linking a tag to the running canister is our word about which +release is deployed — and a `main` build reports a mismatch that means nothing, since +`main` moves on after a release. + +Usage: scripts/release-notes.py [hashes-file] [out-file] [frontend-hash-file] +""" + +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from _github_anchor import anchor, self_test as anchor_self_test # noqa: E402 +from _recipe_pin import pin_in, self_test as pin_self_test # noqa: E402 + +# ⚠️ **Written out, not left as ``.** The notes' audience is precisely the +# people who do not know this canister's id, and it is a stable identifier rather than a +# perishable figure — unlike a hash, which is why no hash is hardcoded anywhere. +BACKEND = "saz2a-riaaa-aaaay-aadha-cai" +FRONTEND = "shy4u-4qaaa-aaaay-aadhq-cai" +REPO = "https://github.com/marc0olo/cyclepay" + +WHAT = { + "backend.wasm": "the gateway canister — **this is the hash to check**", + "frontend.wasm": "the pinned recipe's certified-assets canister (see the note below)", + "backend.did": "the Candid interface, as embedded in the backend module", +} + + +def changelog_section(version: str, text: str) -> str: + """The body of `## `, up to the next `## ` heading.""" + lines = text.split("\n") + head = f"## {version}" + try: + start = lines.index(head) + 1 + except ValueError: + return "" + body = [] + for line in lines[start:]: + if line.startswith("## "): + break + body.append(line) + return "\n".join(body).strip("\n") + + +def _self_test() -> None: + """Unconditional: the extraction is the one part with a wrong-answer failure mode — + a heading whose prefix matches another version, or running off into the next section.""" + sample = "# Changelog\n\n## Unreleased\n\n## 1.2.0\nnew stuff\n\nmore\n\n## 1.2.0-rc.1\nold\n" + assert changelog_section("1.2.0", sample) == "new stuff\n\nmore", "exact-heading match failed" + assert changelog_section("1.2.0-rc.1", sample) == "old", "suffix version failed" + assert changelog_section("9.9.9", sample) == "", "absent version should be empty" + assert changelog_section("Unreleased", sample) == "", "empty section should be empty" + + +def main() -> int: + _self_test() + anchor_self_test() + pin_self_test() + if len(sys.argv) < 2: + sys.exit("usage: scripts/release-notes.py [hashes-file] [out-file]") + version = sys.argv[1].lstrip("v") + hashes_path = Path(sys.argv[2] if len(sys.argv) > 2 else "release/MODULE-HASHES.txt") + out_path = Path(sys.argv[3] if len(sys.argv) > 3 else "release/NOTES.md") + fe_path = Path(sys.argv[4] if len(sys.argv) > 4 else "release/FRONTEND-STATE-HASH.txt") + + if not hashes_path.exists(): + sys.exit(f"error: {hashes_path} is missing — the build has not run.\n" + f" scripts/release.sh {sys.argv[1]}") + # Required, not optional: see the module docstring. Notes without it publish a + # backend a verifier can pin and a frontend they cannot. + if not fe_path.exists(): + sys.exit(f"error: {fe_path} is missing — the frontend hash has not been computed.\n" + f" scripts/release.sh {sys.argv[1]}") + fe_hash = fe_path.read_text().strip() + if not re.fullmatch(r"[0-9a-f]{64}", fe_hash): + sys.exit(f"ABORT: {fe_path} holds no 64-char hex hash: {fe_hash!r}") + raw = hashes_path.read_text() + # ⚠️ Abort rather than degrade. Publishing "built on unknown" two lines above the rule + # telling a verifier to compare like for like would be worse than failing here. + arch = next((l.split(":", 1)[1].strip() for l in raw.split("\n") if l.startswith("# build arch:")), None) + if arch is None: + sys.exit(f"ABORT: {hashes_path} carries no '# build arch:' line — a hash without its" + " architecture cannot be compared, so these notes would mislead") + rows = [(n, h) for h, n in (l.split() for l in raw.split("\n") if l and not l.startswith("#"))] + if not rows: + sys.exit(f"ABORT: no hashes parsed out of {hashes_path} — refusing to publish empty notes") + + # ⚠️ **The changelog comes from the REF when there is one.** Generating notes for a + # tag from the working tree's changelog is how a backfill silently publishes a later + # version's text. Falls back to the working tree for `HEAD` and bare commits. + import subprocess + ref = sys.argv[1] + at_ref = subprocess.run(["git", "show", f"{ref}:CHANGELOG.md"], capture_output=True, text=True) + changelog = at_ref.stdout if at_ref.returncode == 0 else Path("CHANGELOG.md").read_text() + # ⚠️ Validated, not inlined. The link must not 404, and `release.sh` refuses a version + # with no entry — but the notes carry a pointer so the changelog stays the one place + # that describes a release. + if not changelog_section(version, changelog): + sys.exit(f"ABORT: CHANGELOG.md has no '## {version}' section — the notes would link to nothing") + # ⚠️ **The pin comes from the REF too.** The state hash is frozen per certified-assets + # release, so the number means nothing without the release it was computed under — + # and reading that from the working tree would publish the CURRENT pin beside a hash + # computed under the tag's. Same fallback as the changelog, for HEAD and bare commits. + at_ref_yaml = subprocess.run(["git", "show", f"{ref}:icp.yaml"], capture_output=True, text=True) + icp_yaml = at_ref_yaml.stdout if at_ref_yaml.returncode == 0 else Path("icp.yaml").read_text() + # Imported from `_recipe_pin`, the same reader `check-frontend-hash.py` picks the + # verifier with, so the published release and the computed hash cannot disagree. + ca_version = pin_in(icp_yaml) + if ca_version is None: + sys.exit("ABORT: no `@dfinity/static-site@vX.Y.Z` pin found — the frontend hash would" + " be published with no release to interpret it under") + # ⚠️ The SAME anchor rule `check-doc-links.py` verifies the docs with — imported, not + # restated, because a second copy of it is exactly the class of duplication this repo + # keeps finding, and this is the copy nothing gates: `release/NOTES.md` is build + # output that no check reads. + frag = anchor(version) + changes = f"**What changed:** [`CHANGELOG.md`, {version}]({REPO}/blob/v{version}/CHANGELOG.md#{frag})" + + table = "\n".join(f"| `{n}` | `{h}` | {WHAT.get(n, '')} |" for n, h in rows) + out = f"""{changes} + +## Module hashes + +Built in the pinned container on **`{arch}`**. The same commit produces different bytes on +a different architecture, so compare like for like. + +| artifact | sha256 | what it is | +|---|---|---| +{table} + +Verbatim, as `shasum -c` and a diff against your own build read it: + +``` +{raw.strip()} +``` + +## Verify this yourself + +```bash +git clone --recurse-submodules {REPO} && cd cyclepay && git checkout v{version} +scripts/reproducible-build.sh v{version} + +# paste the block above into release/PUBLISHED.txt, then check YOUR binaries +# against THOSE hashes — this fails loudly and names the file that differs +cd release && shasum -a 256 -c PUBLISHED.txt + +# and the canister must report the same backend hash +icp canister status {BACKEND} -n ic -p +``` + +All three must agree: your build, the hashes above, and the canister. + +## Frontend state hash + +``` +{fe_hash} certified-assets v{ca_version} +``` + +One SHA-256 over everything the frontend canister serves: every asset's bytes in every +encoding, its `content_type` and response headers, and the redirect rules in match order. +Built from `src/frontend/dist` at this tag. + +⚠️ **`frontend.wasm` above is not a meaningful check.** It is the pinned recipe's +pre-built certified-assets canister — identical for every project using it, and unrelated +to the page anyone is served. This state hash is the one that describes the page. + +Build this tag's frontend, then compare, either way round. Both need a Rust toolchain: +the hash is defined by certified-assets' own preparation code, so computing it means +running that project's verifier, built once from source and then cached. + +```bash +# from the v{version} checkout above +npm --prefix src/frontend ci && npm --prefix src/frontend run build + +# (a) the repo's check, if this tree has it: it reads the pin, builds a matching +# verifier, confirms the canister runs that release, and compares for you +scripts/check-frontend-hash.py -e ic + +# (b) the verifier on its own, which works in any tree and depends on nothing of ours +cargo install --git https://github.com/dfinity/certified-assets \\ + --tag v{ca_version} --locked state-hash-cli +state-hash src/frontend/dist +icp canister call {FRONTEND} state_hash '()' -n ic -o hex | tail -c 65 +``` + +Three things must agree, as with the backend: your build, the hash above, and the live +canister. ⚠️ **Build the tag, not `main`** — `main` moves on after a release, and one +changed HTML comment is enough to change this number. + +`docs/VERIFY.md` has the rest, including what only a buyer can check and the known limits. +""" + out_path.write_text(out) + print(f" wrote {out_path} ({len(out.splitlines())} lines)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..32fd7d4 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# Release the backend: build in the pinned container, install THAT artifact, and gate on +# the deployed module hash matching what was built. +# +# scripts/release.sh # build + print hashes +# scripts/release.sh --install [icp args] # build, install, verify +# +# ⚠️ **Why this exists as one command.** The bytes that get published and the bytes that +# get installed have to be the same bytes, and `icp deploy` cannot give you that: it +# REBUILDS on the host, so a container build followed by `icp deploy` installs something +# nobody published. `icp canister install --wasm` installs the file. +# +# ⚠️ **The verify step is not optional here, by construction.** It is the step that gets +# skipped when it is a separate instruction, and skipping it is indistinguishable from +# passing it. +set -euo pipefail +cd "$(dirname "$0")/.." + +ref="${1:-}" +[ -n "$ref" ] || { echo "usage: scripts/release.sh [--install [icp args...]]" >&2; exit 2; } +shift + +install=false +if [ "${1:-}" = "--install" ]; then install=true; shift; fi +# Everything after --install goes to icp verbatim: -e ic, --identity , --yes. +icp_args=("$@") + +# ⚠️ **A version with no CHANGELOG entry is not a release.** Checked against the tree +# being built, not the working copy, so the entry is part of the tagged commit and cannot +# be added afterwards — the same reason the hashes come from `git archive`. Only refs that +# look like versions are checked: building HEAD or a bare commit to inspect hashes is a +# normal thing to do and does not need an entry. +version="${ref#v}" +is_version=false +if printf '%s' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+'; then + is_version=true + # -F -x: the version contains dots, which as a regex would match any character — + # `0.1.0` would accept a `## 0X1X0` heading. Fixed string, whole line. + if ! git show "$ref:CHANGELOG.md" 2>/dev/null | grep -qxF "## $version"; then + echo "error: CHANGELOG.md in $ref has no '## $version' section." >&2 + echo " Add the entry, commit it, and move the tag — a published hash with no" >&2 + echo " changelog leaves nobody able to say what changed." >&2 + exit 1 + fi + echo "changelog: '## $version' found in $ref" +fi + +scripts/reproducible-build.sh "$ref" release + +# ⚠️ **The frontend is built from the REF, in a worktree, not from the working tree.** +# The backend gets this for free (`reproducible-build.sh` builds a `git archive` of the +# ref); the frontend has no such step, and a working-tree build would publish whatever +# is checked out. That is not hypothetical: `main` one commit past a tag changed HTML +# comments in `index.html`, which changes the hash, so a release cut from a tag would +# have published a number no verifier of that tag could reproduce. +# +# The hash needs `dist`, and nothing else builds it this early: `icp deploy frontend` +# does, but that is step 5, long after these notes are published. +fe_tmp="$(mktemp -d)" +fe_tree="$fe_tmp/frontend-at-ref" +# `git worktree remove` deletes the worktree but not the temp dir holding it. +cleanup() { git worktree remove --force "$fe_tree" >/dev/null 2>&1 || true; rm -rf "$fe_tmp"; } +trap cleanup EXIT +# A run killed before its trap fired leaves a registration pointing at a deleted +# temp dir; pruning first keeps those from accumulating in `git worktree list`. +git worktree prune +git worktree add -q --detach "$fe_tree" "$ref" +# ⚠️ **`cd`, not `npm --prefix`.** With a prefix under `mktemp -d` — on macOS a +# `/var/folders/...` path that resolves through a symlink — `npm ci` takes the package +# name from the directory instead of `package.json` and dies with +# `Missing: frontend@0.1.0 from lock file`, though the package is `cyclepay-frontend`. +# Measured both ways: the same command against a `/private/tmp` path succeeds, so it is +# the path shape rather than the flag, and `--prefix` elsewhere in the repo (always a +# relative path inside it) is unaffected. A subshell keeps the cwd change local. +(cd "$fe_tree/src/frontend" && npm ci && npm run build) +# --print takes the TREE ROOT, so the recipe pin and the `dist` are the same ref's. +scripts/check-frontend-hash.py --print "$fe_tree" > release/FRONTEND-STATE-HASH.txt +echo "frontend: $(cat release/FRONTEND-STATE-HASH.txt) (state hash, from $ref)" + +# ⚠️ **Notes only for a version ref.** This script documents building `HEAD` or a bare +# commit to inspect hashes, and the changelog gate above deliberately skips those — but +# `release-notes.py` aborts on a ref with no `## ` changelog section, so for +# years the documented inspection build could not finish. An inspection build wants the +# hashes, not release notes. +if $is_version; then + scripts/release-notes.py "$ref" +else + echo "notes: skipped — $ref is not a version ref (hashes above are the point)" +fi + +expected="$(awk '/backend\.wasm/{print $1}' release/MODULE-HASHES.txt)" +[ -n "$expected" ] || { echo "error: no backend.wasm hash in release/MODULE-HASHES.txt" >&2; exit 1; } + +echo +echo "built: $expected" + +if ! $install; then + echo + # ⚠️ Not for a non-version ref: no notes were written for it, so this would send an + # operator to publish whatever `release/NOTES.md` happens to hold from a previous run. + if $is_version; then + echo "Publish release/NOTES.md as the release body — it carries the hashes verbatim," + echo "the architecture they were built on, and how to reproduce them." + echo "Then install and gate in one step:" + else + echo "Inspection build of $ref. To install and gate a release, use a version ref:" + fi + echo " scripts/release.sh $ref --install -e ic --identity " + exit 0 +fi + +echo "installing release/backend.wasm ..." +icp canister install backend --wasm release/backend.wasm --mode upgrade "${icp_args[@]}" + +# ⚠️ Read the hash back from the canister, never from the build. Comparing the build to +# itself is the failure mode this gate exists to prevent. +deployed="$(icp canister status backend "${icp_args[@]}" | awk -F'0x' '/Module hash/{print $2}' | tr -d '[:space:]')" +echo +echo "built: $expected" +echo "deployed: ${deployed:-}" + +if [ "$deployed" = "$expected" ]; then + echo "✓ the canister is running the bytes that were built" +else + echo "✗ MISMATCH — the canister is not running the published bytes." >&2 + echo " Do not publish the deployed hash instead. Re-install release/backend.wasm." >&2 + exit 1 +fi diff --git a/scripts/reproducible-build.sh b/scripts/reproducible-build.sh index 3bb782a..674e931 100755 --- a/scripts/reproducible-build.sh +++ b/scripts/reproducible-build.sh @@ -5,10 +5,21 @@ # # Builds (default HEAD) inside the pinned container and writes # backend.wasm / frontend.wasm / backend.did / MODULE-HASHES.txt to -# (default release/). The context is `git archive ` — the committed tree -# only — so the resulting hashes are a pure function of the ref. Verifiers run -# the same command on the release tag and diff MODULE-HASHES.txt against the -# published one and against `icp canister status --public`. +# (default release/). The context is the committed tree only — so the resulting +# hashes are a pure function of the ref. Verifiers run the same command on the +# release tag and diff MODULE-HASHES.txt against the published one and against +# `icp canister status --public`. +# +# ⚠️ **`git archive` does NOT include submodules, and the pinned crypto is one.** +# A plain `git archive | docker build` produces a context whose +# `vendor/icp-seeding-secrets-poc/` is empty, and the build then fails inside the +# container on a `mops` path dependency that resolves to nothing. So the context is +# assembled explicitly below: the superproject tree, then the submodule tree overlaid at +# its own path. +# +# ⚠️ **The submodule commit is read FROM THE REF**, via `:` — not from whatever +# the working tree happens to have checked out. That is what preserves the property this +# whole script exists for: same ref in, same bytes out, regardless of local state. set -euo pipefail cd "$(dirname "$0")/.." @@ -20,9 +31,42 @@ if [ "$ref" = "HEAD" ] && ! git diff-index --quiet HEAD --; then echo "warning: working tree is dirty — building committed HEAD ($commit); local changes are NOT included" >&2 fi +SUBMODULE="vendor/icp-seeding-secrets-poc" +# `ls-tree` gives the gitlink's object id — the submodule commit this ref pins. +sub_commit="$(git ls-tree "$ref" "$SUBMODULE" | awk '{print $3}')" +[ -n "$sub_commit" ] || { + echo "error: $ref records no submodule at $SUBMODULE" >&2 + exit 1 +} +# It has to be a commit this machine actually has. A fresh clone without +# --recurse-submodules does not, and the failure would otherwise be an obscure tar error. +git -C "$SUBMODULE" cat-file -e "${sub_commit}^{commit}" 2>/dev/null || { + echo "error: submodule commit $sub_commit is not available locally. Run:" >&2 + echo " git submodule update --init --recursive" >&2 + exit 1 +} + echo "building $ref ($commit) in the pinned container..." -git archive --format=tar "$ref" | - docker build -f Dockerfile.release --output "type=local,dest=$out" - +echo " with $SUBMODULE at $sub_commit" + +ctx="$(mktemp -d)" +trap 'rm -rf "$ctx"' EXIT +git archive --format=tar "$ref" | tar -x -C "$ctx" +# The gitlink leaves an empty directory (or none); fill it from the submodule's own tree. +mkdir -p "$ctx/$SUBMODULE" +git -C "$SUBMODULE" archive --format=tar "$sub_commit" | tar -x -C "$ctx/$SUBMODULE" + +# ⚠️ **The platform is pinned, because the output depends on it.** Without `--platform` +# the bytes depend on the releaser's chip, and a verifier on a different one fails the +# gate for no reason. `linux/amd64` is the default because it is what CI and most +# verifiers run; on Apple Silicon it builds under emulation — slower, same bytes. +# Override only to publish for a different target, and publish the architecture with the +# hashes either way (`release-build.sh` records it). +platform="${RELEASE_PLATFORM:-linux/amd64}" +echo " on platform $platform" + +tar -c -C "$ctx" . | + docker build --platform "$platform" -f Dockerfile.release --output "type=local,dest=$out" - echo echo "== $ref ($commit) ==" diff --git a/scripts/seal-secret.sh b/scripts/seal-secret.sh new file mode 100755 index 0000000..6d8687a --- /dev/null +++ b/scripts/seal-secret.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# Provision a Stripe secret into the backend, sealed with vetKD. +# +# Usage: +# scripts/seal-secret.sh api-key [environment] # reads STRIPE_API_KEY +# scripts/seal-secret.sh webhook-secret [environment] # reads STRIPE_WEBHOOK_SECRET +# +# `environment` is an icp-cli environment name and defaults to `local`. Pass `ic` for +# mainnet. +# +# ⚠️ **THE MASTER KEY IS DERIVED FROM THE ENVIRONMENT, NEVER TYPED.** +# +# Mainnet and a local network both have a vetKD key called `key_1`, backed by different +# master keys — necessarily, since a local network cannot hold mainnet's master secret. So +# the key *name* does not identify the key, and sealing against the wrong one produces a +# ciphertext **nobody can ever open**, with nothing to notice until the canister tries. +# +# That is why this mapping lives in code rather than in a flag a human passes: +# +# environment `ic` -> mainnet master key +# anything else -> pocketic master key (a managed local network IS PocketIC) +# +# The sealer refuses to default `--source`, so if this script ever stops passing it the +# result is an error rather than a wrong guess. +# +# ⚠️ **The secret is read from the environment, never from an argument.** A value on the +# command line lands in shell history and in CI logs — the very exposure sealing exists to +# close. Closing it in transit while opening it in `~/.zsh_history` would be theatre. +set -euo pipefail + +cd "$(dirname "$0")/.." + +die() { printf '\033[31m✗\033[0m %s\n' "$1" >&2; exit 1; } +note() { printf '\033[36m·\033[0m %s\n' "$1"; } + +WHICH="${1:-}" +ENVIRONMENT="${2:-local}" + +case "$WHICH" in + api-key) + METHOD="set_stripe_api_key"; STATUS="stripe_api_key_status" + VAR="STRIPE_API_KEY"; WHAT="restricted Stripe API key" + ;; + webhook-secret) + METHOD="set_webhook_secret"; STATUS="webhook_secret_status" + VAR="STRIPE_WEBHOOK_SECRET"; WHAT="Stripe webhook signing secret" + ;; + *) + die "usage: scripts/seal-secret.sh [environment]" + ;; +esac + +# The environment decides the master key. See the header — this is the whole point. +case "$ENVIRONMENT" in + ic) SOURCE="mainnet" ;; + *) SOURCE="pocketic" ;; +esac + +# One flag array, so every icp call below targets the same environment and `local` stays +# the flagless default the rest of scripts/ uses. +ENV_FLAG=() +[ "$ENVIRONMENT" = "local" ] || ENV_FLAG=(-e "$ENVIRONMENT") + +# ⚠️ **SET-BUT-EMPTY is a different state from UNSET, and conflating them sealed the +# wrong key.** `read -rs` prints nothing as you type, so a paste that silently fails +# leaves the variable set to "". This used to fall through to `scripts/.local-dev.env` +# for ANY environment, so an empty paste during a mainnet provisioning sealed the +# SANDBOX key to the mainnet canister — and printed the same byte count, the same +# `isSet = true` and the same `generation = 1`. Both keys are 107 characters, so even +# the length disclosure could not tell them apart. Nothing anywhere reported it. +if [ -n "${!VAR+set}" ] && [ -z "${!VAR}" ]; then + die "$VAR is set but EMPTY — the paste did not take. + Nothing was sealed. Re-run the read and check the length it echoes before continuing. + Refusing to fall back to scripts/.local-dev.env: for '$ENVIRONMENT' that would seal a + different key than the one you meant, indistinguishably." +fi + +# ⚠️ **The file is for LOCAL development and is only read for a local environment.** It +# holds sandbox values; a mainnet or any other named environment must get its secret from +# the caller, so a stale local value can never reach a deployment it was not meant for. +if [ "$ENVIRONMENT" = "local" ] && [ -z "${!VAR:-}" ] && [ -f scripts/.local-dev.env ]; then + # shellcheck disable=SC1091 + . scripts/.local-dev.env +fi + +SECRET="${!VAR:-}" +if [ -z "$SECRET" ]; then + if [ "$ENVIRONMENT" = "local" ]; then + die "$VAR is unset. Put it in scripts/.local-dev.env (gitignored) or export it. + Do NOT pass it as an argument — it would land in shell history and CI logs." + fi + die "$VAR is unset, and scripts/.local-dev.env is NOT consulted for '$ENVIRONMENT'. + Export it for this shell instead: + printf 'value: '; read -rs $VAR; echo \"\${#$VAR} chars\"; export $VAR + Do NOT pass it as an argument — it would land in shell history and CI logs." +fi + +command -v node >/dev/null || die "node is required (>=18)" +if [ ! -d scripts/seal/node_modules ]; then + note "installing the sealer's dependencies (first run only)" + npm --prefix scripts/seal ci --silent 2>/dev/null || npm --prefix scripts/seal install --silent +fi + +# ── the canister id, read from the environment we are about to call ────────────────── +# Read rather than assumed: the canister id is a derivation input, so sealing to one +# canister and calling another produces a ciphertext the target cannot open. +CANISTER_ID="$(icp canister status backend "${ENV_FLAG[@]}" -i 2>/dev/null || true)" +[ -n "$CANISTER_ID" ] || die "could not read the backend canister id for environment '$ENVIRONMENT'. + Is it deployed? icp deploy${ENV_FLAG[*]:+ ${ENV_FLAG[*]}}" + +# ── seal, then send ───────────────────────────────────────────────────────────────── +# Kept visibly apart because only the second step involves your identity: anyone can seal +# a secret TO this canister, and only a controller can install one. +ARGS_FILE="$(mktemp)" +# The ciphertext is not secret — it is useless to anyone but this canister — but the temp +# file goes regardless, so nothing accumulates. +trap 'rm -f "$ARGS_FILE"' EXIT + +note "sealing the $WHAT for '$ENVIRONMENT' using the $SOURCE master key" +CYCLEPAY_SEAL_SECRET="$SECRET" npm --prefix scripts/seal run --silent seal -- \ + --canister "$CANISTER_ID" --source "$SOURCE" --out "$ARGS_FILE" \ + || die "sealing failed" + +icp canister call backend "$METHOD" --args-file "$ARGS_FILE" "${ENV_FLAG[@]}" >/dev/null \ + || die "$METHOD failed. Are you a controller of $CANISTER_ID? + A '#notSealedToThisCanister' error means the ciphertext was sealed to a different + key — most often the other network's master key. This script derives that from the + environment, so check you passed the right one: you used '$ENVIRONMENT' -> $SOURCE." + +printf '\033[32m✓\033[0m %s provisioned — the plaintext never left this machine\n' "$WHAT" + +# Confirms the rotation landed without ever reading the secret back out. +# +# ⚠️ `'()'` is passed explicitly: `icp canister call` with NO argument opens an +# interactive prompt and reads stdin, so a status read written without it hangs — and here +# it would report nothing under `|| true`. Same trap `stripe-dev.sh` already documents. +icp canister call backend "$STATUS" '()' "${ENV_FLAG[@]}" 2>/dev/null | sed 's/^/ /' || true diff --git a/scripts/seal/package-lock.json b/scripts/seal/package-lock.json new file mode 100644 index 0000000..5e09aad --- /dev/null +++ b/scripts/seal/package-lock.json @@ -0,0 +1,740 @@ +{ + "name": "cyclepay-seal", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cyclepay-seal", + "version": "0.1.0", + "dependencies": { + "@icp-sdk/core": "^6.1.0", + "@icp-sdk/vetkeys": "^0.7.0" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@dfinity/cbor": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@dfinity/cbor/-/cbor-0.2.3.tgz", + "integrity": "sha512-5GX+9tAf29r94Pxv8nCzrv2/t+AkOfem/c/G61+FA2Wadq7THvc5cvr/AVLH0LpukVfNSWRRHvl1fkCdiB9MMQ==", + "license": "Apache-2.0" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@icp-sdk/core": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@icp-sdk/core/-/core-6.1.0.tgz", + "integrity": "sha512-hw3kozrCoP/JqRLXmG35stFjNOyzfA7FU+lPm66WdcSEIS5PD/sMu2yhNiPIkXFJ+jodeJyxX/a379nMvLS/3g==", + "license": "Apache-2.0", + "dependencies": { + "@dfinity/cbor": "^0.2.3", + "@noble/curves": "^2.2.0", + "@noble/hashes": "^2.2.0", + "@scure/bip32": "^2.2.0", + "@scure/bip39": "^2.2.0", + "asn1js": "^3.0.7" + } + }, + "node_modules/@icp-sdk/vetkeys": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@icp-sdk/vetkeys/-/vetkeys-0.7.0.tgz", + "integrity": "sha512-Elf5597qanu1KJd6mxZ3Wy7/QGBheErRiz4aGVs/XzFzgUWpOJr7Hvl+Vs6y/1xW/ejaoQDrbDhsjSjEejDaJA==", + "license": "Apache-2.0", + "dependencies": { + "@noble/curves": "^1.9.7", + "@noble/hashes": "^1.8.0", + "idb-keyval": "^6.3.0" + }, + "peerDependencies": { + "@icp-sdk/core": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/@icp-sdk/vetkeys/node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@icp-sdk/vetkeys/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz", + "integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.4.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/base": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.4.0.tgz", + "integrity": "sha512-thZ1TuJwFwBblOhgsjDKvvGirBxNp+wSvY/DR6tJBJOTDhdAAcHJ8Vbr2eFnqaxeca4+t0i9KBf+uHYGWwZORg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.4.0.tgz", + "integrity": "sha512-i3DS0CptAocyvqE4n3SUkpzeQK4vJMFwWLofTwRiiKo2aWojBOfyMCgfKw9HVpO6fSY5AK86sHS/Uzn8kK9Few==", + "license": "MIT", + "dependencies": { + "@noble/curves": "2.4.0", + "@noble/hashes": "2.4.0", + "@scure/base": "2.4.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.4.0.tgz", + "integrity": "sha512-82dxFbZUYboyOf0AXiydsQrFQ5Q4h9mX+O2UkE91ROYmsc0BKMGZLwDmy96Jpa2+vrtoxomjUhy1RPIgH/r2nA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.4.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@types/node": { + "version": "22.20.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz", + "integrity": "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/idb-keyval": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.3.0.tgz", + "integrity": "sha512-um+2dgAWmYsu615EXpWVwSmapJhON0G43t3Ka/EVaohzPQXSMqKEqeDK/oIW3Ow+BXaF2PvSc+oBTFp793A5Ow==", + "license": "Apache-2.0" + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/scripts/seal/package.json b/scripts/seal/package.json new file mode 100644 index 0000000..d6b302a --- /dev/null +++ b/scripts/seal/package.json @@ -0,0 +1,21 @@ +{ + "name": "cyclepay-seal", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Seals a Stripe secret to the backend's vetKD public key, offline", + "scripts": { + "seal": "tsx src/index.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@icp-sdk/core": "^6.1.0", + "@icp-sdk/vetkeys": "^0.7.0" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + }, + "engines": { "node": ">=18" } +} diff --git a/scripts/seal/src/index.ts b/scripts/seal/src/index.ts new file mode 100644 index 0000000..7d2465c --- /dev/null +++ b/scripts/seal/src/index.ts @@ -0,0 +1,142 @@ +/** + * Seals a Stripe secret to the backend canister's vetKD public key. + * + * Two steps, and only the second one involves any identity: + * + * 1. Derive the canister's public key **offline** — from a master public key shipped in + * `@icp-sdk/vetkeys` plus the canister id. No network call, no principal, nothing to + * trust. Anyone can seal a secret *to* the canister; only the canister can open it. + * 2. Encrypt to it. Pure computation with no key of ours in it. + * + * Sending the result is a separate, signed call, which `scripts/seal-secret.sh` makes. + * That split is why this file never talks to a network: if it could fetch the public key, + * whoever answered could substitute one they hold. + * + * ⚠️ **The encryption here is the stronger half.** `@icp-sdk/vetkeys` is DFINITY's own + * library, on the independently audited `@noble/curves`. The canister's decryption runs on + * an experimental BLS12-381 port — see `docs/DESIGN.md` §7.3 for why that asymmetry is what + * makes the arrangement acceptable. + */ +import { writeFileSync } from "node:fs"; +import { Principal } from "@icp-sdk/core/principal"; +import { + IbeCiphertext, + IbeIdentity, + IbeSeed, + MasterPublicKey, + MasterPublicKeyId, + PocketIcMasterPublicKeyId, +} from "@icp-sdk/vetkeys"; + +/** + * ⚠️ **Both must match `src/backend/Sealed.mo` byte for byte**, and `test/sealed.test.mo` + * pins the Motoko side so the two cannot drift silently. + * + * `CONTEXT` selects the canister's keypair; `KEY_LABEL` is the IBE identity every secret + * is sealed to. One label means one derivation covers both secrets. + */ +const CONTEXT = new TextEncoder().encode("cyclepay-secrets"); +const KEY_LABEL = new TextEncoder().encode("stripe-secrets"); + +const USAGE = `Seal a Stripe secret to the backend canister. + + --canister the backend canister id + --source mainnet | pocketic (REQUIRED — see below) + --out write the Candid argument here instead of stdout + +The secret is read from CYCLEPAY_SEAL_SECRET in the environment, never from a flag: a +--value argument would land in shell history and in CI logs, which is the exposure this +whole mechanism exists to close. + +⚠️ --source has NO DEFAULT, on purpose. + +Mainnet and a local network BOTH have a vetKD key called key_1, backed by different +master keys — necessarily, since a local network cannot hold mainnet's master secret. So +the key NAME does not identify the key. Choose the wrong table and you produce a +ciphertext nobody can ever open, and nothing detects it until the canister tries to +decrypt. A default here would be a silent wrong answer waiting for a mainnet deploy; +scripts/seal-secret.sh derives the value from the environment instead. +`; + +function arg(name: string, fallback?: string): string { + const i = process.argv.indexOf(name); + if (i === -1 || i + 1 >= process.argv.length) { + if (fallback !== undefined) return fallback; + console.error(`error: ${name} is required\n\n${USAGE}`); + process.exit(1); + } + return process.argv[i + 1]!; +} + +/** Step 1: the canister's public key, computed here, offline. */ +function derivePublicKey(source: string, keyName: string, canisterId: Principal) { + let master; + if (source === "mainnet") { + master = MasterPublicKey.productionKey( + keyName === "test_key_1" ? MasterPublicKeyId.TEST_KEY_1 : MasterPublicKeyId.KEY_1, + ); + } else if (source === "pocketic") { + master = MasterPublicKey.pocketicKey( + keyName === "test_key_1" + ? PocketIcMasterPublicKeyId.TEST_KEY_1 + : PocketIcMasterPublicKeyId.KEY_1, + ); + } else { + console.error(`error: --source must be "mainnet" or "pocketic", got "${source}"\n\n${USAGE}`); + process.exit(1); + } + return master.deriveCanisterKey(canisterId.toUint8Array()).deriveSubKey(CONTEXT); +} + +/** Candid text for a `blob` argument: every byte escaped, so quoting cannot surprise. */ +function candidBlob(bytes: Uint8Array): string { + const escaped = Array.from(bytes, (b) => `\\${b.toString(16).padStart(2, "0")}`).join(""); + return `(blob "${escaped}")`; +} + +function main() { + if (process.argv.includes("--help")) { + console.log(USAGE); + return; + } + + const canisterId = Principal.fromText(arg("--canister")); + const source = arg("--source"); + const keyName = arg("--key-name", "key_1"); + const out = arg("--out", ""); + + const secret = process.env.CYCLEPAY_SEAL_SECRET; + if (!secret) { + console.error( + "error: CYCLEPAY_SEAL_SECRET is unset.\n" + + " scripts/seal-secret.sh sets it from STRIPE_API_KEY or STRIPE_WEBHOOK_SECRET.\n" + + " Read from the environment on purpose — a --value flag would land in shell\n" + + " history and CI logs.", + ); + process.exit(1); + } + + const publicKey = derivePublicKey(source, keyName, canisterId); + + const ciphertext = IbeCiphertext.encrypt( + publicKey, + IbeIdentity.fromBytes(KEY_LABEL), + new TextEncoder().encode(secret), + IbeSeed.random(), + ).serialize(); + + const candid = candidBlob(ciphertext); + if (out) { + writeFileSync(out, candid); + // The secret's LENGTH is reported, never any of its bytes: enough to see that the + // right value was picked up, useless to anyone reading a log. + console.error( + `sealed ${secret.length} bytes to ${canisterId.toText()} using the ${source} ` + + `${keyName} master key (offline) -> ${ciphertext.length}-byte ciphertext in ${out}`, + ); + } else { + console.log(candid); + } +} + +main(); diff --git a/scripts/seal/tsconfig.json b/scripts/seal/tsconfig.json new file mode 100644 index 0000000..3b3b938 --- /dev/null +++ b/scripts/seal/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "types": ["node"], + "noEmit": true + }, + "include": ["src"] +} diff --git a/scripts/stripe-dev.sh b/scripts/stripe-dev.sh new file mode 100755 index 0000000..08112fb --- /dev/null +++ b/scripts/stripe-dev.sh @@ -0,0 +1,283 @@ +#!/usr/bin/env bash +# Local Stripe-sandbox loop against a local `icp network`. +# +# Wires the Stripe CLI's webhook forwarder to a locally deployed backend so the +# real §6.1 path runs end to end: genuine Stripe signatures, genuine event JSON, +# genuine retry behaviour on a non-2xx. +# +# ## Run scripts/local-dev-seed.sh FIRST +# +# The two scripts own different levers and this one assumes the other has run: +# +# local-dev-seed.sh the money levers — tiers, the cycles reserve, the CMC rate, +# the canister's own cycles +# this script the Stripe levers — expected livemode, the forwarding +# session's signing secret, forwarding +# +# ⚠️ **Neither script touches the other's levers, and that is not tidiness.** When both +# set tiers, the ORDER of the two silently decided the outcome — whichever ran last +# overwrote the other's with a placeholder, with nothing to see afterwards. This script +# refuses to start if the gateway cannot price, which is how it says "run the seed +# first" instead of quietly half-configuring. +# +# `npm --prefix test/integration run sandbox` is the scripted alternative when you do +# not need the frontend in a browser — it boots its own PocketIC and needs none of +# this. +# +# Usage: +# scripts/stripe-dev.sh # bootstrap Stripe config, wire the secret, forward +# scripts/stripe-dev.sh --no-bootstrap # only wire the secret and forward +# scripts/stripe-dev.sh --print-only # print the forward URL and exit +# +# Prerequisites: +# brew install stripe/stripe-cli/stripe +# stripe login # choose a SANDBOX account, never a live one +# icp network start -d && icp deploy && scripts/local-dev-seed.sh +set -euo pipefail + +BOOTSTRAP=1 +PRINT_ONLY=0 +for arg in "$@"; do + case "$arg" in + --no-bootstrap) BOOTSTRAP=0 ;; + --print-only) PRINT_ONLY=1 ;; + -h | --help) + sed -n '2,18p' "$0" | sed 's|^# \{0,1\}||' + exit 0 + ;; + *) + echo "unknown argument: $arg" >&2 + exit 2 + ;; + esac +done + +die() { + echo "error: $*" >&2 + exit 1 +} + +need() { + command -v "$1" >/dev/null 2>&1 || die "$1 not found on PATH. $2" +} + +need jq "Install with: brew install jq" +need icp "Install with: npm i -g @icp-sdk/icp-cli" +if [ "$PRINT_ONLY" -eq 0 ]; then + need stripe "Install with: brew install stripe/stripe-cli/stripe" +fi + +# --- locate the local network and the deployed backend ---------------------- +# The local network runs with `gateway.port: 0`, so the port changes on every +# start — always read it rather than hardcoding one. +STATUS_JSON="$(icp network status --json 2>/dev/null)" || + die "no local network. Run: icp network start -d" +GATEWAY_URL="$(printf '%s' "$STATUS_JSON" | jq -r '.gateway_url')" +[ "$GATEWAY_URL" != "null" ] || die "could not read gateway_url from icp network status" +# Normalise http://localhost:PORT/ → 127.0.0.1:PORT (the Stripe CLI resolves +# 127.0.0.1 more predictably than 'localhost' on dual-stack hosts). +PORT="$(printf '%s' "$GATEWAY_URL" | sed -E 's#^https?://[^:]+:([0-9]+)/?$#\1#')" +[ -n "$PORT" ] || die "could not parse a port out of '$GATEWAY_URL'" + +# ⚠️ **"backend not deployed" is the likely cause, not the only one.** This also fires +# on a network that is not running and on the wrong identity, and swallowing the reason +# sends the operator to re-deploy something that is already there. +if ! BACKEND_STATUS="$(icp canister status backend --json 2>&1)"; then + printf '\n%s\n\n' "$BACKEND_STATUS" >&2 + die "could not read the backend canister's status — the reason is above. Usually it is + not deployed (\`icp deploy backend\`); it can also be a stopped network or the wrong + identity." +fi +BACKEND_ID="$(printf '%s' "$BACKEND_STATUS" | jq -r '.id')" +[ "$BACKEND_ID" != "null" ] && [ -n "$BACKEND_ID" ] || + die "backend not deployed. Run: icp deploy backend" + +# Http.mo strips the query string before route matching, so the gateway's +# ?canisterId= parameter coexists with the /webhook/stripe path. +FORWARD_URL="http://127.0.0.1:${PORT}/webhook/stripe?canisterId=${BACKEND_ID}" + +echo "backend: $BACKEND_ID" +echo "gateway: $GATEWAY_URL" +echo "forward to: $FORWARD_URL" + +if [ "$PRINT_ONLY" -eq 1 ]; then + exit 0 +fi + +# --- the gateway must already be sellable ----------------------------------- +# Assert it rather than re-configuring it. A quote is the honest check: it proves +# tiers exist AND both rate inputs answered AND the canister is above the gate's +# own-cycles floor, which is every money lever this script does not own. +QUOTE="$(icp canister call backend quote_previews '(vec { 1_000 : nat })' 2>&1 || true)" +if ! printf '%s' "$QUOTE" | grep -q 'cycles = opt'; then + echo "error: the gateway cannot price a \$10 purchase, so an order cannot be created." >&2 + echo " Run this first, and note the CMC rate expires after 15 minutes:" >&2 + echo " scripts/local-dev-seed.sh" >&2 + echo " Diagnose with:" >&2 + echo " icp canister call backend pricing_status '()'" >&2 + echo " icp canister call backend cycles_status '()'" >&2 + exit 1 +fi +echo "priceable: a \$10 purchase quotes (presets, both rates, and own-cycles are all fine)" + +# --- bootstrap the STRIPE-side config --------------------------------------- +# Tiers, the cycles reserve, the delivery timeline, the CMC rate and the canister's own +# gas belong to scripts/local-dev-seed.sh and are deliberately not touched here — +# setting them from both scripts made whichever ran last the winner, silently. +if [ "$BOOTSTRAP" -eq 1 ]; then + echo + echo "--- bootstrapping Stripe-side config (dev values, never for mainnet) ---" + + # No TTL to set: an order's deadline is its Stripe session's `expires_at` + # (~35 min, Stripe's own floor is 30). Nothing local shortens it — to see an expiry in a dev session, expire the session in the + # Stripe Dashboard and let `checkout.session.expired` arrive. + + # Declare the Stripe world. A sandbox forwarder sends livemode=false events, so + # without this every honoured payment records `stripe.livemodeUnset`. On mainnet + # this must be `opt true` — see docs/OPERATE.md. + icp canister call backend set_expected_livemode '(opt false)' >/dev/null + echo "livemode: expecting TEST events (mainnet must be 'opt true')" +fi + +# ⚠️ **Run the Stripe CLI with STRIPE_API_KEY UNSET.** +# +# The CLI prefers `STRIPE_API_KEY` from the environment over its own `stripe login` +# credential — and this repo tells operators to export that variable, holding the +# **restricted** `rk_` key scoped to write Checkout Sessions and nothing else. Opening a +# CLI session needs `stripecli_session_write`, which a correctly-scoped rk_ key does not +# have and must not, so an exported key turns every call here into: +# +# FATAL … status=403 "more_permissions_required" … 'rk_test_…' does not have the +# required permissions … Enabling Debugging Tools Write ('stripecli_session_write') +# +# The wrong fix is widening the key. The CLI uses its keychain login, the canister uses the +# restricted key, and neither borrows the other's credential. +stripe_cli() { env -u STRIPE_API_KEY stripe "$@"; } + +# ⚠️ **The hints this script PRINTS run in the operator's shell, not through the wrapper +# above.** With STRIPE_API_KEY exported, a pasted `stripe trigger` uses the restricted +# backend key and fails with "more_permissions_required" — the same failure the wrapper +# exists to prevent, one layer out. So the printed copy carries the prefix exactly when it +# is needed, and stays clean when it is not. +TRIGGER_PREFIX="" +[ -z "${STRIPE_API_KEY:-}" ] || TRIGGER_PREFIX="env -u STRIPE_API_KEY " + +if [ -n "${STRIPE_API_KEY:-}" ]; then + printf '\033[33m!\033[0m STRIPE_API_KEY is set; ignoring it for the Stripe CLI (it is the backend key).\n' +fi + +# --- wire the forwarding session's signing secret --------------------------- +# `stripe listen` mints a secret for the forwarding session. It is NOT the +# Dashboard endpoint's secret, and it changes per `stripe login` — so read it +# rather than pasting one. +echo +echo "--- reading the forwarding session's signing secret ---" +# stderr is kept, not sent to /dev/null: when this fails the reason is the whole message — +# a 403 naming the permission, or "not logged in" — and discarding it leaves only "could +# not read a signing secret", which describes the symptom and hides the cause. +WHSEC_OUT="$(stripe_cli listen --print-secret 2>&1 || true)" +WHSEC="$(printf '%s' "$WHSEC_OUT" | grep -oE 'whsec_[A-Za-z0-9]+' | head -1 || true)" +case "$WHSEC" in + whsec_*) ;; + *) + printf '\n%s\n\n' "$WHSEC_OUT" >&2 + die "could not read a signing secret from 'stripe listen --print-secret' (output above). Run 'stripe login' first (choose a SANDBOX account)." + ;; +esac + +# Sealed — the signing secret never appears in an ingress message or in this +# script's own process arguments. +STRIPE_WEBHOOK_SECRET="$WHSEC" scripts/seal-secret.sh webhook-secret >/dev/null + +# ── the OTHER secret ─────────────────────────────────────────────────── +# The rail is live only when both are provisioned, so a webhook secret alone is +# not enough any more: without an API key `create_order` cannot create a session +# and nobody can pay. Checked rather than set, because the key is yours and it +# does not come from the forwarding session. +KEY_SET="$(icp canister call backend stripe_api_key_status '()' 2>/dev/null | grep -c 'isSet = true' || true)" +if [ "$KEY_SET" = "0" ]; then + printf '\n\033[33m! no Stripe API key is provisioned, so create_order cannot make a session.\033[0m\n' + printf ' Create a RESTRICTED key (rk_...) with Checkout Sessions = Write (Write also\n' + printf ' grants the read the recovery sweep needs) and everything else None, then:\n' + printf ' STRIPE_API_KEY=rk_... ./scripts/seal-secret.sh api-key\n' + printf ' Or put it in scripts/.local-dev.env (gitignored) and re-run the seed:\n' + printf ' ./scripts/local-dev-seed.sh\n\n' +else + echo "api key: provisioned (generation $(icp canister call backend stripe_api_key_status '()' 2>/dev/null | grep -oE 'generation = [0-9_]+' | grep -oE '[0-9_]+' || echo '?'))" +fi +# NOTE: pass an explicit '()' for zero-argument methods. Omitting the argument +# makes `icp canister call` ask "Do you want to send this message? [y/N]" and +# read stdin, which hangs any script or CI job. +GENERATION="$(icp canister call backend webhook_secret_status '()' --query 2>/dev/null | + tr -d ' _' | sed -nE 's/.*generation=([0-9]+).*/\1/p')" +echo "secret set: generation ${GENERATION:-?} (the secret itself is never readable back)" + +# ⚠️ **BOTH heredocs stay QUOTED, and the one dynamic line lives between them.** +# +# This block was briefly `cat <"; subaccount = null } }, null)' + The account must be the caller's own, default subaccount — anything else + is refused with #destinationNotOwned. Get yours with + `icp identity principal`, and pass the same --identity to the call. + (the third argument pins a minimum cycle quantity; null opts out) + 2. Take stripeSessionUrl from the returned order and open it. That IS the + payment page: the canister created a Checkout Session per order and set + client_reference_id through the API, so there is no link to configure and + no URL parameter to append. + 3. Pay with test card 4242 4242 4242 4242. + + ⚠ The session expires 35 minutes after creation, enforced by Stripe. + ⚠ After paying, Stripe redirects to the origin the seed configured + (https://.icp.net), which does NOT serve your local + frontend. The payment still completes and the webhook still fires — + the landing page is the only thing that will not load. Watch the order + through `get_order` or the local UI instead. + + Inspect what happened: + icp canister call backend audit_log_recent '(null, 25 : nat)' + icp canister call backend orphans_unresolved '(null, 50)' + icp canister call backend order_for_payment '("pi_...")' + +Two things to expect locally: + +1. Signature timestamps are checked against the replica's OWN clock with a ±300 s + tolerance. If local time has drifted further than that from real time, every + live webhook is rejected with a 400. + +2. **The CMC rate expires 15 minutes after the seed script set it**, and pricing + then refuses every new order. It does not affect an order that already exists: + the rate is locked at creation. Refresh with: + scripts/local-dev-seed.sh --rate-only + +Starting the forwarder (Ctrl-C to stop)... +NOTES + +exec env -u STRIPE_API_KEY stripe listen --forward-to "$FORWARD_URL" diff --git a/scripts/sweep-vocabulary.py b/scripts/sweep-vocabulary.py new file mode 100755 index 0000000..d279475 --- /dev/null +++ b/scripts/sweep-vocabulary.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""Scan the lines a change ADDS for vocabulary that names a deleted mechanism. + + scripts/sweep-vocabulary.py # added lines vs the merge base + scripts/sweep-vocabulary.py --base # against an explicit base + +⚠️ **Scoped to the diff, and it keeps no counts.** A recorded population is a measurement, +and a measurement expires: it cannot tell "removed two legitimate uses, added one stale +claim" from "removed one", so a prose-purging change that also introduces a stale claim +nets downward and reads clean. That is a check whose negative result is indistinguishable +from a passing one — the class this whole artifact exists to catch. Added lines have no +such hole: a stale claim is a stale claim whatever else the change did. + +⚠️ **It also removes the corpus question.** A count needs a population to count, so it +needs globs, and globs go stale silently — the version that kept counts covered 40 files +and missed `test/`, `src/backend/rails/` and `scripts/` entirely, which is where two stale +claims were added and reviewed past it. A diff has no population to enumerate: every added +line in the tree is in scope, wherever it lives. + +⚠️ **It PRINTS; it does not fail on a hit.** Most hits are correct prose — this repo has +~20 lines that correctly say a removed mechanism is gone — and a check that fires on +correct code teaches people to route around it. Adjudication is a judgement call, and a +judgement call can only be surfaced. The one failure is an **undeterminable base ref**, +because that is the didn't-run case, which this project treats as failure everywhere. + +⚠️ **An empty diff is a genuine pass, not an abort.** Other steps abort when they find +nothing to check, because for them empty input means the step is aimed at nothing. Here it +means the change added no lines, which is a real and correct answer. + +The term list and its dispositions are read FROM `docs/agents/deleted-vocabulary.md`, so +there is one list, not two. +""" + +import re +import subprocess +import sys + +TABLE = "docs/agents/deleted-vocabulary.md" +BASE_CANDIDATES = ("origin/main", "main") + + +def git(*args): + """Run git, returning (ok, stdout).""" + p = subprocess.run(("git",) + args, capture_output=True, text=True) + return p.returncode == 0, p.stdout + + +def terms(): + """Rows look like: | `term` | disposition | + + ⚠️ Aborts on an empty parse. A term list that silently came back empty would make this + scan every added line against nothing and print a clean result. + """ + rows = [] + for line in open(TABLE): + m = re.match(r"\|\s*`([^`]+)`\s*\|\s*(?!-)(.+?)\s*\|?\s*$", line) + if m: + rows.append((m.group(1), m.group(2))) + if not rows: + sys.exit(f"ABORT: parsed no term rows out of {TABLE} — the scan would match nothing") + return rows + + +def collisions(): + """Terms whose hits are almost always correct, from the table's own section. + + ⚠️ Returns an empty set if the section is renamed or emptied, which prints EVERY term + in the main list. The failure direction is more prominence, not less. + """ + out, inside = set(), False + for line in open(TABLE): + if line.startswith("## "): + inside = line.strip() == "## Known-collision terms" + elif inside: + m = re.match(r"-\s*`([^`]+)`", line) + if m: + out.add(m.group(1)) + return out + + +def base(): + """The merge base to diff against, or exit non-zero naming why there is none.""" + if "--base" in sys.argv: + i = sys.argv.index("--base") + 1 + if i >= len(sys.argv): + sys.exit("ABORT: --base needs a ref after it") + wanted = [sys.argv[i]] + else: + wanted = list(BASE_CANDIDATES) + tried = [] + for ref in wanted: + ok, out = git("merge-base", "HEAD", ref) + if ok and out.strip(): + return out.strip(), ref + tried.append(ref) + sys.exit( + "ABORT: no base ref to diff against (tried: " + + ", ".join(tried) + + ").\n This is the DID-NOT-RUN case, so it fails rather than reporting a clean\n" + " scan. Pass --base explicitly, or fetch the default branch." + ) + + +def added_lines(base_sha): + """(path, lineno, text) for every line this working tree adds over `base_sha`. + + ⚠️ Untracked files are included whole. They are absent from `git diff`, so a brand-new + document would otherwise be scanned as zero added lines — invisible in exactly the way + a wrong answer is not. + """ + out = [] + ok, diff = git("diff", "--unified=0", "--no-color", base_sha, "--") + if not ok: + sys.exit(f"ABORT: could not diff against {base_sha}") + path, lineno = None, 0 + for line in diff.split("\n"): + if line.startswith("+++ b/"): + path, lineno = line[6:], 0 + elif line.startswith("@@"): + m = re.search(r"\+(\d+)", line) + lineno = int(m.group(1)) if m else 0 + elif line.startswith("+") and path: + out.append((path, lineno, line[1:])) + lineno += 1 + ok, untracked = git("ls-files", "--others", "--exclude-standard") + if ok: + for path in untracked.strip().split("\n"): + if not path: + continue + try: + for i, text in enumerate(open(path, errors="replace").read().split("\n"), 1): + out.append((path, i, text)) + except (IsADirectoryError, PermissionError): + continue + return [(p, n, t) for p, n, t in out if p != TABLE and not generated(p)] + + +# ⚠️ **Generated files are excluded, and this became necessary rather than tidy.** Before +# moc 1.16.0 the `.did` held 5 doc lines in total, so no generated file could contribute +# prose and no exclusion was needed. 1.16.0 publishes every endpoint's doc — 613 lines — +# and `backend.did.d.ts` mirrors them again, so **one mixin doc edit now surfaces three +# times**. Measured on the 1.16.0 bump: 14 hits, 7 in the `.did`, 7 in the `.d.ts`, and 0 +# in source — a step whose entire output was a restatement of a source change it did not +# report, under a heading asking a reviewer to adjudicate each one. +# +# The rule is "adjudicate prose a human wrote". A generated mirror of that prose is the +# same sentence counted again, and the source line is already in the list. +GENERATED = ("src/backend/dist/", "/generated/declarations/") + + +def generated(path: str) -> bool: + return any(marker in path for marker in GENERATED) + + +def main() -> int: + rows = terms() + base_sha, base_name = base() + lines = added_lines(base_sha) + print(f"scanning {len(lines)} added lines against {len(rows)} terms (base: {base_name})") + if not lines: + print(" no added lines — nothing to scan, and that is a pass, not an abort") + return 0 + + known = collisions() + total, deferred = 0, [] + for term, disposition in rows: + try: + rx = re.compile(term, re.I) + except re.error as e: + print(f" ⚠️ `{term}` is not a valid regex: {e}") + continue + hits = [(p, n, t) for p, n, t in lines if rx.search(t)] + if not hits: + continue + if term in known: + deferred.append((term, disposition, hits)) + continue + total += len(hits) + print(f"\n\033[33m`{term}`\033[0m — {disposition}") + for p, n, t in hits: + print(f" {p}:{n} {t.strip()[:110]}") + + if deferred: + n = sum(len(h) for _, _, h in deferred) + print(f"\n ── below the line: {n} hit(s) on live names, almost always correct ──") + for term, disposition, hits in deferred: + print(f" `{term}` — {disposition}") + for p, ln, t in hits: + print(f" {p}:{ln} {t.strip()[:110]}") + + if total: + print( + f"\n\033[33m⚠️ {total} added line(s) name a deleted mechanism — adjudicate each.\033[0m\n" + " Many are still legitimate: an end-state statement, or the rule text quoting the\n" + " term. Live-name collisions are below the line, not here. What is NOT legitimate\n" + " is a\n" + " present-tense claim that the mechanism still exists, or a deleted mechanism\n" + " cited as the JUSTIFICATION for behaviour that now has a different reason.\n" + " This step does not fail on these; a reviewer decides." + ) + elif deferred: + # ⚠️ Reachable, not hypothetical: a diff that hits `\bmint` and nothing else. Falling + # to the line below would print "no added line names a deleted mechanism" + # immediately under a list of printed hits, and this step's entire value is a + # reader trusting that last line. + print(" nothing above the line — only the live-name collisions listed above") + else: + print(" no added line names a deleted mechanism") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/test-all.sh b/scripts/test-all.sh new file mode 100755 index 0000000..f221076 --- /dev/null +++ b/scripts/test-all.sh @@ -0,0 +1,493 @@ +#!/usr/bin/env bash +# The full verification gate, in dependency order, fail-fast. +# +# AGENTS.md previously listed these as six commands to run by hand, which is how +# you end up reporting a task done on a partial run. This is the single entry +# point. +# +# Usage: +# scripts/test-all.sh # everything (what CI runs; the only claimable run) +# scripts/test-all.sh --fast # skip the PocketIC suite (~30 s vs ~2 min) +# scripts/test-all.sh --changed # only the lanes the change can have broken +# scripts/test-all.sh --changed=REF # ...against REF instead of origin/main +# +# --changed is a LOCAL LOOP tool. It prints what it skipped and its summary says the +# run is partial, because a green tick over a lane that did not run is the failure +# this whole script exists to prevent. CI passes neither flag. +# +# The PocketIC suite needs a 4 KiB-page host (macOS, or x86_64 Linux). It cannot +# run in arm64 Linux guests with 16 KiB pages — the replica hard-asserts 4096-byte +# pages and the server dies at instance creation. --fast exists for that case; say +# the bar is unverified rather than inferring it from the unit tests. +# ⚠️ **`-E` (errtrace) is load-bearing, not decoration.** Without it the `ERR` trap +# below is NOT inherited by shell functions — and every step runs inside `run()` — so +# the gate exited on failure with no banner at all, only the failing tool's own output +# and a nonzero status. That is how a truncated run gets misread as a pass: there was +# nothing for a reader (or a grep) to find that said the gate itself had stopped. +set -Eeuo pipefail + +FAST=0 +CHANGED=0 +BASE="origin/main" +for arg in "$@"; do + case "$arg" in + --fast) FAST=1 ;; + --changed) CHANGED=1 ;; + --changed=*) CHANGED=1; BASE="${arg#--changed=}" ;; + -h | --help) + sed -n '2,17p' "$0" | sed 's|^# \{0,1\}||' + exit 0 + ;; + *) + echo "unknown argument: $arg" >&2 + exit 2 + ;; + esac +done + +cd "$(dirname "$0")/.." + +# ⚠️ **Print the branch, because "I assumed where I was" has cost this repo twice.** +# +# Two work-in-progress commits landed on `main` in one session — the branch had been +# created before an intervening merge and never re-checked. `git branch --show-current` +# before committing was written down both times, and the second time it still was not +# run: a rule that needs remembering is a rule that gets skipped. +# +# This gate runs far more often than commits do, so putting the answer on screen turns +# "remember to check" into "you already know". Both strays were in fact caught by reading +# a number in this output — a scenario count that differed from the branch's — so the +# header is the same detector made explicit rather than incidental. +# +# Not an error when it says `main`: releasing from main is legitimate, and a gate that +# refused would be wrong. It reports; the reader decides. +# ⚠️ **Detached HEAD gets named, not printed as the word "HEAD".** `--abbrev-ref` answers +# `HEAD` when detached, which reads like a branch called HEAD and hides exactly the state +# this header exists to surface: a checkout where "which branch am I on" has no answer and +# a commit would be orphaned. +BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo '(not a git checkout)')" +if [ "$BRANCH" = "HEAD" ]; then + BRANCH="(detached: $(git rev-parse --short HEAD))" +fi +# One comparison against HEAD, so staged and unstaged both count and neither masks the +# other. Tracked files only: an untracked scratch file is not a reason to say "uncommitted". +DIRTY="" +git diff HEAD --quiet 2>/dev/null || DIRTY=" · uncommitted changes" +printf '\033[1mgate\033[0m on \033[1m%s\033[0m%s\n' "$BRANCH" "$DIRTY" + +step=0 +# ── Lanes: don't run what the change cannot have broken ───────────────────────── +# +# ⚠️ **Measured before it was built, because the obvious answer was wrong.** The +# complaint that prompted this was "the UI tests make the loop slow". They do not: +# on a full green run the frontend suite is 2.4 s and the browser specs 10.2 s, out +# of minutes. The time is in the Motoko compile (`mops check`, `mops build`) and the +# PocketIC suite at 61 s. Skipping the UI tests would have saved 3% and deleted the +# only coverage of the cascade. +# +# So the lever is which LANE runs at all, not which suite gets deleted. +# +# ⚠️ **The default is still everything, and CI must never pass `--changed`.** This is +# a local-loop tool. The safe direction is baked into `lane_active`: a path this does +# not recognise activates EVERY lane, and a step name it does not recognise RUNS. +# Both failure modes cost time rather than coverage. +LANES="" +lane_on() { case " $LANES " in *" $1 "*) return 0 ;; *) return 1 ;; esac; } +lane_add() { lane_on "$1" || LANES="$LANES $1"; } + +detect_lanes() { + # Staged, unstaged, untracked AND committed-since-base: a lane must activate for + # work already committed on the branch, or a second run after a commit would skip + # the very thing that was changed. + local files + files="$( + { git diff --name-only HEAD 2>/dev/null + git diff --name-only --cached 2>/dev/null + git ls-files --others --exclude-standard 2>/dev/null + git diff --name-only "$BASE...HEAD" 2>/dev/null + } | sort -u + )" + if [ -z "$files" ]; then + printf ' nothing changed against %s — running the frontend lane only as a smoke check\n' "$BASE" + lane_add frontend + return + fi + local f + while IFS= read -r f; do + [ -n "$f" ] || continue + case "$f" in + # ⚠️ A backend change regenerates the .did, which the frontend bindings and the + # integration bindings are both generated FROM. So it activates everything — + # there is no such thing as a backend-only change here. + # `vendor/*` and `.gitmodules` are the pinned crypto: a submodule bump changes + # what `Sealed.mo` compiles against, so it activates everything a backend change does. + src/backend/*|test/*.mo|mops.toml|mops.lock|deployed/*|vendor/*|.gitmodules) lane_add backend; lane_add frontend; lane_add browser; lane_add integration; lane_add checks ;; + src/frontend/*) lane_add frontend; lane_add browser; lane_add checks ;; + test/browser/*) lane_add browser ;; + test/integration/*) lane_add integration ;; + scripts/*|docs/*|*.md|.github/*) lane_add checks ;; + # Unrecognised: activate everything. Costs time, never coverage. + *) lane_add backend; lane_add frontend; lane_add browser; lane_add integration; lane_add checks ;; + esac + done <&2 + # ⚠️ **Say what did NOT run.** A fail-fast gate's output is genuinely about the step + # that broke, and the mind supplies "…and everything else was fine". It did not run. + # A passing step is evidence; a run that ended early is not evidence about the rest. + if [ "$step" -lt "$TOTAL_STEPS" ]; then + printf '\033[31m steps %d–%d DID NOT RUN — this says nothing about them\033[0m\n' \ + $((step + 1)) "$TOTAL_STEPS" >&2 + fi + exit 1 +} +trap fail ERR + +command -v mops >/dev/null 2>&1 || { + echo "mops not found on PATH. Install: npm i -g ic-mops" >&2 + exit 2 +} + +# -Werror, not plain `mops check`. Motoko reports a non-exhaustive match (M0145) +# as a WARNING, so adding a case to `Types.Owner` — the §11.1.1 Base seam — used +# to leave five authz sites silently trapping at runtime while the build passed. +# On the webhook path that trap is a 5xx and Stripe retries it for ~3 days. +# +# It lives here rather than in mops.toml's [moc] args because `mops test` passes +# --hide-warnings, and moc rejects that combination outright. +# ⚠️ **First, because it is the cheapest and it guards the gate's own substrate.** An +# unquoted heredoc that runs its own body has bitten twice in three days — once destroying +# a GitHub issue body, once turning `stripe-dev.sh`'s closing notes into an `icp deploy`. +# Fixed syntax, so it is enforceable here rather than written down again; see the script. +if [ "$CHANGED" -eq 1 ]; then + printf '\n\033[1mlane detection (--changed against %s)\033[0m\n' "$BASE" + detect_lanes + printf ' active lanes:%s\n' "${LANES:- none}" +fi + +run "shell — no unquoted heredoc runs its own body" scripts/check-heredocs.sh + +# ⚠️ **This also runs the STABLE-COMPATIBILITY check now, because mops.toml +# configures `[canisters.backend.check-stable]`.** No separate step: `mops check` picks it +# up, and CI runs the same command. Before that config, an incompatible stable shape +# passed every one of these steps and was refused at DEPLOY time with an +# `RTS error: Memory-incompatible program upgrade` trap — safe, but the wrong place to +# learn it. +# +# ⚠️ **It fails when the baseline is MISSING**, which is the property that matters: +# "Deployed file not found: deployed/backend.most". A promoted-but-uncommitted baseline, +# or a fresh checkout without one, gives a check with nothing to compare against — and by +# default that reads as a pass. Verified by deleting it. +run "mops check — lint, typecheck, stable compatibility (-Werror)" mops check -- -Werror +# ⚠️ **Before `mops test`, deliberately.** `test/sealed.test.mo` asserts our wiring on top +# of this curve implementation; if the curve itself is wrong, that suite's failures would +# read as our bug. Verify the dependency, then the code that uses it. +run "crypto vectors — the pinned BLS12-381/vetKD vs the Rust reference" scripts/check-crypto-vectors.sh +run "mops test — Motoko unit suites" mops test + +# Before the frontend: the committed .did is both the embedded candid:service +# metadata and the frontend's bindgen source, so it has to be current or the +# typecheck below is checking against a stale interface. +run "mops build — regenerate the committed .did" mops build +# ⚠️ **`fail` rather than a bare `exit 1`.** This check exits the gate outside `run()`, +# so an `exit 1` here skipped the step banner and — worse — the "steps N–M DID NOT RUN" +# line, leaving output that reads as one small problem in an otherwise complete run. +# It is the truncated-run fault in the one place the truncation reporting did not reach: +# a stale `.did` stops the gate before the frontend, browser and PocketIC suites, and +# nothing said so. +if ! git diff --quiet -- src/backend/dist/backend.did; then + printf '\n\033[31m✗ src/backend/dist/backend.did is out of date — commit the regenerated file\033[0m\n' >&2 + git --no-pager diff --stat -- src/backend/dist/backend.did >&2 + fail +fi +printf ' .did is current\n' + +# ⚠️ **After the .did check, deliberately.** This compares the docs' method lists +# against that file, so it is only meaningful once the file is known current — running it +# first would check the docs against a stale interface and pass. +step=$((step + 1)) +printf '\n\033[1m── %d. %s\033[0m\n' "$step" "docs match the canister's actual surface" +scripts/check-doc-surface.py + +# ⚠️ **Same file, a different question, and after it for the same reason.** The step above +# asks whether the DOCS list the methods the interface has; this asks whether every method +# in the interface carries documentation at all. The second is what catches a doc block +# absorbed by a neighbouring declaration — five instances in this repo, every one found by +# a human reading, because the orphan is undetectable and only its victim is. +step=$((step + 1)) +printf '\n\033[1m── %d. %s\033[0m\n' "$step" "every public endpoint is documented" +scripts/check-endpoint-docs.py + +# ⚠️ **Refuses the INPUT the step above detects the output of.** `Main.mo` emits nothing +# to candid except its service doc, so a `///` on anything else there has no destination +# and moc gives it the nearest one it can find — an unrelated endpoint, which the +# interface then documents with the wrong contract. Four such leaks were live before this. +# Source-only, so it needs no build and names the line responsible. +run "Main.mo uses /// only where candid emits it" scripts/check-main-doc-comments.py + +# ⚠️ **After the `.did` regeneration too, and for the same reason as the step above:** it +# reads the interface, so it must read the current one. A config parameter with no reader +# cannot be checked by an operator or shown by a UI, and `set_delivery_config` was exactly +# that — through five PRs touching this surface, because nothing was looking. +run "every config setter has a reader" scripts/check-config-readers.py + +# ⚠️ **Reads the SOURCE, not the interface, so it does not depend on the `.did` step** — +# but it is next to the other authz-shaped check for discoverability. It asserts each +# admin-gated method calls the guard its tier declares: a table alone would prove the list +# complete and say nothing about whether the code honours it, and a method filed +# controller-only whose body calls the delegable guard would pass such a table. +run "admin tiers are enforced, not just listed" scripts/check-admin-tiers.py +# Every mutating admin method is offered as a command or excluded on purpose. +# TypeScript makes a missing RENDERER a compile error and can say nothing about a method +# that never entered the union, which is what this covers. +run "every write is offered as a command, or excluded on purpose" scripts/check-admin-commands.py + +# ⚠️ **The step above checks WHICH methods the console offers; this one checks whether +# the calls printed in prose still run.** Different question, different files, and the +# gap between them is real: a method gains an argument and the prose calling it does +# not, or keeps passing one the method lost. An operator finds these mid-incident, by +# pasting one and getting a serialization error. +run "every documented canister call still matches the .did" scripts/check-doc-calls.py + +# ⚠️ **The decision record is enforced, not trusted.** Its predecessor was a 697-line +# spec that rotted by being updated less often than the code — 67 mentions of deleted +# architecture while Main.mo still cited it by section number. This cannot check whether +# a section is TRUE, only that every §N the code cites exists and every section is used. +step=$((step + 1)) +printf '\n\033[1m── %d. %s\033[0m\n' "$step" "docs/DESIGN.md covers every §N the code cites" +scripts/check-design-sections.py + +# ⚠️ **A symbol whose only caller is its own test is neither used nor covered, and the +# suite counts it as coverage.** `paymentLinkWithRef` survived past the PR that scheduled +# its own deletion for exactly that reason; `canonicalOrigin` was referenced nowhere at +# all. `noUnusedLocals` cannot see either — the symbol IS used, from a test. +step=$((step + 1)) +printf '\n\033[1m── %d. %s\033[0m\n' "$step" "no frontend export is referenced only by tests" +scripts/check-unused-exports.py + +# ⚠️ **A different class from the step above: two functions that BOTH have callers and +# both do the same thing.** The loss there is not dead code but divergence — the next +# correction lands on one of them. The motivating shape is on the money path +# (`Reserve.deliverable` re-implementing `Delivery.deliverableCycles`), and it is +# invisible to a naive implementation — see the script for what it takes to see it. +run "no module function body is duplicated across modules" scripts/check-duplicate-bodies.py + +# ⚠️ **Rendering, not parsing, is where a mermaid diagram fails silently.** A bare `#` +# opens an HTML entity code and swallows the rest of the label, so `#paid` renders as +# nothing and the clause after it disappears — while the diagram still parses and still +# draws. Found by rendering the diagrams in Chromium and diffing the visible text against +# the source; this lints the two silent cases so they cannot come back. +run "mermaid labels keep their text on GitHub" scripts/check-mermaid.py + +# ⚠️ **A link to a renamed SECTION fails silently** — the page opens at the top and the +# reader never learns they were sent somewhere specific. This repo has produced that +# class repeatedly: `§10` for a checklist that was `§9`, a "step 5" that renumbered, a +# "commit identification above" that had been deleted. +run "every doc link resolves, file and #anchor" scripts/check-doc-links.py + +# ⚠️ **The rule this enforces lived in AGENTS.md as prose for exactly one commit**, and +# the sweep that cleared 1,117 references still left three behind — all three the same +# shape a hand-written scan pattern had excluded. `docs/agents/` is exempt; a qualified +# external tracker (`owner/repo#NN`) is allowed and has one live instance. +run "no bare issue reference outside docs/agents/" scripts/check-issue-refs.py + +# ⚠️ **The glyph only works while it is rare, and nothing else measures that.** Two +# things a check can own: no marker in a test NAME, and the tree-wide count may only fall +# (a ratcheting ceiling, which is the only form that covers Markdown and trailing +# comments as well as comment blocks). Whether a marker earns its glyph is a review +# question; AGENTS.md carries that rule. +run "the ⚠️ marker marks traps, not facts" scripts/check-markers.py + +# Reads the regenerated .did, so it sits after the build step. No endpoint returns a +# `Result<_, Text>`; this keeps it that way. +run "every endpoint error type is a variant, not text" scripts/check-typed-errors.py + +# ⚠️ **The Motoko half of the step above, and the compiler covers NONE of it:** M0194 +# fires only in the canister's main file, so a module of dead exports compiles clean +# under -Werror. Four instances were live when this was added, one of them a renderer +# propping up a test that asserted a sentence nothing produced. +run "no module function is called only by its tests" scripts/check-unused-motoko.py + +# ⚠️ **After the .did check, and before the integration typecheck.** The suite's decoders +# are generated FROM that file, so this only means anything once it is known current — and +# the typecheck downstream is what turns a Motoko signature change into a failure. +run "integration bindings match the canister" scripts/check-bindings.sh + +# `npm run build` regenerates the bindings the typecheck needs; running the +# typecheck first on a clean tree fails for the wrong reason. +run "frontend build — regenerate bindings" npm --prefix src/frontend run build +run "frontend typecheck" npm --prefix src/frontend run typecheck +run "frontend tests" npm --prefix src/frontend run test + +# The test-only fixture hook (src/frontend/src/fixtures.ts) exists behind a +# `define`d `__FIXTURES__` so the browser specs can reach the post-purchase +# surfaces. Its absence from the SHIPPING bundle is the whole basis for letting it +# exist, and dead-code elimination is a compiler behaviour rather than a promise — +# so it is checked here against the bytes that would be deployed. +step=$((step + 1)) +if lane_active "no test hooks in the shipping bundle"; then +printf '\n\033[1m── %d. %s\033[0m\n' "$step" "no test hooks in the shipping bundle" +if grep -rl "__cyclepayFixtures" src/frontend/dist >/dev/null 2>&1; then + printf '\n\033[31m✗ the fixture hook is present in src/frontend/dist — a fixtures build was left in the shipping output\033[0m\n' >&2 + grep -rl "__cyclepayFixtures" src/frontend/dist >&2 + exit 1 +fi +printf ' dist/ carries no fixture hook\n' +else + printf '\n\033[2m── %d. no test hooks in the shipping bundle — SKIPPED (--changed)\033[0m\n' "$step" + SKIPPED_STEPS="$SKIPPED_STEPS + - no test hooks in the shipping bundle" +fi + +# The reserve floor's premise, enforced rather than asserted. +# +# `Reserve.mo`'s floor is a lower bound on the reserve balance ONLY because the +# balance cannot fall except when we transfer out. What makes that true is that +# `Cmc.CyclesLedgerService` does not declare the two methods the account's owner could +# otherwise use — `icrc2_approve` (which would create an allowance for someone else to +# pull from the account) and the ledger's `withdraw`. Undeclared means uncallable. +# +# So the premise has exactly one failure mode: someone widens that actor type. A +# comment cannot catch that; this can. The floor breaks SILENTLY and in the optimistic +# direction — it would keep admitting sales against cycles that had left — which is +# why this is a gate failure and not a warning. +step=$((step + 1)) +printf '\n\033[1m── %d. %s\033[0m\n' "$step" "the reserve account has exactly one outflow" +# ⚠️ **The whole tree, deliberately.** A second actor declaration anywhere — inline in +# Main.mo as `actor "um5iw-…" : actor { withdraw : … }`, or a new binding in any module +# — widens what this canister can call without touching the service type in +# Delivery.mo. The threat is "a declaration exists", so the search is for declarations, +# everywhere. +if ! lane_active "the reserve account has exactly one outflow"; then + printf '\n\033[2m── %d. the reserve account has exactly one outflow — SKIPPED (--changed)\033[0m\n' "$step" + SKIPPED_STEPS="$SKIPPED_STEPS + - the reserve account has exactly one outflow" +elif grep -rnE '(icrc2_approve|icrc2_transfer_from|withdraw)[[:space:]]*:' src/backend/ >/dev/null 2>&1; then + printf '\n\033[31m✗ a second way to move the reserve is declared somewhere in src/backend\033[0m\n' >&2 + grep -rnE '(icrc2_approve|icrc2_transfer_from|withdraw)[[:space:]]*:' src/backend/ >&2 + printf '\n The reserve floor is a LOWER BOUND only while `icrc1_transfer` is the only\n' >&2 + printf ' outflow. Adding one of these makes the balance fall in a way the floor cannot\n' >&2 + printf ' see, so the gate admits sales against cycles that already left — silently.\n' >&2 + printf ' Read the floor section in src/backend/Reserve.mo before proceeding.\n' >&2 + exit 1 +else + printf ' icrc1_transfer is the only declared way out, tree-wide\n' +fi + +# Brand guidelines: the mechanically checkable rules only (banned characters, +# banned vocabulary, hardcoded colour, the no-auto-dark rule). Typography and +# hierarchy still need eyes. +run "brand lint — user-facing copy and tokens" bash scripts/brand-lint.sh + +# Browser specs. These cover what jsdom is structurally blind to: the +# CASCADE and LAYOUT. A class selector's `display` outranks the UA stylesheet's +# `[hidden]`, so elements the app had hidden stayed on screen while every DOM +# test passed — that shipped once, and these exist so it cannot again. +# Serves the built dist over a static server; needs no local network. +if [ -d test/browser/node_modules ]; then + run "browser specs — cascade, layout, reachability" npm --prefix test/browser test +else + # FAILS, rather than warning and carrying on to a green tick. Nobody asked for + # this skip: it means the dependency is not installed, and the fix is one + # command. Every frontend claim in this gate — that a view is on screen, that + # the delivered tour renders, that a poll does not repaint a dead view — is + # unverified without it, and a yellow line above "✓ gate passed" is not how you + # tell someone that. + printf '\n\033[31m✗ the browser specs cannot run, so nothing about the frontend is verified.\033[0m\n' >&2 + printf '\033[31m Install them: npm --prefix test/browser ci && npx --prefix test/browser playwright install chromium\033[0m\n' >&2 + exit 1 +fi + +if [ "$FAST" -eq 1 ]; then + printf '\n\033[31m⚠ skipped the PocketIC suite (--fast)\033[0m\n' >&2 +elif ! lane_active "PocketIC integration suite"; then + step=$((step + 2)) + printf '\n\033[2m── integration typecheck + PocketIC suite — SKIPPED (--changed)\033[0m\n' + SKIPPED_STEPS="$SKIPPED_STEPS + - integration typecheck + - PocketIC integration suite" +else + # `npm test` and never `npx vitest run`: the latter skips pretest, which fetches + # the sha256-pinned wasms and rebuilds the backend — so it would silently test a + # stale wasm and pass. + # Typecheck the suite too: vitest does not, so a broken spec would otherwise + # only surface as a runtime failure — or not at all for an unused helper. + run "integration typecheck" npm --prefix test/integration run typecheck + run "PocketIC integration suite — the go-live bar" \ + npm --prefix test/integration test +fi + +# ⚠️ **Last, and advisory by design.** It PRINTS lines the change adds that name a +# deleted mechanism, and exits zero on every one of them — most such lines are correct +# prose, and a check that fires on correct code teaches people to route around it. Last +# so its output sits immediately above the summary, where it gets read. +# +# ⚠️ It fails on exactly one thing: no determinable base ref. That is the didn't-run +# case, and a scan with nothing to scan reads exactly like a clean one. An empty diff is +# a genuine pass here, unlike every other step, where empty input means the step is +# aimed at nothing. +run "vocabulary the change ADDS (advisory — read the hits)" python3 scripts/sweep-vocabulary.py + +# One summary line, and it must not say "passed" when a suite did not run. --fast +# is an explicit request so it still exits 0, but a green tick after an admitted +# skip is the thing that lets "the gate is green" be quoted for a run that never +# checked the go-live bar. +if [ -n "$SKIPPED_STEPS" ]; then + # ⚠️ **Never "passed" for a partial run.** The whole point of one entry point is + # that "the gate is green" means something; a lane-filtered run has to say so in + # the same breath, and name what it did not check rather than leaving the reader to + # infer it from a flag they may not have seen. + printf '\n\033[31m✗ gate PARTIAL (--changed against %s): the steps that ran passed.\033[0m\n' "$BASE" >&2 + printf '\033[31m NOT verified:%s\033[0m\n' "$SKIPPED_STEPS" >&2 + printf '\033[31m Run scripts/test-all.sh with no flags before claiming the gate is green.\033[0m\n' >&2 +elif [ "$FAST" -eq 1 ]; then + printf '\n\033[31m✗ gate INCOMPLETE: everything except the PocketIC suite passed.\033[0m\n' >&2 + printf '\033[31m The go-live bar is UNVERIFIED. Run without --fast before claiming it.\033[0m\n' >&2 +else + printf '\n\033[32m✓ gate passed\033[0m\n' +fi diff --git a/skills-lock.json b/skills-lock.json deleted file mode 100644 index 7666bad..0000000 --- a/skills-lock.json +++ /dev/null @@ -1,173 +0,0 @@ -{ - "version": 1, - "skills": { - "asset-canister": { - "source": "dfinity/icskills", - "sourceType": "github", - "skillPath": "skills/asset-canister/SKILL.md", - "computedHash": "9c7d9f132a00440288123f76af21a50977178b65d3bd56d4077250ec008c71e7" - }, - "autosync-ic-skills": { - "source": "dfinity/icskills", - "sourceType": "github", - "skillPath": "skills/autosync-ic-skills/SKILL.md", - "computedHash": "0ccbf3c4c42156bf3c3150efda6a97e4db27068df498e6b9ad781c0aa80eefb8" - }, - "canhelp": { - "source": "dfinity/icskills", - "sourceType": "github", - "skillPath": "skills/canhelp/SKILL.md", - "computedHash": "d7418dc6133afbf815501c1c46ce2129c6eb1e9332adbc485e0bdeef9f31505c" - }, - "canister-security": { - "source": "dfinity/icskills", - "sourceType": "github", - "skillPath": "skills/canister-security/SKILL.md", - "computedHash": "8367257eb731552d5406e4d7ee49879036b7bcbb9483fdb2664d1953e81738f1" - }, - "certified-variables": { - "source": "dfinity/icskills", - "sourceType": "github", - "skillPath": "skills/certified-variables/SKILL.md", - "computedHash": "9c099d6cf8cebdb4dd7ee97f4f9fe200ea0e8c297b9c6bc062a8d9c6c8978d5a" - }, - "ckbtc": { - "source": "dfinity/icskills", - "sourceType": "github", - "skillPath": "skills/ckbtc/SKILL.md", - "computedHash": "549b31591c740983bde86c660d9574da50890525ecac764b26b440b2503727f3" - }, - "custom-domains": { - "source": "dfinity/icskills", - "sourceType": "github", - "skillPath": "skills/custom-domains/SKILL.md", - "computedHash": "005687085a6e2e57f7c3bb35481f6f1c2689a33c32b3ae1d58a7cf0ecec645f2" - }, - "cycles-management": { - "source": "dfinity/icskills", - "sourceType": "github", - "skillPath": "skills/cycles-management/SKILL.md", - "computedHash": "4b8f9ac545376886b6473084ae5de6c77edc4856c2da339f95d3a0b22528068d" - }, - "evm-rpc": { - "source": "dfinity/icskills", - "sourceType": "github", - "skillPath": "skills/evm-rpc/SKILL.md", - "computedHash": "9099a405cda7f1ada279acfb2c04a322df90ec7701ddecae5a8f0e671fd3f340" - }, - "grill-me": { - "source": "mattpocock/skills", - "sourceType": "github", - "skillPath": "skills/productivity/grill-me/SKILL.md", - "computedHash": "784f0dbb7403b0f00324bce9a112f715342777a0daee7bbb7385f9c6f0a170ea" - }, - "https-outcalls": { - "source": "dfinity/icskills", - "sourceType": "github", - "skillPath": "skills/https-outcalls/SKILL.md", - "computedHash": "0207638b0c3b0410900d355ad0dc63343f6ac1102b338b0349d430de34265118" - }, - "ic-dashboard": { - "source": "dfinity/icskills", - "sourceType": "github", - "skillPath": "skills/ic-dashboard/SKILL.md", - "computedHash": "ea969d231566b38d90c16a59f165d16496dd5f47faf3c7e939a051aeecc6fcb3" - }, - "icp-cli": { - "source": "dfinity/icskills", - "sourceType": "github", - "skillPath": "skills/icp-cli/SKILL.md", - "computedHash": "ae12383d4605471b6b8071ab0376856ca0789f8c9146cc4dc8e3bfad66695d48" - }, - "icrc-ledger": { - "source": "dfinity/icskills", - "sourceType": "github", - "skillPath": "skills/icrc-ledger/SKILL.md", - "computedHash": "0a87182ac37e596cdc8c0c329d18191483ae25a7e5dfd647fee717a08bd92d7e" - }, - "improve-ic-skill": { - "source": "dfinity/icskills", - "sourceType": "github", - "skillPath": ".agents/skills/improve-ic-skill/SKILL.md", - "computedHash": "eb96d515968842884ad9e175e5a6372cc67e0cdb8b0b47a9600e1459026cdc9a" - }, - "internet-identity": { - "source": "dfinity/icskills", - "sourceType": "github", - "skillPath": "skills/internet-identity/SKILL.md", - "computedHash": "c72b05ba9b44173e050b6d50b89c44654fc906dd0d4ebd264ccf5d2452dea84a" - }, - "migrating-motoko": { - "source": "dfinity/icskills", - "sourceType": "github", - "skillPath": "skills/migrating-motoko/SKILL.md", - "computedHash": "c3bf144fa83262ae3d0a212b8bb45be11641e7f25cc8e059dffb4715bd91f4cd" - }, - "migrating-motoko-enhanced": { - "source": "dfinity/icskills", - "sourceType": "github", - "skillPath": "skills/migrating-motoko-enhanced/SKILL.md", - "computedHash": "2b55988526543ec4ec28f028e7025838110daf7be8fa8fce2e64f9ae4871d1e8" - }, - "mops-cli": { - "source": "dfinity/icskills", - "sourceType": "github", - "skillPath": "skills/mops-cli/SKILL.md", - "computedHash": "8dc49e796c33b8a7f9afaf710633ae7368c652f306e4cdacb5247a7372432e0d" - }, - "motoko": { - "source": "dfinity/icskills", - "sourceType": "github", - "skillPath": "skills/motoko/SKILL.md", - "computedHash": "95213a75a9c65edb3fede53fcc0bfb42edb6fbec85e91d2ca0fb1095a75d391b" - }, - "multi-canister": { - "source": "dfinity/icskills", - "sourceType": "github", - "skillPath": "skills/multi-canister/SKILL.md", - "computedHash": "278aff0df2bf5c20af2ca0c5ae64452c1784d3f770540c78f11481b3da9c443d" - }, - "skill-creator": { - "source": "dfinity/icskills", - "sourceType": "github", - "skillPath": ".agents/skills/skill-creator/SKILL.md", - "computedHash": "d53356a7a117bd9f1de43aff36a0fd16ab816786872588ab36f93f7819a2b245" - }, - "sns-launch": { - "source": "dfinity/icskills", - "sourceType": "github", - "skillPath": "skills/sns-launch/SKILL.md", - "computedHash": "7d02bb7addb5504ae160f94d53a97a2b1b9a6d3338efb9bc262903fbeb198056" - }, - "stable-memory": { - "source": "dfinity/icskills", - "sourceType": "github", - "skillPath": "skills/stable-memory/SKILL.md", - "computedHash": "bc26d61750c8a22000fed6b7d752d82dfd773fc22889346e904e6cefd88b0078" - }, - "to-issues": { - "source": "mattpocock/skills", - "sourceType": "github", - "skillPath": "skills/engineering/to-issues/SKILL.md", - "computedHash": "47f648f3414848ccfc62cb41d2828b7e575fb5e7cbd6c4bdf630c063b5dc5e82" - }, - "to-prd": { - "source": "mattpocock/skills", - "sourceType": "github", - "skillPath": "skills/engineering/to-prd/SKILL.md", - "computedHash": "a6d6d475f1d01c201937fa0c306e55b7056b02023ad615bc92f57514e831dd31" - }, - "vetkd": { - "source": "dfinity/icskills", - "sourceType": "github", - "skillPath": "skills/vetkd/SKILL.md", - "computedHash": "c80cee4c04d2b9e044e5cfd9436e7a8ac973ef895296c4e7824b3adfaafcccdd" - }, - "wallet-integration": { - "source": "dfinity/icskills", - "sourceType": "github", - "skillPath": "skills/wallet-integration/SKILL.md", - "computedHash": "b4ba1162c10886dfead681286676d66e5c9bc0aa104e3ef45a03975e2a8d99a5" - } - } -} diff --git a/src/backend/AuditLog.mo b/src/backend/AuditLog.mo index c673378..8fef99c 100644 --- a/src/backend/AuditLog.mo +++ b/src/backend/AuditLog.mo @@ -1,11 +1,46 @@ -/// Bounded audit-log ring buffer (§4.2). +/// The audit log — unbounded, append-only operational trail (§4.2). /// -/// Operational trail — balance alerts (§5.3), dedup drops, error-queue -/// evictions — NOT a financial record. Orders, journal, and the error queue -/// are the records of money and are retained; this buffer hard-caps and drops -/// oldest-first. `seq` is monotonic and never reused, so a reader holding the -/// last seq it saw can tell dropped events from an empty interval. +/// **Facts about money live on the objects, not here.** Where an order got to, what the +/// buyer paid, why it expired: all on `Types.Order`. The journal holds the delivery +/// position; the order's `problems` and the orphan list hold open obligations. This is +/// where to look for "what happened around then", never for "what happened to this order". +/// +/// A refund is the one money fact deliberately not on the order: it lives in **Stripe**, +/// where it was issued, plus the unresolved `#refundAfterDelivery` problem. +/// +/// ## ⚠️ What may be written here — the admission rule +/// +/// > **An audit line may be written only where something other than a caller's +/// > willingness to call bounds how often it fires.** +/// +/// Qualifying bounds, and they are the only three: **our own timer cadence**, **an +/// authenticated event** (the `stripe.*` lines need the signing secret, so growth is +/// bounded by Stripe rather than by a caller), or **a state transition** +/// (`gate.startedRefusing` fires once when the rail starts refusing, not per refused +/// request). +/// +/// **The rule is about frequency, not about what the line describes.** Phrasing it as +/// "records a state change, not a request" excludes legitimate lines — the outcome of our +/// own outbound request is the only detector for a silently non-functioning recovery +/// sweep, and it is bounded by our cadence, not by a caller. +/// +/// **Our cadence bounds a RATE, and a rate against an unfixed persistent condition is +/// unbounded over time.** A per-pass line about a condition nobody has fixed is not +/// admissible just because a timer paces it; latch the condition and write once. +/// +/// What the rule excludes is a pre-commit refusal line: refusals are free to attempt — +/// one cent from any fresh principal reaches `#amountBelowMin` — so a log fed by a free +/// caller is permanent stable-state growth at zero attacker cost. Every other structure +/// here is attacker-priced. This one is not, so admission is the guard. +/// +/// **Adding a tag?** Name the bound. If the honest answer is "a caller decides", it is a +/// counter with a monitoring row, not a line. +/// +/// **Nothing is dropped and the `capacity` parameter is gone rather than large** +/// `seq` is monotonic and never reused; with no drops there are no gaps, so a +/// reader holding the last seq it saw can tell new events from an empty interval. import Queue "mo:core/Queue"; +import List "mo:core/List"; import Iter "mo:core/Iter"; module { @@ -13,7 +48,7 @@ module { public type Event = { seq : Nat; atNs : Int; - /// Short greppable category, e.g. "treasury.lowFloat", "errorQueue.evicted". + /// Short greppable category, e.g. "delivery.sent", "orphanStore.resolved". tag : Text; detail : Text; }; @@ -24,20 +59,97 @@ module { }; public func emptyLog() : Log { - { events = Queue.empty(); var nextSeq = 0 }; + { events = Queue.empty(); var nextSeq = 0 }; }; - /// Append, dropping oldest events past `capacity`. - public func append(log : Log, capacity : Nat, atNs : Int, tag : Text, detail : Text) : Event { + /// Append. **Nothing is evicted**. + /// + /// ⚠️ **The `capacity` parameter is gone, not defaulted to a large number.** A bound + /// that is never reached still has to be reasoned about at every call site, and a later + /// "let's lower it for safety" would silently reintroduce a lossy log under a name that + /// says it is not. There is nowhere left to put a cap. + /// + /// ⚠️ **Only safe because of the admission rule above**, and only once that rule has + /// been run against the WHOLE tag population rather than the paths that prompted it. + public func append(log : Log, atNs : Int, tag : Text, detail : Text) : Event { let event : Event = { seq = log.nextSeq; atNs; tag; detail }; log.nextSeq += 1; log.events.pushBack(event); - while (log.events.size() > capacity) { - ignore log.events.popFront(); - }; event; }; + /// Cap on one page. Matches `Orders.maxPageSize` and `Orphans.maxPageSize` so an + /// operator learns one number rather than three. + public let maxPageSize : Nat = 200; + + public type Page = { events : [Event]; nextCursor : ?Nat }; + + /// One page of events, oldest → newest, after `afterSeq`. + /// + /// ⚠️ **Retention is total, which is why this has to paginate.** Nothing bounds the + /// log, and a query response is capped at ~2 MB — so an unpaged read is on a path to + /// *"the query cannot answer"*. ⚠️ **Do not answer that with a cap on the log**: that + /// trades it for *"history is lossy"*, which is the worse of the two. + /// + /// Cursor on `seq`, which is monotonic, never reused, and has **no gaps**, since + /// nothing is dropped. `nextCursor` is set only when further events remain, so a caller + /// stops the moment it is null. + public func page(log : Log, afterSeq : ?Nat, limit : Nat) : Page { + let capped = if (limit == 0 or limit > maxPageSize) maxPageSize else limit; + let collected = List.empty(); + var last : ?Nat = null; + for (event in log.events.values()) { + let past = switch (afterSeq) { case (?cursor) event.seq > cursor; case null true }; + if (past) { + if (collected.size() == capped) return { events = collected.toArray(); nextCursor = last }; + collected.add(event); + last := ?event.seq; + }; + }; + { events = collected.toArray(); nextCursor = null }; + }; + + /// One page of events, **newest → oldest**, strictly older than `beforeSeq`. + /// + /// **Why this exists rather than reversing `page`'s result.** Reversing a page gives + /// the OLDEST events in descending order, which is the opposite of what an operator + /// opening the console wants; and reversing the whole log to find the tail defeats the + /// paging. The store is a `Queue`, so `reverseValues` walks from the newest end and this + /// costs O(limit) for the first page where `page` costs O(n) to reach a late cursor. + /// + /// ⚠️ **The STORAGE order stays ascending, and `page` is unchanged.** Inverting the + /// existing endpoint would have broken two integration scenarios, one of them silently: + /// `allAuditEvents(gw).slice(auditBefore)` means "events since I last looked" and + /// assumes append-at-end, so under a descending log it would have kept running while + /// inspecting the wrong window. An append-only log is ascending; a console wants a + /// reverse view of it. Those are different questions and this is the second one. + /// + /// ⚠️ **`nextCursor` means the opposite of `page`'s, which is the one trap here.** For + /// `page` it is the NEWEST seq returned, to be passed back as `afterSeq`. For this it is + /// the OLDEST seq returned, to be passed back as `beforeSeq`. Same `Page` type, mirrored + /// meaning; a caller that swaps them pages the wrong way and sees a stuck first page. + /// + /// `seq` starts at 0, so `beforeSeq = ?0` correctly yields nothing older and `null` + /// means "start at the newest". + public func recentPage(log : Log, beforeSeq : ?Nat, limit : Nat) : Page { + let capped = if (limit == 0 or limit > maxPageSize) maxPageSize else limit; + let collected = List.empty(); + var oldest : ?Nat = null; + for (event in log.events.reverseValues()) { + let older = switch (beforeSeq) { case (?cursor) event.seq < cursor; case null true }; + if (older) { + // Reaching the cap with another qualifying event still to come is exactly the + // condition that means "more remain", so the cursor is set here and nowhere else. + if (collected.size() == capped) { + return { events = collected.toArray(); nextCursor = oldest }; + }; + collected.add(event); + oldest := ?event.seq; + }; + }; + { events = collected.toArray(); nextCursor = null }; + }; + /// Retained events, oldest → newest. public func events(log : Log) : [Event] { log.events.values().toArray(); diff --git a/src/backend/Auth.mo b/src/backend/Auth.mo index f1430cd..47d04ab 100644 --- a/src/backend/Auth.mo +++ b/src/backend/Auth.mo @@ -8,11 +8,25 @@ /// true M-of-N needs a multisig canister installed as the sole controller /// (the documented hardening path, §7). /// -/// Admin authz = caller ∈ controllers. The controller check is injected as a -/// predicate so this module stays pure and unit-testable; the composition -/// root passes `Principal.isController` (ic0.is_controller). Anonymous is -/// rejected *before* the predicate is consulted — `2vxsx-fae` must never be -/// an admin even if it somehow lands in the controller set. +/// ⚠️ **Authz is TWO tiers, not one.** "Admin authz = caller ∈ controllers" was +/// true until an app-admin role existed: +/// +/// - `checkController` — the controller set, unchanged. Everything that changes the +/// RULES: secrets, pricing, the gate, the mode, the delivery bounds, and the admin +/// list itself. A controller can upgrade, withdraw and rotate, so this tier is +/// canister control. +/// - `checkAdmin` — controller **or** a listed admin principal. Everything that resolves +/// an individual CASE: the operator reads, the idempotent levers, and the per-order +/// decisions. An admin can end one order and cannot upgrade the canister. +/// +/// ⚠️ **Why an admin tier at all, and it is not ergonomics: the audit trail becomes +/// truthful.** Every admin method audits `caller`. With controller-only mutations, an +/// action is attributed to whoever holds the controller key regardless of who decided it. +/// +/// Both checks inject their predicates so this module stays pure and unit-testable; the +/// composition root passes `Principal.isController` (ic0.is_controller) and a membership +/// test over the stable admin set. Anonymous is rejected *before* either predicate is +/// consulted — `2vxsx-fae` must never be an admin even if it somehow lands in a set. /// /// User authz: anything caller-keyed (order creation/lookup, task 6) must /// reject the anonymous principal — it is a shared identity, so an order @@ -26,7 +40,7 @@ module { public type UserError = { #anonymous }; - public type AdminError = { #anonymous; #notController }; + public type AdminError = { #anonymous; #notController; #notAdmin }; /// Gate for the II-authenticated user API: any non-anonymous principal. public func checkUser(caller : Principal) : Result.Result<(), UserError> { @@ -34,8 +48,8 @@ module { #ok; }; - /// Gate for admin methods: non-anonymous AND in the controller set. - public func checkAdmin( + /// The RULES tier: non-anonymous AND in the controller set. + public func checkController( caller : Principal, isController : Principal -> Bool, ) : Result.Result<(), AdminError> { @@ -44,4 +58,21 @@ module { #ok; }; + /// The CASES tier: non-anonymous AND (a controller OR a listed admin). + /// + /// ⚠️ **A controller passes here too, deliberately.** The tiers are nested, not + /// disjoint: a controller can already change the rules, so denying them a per-order + /// decision would only mean adding themselves to the list first. Reading it as an + /// either/or would make the controller a weaker principal than the admin it grants. + public func checkAdmin( + caller : Principal, + isController : Principal -> Bool, + isAdmin : Principal -> Bool, + ) : Result.Result<(), AdminError> { + if (caller.isAnonymous()) return #err(#anonymous); + if (isController(caller)) return #ok; + if (not isAdmin(caller)) return #err(#notAdmin); + #ok; + }; + }; diff --git a/src/backend/Cmc.mo b/src/backend/Cmc.mo index 65cc55c..bfde0e1 100644 --- a/src/backend/Cmc.mo +++ b/src/backend/Cmc.mo @@ -1,89 +1,20 @@ -/// CMC mint pipeline — the pure half (§5, §5.1). +/// The Cycles Minting Canister, read for **one thing**: `xdr_permyriad_per_icp`. /// -/// Candid interfaces for the ICP ledger + CMC + cycles ledger, deterministic -/// transfer-intent construction, ICP amount derivation off the CMC rate, and -/// the resume/replay decision logic. Everything here is synchronous and -/// unit-testable; Main.mo owns the awaits, the journal/order stores, and the -/// forward step. +/// That rate is stored on every order beside the XRC's USD/ICP price, so a buyer can +/// recompute their own quote from two public canisters instead of trusting ours. ICP +/// cancels out of the arithmetic, which is why both halves have to come from the same +/// refresh — see `Pricing.mo`. /// -/// §5.1 invariant this module exists to uphold: the transfer args are -/// **deterministic and persisted before the ledger call** (write-intent- -/// before-call). Recovery replays the *identical* args — the ledger either -/// performs the transfer once or answers `#Duplicate { duplicate_of }`; -/// either way the `block_index` is recovered with no double-spend. An intent -/// older than the ledger's ~24 h dedup window without a known block_index is -/// never auto-replayed (the original transfer's fate is unknowable) — it -/// escalates to the error queue. -/// -/// Delivery is **mint-to-self-then-forward** (§5): one notify path, -/// `notify_top_up(self)` with the TPUP memo, for *both* destination kinds — -/// cycles always land in the app canister's own balance first, so a failed -/// forward strands them exactly where Type 2 (§4.1) says they are. The -/// CMC's `notify_mint_cycles`/MINT path is deliberately unused: it would -/// strand value in the app's *cycles-ledger* balance instead, splitting the -/// Type-2 invariant across two places. -import Array "mo:core/Array"; -import Blob "mo:core/Blob"; -import Map "mo:core/Map"; -import Nat "mo:core/Nat"; -import Nat8 "mo:core/Nat8"; +/// ⚠️ **Nothing here mints, and nothing may.** This canister holds no ICP and has no +/// path to any: `CmcService` declares the rate query alone, so adding a method is the +/// only way to give it one. Delivery sells cycles the gateway already holds — see +/// `Delivery.mo`. import Nat64 "mo:core/Nat64"; -import Principal "mo:core/Principal"; -import Text "mo:core/Text"; -import Types "Types"; module { - // ── Well-known principals (identical on PocketIC's NNS subnet, §9) ────── - - /// ICP ledger. - public let icpLedgerId : Text = "ryjl3-tyaaa-aaaaa-aaaba-cai"; /// Cycles minting canister. public let cmcId : Text = "rkp4c-7iaaa-aaaaa-aaaca-cai"; - /// Cycles ledger (forward target for `#cyclesLedgerAccount` destinations). - public let cyclesLedgerId : Text = "um5iw-rqaaa-aaaaq-qaaba-cai"; - - // ── Candid interfaces (only the methods this pipeline calls) ──────────── - - public type TransferArg = { - from_subaccount : ?Blob; - to : Types.Account; - amount : Nat; - fee : ?Nat; - memo : ?Blob; - created_at_time : ?Nat64; - }; - - public type TransferError = { - #BadFee : { expected_fee : Nat }; - #BadBurn : { min_burn_amount : Nat }; - #InsufficientFunds : { balance : Nat }; - #TooOld; - #CreatedInFuture : { ledger_time : Nat64 }; - #Duplicate : { duplicate_of : Nat }; - #TemporarilyUnavailable; - #GenericError : { error_code : Nat; message : Text }; - }; - - public type TransferResult = { #Ok : Nat; #Err : TransferError }; - - public type LedgerService = actor { - icrc1_transfer : shared TransferArg -> async TransferResult; - icrc1_balance_of : shared query Types.Account -> async Nat; - }; - - public type NotifyTopUpArg = { block_index : Nat64; canister_id : Principal }; - - public type NotifyError = { - #Refunded : { reason : Text; block_index : ?Nat64 }; - #InvalidTransaction : Text; - #Other : { error_code : Nat64; error_message : Text }; - #Processing; - #TransactionTooOld : Nat64; - }; - - /// `#Ok` carries the cycles minted to `canister_id`'s balance. - public type NotifyTopUpResult = { #Ok : Nat; #Err : NotifyError }; public type IcpXdrConversionRate = { timestamp_seconds : Nat64; @@ -96,64 +27,18 @@ module { certificate : Blob; }; + /// ⚠️ **The rate query only.** The CMC is read for `xdr_permyriad_per_icp`, which is + /// stored on every order so a buyer can recompute their own price from public + /// canisters. Nothing is minted here and nothing may be: declaring a second method + /// would give this canister a way to spend ICP it does not hold. public type CmcService = actor { - notify_top_up : shared NotifyTopUpArg -> async NotifyTopUpResult; get_icp_xdr_conversion_rate : shared query () -> async IcpXdrConversionRateResponse; }; - public type DepositArgs = { to : Types.Account; memo : ?Blob }; - - public type CyclesLedgerService = actor { - deposit : shared DepositArgs -> async { balance : Nat; block_index : Nat }; - }; - - // ── Constants ──────────────────────────────────────────────────────────── - - /// ICP ledger transfer fee (e8s). Fixed protocol-wide; a change would - /// surface as `#BadFee` → escalation, never a silent wrong transfer. - public let icpTransferFeeE8s : Nat = 10_000; - - /// The CMC recognizes a top-up by this icrc1 memo: the 8-byte little-endian - /// encoding of 0x50555054 ("TPUP"). Pinned by test vector. - public let topUpMemo : Blob = "\54\50\55\50\00\00\00\00"; - - /// The ICP ledger deduplicates on `created_at_time` for ~24 h (§5.1). - /// At/after this age an intent without a block_index must escalate, never - /// replay. (86_400 s in ns; module-level lets must be static — no arithmetic.) - public let ledgerDedupWindowNs : Int = 86_400_000_000_000; - /// §5 staleness guard on the CMC conversion rate, mirroring CyclePay's /// post-incident 15 min (in ns). public let cmcRateMaxAgeNs : Int = 900_000_000_000; - // ── Pure derivations ───────────────────────────────────────────────────── - - /// The CMC subaccount that credits `canister` on `notify_top_up`: - /// 32 bytes = length-prefixed principal, zero-padded. Pinned by test vector. - public func topUpSubaccount(canister : Principal) : Blob { - let raw = canister.toBlob().toArray(); - let sub = Array.tabulate( - 32, - func(i) { - if (i == 0) { Nat8.fromNat(raw.size()) } else if (i <= raw.size()) { - raw[i - 1]; - } else { 0 }; - }, - ); - Blob.fromArray(sub); - }; - - /// ICP e8s that mint (at least) `cycles` at the CMC rate. 1 ICP = - /// `xdr_permyriad_per_icp`·10⁻⁴ XDR and 1 XDR = 10¹² cycles, so one e8s - /// mints exactly `xdr_permyriad_per_icp` cycles — rounding *up* means the - /// mint covers the locked quantity; the dust overshoot stays in the app - /// balance (operator side, §3 "operator absorbs drift"). Null on a zero - /// rate (a broken CMC answer must fail the mint, not trap the sweep). - public func icpE8sForCycles(cycles : Nat, xdrPermyriadPerIcp : Nat) : ?Nat { - if (xdrPermyriadPerIcp == 0) return null; - ?((cycles + xdrPermyriadPerIcp - 1) / xdrPermyriadPerIcp); - }; - /// §5 staleness guard: the rate iff younger than `maxAgeNs` (age ≥ window = /// stale, the Forex/Idempotency convention) and positive. The CMC clock is /// authoritative — a timestamp ahead of `nowNs` is fresh, not an error. @@ -165,223 +50,4 @@ module { ?permyriad; }; - /// §5.1 step 1 — the deterministic transfer args persisted *before* the - /// ledger call. Everything the ledger dedups on is fixed here; replaying - /// this intent can never move money twice. - public func buildIntent(gateway : Principal, amountE8s : Nat, nowNs : Int) : Types.TransferIntent { - { - createdAtTimeNs = Nat64.fromIntWrap(nowNs); - amountE8s; - to = { - owner = Principal.fromText(cmcId); - subaccount = ?topUpSubaccount(gateway); - }; - memo = topUpMemo; - }; - }; - - /// The intent's wire form. A pure projection — replay maps the *stored* - /// intent through this, so the replayed call is bit-identical to the first. - public func transferArgs(intent : Types.TransferIntent) : TransferArg { - { - from_subaccount = null; - to = intent.to; - amount = intent.amountE8s; - fee = ?icpTransferFeeE8s; - memo = ?intent.memo; - created_at_time = ?intent.createdAtTimeNs; - }; - }; - - // ── Result interpretation ──────────────────────────────────────────────── - - /// What the driver does next; `#retriable` leaves state untouched for the - /// recovery sweep, `#escalate` is terminal (error queue, §5.1). - public type TransferOutcome = { - /// Block index recovered — `#Ok` or `#Duplicate` (the §5.1 replay payoff: - /// a replayed intent that already executed *returns* its block). - #blockIndex : Nat; - #retriable : Text; - #escalate : Text; - }; - - public func interpretTransfer(result : TransferResult) : TransferOutcome { - switch (result) { - case (#Ok(block)) #blockIndex(block); - case (#Err(#Duplicate({ duplicate_of }))) #blockIndex(duplicate_of); - // Retriable: the ledger did NOT record a transfer, and the identical - // intent can succeed later (float refilled, ledger back up, ledger - // clock caught up to created_at_time). - case (#Err(#TemporarilyUnavailable)) #retriable("ledger temporarily unavailable"); - case (#Err(#InsufficientFunds({ balance }))) #retriable("insufficient float: balance " # balance.toText() # " e8s"); - case (#Err(#CreatedInFuture(_))) #retriable("created_at_time ahead of ledger clock"); - case (#Err(#GenericError({ error_code; message }))) #retriable("ledger error " # error_code.toText() # ": " # message); - // Escalations: replaying the identical args can never succeed. - case (#Err(#TooOld)) #escalate("intent aged past the ledger dedup window"); - case (#Err(#BadFee({ expected_fee }))) #escalate("ledger fee changed: expected " # expected_fee.toText() # " e8s"); - case (#Err(#BadBurn(_))) #escalate("ledger answered BadBurn to a transfer"); - }; - }; - - public type NotifyOutcome = { - /// Cycles credited to the app canister's balance. - #minted : Nat; - #retriable : Text; - #escalate : Text; - }; - - public func interpretNotify(result : NotifyTopUpResult) : NotifyOutcome { - switch (result) { - case (#Ok(cycles)) #minted(cycles); - case (#Err(#Processing)) #retriable("CMC still processing this block"); - case (#Err(#Other({ error_code; error_message }))) #retriable("CMC error " # error_code.toText() # ": " # error_message); - // Refunded = the CMC sent the ICP back (minus fee) and minted nothing; - // the order cannot complete automatically. InvalidTransaction / - // TransactionTooOld = the CMC rejects this block outright. - case (#Err(#Refunded({ reason; block_index = _ }))) #escalate("CMC refunded: " # reason); - case (#Err(#InvalidTransaction(reason))) #escalate("CMC rejected block: " # reason); - case (#Err(#TransactionTooOld(_))) #escalate("CMC: transaction too old"); - }; - }; - - // ── Journal (§4.2 `journal : Map`) ─────────────── - - public type Journal = Map.Map; - - public func emptyJournal() : Journal { - Map.empty(); - }; - - /// §5.1 step 1: persist the intent. Written in the same synchronous block - /// as the `#paid → #minting` transition, *before* the transfer await. - public func openEntry(journal : Journal, order : Types.Order, intent : Types.TransferIntent, nowNs : Int) : Types.JournalEntry { - let entry : Types.JournalEntry = { - orderId = order.id; - status = #minting; - destination = order.destination; - transferIntent = ?intent; - blockIndex = null; - cyclesMinted = null; - retries = 0; - createdAtNs = nowNs; - updatedAtNs = nowNs; - }; - journal.add(order.id, entry); - entry; - }; - - /// Patch the entry; no-op on a missing id (a pipeline bug must degrade to - /// a stuck order the sweep escalates, never a trap mid-money-flow). - public func patch( - journal : Journal, - orderId : Types.OrderId, - changes : { - status : ?Types.OrderStatus; - blockIndex : ?Nat; - cyclesMinted : ?Nat; - bumpRetries : Bool; - }, - nowNs : Int, - ) { - let ?entry = journal.get(orderId) else return; - journal.add( - orderId, - { - entry with - status = switch (changes.status) { case (?s) s; case null entry.status }; - blockIndex = switch (changes.blockIndex) { case (?b) ?b; case null entry.blockIndex }; - cyclesMinted = switch (changes.cyclesMinted) { case (?c) ?c; case null entry.cyclesMinted }; - retries = entry.retries + (if (changes.bumpRetries) 1 else 0); - updatedAtNs = nowNs; - }, - ); - }; - - // ── Resume/replay decision (§5.1, §5.2) ────────────────────────────────── - - /// Why a mint can no longer proceed automatically. - public type EscalateReason = { - /// Intent without a block_index aged past the dedup window — the original - /// transfer's fate is unknowable; auto-replay risks a double-spend (§5.1). - #staleIntent; - /// Retry budget exhausted on a retriable error that never cleared. - #retriesExhausted; - /// The pre-forward marker is set but the order never reached #delivered — - /// the forward may or may not have happened; re-forwarding risks double - /// delivery, so the operator checks the destination instead. - #ambiguousForward; - /// Order status implies money-out state the journal doesn't have. - #missingJournal; - }; - - public func escalateReasonToText(reason : EscalateReason) : Text { - switch (reason) { - case (#staleIntent) "staleIntent"; - case (#retriesExhausted) "retriesExhausted"; - case (#ambiguousForward) "ambiguousForward"; - case (#missingJournal) "missingJournal"; - }; - }; - - /// The driver's next move for one order, derived from status + journal. - public type Stage = { - /// Nothing to do (terminal, pre-payment, or treasury-held). - #none; - /// `#paid`, no intent yet: derive the amount, write the intent, transfer. - #begin; - /// `#minting` with a fresh intent and no block: (re)issue the identical - /// transfer — first attempt and recovery replay are the same move (§5.1). - #replayTransfer : Types.TransferIntent; - /// Block known, mint unconfirmed: `notify_top_up` (idempotent on - /// block_index, §5.2), then forward. - #notifyCmc : Nat; - #escalate : EscalateReason; - }; - - /// Decide the next move. Pure — the §5.1/§5.2 resume semantics in one - /// testable place. `maxRetries` bounds the retriable-error loop on stages - /// the 24 h window doesn't already bound (notify can otherwise retry - /// forever). - public func stageOf( - status : Types.OrderStatus, - entry : ?Types.JournalEntry, - nowNs : Int, - dedupWindowNs : Int, - maxRetries : Nat, - ) : Stage { - switch (status) { - case (#paid) #begin; - case (#minting) { - let ?e = entry else return #escalate(#missingJournal); - if (e.retries >= maxRetries) return #escalate(#retriesExhausted); - switch (e.blockIndex) { - // Block recorded but the #icpAtCmc transition didn't commit — - // unreachable today (both happen in one sync block), handled so a - // future regression degrades to a resumable state. - case (?block) #notifyCmc(block); - case null { - let ?intent = e.transferIntent else return #escalate(#missingJournal); - if (nowNs - intent.createdAtTimeNs.toNat() >= dedupWindowNs) { - #escalate(#staleIntent); - } else { - #replayTransfer(intent); - }; - }; - }; - }; - case (#icpAtCmc) { - let ?e = entry else return #escalate(#missingJournal); - // cyclesMinted is the pre-forward marker: set *before* the forward - // await, so its presence here (status never reached #delivered) - // means the forward's fate is unknown. At-most-once delivery — - // never auto-re-forward. - if (e.cyclesMinted != null) return #escalate(#ambiguousForward); - if (e.retries >= maxRetries) return #escalate(#retriesExhausted); - let ?block = e.blockIndex else return #escalate(#missingJournal); - #notifyCmc(block); - }; - case (#created or #expired or #awaitingTreasury or #delivered or #errorQueue) #none; - }; - }; - }; diff --git a/src/backend/Delivery.mo b/src/backend/Delivery.mo new file mode 100644 index 0000000..e76192e --- /dev/null +++ b/src/backend/Delivery.mo @@ -0,0 +1,628 @@ +/// Delivering cycles from the reserve: the pure half. +/// +/// Everything money-out needs and nothing it does not — the cycles-ledger interface, +/// the crash-safe journal, the intent that makes a replay byte-identical, the resume +/// decision, and the timeline that bounds how long a buyer waits. +/// +/// **`CyclesLedgerService` is the reserve floor's enforcement mechanism.** +/// `Reserve.mo`'s floor is a lower bound only because the balance cannot fall except +/// when this canister transfers out, and what makes that true is the declaration +/// below: `icrc2_approve` and the ledger's `withdraw` are absent, so they cannot be +/// called. `scripts/test-all.sh` fails the gate on a declaration that widens it. +/// +/// ⚠️ **One logical outflow per delivery.** `icrc1_transfer` is called twice — the +/// attempt and its `#BadFee` re-issue of the same intent — and at most one debits: the +/// re-issue runs only after the ledger definitively refused, and a landed earlier +/// attempt is deduplicated. `Reserve.mo`'s rules 2 and 3 net to one decrement per +/// execution, so reading "two call sites" as "two outflows" would double-count every +/// delivery. +/// +/// ⚠️ **A third call site exists and it is a different DESTINATION CLASS, not a third +/// outflow mechanism**: `Main.withdraw_reserve` transfers to a controller rather +/// than to a buyer. `Reserve.mo`'s outflow section names both classes and what bounds +/// each; `withdrawArgs` below builds its args, and `withdrawMemo` separates the two in +/// the ledger's own record. +import Blob "mo:core/Blob"; +import Map "mo:core/Map"; +import Nat "mo:core/Nat"; +import Nat64 "mo:core/Nat64"; +import Principal "mo:core/Principal"; +import Result "mo:core/Result"; +import Text "mo:core/Text"; +import Types "Types"; + +module { + + // ── Well-known principals (identical on PocketIC's NNS subnet, §9) ────── + + /// Cycles ledger (forward target for `#cyclesLedgerAccount` destinations). + public let cyclesLedgerId : Text = "um5iw-rqaaa-aaaaq-qaaba-cai"; + + // ── Candid interfaces (only the methods this pipeline calls) ──────────── + + public type TransferArg = { + from_subaccount : ?Blob; + to : Types.Account; + amount : Nat; + fee : ?Nat; + memo : ?Blob; + created_at_time : ?Nat64; + }; + + public type TransferError = { + #BadFee : { expected_fee : Nat }; + #BadBurn : { min_burn_amount : Nat }; + #InsufficientFunds : { balance : Nat }; + #TooOld; + #CreatedInFuture : { ledger_time : Nat64 }; + #Duplicate : { duplicate_of : Nat }; + #TemporarilyUnavailable; + #GenericError : { error_code : Nat; message : Text }; + }; + + public type TransferResult = { #Ok : Nat; #Err : TransferError }; + + /// A ledger refusal, rendered for an operator. + /// + /// ⚠️ Exhaustive, so a new `TransferError` case has to decide here rather than + /// arriving as an unexplained failure on a money path. + public func transferErrorToText(error : TransferError) : Text { + switch (error) { + case (#BadFee({ expected_fee })) "BadFee(ledger expects " # expected_fee.toText() # ")"; + case (#BadBurn({ min_burn_amount })) "BadBurn(min " # min_burn_amount.toText() # ")"; + case (#InsufficientFunds({ balance })) "InsufficientFunds(balance " # balance.toText() # ")"; + case (#TooOld) "TooOld"; + case (#CreatedInFuture({ ledger_time })) "CreatedInFuture(ledger " # ledger_time.toText() # ")"; + case (#Duplicate({ duplicate_of })) "Duplicate(of block " # duplicate_of.toText() # ")"; + case (#TemporarilyUnavailable) "TemporarilyUnavailable"; + case (#GenericError({ error_code; message })) { + "GenericError(" # error_code.toText() # ": " # message # ")"; + }; + }; + }; + + + + + + + + + + /// ⚠️ **THIS TYPE IS THE RESERVE FLOOR'S ENFORCEMENT MECHANISM.** `Reserve.mo`'s + /// floor is sound only because the reserve balance cannot fall except when we + /// transfer out — and the reason it cannot is right here: **the account's owner + /// could call `icrc2_approve` or `withdraw` on the cycles ledger, and neither is + /// declared, so neither can be called.** Not "we do not plan to add them"; the + /// compiler will not let this canister reach them. + /// + /// So breaking the floor's premise takes a visible act in this file — adding a + /// method to this type — and `scripts/test-all.sh` fails the gate on exactly that. + /// Adding one here without reading `Reserve.mo`'s floor section turns a bound into + /// a guess, silently and in the optimistic direction. + public type CyclesLedgerService = actor { + /// **The one outflow.** Delivery pays the buyer from the canister's own + /// cycles-ledger account. Two syntactic call sites — the attempt and its + /// `#BadFee` re-issue — are ONE logical transfer of one intent, and at most one + /// of them debits: the re-issue only runs after a definitively-rejected attempt, + /// and a duplicate is deduplicated by the ledger. That is precisely why rules 2 + /// and 3 net to one floor decrement per real execution. + icrc1_transfer : shared TransferArg -> async TransferResult; + /// The reserve's authoritative balance, read by the hourly reconcile and by + /// `refresh_reserve` — **never by the gate.** ⚠️ An earlier version of this + /// comment said `create_order` called it as the gate's read; that read is gone — + /// that read entirely, because an awaited value is historical by the time it is + /// used. The gate decides against the maintained floor, synchronously. + icrc1_balance_of : shared query Types.Account -> async Nat; + }; + + /// The canister's own cycles-ledger account — the reserve. + /// + /// Default subaccount, matching the rule for a buyer's destination: one + /// canonical form, so "the reserve" names exactly one account in the ledger, + /// in `reserve_status`, and in whatever an operator types at a terminal. + public func reserveAccount(gateway : Principal) : Types.Account { + { owner = gateway; subaccount = null }; + }; + + // ── Constants ──────────────────────────────────────────────────────────── + + + + + /// **Seed** for the stored cycles-ledger transfer fee. The live value + /// lives in `Main.cyclesLedgerFee`, because the ledger owns it and can move it. + /// + /// It is a seed and not a constant: `#BadFee` carries the ledger's expected fee, + /// so the first delivery after any change corrects the stored copy and every + /// later one uses the corrected value. That self-correction is why delivery no + /// longer awaits `icrc1_fee` — see the stored variable's doc for the trade. + /// + /// ⚠️ **Not disclosed through `quote_previews`.** The fee applies to the transfer + /// the gateway makes, so it is the operator's cost, not a line in the buyer's + /// quote; the frontend reads the ledger for what a buyer's own transfer would + /// cost. Do not add it to the preview — `docs/STRIPE.md` explains why absorbing + /// it rather than grossing it up is the safe direction. + public let cyclesLedgerDefaultFee : Nat = 100_000_000; + + + /// The cycles ledger deduplicates on `created_at_time` for ~24 h (§5.1). + /// At/after this age an intent without a block_index must escalate, never + /// replay. (86_400 s in ns; module-level lets must be static — no arithmetic.) + public let ledgerDedupWindowNs : Int = 86_400_000_000_000; + + + // ── Pure derivations ───────────────────────────────────────────────────── + + + + + + /// §5.1 for the reserve — the delivery transfer's frozen args. + /// + /// ⚠️ **`memo` is the order id, and that is a correctness requirement, not a + /// convenience.** The ledger dedups on `(created_at_time, from, to, amount, + /// memo)` — *not* on the order. Two `#paid` orders from one buyer for the same + /// amount are reachable (the open-order cap counts only `#created`, so + /// pay → create → pay produces exactly that), and if both intents are built in + /// the same round they share `Time.now()`, destination and amount. Identical + /// args means the second transfer is **falsely deduplicated**: order B is + /// marked delivered against order A's block and the buyer is shorted a whole + /// purchase. The order id makes every intent unique whatever the timing, and + /// it makes ledger blocks self-attributing for free. + /// + + public func buildDeliveryIntent( + orderId : Types.OrderId, + to : Types.Account, + cycles : Nat, + nowNs : Int, + ) : Types.TransferIntent { + { + createdAtTimeNs = Nat64.fromIntWrap(nowNs); + amountCycles = cycles; + to; + memo = orderId.encodeUtf8(); + }; + }; + + /// The delivery intent's wire form. The fee is passed **explicitly** so that a + /// change shows up as `#BadFee` and can be audited, rather than being absorbed + /// silently by passing `null`. + /// + /// ⚠️ **The caller must pass the fee the intent was BUILT with, never a fresh read.** + /// Whether the fee sits inside the ledger's dedup key is not knowable from here — and if + /// it does, a transfer that executed, lost its response and is replayed after a fee + /// change is a **distinct transaction, and the buyer is paid twice.** So the args are + /// *reproduced* rather than rebuilt: `Main.driveDelivery` recovers the original fee + /// arithmetically as `lockedCycles - intent.amountCycles`. A corrected fee is only ever + /// sent after a **definitive** `#BadFee`, where the ledger has said it did not execute. + public func deliveryArgs(intent : Types.TransferIntent, fee : Nat) : TransferArg { + { + from_subaccount = null; + to = intent.to; + amount = intent.amountCycles; + fee = ?fee; + memo = ?intent.memo; + created_at_time = ?intent.createdAtTimeNs; + }; + }; + + /// Transfer args for a reserve WITHDRAWAL — the second destination class. + /// + /// ⚠️ **This is not a delivery, and the difference is the destination.** Every other + /// transfer this canister makes goes to a buyer's own account for an order they paid + /// for; this one goes to a controller. `Reserve.mo`'s outflow section names both + /// classes and what bounds each. + /// + /// `created_at_time` is set for the same reason delivery sets it: the ledger + /// deduplicates on it, so a retry of the identical withdrawal cannot double-debit. + public func withdrawArgs( + to : Types.Account, + amount : Nat, + fee : Nat, + nowNs : Int, + ) : TransferArg { + { + from_subaccount = null; + to; + amount; + fee = ?fee; + memo = ?withdrawMemo; + // Converted here rather than by the caller, so `Nat64` stays in the module that + // already owns the ledger's wire types — exactly as `intent` does above. + created_at_time = ?Nat64.fromIntWrap(nowNs); + }; + }; + + /// Distinguishes a withdrawal from a delivery in the ledger's own record, so the + /// two destination classes are separable by someone reading the ledger rather than + /// our audit log. + public let withdrawMemo : Blob = "cyclepay:withdraw"; + + /// What the buyer receives: the locked quantity less the ledger's transfer fee. + /// + /// Probe-measured: the ledger debits `amount + fee`, so sending + /// `lockedCycles - fee` moves the reserve by **exactly `lockedCycles`**. That + /// is why the promise tally is `Σ lockedCycles` with no separate fee term — + /// an earlier draft wrote `Σ (lockedCycles + fee)` and double-counted. + /// + /// Null when the fee swallows the whole order, which the purchase floor makes + /// unreachable ($10 buys ~7 T cycles against a 100 M fee) but which must not + /// be an underflow trap on the money path if either number ever moves. + public func deliverableCycles(lockedCycles : Nat, fee : Nat) : ?Nat { + if (fee >= lockedCycles) return null; + ?(lockedCycles - fee); + }; + + + // ── Result interpretation ──────────────────────────────────────────────── + + /// What the driver does next; `#retriable` leaves state untouched for the + /// recovery sweep, `#escalate` is terminal — it files a problem on the order (§4.1). + public type TransferOutcome = { + /// This call moved the money: the ledger accepted it and recorded a block. + #delivered : Nat; + /// An EARLIER call moved the money; this one was deduplicated and handed back + /// the original block. Still success — the §5.1 replay payoff. + /// + /// ⚠️ **Distinct from `#delivered`, deliberately.** Treating them as one case + /// (`#blockIndex`) until the reserve floor needed to know whether *this* call + /// debited the ledger: the floor is decremented when a transfer is issued, so a + /// deduplicated call must credit its decrement back (an earlier attempt's + /// decrement is the real one and is still standing), while a fresh block must + /// keep it. Collapsing them refunds real debits — optimistic — or under-counts + /// every healed replay by a whole order. Every other consumer treats them + /// identically, and says so. + #deduplicated : Nat; + #retriable : Text; + /// The ledger's fee is not what we passed; it reports the expected one. + /// + /// Its own case, and neither retriable nor an escalation: delivery re-sends + /// with the reported fee and **persists it**, so a risen fee is absorbed by the + /// reserve rather than shorting the buyer. + /// + /// ⚠️ **This case IS the fee-refresh mechanism**, which is what lets delivery + /// never await `icrc1_fee`: a stale stored copy costs one rejected call, once, + /// and never a wrong debit. `#BadFee` is definitive — the ledger tells us the + /// number it wants — so the correction cannot loop. + #badFee : Nat; + #escalate : Text; + }; + + public func interpretTransfer(result : TransferResult) : TransferOutcome { + switch (result) { + case (#Ok(block)) #delivered(block); + case (#Err(#Duplicate({ duplicate_of }))) #deduplicated(duplicate_of); + // Retriable: the ledger did NOT record a transfer, and the identical + // intent can succeed later (reserve refunded, ledger back up, ledger + // clock caught up to created_at_time). + case (#Err(#TemporarilyUnavailable)) #retriable("ledger temporarily unavailable"); + case (#Err(#InsufficientFunds({ balance }))) #retriable("reserve short: balance " # balance.toText() # " cycles"); + case (#Err(#CreatedInFuture(_))) #retriable("created_at_time ahead of ledger clock"); + case (#Err(#GenericError({ error_code; message }))) #retriable("ledger error " # error_code.toText() # ": " # message); + // Escalations: replaying the identical args can never succeed. + case (#Err(#TooOld)) #escalate("intent aged past the ledger dedup window"); + case (#Err(#BadFee({ expected_fee }))) #badFee(expected_fee); + case (#Err(#BadBurn(_))) #escalate("ledger answered BadBurn to a transfer"); + }; + }; + + + + // ── Journal (§4.2 `journal : Map`) ─────────────── + + public type Journal = Map.Map; + + public func emptyJournal() : Journal { + Map.empty(); + }; + + /// §5.1 step 1: persist the intent. Written in the same synchronous block as the + /// status transition, *before* the transfer await — so a trap between the two is + /// impossible and there is no window where money moved with no record of it. + public func openEntry(journal : Journal, order : Types.Order, intent : Types.TransferIntent, nowNs : Int) : Types.JournalEntry { + let entry : Types.JournalEntry = { + orderId = order.id; + // ⚠️ **The ORDER's status, never a literal.** `unsettledDeliveries` — the + // predicate deciding whether a reserve observation may be adopted — reads this + // field back and tests it for `#paid`. A literal here that disagrees with the + // order makes that predicate match nothing, the quiet window is then always + // satisfied, and a reconcile can overwrite the reserve floor while a transfer + // it cannot see is in flight. A hardcoded status is not a tidiness question here; it + // is a silent solvency bug three files away. + status = order.status; + destination = order.destination; + transferIntent = ?intent; + blockIndex = null; + cyclesDelivered = null; + retries = 0; + lastError = null; + createdAtNs = nowNs; + updatedAtNs = nowNs; + }; + journal.add(order.id, entry); + entry; + }; + + /// Patch the entry; no-op on a missing id (a pipeline bug must degrade to + /// a stuck order the sweep escalates, never a trap mid-money-flow). + public func patch( + journal : Journal, + orderId : Types.OrderId, + changes : { + status : ?Types.OrderStatus; + blockIndex : ?Nat; + cyclesDelivered : ?Nat; + bumpRetries : Bool; + /// The ledger's words for a failed attempt. `null` leaves the stored value + /// alone — pass it only when something actually failed, so a later success + /// does not erase the diagnosis an operator is reading. + lastError : ?Text; + }, + nowNs : Int, + ) { + let ?entry = journal.get(orderId) else return; + journal.add( + orderId, + { + entry with + status = switch (changes.status) { case (?s) s; case null entry.status }; + blockIndex = switch (changes.blockIndex) { case (?b) ?b; case null entry.blockIndex }; + cyclesDelivered = switch (changes.cyclesDelivered) { case (?c) ?c; case null entry.cyclesDelivered }; + retries = entry.retries + (if (changes.bumpRetries) 1 else 0); + lastError = switch (changes.lastError) { case (?e) ?e; case null entry.lastError }; + updatedAtNs = nowNs; + }, + ); + }; + + // ── Resume/replay decision (§5.1, §5.2) ────────────────────────────────── + + /// Why the resume decision stopped, when it cannot continue on its own. + public type EscalateReason = { + /// The intent is past the ledger's ~24 h dedup window with no recorded block, so + /// a replay is no longer protected and its fate is **unknown**. The only case + /// where retrying is the unsafe option. + #staleIntent; + /// The transfer landed and the order never moved — the buyer HAS their cycles. + /// Should be unreachable: the block and the transition commit in one synchronous + /// block. Handled so a future regression degrades to something an operator can + /// resolve rather than to a paid order whose buyer already holds the cycles. + #landedNotRecorded; + /// The order's status implies a money-out position the journal cannot support. + #missingJournal; + }; + + public func escalateReasonToText(reason : EscalateReason) : Text { + switch (reason) { + case (#staleIntent) "staleIntent"; + case (#landedNotRecorded) "landedNotRecorded"; + case (#missingJournal) "missingJournal"; + }; + }; + + /// The driver's next move for one order, derived from status + journal. + public type Stage = { + /// Nothing to do — the order is terminal or not yet paid. + #none; + #beginDelivery; + /// A fresh delivery intent and no block: (re)issue the identical cycles + /// transfer, reading the STORED args and **never rebuilding them** — a rebuilt + /// `created_at_time` defeats the ledger's dedup and pays the buyer twice. + #replayDelivery : Types.TransferIntent; + /// The transfer landed and the `#delivered` transition did not. Carries the + /// block so the journal and the receipt still name it. + #finishDelivery : Nat; + #escalate : EscalateReason; + }; + + /// What to escalate when a **time bound** terminates an order, and the exact + /// instruction to hand the operator. + /// + /// ⚠️ **Derived from the journal, not from the status.** The status says where + /// the order stopped; the *journal* says where the money is, and the money + /// position is what determines the operator's action. Three consecutive defects + /// in this codebase came from deciding an escalation off status alone, so the + /// decision lives here as one pure function with every arm pinned by unit test + /// — the composition is what kept going wrong, not the pieces. + /// + /// The dangerous cell, and the reason this exists: a `#paid` order whose transfer + /// already landed (`blockIndex` journaled) and whose `#delivered` transition never + /// ran. Read off the status alone it looks like "never delivered", and the + /// instruction that follows — deliver it — is **factually wrong**: the buyer + /// already has their cycles. Following it pays twice. That cell is why + /// `#landedNotRecorded` is its own reason with its own instruction (record, do not + /// send), and why the journal outranks the status here. + public func terminationFor( + status : Types.OrderStatus, + entry : ?Types.JournalEntry, + ) : { stage : Text; detail : Text } { + switch (status) { + // `#paid`'s money position depends on the JOURNAL, not the status: an order + // with an unconfirmed transfer is not in the same position as one that never + // started, and the operator's action differs completely. + case (#paid) { + switch (entry) { + case (?e) { + switch (e.blockIndex, e.transferIntent) { + case (?block, _) { + { + stage = escalateReasonToText(#landedNotRecorded); + detail = "cycles WERE delivered (ledger block " # block.toText() # ") but the order never moved to delivered — the buyer HAS their cycles. Confirm the block on the cycles ledger, then resolve; do NOT re-send."; + }; + }; + case (null, ?intent) { + { + stage = escalateReasonToText(#staleIntent); + detail = "cycles transfer unconfirmed — establish its fate on the cycles ledger by matching memo " # intent.memo.size().toText() # "-byte order id, created_at_time " # intent.createdAtTimeNs.toText() # " and amount " # intent.amountCycles.toText() # " cycles. Executed -> the buyer has them; mark it resolved. Not executed -> fiat in, nothing delivered; re-deliver by hand or refund. NEVER rebuild the intent: past the dedup window a rebuilt one pays twice."; + }; + }; + case (null, null) { + { + stage = "deliveryWaitExceeded"; + detail = "paid but nothing was ever sent past the max wait: fiat received, no transfer attempted — refund in the Stripe Dashboard."; + }; + }; + }; + }; + case null { + { + stage = "deliveryWaitExceeded"; + detail = "paid but nothing was ever sent past the max wait: fiat received, no transfer attempted — refund in the Stripe Dashboard."; + }; + }; + }; + }; + // ⚠️ Only `#paid` reaches this function — it is called from the 72 h bound and + // from the escalate route, both of which run on the one in-flight status. Every + // other status is either pre-payment or terminal, so there is no money-out + // position to report and saying so beats inventing one. + case (_) { + { + stage = "notInFlight"; + detail = "this order has no delivery in flight, so it has no money-out position — if an escalation names this stage, the drive loop reached it from a status it should not have."; + }; + }; + }; + }; + + /// Decide the next move. Pure — the §5.1/§5.2 resume semantics in one testable + /// place. + /// + /// ⚠️ **No retry-budget parameter, and do not add one.** Retrying is bounded by + /// *time* on both ends: the ~24 h dedup window escalates a stale intent, and §5.3's + /// 72 h max-wait gets a human involved. A count-based budget adds nothing between + /// those two, and it fails in the wrong direction — exhausting it escalates an + /// order that a further replay would have completed. + public func stageOf( + status : Types.OrderStatus, + entry : ?Types.JournalEntry, + nowNs : Int, + dedupWindowNs : Int, + ) : Stage { + switch (status) { + // Delivery is ONE transfer from `#paid`, so retry state lives in the journal + // rather than in a status of its own. Three cases, and the middle one is why the + // intent is persisted before the call: + // + // no entry / no intent → #begin: read the fee, build the intent, send + // intent, no block → #replayTransfer: re-send the STORED args + // intent + block → #finishDelivery: the transfer landed, the + // transition did not (unreachable today — both + // commit in one sync block — handled so a future + // regression degrades to something resumable) + // + // ⚠️ Never rebuild the intent on a retry: a rebuilt one carries a fresh + // `created_at_time`, the ledger does not dedup it, and the buyer is paid + // twice. + // + // ⚠️ **Do not remove the mutex on the theory that replay-dedup covers it.** Two + // drivers (the webhook's detached kick and the recovery sweep) do arrive at one + // order concurrently; the second bounces off `processDelivery`'s in-flight set, + // checked and added in one synchronous block. The mutex is the FIRST line of + // defence and dedup the second, and they are not interchangeable: `openEntry` + // overwrites, so two drivers past the re-check would build intents with different + // `created_at_time` — two transfers the ledger will not dedup. + case (#paid) { + let ?e = entry else return #beginDelivery; + switch (e.blockIndex) { + case (?block) #finishDelivery(block); + case null { + switch (e.transferIntent) { + case null #beginDelivery; + case (?intent) { + // ⚠️ **No retry cap here, deliberately.** A replay is *provably* safe + // — byte-identical args, the ledger deduplicates, `#Duplicate` recovers + // the block — so a cap would convert a recoverable state into a manual + // one for no safety gain. + // + // Retrying is bounded twice over, by time rather than by a count: the + // ~24 h dedup window escalates the intent on the very next line, and + // §5.3's 72 h max-wait gets a human involved for the buyer's sake long + // before that. + // + // Past the dedup window a replay is no longer protected, so a blind + // retry could pay twice. **This is the one ambiguous case in money-out** + // and the only thing that escalates on this path. + if (nowNs - intent.createdAtTimeNs.toNat() >= dedupWindowNs) { + #escalate(#staleIntent); + } else { + #replayDelivery(intent); + }; + }; + }; + }; + }; + }; + case (#created or #cancelled or #expired or #delivered or #needsReview or #abandoned) #none; + }; + }; + + + + /// The two thresholds. This is the Candid type `set_delivery_config` takes. + public type Config = { + /// How long an order may sit with money in and nothing delivered before it is + /// **terminated** and the operator refunds. + /// + /// Terminating matters, and not for tidiness: a buyer left waiting indefinitely + /// files a chargeback, which is strictly worse for the operator than a refund — + /// dispute fees, a dispute process, and damage to Stripe account health. + /// Refunding proactively is the *protective* action. By the time this elapses + /// the cause is not transient either. + maxHoldNs : Int; + /// When to raise the alert, which is **not** when to give up. Complementary to + /// `maxHoldNs`: the alert fires while the cause is still fixable, so the + /// operator gets a chance to fix it and the sale completes. + alertAfterNs : Int; + }; + + public func defaultConfig() : Config { + { + maxHoldNs = 259_200_000_000_000; // 72 h — terminate, operator refunds + alertAfterNs = 7_200_000_000_000; // 2 h — tell someone while it is fixable + }; + }; + + public type ConfigError = { + /// A zero/negative max hold would escalate every order instantly. + #nonPositiveMaxHold; + #nonPositiveAlertAfter; + /// An alert at or after the terminal bound would never be actionable: the + /// operator would be told at the moment the decision was already taken. + #alertNotBeforeMaxHold : { alertAfterNs : Int; maxHoldNs : Int }; + }; + + public func validateConfig(config : Config) : Result.Result<(), ConfigError> { + if (config.maxHoldNs <= 0) return #err(#nonPositiveMaxHold); + if (config.alertAfterNs <= 0) return #err(#nonPositiveAlertAfter); + if (config.alertAfterNs >= config.maxHoldNs) { + return #err(#alertNotBeforeMaxHold({ alertAfterNs = config.alertAfterNs; maxHoldNs = config.maxHoldNs })); + }; + #ok; + }; + + /// The timeline for an order with money in and nothing delivered. + /// + /// Three outcomes, not two: quiet retry while it is probably transient, then an + /// alert while it is still fixable, then termination once it plainly is not. + /// Splitting these is what lets the alert be early *without* making the give-up + /// early too. + /// + /// ⚠️ **A per-state bound, not end-to-end.** The age it reads is + /// `order.updatedAtNs`, which retries deliberately do not move — a retry is not a + /// transition, so the clock stays pinned to the moment the order entered its + /// current state. + public func waitStage( + heldSinceNs : Int, + nowNs : Int, + config : Config, + ) : { #retry; #alert; #terminate } { + let waited = nowNs - heldSinceNs; + if (waited >= config.maxHoldNs) return #terminate; + if (waited >= config.alertAfterNs) return #alert; + #retry; + }; + +}; diff --git a/src/backend/ErrorQueue.mo b/src/backend/ErrorQueue.mo deleted file mode 100644 index 6f36842..0000000 --- a/src/backend/ErrorQueue.mo +++ /dev/null @@ -1,194 +0,0 @@ -/// Bounded error queue — exactly two types (§4.1). -/// -/// Every dollar that arrives must resolve to delivery, Type 1, or Type 2. -/// Type 1 (`#duplicate` | `#unattributed`): fiat exists, nothing was minted -/// (dedup gates the mint). Resolution is the operator refunding in the Stripe -/// Dashboard; the `charge.refunded` webhook auto-resolves via -/// `resolveByPaymentRef`. Type 2 (`#undeliverable`): cycles were minted but -/// delivery failed — they sit in the app canister's own balance until the -/// operator refunds or re-delivers. -/// -/// Resolution lives on the queue entry, not the order: `#errorQueue` is a -/// terminal order status (§4), and resolving here never transitions an order. -import Map "mo:core/Map"; -import List "mo:core/List"; -import Iter "mo:core/Iter"; -import Nat "mo:core/Nat"; -import Result "mo:core/Result"; -import Types "Types"; - -module { - - /// §4.1 — the payloads make the two types structural: Type 1 always carries - /// the Stripe `payment_intent` (`paymentRef`) that a refund resolves; - /// Type 2 always carries the stranded cycle quantity. - public type Kind = { - /// Type 1 — genuine 2nd/distinct payment for an already-handled order. - #duplicate : { orderId : Types.OrderId; paymentRef : Text }; - /// Type 1 — `client_reference_id` resolved to no order (claimed, not - /// trusted: it is an attacker-editable URL param). - #unattributed : { claimedRef : Text; paymentRef : Text }; - /// Type 2 — minted, but forward failed (e.g. target canister deleted). - #undeliverable : { orderId : Types.OrderId; cycles : Nat }; - /// §5.1/§5.3/§6.2 escalation — a money pipeline stopped where it cannot - /// continue automatically (stale mint intent whose transfer fate is - /// unknowable, a CMC rejection/refund, an ambiguous forward, a treasury - /// hold past its max wait — there the position is certain: fiat in, - /// nothing minted — or a ck-USDC pull intent aged past the ledger dedup - /// window). Neither Type 1 nor Type 2: the operator inspects the - /// ledger/CMC/destination, then refunds or re-delivers manually. - /// `stage` = Cmc.EscalateReason text, "treasuryWaitExceeded", or - /// "stalePullIntent". - #stuckMint : { orderId : Types.OrderId; stage : Text }; - }; - - public func isType1(kind : Kind) : Bool { - switch (kind) { - case (#duplicate(_) or #unattributed(_)) true; - case (#undeliverable(_) or #stuckMint(_)) false; - }; - }; - - /// The payment reference a `charge.refunded` resolves — Type 1 only. - /// (`#stuckMint` carries no paymentRef: the order store doesn't retain the - /// payment_intent, and a refund alone doesn't settle an uncertain mint.) - public func paymentRefOf(kind : Kind) : ?Text { - switch (kind) { - case (#duplicate({ paymentRef; orderId = _ })) ?paymentRef; - case (#unattributed({ paymentRef; claimedRef = _ })) ?paymentRef; - case (#undeliverable(_) or #stuckMint(_)) null; - }; - }; - - public type Entry = { - /// Monotonic — doubles as arrival order for bounded eviction. - id : Nat; - rail : Types.Rail; - kind : Kind; - detail : Text; - createdAtNs : Int; - resolvedAtNs : ?Int; - }; - - public type Store = { - /// Keyed by monotonic id → `entries()` iterates oldest-first. - entries : Map.Map; - var nextId : Nat; - }; - - public func emptyStore() : Store { - { entries = Map.empty(); var nextId = 0 }; - }; - - public type AddResult = { - entry : Entry; - /// Evicted to honor the bound — oldest resolved first; only when nothing - /// is resolved does the oldest *unresolved* entry go (the bound exists so - /// spammed unattributed payments can't grow state without limit). The - /// caller must audit-log unresolved evictions: each is a live money - /// obligation dropped from on-chain state. - evicted : [Entry]; - }; - - /// Append an entry, evicting past `capacity` (see `AddResult.evicted`). - public func add( - store : Store, - capacity : Nat, - rail : Types.Rail, - kind : Kind, - detail : Text, - nowNs : Int, - ) : AddResult { - let entry : Entry = { - id = store.nextId; - rail; - kind; - detail; - createdAtNs = nowNs; - resolvedAtNs = null; - }; - store.nextId += 1; - store.entries.add(entry.id, entry); - let evicted = List.empty(); - while (store.entries.size() > capacity) { - switch (oldestPreferResolved(store)) { - case (?victim) { - store.entries.remove(victim.id); - evicted.add(victim); - }; - case null {}; // unreachable: size > capacity ≥ 0 means non-empty - }; - }; - { entry; evicted = evicted.toArray() }; - }; - - /// Eviction victim: oldest resolved entry, else oldest entry outright. - /// Scans oldest-first (ids are monotonic, the map iterates in key order). - func oldestPreferResolved(store : Store) : ?Entry { - var oldest : ?Entry = null; - for ((_, entry) in store.entries.entries()) { - switch (entry.resolvedAtNs) { - case (?_) return ?entry; - case null { if (oldest == null) oldest := ?entry }; - }; - }; - oldest; - }; - - public type ResolveError = { #notFound : Nat; #alreadyResolved : Nat }; - - /// Manual operator resolution (refund issued / cycles re-delivered). - public func resolve(store : Store, id : Nat, nowNs : Int) : Result.Result { - switch (store.entries.get(id)) { - case null #err(#notFound(id)); - case (?entry) { - switch (entry.resolvedAtNs) { - case (?_) #err(#alreadyResolved(id)); - case null { - let updated = { entry with resolvedAtNs = ?nowNs }; - store.entries.add(id, updated); - #ok(updated); - }; - }; - }; - }; - }; - - /// `charge.refunded` auto-resolve (§4.1): marks every unresolved Type 1 - /// entry carrying this `payment_intent` resolved; returns them. Type 2 - /// entries never match (no paymentRef) — minted cycles are not a refund's - /// business. Empty result = refund for something not in the queue (fine: - /// operators may refund proactively). - public func resolveByPaymentRef(store : Store, paymentRef : Text, nowNs : Int) : [Entry] { - let matches = store.entries.values().filter( - func(e) = e.resolvedAtNs == null and paymentRefOf(e.kind) == ?paymentRef - ).toArray(); - let resolved = List.empty(); - for (entry in matches.values()) { - let updated = { entry with resolvedAtNs = ?nowNs }; - store.entries.add(entry.id, updated); - resolved.add(updated); - }; - resolved.toArray(); - }; - - public func get(store : Store, id : Nat) : ?Entry { - store.entries.get(id); - }; - - /// Open obligations, oldest first — the operator's worklist. - public func unresolved(store : Store) : [Entry] { - store.entries.values().filter(func(e) = e.resolvedAtNs == null).toArray(); - }; - - /// Everything still retained (resolved entries stay until evicted), oldest - /// first. - public func all(store : Store) : [Entry] { - store.entries.values().toArray(); - }; - - public func size(store : Store) : Nat { - store.entries.size(); - }; - -}; diff --git a/src/backend/Forex.mo b/src/backend/Forex.mo deleted file mode 100644 index d484059..0000000 --- a/src/backend/Forex.mo +++ /dev/null @@ -1,242 +0,0 @@ -/// Forex rate subsystem (§3.1) — the one outbound-HTTPS exception. -/// -/// This is the *pure* half: JSON rate extraction + coarse rounding (the -/// outcall `transform` body, so replicas converge on a tiny canonical -/// response), the configurable fee formula (§3 net-of-fees), staleness, and -/// the cycles quote. Main.mo owns the impure half — the actual outcall, -/// the single-flight guard, and the in-call retry cap — so everything here -/// unit-tests without an IC environment (mocked-outcall bodies are just -/// Blobs). -/// -/// Rate representation: **XDR per USD in micro-units (×1e6)**, the shape -/// USD-base forex APIs serve (`"XDR": 0.7374` → `737_400`). The default -/// source is keyless and IPv6-reachable (§3.1 — a keyed API would add a -/// second node-provider-readable secret). 1 XDR = 1T cycles (CMC), so -/// cycles = netCents/100 · micros/1e6 · 1e12 = netCents · micros · 10_000. -/// -/// Fail-closed posture (§3.1): every parse/sanity failure collapses to -/// "no rate", and no rate means order creation is blocked — never priced on -/// a stale, implausible, or invented rate. -import Char "mo:core/Char"; -import Iter "mo:core/Iter"; -import Nat "mo:core/Nat"; -import Nat32 "mo:core/Nat32"; -import Result "mo:core/Result"; -import Text "mo:core/Text"; - -module { - - /// Coarse-rounding step for the transform (§3.1 determinism): 1_000 micros - /// = 0.001 XDR/USD, ~0.14% at historical rates (~0.74). Coarse enough that - /// replicas sampling a slightly different live value converge; fine enough - /// that the pricing error is far below card-fee variance the operator - /// already absorbs (§3). - public let roundStepMicros : Nat = 1_000; - - /// Plausibility band: XDR/USD has lived between ~0.6 and ~0.9 for decades. - /// A 10×-each-way band rejects decimal-point bugs at the source (a 1000× - /// rate would mint free or absurdly expensive orders) while never tripping - /// on real market moves. Outside the band = parse failure = fail closed. - public let minPlausibleMicros : Nat = 100_000; - public let maxPlausibleMicros : Nat = 10_000_000; - - /// Forex params (§7: controllers may adjust). `feeBps`/`feeFixedCents` is - /// the §3 fee formula (≈2.9% + $0.30); `maxAgeNs` is the lazy-refresh - /// staleness window; `url` must be a keyless USD-base JSON source carrying - /// an `"XDR"` rate. - public type Config = { - url : Text; - feeBps : Nat; - feeFixedCents : Nat; - maxAgeNs : Int; - }; - - /// USD/XDR moves slowly (SDR basket); 1 h staleness is immaterial next to - /// the §3 fee variance, and keeps the outcall cadence ≤ 24/day. - public func defaultConfig() : Config { - { - url = "https://open.er-api.com/v6/latest/USD"; - feeBps = 290; - feeFixedCents = 30; - maxAgeNs = 3_600_000_000_000; - }; - }; - - public type ConfigError = { - /// Fee ≥ 100% can never net out. - #feeBpsTooHigh; - /// A zero window would refresh on every order. - #nonPositiveMaxAge; - /// Outcalls are HTTPS-only; anything else fails at call time anyway. - #notHttps; - }; - - public func validateConfig(config : Config) : Result.Result<(), ConfigError> { - if (config.feeBps >= 10_000) return #err(#feeBpsTooHigh); - if (config.maxAgeNs <= 0) return #err(#nonPositiveMaxAge); - if (not config.url.startsWith(#text "https://")) return #err(#notHttps); - #ok; - }; - - /// The stable `{rate, ts}` cache (§3.1): orders read this; only staleness - /// triggers an outcall. - public type Rate = { xdrPerUsdMicros : Nat; fetchedAtNs : Int }; - - public type Cache = { var rate : ?Rate }; - - public func emptyCache() : Cache { - { var rate = null }; - }; - - public func record(cache : Cache, xdrPerUsdMicros : Nat, nowNs : Int) { - cache.rate := ?{ xdrPerUsdMicros; fetchedAtNs = nowNs }; - }; - - /// The cached rate iff younger than `maxAgeNs` (age ≥ window = stale, - /// matching the Idempotency pruning convention). Null = caller must - /// refresh or fail closed. - public func freshMicros(cache : Cache, maxAgeNs : Int, nowNs : Int) : ?Nat { - let ?{ xdrPerUsdMicros; fetchedAtNs } = cache.rate else return null; - if (nowNs - fetchedAtNs >= maxAgeNs) return null; - ?xdrPerUsdMicros; - }; - - /// Extract the `"XDR"` rate from a USD-base JSON body, as micro-XDR/USD. - /// First `"XDR"` key wins; it must be followed by `:` and a non-negative - /// JSON number. Fractional digits beyond 6 are truncated (sub-micro - /// precision is far below the coarse-rounding step anyway). Null on any - /// deviation — fail closed, never guess. - public func extractXdrPerUsdMicros(body : Text) : ?Nat { - let chars = body.toArray(); - let needle : [Char] = ['\"', 'X', 'D', 'R', '\"']; - var i = 0; - while (i + needle.size() <= chars.size()) { - var k = 0; - while (k < needle.size() and chars[i + k] == needle[k]) k += 1; - if (k == needle.size()) return parseDecimalMicros(chars, i + needle.size()); - i += 1; - }; - null; - }; - - /// Parse `: [.]` (JSON number, no sign/exponent — a forex - /// rate is a plain positive decimal) starting at `from`, into micros. - func parseDecimalMicros(chars : [Char], from : Nat) : ?Nat { - var i = from; - func skipSpace() { - while (i < chars.size() and (chars[i] == ' ' or chars[i] == '\t' or chars[i] == '\n' or chars[i] == '\r')) { - i += 1; - }; - }; - func digit(c : Char) : ?Nat { - if (c >= '0' and c <= '9') ?(c.toNat32().toNat() - 48) else null; - }; - skipSpace(); - if (i >= chars.size() or chars[i] != ':') return null; - i += 1; - skipSpace(); - var intPart = 0; - var intDigits = 0; - label int while (i < chars.size()) { - let ?d = digit(chars[i]) else break int; - intPart := intPart * 10 + d; - intDigits += 1; - i += 1; - }; - if (intDigits == 0) return null; - var fracMicros = 0; - if (i < chars.size() and chars[i] == '.') { - i += 1; - var scale = 100_000; - var fracDigits = 0; - label frac while (i < chars.size()) { - let ?d = digit(chars[i]) else break frac; - if (fracDigits < 6) fracMicros += d * scale; - scale := scale / 10; - fracDigits += 1; - i += 1; - }; - if (fracDigits == 0) return null; - }; - ?(intPart * 1_000_000 + fracMicros); - }; - - /// Round to the nearest `roundStepMicros` (half up). - public func coarseRound(micros : Nat) : Nat { - (micros + roundStepMicros / 2) / roundStepMicros * roundStepMicros; - }; - - public func plausible(micros : Nat) : Bool { - minPlausibleMicros <= micros and micros <= maxPlausibleMicros; - }; - - /// The outcall `transform` body (§3.1 determinism): raw API response → - /// canonical micros decimal string, coarse-rounded and sanity-banded. - /// Any failure → empty body, so replicas reach consensus on the failure - /// too and the caller retries instead of splitting. - public func transformBody(body : Blob) : Blob { - let ?text = body.decodeUtf8() else return "".encodeUtf8(); - let ?raw = extractXdrPerUsdMicros(text) else return "".encodeUtf8(); - let rounded = coarseRound(raw); - if (not plausible(rounded)) return "".encodeUtf8(); - rounded.toText().encodeUtf8(); - }; - - /// Parse the transform's canonical body back into micros. Re-applies the - /// plausibility band: `http_request_update`-style trust boundaries are - /// cheap to re-check, and an empty (failure) body parses to null here. - public func parseCanonicalMicros(body : Blob) : ?Nat { - let ?text = body.decodeUtf8() else return null; - let ?micros = Nat.fromText(text) else return null; - if (not plausible(micros)) return null; - ?micros; - }; - - /// `Host` for the outcall headers, derived from the configured URL (the - /// outcall adapter does not set it from the URL). - public func hostOf(url : Text) : Text { - let rest = switch (url.stripStart(#text "https://")) { - case (?r) r; - case null url; - }; - let chars = rest.toArray(); - var end = 0; - while (end < chars.size() and chars[end] != '/' and chars[end] != '?') end += 1; - Text.fromIter(chars.values().take(end)); - }; - - /// §3 net-of-fees: gross − (⌈gross·bps/10000⌉ + fixed). The percentage - /// part rounds *up* — overestimating the fee means we never over-deliver; - /// the operator's at-cost posture absorbs the ≤1¢ variance (§3). Null when - /// the fee swallows the whole amount (tier priced below the fee floor). - /// The fee argument is the narrowed shape so both a live `Config` and an - /// order's creation-time `Pricing` snapshot (§6.1 amount honoring) fit. - public func netCents(fee : { feeBps : Nat; feeFixedCents : Nat }, grossCents : Nat) : ?Nat { - let total = (grossCents * fee.feeBps + 9_999) / 10_000 + fee.feeFixedCents; - if (total >= grossCents) return null; - ?(grossCents - total); - }; - - /// Locked cycle quantity (§3) for an already-netted amount: - /// netCents · micros · 10_000 (derivation in the module doc). - public func cyclesForCents(netCents : Nat, xdrPerUsdMicros : Nat) : Nat { - netCents * xdrPerUsdMicros * 10_000; - }; - - /// One-shot quote: one consistent snapshot of fee config + cached rate. - /// `#ok` carries the rate alongside the cycles so the caller can persist - /// the §6.1 pricing snapshot the quote was made from. `#stale` = caller - /// refreshes and re-quotes, or fails closed (§3.1); `#unpriceable` = the - /// tier's gross doesn't clear the fee — a config problem, not a rate - /// problem. - public func quote(cache : Cache, config : Config, grossCents : Nat, nowNs : Int) : { - #ok : { cycles : Nat; xdrPerUsdMicros : Nat }; - #stale; - #unpriceable; - } { - let ?net = netCents(config, grossCents) else return #unpriceable; - let ?micros = freshMicros(cache, config.maxAgeNs, nowNs) else return #stale; - #ok({ cycles = cyclesForCents(net, micros); xdrPerUsdMicros = micros }); - }; - -}; diff --git a/src/backend/Gate.mo b/src/backend/Gate.mo new file mode 100644 index 0000000..e4df881 --- /dev/null +++ b/src/backend/Gate.mo @@ -0,0 +1,662 @@ +/// Pre-creation admission gate — the "check before you take money" pass. +/// +/// Order creation is refused when fulfilment is already known to be impossible: +/// too few cycles in the reserve to cover the order, this canister's own cycle +/// balance too low to keep running, an amount outside the per-purchase bounds, +/// or a principal holding too many open orders. +/// +/// ⚠️ **Advisory about money, authoritative about abuse.** The binding money check is +/// at delivery, when the transfer lands or does not. This gate exists so a refusal +/// reaches the buyer *before* they pay Stripe — a user-experience guarantee, not a +/// solvency one. +/// +/// An admission gate, **not** a capacity reservation: nothing is escrowed at creation, +/// and `lockedCycles` is a price rather than a hold. A reservation would need +/// reserved-but-unpaid accounting plus a release path, and still could not guarantee +/// delivery. +/// +/// Every check is pure arithmetic over values the caller supplies, so the whole policy +/// unit-tests with no IC environment. +import Nat "mo:core/Nat"; +import Result "mo:core/Result"; +import Reserve "Reserve"; + +module { + + public type Config = { + /// Cap on simultaneously-`#created` orders per principal. This is the + /// bound that actually stops unbounded state growth: paid volume is + /// self-limiting, because every delivery spends a reserve someone had to + /// fund, so the only unbounded vector is *unpaid* orders — free to create + /// (`canister-security`: allowing unbounded user-controlled storage). + /// Not a money decision — an abuse bound. Raise it for legitimate power + /// users; do not set it to 0, which would block the rail entirely. + /// + /// One, as a product call rather than a safety control: a buyer who wants another + /// slot cancels the one they have, which is what makes `cancel_order` load-bearing. + /// **Do not harden this against identity flooding.** A per-principal cap cannot + /// bound an attacker who rotates self-authenticating principals, so the honest bound + /// is the cycles cost of the calls plus `minCanisterCycles` failing the rail closed. + /// + /// ⚠️ **Only safe because `Orders.openOrderCount` skips past-deadline orders.** At a + /// cap of 1 without that check, one missed expiry webhook locks a buyer out forever. + maxOpenOrdersPerPrincipal : Nat; + /// Floor on this canister's OWN cycle balance — its gas, not the cycles it + /// sells. Below it the gate refuses to admit new orders. + /// + /// ⚠️ **It gates ADMISSION only, and reading it as a delivery guard inverts + /// the lever.** `admit` is reached from `create_order` and `can_purchase` and + /// nowhere else: the Stripe webhook reaches delivery without consulting it, and + /// `cancel_order`, `withdraw_reserve` and every admin setter keep working below + /// the floor. That asymmetry IS the design — stop taking on new obligations, + /// leave every path that discharges the existing ones open. An operator who + /// believes this protects delivery raises it to rescue a stalled one, which + /// refuses every buyer and changes nothing about delivery. + /// + /// **What it bounds is a gas DRAIN, not freezing.** At the 5 T default the + /// freezing threshold is ~149× further down (~34 B, i.e. 30 days of idle burn), + /// so sales close with over a year of runway still in hand. Sizing it against + /// freezing invites lowering it by two orders of magnitude, and that is exactly + /// what removes the bound. The three things it holds: + /// - **identity-rotated order flooding**, whose only bound this is. See + /// `maxOpenOrdersPerPrincipal` above: a per-principal cap cannot see an + /// attacker generating fresh principals. + /// - **a Stripe key that is present but revoked.** Every `create_order` reaches + /// the session outcall and 401s for ~220 M cycles. Nothing else bounds it. + /// - **the 1 B every rate call must ATTACH** (`Xrc`), which leaves the spendable + /// balance for the call's duration. Below that, pricing dies as well. + /// + /// `0` is permitted and means "do not gate on my own balance" (`validateConfig`). + /// It leaves the first two bounded only by the canister freezing. + /// + /// ⚠️ **THREE balances, and confusing them is the classic operational error.** + /// - **gas** — this canister's own balance, spent by running. `icp canister status`. + /// - **stock** — the reserve: the gateway's cycles-ledger *account*, spent by + /// delivering, topped up by a plain `icp cycles transfer `. + /// `reserve_status`, or `icp cycles balance --of-principal `. + /// - **the operator's own cycles-ledger account**, which funds both. `icp cycles + /// balance`. A failed top-up reports *this* balance, under a message about the + /// reserve — so read "insufficient funds. balance: N" as the sender's, not the + /// reserve's. + minCanisterCycles : Nat; + /// Per-purchase ceiling on the gross USD amount. + /// + /// It stopped being defence in depth when custom amounts arrived: the + /// buyer names the amount now, so this is the **only** upper bound, and it is + /// the real lever on reserve availability — the ceiling IS + /// the per-order reserve exposure. At $1,000 one unpaid order ties up ~720 T + /// of reserve for a few hundred million cycles of our gas; at $100 it is + /// ~72 T, a 10× improvement for one config value and no new machinery. + /// + /// **One ceiling governs presets AND custom amounts.** Do not add a + /// custom-amount-specific limit. Two knobs for one rule drift apart, and then + /// which one binds depends on the path the amount arrived by — an amount a + /// preset may be sold at but a custom order may not, or the reverse. + maxPurchaseUsdCents : Nat; + /// Floor on the gross USD amount, for the same two cases. + /// + /// Below it the §3 fee formula swallows too much of the payment to be worth + /// the outcall and the reserve hold: a $1 purchase pays ~33¢ in card fees + /// before it buys a cycle. + /// + /// Enforced in **two** places, and both are needed: here, for every order; + /// and in `set_card_tiers`, or a registered tier becomes unsellable — the + /// mirror of `#tierAboveCeiling`. + minPurchaseUsdCents : Nat; + }; + + /// Deliberately non-zero. These are *safety limits*, not money decisions: a + /// default of 0 would brick the canister rather than protect it, which is the + /// wrong direction of fail-closed. The one value that does gate money — how many + /// cycles are available to sell — has no default at all, because it is whatever + /// the operator actually funded the reserve with. + /// + /// The card rail's on/off switch is **both Stripe secrets being provisioned** + /// rather than the tier list. An empty tier list means only "no presets + /// shown", because a custom amount is orderable without one. + public func defaultConfig() : Config { + { + maxOpenOrdersPerPrincipal = 1; + minCanisterCycles = 5_000_000_000_000; // 5T + // $100, down from $1,000. The ceiling IS the per-order reserve + // exposure, so this is the main lever against reserve griefing. + maxPurchaseUsdCents = 10_000; + // $10, for two independent reasons: + // + // 1. ⚠️ **What a buyer needs, which binds first.** `icp canister create` funds each + // canister with 2 T by default, so the two a buyer typically deploys ask for + // **4.0 T** before anything runs — not the ~1.34 T a month consumes. $10 buys + // 6.851 T and clears that 1.7×; $5 buys 3.313 T and does not clear it at all, + // failing on the second canister. + // 2. **The card fee.** `feeCents` is 9.0% of a $5 purchase against 5.9% of a $10 + // one — the fixed 30¢ is regressive — so below this the fee eats too much of the + // payment to be worth an outcall and a reserve hold. + // + // `docs/BUYER-COST-MODEL.md` has the model, the tier table and the assumptions. + minPurchaseUsdCents = 1_000; + }; + }; + + /// Config validation failed, and each arm carries what the operator needs to fix it. + /// + /// ⚠️ **`#tierAboveCeiling` names the offending tier and both numbers**, because "your + /// ceiling is too low" without saying which tier collides is a message an operator has + /// to go and investigate. + public type ConfigError = { + #zeroOpenOrderCap; + #zeroPurchaseCeiling; + /// A registered card tier costs more than the new per-purchase ceiling. + /// + /// `set_card_tiers` already refuses a tier above the ceiling; without the + /// inverse check, lowering the ceiling leaves that tier **sellable but + /// unpayable**: the buyer completes checkout and the webhook files a refundable + /// obligation, + /// because the honoured amount exceeds the ceiling. There is no rescue path — + /// the only remedy is a refund, + /// so the money is taken and given back for a config change made earlier. + #tierAboveCeiling : { tierId : Text; usdCents : Nat; maxUsdCents : Nat }; + /// The mirror: a registered tier costs less than the new floor. + #tierBelowFloor : { tierId : Text; usdCents : Nat; minUsdCents : Nat }; + /// A floor above the ceiling admits nothing at all — the rail would refuse + /// every amount, which is a config typo rather than a policy. + #floorAboveCeiling : { minUsdCents : Nat; maxUsdCents : Nat }; + /// ⚠️ **The mirror of `Pricing.ConfigError.divisorUndeliverable`, and it exists + /// because the divisor's ceiling is a function of the FLOOR**. + /// + /// `set_pricing_config` refuses a divisor that scales the current minimum + /// purchase below what the cycles-ledger deposit fee eats. Without this check, + /// lowering the floor afterwards reaches the same configuration from the other + /// direction: accept divisor 1,000 at a $10 floor, then drop the floor to $1, + /// and every minimum-amount purchase starts refusing with a message about the + /// simulation scale rather than about the change the operator just made. + /// + /// Not unsafe — `Pricing.quote` still refuses at creation, so no money moves + /// and nothing stalls. It is the *learns at set time rather than through a + /// buyer's refusal* property that goes missing, in the one direction that is + /// reachable. Mutual, like the livemode guard, so neither order of operations + /// gets there. + #floorUndeliverableAtDivisor : { + minUsdCents : Nat; + divisor : Nat; + scaledCycles : Nat; + ledgerFee : Nat; + }; + }; + + /// `tierPrices` is every registered card tier, so the ceiling cannot be lowered + /// underneath one. Pass an empty array when there are no tiers to consider. + public func validateConfig( + config : Config, + tierPrices : [(Text, Nat)], + ) : Result.Result<(), ConfigError> { + if (config.maxOpenOrdersPerPrincipal == 0) return #err(#zeroOpenOrderCap); + if (config.maxPurchaseUsdCents == 0) return #err(#zeroPurchaseCeiling); + if (config.minPurchaseUsdCents > config.maxPurchaseUsdCents) { + return #err(#floorAboveCeiling({ + minUsdCents = config.minPurchaseUsdCents; + maxUsdCents = config.maxPurchaseUsdCents; + })); + }; + for ((tierId, usdCents) in tierPrices.values()) { + if (usdCents > config.maxPurchaseUsdCents) { + return #err(#tierAboveCeiling({ tierId; usdCents; maxUsdCents = config.maxPurchaseUsdCents })); + }; + // The mirror, for the same reason: raising the floor over a registered tier + // would leave it sellable but unpayable, and the operator would have to + // connect a refused order to a config change made earlier. + if (usdCents < config.minPurchaseUsdCents) { + return #err(#tierBelowFloor({ tierId; usdCents; minUsdCents = config.minPurchaseUsdCents })); + }; + }; + // minCanisterCycles = 0 is permitted: it means "do not gate on my own + // balance", which is a coherent (if unwise) operator choice and is the + // only way to keep serving while deliberately running the canister down. + #ok; + }; + + /// Why admission was refused. Each case carries the observed value and the + /// bound so the frontend can explain the refusal rather than showing a + /// generic failure, and so the operator can see which lever to move. + public type Reason = { + #tooManyOpenOrders : { open : Nat; max : Nat }; + #canisterCyclesLow : { balance : Nat; min : Nat }; + /// The reserve cannot cover this order on top of what is already owed. + /// Carries both figures so the frontend can offer a smaller amount instead of + /// a bare failure, and an operator reading a ticket knows whether to top the + /// reserve up or hunt a leak. + #reserveShort : { requested : Nat; available : Nat }; + #amountAboveMax : { usdCents : Nat; maxUsdCents : Nat }; + /// Below the floor. Distinguishable from `#amountAboveMax` because the buyer + /// acts on them differently — one means "ask for less", the other "ask for + /// more" — and with custom amounts both are reachable by typing. + #amountBelowMin : { usdCents : Nat; minUsdCents : Nat }; + /// ⚠️ **The gateway is a faucet: it accepts free test payments, has an empty + /// buyer allow-list, and has a funded reserve.** Stripe test + /// payments are free and unlimited — `4242 4242 4242 4242` pays any session, + /// for anyone who reaches the page — so this combination gives cycles away to + /// the internet. Refused rather than warned about. + /// + /// ⚠️ **A fact about the GATEWAY, not about this buyer**, which is why it is + /// a separate reason from `#buyerNotAllowed` and why it is checked first. With + /// an empty list, a per-principal check would refuse every buyer with "not on + /// the allow-list" — and populating the list genuinely does fix it, so the + /// lever is right and the *diagnosis* is wrong. It reads as "this buyer is not + /// authorized" when the state is "we are unbounded and refusing everyone", + /// sending an operator to the list rather than to the order in which they + /// funded the reserve. + #unboundedGiveaway : { reserveFloor : Nat }; + /// This principal is not on the buyer allow-list, which is enforced while the + /// gateway accepts free test payments. + /// + /// Per-*principal*, so it never latches and never announces — nothing about + /// the gateway changed, exactly like `#tooManyOpenOrders`. + #buyerNotAllowed; + }; + + public func reasonToText(reason : Reason) : Text { + switch (reason) { + case (#tooManyOpenOrders({ open; max })) "tooManyOpenOrders(" # open.toText() # "/" # max.toText() # ")"; + case (#canisterCyclesLow({ balance; min })) "canisterCyclesLow(" # balance.toText() # "<" # min.toText() # ")"; + case (#unboundedGiveaway({ reserveFloor })) "unboundedGiveaway(floor " # reserveFloor.toText() # ", empty allow-list, test payments accepted)"; + case (#buyerNotAllowed) "buyerNotAllowed"; + case (#reserveShort({ requested; available })) "reserveShort(need " # requested.toText() # ", have " # available.toText() # ")"; + case (#amountAboveMax({ usdCents; maxUsdCents })) "amountAboveMax(" # usdCents.toText() # ">" # maxUsdCents.toText() # ")"; + case (#amountBelowMin({ usdCents; minUsdCents })) "amountBelowMin(" # usdCents.toText() # "<" # minUsdCents.toText() # ")"; + }; + }; + + /// Running tally of refusals, one counter per `Reason`. + /// + /// ⚠️ **A counter and not an audit line, because refusals are free to attempt.** + /// `#amountBelowMin` needs no prior state — one cent from any fresh principal + /// reaches it with no order, no payment and no setup — and principals are free. The + /// audit log drops nothing, so a line per attempt is permanent stable-state growth at + /// zero attacker cost. Every other structure here is attacker-priced: orders by the + /// open-order cap and the reserve, orphans by needing a real payment to exist. + /// + /// **A record rather than a map, so a new `Reason` cannot be silently untallied** — + /// `countRefusal` switches exhaustively, so adding a variant is a compile error here + /// rather than a counter that reads zero in production. + public type RefusalCounts = { + amountAboveMax : Nat; + amountBelowMin : Nat; + tooManyOpenOrders : Nat; + canisterCyclesLow : Nat; + reserveShort : Nat; + /// ⚠️ **Not a `Reason`, and that is why it is easy to miss.** The rail being + /// unprovisioned is refused *before* the gate — `create_order` checks caller, + /// destination, then the RAIL, then tier and admission — so while the rail is + /// closed **100% of attempts never reach `admit` at all**. A counter set that + /// only covered `Reason` would record nothing during exactly the window the + /// gateway spends freshly deployed, because docs/OPERATE.md prescribes + /// provisioning the secrets last. + railClosed : Nat; + /// The session outcall failed. Separate from `railClosed` because a present + /// but invalid key is a different incident from an absent one. + stripeApiFailed : Nat; + /// The faucet refusal — see `Reason.unboundedGiveaway`. + unboundedGiveaway : Nat; + /// A non-allow-listed buyer against a POPULATED list. Counted separately from + /// `unboundedGiveaway` because the two mean opposite things: this one is the + /// allow-list working, that one is the allow-list missing. + buyerNotAllowed : Nat; + }; + + public func noRefusals() : RefusalCounts { + ({ + amountAboveMax = 0; + amountBelowMin = 0; + tooManyOpenOrders = 0; + canisterCyclesLow = 0; + reserveShort = 0; + railClosed = 0; + stripeApiFailed = 0; + unboundedGiveaway = 0; + buyerNotAllowed = 0; + }); + }; + + /// Rail closure is counted through its own entry point, because it is not a + /// `Reason` — see `RefusalCounts.railClosed`. + public func countRailClosed(counts : RefusalCounts) : RefusalCounts { + ({ counts with railClosed = counts.railClosed + 1 }); + }; + + public func countStripeApiFailed(counts : RefusalCounts) : RefusalCounts { + ({ counts with stripeApiFailed = counts.stripeApiFailed + 1 }); + }; + + public func countRefusal(counts : RefusalCounts, reason : Reason) : RefusalCounts { + switch (reason) { + case (#amountAboveMax(_)) ({ counts with amountAboveMax = counts.amountAboveMax + 1 }); + case (#amountBelowMin(_)) ({ counts with amountBelowMin = counts.amountBelowMin + 1 }); + case (#tooManyOpenOrders(_)) ({ counts with tooManyOpenOrders = counts.tooManyOpenOrders + 1 }); + case (#canisterCyclesLow(_)) ({ counts with canisterCyclesLow = counts.canisterCyclesLow + 1 }); + case (#reserveShort(_)) ({ counts with reserveShort = counts.reserveShort + 1 }); + case (#unboundedGiveaway(_)) ({ counts with unboundedGiveaway = counts.unboundedGiveaway + 1 }); + case (#buyerNotAllowed) ({ counts with buyerNotAllowed = counts.buyerNotAllowed + 1 }); + }; + }; + + /// Does this refusal describe the **gateway's** state, or **one request's**? + /// + /// Only the two rail-state conditions get an audit line, and the split is the + /// whole point: `#reserveShort` and `#canisterCyclesLow` are global facts + /// about this gateway, so *"it started refusing at T"* is a real event an + /// operator wants. The other three have **no meaningful transition** — nothing + /// about the gateway changed, one request was malformed (`#amountBelowMin`, + /// `#amountAboveMax`) or one principal used its own slot + /// (`#tooManyOpenOrders`). + /// A condition that is a fact about the **gateway**, so entering it is worth + /// exactly one audit line and every later refusal under it is noise. + /// + /// **Three, not two.** `#railClosed` belongs here for the same reason the + /// other two do — either the API key and origin are provisioned or they are not + /// — and it is refused on a path that never reaches `admit`, so it cannot be + /// expressed as a `Reason`. + public type RailCondition = { + #reserveShort; + #canisterCyclesLow; + #railClosed; + /// A Stripe session **outcall** is failing, as distinct from `#railClosed`'s "no + /// key at all". Its clearest cause is a key that is present but **invalid** — + /// rotated or revoked at Stripe without updating the canister — which + /// `sessionConfig` cannot detect, because the secret exists. + /// + /// **One condition for BOTH session outcalls — create and expire — because they + /// share one diagnosis and one lever.** A revoked key fails both, and "rotate the + /// key" is the answer to either. Splitting them would file two incidents for one + /// cause and leave an operator wondering which to act on. Named for the API rather + /// than for `create`, so the next outcall added here does not need a third + /// condition. + /// + /// **Different diagnosis, different lever, so not folded into `#railClosed`:** + /// that one says *provision the key*, this one says *rotate it*. Folding them + /// would file the wrong instruction. + #stripeApiFailing; + /// The gateway is a faucet — see `Reason.unboundedGiveaway`. + /// + /// **A rail condition rather than a per-request reason** because it is a fact + /// about this gateway: it started refusing at a definite T, which is the + /// transition an operator wants exactly one audit line for. The lever is + /// "populate the allow-list", or "go live", or "do not fund the reserve yet". + #unboundedGiveaway; + }; + + /// Which refusals describe the gateway rather than one request. Exhaustive, so + /// a new `Reason` has to decide rather than defaulting to "not rail state". + public func railConditionOf(reason : Reason) : ?RailCondition { + switch (reason) { + case (#reserveShort(_)) ?#reserveShort; + case (#canisterCyclesLow(_)) ?#canisterCyclesLow; + case (#unboundedGiveaway(_)) ?#unboundedGiveaway; + // Nothing about the gateway changed: one request was malformed + // (`#amountBelowMin`, `#amountAboveMax`), or one principal used its own slot + // (`#tooManyOpenOrders`), or one principal is not on a list that IS doing its + // job (`#buyerNotAllowed` — the gateway is correctly bounded, so there is no + // gateway-level transition to announce). + case ( + #amountAboveMax(_) or #amountBelowMin(_) or #tooManyOpenOrders(_) + or #buyerNotAllowed + ) null; + }; + }; + + /// Which rail-state conditions are **currently** refusing, latched per + /// condition so that entering the state writes exactly one audit line. + /// + /// ⚠️ **Per condition, NOT one global "was admitting, now refusing" flag** — + /// that naive version reintroduces a smaller copy of the leak this exists to + /// close. A single flag is reset by *any* legitimate success, so the next + /// refusal announces again: bounded by real traffic rather than free, but + /// avoidable entirely by latching only the conditions that actually **have** + /// states. + public type RailStateLatch = { + reserveShort : Bool; + canisterCyclesLow : Bool; + railClosed : Bool; + stripeApiFailing : Bool; + unboundedGiveaway : Bool; + }; + + public func admitting() : RailStateLatch { + ({ + reserveShort = false; + canisterCyclesLow = false; + railClosed = false; + stripeApiFailing = false; + unboundedGiveaway = false; + }); + }; + + /// Enter a rail-state condition. `announce` is true only on the **transition + /// in** — the caller writes its audit line exactly then. + /// + /// This is the single place the announce-once semantics live, so every refusal + /// path gets them by routing here rather than by reimplementing them. + public func latchCondition(latch : RailStateLatch, condition : RailCondition) : { + latch : RailStateLatch; + announce : Bool; + } { + switch (condition) { + case (#reserveShort) { + ({ latch = { latch with reserveShort = true }; announce = not latch.reserveShort }); + }; + case (#canisterCyclesLow) { + ({ latch = { latch with canisterCyclesLow = true }; announce = not latch.canisterCyclesLow }); + }; + case (#railClosed) { + ({ latch = { latch with railClosed = true }; announce = not latch.railClosed }); + }; + case (#stripeApiFailing) { + ({ + latch = { latch with stripeApiFailing = true }; + announce = not latch.stripeApiFailing; + }); + }; + case (#unboundedGiveaway) { + ({ + latch = { latch with unboundedGiveaway = true }; + announce = not latch.unboundedGiveaway; + }); + }; + }; + }; + + /// Observe a refusal. `announce` is true only on the **transition into** a + /// rail-state condition — the caller writes its audit line exactly then. + public func latchRefusal(latch : RailStateLatch, reason : Reason) : { + latch : RailStateLatch; + announce : Bool; + } { + switch (railConditionOf(reason)) { + case (?condition) latchCondition(latch, condition); + // Per-request and per-principal reasons never announce and never latch: + // they say nothing about whether the gateway is refusing. + case null ({ latch; announce = false }); + }; + }; + + /// Observe a **successful** admission, which is the only thing that clears the + /// latches. + /// + /// ⚠️ **Only a full admission clears them, and that is deliberate.** `admit` + /// returns on the first failure, so a request refused for being below the + /// minimum never reaches the reserve check and tells us nothing about whether + /// the reserve recovered. Clearing on any refusal would drop the latch on + /// evidence that does not bear on it — and then re-announce on the next + /// genuine refusal. Reaching a full admission means neither condition fired, + /// which is exactly the evidence the latch needs. + /// ⚠️ **`#unboundedGiveaway` IS cleared here, and `#stripeApiFailing` is not — + /// the difference is what a synchronous admission proves.** A successful admit + /// says nothing about whether a Stripe *outcall* works, so clearing + /// `#stripeApiFailing` on it would drop the latch on evidence that does not bear + /// on it. The faucet condition is the opposite: it is a predicate over state the + /// admission just evaluated, so **if a buyer was admitted the predicate was + /// false at that moment** — the list is non-empty, or we are live, or the floor + /// is zero. Admission is direct proof. + /// + /// Decided rather than left to symmetry, because the other way round it + /// latches when the reserve is funded against an empty list and **never + /// clears**: `refusingNow` would report the faucet forever after the operator + /// populated the list and buyers started succeeding — a condition become + /// permanently unsatisfiable, making the console lie. + public func latchAdmission(latch : RailStateLatch) : RailStateLatch { + ({ + latch with + reserveShort = false; + canisterCyclesLow = false; + railClosed = false; + unboundedGiveaway = false; + }); + }; + + /// A session was created successfully — the only evidence that bears on + /// `#stripeApiFailing`. + /// + /// ⚠️ **Deliberately NOT cleared by `latchAdmission`, and this is the trap.** The + /// session outcall runs *after* admission, so a successful admission says nothing + /// about it. If admission cleared it, the sequence "admit ok → session fails → + /// admit ok → session fails" would announce **every time** — the naive global-flag + /// bug reappearing one level down. Each condition is cleared only by the evidence + /// that actually bears on it, which is why `latchAdmission` now names the three it + /// covers instead of resetting everything. + public func latchStripeApiOk(latch : RailStateLatch) : RailStateLatch { + ({ latch with stripeApiFailing = false }); + }; + + /// Everything the gate needs to decide, read by the caller immediately + /// before the decision (all synchronous — no awaits, so no TOCTOU window + /// between reading and deciding). + public type Observation = { + /// Count of this principal's `#created` orders. + openOrders : Nat; + /// `Cycles.balance()` — this canister's own gas. + canisterCycles : Nat; + /// The maintained reserve **floor**, used only as "is there anything to sell + /// at all" by the faucet check. + /// + /// ⚠️ **This is NOT a solvency input, and the distinction is load-bearing** — + /// see the warning at the end of `admit`. Solvency asks "can the reserve cover + /// *this order*", which must be paired with the hold in one synchronous block + /// or it is a TOCTOU window. This asks `> 0`, a question no interleaving can + /// make wrong in a harmful direction: if the floor is zero here and funded a + /// moment later, `solvent` still governs the actual sale and the next order + /// gets the faucet refusal. + /// + /// The **floor** rather than a ledger balance, and not merely because it is + /// synchronous: `solvent` derives availability from the floor too, so a + /// funded-but-unobserved reserve cannot sell and need not trigger the + /// condition. + reserveFloor : Nat; + /// Whether free Stripe **test** payments would be accepted — the rail's + /// `expectLivemode` is anything other than `?true`. + /// + /// Passed as a plain Bool rather than the `?Bool` itself so this module stays + /// ignorant of Stripe: the gate's question is "are payments here free and + /// unlimited", not "which mode is the key in". + acceptsTestPayments : Bool; + /// Whether the buyer allow-list has no entries at all. + buyerAllowlistEmpty : Bool; + /// Whether THIS caller is on the buyer allow-list. + buyerAllowed : Bool; + }; + + /// Admission decision. Cheapest checks first so a spammed principal is + /// rejected before anything expensive happens. + /// + /// `usdCents` is the *gross* tier/order amount, checked against the + /// per-purchase ceiling before any quote is computed. + public func admit(config : Config, observation : Observation, usdCents : Nat) : Result.Result<(), Reason> { + // ── The faucet, before anything about this request ─────────────────────── + // + // ⚠️ **First, and the order is the point.** This is a fact about the gateway, + // so it must not be shadowed by a refusal about the request: a below-minimum + // amount arriving while we are a faucet would report `#amountBelowMin`, the + // condition would never latch, and `refusingNow` would say we are admitting. + if ( + observation.acceptsTestPayments and observation.buyerAllowlistEmpty + and observation.reserveFloor > 0 + ) { + return #err(#unboundedGiveaway({ reserveFloor = observation.reserveFloor })); + }; + if (usdCents > config.maxPurchaseUsdCents) { + return #err(#amountAboveMax({ usdCents; maxUsdCents = config.maxPurchaseUsdCents })); + }; + // Checked HERE rather than only in the frontend, because a frontend-only + // bound is not a bound — and with custom amounts the buyer supplies this + // number directly. + if (usdCents < config.minPurchaseUsdCents) { + return #err(#amountBelowMin({ usdCents; minUsdCents = config.minPurchaseUsdCents })); + }; + if (observation.openOrders >= config.maxOpenOrdersPerPrincipal) { + return #err(#tooManyOpenOrders({ + open = observation.openOrders; + max = config.maxOpenOrdersPerPrincipal; + })); + }; + if (observation.canisterCycles < config.minCanisterCycles) { + return #err(#canisterCyclesLow({ + balance = observation.canisterCycles; + min = config.minCanisterCycles; + })); + }; + // ── This caller against a list that is doing its job ───────────────────── + // + // ⚠️ **LAST, and the faucet check is FIRST, and the two positions are the same + // argument.** `#unboundedGiveaway` is a fact about the gateway, so it must not + // be shadowed by anything about one request. `#buyerNotAllowed` is a fact about + // one *principal*, so it must not shadow anything about the gateway — and it + // would: `can_purchase` is a query the frontend calls to ask "can anyone buy + // right now", and every suite in this repo probes it **anonymously**. Checked + // early, an unlisted caller answered `#buyerNotAllowed` to that probe and hid + // the gas floor, the short reserve and the faucet behind it. 50 integration + // assertions failed that way. + // + // ⚠️ **Two conditions before the caller is even considered, and both are + // load-bearing.** At go-live the list must have no effect whatsoever, because + // one that keeps filtering afterwards is an outage nobody would look for. And + // an EMPTY list must not filter either: that is the state a sandbox gateway is + // configured in before the list is populated, so filtering on it would refuse + // every buyer during exactly the window the deployment is being explored in. + // The empty case is bounded by `#unboundedGiveaway` instead, which fires only + // once there is something to sell. + if ( + observation.acceptsTestPayments and not observation.buyerAllowlistEmpty + and not observation.buyerAllowed + ) { + return #err(#buyerNotAllowed); + }; + // ⚠️ **Solvency is NOT decided here — see `solvent` below.** Everything above + // is arithmetic over values the caller already holds; the money question needs + // a ledger read, so it cannot live in a synchronous function. Do not add a + // balance check here to save a call: that is what reintroduces the TOCTOU + // window this split exists to close. + // + // ⚠️ **The faucet check above reads `reserveFloor` and is NOT that mistake.** + // It asks `> 0` — is there anything to sell — not "does the reserve cover this + // order". A stale `> 0` cannot over-promise: `solvent` still decides every + // sale, paired with its hold. See `Observation.reserveFloor`. + #ok; + }; + + /// Can the reserve cover this order, on top of everything already owed? + /// + /// **Kept separate from `admit`, structurally.** `admit` is synchronous so there + /// is no TOCTOU window between observing and deciding; folding solvency in would force + /// every caller to supply a balance, including `can_purchase`, which is a **query** + /// and cannot await one. + /// + /// ⚠️ **The caller must hold `lockedCycles` in the SAME synchronous block as this + /// check.** Two concurrent creates that both pass here against one `promisedTotal` and + /// only then hold will together promise more than the balance they checked — two + /// honest buyers, no attacker. + public func solvent(reserveBalance : Nat, promisedTotal : Nat, lockedCycles : Nat) : Result.Result<(), Reason> { + if (Reserve.canCover(reserveBalance, promisedTotal, lockedCycles)) return #ok; + #err(#reserveShort({ + requested = lockedCycles; + available = Reserve.available(reserveBalance, promisedTotal); + })); + }; + +}; diff --git a/src/backend/Hmac.mo b/src/backend/Hmac.mo index ec57ab2..8e5fb2b 100644 --- a/src/backend/Hmac.mo +++ b/src/backend/Hmac.mo @@ -21,10 +21,10 @@ module { let keyBytes = normalized.toArray(); let padded = func(i : Nat) : Nat8 = if (i < keyBytes.size()) keyBytes[i] else 0; let innerDigest = Sha256.new(); - innerDigest.writeArray(Array.tabulate(blockSize, func i = padded(i) ^ 0x36)); + innerDigest.writeArray(Array.tabulate(blockSize, func i = padded(i) ^ 0x36)); for (part in parts.values()) { innerDigest.writeBlob(part) }; let outerDigest = Sha256.new(); - outerDigest.writeArray(Array.tabulate(blockSize, func i = padded(i) ^ 0x5c)); + outerDigest.writeArray(Array.tabulate(blockSize, func i = padded(i) ^ 0x5c)); outerDigest.writeBlob(innerDigest.sum()); outerDigest.sum(); }; diff --git a/src/backend/Http.mo b/src/backend/Http.mo index 5546f7e..0999489 100644 --- a/src/backend/Http.mo +++ b/src/backend/Http.mo @@ -1,16 +1,31 @@ -/// Hand-rolled HTTP ingress (§6.0) — deliberately NOT mo:server, which drags -/// in deprecated mo:base plus asset/caching/certification machinery this -/// canister doesn't need (assets live on a separate canister; this one only -/// takes POSTs whose query responses the gateway discards on upgrade). +/// Hand-rolled HTTP ingress (§6.0). +/// +/// ⚠️ **Not a rejected dependency — there is nothing to depend on.** Checked against the +/// pinned packages rather than assumed: `mo:core` contains no HTTP at all, and `mo:ic`'s +/// HTTP types are **outbound only** (`HttpRequestArgs` with `max_response_bytes`, +/// `transform`, `is_replicated` — the management canister's outcall interface, which +/// `rails/Session.mo` does use). Neither models the INBOUND gateway shape: `method`, +/// `url`, `headers`, `body` in; `status_code` and `upgrade` out. Those types have to be +/// declared here whichever way this goes. +/// +/// So what is actually hand-rolled is the dispatch — about forty lines: a route table, a +/// per-route `upgrade` flag, a body-size guard, and two header helpers. The alternative +/// is a framework (`mo:server`), whose asset, caching and certification machinery this +/// canister does not use: assets live on a separate canister, and every response here is +/// either discarded pre-upgrade or an error, so nothing is certified. ⚠️ That comparison +/// is a judgement about a package this repo does not install and nothing here verifies — +/// the checkable half is the paragraph above. /// /// Requests arrive as the *anonymous* principal (§6.0): routes here are /// authenticated by payload only, never by caller. Dispatch is off a route /// *table* with a per-route `upgrade` flag (binding seam §11.1.2) — "exactly /// one route" is policy, not architecture; a future rail adds rows. -import Array "mo:core/Array"; import Blob "mo:core/Blob"; import Char "mo:core/Char"; import List "mo:core/List"; +// `Nat32` for the receiver `.toChar()` in `asciiLower`: the conversion is spelled on +// the source value, and the import is what lets the method resolve. +import Nat32 "mo:core/Nat32"; import Text "mo:core/Text"; module { @@ -71,7 +86,7 @@ module { /// ASCII-only lowercase. Header names are ASCII tokens, so this is enough /// — and it avoids Unicode case-folding surprises. func asciiLower(text : Text) : Text { - text.map(func c = if (c >= 'A' and c <= 'Z') Char.fromNat32(c.toNat32() + 32) else c); + text.map(func c = if (c >= 'A' and c <= 'Z') (c.toNat32() + 32).toChar() else c); }; public func response(statusCode : Nat16, headers : [HeaderField], body : Blob) : Response { @@ -108,7 +123,7 @@ module { return text(413, "payload too large"); }; if (isQuery and route.upgrade) { - return { status_code = 200; headers = []; body = Blob.fromArray([]); upgrade = ?true }; + return { status_code = 200; headers = []; body = Blob.empty(); upgrade = ?true }; }; return route.handler(req); }; diff --git a/src/backend/Idempotency.mo b/src/backend/Idempotency.mo index c27c156..4a44ed0 100644 --- a/src/backend/Idempotency.mo +++ b/src/backend/Idempotency.mo @@ -1,10 +1,10 @@ /// Per-rail dedup sets with retention/pruning (§4.2). /// -/// Dedup gates the mint (§4.1 invariant): a key that fails `record*` here is -/// the *same* payment seen again (Stripe at-least-once redelivery, ck-USDC -/// block replay) and must be silently acked — never minted, never queued. +/// Dedup gates delivery (§4.1 invariant): a key that fails `record*` here is +/// the *same* payment seen again (Stripe at-least-once redelivery, +/// block replay) and must be silently acked — never delivered, never queued. /// A genuine second payment carries a fresh `event.id`/`payment_intent`, -/// passes dedup, and is the rail's business to queue as Type 1. +/// passes dedup, and is the rail's business to queue as a refundable obligation. /// /// Retention (§4.2): Stripe keys are timestamped at first sight and pruned /// after ~7 days (Stripe redelivers ≤3 days); crypto `block_index` dedup is @@ -24,18 +24,15 @@ module { public type Store = { /// Stripe `event.id` → first-seen ns. Catches event redelivery. stripeEvents : Map.Map; - /// Stripe `payment_intent` → first-seen ns. One mint per payment, even + /// Stripe `payment_intent` → first-seen ns. One delivery per payment, even /// across distinct event deliveries for the same intent. stripeIntents : Map.Map; - /// ck-USDC ledger block indexes already credited (§4.2). Never pruned. - ckUsdcBlocks : Set.Set; }; public func emptyStore() : Store { { - stripeEvents = Map.empty(); - stripeIntents = Map.empty(); - ckUsdcBlocks = Set.empty(); + stripeEvents = Map.empty(); + stripeIntents = Map.empty(); }; }; @@ -49,13 +46,6 @@ module { recordTimestamped(store.stripeIntents, paymentIntent, nowNs); }; - /// True = first sight, recorded; false = block already credited. - public func recordCkUsdcBlock(store : Store, blockIndex : Nat) : Bool { - if (store.ckUsdcBlocks.contains(blockIndex)) return false; - store.ckUsdcBlocks.add(blockIndex); - true; - }; - /// Drop Stripe keys first seen ≥ `stripeRetentionNs` ago. Returns the count /// pruned. Cheap enough to call opportunistically from the webhook path or /// the recovery timer; the first-seen timestamp never refreshes, so a key diff --git a/src/backend/Json.mo b/src/backend/Json.mo index 9e8e4bb..788a924 100644 --- a/src/backend/Json.mo +++ b/src/backend/Json.mo @@ -93,9 +93,9 @@ module { i += 2; let ?low = parseHex4() else return null; if (low < 0xDC00 or low > 0xDFFF) return null; - return ?Char.fromNat32(0x10000 + (unit - 0xD800) * 0x400 + (low - 0xDC00)); + return ?(0x10000 + (unit - 0xD800) * 0x400 + (low - 0xDC00)).toChar(); }; - ?Char.fromNat32(unit); + ?unit.toChar(); }; case (_) null; }; @@ -284,4 +284,14 @@ module { }; }; + /// Null unless the path holds a genuine JSON boolean — a string `"true"` or a + /// missing field are both "not a boolean", so a caller deciding something + /// security-relevant cannot be fooled by a value of the wrong type. + public func boolAt(json : Json, path : Text) : ?Bool { + switch (at(json, path)) { + case (?#bool(b)) ?b; + case (_) null; + }; + }; + }; diff --git a/src/backend/Main.mo b/src/backend/Main.mo index c66d3e7..07d4165 100644 --- a/src/backend/Main.mo +++ b/src/backend/Main.mo @@ -1,13 +1,18 @@ /// Fully on-chain cycles gateway — composition root. /// -/// See design-docs/ONCHAIN_GATEWAY_SPEC.md (spec v2.1) and PRD.md for the -/// module layout this actor grows into: Orders.mo wiring, rails/, Cmc.mo, -/// Forex.mo, Treasury.mo, ErrorQueue.mo, Auth.mo. +/// The module layout this actor composes: Orders.mo, Delivery.mo, Pricing.mo, +/// Reserve.mo, Gate.mo, Problems.mo, Orphans.mo, Auth.mo, Card.mo, Http.mo. +/// Decision record for the `§N` comments: `docs/DESIGN.md`. +import Array "mo:core/Array"; +import Cycles "mo:core/Cycles"; import Error "mo:core/Error"; +// ⚠️ Referenced only through dot-notation sugar (`someIter.toArray()`), which resolves +// via the imported module — so this reads as an unused import and is not. Removing it +// fails with M0072 "field toArray does not exist", nowhere near the import. +import Int "mo:core/Int"; import List "mo:core/List"; import Map "mo:core/Map"; import Nat "mo:core/Nat"; -import Nat64 "mo:core/Nat64"; import Principal "mo:core/Principal"; import Result "mo:core/Result"; import Runtime "mo:core/Runtime"; @@ -15,477 +20,1244 @@ import Set "mo:core/Set"; import Text "mo:core/Text"; import Time "mo:core/Time"; import Timer "mo:core/Timer"; -import { ic } "mo:ic"; +// `Call.httpRequest` attaches the exact `ic0.cost_http_request` price; `IC` is +// imported for the request/response types the transform signature needs. import Call "mo:ic/Call"; +// The vetKD argument/result records for the two management-canister methods +// `Sealed.mo` needs. Only reached through the `management` actor declaration below. import IC "mo:ic/Types"; +// The unwrapped vetKey's type, for the cache below. EXPERIMENTAL — see Sealed.mo. +import G1 "mo:ic-bls12-381/G1"; import AuditLog "AuditLog"; import Auth "Auth"; import Cmc "Cmc"; -import ErrorQueue "ErrorQueue"; -import Forex "Forex"; +import Delivery "Delivery"; +import Orphans "Orphans"; +import Pricing "Pricing"; +import Xrc "Xrc"; +import Gate "Gate"; import Http "Http"; import Idempotency "Idempotency"; import Orders "Orders"; import Recovery "Recovery"; +import Reserve "Reserve"; import Card "rails/Card"; -import CkUsdc "rails/CkUsdc"; +import SecretsMixin "mixins/Secrets"; +import PrincipalsMixin "mixins/Principals"; +import MonitoringMixin "mixins/Monitoring"; +import ConfigMixin "mixins/Config"; +import OrdersMixin "mixins/Orders"; +import WebhookMixin "mixins/Webhook"; +import BuyingMixin "mixins/Buying"; +import AdminOrdersMixin "mixins/AdminOrders"; +import MaintenanceMixin "mixins/Maintenance"; +import Session "rails/Session"; +import Sealed "Sealed"; import Secret "Secret"; import Tiers "Tiers"; -import Treasury "Treasury"; import Types "Types"; persistent actor CyclesGateway { - /// §7: the only stored secret — plaintext by design, SEV-SNP posture and - /// burn-cap backstop documented in Secret.mo. Persists across upgrades; - /// rotation never requires a redeploy. + // §7 secret one of TWO: the Stripe webhook signing key. Plaintext by design, + // SEV-SNP posture documented in Secret.mo. Persists across upgrades; rotation + // never requires a redeploy. let webhookSecret : Secret.Store = Secret.emptyStore(); - /// §7 admin authz: caller ∈ controllers (flat allowlist, equal - /// privileges — see Auth.mo). Traps rather than returning an error so an - /// unauthorized call can never be mistaken for a handled outcome. - func requireAdmin(caller : Principal) { - switch (Auth.checkAdmin(caller, Principal.isController)) { + // §7 secret two: the Stripe **API key** that creates Checkout Sessions. + // + // Same store, same posture, same never-readable-back guarantee. Use a + // **restricted key** (`rk_...`) with *Checkout Sessions = Write* and everything else + // None: a leaked key at that scope can create sessions that pay us and read sessions + // back, which is materially different from one that can also issue refunds. + // ⚠️ **Write rather than Read, because both are needed** — the rail creates sessions + // and the recovery sweep retrieves one to settle a stranded order. Stripe's + // permissions are escalating per resource, so Write is the single level that covers + // both. Stripe's + // IP/ASN access policies are not usable here — a subnet's replicas have many + // changing addresses. + let stripeApiKey : Secret.Store = Secret.emptyStore(); + + // The vetKey that opens both sealed secrets, cached after the first provisioning call. + // + // Safe to cache: derivation is deterministic in `(canister, context, input, key_id)`, + // none of which depends on the secrets. One derivation therefore serves the API key and + // the webhook secret — they share `Sealed.keyLabel`. + // + // **`transient`, so an upgrade drops it**, and the next provisioning derives again + // for ~26 B cycles. Orthogonal persistence would keep it for free, and that is exactly + // the trap: editing `Sealed.context` or `Sealed.keyLabel` would then leave a cache + // serving the key for the OLD values until the canister was reinstalled, so sealing + // against the new published key would fail with `#notSealedToThisCanister` while the + // source said it should work. + transient var sealedVetkey : ?G1.Affine = null; + + // Stripe's two operator-set values: where buyers are returned, and which mode + // this deployment serves. + // ⚠️ **A record rather than loose `var` fields, because `include` passes by value.** + // A mixin handed a bare `var` gets a snapshot from install time, so its + // writes land on a copy and its reads never move. A record is a heap object, so the + // mixin and the actor share one. Grouped by subsystem, which is the slice a mixin + // asks for (`reviewing-motoko` A6) rather than an accessor per field. + let stripeState : { + // The asset origin Stripe returns the buyer to, e.g. + // `https://.icp.net`. Null until an admin sets it, and + // `create_order` fails closed rather than creating a sessionless order. + // + // ⚠️ **Admin config, never a `create_order` parameter.** A caller-supplied + // `success_url` is an open redirect that Stripe renders *after a real + // payment* — a phishing primitive wearing a genuine receipt page. + // + // Changing it later invalidates nothing already paid, but Internet Identity + // derives a principal **per origin**, so an origin change is a user-visible + // migration rather than a config tweak: existing buyers get new principals and + // cannot see their old orders. Choose it once. + var origin : ?Text; + // Which Stripe world this gateway belongs to, or null for "not declared". + // + // A test-mode webhook secret provisioned against a canister holding a funded + // reserve would deliver real cycles for payments that never happened — the secret + // is the only thing separating the two, and provisioning the wrong one is an + // ordinary operator slip. Declaring the expectation lets the canister + // refuse the mismatch instead of trusting that nobody pasted the wrong value. + // + // Null rather than `?true` by default so a fresh local install works against + // a Stripe sandbox without configuration. The go-live checklist sets it, and + // until it is set every honoured payment records `stripe.livemodeUnset` — a + // nudge that stops as soon as the expectation is declared. + var expectLivemode : ?Bool; + } = { + var origin = null; + var expectLivemode = null; + }; + + // Principals granted the CASES tier. Controllers are not listed here and do not + // need to be — `Auth.checkAdmin` passes them anyway. + // + // ⚠️ **A principal here is derived from the origin the admin signed in at**, because + // Internet Identity derives per origin. Naming a canonical origin is owner-owned and + // lands before production; until it is pinned, grants made now may need re-granting by + // a controller. Alternative origins let a second origin obtain the canonical origin's + // principal — they do not retroactively fix principals derived before one was declared. + let adminPrincipals = Set.empty(); + + // Principals allowed to create orders **while this gateway accepts free Stripe + // test payments.** + // + // ⚠️ **Without it a sandbox deployment is a cycles faucet.** Stripe test + // payments are free and unlimited, so `4242 4242 4242 4242` pays any session + // for anyone who reaches the page. The simulation divisor caps the loss *per + // order*; only this list caps the total. + // + // ⚠️ **An EMPTY list is not "refuse everyone" per buyer** — that would refuse + // every buyer on a sandbox deployment before this list is populated, which is + // the state a fresh gateway is configured in. What bounds the empty case is + // `Gate.Reason.unboundedGiveaway`, which refuses the moment there is something + // to sell. So an empty list means unrestricted while the reserve floor is zero + // (where nothing can be sold anyway) and refusing-everyone once it is not. + // + // At go-live (`stripe.expectLivemode == ?true`) it has no effect whatsoever. A list + // that keeps filtering after go-live is an outage nobody would look for. + let allowedBuyers = Set.empty(); + + // The RULES tier: controller only. Traps rather than returning an error so an + // unauthorized call can never be mistaken for a handled outcome. + // + // **Everything that changes the rules is here, and `scripts/check-admin-tiers.py` + // is what keeps it that way** — it reads each method's body and fails when the guard it + // calls is not the one its tier declares. A table alone would prove the list complete + // and say nothing about whether the code honours it. + func requireController(caller : Principal) { + switch (Auth.checkController(caller, Principal.isController)) { case (#ok) {}; case (#err(#anonymous)) Runtime.trap("admin API: anonymous caller rejected"); - case (#err(#notController)) Runtime.trap("admin API: caller is not a controller"); + case (#err(_)) Runtime.trap("admin API: caller is not a controller"); }; }; - /// Provision or rotate the Stripe webhook signing secret (§7). Pass the - /// full `whsec_...` string from the Stripe dashboard — the whole string, - /// prefix included, is the HMAC key. NOTE: the argument transits the - /// TLS-terminating boundary node as plain ingress (§7 provisioning - /// exposure); rotate after provisioning over an untrusted path. - public shared ({ caller }) func set_webhook_secret(secret : Text) : async Result.Result<(), Secret.SetError> { - requireAdmin(caller); - Secret.set(webhookSecret, secret.encodeUtf8(), Time.now()); + // The CASES tier: a controller, or a principal a controller has granted. + func requireAdmin(caller : Principal) { + switch (Auth.checkAdmin(caller, Principal.isController, isGrantedAdmin)) { + case (#ok) {}; + case (#err(#anonymous)) Runtime.trap("admin API: anonymous caller rejected"); + case (#err(_)) Runtime.trap("admin API: caller is not an admin"); + }; }; - /// Provisioning state only — the secret itself is never readable back - /// out, even by controllers. `generation` confirms a rotation landed. - public shared query ({ caller }) func webhook_secret_status() : async Secret.Status { - requireAdmin(caller); - Secret.status(webhookSecret); + func isGrantedAdmin(p : Principal) : Bool { + adminPrincipals.contains(p); }; + + + + + // ── The buyer allow-list ──────────────────────────────────────────────── + // + // **Controller-only, like everything that changes the RULES.** The list + // decides who may take cycles out of a funded reserve for free test money, so + // it is not a per-case decision an admin makes — it is a rule. + + + + // ── Order + tier state (task 6) ───────────────────────────────────────── - /// §4.2 order store: `orders` + `principalsToOrders` history. + // §4.2 order store: `orders` + `principalsToOrders` history. let orderStore : Orders.Store = Orders.emptyStore(); - /// §3 fixed card tiers. Operator config (§7): controllers create the - /// Payment Links in the Stripe Dashboard and register them here. Empty - /// until first `set_card_tiers` — no made-up default prices. - var cardTiers : [Tiers.Tier] = []; - - // ── Forex (task 7, §3.1) ──────────────────────────────────────────────── - - /// §3.1 stable `{rate, ts}` cache — orders read this; only staleness - /// triggers an outcall. Survives upgrades, so a redeploy doesn't force a - /// refresh before the first order. - let forexCache : Forex.Cache = Forex.emptyCache(); - - /// §3 fee formula + §3.1 staleness window + source URL. Admin-adjustable - /// (§7 "adjust forex params") without a redeploy. - var forexConfig : Forex.Config = Forex.defaultConfig(); - - /// §3.1 single-flight guard: at most one outcall in flight; a burst - /// hitting a stale cache fails closed (#rateUnavailable, retry shortly) - /// instead of stampeding the source. Transient on purpose — a persistent - /// flag left true by an upgrade mid-outcall would deadlock refreshes - /// forever; an upgrade resets it. - transient var forexRefreshInFlight = false; - - /// §3.1 in-call retry cap — consensus boundary-splits often clear on an - /// immediate retry; API-down does not, so we bound the attempts and fail - /// closed. - transient let forexMaxAttempts : Nat = 3; - - /// The default source's USD-base body is ~4 KiB; cycles are charged on - /// this ceiling, not the actual size, so keep it tight. - transient let forexMaxResponseBytes : Nat64 = 16_000; - - /// §3.1 coarse-rounding transform: every replica reduces its raw response - /// to a tiny canonical body (rounded micro-XDR/USD, or empty on parse - /// failure), so consensus is over `"737000"`, not 4 KiB of live JSON. - public query func forex_transform(args : { context : Blob; response : IC.HttpRequestResult }) : async IC.HttpRequestResult { - { args.response with headers = []; body = Forex.transformBody(args.response.body) }; + // The price tiles, as one record. + // **A record rather than loose `var` fields, because `include` passes by value.** + // A mixin handed a bare `var` gets a snapshot from install time, so its + // writes land on a copy and its reads never move. A record is a heap object, so the + // mixin and the actor share one. Grouped by subsystem, which is the slice a mixin + // asks for (`reviewing-motoko` A6) rather than an accessor per field. + let tierState : { + // §3 fixed card tiers. Operator config (§7): controllers create the + // amounts the UI offers as tiles. Presentational: a buyer can order + // any amount between the gate's floor and ceiling, so an empty list means "no + // tiles", not "rail off". Empty + // until first `set_card_tiers` — no made-up default prices. + var cards : [Tiers.Tier]; + } = { + var cards = []; + }; + + // The admission gate's mutable state, as ONE record. + // + // **A record rather than three `var` fields, because `include` passes by value** — + // a mixin handed a bare `var` gets a snapshot from install time, so its writes land + // on a copy and its reads never move. A record is a heap object, so the mixin and the + // actor share it. Grouped by subsystem rather than one wrapper per field: that is the + // slice a mixin asks for (`reviewing-motoko` A6), and it keeps the include sites from + // carrying an accessor per field. + let gateState : { + // Pre-creation admission policy (Gate.mo) — open-order cap, own-cycles + // floor, per-purchase ceiling. These default to real + // values: they are safety limits, and a zero default would brick the + // canister rather than protect it. + var config : Gate.Config; + // Refusal tallies and the rail-state latch. + // + // **Stable, because they replace an audit line.** These carry the content + // of a per-attempt audit line that no longer exists, so losing them on upgrade + // would lose the volume signal the monitoring rows read. + var refusals : Gate.RefusalCounts; + var latch : Gate.RailStateLatch; + } = { + var config = Gate.defaultConfig(); + var refusals = Gate.noRefusals(); + var latch = Gate.admitting(); + }; + + // `payment_intent` → the order it paid for. Financial record, never pruned; + // the only way `charge.refunded` can tell whether the refunded payment had + // already been delivered as cycles. + let paidIntents = Map.empty(); + + // ── Pricing rates (§3/§3.1) ───────────────────────────────────────────── + + // Both §3 rate inputs, cached together. Persistent, so a redeploy does not + // blank the price — an upgrade only costs pricing if it outlasts the + // staleness window, which the one-shot refresh below covers. + let rateCache : Pricing.Cache = Pricing.emptyCache(); + + // Pricing policy and the last refresh attempt, as one record. + // **A record rather than loose `var` fields, because `include` passes by value.** + // A mixin handed a bare `var` gets a snapshot from install time, so its + // writes land on a copy and its reads never move. A record is a heap object, so the + // mixin and the actor share one. Grouped by subsystem, which is the slice a mixin + // asks for (`reviewing-motoko` A6) rather than an accessor per field. + let pricingState : { + // §3 fee formula + staleness window + the delta guard. Admin-adjustable + // without a redeploy. There is deliberately no rate-source setting: the XRC + // and CMC ids are pinned in their modules, because a settable rate source is + // a money lever that does not look like one. + var config : Pricing.Config; + // Liveness for ops. A stale rate is ambiguous between "the timer is dead" + // and "XRC is erroring", and those want different responses — so both the + // last attempt and the last error are recorded. + var lastAttempt : ?{ atNs : Int; ok : Bool; detail : Text }; + } = { + var config = Pricing.defaultConfig(); + var lastAttempt = null; + }; + + + // Which XRC this gateway prices from. + // + // Read **lazily on every use, never cached at init**, per the icp-cli guidance: + // on a first deploy a sibling canister may not exist yet when this one + // initialises, and `--mode reinstall` wipes anything held in state while the + // automatic variables are re-stamped on every deploy. A lazy read self-heals. + // + // Absent variable → the mainnet XRC, so a production deploy that injects + // nothing is correct by default. + // The id the last refresh actually used, for `pricing_status`. + // + // Mirrored into a var because reading an environment variable needs the + // `system` capability, which a query does not have — and "which XRC am I + // pricing from?" has to be answerable from a query, since a mainnet deploy + // wrongly pointed at a mock is otherwise completely silent. + // + // **Null until an XRC call has actually resolved the id**, and transient, so it + // is null again after every upgrade until the refresh timer warms (seconds). + // Defaulting it to the mainnet id instead would make the one signal that + // detects a mock read *all-clear* during exactly the window an operator checks + // a fresh deploy — an alert that is silent when unverified is worse than none. + transient var lastXrcCanisterId : ?Text = null; + + func xrcActor() : Xrc.Service { + let id = switch (Runtime.envVar(Xrc.canisterIdEnvVar)) { + case (?injected) injected; + case null Xrc.mainnetCanisterId; + }; + lastXrcCanisterId := ?id; + actor (id); + }; + + // Single-flight guard for the refresh. Transient: a flag left true by an + // upgrade mid-call would deadlock refreshes forever. + transient var rateRefreshInFlight = false; + + // Consecutive refresh failures, for backoff. Transient — an upgrade is a + // fine moment to retry immediately. + transient var rateRefreshFailures : Nat = 0; + + // Ticks to skip after a failure, doubling to this cap. XRC answers + // `RateLimited` if we hammer it, so backing off is both cheaper and the + // behaviour that recovers fastest. + transient let rateBackoffMaxTicks : Nat = 8; + + // Remaining ticks to skip before the next attempt. + transient var rateTicksToSkip : Nat = 0; + + + // Is the rail live enough to be worth spending cycles keeping a rate warm? + // A dark gateway refreshes nothing. + // Is the card rail capable of completing a purchase? + // + // **Both Stripe secrets, and nothing else**. Derived from actual + // capability rather than declared separately: + // + // - no **API key** → `create_order` cannot produce a payable session at all; + // - no **webhook secret** → `handleWebhook` answers 503, so a buyer can pay + // and we cannot credit them. + // + // Neither state can complete a purchase, so neither should accept one. This + // used to read `tiers.cards.size() > 0`, which was a proxy inherited from the + // Payment Link design — and with custom amounts it would stop nothing. + // + // It gates the rate-refresh timer, so this also fixes a real waste: a gateway + // with presets but no API key used to pay for XRC calls it could never use. + func railsLive() : Bool { + Secret.status(stripeApiKey).isSet and Secret.status(webhookSecret).isSet; + }; + + func recordRateAttempt(ok : Bool, detail : Text) { + pricingState.lastAttempt := ?{ atNs = Time.now(); ok; detail }; + if (ok) { + rateRefreshFailures := 0; + } else { + if (rateRefreshFailures < rateBackoffMaxTicks) rateRefreshFailures += 1; + audit("rates.refreshFailed", detail); + }; }; - /// §3.1 lazy refresh: fire the outcall (≤ forexMaxAttempts), record the - /// rate on success. Returns the fresh micro-rate, or null for the caller - /// to fail closed. Never traps on outcall failure — the error becomes a - /// blocked order, not a 5xx. - func refreshForexRate() : async* ?Nat { - if (forexRefreshInFlight) return null; - forexRefreshInFlight := true; + // Read both §3 rate inputs and cache them together. + // + // Only ever called from the refresh timer — never from a user-facing method. + // The XRC charges per request, so a call reachable from `create_order` would + // be an operation that is free to invoke and expensive to serve; worse, a + // failing XRC would leave the cache stale and let every subsequent order + // retry, which is a self-reinforcing drain. Orders read the cache and fail + // closed instead. + func refreshRates() : async* () { + if (rateRefreshInFlight) return; + rateRefreshInFlight := true; try { - var attempts = 0; - while (attempts < forexMaxAttempts) { - let micros = try { - let response = await Call.httpRequest({ - url = forexConfig.url; - method = #get; - max_response_bytes = ?forexMaxResponseBytes; - body = null; - headers = [ - { name = "Host"; value = Forex.hostOf(forexConfig.url) }, - { name = "User-Agent"; value = "cycles-gateway" }, - ]; - transform = ?{ function = forex_transform; context = "".encodeUtf8() }; - is_replicated = null; - }); - if (response.status == 200) Forex.parseCanonicalMicros(response.body) else null; - } catch (_) null; - switch (micros) { - case (?m) { Forex.record(forexCache, m, Time.now()); return ?m }; - case null attempts += 1; + // ICP/USD from the XRC. Exactly 1 B cycles must be attached; the unused + // remainder is refunded. + let usdResult = try { + await (with cycles = Xrc.callCycles) xrcActor().get_exchange_rate(Xrc.icpUsdRequest()); + } catch (e) { + recordRateAttempt(false, "xrc call rejected: " # e.message()); + return; + }; + let rate = switch (usdResult) { + case (#Ok(rate)) rate; + case (#Err(error)) { + recordRateAttempt(false, "xrc: " # Xrc.errorToText(error)); + return; }; }; - null; + let ?usdPerIcpMicros = Xrc.toMicros(rate) else { + recordRateAttempt(false, "xrc returned an unusable rate scale"); + return; + }; + if (not Pricing.plausibleUsdPerIcp(usdPerIcpMicros)) { + recordRateAttempt(false, "implausible ICP price: " # usdPerIcpMicros.toText() # " micro-USD"); + return; + }; + // The one-exchange case: XRC's own consistency check cannot catch it, + // because a single rate cannot disagree with itself. + let quality = Xrc.qualityOf(rate); + if (quality.receivedRates < pricingState.config.minRateSources) { + recordRateAttempt( + false, + "too few rate sources: " # quality.receivedRates.toText() # " of " + # quality.queriedSources.toText() # " answered, need " + # pricingState.config.minRateSources.toText(), + ); + return; + }; + // Reject an implausible *move* against the last good price, keeping the + // previous rate serving until it goes stale rather than pricing on a + // suspected glitch. + // + // Only a rate that is still *fresh* is a valid baseline. A stale one is not + // evidence about the current market, and comparing against it deadlocks: + // after an outage spanning a move larger than the delta bound, every + // refresh would be rejected against an ancient price that itself can never + // be replaced, so orders stay refused until an operator widens the config. + // Guarding a move only makes sense between two observations close in time. + let previous = switch (Pricing.freshRates(rateCache, pricingState.config.maxAgeNs, Time.now())) { + case (?prior) ?prior.usdPerIcpMicros; + case null null; + }; + if (not Pricing.withinDelta(previous, usdPerIcpMicros, pricingState.config.maxRateDeltaBps)) { + recordRateAttempt(false, "ICP price moved beyond the delta guard: " # usdPerIcpMicros.toText() # " micro-USD"); + return; + }; + // XDR/ICP from the CMC — the rate the CMC will actually honour, so it is + // read from the CMC and nowhere else. Same tick as the ICP price above, + // which is what makes the pair time-aligned. + let cmcRate = try { await cmc.get_icp_xdr_conversion_rate() } catch (e) { + recordRateAttempt(false, "cmc call rejected: " # e.message()); + return; + }; + let ?permyriad = Cmc.freshCmcRate(cmcRate.data, Time.now(), Cmc.cmcRateMaxAgeNs) else { + recordRateAttempt(false, "cmc rate is stale or zero"); + return; + }; + // Cross-check the two independent sources against each other. Dividing + // them yields an implied XDR/USD, and XDR/USD is stable enough that an + // implausible value means one of the two is wrong — which the wide band on + // the ICP price alone would not catch. + let ?implied = Pricing.impliedXdrPerUsdMicros(permyriad, usdPerIcpMicros) else { + recordRateAttempt(false, "cannot derive an implied XDR/USD from the rate pair"); + return; + }; + if (not Pricing.plausibleImpliedXdrPerUsd(implied)) { + recordRateAttempt( + false, + "rate pair disagrees: implied " # implied.toText() # " micro-XDR/USD from " + # usdPerIcpMicros.toText() # " micro-USD/ICP and " # permyriad.toText() + # " permyriad XDR/ICP", + ); + return; + }; + Pricing.record( + rateCache, + { + usdPerIcpMicros; + xdrPermyriadPerIcp = permyriad; + fetchedAtNs = Time.now(); + quality; + }, + ); + recordRateAttempt(true, usdPerIcpMicros.toText() # " micro-USD/ICP, " # permyriad.toText() # " permyriad XDR/ICP"); } finally { - forexRefreshInFlight := false; + rateRefreshInFlight := false; }; }; - /// Adjust forex params (§7): fee formula, staleness window, source URL. - /// Validated atomically — a bad config never partially applies. - public shared ({ caller }) func set_forex_config(config : Forex.Config) : async Result.Result<(), Forex.ConfigError> { - requireAdmin(caller); - switch (Forex.validateConfig(config)) { - case (#ok) { forexConfig := config; #ok }; - case (#err(e)) #err(e); + // The rate timer's job: refresh unless backing off, and only while a rail is + // actually selling. + // + // Refreshing on a timer rather than on demand is what makes the XRC's + // per-request fee independent of call volume — no user-facing method can + // trigger it, so no caller can drive our cycle spend. The cost is that a + // live gateway pays continuously whether or not anyone buys, which is why + // `railsLive` gates it: a dark gateway spends nothing. + func rateTimerJob() : async () { + if (not railsLive()) return; + if (rateTicksToSkip > 0) { + rateTicksToSkip -= 1; + return; + }; + await* refreshRates(); + // Exponential-ish backoff after a failure so an XRC outage neither burns + // cycles nor earns us `RateLimited`. + if (rateRefreshFailures > 0) { + var skip = 1; + var n = rateRefreshFailures; + while (n > 1 and skip < rateBackoffMaxTicks) { skip *= 2; n -= 1 }; + rateTicksToSkip := if (skip > rateBackoffMaxTicks) rateBackoffMaxTicks else skip; }; }; - /// Cache + params, public: the rate is market data and the fee formula is - /// what users are charged — nothing here is secret, and transparency is - /// the product thesis. - public query func forex_status() : async { rate : ?Forex.Rate; config : Forex.Config } { - { rate = forexCache.rate; config = forexConfig }; + // Refresh cadence. Derived from the staleness window rather than configured + // separately, so the two can never be set inconsistently — a cadence longer + // than the window would let the cache lapse between ticks and refuse orders. + func rateIntervalNs() : Nat { + let half = Int.abs(pricingState.config.maxAgeNs) / 2; + if (half < 30_000_000_000) 30_000_000_000 else half; }; - // ── Orders: create/query (task 6) ─────────────────────────────────────── - /// raw_rand source for order IDs (§2). + + + // ── Management canister: entropy and vetKD ────────────────────────────── + + // One reference, two unrelated users: `raw_rand` for order ids (§2) and the two vetKD + // methods for sealed provisioning. Its own section because it belongs to neither + // path exclusively. transient let management = actor "aaaaa-aa" : actor { raw_rand : () -> async Blob; + // vetKD, for sealed provisioning. Declared on the same reference as `raw_rand` + // rather than reaching for `mo:ic`'s `ic` object, so this actor has one path to the + // management canister instead of two. + vetkd_derive_key : IC.VetkdDeriveKeyArgs -> async IC.VetkdDeriveKeyResult; + vetkd_public_key : IC.VetkdPublicKeyArgs -> async IC.VetkdPublicKeyResult; + }; + + transient let vetkdKeyId : { name : Text; curve : IC.VetkdCurve } = { + name = Sealed.keyName; + curve = #bls12_381_g2; + }; + + // Derives (once) the vetKey that opens sealed secrets. + // + // Three management-canister calls on a cold cache: entropy for the transport keypair, + // the derivation itself, and the derived public key the reply is verified against. + // + // **Two concurrent provisioning calls on a cold cache will both derive.** Accepted + // rather than prevented: derivation is deterministic, so both get the identical key and + // the only cost is a duplicate ~26 B cycle fee. Both are controller-gated and + // provisioning is rare, so a lock would guard against nothing that happens. + func sealedKey() : async* Result.Result { + switch (sealedVetkey) { case (?key) return #ok(key); case null {} }; + + let entropy = try { await management.raw_rand() } catch (e) { + return #err(#entropyUnavailable({ detail = e.message() })); + }; + let transport = Sealed.transportSecret(entropy); + + // The replica's own figure (moc 1.16.0), not a constant. A rejected cost query is + // returned rather than defaulted — see `Sealed.derivationFee`. + let fee = switch (Sealed.derivationFee()) { + case (#ok(cycles)) cycles; + case (#err(e)) return #err(e); + }; + + let reply = try { + await (with cycles = fee) management.vetkd_derive_key({ + context = Sealed.context(); + input = Sealed.keyLabel(); + key_id = vetkdKeyId; + transport_public_key = Sealed.transportPublicKey(transport); + }); + } catch (e) { + return #err(#vetkdUnavailable({ detail = e.message() })); + }; + + let reported = try { + await management.vetkd_public_key({ + canister_id = null; + context = Sealed.context(); + key_id = vetkdKeyId; + }); + } catch (e) { + return #err(#vetkdUnavailable({ detail = e.message() })); + }; + + switch (Sealed.unwrap(reply.encrypted_key, transport, reported.public_key)) { + case (#err(e)) #err(e); + case (#ok(key)) { sealedVetkey := ?key; #ok(key) }; + }; + }; + + // Turns a sealed argument into the plaintext a `Secret.Store` holds. + // + // Passed to `SecretsMixin` as a closure, so the mixin stays at authorize → delegate → + // map and needs no view of the management canister or of the vetKey cache. + func openSealed(ciphertext : Blob) : async* Result.Result { + switch (await* sealedKey()) { + case (#err(e)) #err(e); + case (#ok(key)) Sealed.open(ciphertext, key); + }; }; - /// raw_rand re-draws on an ID collision. With 128-bit IDs a single - /// collision is already astronomically unlikely; exhausting this means - /// the entropy source is broken, not that we're unlucky. + // ── Orders: create/query (task 6) ─────────────────────────────────────── + + // raw_rand re-draws on an ID collision. With 128-bit IDs a single + // collision is already astronomically unlikely; exhausting this means + // the entropy source is broken, not that we're unlucky. transient let maxIdAttempts : Nat = 3; - public type CreateOrderError = { - /// Anonymous principal — a shared identity can't own orders (§7). - #anonymous; - /// No configured tier with this id. - #unknownTier : Text; - /// §3 fee formula swallows the tier's gross amount — a tier/fee config - /// problem for the operator, not something a retry fixes. - #tierBelowFees : Text; - /// §3.1 fail-closed: no fresh rate and the refresh failed (or one is - /// already in flight), so no price, so no order. Retry shortly. - #rateUnavailable; - /// Entropy source misbehaved (short blob or repeated collisions). - #idGeneration; + + + + + // Create the Checkout Session for a freshly committed order. + // + // Returns the session, or a reason the caller turns into a distinguishable + // `create_order` error. Cycles are attached by `Call.httpRequest`, which + // computes the exact `ic0.cost_http_request` price — **never hand-attach and + // never add a buffer**: over-attaching is refunded, but the cycles are reserved + // for the call's duration, so a buffer reduces how many outcalls can be in + // flight, which is precisely why the library attaches the minimum. + // The two things a session needs, or a reason there is none. + // + // ⚠️ **Read this BEFORE committing an order.** Both checks short-circuit + // without an outcall, so an unprovisioned gateway that committed the order + // first would create a permanent `#expired` record for **free**: no cycles are + // spent, so `minCanisterCycles` never bounds the loop, and the record is not + // `#created`, so the open-order cap does not either. Unbounded storage growth + // at zero attacker cost — and precisely in the state docs/OPERATE.md prescribes + // during go-live, since provisioning the secrets last is what opens the rail. + // + // Fail closed rather than create a sessionless order. + func sessionConfig() : { #ok : { apiKey : Text; origin : Text }; #err : Session.Error } { + let ?apiKey = Secret.get(stripeApiKey) else return #err(#railClosed); + let ?keyText = apiKey.decodeUtf8() else return #err(#railClosed); + let ?origin = stripeState.origin else return #err(#originUnset); + #ok({ apiKey = keyText; origin }); }; - public type CreatedOrder = { - order : Types.Order; - /// `_` — the frontend appends this to the tier's - /// Payment Link as `?client_reference_id=` (§6.1). - clientReferenceId : Text; + func createStripeSession( + config : { apiKey : Text; origin : Text }, + orderId : Types.OrderId, + clientReferenceId : Text, + usdCents : Nat, + ) : async* { #ok : Session.Created; #err : Session.Error } { + let keyText = config.apiKey; + let origin = config.origin; + // Stripe evaluates the 30-minute floor against ITS clock on receipt, so the + // request asks for 35 to survive skew and consensus latency. `expiresAtNs` + // comes from the response, not from this. + let expiresAtSeconds = Int.abs(Time.now() / 1_000_000_000) + Session.requestedLifetimeSeconds; + let body = Session.createBody({ + orderId; + clientReferenceId; + usdCents; + origin; + expiresAtSeconds; + }); + let response = try { + await Call.httpRequest({ + url = Session.createUrl; + method = #post; + max_response_bytes = ?Session.maxResponseBytes; + body = ?body.encodeUtf8(); + headers = Session.createHeaders(keyText, orderId); + transform = ?{ function = transform_stripe_response; context = "" }; + is_replicated = null; + }); + } catch (e) { + // Classified rather than passed through raw: "outcall failed" cannot tell an + // operator whether to wait, look at Stripe, or look at our own transform — + // and the transform case is the one no test suite can catch. + let kind = Session.classifyFailure(e.message()); + return #err(#outcallFailed(Session.failureAdvice(kind) # " [" # e.message() # "]")); + }; + if (response.status != 200) { + return #err(#stripeRejected({ status = response.status })); + }; + switch (Session.parseCreated(response.body)) { + case (#err(#unparseable)) #err(#unparseableResponse); + case (#err(#missingField(f))) #err(#missingField(f)); + case (#ok(created)) { + // Checked HERE rather than at webhook time: with two mode-bearing + // secrets — this key and the webhook secret — they can disagree, and + // catching it at session creation is before any money moves. + switch (stripeState.expectLivemode) { + case (?expected) { + if (created.livemode != expected) { + return #err(#livemodeMismatch({ sessionLivemode = created.livemode; expected })); + }; + }; + // Unset means "either mode", which is only sensible while nothing of + // value is at stake. The go-live checklist declares it. + case null {}; + }; + #ok(created); + }; + }; }; - /// One quote = one consistent epoch: the caller snapshots the rail's fee - /// formula *before* any await and the cached rate is read once here; the - /// §6.1 pricing snapshot persisted on the order is built from that same - /// epoch — the webhook reprices a mismatched paid amount from it, never a - /// fresh rate. The fee is a parameter because each rail prices with its - /// own formula (card = forexConfig's Stripe formula, ck-USDC = its rail - /// config) over the one shared rate cache. - func quoteCents(fee : { feeBps : Nat; feeFixedCents : Nat }, usdCents : Nat) : { - #ok : (Nat, Types.Pricing); - #stale; - #unpriceable; - } { - let ?net = Forex.netCents(fee, usdCents) else return #unpriceable; - let ?micros = Forex.freshMicros(forexCache, forexConfig.maxAgeNs, Time.now()) else return #stale; - #ok(( - Forex.cyclesForCents(net, micros), - { - usdCents; - xdrPerUsdMicros = micros; - feeBps = fee.feeBps; - feeFixedCents = fee.feeFixedCents; - }, - )); + // Expire a session at Stripe so the order is provably unpayable. + // + // Three outcomes, and the distinction between the last two is load-bearing: + // "not open" means the session already completed or expired, so the caller + // must change nothing and let the webhook resolve it; "failed" means we do not + // know, so the order must stay payable and uncancelled. + func expireStripeSession(sessionId : Text) : async* Session.ExpireOutcome { + let ?apiKey = Secret.get(stripeApiKey) else return #failed("the Stripe API key is not provisioned"); + let ?keyText = apiKey.decodeUtf8() else return #failed("the stored API key is not valid UTF-8"); + let response = try { + await Call.httpRequest({ + url = Session.expireUrl(sessionId); + method = #post; + max_response_bytes = ?Session.maxResponseBytes; + // Stripe's expire endpoint takes no parameters; the session is in the + // path. An empty body still needs to be `?` rather than null so the POST + // is well formed. + body = ?("" : Blob); + headers = Session.authHeaders(keyText); + transform = ?{ function = transform_stripe_response; context = "" }; + is_replicated = null; + }); + } catch (e) { + let kind = Session.classifyFailure(e.message()); + return #failed(Session.failureAdvice(kind) # " [" # e.message() # "]"); + }; + // Classified in ONE place, by STATUS — see `Session.expireOutcome`. Never key this + // off the error prose: a phrase that is not exactly Stripe's wording makes the + // branch it guards unreachable against the real API. + Session.expireOutcome(response.status, response.body); }; - /// §3.1 quote with lazy refresh: a stale cache triggers (at most) one - /// refresh outcall, then re-quotes; a failed refresh fails closed. - func quoteWithRefresh(fee : { feeBps : Nat; feeFixedCents : Nat }, usdCents : Nat) : async* { - #ok : (Nat, Types.Pricing); - #unpriceable; - #rateUnavailable; + // `GET /v1/checkout/sessions/{id}` — the read that settles a stranded `#created` + // order. + // + // ⚠️ **`#unauthorized` is its own answer, not folded into `#failed`.** A restricted + // key without read on Checkout Sessions 401s on every retrieve, which makes this + // whole feature inert *quietly* — the arm audits, the sweep moves on, and the only + // symptom is capacity that stays stranded. "Stripe refused the read" and "Stripe is + // unreachable" are different operator actions, so they get different audit tags and + // different RUNBOOK rows. + func retrieveStripeSession(sessionId : Text) : async* { + #ok : Session.Status; + #unauthorized; + #failed : Text; } { - switch (quoteCents(fee, usdCents)) { - case (#ok(quoted)) return #ok(quoted); - case (#unpriceable) return #unpriceable; - case (#stale) {}; + let ?apiKey = Secret.get(stripeApiKey) else return #failed("the Stripe API key is not provisioned"); + let ?keyText = apiKey.decodeUtf8() else return #failed("the stored API key is not valid UTF-8"); + let response = try { + await Call.httpRequest({ + url = Session.retrieveUrl(sessionId); + method = #get; + // Larger than the create cap on purpose — see `Session.retrieveMaxResponseBytes`. + max_response_bytes = ?Session.retrieveMaxResponseBytes; + body = null; + headers = Session.authHeaders(keyText); + transform = ?{ function = transform_stripe_response; context = "" }; + is_replicated = null; + }); + } catch (e) { + let kind = Session.classifyFailure(e.message()); + return #failed(Session.failureAdvice(kind) # " [" # e.message() # "]"); }; - ignore await* refreshForexRate(); - switch (quoteCents(fee, usdCents)) { - case (#ok(quoted)) #ok(quoted); - case (#unpriceable) #unpriceable; - case (#stale) #rateUnavailable; + if (response.status == 401 or response.status == 403) return #unauthorized; + if (response.status != 200) return #failed("Stripe answered " # response.status.toText()); + #ok(Session.classify(response.body)); + }; + + + func sessionErrorToText(e : Session.Error) : Text { + switch (e) { + case (#railClosed) "the Stripe API key is not provisioned"; + case (#originUnset) "no return origin is configured"; + case (#outcallFailed(detail)) "outcall failed: " # detail; + case (#stripeRejected({ status })) "Stripe answered " # status.toText(); + case (#unparseableResponse) "Stripe's response was not usable JSON"; + case (#missingField(f)) "Stripe's response had no " # f; + case (#livemodeMismatch({ sessionLivemode; expected })) { + "livemode mismatch: the API key is " + # (if (sessionLivemode) "LIVE" else "test") + # " but this gateway expects " + # (if (expected) "LIVE" else "test"); + }; + }; + }; + + + // Read every admission input, synchronously, immediately before deciding — + // no awaits in between, so there is no TOCTOU window between observing and + // admitting. `Cycles.balance()` is this canister's own **gas**, which is a + // different pot from the reserve it sells — solvency is decided separately, and + // synchronously, against the maintained floor. + func gateObservation(caller : Principal) : Gate.Observation { + { + openOrders = Orders.openOrderCount(orderStore, caller, Time.now()); + canisterCycles = Cycles.balance(); + // The maintained floor, read synchronously like everything else here. Used + // by the faucet check as `> 0` only — never as a solvency input, which is + // decided separately in `admitOrder`. See `Gate.Observation.reserveFloor`. + reserveFloor = reserveState.floor; + // ⚠️ `!= ?true`, so `null` ("either mode") counts as accepting test + // payments. It is also the DEFAULT, so a freshly installed canister is in + // this state — a predicate keyed on `?false` would miss exactly that. + acceptsTestPayments = stripeState.expectLivemode != ?true; + buyerAllowlistEmpty = allowedBuyers.size() == 0; + // ⚠️ **The anonymous principal is EXEMPT from the list, and the exemption + // cannot widen anything.** `create_order` rejects `#anonymous` through + // `Auth.checkUser` before the gate is consulted, so the shared identity still + // cannot buy. What this preserves is `can_purchase`: it is a query the + // frontend calls *before* sign-in, and every suite in this repo probes it + // anonymously to ask "is the gateway open right now". Filtered, an anonymous + // probe answered `#buyerNotAllowed` and could no longer see the gas floor, + // the short reserve or the faucet behind it. + // + // The answer to "can this anonymous caller buy" is decided by `#anonymous`, + // not by a list — so the list has nothing to say about it. + buyerAllowed = caller.isAnonymous() or allowedBuyers.contains(caller); + }; + }; + + // The §5.3-adjacent admission gate: refuse to *quote* when fulfilment is + // already known to be impossible, rather than taking the user's money and + // discovering it at delivery time. Audited on refusal — a rail that has quietly + // stopped selling is something the operator must be able to see. + func admit(caller : Principal, usdCents : Nat) : Result.Result<(), Gate.Reason> { + switch (Gate.admit(gateState.config, gateObservation(caller), usdCents)) { + case (#ok) #ok; + case (#err(reason)) { + noteRefusal(reason); + #err(reason); + }; + }; + }; + + // **The** admission decision: everything `Gate.admit` asks, plus solvency. + // + // **One callable answer, deliberately.** Solvency is not part of + // `Gate.admit` because reading the reserve needs an `await` and `admit` is + // synchronous by design — but that leaves the decision with two owners, and + // `-Werror` cannot see that calling `admit` alone is *half* a decision. A future + // entry point that called `admit` and forgot `solvent` would silently skip + // solvency. So this is the only thing `create_order` consults, and the split + // lives inside it as an implementation detail. + // + // ⚠️ **Must be called in the same synchronous block as `Orders.create`.** See + // the interleaving trace at the call site. + func admitOrder( + caller : Principal, + usdCents : Nat, + lockedCycles : Nat, + ) : Result.Result<(), Gate.Reason> { + switch (admit(caller, usdCents)) { + case (#err(reason)) return #err(reason); + case (#ok) {}; + }; + // **Synchronous, and that is the whole design.** `reserveState.floor` is a + // maintained lower bound on the ledger balance, moved only by our own + // outflows — so there is no awaited value to go stale and nothing to pair + // across an await. `Reserve.mo`'s floor section carries the asymmetry this + // rests on (only we can debit; top-ups only ever add). + // + // An earlier version awaited `icrc1_balance_of` here. It was correct when it + // arrived and historical when used, and pairing it with a live tally made + // `available` optimistic by a full order at the ceiling. The fix was not a + // fresher read — any awaited value is historical by the time it is used — it + // was removing the read from the decision. + switch (Gate.solvent(reserveState.floor, Orders.promised(orderStore), lockedCycles)) { + case (#err(reason)) { + noteRefusal(reason); + #err(reason); + }; + case (#ok) { + // ⚠️ **The only thing that clears the latch, and the only place it can + // be cleared correctly.** Reaching here means neither rail-state + // condition fired. A refusal earlier in `admit` — below the minimum, + // say — returns before the reserve is ever consulted, so it is not + // evidence that the reserve recovered; clearing on it would drop the + // latch and re-announce on the next genuine refusal. + gateState.latch := Gate.latchAdmission(gateState.latch); + #ok; + }; }; }; - /// raw_rand → Orders.create, re-drawing fresh entropy on an ID collision - /// (§2). Null = the entropy source misbehaved (short blob or repeated - /// collisions), never bad luck. + // raw_rand → Orders.create, re-drawing fresh entropy on an ID collision + // (§2). Null = the entropy source misbehaved (short blob or repeated + // collisions), never bad luck. func createOrderWithFreshId( + caller : Principal, + usdCents : Nat, owner : Types.Owner, rail : Types.Rail, destination : Types.Destination, lockedCycles : Nat, pricing : Types.Pricing, - ) : async* ?Types.Order { + ) : async* Result.Result { var attempts = 0; while (attempts < maxIdAttempts) { let entropy = await management.raw_rand(); - let ?id = Orders.idFromEntropy(entropy) else return null; + let ?id = Orders.idFromEntropy(entropy) else return #err(#idGeneration); + // ── ONE SYNCHRONOUS BLOCK: decide, then hold. No `await` between them. ── + // + // ⚠️ **Never put an `await` between the check and the hold.** Split by one, two + // concurrent `create_order` calls both pass the gate against the same `promised` + // and only then both hold — together promising more than the balance either of + // them checked. **Two honest buyers, no attacker.** Interleaved creates are safe + // only because the check and the hold cannot be split: "each resumes after the + // other has recorded its promise" is true of this shape and of no other. + // + // The redraw loop is why the decision is *inside* the loop rather than + // before it: a duplicate id sends us back through `raw_rand`, and re-deciding + // after that await is what stops the redraw reopening the same window. + // `admitOrder` re-reads `Orders.promised` each time, so the tally half is + // always fresh; the balance is deliberately not re-read (see the trace at + // the call site — a stale balance cannot make this optimistic). + switch (admitOrder(caller, usdCents, lockedCycles)) { + case (#err(reason)) return #err(#notAdmitted(reason)); + case (#ok) {}; + }; switch (Orders.create(orderStore, id, owner, rail, destination, lockedCycles, pricing, Time.now())) { - case (#ok(order)) return ?order; - case (#err(#duplicateId(_))) {}; // re-draw fresh entropy + case (#ok(order)) return #ok(order); // the hold is taken inside `create` + case (#err(#duplicateId(_))) {}; // re-draw fresh entropy, then re-decide }; attempts += 1; }; - null; + #err(#idGeneration); }; - /// Create a card-rail order: II caller becomes the owner (ownership is - /// captured here at the API edge, seam §11.1.3), the tier's USD amount is - /// quoted into a locked cycle *quantity* (§3, net of fees at the cached - /// rate — a stale cache refreshes lazily, a failed refresh fails closed - /// §3.1), and the ID comes from raw_rand. The fee config is snapshotted - /// before the refresh await, so one order is always priced from one - /// consistent epoch even when a refresh interleaves with a config change; - /// the store write after the awaits is atomic. - public shared ({ caller }) func create_order( - tierId : Text, - destination : Types.Destination, - ) : async Result.Result { - switch (Auth.checkUser(caller)) { - case (#err(#anonymous)) return #err(#anonymous); - case (#ok) {}; - }; - let ?tier = Tiers.find(cardTiers, tierId) else return #err(#unknownTier(tierId)); - let fee = { feeBps = forexConfig.feeBps; feeFixedCents = forexConfig.feeFixedCents }; - let (lockedCycles, pricing) = switch (await* quoteWithRefresh(fee, tier.usdCents)) { - case (#ok(quoted)) quoted; - case (#unpriceable) return #err(#tierBelowFees(tierId)); - case (#rateUnavailable) return #err(#rateUnavailable); - }; - let owner : Types.Owner = #ii(caller); - switch (await* createOrderWithFreshId(owner, #card, destination, lockedCycles, pricing)) { - case (?order) #ok({ order; clientReferenceId = Orders.clientReferenceId(owner, order.id) }); - case null #err(#idGeneration); - }; - }; - /// §2 query authz: `caller == order.owner`, null otherwise — existence is - /// not revealed to non-owners. Anonymous callers own nothing by - /// construction (create_order rejects them), so they always get null. - public shared query ({ caller }) func get_order(id : Types.OrderId) : async ?Types.Order { - Orders.getOwned(orderStore, id, caller); - }; - /// Order history for the caller (§2, fixes the lost-receipt problem). - public shared query ({ caller }) func list_orders() : async [Types.Order] { - Orders.ordersFor(orderStore, caller); - }; - /// Replace the card tier config (§3/§7 — admin, validated atomically: a - /// bad config never partially applies). - public shared ({ caller }) func set_card_tiers(tiers : [Tiers.Tier]) : async Result.Result<(), Tiers.ValidateError> { - requireAdmin(caller); - switch (Tiers.validate(tiers)) { - case (#ok) { cardTiers := tiers; #ok }; - case (#err(e)) #err(e); - }; - }; - /// Public — the frontend renders tiers and their Payment Links from this. - public query func card_tiers() : async [Tiers.Tier] { - cardTiers; - }; + + + + + + + + // ── Webhook ingestion state (task 8, §4.1/§4.2) ───────────────────────── - /// §4.2 per-rail dedup sets. Stripe keys prune opportunistically on the - /// webhook path (~7 days, Idempotency.mo); ck-USDC block indexes never. + // §4.2 per-rail dedup sets. Stripe keys prune opportunistically on the + // webhook path (~7 days, Idempotency.mo). let dedup : Idempotency.Store = Idempotency.emptyStore(); - /// §4.1 bounded error queue: Type 1 (fiat-only, operator refunds in the - /// Stripe Dashboard) + Type 2 (stranded cycles, task 9). - let errorQueue : ErrorQueue.Store = ErrorQueue.emptyStore(); + // §4.1 — payments that could not be attributed to any order. Every dollar that + // arrives resolves to a delivery or to an obligation, and this list holds the ones + // with no order to hang off; the rest live on their order's `problems`. + let orphanStore : Orphans.Store = Orphans.emptyStore(); - /// §4.2 bounded audit-log ring buffer — operational trail, not a - /// financial record (orders/error queue are the records of money). + // §4.2 audit log, unbounded — operational trail, not a + // financial record (orders, their problems and the orphan list are the records of money). let auditLog : AuditLog.Log = AuditLog.emptyLog(); - /// Bounds are operator headroom knobs, not financial records — transient - /// so a redeploy can retune them (same reasoning as maxRequestBodyBytes). - transient let errorQueueCapacity : Nat = 1_000; - transient let auditLogCapacity : Nat = 4_096; - - transient let webhookDeps : Card.Deps = { - orders = orderStore; - dedup; - errorQueue; - errorQueueCapacity; - auditLog; - auditLogCapacity; + + // Tally a refusal, and write an audit line **only** on the transition into a + // rail-state condition. + // + // **The one place a refusal is recorded, and it is a COUNTER rather than an audit + // line.** A pre-commit refusal is reachable for free — `#amountBelowMin` needs no + // prior state at all, so one cent from any fresh principal would drive one permanent + // line per attempt. The audit log is the only structure here whose growth is not + // attacker-priced. + func noteRefusal(reason : Gate.Reason) { + gateState.refusals := Gate.countRefusal(gateState.refusals, reason); + let latched = Gate.latchRefusal(gateState.latch, reason); + gateState.latch := latched.latch; + if (latched.announce) { + audit("gate.startedRefusing", Gate.reasonToText(reason)); + }; }; - /// §4.1 operator worklist + retained history. Admin: entries carry - /// payment references and claimed-but-bogus URL params. - public shared query ({ caller }) func error_queue() : async [ErrorQueue.Entry] { - requireAdmin(caller); - ErrorQueue.all(errorQueue); + // Which `sessionConfig` failures are rail **state** — a configuration fact + // about this gateway rather than anything about the request. + // + // **Exhaustive on purpose.** `sessionConfig` can only produce the first two + // today, but a new `Session.Error` must decide whether it is a persistent + // configuration state (latch it, announce once) or a transient outcall failure + // (do not latch — a transient that latched would be cleared by the next + // success anyway, but announcing it as "the rail started refusing" would be a + // false report). + func railClosureCondition(e : Session.Error) : ?Gate.RailCondition { + switch (e) { + // Either the key and origin are provisioned or they are not. + case (#railClosed or #originUnset) ?#railClosed; + // These five come from the outcall in `createStripeSession` and cannot + // reach `sessionConfig`. If one ever does, it is counted but not + // announced — a `railClosed` counter climbing while + // `refusingNow.railClosed` stays false is the tell that this happened. + case ( + #outcallFailed(_) or #stripeRejected(_) or #unparseableResponse + or #missingField(_) or #livemodeMismatch(_) + ) null; + }; }; - /// Manual resolution (§4.1/§7): refund issued off-chain, or Type 2 cycles - /// re-delivered/refunded. (`charge.refunded` resolves Type 1 entries - /// automatically; this is the fallback and the only path for Type 2.) - public shared ({ caller }) func resolve_error(id : Nat) : async Result.Result { - requireAdmin(caller); - ErrorQueue.resolve(errorQueue, id, Time.now()); + // Tally a pre-gate refusal caused by the rail being closed, announcing once + // on the way in. + // + // ⚠️ **This path never reaches `admit`, which is what made it easy to miss.** + // `create_order` checks caller, destination, then the RAIL, then tier and + // admission — so while the rail is closed, **100% of attempts refuse here** and + // a counter set covering only `Gate.Reason` would record nothing. docs/OPERATE.md + // prescribes provisioning the secrets last, so a freshly deployed gateway sits + // in exactly this state by design. + func noteRailClosed(e : Session.Error) { + gateState.refusals := Gate.countRailClosed(gateState.refusals); + switch (railClosureCondition(e)) { + case (?condition) { + let latched = Gate.latchCondition(gateState.latch, condition); + gateState.latch := latched.latch; + if (latched.announce) { + audit("gate.startedRefusing", "railClosed: " # sessionErrorToText(e)); + }; + }; + case null {}; + }; }; - /// §4.2 operational trail, newest-last. Admin: details reference payment - /// intents. Readers detect ring-buffer drops via gaps in `seq`. - public shared query ({ caller }) func audit_log() : async [AuditLog.Event] { - requireAdmin(caller); - AuditLog.events(auditLog); + // Tally a failed session creation, announcing once on the way into the + // condition. + // + // **A line per attempt is what this avoids, and the driver is not a transient + // outage** — it is a key that is **present but invalid**, rotated or revoked at + // Stripe without updating the canister. `sessionConfig` cannot see that (the secret + // exists), so every attempt reaches the outcall and 401s. A transient timeout is + // self-limiting; a revoked key repeats until `minCanisterCycles` closes the rail. + // + // ⚠️ **The order-record half of that loop is NOT fixed here.** Each attempt still + // commits an order and expires it, and because the record is not `#created` the + // open-order cap does not bound it. Committing first is forced — the order id *is* + // the `client_reference_id` — so no pre-commit check can cover this branch. A + // circuit breaker is deferred until there is evidence it is needed; this fixes the + // audit half, which is the permanent one. + // **Takes a DETAIL rather than a `Session.Error`, because the callers know + // different things.** It used to render `sessionErrorToText(e)`, and the retrieve + // path's `#unauthorized` has no honest `Session.Error` to pass: `#railClosed` renders + // as "the API key is not provisioned", which is exactly wrong — the key is present + // and Stripe is refusing it. The remedy differs too (rotate versus provision), so + // squeezing three call sites through one enum produced a confident wrong sentence. + func noteStripeApiFailed(detail : Text) { + gateState.refusals := Gate.countStripeApiFailed(gateState.refusals); + let latched = Gate.latchCondition(gateState.latch, #stripeApiFailing); + gateState.latch := latched.latch; + if (latched.announce) { + audit("gate.startedRefusing", "stripeApiFailing: " # detail); + }; }; - // ── CMC mint pipeline (task 9, §5/§5.1) ───────────────────────────────── + // Built per request rather than held in a transient field: it carries + // `maxPurchaseUsdCents` from the live gate config, so a ceiling change takes + // effect on the very next webhook. + func webhookDeps() : Card.Deps { + { + // The live set: `Deps` is rebuilt per call, so this is never a stale copy. + cancelRequests; + orders = orderStore; + dedup; + orphanStore; + expectLivemode = stripeState.expectLivemode; + auditLog; + paidIntents; + maxPurchaseUsdCents = gateState.config.maxPurchaseUsdCents; + }; + }; - /// §4.2 `journal : Map` — the money-out record: - /// transfer intent (written *before* the ledger call, §5.1), block_index, - /// minted cycles, retries. Financial record — kept for years, never pruned. - let mintJournal : Cmc.Journal = Cmc.emptyJournal(); - transient let icpLedger = actor (Cmc.icpLedgerId) : Cmc.LedgerService; - transient let cmc = actor (Cmc.cmcId) : Cmc.CmcService; - transient let cyclesLedger = actor (Cmc.cyclesLedgerId) : Cmc.CyclesLedgerService; - /// Per-order single-flight guard: two concurrent drivers for one order - /// would both pass the status gates between awaits. Transient — an - /// upgrade mid-mint clears it and the journal-driven resume (Cmc.stageOf) - /// picks up where the state actually is. - transient let mintsInFlight = Set.empty(); - /// Bounds the retriable-error loop on stages the ledger's 24 h dedup - /// window doesn't already bound (notify_top_up could otherwise retry - /// forever). Sweep cadence (task 11) makes 25 retries ≫ a day of outage. - transient let maxMintRetries : Nat = 25; - func selfPrincipal() : Principal = Principal.fromActor(CyclesGateway); - // ── Treasury + burn cap (task 10, §5.3) ───────────────────────────────── - /// §5.3 treasury policy. burnCapE8s defaults to 0 — every mint holds in - /// #awaitingTreasury until the operator consciously sizes the primary - /// blast-radius bound (same no-invented-numbers stance as the empty tier - /// list; a default cap in ICP would be a money decision invented here). - var treasuryConfig : Treasury.Config = Treasury.defaultConfig(); - /// §5.3 rolling-window burn accounting. Persistent — an upgrade must not - /// reset the blast-radius bound mid-window. - let burnLedger : Treasury.Ledger = Treasury.emptyLedger(); - /// Last float balance the mint pre-gate (or an admin refresh) observed. - /// The balance-alert query reads this; queries can't call the ledger. - var lastFloatObservation : ?Treasury.FloatObservation = null; - /// Record a float observation, audit-alerting on the *crossing* into low - /// (not every low observation — a sweep over held orders would spam the - /// ring buffer out of its useful history). - func observeFloat(e8s : Nat) { - let wasLow = switch (lastFloatObservation) { - case (?previous) Treasury.isLowFloat(treasuryConfig, previous.e8s); - case null false; - }; - lastFloatObservation := ?{ e8s; atNs = Time.now() }; - if (Treasury.isLowFloat(treasuryConfig, e8s) and not wasLow) { - audit("treasury.lowFloat", "float " # e8s.toText() # " e8s below threshold " # treasuryConfig.lowFloatThresholdE8s.toText()); - }; - }; - /// Adjust treasury policy (§5.3/§7): burn cap, window, max hold, alert - /// threshold. Validated atomically — a bad config never partially applies. - public shared ({ caller }) func set_treasury_config(config : Treasury.Config) : async Result.Result<(), Treasury.ConfigError> { - requireAdmin(caller); - switch (Treasury.validateConfig(config)) { - case (#ok) { treasuryConfig := config; #ok }; - case (#err(e)) #err(e); - }; + // ── Delivery from the reserve (§5/§5.1) ───────────────────────────────── + + // The reserve's own mutable state, as ONE record. + // + // **A record rather than four `var` fields, because `include` passes by value.** + // A mixin handed `var reserveState.floor` would get a snapshot from install time — its + // writes would land on a copy and its reads would never move. A record is a heap + // object, so the mixin and the actor share it. The alternative, an accessor closure + // per field, puts four of them at every include site to work around the same + // semantics; grouping by subsystem is also what `reviewing-motoko` A6 asks for — + // a mixin receives the slice it uses, not the fields one at a time. + // + // **Grouping fields into a record CHANGES the stable shape**, so it is a reinstall + // and a `deployed/backend.most` promotion in the same commit. There is no migration + // chain, so that is the documented loop pre-launch; + // `scripts/check-stable-promotion.sh` reports such a promotion as a REAL shape change + // rather than renumbering, which is what makes it reviewable. + let reserveState : { + // A maintained **lower bound** on the reserve's ledger balance. + // + // Sound because the balance can only fall when we transfer out — delivery to a + // buyer, or `withdraw_reserve` to a controller, both of which decrement this + // floor before issuing; no allowance exists for anyone to pull from the account, and + // the ledger's own `withdraw` is owner-only and not declared. It can only rise on a + // top-up we cannot see until we look. So every unobserved change is in our favour. `Reserve.mo`'s floor section has the + // full argument and the three maintenance rules. + // + // ⚠️ It is a bound, not the balance. The **actual** reserve is a public account + // on a public ledger that anyone — the operator, the frontend, monitoring — can + // read for free without asking this canister. `reserve_status` reports all three + // figures so "the ledger says 100 T and the gateway will sell 0" is diagnosable + // at a glance rather than a mystery. + var floor : Nat; + + // Monotone count of transfers ISSUED out of the reserve. Only purpose: letting a + // reconcile prove no outflow happened across its balance read (see + // `refresh_reserve`). Transient is wrong here — an upgrade mid-reconcile would + // make the counter look unchanged — so it is stable. + var outflowsIssued : Nat; + + // When `reserveState.floor` was last reconciled against the ledger, so staleness is + // legible rather than invisible. Null until the first observation — which is + // also why a fresh canister sells nothing until the operator refreshes. + var observedAtNs : ?Int; + + // The cycles ledger's transfer fee, as last learned from the ledger. + // + // **Stored rather than awaited, and `#BadFee` is why that is safe.** An ICRC-1 + // ledger rejects a wrong fee **definitively and reports the expected one**, so a stale + // value costs one rejected call, self-corrects in the same message, and is persisted + // for every later order. In exchange the delivery path loses an await — and with it + // the failure mode where a ledger hiccup on a *read* stalled a delivery that was fully + // funded and ready. + // + // ⚠️ **No admin lever writes this** — `#BadFee` is the only writer, which is what + // keeps it honest. See `delivery.feeExceedsOrder` for the one state that cannot + // self-correct, and why a lever for it was deleted rather than kept. + // + // ⚠️ **A fee DECREASE shorts that one buyer by the delta.** `amount = locked − + // fee_stored`, so if the ledger has become cheaper than our copy, the first order + // after the change delivers a little less than it could have, and the reserve + // keeps the difference. The correction cannot recover it, because raising a + // committed intent's *amount* would be rebuilding the intent — which is the + // double-pay this whole path is built to avoid. Bounded by one fee-delta on one + // order, and it self-corrects for every order after it. + // + // An increase is the harmless direction: the reserve absorbs `delta` and the + // buyer gets exactly what was quoted (see the `#badFee` arm). + var cyclesLedgerFee : Nat; + } = { + var floor = 0; + var outflowsIssued = 0; + var observedAtNs = null; + var cyclesLedgerFee = Delivery.cyclesLedgerDefaultFee; }; - /// §5.3 manual override: clear the rolling window's consumption after - /// confirming the traffic was legitimate (or rotating a leaked secret). - /// Returns the e8s of consumption cleared. Held orders resume on the next - /// sweep, not here — the pre-gate stays the single decision point. - public shared ({ caller }) func reset_burn_window() : async Nat { - requireAdmin(caller); - let cleared = Treasury.burnedInWindow(burnLedger, treasuryConfig.burnWindowNs, Time.now()); - Treasury.reset(burnLedger); - audit("treasury.burnWindowReset", cleared.toText() # " e8s of window consumption cleared by operator"); - cleared; - }; + // §4.2 `journal : Map` — the money-out record: + // transfer intent (written *before* the ledger call, §5.1), block_index, + // delivered cycles, retries. Financial record — kept for years, never pruned. + let deliveryJournal : Delivery.Journal = Delivery.emptyJournal(); - /// On-demand float refresh (admin — public would let anyone spend our - /// cycles on ledger calls). The mint pre-gate refreshes the observation as - /// a side effect; this is the ops lever between mints. - public shared ({ caller }) func refresh_float() : async Nat { - requireAdmin(caller); - let e8s = await icpLedger.icrc1_balance_of({ owner = selfPrincipal(); subaccount = null }); - observeFloat(e8s); - e8s; + transient let cmc = actor (Cmc.cmcId) : Cmc.CmcService; + transient let cyclesLedger = actor (Delivery.cyclesLedgerId) : Delivery.CyclesLedgerService; + + // Per-order single-flight guard: two concurrent drivers for one order + // would both pass the status gates between awaits. Transient — an + // upgrade mid-delivery clears it and the journal-driven resume (Delivery.stageOf) + // picks up where the state actually is. + transient let deliveriesInFlight = Set.empty(); + // Single-flight for the stranded-`#created` retrieve, and the scan's cadence. + // + // **Transient, both of them, deliberately.** A single-flight guard that survived an + // upgrade would block the order it was holding forever, and a cadence stamp is worth + // re-earning after a deploy. Neither is money state. + transient let expiryChecksInFlight = Set.empty(); + transient var lastExpiryScanAtNs : Int = 0; + + // Orders whose OWNER has asked to cancel, recorded before the Stripe outcall (§4.3). + // + // ⚠️ **`cancel_order` expires the session at Stripe BEFORE recording the cancel** + // — nothing is ever half-cancelled. Between those two steps the order + // looks expired to everyone, because it is: Stripe fires `checkout.session.expired` + // immediately, and three writers can reach the order first — that webhook, the + // recovery sweep, and the admin expire. The buyer's own cancellation was recorded as + // `#sessionExpired`, which is exactly the provenance `expiredBy` exists to keep. + // + // **Intent, not a lock, and the distinction is what makes it work.** A lock would + // need every writer to remember a guard, and the webhook lives in another module — + // so the writer that forgets goes on winning. Recording the intent means whoever + // wins ATTRIBUTES correctly, so the race stops mattering. + // `Orders.settleUnpayable` is the one place that reads it. + // + // **`expireWithCause`'s own no-op guard cannot cover this.** That guard protects + // an order that is ALREADY `#cancelled`; here the cancel has not been recorded yet. + // + // **Stable, because a trap or an upgrade mid-cancel must not lose the intent** — + // that is precisely the window where the order settles without the buyer. A new + // stable var is upgrade-compatible; this is not a field on an existing record. + // + // ⚠️ **Membership implies `#created`, and every exit from `#created` removes.** That + // is the bound, and it has to be enumerated rather than asserted, because the earlier + // claim here — "pruned when the order goes terminal" — was simply not true of the + // paths that return an error. `isLegalTransition` gives `#created` three exits: + // + // * `#cancelled` and `#expired` — every writer goes through `Orders.settleUnpayable` + // or `Orders.expireBySession`, which remove the id in the act of deciding with it, + // plus `cancel_order`'s own success and already-settled returns below. + // * `#paid` — one writer, `Orders.markPaid`, called only from `rails/Card.mo`, which + // removes it there: the payment won, and `#cancelled` is unreachable from `#paid`, + // so nothing could honour the intent afterwards. + // + // `expireWithCause` is the apparent fourth writer and needs no removal: it fires only + // for an order whose session never attached, and `cancel_order` records nothing for + // one of those — with no session id it takes the sessionless branch. + // + // **A stale entry could never mis-attribute even so**, because a delivered or paid + // order cannot transition to `#cancelled`. The reason to bound it is stable growth, + // not correctness — which is why the fix is one removal at the third exit rather than + // threading the set through every status writer. + let cancelRequests = Set.empty(); + + // Orders already audited for a blocked delivery this session, so a stuck + // order contributes one audit line rather than one per sweep. Transient: the + // durable record of a stuck order is the problem filed on it once the max-wait + // bound trips, not this. + transient let deliveryBlockedAudited = Set.empty(); + + + + + // Audit a blocked delivery at most once per order per session. + func auditDeliveryBlockedOnce(orderId : Types.OrderId, tag : Text, detail : Text) { + if (deliveryBlockedAudited.contains(orderId)) return; + deliveryBlockedAudited.add(orderId); + audit(tag, detail); }; - /// §5.3 balance alert + soft UI gate, public: the frontend disables tiers - /// off `lowFloat`, and cap consumption is operational transparency (the - /// thesis), not a secret. The float observation may be stale — `atNs` says - /// how stale; `refresh_float` is the admin lever for a fresh read. - public query func treasury_status() : async Treasury.Status { - var held = 0; - for ((_, order) in orderStore.orders.entries()) { - if (order.status == #awaitingTreasury) held += 1; - }; - { - config = treasuryConfig; - burnedInWindowE8s = Treasury.burnedInWindow(burnLedger, treasuryConfig.burnWindowNs, Time.now()); - lastObservedFloat = lastFloatObservation; - lowFloat = Treasury.lowFloatSignal(treasuryConfig, lastFloatObservation); - heldOrders = held; - }; + func selfPrincipal() : Principal = Principal.fromActor(CyclesGateway); + + // ── Delivery timeline config (§5.3) ───────────────────────────────────── + + // Delivery policy, as one record. + // **A record rather than loose `var` fields, because `include` passes by value.** + // A mixin handed a bare `var` gets a snapshot from install time, so its + // writes land on a copy and its reads never move. A record is a heap object, so the + // mixin and the actor share one. Grouped by subsystem, which is the slice a mixin + // asks for (`reviewing-motoko` A6) rather than an accessor per field. + let deliveryState : { + // The two thresholds the delivery timeline reads: alert at 2 h, terminate at 72 h. + var config : Delivery.Config; + } = { + var config = Delivery.defaultConfig(); }; + func audit(tag : Text, detail : Text) { - ignore AuditLog.append(auditLog, auditLogCapacity, Time.now(), tag, detail); + ignore AuditLog.append(auditLog, Time.now(), tag, detail); }; - /// Driver-side transition helper: the pipeline only requests legal edges, - /// so a refusal is a concurrent-update race — degrade to "stop this pass" - /// (null), never trap mid-money-flow. + // Audit an admin action, recording **which principal took it**. + // + // §7's trust model is a flat controller allowlist with equal privileges — + // "any one can upgrade-then-drain". With several controllers and no caller + // recorded, the trail can say a limit was raised but not by whom, which + // is the one thing it most needs to say. Every admin mutation goes through + // this. + func auditAdmin(caller : Principal, tag : Text, detail : Text) { + audit(tag, "by " # caller.toText() # ": " # detail); + }; + + // Driver-side transition helper: the pipeline only requests legal edges, + // so a refusal is a concurrent-update race — degrade to "stop this pass" + // (null), never trap mid-money-flow. func tryTransition(id : Types.OrderId, to : Types.OrderStatus) : ?Types.Order { switch (Orders.applyTransition(orderStore, id, to, Time.now())) { case (#ok(order)) ?order; @@ -493,693 +1265,1433 @@ persistent actor CyclesGateway { }; }; - /// Queue a mint-path error entry, audit-logging any unresolved eviction - /// (each is a live money obligation dropped from on-chain state, §4.1). - func queueMintError(rail : Types.Rail, kind : ErrorQueue.Kind, detail : Text) { - let result = ErrorQueue.add(errorQueue, errorQueueCapacity, rail, kind, detail, Time.now()); - for (victim in result.evicted.values()) { - if (victim.resolvedAtNs == null) { - audit("errorQueue.evictedUnresolved", "entry " # victim.id.toText() # ": " # victim.detail); - }; - }; - }; - /// §5.1 escalation: the mint stopped where the money position is - /// uncertain. Terminal — the order goes `#errorQueue` and the operator - /// resolves off-chain (inspect ledger/CMC/destination, refund/re-deliver). - func escalateStuckMint(order : Types.Order, stage : Text, detail : Text) { - ignore tryTransition(order.id, #errorQueue); - Cmc.patch(mintJournal, order.id, { status = ?#errorQueue; blockIndex = null; cyclesMinted = null; bumpRetries = false }, Time.now()); - queueMintError(order.rail, #stuckMint({ orderId = order.id; stage }), detail); - audit("mint.stuck", order.id # " [" # stage # "]: " # detail); - }; - - /// §5 forward half of mint-to-self-then-forward. The cycles ride the call - /// from the app balance; a rejected/failed call refunds them to the app - /// balance — exactly the Type 2 posture (§4.1). - func forwardCycles(order : Types.Order) : async* { #ok; #failed : Text } { - try { - switch (order.destination) { - case (#canister(canisterId)) { - await (with cycles = order.lockedCycles) ic.deposit_cycles({ canister_id = canisterId }); - }; - case (#cyclesLedgerAccount(account)) { - ignore await (with cycles = order.lockedCycles) cyclesLedger.deposit({ to = account; memo = null }); - }; - }; - #ok; - } catch (e) { - #failed(e.message()); - }; + // **The one escalation.** A delivery stopped where it cannot continue + // automatically, so the order goes `#needsReview` — **not** `#abandoned`: the + // money position may be unknown, its promise stays held, and a human + // resolves it off-chain. Only `abandon_order` and `record_delivered` end an order. + // + // **One escalation function, and resist splitting it again.** Two of them once + // filed two queue kinds for the same question; they differed only in which audit tag + // they emitted and whether they read `blockIndex`, and both left the order in the + // same state. A second escalation path is a second answer to "where is the money", + // which is the one question that must have exactly one. + // + // Every route to `#needsReview`, and why the cause and the money position are recorded + // separately: `docs/DESIGN.md` §4.1. + // + // ⚠️ **`journalInconsistent` is an unreachable guard, and if it ever fires it is not a + // delivery problem.** The intent's amount cannot exceed the order's locked quantity, + // because it was derived by subtracting a fee from that quantity — so firing means + // `lockedCycles` acquired a second writer. Escalating rather than guessing a fee on a + // money path is the point. + func escalateDelivery(order : Types.Order, stage : Text, detail : Text) { + ignore tryTransition(order.id, #needsReview); + Delivery.patch(deliveryJournal, order.id, { status = ?#needsReview; blockIndex = null; cyclesDelivered = null; bumpRetries = false; lastError = null }, Time.now()); + // **No delay alert to close here, by construction.** An entry saying "it + // delivers on the next sweep" would have become a false promise on the + // worklist next to the real problem. Escalation moves the status off `#paid`, so + // Same for the once-per-order audit guard: nothing will re-audit a blocked + // delivery for an escalated order, and keeping the id would only suppress a + // legitimate line if it were ever re-driven. + deliveryBlockedAudited.remove(order.id); + // **`blockIndex` is no longer carried here.** It is on the `JournalEntry` + // along with `status`, `retries` and `updatedAtNs`, and the last thing this problem + // held alone — the ledger's error text — is on `JournalEntry.lastError`. + // What is left is the stage and the resolution state, which is what a problem on + // an order is for. + ignore Orders.fileProblem(orderStore, order.id, #deliveryStuck({ stage }), detail, Time.now()); + audit("delivery.stuck", order.id # " [" # stage # "]: " # detail); }; - /// Drive one order as far toward `#delivered` as the world allows (§5). - /// Each loop pass asks Cmc.stageOf for the next move off status + journal, - /// so the first attempt and every recovery resume run the same code — - /// "replay the identical transfer" (§5.1) isn't a special case, it IS the - /// transfer path. Retriable failures return with state untouched (plus a - /// retry bump) for the next sweep; uncertainty escalates. - func driveMint(orderId : Types.OrderId) : async* () { + // Drive one order as far toward `#delivered` as the world allows (§5). + // Each loop pass asks Delivery.stageOf for the next move off status + journal, + // so the first attempt and every recovery resume run the same code — + // "replay the identical transfer" (§5.1) isn't a special case, it IS the + // transfer path. Retriable failures return with state untouched (plus a + // retry bump) for the next sweep; uncertainty escalates. + func driveDelivery(orderId : Types.OrderId) : async* () { label drive loop { let ?order = Orders.get(orderStore, orderId) else return; - // §5.3: a treasury-held order resumes here — Treasury owns the hold - // policy (max-wait), Cmc.stageOf owns the money-out resume logic. A - // hold within the wait bound retries #begin: the pre-gate inside it is - // the single decision point for cap/float, so a rolled window or a - // refill clears the hold with no second code path. updatedAtNs is the - // hold start (the hold transition is the last one the order took). - let stage : Cmc.Stage = switch (order.status) { - case (#awaitingTreasury) { - switch (Treasury.holdStage(order.updatedAtNs, Time.now(), treasuryConfig.maxHoldNs)) { - case (#escalate) { - // Money position is *certain* here (fiat in, nothing minted) - // but the resolution is the same operator worklist: refund in - // the Stripe Dashboard (§5.3 max-wait → error queue). - escalateStuckMint(order, "treasuryWaitExceeded", "held past max wait: fiat received, nothing minted — refund via Stripe Dashboard"); - return; + // ⚠️ **The wait bound applies to `#paid` and only `#paid`** — the one status with + // money in and nothing delivered. `updatedAtNs` is the right anchor because + // **retries deliberately do not transition**, so the clock stays pinned to the + // moment the order was paid. Several failure paths return without transitioning + // and leave the order `#paid` for the next sweep, which is right for a transient + // fault and would park an order forever on a persistent one. + if (order.status == #paid) { + switch (Delivery.waitStage(order.updatedAtNs, Time.now(), deliveryState.config)) { + case (#retry) {}; + case (#alert) { + // Tell someone while the cause is still fixable, and keep retrying: most + // incidents end here with the order delivering. + // + // **One line per order, bounded by `markDelayed`** — it returns true only + // on the first crossing, so the line needs no bookkeeping of its own and + // passes `AuditLog.mo`'s admission rule. + // + // One in-flight status means one sentence, so no switch here. If a second + // status can ever sit still, this becomes a switch and the wording has to + // name which one. + if (Orders.markDelayed(orderStore, orderId, Time.now())) { + audit( + "delivery.delayed", + orderId # ": paid but not yet delivered past the alert threshold — fix the cause and it delivers on the next sweep", + ); }; - case (#retry) #begin; + }; + case (#terminate) { + // §5.3 max-wait bound. By now the cause is not transient, and a buyer left + // waiting files a chargeback — which costs more than a refund. Terminating + // so the operator refunds is the protective act. + // + // The escalation's stage comes from `Delivery.terminationFor`, which reads the + // **journal** and not just the status: the status says where the order + // stopped, the journal says where the money is, and the money position is + // what the operator acts on. + let termination = Delivery.terminationFor(order.status, deliveryJournal.get(orderId)); + escalateDelivery(order, termination.stage, termination.detail); + deliveryBlockedAudited.remove(orderId); + return; }; }; - case (_) Cmc.stageOf(order.status, mintJournal.get(orderId), Time.now(), Cmc.ledgerDedupWindowNs, maxMintRetries); }; + // The whole next-move decision is one pure call: status + journal in, stage out. + // Keep it that way — every arm of `stageOf` is unit-pinned, and a `switch` here + // would be a second, untested copy of the same decision. + let stage : Delivery.Stage = Delivery.stageOf(order.status, deliveryJournal.get(orderId), Time.now(), Delivery.ledgerDedupWindowNs); switch (stage) { case (#none) return; case (#escalate(reason)) { - let stage = Cmc.escalateReasonToText(reason); - escalateStuckMint(order, stage, "mint pipeline stopped: " # stage); + // **The high-probability escalation route, so it must carry an + // instruction.** `stage` is the CAUSE (the runbook's triage key); the detail is + // the MONEY POSITION from `terminationFor` (what determines the action). They + // can legitimately disagree — see §4.1 — so both are recorded and neither + // reading can mislead. A bare "delivery stopped: " left the operator's + // first read with no instruction. + let stage = Delivery.escalateReasonToText(reason); + let position = Delivery.terminationFor(order.status, deliveryJournal.get(orderId)); + let detail = + if (position.stage == stage) { + position.detail; + } else { + "stopped because: " # stage # ". Money position is " # position.stage + # " — " # position.detail; + }; + escalateDelivery(order, stage, detail); return; }; - case (#begin) { - // §5 rate derivation: fresh CMC ICP/XDR rate, staleness-guarded. - let rate = try { await cmc.get_icp_xdr_conversion_rate() } catch (e) { - audit("mint.rateFetchFailed", orderId # ": " # e.message()); - return; // stays #paid; the next sweep retries - }; - let ?permyriad = Cmc.freshCmcRate(rate.data, Time.now(), Cmc.cmcRateMaxAgeNs) else { - audit("mint.rateStale", orderId); + case (#beginDelivery) { + // Delivery is ONE transfer out of the reserve. + // + // ⚠️ **The fee is READ FROM STATE, and this whole case is synchronous.** Do + // NOT `await icrc1_fee()` here: `#BadFee` is the ledger telling us the fee, + // which makes a stored copy self-correcting. See + // `reserveState.cyclesLedgerFee`. + // + // ⚠️ **And do not put any other await between here and the transfer issue.** + // Two things depend on there being none: no order can move inside a + // synchronous stretch, so this case needs no re-read, + // and `unsettledDeliveries` — the reconcile's quiet-window predicate — + // relies on an intent never being visible without its transfer having been + // issued in the same message. Its doc spells that out. + let fee = reserveState.cyclesLedgerFee; + let ?amount = Delivery.deliverableCycles(order.lockedCycles, fee) else { + // Unreachable under the $10 floor (~7 T cycles against a 100 M fee), + // and audited rather than silent so that a future move in either + // number surfaces as a stuck order with a reason instead of a trap. + // + // This is the ONE state the stored fee cannot correct itself out of: + // nothing reaches the ledger, so no `#BadFee` ever arrives to fix the copy, + // and every order stalls here — audited, loudly, once per order. + // + // **There is deliberately no admin lever to reset the fee, and the + // reason is worth keeping.** One existed briefly and was + // deleted as self-justifying: reaching this state needs the ledger to + // report a fee above a whole order's locked quantity — a ~70,000× rise, + // at which point the rail cannot sell at all and the answer is a code + // change — or an operator typing a wrong number into the lever itself. A + // lever whose main reachable failure mode is itself, and whose typo + // silently shorts buyers, is worse than the stall it fixes. A stalled + // rail is loud and costs nothing; a shorted buyer is quiet and costs + // them. + auditDeliveryBlockedOnce(orderId, "delivery.feeExceedsOrder", orderId # ": fee " # fee.toText() # " >= locked " # order.lockedCycles.toText()); return; }; - // §5.3 pre-gate input: the live float balance (also feeds the - // balance-alert observation). - let floatE8s = try { - await icpLedger.icrc1_balance_of({ owner = selfPrincipal(); subaccount = null }); - } catch (e) { - audit("mint.balanceFetchFailed", orderId # ": " # e.message()); - return; // status untouched; the next sweep retries - }; - observeFloat(floatE8s); - // Re-read after the awaits; only an untouched mintable order - // proceeds (#awaitingTreasury arrives here via the hold retry). - let ?fresh = Orders.get(orderStore, orderId) else return; - switch (fresh.status) { - case (#paid or #awaitingTreasury) {}; - case (_) continue drive; + let destination = switch (order.destination) { + case (#cyclesLedgerAccount(account)) account; }; - let ?e8s = Cmc.icpE8sForCycles(fresh.lockedCycles, permyriad) else return; - // §5.3 pre-gate: burn cap (blast-radius bound, checked first — - // it must hold mints even when the float could fund them), then - // float sufficiency. A held order stays put on re-hold (no - // transition, no re-audit — the hold start must keep its max-wait - // clock and the ring buffer its history). - switch (Treasury.gate(burnLedger, treasuryConfig, floatE8s, e8s, Cmc.icpTransferFeeE8s, Time.now())) { - case (#hold(reason)) { - if (fresh.status == #paid) { - ignore tryTransition(orderId, #awaitingTreasury); - audit("mint.held", orderId # ": " # Treasury.holdReasonToText(reason)); - }; - return; - }; - case (#proceed) {}; - }; - // §5.1 step 1 — intent + #minting + burn-cap consumption commit - // in ONE sync block, before the transfer await. From here the - // transfer args are frozen; replay is always bit-identical. The - // cap entry is never refunded on failure — over-counting pauses - // mints early, the fail-safe direction for a blast-radius bound. - let intent = Cmc.buildIntent(selfPrincipal(), e8s, Time.now()); - let ?minting = tryTransition(orderId, #minting) else return; - Treasury.recordBurn(burnLedger, treasuryConfig.burnWindowNs, e8s, Time.now()); - ignore Cmc.openEntry(mintJournal, minting, intent, Time.now()); - // fall through the loop → #replayTransfer issues the transfer + // §5.1 step 1 — the intent commits BEFORE the transfer await, in a + // sync block. From here the args are frozen and every retry replays + // them byte-identically; that is what makes two concurrent drivers + // (the webhook kick and the recovery sweep) safe. + let intent = Delivery.buildDeliveryIntent(orderId, destination, amount, Time.now()); + ignore Delivery.openEntry(deliveryJournal, order, intent, Time.now()); + // fall through the loop → #replayDelivery issues the transfer }; - case (#replayTransfer(intent)) { - let result = try { await icpLedger.icrc1_transfer(Cmc.transferArgs(intent)) } catch (e) { - Cmc.patch(mintJournal, orderId, { status = null; blockIndex = null; cyclesMinted = null; bumpRetries = true }, Time.now()); - audit("mint.transferFailed", orderId # ": " # e.message()); + case (#replayDelivery(intent)) { + // ⚠️ **The fee is DERIVED from the intent, never re-read here** — the fee the + // original attempt used is recoverable exactly as `lockedCycles - amount`, so a + // replay reproduces the original args **bit for bit**. Re-reading it would make + // a replay after a fee change a DISTINCT transaction if the fee is inside the + // ledger's dedup key, and the buyer is paid twice. Whether it is in that key is + // not knowable from here, which is why the args are reproduced rather than + // rebuilt. + // + // ⚠️ **No test can catch a regression here — verified by mutation.** Re-reading + // the fee passed every unit assertion and the whole PocketIC suite: the unit + // tests pin the arithmetic and the integration ledger's fee never moves. The + // real guard is now the ledger's service type, which does not declare + // `icrc1_fee` at all, so the mutation no longer compiles. **Keep it undeclared.** + let fee : Nat = if (order.lockedCycles >= intent.amountCycles) { + order.lockedCycles - intent.amountCycles : Nat; + } else { + // Unreachable: the amount was derived by subtracting a fee from this + // very quantity. Escalating beats guessing a fee on a money path. + escalateDelivery(order, "journalInconsistent", "delivery intent amount " # intent.amountCycles.toText() # " exceeds the order's locked " # order.lockedCycles.toText() # " — the fee cannot be recovered; establish the transfer's fate on the cycles ledger before re-sending"); return; }; - switch (Cmc.interpretTransfer(result)) { - case (#blockIndex(block)) { - // §5.1 step 2 — block_index + #icpAtCmc in one sync block. - ignore tryTransition(orderId, #icpAtCmc); - Cmc.patch(mintJournal, orderId, { status = ?#icpAtCmc; blockIndex = ?block; cyclesMinted = null; bumpRetries = false }, Time.now()); - // fall through → #notifyCmc - }; - case (#retriable(detail)) { - Cmc.patch(mintJournal, orderId, { status = null; blockIndex = null; cyclesMinted = null; bumpRetries = true }, Time.now()); - audit("mint.transferRetriable", orderId # ": " # detail); + // ── Rule 2 (§5.4): the floor drops when the transfer is ISSUED ── + // + // ⚠️ By `amount + fee` — the figure actually being debited — not + // `lockedCycles`, because the `#BadFee` re-issue below debits `amount + + // corrected fee`. The case this is pessimistic about: our reply callback + // traps, so the ledger's debit stands while the journal patch rolls back. + // (A controlled upgrade cannot do that — `stop_canister` drains outstanding + // callbacks first.) + let debited = intent.amountCycles + fee; + reserveState.floor := Reserve.floorAfterOutflow(reserveState.floor, debited); + reserveState.outflowsIssued += 1; + let result = try { await cyclesLedger.icrc1_transfer(Delivery.deliveryArgs(intent, fee)) } catch (e) { + // ⚠️ The floor is NOT credited back. A call that failed without a reply + // tells us nothing about whether the ledger acted, and rule 2 exists to + // be pessimistic about exactly that. A reconcile heals it if the + // transfer never happened. + Delivery.patch(deliveryJournal, orderId, { status = null; blockIndex = null; cyclesDelivered = null; bumpRetries = true; lastError = ?e.message() }, Time.now()); + audit("delivery.transferFailed", orderId # ": " # e.message()); + return; + }; + switch (Delivery.interpretTransfer(result)) { + // ⚠️ **`#deduplicated` credits the floor back; `#delivered` does not.** + // Rule 2 decremented the floor when this call was issued. A fresh block + // means this call really debited, so the decrement stands. A duplicate + // means an EARLIER attempt debited — and that attempt's own decrement is + // still standing, because a reply-callback trap rolls back the journal + // patch but not the issuing message's decrement. So the replay sequence + // nets to exactly one decrement per real execution. Crediting on both + // arms would refund a real debit (optimistic); crediting on neither + // would under-count every healed replay by a whole order. + case (#deduplicated(block)) { + reserveState.floor += debited; + deliveryBlockedAudited.remove(orderId); + ignore tryTransition(orderId, #delivered); + Delivery.patch(deliveryJournal, orderId, { status = ?#delivered; blockIndex = ?block; cyclesDelivered = ?intent.amountCycles; bumpRetries = false; lastError = null }, Time.now()); + audit("delivery.deduplicated", orderId # ": ledger block " # block.toText() # " was already ours; floor credited back " # debited.toText()); return; }; - case (#escalate(detail)) { - escalateStuckMint(order, "transferRejected", detail); + case (#delivered(block)) { + deliveryBlockedAudited.remove(orderId); + // Block + `#delivered` in ONE sync block, so the pair cannot disagree. + ignore tryTransition(orderId, #delivered); + Delivery.patch(deliveryJournal, orderId, { status = ?#delivered; blockIndex = ?block; cyclesDelivered = ?intent.amountCycles; bumpRetries = false; lastError = null }, Time.now()); + audit("delivery.sent", orderId # ": " # intent.amountCycles.toText() # " cycles, ledger block " # block.toText()); return; }; - }; - }; - case (#notifyCmc(block)) { - // Heal the (today unreachable) #minting-with-block combination. - if (order.status == #minting) { ignore tryTransition(orderId, #icpAtCmc) }; - let result = try { - await cmc.notify_top_up({ block_index = Nat64.fromNat(block); canister_id = selfPrincipal() }); - } catch (e) { - Cmc.patch(mintJournal, orderId, { status = null; blockIndex = null; cyclesMinted = null; bumpRetries = true }, Time.now()); - audit("mint.notifyFailed", orderId # ": " # e.message()); - return; - }; - switch (Cmc.interpretNotify(result)) { - case (#minted(cycles)) { - // Pre-forward marker: commits before the forward await. If we - // die mid-forward, stageOf answers #ambiguousForward and the - // operator checks the destination — at-most-once delivery, - // never an auto-double-forward. - Cmc.patch(mintJournal, orderId, { status = null; blockIndex = null; cyclesMinted = ?cycles; bumpRetries = false }, Time.now()); + case (#badFee(expected)) { + // The reserve absorbs a risen fee; the buyer is never shorted, so the + // intent's AMOUNT is untouched — only the fee we pass. + // + // ⚠️ **Re-issued HERE, in the same message, or the loop never terminates.** + // The fee is derived from the intent, so a later replay derives the same + // rejected fee and bounces again — every sweep until the max-wait bound. + // Changing the fee is safe only because `#BadFee` is *definitive*: the + // ledger did not execute, so this is a first attempt with corrected args, + // not a replay of something that might already have happened. + // + // The floor then drops by `amount + corrected fee` while the promise tally + // drops by the amount alone, so `available` drifts down by the delta — + // fractions of a fee, erring conservative. Not a bug (§5.4). + // **Learn the fee for every LATER order.** This arm is the only place + // the ledger tells us its fee, so persisting here bounds the cost of a + // stale copy to one rejected call. It does **not** change this order's + // intent — rebuilding that is the double-pay this path exists to avoid. + reserveState.cyclesLedgerFee := expected; + audit("delivery.feeChanged", orderId # ": ledger expects " # expected.toText() # " (intent implies " # fee.toText() # "); reserve absorbs the difference, and " # expected.toText() # " is now the stored fee"); + // ── Rule 3 (§5.4): a DEFINITIVE rejection credits the floor back ── + // `#BadFee` means the ledger processed the call and refused it, so nothing + // moved and rule 2's decrement was not a real debit. + // ⚠️ If the re-issue then fails with no reply, the LARGER decrement stands + // — correctly pessimistic. + reserveState.floor += debited; + let reDebited = intent.amountCycles + expected; + reserveState.floor := Reserve.floorAfterOutflow(reserveState.floor, reDebited); + reserveState.outflowsIssued += 1; + let retried = try { + await cyclesLedger.icrc1_transfer(Delivery.deliveryArgs(intent, expected)); + } catch (e) { + Delivery.patch(deliveryJournal, orderId, { status = null; blockIndex = null; cyclesDelivered = null; bumpRetries = true; lastError = ?("after fee correction: " # e.message()) }, Time.now()); + audit("delivery.transferFailed", orderId # " (after fee correction): " # e.message()); + return; + }; + switch (Delivery.interpretTransfer(retried)) { + case (#deduplicated(block)) { + // An earlier attempt had already landed: this one moved nothing. + reserveState.floor += reDebited; + deliveryBlockedAudited.remove(orderId); + ignore tryTransition(orderId, #delivered); + Delivery.patch(deliveryJournal, orderId, { status = ?#delivered; blockIndex = ?block; cyclesDelivered = ?intent.amountCycles; bumpRetries = false; lastError = null }, Time.now()); + audit("delivery.deduplicated", orderId # ": block " # block.toText() # " after fee correction; floor credited back"); + return; + }; + case (#delivered(block)) { + deliveryBlockedAudited.remove(orderId); + ignore tryTransition(orderId, #delivered); + Delivery.patch(deliveryJournal, orderId, { status = ?#delivered; blockIndex = ?block; cyclesDelivered = ?intent.amountCycles; bumpRetries = false; lastError = null }, Time.now()); + audit("delivery.sent", orderId # ": " # intent.amountCycles.toText() # " cycles at the corrected fee, ledger block " # block.toText()); + return; + }; + case (_) { + // One correction attempt per pass, no loop. If the fee moved + // again mid-flight the next sweep starts over from the derived + // fee — which is still the byte-identical replay, so the + // at-most-once guarantee is never traded for convergence. + Delivery.patch(deliveryJournal, orderId, { status = null; blockIndex = null; cyclesDelivered = null; bumpRetries = true; lastError = null }, Time.now()); + audit("delivery.retriable", orderId # ": fee correction to " # expected.toText() # " did not settle; retrying next sweep"); + return; + }; + }; }; case (#retriable(detail)) { - Cmc.patch(mintJournal, orderId, { status = null; blockIndex = null; cyclesMinted = null; bumpRetries = true }, Time.now()); - audit("mint.notifyRetriable", orderId # ": " # detail); + // ── Rule 3: definitive rejection, so credit the floor back ── + // Every `#retriable` case is a ledger *response* — `#InsufficientFunds`, + // `#TemporarilyUnavailable`, `#CreatedInFuture`, `#GenericError`. The + // ledger processed the call and declined it, so nothing moved and rule + // 2's decrement was not a debit. (Contrast the `catch` arm above: no + // reply means no knowledge, so that decrement stands.) + reserveState.floor += debited; + // `#InsufficientFunds` should be unreachable — the gate reserved this + // quantity — and if it does fire, the floor and the ledger disagree. + // Check `reserve_status.tallySaturations` and the last reconcile before + // hunting a fee delta. + Delivery.patch(deliveryJournal, orderId, { status = null; blockIndex = null; cyclesDelivered = null; bumpRetries = true; lastError = ?detail }, Time.now()); + audit("delivery.retriable", orderId # ": " # detail); return; }; case (#escalate(detail)) { - escalateStuckMint(order, "notifyRejected", detail); + // ── Rule 3, and the framing that stops a future "fix" ── + // + // The accounting is **strictly per attempt**. `#TooOld` and `#BadBurn` + // are ledger responses refusing *this* call, so crediting *this* + // attempt's decrement is exact — and it says nothing about an earlier + // attempt, whose decrement correctly stands. + // + // That standing decrement is **not a leak**. It is the floor-side + // expression of the same unknown that parks the order at + // `#needsReview`: the money position is unknown, so the floor assumes + // the debit until a human establishes otherwise, and a quiet adopt + // eventually absorbs whichever way it went. Do not "fix" the apparent + // asymmetry by also crediting the original attempt — that is the + // optimistic direction. + reserveState.floor += debited; + escalateDelivery(order, "transferRejected", detail); return; }; }; - switch (await* forwardCycles(order)) { - case (#ok) { - ignore tryTransition(orderId, #delivered); - Cmc.patch(mintJournal, orderId, { status = ?#delivered; blockIndex = null; cyclesMinted = null; bumpRetries = false }, Time.now()); - audit("mint.delivered", orderId # ": " # order.lockedCycles.toText() # " cycles"); - }; - case (#failed(detail)) { - // §4.1 Type 2: the failed deposit refunded the cycles to the - // app balance — minted money exists, delivery didn't happen. - ignore tryTransition(orderId, #errorQueue); - Cmc.patch(mintJournal, orderId, { status = ?#errorQueue; blockIndex = null; cyclesMinted = null; bumpRetries = false }, Time.now()); - queueMintError(order.rail, #undeliverable({ orderId; cycles = order.lockedCycles }), detail); - audit("mint.undeliverable", orderId # ": " # detail); - }; - }; + }; + case (#finishDelivery(block)) { + // The transfer landed and the transition did not — unreachable today + // (both commit in one sync block), handled so a future regression + // degrades to something resumable rather than to a stuck paid order + // whose buyer already has their cycles. + ignore tryTransition(orderId, #delivered); + Delivery.patch(deliveryJournal, orderId, { status = ?#delivered; blockIndex = ?block; cyclesDelivered = null; bumpRetries = false; lastError = null }, Time.now()); + audit("delivery.healed", orderId # ": block " # block.toText() # " was recorded but the order had not moved"); return; }; }; }; }; - /// Single-flight wrapper around the driver. - func processMint(orderId : Types.OrderId) : async* () { - if (mintsInFlight.contains(orderId)) return; - mintsInFlight.add(orderId); - try { await* driveMint(orderId) } finally { mintsInFlight.remove(orderId) }; + // Single-flight wrapper around the driver. + func processDelivery(orderId : Types.OrderId) : async* () { + if (deliveriesInFlight.contains(orderId)) return; + deliveriesInFlight.add(orderId); + try { await* driveDelivery(orderId) } finally { deliveriesInFlight.remove(orderId) }; }; - /// Sweep every order with money-out work pending (Recovery.isSweepable: - /// #paid/#minting/#icpAtCmc/#awaitingTreasury — the §5.3 hold retries - /// until refill or max-wait) through the driver. Kicked after webhook - /// ingestion; the §5.2 recovery timer sweeps it on a cadence. - func sweepMintable() : async* Nat { + // Sweep every order with money-out work pending (Recovery.isSweepable: + // `#paid` is the only status with money-out work) through the driver. Kicked after webhook + // ingestion; the §5.2 recovery timer sweeps it on a cadence. + func sweepDeliverable() : async* Nat { + // Answer "is there anything to do?" in O(1) before looking. + // + // The maintained tally covers exactly the sweepable status, so an idle sweep is + // free — which is what makes a short cadence affordable. + if (sweepableCount() == 0) return 0; + // **Over the non-terminal index, not the order store.** `#paid` — the one + // sweepable status — holds its promise, so the index is a superset of the population + // and the filter is exact. This used to walk every order ever created on every tick + // that had work, a cost that grows with lifetime sales and never comes back down. + // + // ⚠️ **Collect first, then await.** `processDelivery` awaits, and iterating the + // index while a transition removes members from it would be a mutation during + // iteration. The list is bounded by the index, so materialising it is cheap. let pending = List.empty(); - for ((id, order) in orderStore.orders.entries()) { - if (Recovery.isSweepable(order.status)) pending.add(id); + for (id in Orders.promiseHolderIds(orderStore)) { + switch (Orders.get(orderStore, id)) { + case (?order) if (Recovery.isSweepable(order.status)) pending.add(id); + case null {}; + }; }; for (id in pending.values()) { - await* processMint(id); + await* processDelivery(id); }; pending.size(); }; - public type ProcessOrderError = { #notFound; #inFlight }; - - /// Manual mint kick (admin, §7) — ops lever for a stuck-looking order; - /// safe to spam, every step is deduped/idempotent/single-flighted. - public shared ({ caller }) func process_order(id : Types.OrderId) : async Result.Result { - requireAdmin(caller); - if (Orders.get(orderStore, id) == null) return #err(#notFound); - if (mintsInFlight.contains(id)) return #err(#inFlight); - await* processMint(id); - switch (Orders.get(orderStore, id)) { - case (?order) #ok(order); - case null #err(#notFound); - }; + // Orders with money-out work pending, from the maintained tally. Must stay in step + // with `Recovery.isSweepable` — the unit tests pin that, and both are one status. + func sweepableCount() : Nat { + Orders.countOf(orderStore, #paid); }; - /// Money-out journal for one order (admin, §4.2) — intent, block_index, - /// minted cycles, retries. - public shared query ({ caller }) func mint_journal(id : Types.OrderId) : async ?Types.JournalEntry { - requireAdmin(caller); - mintJournal.get(id); + + + + // The journal half of the quiet-window predicate: a transfer issued and no block + // recorded. Says nothing about status on purpose — the ORDER supplies that, and + // `unsettledDeliveries` is the one place the two meet. + // + // ⚠️ **Soundness requires NO AWAIT between the intent write and the transfer issue, and + // nothing in the type system enforces it.** The one await that used to sit in that + // stretch (`icrc1_fee`) became a stored value, which is why the two are adjacent today. + // Reintroduce an await there and a reconcile can adopt a balance while a transfer it + // cannot see is in flight. This comment is the guard. + func openTransfer(entry : Types.JournalEntry) : Bool { + entry.transferIntent != null and entry.blockIndex == null; }; - // ── Recovery timer (task 11, §5.2) ────────────────────────────────────── + // Every promise-holding order paired with its journal entry — **the only route either + // reader takes to the journal**, so neither can be bounded and the other not. + // + // Bounded by `promiseHolders`, which is bounded by flow (§5.4), NOT by the journal, + // which gains an entry per `#paid` order and never loses one. + func forEachPromisedDelivery(f : (Types.Order, Types.JournalEntry) -> ()) { + for (id in Orders.promiseHolderIds(orderStore)) { + switch (Orders.get(orderStore, id), deliveryJournal.get(id)) { + case (?order, ?entry) f(order, entry); + case _ {}; + }; + }; + }; - /// §5.2 sweep cadence. Persistent — an operator-tuned cadence survives - /// upgrades; the transient timer below re-arms at this value. Bounded by - /// Recovery.validateInterval (≪ the §5.1 ledger dedup window). - var recoverySweepIntervalNs : Nat = Recovery.defaultIntervalNs; + // Deliveries that may still respond: a transfer issued, no recorded block, on an order + // that is still `#paid`. The quiet-window predicate — why it is a superset of "in + // flight", and why the cost is not a deadlock: `docs/DESIGN.md` §5.4. + // + // ⚠️ **The `#paid` clause is load-bearing, and leaving it out froze the reserve.** An + // escalated order keeps the intent-without-block shape *forever*, so without it one + // escalation makes the quiet window unsatisfiable for the life of the canister: every + // reconcile skips, every `refresh_reserve` skips, and top-ups stop registering. + // + // **This count going LOW is the oversell direction** — a quiet window that reads + // quiet while a transfer is in flight lets the floor adopt a balance the transfer has + // already left. So completeness is the property to protect, and it is by construction + // rather than by recount: a transfer is only ever issued from `#paid`, `#paid` holds + // the promise, and `promiseHolders` is maintained on that same `Reserve.holdsPromise` + // predicate inside `Orders.commitTransition`. Every order with a transfer in flight is + // therefore in the index. Its three exits keep it that way — `#delivered` records the + // block in the same patch, `#needsReview` is still non-terminal and still indexed, and + // `#abandoned` is **refused while a transfer is open** (`abandon_order`). + // + // A stale index member is harmless here and does not need dropping: its order reads + // non-`#paid`, so it simply does not count. + // + // ⚠️ **Do not add a force flag to `refresh_reserve`.** Adopting across an unsettled + // delivery is the exact bug this predicate prevents, so a lever for it is a lever for + // the bug. + func unsettledDeliveries() : Nat { + var n = 0; + forEachPromisedDelivery( + func(order, entry) { + if (order.status == #paid and openTransfer(entry)) n += 1; + } + ); + n; + }; - /// §5.2 single-flight guard: a sweep slower than the interval must skip - /// the next firing, never pile up. Transient on purpose — a persistent - /// flag left true by an upgrade mid-sweep would deadlock recovery - /// forever (the `pumping`-style deadlock §5.2 warns about); an upgrade - /// resets it and the timer below re-arms. - transient var recoverySweepInFlight = false; - /// Last *completed* timer sweep — recovery liveness for ops (the §5.2 - /// timer is the backstop for every detached webhook kick that dies, so - /// "is it actually firing" must be observable). - var lastRecoverySweep : ?{ atNs : Int; pending : Nat } = null; + // How long a `#paid` order has waited, as a stage; `null` for any other status. + // + // **ONE read of the clock per order.** "Is it delayed" and "is it past max hold" are + // both answered off this single value. Calling `waitStage` twice worked — it is + // deterministic — but it put a second call site of the same computation in the code + // whose point is one definition, and `pastMaxHold` would have diverged silently if this + // predicate's clock or config source ever changed. + // + // ⚠️ **`#paid` only, and NOT gated on a journal entry or an intent existing.** A + // delivery that BAILS before issuing a transfer — a short reserve, a stale rate, the gas + // floor — leaves a `#paid` order with no intent, and that is exactly the order an + // operator must see, because nothing else will surface it. So this reads the ORDER's + // clock. Going through the order↔journal join, as `deliveriesOutstanding` does, would + // silently under-report it. + // + // (Not "before the first sweep runs": the webhook drives delivery itself, so a paid + // order normally has its entry and intent immediately.) + func deliveryStage(order : Types.Order, nowNs : Int) : ?{ #retry; #alert; #terminate } { + if (order.status != #paid) return null; + ?Delivery.waitStage(order.updatedAtNs, nowNs, deliveryState.config); + }; - /// The timer job. Correctness against concurrent drivers is processMint's - /// per-order single-flight; this flag only stops sweep pile-up. The - /// webhook kick deliberately bypasses it — a just-paid order must not - /// wait a full interval because a background sweep (which enumerated - /// `pending` before that order turned #paid) was still in flight. - func recoverySweep() : async () { - if (recoverySweepInFlight) return; - recoverySweepInFlight := true; - try { - let pending = await* sweepMintable(); - lastRecoverySweep := ?{ atNs = Time.now(); pending }; - } finally { - recoverySweepInFlight := false; + // Which stages are worth an operator's attention — **the one definition**, shared by + // `delayed_deliveries` and `operator_summary`. Two copies would let the paged list and + // the summary count disagree, and the summary is the number someone acts on. + func stageIsDelayed(stage : { #retry; #alert; #terminate }) : Bool { + switch (stage) { + case (#retry) false; + case (#alert or #terminate) true; }; }; - /// §5.2 the timer itself. Transient initializer = runs on install AND on - /// every upgrade (postupgrade re-initialization), so a deploy can never - /// leave recovery dead; the IC drops timers across upgrades, so there is - /// no stale duplicate to cancel. - transient var recoveryTimerId : Timer.TimerId = - Timer.recurringTimer(#nanoseconds(recoverySweepIntervalNs), recoverySweep); - - /// Tune the sweep cadence (admin, §7) — re-arms immediately, no redeploy. - /// Validated against the §5.1 bound: the cadence must stay well inside - /// the ledger dedup window or replay loses its safety margin. - public shared ({ caller }) func set_recovery_interval(intervalNs : Nat) : async Result.Result<(), Recovery.IntervalError> { - requireAdmin(caller); - switch (Recovery.validateInterval(intervalNs, Cmc.ledgerDedupWindowNs)) { - case (#err(e)) #err(e); - case (#ok) { - recoverySweepIntervalNs := intervalNs; - Timer.cancelTimer(recoveryTimerId); - recoveryTimerId := Timer.recurringTimer(#nanoseconds(intervalNs), recoverySweep); - audit("recovery.intervalSet", "sweep cadence set to " # intervalNs.toText() # " ns"); - #ok; + func deliveryDelayed(order : Types.Order, nowNs : Int) : Bool { + switch (deliveryStage(order, nowNs)) { + case (?stage) stageIsDelayed(stage); + case null false; + }; + }; + + // How many deliveries are late — the count behind `delayed_deliveries`' page. + // + // Bounded by `promiseHolders`, which is bounded by flow (§5.4), not by lifetime + // sales. + func delayedDeliveryCount(nowNs : Int) : Nat { + var n = 0; + for (id in Orders.promiseHolderIds(orderStore)) { + switch (Orders.get(orderStore, id)) { + case (?order) { if (deliveryDelayed(order, nowNs)) n += 1 }; + case null {}; }; }; + n; }; - /// §5.2 liveness observability, public (operational transparency, same - /// stance as treasury_status): cadence + last completed timer sweep. A - /// null or stale `lastSweep` means recovery is not running. - public query func recovery_status() : async { - intervalNs : Nat; - lastSweep : ?{ atNs : Int; pending : Nat }; - sweepInFlight : Bool; - } { - { - intervalNs = recoverySweepIntervalNs; - lastSweep = lastRecoverySweep; - sweepInFlight = recoverySweepInFlight; + + + // ⚠️ `quiet` must mean "no outflow could have moved the balance between the read + // and this call" — see `Reserve.adoptObservation`, and `refresh_reserve` for how + // it is established. Passing `true` loosely reintroduces the bug this design + // exists to remove. + func reconcileReserve(observed : Nat, quiet : Bool) { + let adopted = Reserve.adoptObservation(reserveState.floor, observed, quiet); + if (not adopted.adopted) { + audit("reserve.reconcileSkipped", "deliveries were in flight across the balance read; keeping the maintained floor of " # reserveState.floor.toText()); + return; + }; + if (adopted.unexplainedShortfall > 0) { + audit( + "reserve.unexplainedShortfall", + "ledger holds " # observed.toText() # " but the floor said at least " + # reserveState.floor.toText() # " — short by " # adopted.unexplainedShortfall.toText() + # ". No outflow but ours is possible, so treat this as a bookkeeping breach and reconcile before selling more.", + ); }; + reserveState.floor := adopted.floor; + reserveState.observedAtNs := ?Time.now(); }; - // ── ck-USDC rail (task 14, §6.2) ──────────────────────────────────────── - - /// §6.2 rail config. Fails closed: maxUsdCents defaults to 0, so the rail - /// is disabled until the operator consciously sizes its bounds (the - /// empty-tier-list stance). - var ckUsdcConfig : CkUsdc.Config = CkUsdc.defaultConfig(); - - /// Money-IN journal for this rail: the §5.1-style pull intent persisted - /// *before* the transfer_from await, plus the recovered block. Financial - /// record — never pruned (it proves which ledger block paid which order). - let ckUsdcPulls : CkUsdc.PullJournal = CkUsdc.emptyPullJournal(); - - /// Per-order single-flight for the claim path: two concurrent claims for - /// one order would both pass the status gate before the ledger await. - /// Transient — an upgrade mid-pull clears it and the persisted intent - /// drives the replay (CkUsdc.claimStage) instead. - transient let pullsInFlight = Set.empty(); - - transient let ckUsdcLedger = actor (CkUsdc.ledgerId) : CkUsdc.LedgerService; - - public type CreateCkUsdcOrderError = { - #anonymous; - /// Operator has not enabled the rail (maxUsdCents = 0). - #railDisabled; - #zeroAmount; - /// Carry the bound so the frontend can render what would be accepted. - #belowMinimum : Nat; - #aboveMaximum : Nat; - /// §3 fee formula swallows the amount — config problem, not retryable. - #amountBelowFees; - /// §3.1 fail-closed: no fresh rate and the refresh failed. - #rateUnavailable; - #idGeneration; + // Read the ledger balance and reconcile the floor against it — the ONE place + // that establishes the quiet window, so the operator lever and the sweep cannot + // drift apart on the property the whole design rests on. + // + // ⚠️ **The quiet window is established across the await, not assumed.** Nothing + // unsettled before, nothing unsettled after, and no transfer issued in between — + // then and only then does the observed balance bound the current one. Any of the + // three failing means an outflow may have moved the balance in the gap, and + // adopting would erase rule 2's decrement. + // **`holdersAfter` is returned rather than left for the caller to re-read, and + // that is the point.** `withdraw_reserve` must re-check the promise-holder count + // after this await — the floor is still full across it, so a `create_order` queued + // here would be admitted against a reserve about to leave. Handing the count back + // from the await the caller is already forced to make means there is nothing left to + // forget: a comment saying "re-read this" is the weakest possible guard, and this + // makes the value arrive with the result instead. + // + // Read AFTER the await, deliberately — a count captured before it answers the wrong + // question. + func observeReserve() : async* { observed : Nat; quiet : Bool; holdersAfter : Nat } { + let unsettledBefore = unsettledDeliveries(); + let issuedBefore = reserveState.outflowsIssued; + let observed = await cyclesLedger.icrc1_balance_of(Delivery.reserveAccount(selfPrincipal())); + let quiet = unsettledBefore == 0 and unsettledDeliveries() == 0 and reserveState.outflowsIssued == issuedBefore; + reconcileReserve(observed, quiet); + ({ observed; quiet; holdersAfter = Orders.promiseHolderCount(orderStore) }); }; - public type CreatedCkUsdcOrder = { - order : Types.Order; - /// Ledger units the claim will pull (the exact quoted price). - amountUnits : Nat; - /// The user's `icrc2_approve` must cover at least this: amount + the - /// ledger transfer fee (charged to the `from` account on the pull). - approveUnits : Nat; + + + + + + + + + + + + + + // ── Recovery timer (task 11, §5.2) ────────────────────────────────────── + + // The recovery machinery's mutable state, as ONE record (docs/DESIGN.md §9.1). + // + // ⚠️ Four independent passes write here — the stranded sweep, the tally reconcile, the + // reserve reconcile and the rotating index scan — and `recovery_status` reads all of + // them. Grouped so a mixin receives one slice rather than eight fields, and shared by + // reference because `include` passes by value. + let recoveryState : { + // §5.2 sweep cadence. Persistent — an operator-tuned cadence survives + // upgrades; the transient timer below re-arms at this value. Bounded by + // Recovery.validateInterval (≪ the §5.1 ledger dedup window). + var sweepIntervalNs : Nat; + // Last *completed* timer sweep — recovery liveness for ops (the §5.2 + // timer is the backstop for every detached webhook kick that dies, so + // "is it actually firing" must be observable). + var lastSweep : ?{ atNs : Int; pending : Nat }; + // When the tallies were last **successfully** reconciled, and what the pass found. + // Surfaced on `recovery_status` so "the counts are trustworthy" is an observable + // fact rather than an assumption. Written only on success, so it falling behind + // while `lastSweep` advances is the signal that the reconcile itself is failing + // (RUNBOOK's monitoring section). + // + // ⚠️ **`drift` and `refused` are different verdicts and are reported separately.** + // `drift` is a tally that was raised to the recount and is now correct, while + // `refused` is one the pass would not touch because the recount came out lower — see + // `Orders.adoptOnlyIncreases`. A monitor that alerts on the pair as if they were one + // number cannot tell "repaired" from "still suspect". + // + // `ordersRead` is how much work the pass did. It is bounded by the two index sizes, + // so watching it grow with lifetime sales would mean the bound had broken. + var lastCountReconcile : ?{ + atNs : Int; + drift : [Orders.Drift]; + refused : [Orders.Drift]; + ordersRead : Nat; + }; + // When the reserve reconcile was last *attempted*. Same attempt-vs-success split + // as the count reconcile below, for the same reason: `reserveState.observedAtNs` records + // success, and gating the cadence on that alone would retry a failing (or + // perpetually non-quiet) read on every single tick. + var lastReserveReconcileAttemptNs : Int; + // When a reconcile was last *attempted*, which is what gates the cadence. + // + // Separate from the success timestamp on purpose. A trap rolls back every + // state change in its own message, so a reconcile that traps cannot record + // that it ran — gating on success alone would leave it due on the next tick + // and every tick after, trapping forever. This is written by the **sweep's** + // message, which commits regardless of what the detached reconcile does. + var lastCountReconcileAttemptNs : Int; + // Where the current coverage cycle has reached. `null` means a cycle is about to + // start from the beginning of the store. + // + // **Persistent, because the coverage claim is what this state is for.** A + // transient cursor would silently restart every cycle on every upgrade, so + // `lastIndexScanCycle` would report a completed pass that an upgrade had truncated — + // a green check that means nothing. + var indexScanCursor : ?Types.OrderId; + // The cycle in progress: when it began, how many orders it has read, and how many + // disagreements it has repaired so far. + var indexScanCycle : { startedAtNs : Int; ordersRead : Nat; repairs : Nat }; + // The last **completed** cycle — the only thing that licenses reading a clean scan + // as evidence about the whole store. + // + // **This field IS the third state.** `orders.problemIndexDrift` and + // `orders.unindexedHolders` only mean "a writer bypassed the maintaining functions" + // if the absence of those lines means "we looked". Without a completed-cycle stamp, + // silence means either *verified clean* or *not yet visited*, which are two readings + // with opposite responses — find the bug, versus wait for the next pass. So the three + // states are: an audit line (verified, disagreed), silence with a recent + // `completedAtNs` (verified, clean), and silence without one (unverified). + var lastIndexScanCycle : ?{ + startedAtNs : Int; + completedAtNs : Int; + ordersRead : Nat; + repairs : Nat; + }; + } = { + var sweepIntervalNs = Recovery.defaultIntervalNs; + var lastSweep = null; + var lastCountReconcile = null; + var lastReserveReconcileAttemptNs = 0; + var lastCountReconcileAttemptNs = 0; + var indexScanCursor = null; + var indexScanCycle = { + startedAtNs = 0; + ordersRead = 0; + repairs = 0; + }; + var lastIndexScanCycle = null; }; - /// Create a ck-USDC order (§6.2): user-chosen amount within operator - /// bounds — nothing structural pins the amount the way a card Payment Link - /// does, and the canister pulls the exact price itself. Same §3 quote path - /// as the card rail (locked cycle quantity, fail-closed on a stale rate), - /// priced with this rail's own fee formula. The flow after this: - /// `icrc2_approve` (user → ledger, ≥ approveUnits) then `claim_ck_usdc_order`. - public shared ({ caller }) func create_ck_usdc_order( - usdCents : Nat, - destination : Types.Destination, - ) : async Result.Result { - switch (Auth.checkUser(caller)) { - case (#err(#anonymous)) return #err(#anonymous); - case (#ok) {}; + // §5.2 single-flight guard: a sweep slower than the interval must skip + // the next firing, never pile up. Transient on purpose — a persistent + // flag left true by an upgrade mid-sweep would deadlock recovery + // forever (the `pumping`-style deadlock §5.2 warns about); an upgrade + // resets it and the timer below re-arms. + transient var recoverySweepInFlight = false; + // Re-entry guard for the detached stranded-`#created` pass. + // + // ⚠️ **The per-pass cap bounds ONE pass, not overlapping ones.** The cadence gate is + // claimed before the pass is detached, so a pass that outlives its hour — a slow or + // unresponsive Stripe, with up to `maxRetrievesPerPass` sequential outcalls — would + // otherwise let the next tick start a second pass against the same orders and stack + // outcalls. That is a production concern, not only a test one. + transient var expiryScanInFlight = false; + // Where the last stranded-`#created` pass stopped, so the next one resumes rather than + // restarting. + // + // ⚠️ **The cap alone STARVES.** Taking the first `maxRetrievesPerPass` due orders in + // store order means a due order that stays due — Stripe answering `open` because of + // clock skew, or a retrieve that keeps failing — is asked again every pass while + // orders behind it are never reached at all. The bound has to come with a resume or it + // is a bound on *which* orders get looked at, not on how many. + // + // Measured, not theorised: two integration scenarios failed with "the sweep never + // retrieved session …" because their order sat behind ten permanently-due neighbours. + // + // ⚠️ **An id, NOT an index.** An index into a snapshot is meaningless across ticks, + // because an insert shifts every later position. + // + // The lesson belongs with the deletion discipline rather than here: **a disposal + // record has to carry the invariants the deleted code satisfied**, not only where its + // behaviour went, or the next thing of that shape pays for them again. + // + // This pages the **due set** rather than the store, because "due" is not a + // store-order property — collecting it costs one scan and no outcalls, and the cap + // applies to the expensive half. + transient var expiryScanCursor : Text = ""; + + + + + + // Report what a bounded reconcile pass found, shared by the timer and the + // admin lever so the two cannot report differently. + // + // ⚠️ **Every audit line here is a code bug, not an operational condition**, and each + // is written only when it fires — a clean pass writes nothing, because a daily "all + // well" line would bury the one that matters. + // + // **`adopted` and `refused` are separate tags on purpose.** They demand opposite + // readings: an adopted drift means *the tallies are correct again and a writer lost + // an adjustment*; a refused one means *the tallies are still suspect and the pass + // would not touch them*. One tag covering both would be a row an operator cannot act + // on, which is worse than no row. + func reportReconciliation(report : Orders.Reconciliation) { + if (report.adopted.size() > 0) { + let rendered = report.adopted.map( + func d = d.status # " " # d.was.toText() # "→" # d.is.toText() + ); + audit( + "orders.countDrift", + "raised to the recount over the non-terminal index: " # rendered.values().join(", ") + # ". The counts are right again; the writer that lost the adjustment is not fixed.", + ); }; - // One config snapshot: bounds, fee formula, and ledger fee from one epoch. - let config = ckUsdcConfig; - switch (CkUsdc.validateAmount(config, usdCents)) { - case (#err(#railDisabled)) return #err(#railDisabled); - case (#err(#zeroAmount)) return #err(#zeroAmount); - case (#err(#belowMinimum(min))) return #err(#belowMinimum(min)); - case (#err(#aboveMaximum(max))) return #err(#aboveMaximum(max)); - case (#ok) {}; + if (report.refused.size() > 0) { + let rendered = report.refused.map( + func d = d.status # " tally " # d.was.toText() # ", recount " # d.is.toText() + ); + audit( + "orders.countRecountLow", + "the recount came out BELOW the maintained tally and was refused: " # rendered.values().join(", ") + # ". Two causes with one response: either the non-terminal index is missing a member or a tally" + # " gained an adjustment it should not have — both are bugs in Orders.mo. The maintained value" + # " stands, which over-refuses rather than overselling. orders.unindexedHolders from the rotating" + # " scan names the missing members if that is the cause.", + ); + }; + if (report.promisedWas != report.promisedIs) { + audit( + if (report.promisedAdopted) "reserve.promisedRaised" else "reserve.promisedRecountLow", + "promised tally " # report.promisedWas.toText() # ", recount over the non-terminal index " + # report.promisedIs.toText() + # ( + if (report.promisedAdopted) ". Raised: the tally had lost a hold, so the reserve was reading" + # " as MORE available than it was. This is the direction that oversells, and it is now closed." + else ". Refused: a recount below the tally is indistinguishable from an incomplete index, so" + # " adopting it is the one move that could oversell the reserve. The maintained value stands and" + # " over-refuses." + ), + ); }; - let fee = { feeBps = config.feeBps; feeFixedCents = config.feeFixedCents }; - let (lockedCycles, pricing) = switch (await* quoteWithRefresh(fee, usdCents)) { - case (#ok(quoted)) quoted; - case (#unpriceable) return #err(#amountBelowFees); - case (#rateUnavailable) return #err(#rateUnavailable); + if (report.staleHolders.size() > 0) { + audit( + "orders.staleHolders", + report.staleHolders.size().toText() # " id(s) in the non-terminal index belonged to terminal orders" + # " and were dropped: " # report.staleHolders.values().join(", ") + # ". The order's own status is the authority, so this repair is sound — but the index is" + # " maintained only by Orders.create and Orders.commitTransition, so something else wrote a" + # " status. Find that writer; the drop is not the fix.", + ); }; - switch (await* createOrderWithFreshId(#ii(caller), #ckUsdc, destination, lockedCycles, pricing)) { - case (?order) { - let amountUnits = CkUsdc.unitsForCents(usdCents); - #ok({ order; amountUnits; approveUnits = amountUnits + config.ledgerFeeUnits }); - }; - case null #err(#idGeneration); + if (report.staleProblemIds.size() > 0) { + audit( + "orders.problemIndexDrift", + report.staleProblemIds.size().toText() # " id(s) in the unresolved-problems index had no unresolved" + # " problem and were dropped: " # report.staleProblemIds.values().join(", ") + # ". That index is maintained only by Orders.fileProblem and Orders.resolveProblems, so something" + # " else wrote order.problems. Find that writer; the drop is not the fix.", + ); + }; + if (report.expiredIs < report.expiredWas) { + audit( + "orders.expiredWentBackwards", + "the Expired tally fell from " # report.expiredWas.toText() # " to " # report.expiredIs.toText() + # ", which the transition matrix makes impossible: #created → #expired is its only inbound edge" + # " and it has no outbound one. A bookkeeping breach in Orders.bump. Reported once per decrease," + # " not daily, so a repeat means it fell again.", + ); + }; + if (report.expiredOverflow) { + audit( + "orders.expiredOverflow", + "Expired tally " # report.expiredIs.toText() # " plus " # Orders.promiseHolderCount(orderStore).toText() + # " non-terminal orders exceeds the " # Orders.storedCount(orderStore).toText() # " orders in the store." + # " The two sets are disjoint subsets of it, so this is arithmetically impossible and the Expired" + # " tally is over-counted. Observability only — nothing decides on it — but it is a real breach.", + ); }; }; - public type ClaimCkUsdcError = { - #anonymous; - /// Not found or not owned — existence is not revealed to non-owners. - #notFound; - #wrongRail; - /// Order status is past money-in (carries the status text). - #notClaimable : Text; - /// A claim for this order is already in flight. - #inFlight; - /// §6.2 amount-short mismatch: approve at least `required`, then retry. - #insufficientAllowance : { allowance : Nat; required : Nat }; - #insufficientFunds : { balance : Nat; required : Nat }; - /// ledgerFeeUnits config drifted from the ledger's fee — operator fixes. - #badFee : { expectedFee : Nat }; - #ledgerRejected : Text; - /// Transient ledger trouble — safe to retry (the intent replays). - #retryable : Text; - /// The pull's fate is unknowable (intent aged past the dedup window) — - /// escalated to the operator; do not approve again until resolved. - #staleIntent; + // Reconcile the maintained tallies against the orders. Audits **only on a finding**: + // a clean pass every day would bury the one line that matters. + // + // Runs in its own message (see the call site) and takes no `await`, so it sees a + // consistent snapshot of the order store. + // + // **Its cost is now bounded by flow rather than by lifetime sales** — it + // recounts over `Orders.promiseHolders`, whose size the reserve caps. The pass it + // replaced summed every order ever created in one message and was on a path to the + // instruction limit. + // + // **Daily is a SUFFICIENCY choice, not a cost one.** Drift can only come from a + // bookkeeping bug, which does not need a 15-minute detection window. Do not justify + // the cadence by cost — that would describe a scan this function no longer performs. + // + // **Not chunked, and it cannot be**: a global sum cannot be split across messages, + // because mutations between chunks manufacture false drift. The check that + // *does* need every order — the outside direction of both indexes — is + // `scanIndexChunk`, which may be chunked precisely because it evaluates a per-order + // predicate rather than a sum. + func reconcileCounts() { + let report = Orders.reconcileBounded(orderStore); + // Stamped from inside, not handed the sweep's clock: this message runs after the + // one that scheduled it, and the two timestamps are compared against each other + // (attempt vs success) to tell a failing reconcile from a due one. + recoveryState.lastCountReconcile := ?{ + atNs = Time.now(); + drift = report.adopted; + refused = report.refused; + ordersRead = report.ordersRead; + }; + reportReconciliation(report); }; - /// Mark the order paid off a recovered ledger block: dedup + journal + - /// transition in one sync block. `#Duplicate`-recovered blocks from our own - /// replayed intent skip the dedup insert (it was recorded with the block). - func creditPull(orderId : Types.OrderId, block : Nat) : Result.Result { - let ownBlock = switch (ckUsdcPulls.get(orderId)) { - case (?entry) entry.blockIndex == ?block; - case null false; + // ── The rotating index scan ─────────────────────────────────────── + + + + + // The expected time for one full coverage cycle, hence the detection latency for the + // outside direction. The arithmetic is `Recovery.indexScanCycleNs`, which is pure and + // unit-tested — this only supplies the three live inputs. + func expectedIndexScanCycleNs() : Nat { + Recovery.indexScanCycleNs( + Orders.storedCount(orderStore), + Orders.scanChunkSize, + recoveryState.sweepIntervalNs, + ); + }; + + // One chunk of the rotating scan, on the sweep cadence. + // + // **Sweep cadence rather than daily, because one of its findings is money.** + // `unindexedHolders` is the one inconsistency the daily reconcile cannot see: an + // order that holds a promise, is missing from the index, and whose cycles are missing + // from `promised` too — index and tally agree, both low, and the reserve reads as + // more available than it is. + // + // ⚠️ **The honest claim is "bounded PER MESSAGE", not "bounded".** The daily reconcile + // verifies only the inside direction of each index; the outside direction is this scan, + // so detecting an unindexed promise-holder takes up to one full cycle — and the cycle + // grows **linearly in stored orders** (`⌈stored ÷ chunk⌉ × interval`). The right trade + // (work that traps is fatal; latency that grows is degradable and observable) but a + // trade, so do not describe the reconcile as simply "bounded". + // + // ⚠️ **`set_recovery_interval` is a lever on this latency and its name does not say + // so** — tunable to 24× the default. `recovery_status.indexScan.expectedFullCycleNs` is + // computed from the live interval so an operator reads the window rather than deriving + // it. + // + // **Detached into its own message by the caller, like the reconcile**, so a trap + // here cannot take the sweep — and therefore money-out — down with it. It takes no + // `await`, so its own state commits or rolls back as a unit. + func scanIndexChunk() { + let now = Time.now(); + let starting = recoveryState.indexScanCursor == null; + if (starting) { + recoveryState.indexScanCycle := { startedAtNs = now; ordersRead = 0; repairs = 0 }; }; - if (not ownBlock) { - if (not Idempotency.recordCkUsdcBlock(dedup, block)) { - // Structurally unreachable: every pull is its own ledger transaction. - // §4.1 invariant anyway — dedup gates the mint, so refuse to credit. - audit("ckusdc.blockAlreadyCredited", orderId # ": block " # block.toText()); - return #err(#retryable("ledger block already credited")); - }; - CkUsdc.recordPullBlock(ckUsdcPulls, orderId, block, Time.now()); + let chunk = Orders.scanChunk(orderStore, recoveryState.indexScanCursor, Orders.scanChunkSize); + let repairs = chunk.unindexedHolders.size() + chunk.unindexedProblems.size(); + recoveryState.indexScanCycle := { + recoveryState.indexScanCycle with + ordersRead = recoveryState.indexScanCycle.ordersRead + chunk.visited; + repairs = recoveryState.indexScanCycle.repairs + repairs; }; - switch (Orders.applyTransition(orderStore, orderId, #paid, Time.now())) { - case (#ok(paid)) { - audit("ckusdc.paid", orderId # ": block " # block.toText()); - #ok(paid); - }; - case (#err(_)) { - // Block + journal are committed; the next claim heals via - // #recoverBlock. Degrade, never trap mid-money-flow. - audit("ckusdc.paidTransitionRefused", orderId # ": block " # block.toText()); - #err(#retryable("payment recorded; retry to finalize")); + recoveryState.indexScanCursor := chunk.nextCursor; + if (chunk.unindexedHolders.size() > 0) { + audit( + "orders.unindexedHolders", + chunk.unindexedHolders.size().toText() # " order(s) hold a promise and were missing from the" + # " non-terminal index; they have been added: " # chunk.unindexedHolders.values().join(", ") + # ". This is the one bookkeeping error the daily reconcile cannot see — index and promise" + # " tally can be missing the same order and agree with each other, so the reserve reads as MORE" + # " available than it is and can be oversold. The next reconcile will raise `promised` to the" + # " larger index. Find the writer that set a status outside Orders.create and" + # " Orders.commitTransition; the repair is not the fix.", + ); + }; + if (chunk.unindexedProblems.size() > 0) { + audit( + "orders.unindexedProblems", + chunk.unindexedProblems.size().toText() # " order(s) carry an unresolved problem and were missing" + # " from the unresolved-problems index; they have been added: " + # chunk.unindexedProblems.values().join(", ") + # ". Until now the worklist did not show those obligations and resolveByPaymentRef could not" + # " reach them. Find the writer of order.problems outside Orders.fileProblem and" + # " Orders.resolveProblems.", + ); + }; + // ⚠️ **Only a null cursor licenses the coverage claim.** Every order that existed + // when the cycle began was ahead of a cursor that started at the beginning, so a + // cycle that ran to exhaustion visited all of them. Orders created mid-cycle may + // land behind the cursor and wait for the next one — which is why the claim is + // "every order that existed when this cycle began", and not "every order". + if (chunk.nextCursor == null) { + recoveryState.lastIndexScanCycle := ?{ + startedAtNs = recoveryState.indexScanCycle.startedAtNs; + completedAtNs = Time.now(); + ordersRead = recoveryState.indexScanCycle.ordersRead; + repairs = recoveryState.indexScanCycle.repairs; }; }; }; - /// §5.1 stale-intent escalation, money-IN edition: the pull's fate is - /// unknowable. The order deliberately stays `#created` (no legal edge to - /// `#errorQueue` pre-payment, and the position may well be "nothing - /// happened") — the pull journal's escalation mark blocks further claims; - /// the operator reads the ck-USDC ledger, then either refunds via - /// `withdraw_ck_usdc` (pull executed) or `reset_ck_usdc_pull` (it didn't). - func escalateStalePull(order : Types.Order, detail : Text) { - CkUsdc.markEscalated(ckUsdcPulls, order.id, Time.now()); - queueMintError(#ckUsdc, #stuckMint({ orderId = order.id; stage = "stalePullIntent" }), detail); - audit("ckusdc.stalePull", order.id # ": " # detail); + // The timer job. Correctness against concurrent drivers is processDelivery's + // per-order single-flight; this flag only stops sweep pile-up. The + // webhook kick deliberately bypasses it — a just-paid order must not + // wait a full interval because a background sweep (which enumerated + // `pending` before that order turned #paid) was still in flight. + // + // This is the **§5.2 recovery** timer and it stays. It is not a retention sweep and + // must not be folded into one: this one backstops a money-out message that died, which + // no webhook reports, and asks Stripe about `#created` orders whose expiry event never + // arrived. + // + // **Bounded per pass and resumed on the next one.** The stranded population is + // **correlated** — one unprovisioned webhook secret or one frozen canister strands + // every order in that window at once — so "rare" describes incidents, not orders per + // incident. Uncapped, one incident becomes N outcalls an hour for as long as it lasts. + // + // **Bounded by the non-terminal index, not by lifetime sales.** It still + // runs detached in its own message, because it makes outcalls and a release check + // must not be able to stop money-out. The `countOf(#created)` gate below is a + // maintained tally, so an idle pass is free. + func sweepStrandedCreated() : async* Nat { + if (expiryScanInFlight) return 0; + if (Orders.countOf(orderStore, #created) == 0) return 0; + expiryScanInFlight := true; + try { await* runStrandedPass() } finally { expiryScanInFlight := false }; }; - func driveClaim(order : Types.Order) : async* Result.Result { - let orderId = order.id; - let intent : CkUsdc.PullIntent = switch (CkUsdc.claimStage(order.status, ckUsdcPulls.get(orderId), Time.now(), CkUsdc.ledgerDedupWindowNs)) { - case (#notClaimable) return #err(#notClaimable(Types.statusToText(order.status))); - case (#alreadyEscalated) return #err(#staleIntent); - case (#escalate(_)) { - escalateStalePull(order, "pull intent aged past the ledger dedup window without a recorded block — check the ck-USDC ledger for the pull before refunding (order stays Created)"); - return #err(#staleIntent); - }; - case (#recoverBlock(block)) { - switch (creditPull(orderId, block)) { - case (#ok(paid)) { - // Detached money-out kick (§5) — the claim ack never waits on - // ledger/CMC latency; the §5.2 timer backstops a dead message. - ignore async { await* processMint(orderId) }; - return #ok(paid); - }; - case (#err(e)) return #err(e); + func runStrandedPass() : async* Nat { + let now = Time.now(); + // Collect every due order first. This costs **no outcalls**, so it is the cheap + // half; the cap applies to the expensive half below. + // + // **Over the non-terminal index, not the order store.** + // `Recovery.expiryCheckDue` matches `#created` alone, which holds its promise, so + // the index is a superset of the population and the filter is exact. This used to + // walk every order ever created on every hourly pass. + let all = List.empty(); + for (id in Orders.promiseHolderIds(orderStore)) { + switch (Orders.get(orderStore, id)) { + case (?order) { + if ( + Recovery.expiryCheckDue(order.status, order.expiresAtNs, now, Recovery.expiryGraceNs) + and not expiryChecksInFlight.contains(id) + ) { all.add(id) }; }; + case null {}; }; - case (#fresh) { - // §5.1 step 1, money-IN edition: the pull args are frozen and - // persisted in this sync block, before the ledger await. The amount - // comes from the order's own pricing snapshot, never live config. - let #ii(fromOwner) = order.owner; - let intent = CkUsdc.buildPullIntent( - fromOwner, - orderId, - CkUsdc.unitsForCents(order.pricing.usdCents), - ckUsdcConfig.ledgerFeeUnits, - Time.now(), - ); - ignore CkUsdc.openPull(ckUsdcPulls, orderId, intent, Time.now()); - intent; - }; - case (#replay(intent)) intent; }; - let result = try { - await ckUsdcLedger.icrc2_transfer_from(CkUsdc.transferFromArgs(selfPrincipal(), intent)); - } catch (e) { - // Call rejected — keep the intent; the next claim replays it. - audit("ckusdc.pullFailed", orderId # ": " # e.message()); - return #err(#retryable(e.message())); + let ids = all.toArray(); + if (ids.size() == 0) return 0; + + // Resume after the cursor, wrapping — so a permanently-due order cannot monopolise + // the pass. Ids are opaque, so "after" is just lexicographic order over a stable set; + // all that is required is that the starting point advances. + var start = 0; + label find for (i in ids.keys()) { + if (ids[i] > expiryScanCursor) { start := i; break find }; }; - switch (CkUsdc.interpretPull(result)) { - case (#pulled(block)) { - switch (creditPull(orderId, block)) { - case (#ok(paid)) { - ignore async { await* processMint(orderId) }; - #ok(paid); - }; - case (#err(e)) #err(e); - }; + + var asked = 0; + label ask for (offset in Nat.range(0, ids.size())) { + if (asked >= Recovery.maxRetrievesPerPass) break ask; + let id = ids[(start + offset) % ids.size()]; + expiryScanCursor := id; + expiryChecksInFlight.add(id); + try { await* checkSessionExpiry(id) } finally { expiryChecksInFlight.remove(id) }; + asked += 1; + }; + asked; + }; + + // One order: ask Stripe, then act on Stripe's answer and nothing else. + func checkSessionExpiry(orderId : Types.OrderId) : async* () { + let ?order = Orders.get(orderStore, orderId) else return; + let ?sessionId = order.stripeSessionId else return; + let answer = await* retrieveStripeSession(sessionId); + + // ⚠️ **Re-read the order. The await above is a window and the order can move + // through it** — `cancel_order` from the buyer, a late `checkout.session.expired`, + // even a late `completed` followed by a delivery. Acting on the copy read before the + // await is the bug `create_order` warns about in the same shape. + let ?fresh = Orders.get(orderStore, orderId) else return; + + switch (answer) { + // ⚠️ **The THIRD Stripe outcall, and it needs the same latch as the other two.** + // These two arms passed the admission rule on a technicality: their + // frequency is bounded by *our own* sweep cadence, which the rule accepts. + // + // **But our cadence bounds a RATE, and a rate against an unfixed persistent + // condition is unbounded over time.** A revoked key — the exact cause + // `#stripeApiFailing` exists for — fails the retrieve on every stranded order the + // sweep touches: hourly, up to `maxRetrievesPerPass` each pass, forever, for one + // unfixed problem. Roughly 240 permanent lines a day once the ring is gone. The + // ring is what turned "bounded per day" into "bounded in total", and removing it + // is what makes the difference matter. + // + // Same condition, not a fourth: a 401 on retrieve and a 401 on create are **one + // incident with one lever**. Which is why the condition is named for the API. + case (#unauthorized) { + // The guidance the deleted `stripe.retrieveUnauthorized` line carried, kept + // verbatim — it is the one message that names the fix, and RUNBOOK's monitoring P1 row + // is keyed on this text now rather than on a tag that no longer exists. + noteStripeApiFailed( + "retrieve REFUSED (401/403): the restricted key needs WRITE on Checkout Sessions, which includes the read this sweep does. Stranded reserve capacity cannot be released until it does — rotate the key" + ); }; - case (#drop(reason)) { - // Definite rejection — proven nothing ever moved under these args - // (dedup-first ledger semantics, CkUsdc.mo doc), so the intent goes - // and the next claim builds a fresh one. - CkUsdc.dropPull(ckUsdcPulls, orderId); - let required = intent.amountUnits + intent.feeUnits; - switch (reason) { - case (#insufficientAllowance({ allowance })) #err(#insufficientAllowance({ allowance; required })); - case (#insufficientFunds({ balance })) #err(#insufficientFunds({ balance; required })); - case (#badFee({ expectedFee })) { - audit("ckusdc.badFee", orderId # ": ledger expects " # expectedFee.toText() # " units"); - #err(#badFee({ expectedFee })); - }; - case (#rejected(detail)) { - audit("ckusdc.pullRejected", orderId # ": " # detail); - #err(#ledgerRejected(detail)); - }; + case (#failed(detail)) { + noteStripeApiFailed("retrieve: " # detail); + }; + case (#ok(#open) or #ok(#unknown(_))) { + // Nothing. Our clock decided when to ask; Stripe decides what is true, and it + // has not said the session is finished. `#unknown` lands here on purpose: an + // answer we cannot read must make this feature inert, never wrong. + switch (answer) { + case (#ok(#unknown(detail))) audit("stripe.retrieveUnreadable", "order " # orderId # ": " # detail); + case (_) {}; }; }; - case (#retry(detail)) { - audit("ckusdc.pullRetriable", orderId # ": " # detail); - #err(#retryable(detail)); + case (#ok(#expired)) { + // **The leak this issue exists to close.** Stripe says nobody can ever pay this + // session, so the promise is holding capacity against a sale that cannot happen. + // + // Through `expireWithCause`, never a status write: the matrix no-ops + // `#cancelled → #expired` for free, which is what keeps a buyer's own + // cancellation from being overwritten with a system expiry — and with it the + // `expiredBy` provenance that says which of the two happened. + switch (Orders.settleUnpayable(orderStore, cancelRequests, orderId, #sessionExpired, Time.now())) { + case (#ok(_)) audit("stripe.strandedExpired", orderId # ": Stripe confirmed the session expired; reserve capacity released"); + case (#err(_)) {}; // moved under us — cancelled, paid, already expired. Correct to do nothing. + }; }; - case (#uncertain(detail)) { - escalateStalePull(order, detail); - #err(#staleIntent); + case (#ok(#completePaid({ paymentIntent }))) { + // The buyer paid and we never credited it: we missed the `completed` event. + // + // **This is NOT the leak, and the difference decides the urgency.** Capacity + // held against an order the buyer genuinely paid for is capacity *correctly + // committed* — the promise is doing its job and releases at delivery once the + // event lands. So there is nothing to release here and nothing to hurry. + // + // Stripe redelivers for ~3 days (§4.2), so before the horizon the event is still + // coming and the real credit path will handle it. Filing an obligation then would + // file a self-resolving obligation on the order — noise in a worklist that + // never drops anything, so it buries the real ones. The audit line is the + // support signal instead: a buyer who paid + // sees their own page render expired from `expiresAtNs` and calls the same hour, + // and this is how an operator confirms Stripe says the session completed. + if (not Recovery.paidEscalationDue(fresh.createdAtNs, Time.now(), Recovery.paidRetryHorizonNs)) { + audit("stripe.paidAwaitingEvent", orderId # ": Stripe says this session was paid; waiting for the completed event Stripe is still retrying"); + return; + }; + // Past the horizon: nobody is going to credit this on its own. + // + // ⚠️ **Do not re-file — and that guard is now free.** `Problems.file` dedups on + // the kind's `paymentRef`, so no explicit `hasUnresolvedPaidNotCredited` check + // is needed alongside it. + // + // ⚠️ **The dedup is only safe because of one coupling, which survives the move:** + // the problem cannot be closed while it still exists, because its closer is *the + // order being credited*, not the money moving. Suppressing a duplicate therefore + // cannot hide anything. **Do not move the closer onto money state without + // revisiting this**, or the dedup silently becomes a hider. + if ( + Orders.fileProblem( + orderStore, + orderId, + #paidNotCredited({ paymentRef = paymentIntent; sessionId }), + "Stripe says session " # sessionId # " was paid and this order was never credited, past the point where Stripe would still be retrying. **Resend the event from the Stripe Dashboard first, always** — that credits the order through the normal path and closes this problem. Refunding instead settles the money and leaves the order stranded in Created with no event left to release it.", + Time.now(), + ) + ) { + audit("stripe.paidNotCredited", orderId # ": paid and uncredited past Stripe's retry horizon; obligation filed"); + }; }; }; }; - /// §6.2 pull: after `icrc2_approve` (≥ the order's approveUnits, spender = - /// this canister), the owner claims and the canister pulls the exact quoted - /// price. Idempotent against double-clicks (single-flight) and safe against - /// lost responses (the persisted intent replays bit-identically; the ledger - /// dedups on created_at_time). On success the order is `#paid` and the - /// mint pipeline is kicked — the same money-out path as the card rail. - public shared ({ caller }) func claim_ck_usdc_order(id : Types.OrderId) : async Result.Result { - switch (Auth.checkUser(caller)) { - case (#err(#anonymous)) return #err(#anonymous); - case (#ok) {}; + func recoverySweep() : async () { + if (recoverySweepInFlight) return; + recoverySweepInFlight := true; + try { + // Detached into its own message rather than run inline, and it stays detached + // even though its cost is now bounded. The reason was never only the instruction + // limit: **a bookkeeping check must not be able to stop orders from delivering**, + // whatever makes it trap. Inline, any trap in the reconcile takes the whole sweep + // down with it, leaving money-out dead while the reconcile stays due and traps + // again on every tick. + // + // Claiming the cadence here, in the sweep's own message, is what bounds the + // damage: this write commits whatever the detached message does, so a + // trapping reconcile retries daily rather than every tick. Its cost is a + // visibly stale `lastCountReconcile` (RUNBOOK's monitoring section), which is the right + // signal — the tallies are unverified, not known-wrong. + let now = Time.now(); + if (Recovery.reconcileDue(recoveryState.lastCountReconcileAttemptNs, now, Recovery.countReconcileIntervalNs)) { + recoveryState.lastCountReconcileAttemptNs := now; + ignore async { reconcileCounts() }; + }; + // ── One chunk of the rotating index scan ────────────────────────── + // + // Every tick, not on a cadence of its own: the chunk is what bounds it, and the + // coverage window is `stored orders ÷ (chunk × ticks per day)`, so a longer + // cadence buys nothing and lengthens the window on the one finding that can + // indicate an oversellable reserve. Detached for the same reason as the reconcile. + // + // **No cadence claim to make.** The cursor advances inside the detached + // message, so a chunk that traps simply leaves the cursor where it was and the + // next tick retries the same chunk — a permanently trapping chunk stalls coverage + // (visible as a frozen `indexScan.inFlightCycle.ordersRead`) rather than + // re-scanning from the start or skipping forward. + ignore async { scanIndexChunk() }; + let pending = await* sweepDeliverable(); + recoveryState.lastSweep := ?{ atNs = Time.now(); pending }; + // ── Stranded `#created` capacity ────────────────────────────────── + // + // **Detached, like the count reconcile and for the same reason**: it reads the + // whole order store AND makes outcalls, so it has two ways to fail that the + // delivery sweep must survive. Money-out is the thing with cycles outstanding; a + // release check must never be able to stop it. + // + // The cadence is claimed here, in the sweep's own message, so a detached pass that + // traps retries hourly rather than every tick. + let now3 = Time.now(); + if (Recovery.expiryScanDue(lastExpiryScanAtNs, now3, Recovery.expiryScanIntervalNs)) { + lastExpiryScanAtNs := now3; + ignore async { ignore await* sweepStrandedCreated() }; + }; + // ── Rule 1: the floor learns about top-ups only by looking ── + // + // **After the sweep, and INLINE — both deliberate, and for opposite + // reasons from the count reconcile above.** + // + // *After*, because a quiet window is what makes an observation adoptable, and + // the sweep is the one thing in this canister that issues transfers. Reading + // the balance first would race our own deliveries and skip almost every time + // a sweep had work — the floor would then only ever rise on idle ticks, which + // is precisely when nobody needs it to. + // + // *Inline*, because the detached-message trick the count reconcile uses would + // put this message's writes and the sweep's deliveries in flight together, + // recreating the same race. The trap risk it exists to avoid is handled by + // catching instead: a ledger that will not answer is a skipped reconcile, and + // the floor it leaves standing is a lower bound, so nothing unsafe follows. + let now2 = Time.now(); + if (Recovery.reconcileDue(recoveryState.lastReserveReconcileAttemptNs, now2, Recovery.reserveReconcileIntervalNs)) { + recoveryState.lastReserveReconcileAttemptNs := now2; + try { ignore (await* observeReserve()).observed } catch (e) { + audit("reserve.observeFailed", "could not read the reserve balance: " # e.message() # " — the floor stands, so the gateway under-sells until the next attempt"); + }; + }; + } finally { + recoverySweepInFlight := false; }; - let ?order = Orders.getOwned(orderStore, id, caller) else return #err(#notFound); - if (order.rail != #ckUsdc) return #err(#wrongRail); - if (pullsInFlight.contains(id)) return #err(#inFlight); - pullsInFlight.add(id); - try { await* driveClaim(order) } finally { pullsInFlight.remove(id) }; }; - /// Adjust the rail config (§7): amount bounds, fee formula, ledger fee. - /// Validated atomically — a bad config never partially applies. - public shared ({ caller }) func set_ck_usdc_config(config : CkUsdc.Config) : async Result.Result<(), CkUsdc.ConfigError> { - requireAdmin(caller); - switch (CkUsdc.validateConfig(config)) { - case (#ok) { ckUsdcConfig := config; #ok }; - case (#err(e)) #err(e); - }; - }; - /// Public — bounds and fee formula are what users are charged (the same - /// transparency stance as forex_status); the frontend renders the rail - /// panel (or its disabled state) from this. - public query func ck_usdc_config() : async CkUsdc.Config { - ckUsdcConfig; - }; - /// Money-IN journal for one order (admin, ops parity with mint_journal). - public shared query ({ caller }) func ck_usdc_pull(id : Types.OrderId) : async ?CkUsdc.PullEntry { - requireAdmin(caller); - ckUsdcPulls.get(id); - }; - /// Clear a stuck pull intent so the order becomes claimable again — ONLY - /// after verifying on the ck-USDC ledger that no transaction matches the - /// intent (a fresh intent after an executed-but-unrecorded pull would debit - /// the user twice). Refuses when a block is recorded (money moved). False = - /// nothing cleared. - public shared ({ caller }) func reset_ck_usdc_pull(id : Types.OrderId) : async Bool { - requireAdmin(caller); - switch (ckUsdcPulls.get(id)) { - case (?entry) { - if (entry.blockIndex != null) return false; - CkUsdc.dropPull(ckUsdcPulls, id); - audit("ckusdc.pullReset", id # ": intent cleared by operator after ledger verification"); - true; - }; - case null false; - }; - }; - /// §6.2 hold-ckUSDC treasury posture: pulled ck-USDC accrues in this - /// canister's ledger account; the operator withdraws it here, converts to - /// ICP off-chain, and refills the float. Attended admin lever — no - /// created_at_time dedup; on an ambiguous failure check the ledger before - /// retrying. (Balance is public on the ck-USDC ledger; no query needed.) - public shared ({ caller }) func withdraw_ck_usdc(to : Types.Account, amountUnits : Nat) : async Result.Result { - requireAdmin(caller); - let result = try { - await ckUsdcLedger.icrc1_transfer({ - from_subaccount = null; - to; - amount = amountUnits; - fee = ?ckUsdcConfig.ledgerFeeUnits; - memo = null; - created_at_time = null; - }); - } catch (e) { - return #err(e.message()); - }; - switch (result) { - case (#Ok(block)) { - audit("ckusdc.withdraw", amountUnits.toText() # " units to " # to.owner.toText() # ", block " # block.toText()); - #ok(block); - }; - case (#Err(error)) #err(CkUsdc.transferErrorToText(error)); - }; - }; // ── HTTP ingress ──────────────────────────────────────────────────────── - /// §6.0 body-size guard. Stripe events are a few KiB; 64 KiB is generous - /// headroom and far below the 2 MiB ingress cap. Transient so a redeploy - /// can retune it — a persistent let would freeze the first-deploy value. + // Set by the webhook route handler when a delivery marks an order `#paid`, + // read by `http_request_update` immediately afterwards to decide whether to + // kick money-out. Transient: it only carries a value within one message + // execution, and the §5.2 recovery timer is the backstop if an upgrade lands + // between the write and the read. + transient var webhookPaidOrder : ?Types.OrderId = null; + + // §6.0 body-size guard. Stripe events are a few KiB; 64 KiB is generous + // headroom and far below the 2 MiB ingress cap. Transient so a redeploy + // can retune it — a persistent let would freeze the first-deploy value. transient let maxRequestBodyBytes : Nat = 65_536; - /// HTTP route table (binding seam §11.1.2) — exactly one anonymous, - /// payload-authed route (§6.0). The whole §6.1 path lives in Card.mo; - /// an unprovisioned secret answers 503 inside handleWebhook, which makes - /// Stripe keep retrying instead of treating the delivery as accepted. + // HTTP route table (binding seam §11.1.2) — exactly one anonymous, + // payload-authed route (§6.0). The whole §6.1 path lives in Card.mo; + // an unprovisioned secret answers 503 inside handleWebhook, which makes + // Stripe keep retrying instead of treating the delivery as accepted. transient let routes : [Http.Route] = [ { method = "POST"; path = "/webhook/stripe"; upgrade = true; - handler = func req = Card.handleWebhook( - webhookDeps, - Secret.get(webhookSecret), - req, - Time.now(), - Card.defaultToleranceSeconds, - ); + handler = func req { + let outcome = Card.handleWebhook( + webhookDeps(), + Secret.get(webhookSecret), + req, + Time.now(), + Card.defaultToleranceSeconds, + ); + // Handed to http_request_update, which runs in the same atomic block + // (the dispatch is synchronous and nothing awaits in between), so this + // cannot be read by a different message than the one that set it. + webhookPaidOrder := outcome.paidOrder; + outcome.response; + }; }, ]; - /// §6.0 query half: the boundary node calls this first; a matched - /// upgrade route answers `upgrade = ?true` and the gateway re-issues the - /// request to `http_request_update` through consensus. - public query func http_request(req : Http.Request) : async Http.Response { - Http.handleQuery(routes, req, maxRequestBodyBytes); - }; - /// §6.0 update half. Anyone can call this directly via Candid, so the - /// dispatcher re-applies every guard; the route handlers themselves are - /// payload-authenticated (HMAC), never caller-authenticated. - public func http_request_update(req : Http.Request) : async Http.Response { - let response = Http.handleUpdate(routes, req, maxRequestBodyBytes); - // A verified checkout marked its order #paid synchronously inside the - // dispatch above; kick money-out (§5) as a detached self-message so the - // Stripe ack is never held hostage by ledger/CMC latency. The §5.2 - // recovery timer is the backstop if this detached message dies. - ignore async { ignore await* sweepMintable() }; - response; - }; - /// Liveness probe; also used by the scaffold smoke test path. - public query func health() : async Bool { - true; - }; + + // §5.2 the timer itself. Transient initializer = runs on install AND on + // every upgrade (postupgrade re-initialization), so a deploy can never + // leave recovery dead; the IC drops timers across upgrades, so there is + // no stale duplicate to cancel. + // + // Declared last in the actor body: the initializer evaluates during actor + // init and `recoverySweep` reaches the order store and the delivery journal, + // both of which must already be initialized (M0016 otherwise). + transient var recoveryTimerId : Timer.TimerId = + Timer.recurringTimer(#nanoseconds(recoveryState.sweepIntervalNs), recoverySweep); + + // §3 rate refresh. Same transient-initializer pattern as the recovery timer: + // it runs on install AND on every upgrade, which is what the IC requires + // (global timers are deactivated when the Wasm module changes) without an + // explicit `postupgrade` hook — which enhanced orthogonal persistence + // forbids anyway. Do not "improve" this by adding one. + // + // A dead rate timer is an availability failure, not an exploitable one: the + // cache goes stale and orders are refused. `Pricing.Config.maxAgeNs` is the + // control that guarantees that, which is why it is bounded. + transient var rateTimerId : Timer.TimerId = + Timer.recurringTimer(#nanoseconds(rateIntervalNs()), rateTimerJob); + + // Refresh immediately rather than after a full interval. The rate cache is + // persistent so an upgrade does not blank the price, but a stop→upgrade→start + // can outlast a 5-minute window; this closes that gap on install and upgrade + // alike. + transient let _rateWarmup : Timer.TimerId = + Timer.setTimer(#nanoseconds(0), rateTimerJob); + + // ── Endpoints, by feature (the rules are docs/DESIGN.md §9.1) ────────────── + // + // ⚠️ **Every `include` sits at the END of the actor body, and that is required rather + // than tidy.** Its arguments are evaluated once, right here, so each must name a field + // or helper already declared above — `auditAdmin` is defined ~2,000 lines up. An + // include placed where the endpoints used to be would capture bindings that do not + // exist yet. + // + // ⚠️ **A `var` field is passed as an ACCESSOR PAIR, never as the field.** `include` + // takes its arguments by value, so handing over `stripe.origin` would give the mixin a + // snapshot from install time: the setter would mutate a copy and the getter would + // answer that snapshot forever. Records and collections (`Secret.Store`, + // `Orders.Store`, the maps and sets) are heap objects, so those pass directly and + // write through. + // + // **Accessors rather than wrapping the `var` in a record**, which is the other fix + // and the expensive one: wrapping changes the actor's stable shape, and with no + // migration chain that means a reinstall. Closures are parameters, never stable + // state, so `deployed/backend.most` does not move for this split. + include MaintenanceMixin( + orderStore, + rateCache, + cyclesLedger, + reserveState, + { + auditAdmin; + requireAdmin; + requireController; + refreshRates; + observeReserve; + reportReconciliation; + }, + ); + + include AdminOrdersMixin( + orderStore, + dedup, + orphanStore, + auditLog, + deliveryJournal, + paidIntents, + cancelRequests, + { + audit; + auditAdmin; + requireAdmin; + isGrantedAdmin; + noteStripeApiFailed; + tryTransition; + expireStripeSession; + deliveryStage; + stageIsDelayed; + openTransfer; + forEachPromisedDelivery; + }, + ); + + include BuyingMixin( + orderStore, + rateCache, + reserveState, + gateState, + pricingState, + tierState, + { + audit; + admit; + gateObservation; + admitOrder; + noteStripeApiFailed; + sessionConfig; + createStripeSession; + createOrderWithFreshId; + noteRailClosed; + sessionErrorToText; + }, + ); + + include WebhookMixin( + routes, + maxRequestBodyBytes, + { + // Read and cleared as one step, so a stale value cannot trigger a second kick. + take = func() : ?Types.OrderId { + let taken = webhookPaidOrder; + webhookPaidOrder := null; + taken; + }; + }, + { processDelivery }, + ); + + include OrdersMixin( + orderStore, + deliveryJournal, + cancelRequests, + { + audit; + auditAdmin; + noteStripeApiFailed; + tryTransition; + expireStripeSession; + processDelivery; + isGrantedAdmin; + deliveryInFlight = func(id : Types.OrderId) = deliveriesInFlight.contains(id); + }, + ); + + include ConfigMixin( + orderStore, + rateCache, + reserveState, + gateState, + pricingState, + stripeState, + tierState, + deliveryState, + recoveryState, + { + requireController; + auditAdmin; + expectedIndexScanCycleNs; + rearmRateTimer = func() { + Timer.cancelTimer(rateTimerId); + rateTimerId := Timer.recurringTimer(#nanoseconds(rateIntervalNs()), rateTimerJob); + }; + rearmRecoveryTimer = func(intervalNs : Nat) { + Timer.cancelTimer(recoveryTimerId); + recoveryTimerId := Timer.recurringTimer(#nanoseconds(intervalNs), recoverySweep); + }; + }, + ); + + include MonitoringMixin( + orderStore, + dedup, + orphanStore, + paidIntents, + rateCache, + reserveState, + gateState, + pricingState, + stripeState, + recoveryState, + { + requireAdmin; + selfPrincipal; + unsettledDeliveries; + delayedDeliveryCount; + expectedIndexScanCycleNs; + lastXrcCanisterId = func() = lastXrcCanisterId; + recoverySweepInFlight = func() = recoverySweepInFlight; + }, + ); + + include PrincipalsMixin( + adminPrincipals, + allowedBuyers, + reserveState, + stripeState, + requireController, + auditAdmin, + ); + + include SecretsMixin( + webhookSecret, + stripeApiKey, + { get = func() = stripeState.origin; set = func(v : ?Text) { stripeState.origin := v } }, + requireController, + requireAdmin, + auditAdmin, + func() = Time.now(), + openSealed, + ); }; diff --git a/src/backend/Orders.mo b/src/backend/Orders.mo index a4edb47..b329d2f 100644 --- a/src/backend/Orders.mo +++ b/src/backend/Orders.mo @@ -1,19 +1,23 @@ /// Order store + the §4 state machine. /// -/// The state machine owns *transitions only*. Expiry policy, float checks, +/// The state machine owns *transitions only*. Expiry policy, solvency checks, /// dedup, and delivery are the callers' business (per-rail money-in, §5 /// money-out) — they ask for a transition and this module answers whether it /// is legal. Seam §11.1.3: `create` takes the owner as a parameter and never -/// reads a caller itself; Card/ck-USDC owners come from an II Candid call, +/// reads a caller itself; owners come from an II Candid call, /// a future Base owner from a verified EIP-3009 signature. import Array "mo:core/Array"; +import Problems "Problems"; import Blob "mo:core/Blob"; import Map "mo:core/Map"; +import Set "mo:core/Set"; import List "mo:core/List"; +import Int "mo:core/Int"; import Iter "mo:core/Iter"; import Principal "mo:core/Principal"; import Text "mo:core/Text"; import Result "mo:core/Result"; +import Reserve "Reserve"; import Types "Types"; import Util "Util"; @@ -30,10 +34,10 @@ module { public func idFromEntropy(entropy : Blob) : ?Types.OrderId { if (entropy.size() < idEntropyBytes) return null; let prefix = entropy.toArray().sliceToArray(0, idEntropyBytes); - ?Util.hexEncode(Blob.fromArray(prefix)); + ?Util.hexEncode(prefix.toBlob()); }; - /// §6.1 — the value carried on the tier's Payment Link URL: + /// §6.1 — the reference the canister sets on the Checkout Session it creates: /// `_`. Unambiguous to split: principal text is /// `[a-z0-9-]`, the ID is hex, so the one `_` is the separator. Claimed, /// not trusted — webhook ingestion re-resolves and verifies it. @@ -64,13 +68,584 @@ module { /// §4.2 — `orders : Map`. orders : Map.Map; /// §4.2 — order history per principal (fixes the lost-receipt problem). - principalsToOrders : Map.Map>; + /// + /// ⚠️ **A `Set`, so `ownerPage` can seek to a cursor in O(log n).** A `List` holds + /// insertion order, which has no relation to the `OrderId` order every cursor in this + /// codebase is expressed in — so paging it meant scanning the whole store instead. + principalsToOrders : Map.Map>; + /// Live per-status tallies, maintained by `create` / `applyTransition` / + /// `markPaid` — the only three functions that write an order's status. + /// + /// These exist so the public status queries are O(1). Counting by scanning + /// `orders` makes an unauthenticated query cost O(total orders), which is + /// an operation that is free for anyone to invoke and grows more expensive + /// forever — the shape of DoS the security guidance warns about. + /// + /// Only the statuses the queries report are tracked; `countOf` returns 0 + /// for the rest. `recount` rebuilds them if they are ever suspected wrong. + counts : Map.Map; + /// Cycles promised to orders that exist and are not settled. + /// + /// ⚠️ **It lives HERE, in the store, deliberately.** `create`, + /// `applyTransition` and `markPaid` are the only three functions that write an + /// order's status, and they are all in this module — so the tally sits next to + /// every site that can move it, and adding a fourth writer means editing this + /// file and seeing the tally. Holding it in `Main.mo` instead would put the + /// state and its adjustment sites in different files, which is how per-status + /// counters drift. + /// + /// The value is `Σ lockedCycles` over orders whose status is **not terminal** + /// (`Reserve.holdsPromise`). See `Reserve.mo` for why it is a rule rather than + /// a list, and why there is no fee term. + var promised : Nat; + /// How many times `promised` had to saturate at zero — i.e. a release asked to + /// remove more than was held. **Any non-zero value means the tally diverged**, + /// and it is surfaced by `reserve_status` so it does not wait for a recount. + var tallySaturations : Nat; + /// Orders carrying at least one **unresolved** problem. + /// + /// **An index, because the alternative is a full scan on the webhook path.** + /// `resolveByPaymentRef` runs synchronously inside the `charge.refunded` handler, + /// where a trap is a 5xx Stripe retries for three days. Under indefinite retention + /// a scan over every order ever created is exactly the hazard this codebase guards + /// everywhere else. + /// + /// **A status index would NOT have bounded it** — `#duplicate` is filed from a + /// catch-all covering `#delivered` (the *common* case: order delivered, buyer pays + /// again) as well as terminal statuses, so a non-terminal index narrows nothing. + /// Establish that a bound narrows the set before writing that it does. + /// + /// ⚠️ **Derived state, safe only because the orders can adjudicate it.** Maintained by + /// the only two functions that write `problems` — `fileProblem` and `resolveProblems`, + /// both here — and checked from both directions: the inside daily by + /// `reconcileBounded`, the outside on a coverage window by `scanChunk`. This is + /// a projection of `orders`, recomputable at any time; a **map pointing at ids in + /// another structure** would be the unrecoverable version, because the two could + /// disagree with no way to tell which was right. + /// + /// ⚠️ **No wholesale rebuild lever, deliberately.** A pass that clears and refills + /// this from every order grows with lifetime sales, and "the index was rebuilt" reads + /// as a fix for what is actually a bug in this file. + /// + /// Growth is attacker-priced: every problem needs a real payment event to exist. + unresolvedProblems : Set.Set; + /// Orders whose promise is still held — i.e. **the non-terminal set**. + /// + /// **Named for the predicate that defines it, not for what it is used for.** + /// `Reserve.holdsPromise` IS the membership rule, and it is the same predicate the + /// `promised` tally moves on, so the two cannot drift apart into two definitions of + /// "still live". ⚠️ Do not read this as `reserve_status.openOrders` — that figure is + /// the `#created` tally alone, a strict subset. + /// + /// **Bounded by the reserve, which is what makes it fit in one message.** Every + /// member holds its `lockedCycles` in `promised`, and admission refuses once + /// `promised` would exceed the reserve floor (`Reserve.canCover`), so + /// `|promiseHolders| ≤ reserveFloor / minimum order`. That bound is set by **flow**, + /// not by lifetime sales — which is precisely what the order store is not. + /// + /// ⚠️ **It is derived state trusted by the reconcile, and the circularity below is + /// what keeps that safe — read it before extending this.** Iterating this and + /// reading each order's real status yields a tally that is **exact if the index is + /// complete and too LOW otherwise** — never too high, because a member whose order + /// turned out terminal is dropped on sight. So the reconcile adopts only + /// *increases*, and the one error direction this index can hide is the one direction + /// it refuses to adopt. The completeness half — nothing outside here holds a promise + /// — is not knowable from the index at all, and that is what the rotating + /// `scanChunk` is for. + promiseHolders : Set.Set; + /// Cumulative deliveries, for the public trust figures. + /// + /// **Counted in `commitTransition` on the transition INTO `#delivered`, which is + /// the only place it can be counted exactly.** Six call sites move an order to + /// `#delivered`, so a per-site counter would be six chances to forget — and this file + /// already carries the scar of a comment that named "the only three writers" and was + /// false in the same file. Here it is structural: writing a status *is* calling that + /// function. + /// + /// ⚠️ **Double-counting is unrepresentable, not merely unlikely.** `#delivered` has no + /// outbound edge in `isLegalTransition`, so once an order is delivered + /// `commitTransition` is never called for it again. There is no idempotency guard here + /// because none is reachable. + /// + /// ⚠️ **`cycles` is what was SOLD (`lockedCycles`), not what landed** (`lockedCycles − + /// the ledger fee, which the reserve absorbs). The difference is one transfer fee per + /// order — around 0.0014% at the smallest tier — and buying exactness would mean + /// threading the delivered amount through six sites for a figure that is displayed + /// rounded to two significant figures. Say "sold" if the wording ever has to be + /// precise. + var deliveredOrders : Nat; + var deliveredCycles : Nat; + /// ⚠️ Cumulative gross USD cents actually PAID (`paidUsdCents`), not quoted. Null + /// cannot happen on a delivered order — `markPaid` sets it before any delivery — so a + /// null contributes zero and `deliveredNullPaid` records that the impossible happened + /// rather than silently understating the total. + var deliveredUsdCents : Nat; + var deliveredNullPaid : Nat; + /// The highest `#expired` tally seen by a reconcile, for the monotonicity check + /// that replaces re-summing it. + /// + /// ⚠️ **`#expired` is the only tracked status that is terminal**, so it is the only + /// one `promiseHolders` cannot recount — and it is also the only one that can be + /// checked without history: `#created → #expired` is its sole inbound edge and the + /// matrix has no outbound one, so the tally is **monotonically non-decreasing**. A + /// decrease is a bookkeeping breach. + /// + /// ⚠️ **It follows the count back DOWN once a decrease is reported**, so each + /// decrease is reported exactly once. Holding a true high-water mark would re-report + /// the same unfixed condition on every daily pass — our own cadence bounding a + /// *rate* against a persistent state, which is unbounded over time. + var expiredHighWater : Nat; }; public func emptyStore() : Store { { - orders = Map.empty(); - principalsToOrders = Map.empty>(); + orders = Map.empty(); + principalsToOrders = Map.empty(); + counts = Map.empty(); + var promised = 0; + var tallySaturations = 0; + unresolvedProblems = Set.empty(); + promiseHolders = Set.empty(); + var deliveredOrders = 0; + var deliveredCycles = 0; + var deliveredUsdCents = 0; + var deliveredNullPaid = 0; + var expiredHighWater = 0; + }; + }; + + /// Cycles promised to unsettled orders. O(1). + public func promised(store : Store) : Nat { + store.promised; + }; + + /// How many orders still hold a promise. O(1) — `Set.size` is a stored field. + public func promiseHolderCount(store : Store) : Nat { + store.promiseHolders.size(); + }; + + /// The ids of every order that still holds a promise, in id order. + /// + /// ⚠️ **This is the bounded stand-in for a store walk, and callers owe it two + /// disciplines.** *One*: look each id up rather than trusting the index for the + /// order's status — the index is derived state and the order is the record. *Two*: + /// materialise the ids before doing anything that can `await` or transition an order, + /// because a transition removes members and mutating a Set under iteration is not + /// safe. Both are cheap: the set is bounded by the reserve. + public func promiseHolderIds(store : Store) : Iter.Iter { + store.promiseHolders.values(); + }; + + /// Resume iterating the promise holders at or after `id` — the seek a paginated + /// admin query needs, in O(log n) rather than by re-walking. + public func promiseHolderIdsFrom(store : Store, id : Types.OrderId) : Iter.Iter { + store.promiseHolders.valuesFrom(id); + }; + + /// The cumulative delivery figures. O(1) — maintained, never scanned. + public func deliveryTotals(store : Store) : { + orders : Nat; + cycles : Nat; + usdCents : Nat; + nullPaid : Nat; + } { + { + orders = store.deliveredOrders; + cycles = store.deliveredCycles; + usdCents = store.deliveredUsdCents; + nullPaid = store.deliveredNullPaid; + }; + }; + + /// How many orders the store holds in total. O(1), and the only figure derived from + /// lifetime sales that any reconcile path may read — it costs one field read, not a + /// walk. + public func storedCount(store : Store) : Nat { + store.orders.size(); + }; + + /// Statuses whose live counts are maintained. Keyed by `statusToText` so the + /// map is a shared type and the key set is self-documenting. + /// + /// ⚠️ **A status earns an O(1) tally only when something READS it, and the reader is + /// named here** — a justification citing readers that do not exist is how this list + /// drifts. + /// + /// - `#paid` → `Main.sweepableCount`, which short-circuits the recovery sweep at 0. + /// The only tally on a money path. + /// - `#created`, `#expired` → `reserve_status`. Operator metrics, not decisions. + /// - `#needsReview` → `Main.operator_summary`, the one number on that summary that + /// means a human is needed rather than that something is in flight. It was tracked + /// with no reader before that, deliberately: being here is what puts escalated + /// orders under `reconcileBounded`'s drift detection, and an order whose money + /// position a human is establishing is the last one whose bookkeeping should go + /// unchecked. + /// + /// ⚠️ **The admission gate reads NONE of these**, and must not be "optimised" onto + /// `countOf`: its cap is per principal, so a global tally cannot answer it, and + /// switching would silently change the cap's meaning from per-buyer to system-wide. + /// + /// `#cancelled`, `#delivered` and `#abandoned` are absent because nothing counts them. + let trackedStatuses : [Types.OrderStatus] = [#created, #expired, #paid, #needsReview]; + + func isTracked(status : Types.OrderStatus) : Bool { + for (tracked in trackedStatuses.values()) { + if (tracked == status) return true; + }; + false; + }; + + func bump(store : Store, status : Types.OrderStatus, delta : Int) { + if (not isTracked(status)) return; + let key = Types.statusToText(status); + let current = switch (store.counts.get(key)) { case (?n) n; case null 0 }; + let next : Int = current + delta; + // Clamp at zero rather than trapping: a miscount must never be able to + // break the money path, and `recount` is the repair lever. + store.counts.add(key, if (next < 0) 0 else Int.abs(next)); + }; + + /// Live count for a tracked status; 0 for anything untracked. + public func countOf(store : Store, status : Types.OrderStatus) : Nat { + switch (store.counts.get(Types.statusToText(status))) { + case (?n) n; + case null 0; + }; + }; + + /// Every tracked status paired with its current tally, in `trackedStatuses` + /// order — so two snapshots can be compared index-by-index. + func snapshotCounts(store : Store) : [(Text, Nat)] { + let out = List.empty<(Text, Nat)>(); + for (status in trackedStatuses.values()) { + out.add((Types.statusToText(status), countOf(store, status))); + }; + out.toArray(); + }; + + /// A tracked count that did not match the order store: what it said, and what + /// the orders actually add up to. + public type Drift = { status : Text; was : Nat; is : Nat }; + + /// What one bounded reconcile pass found. + /// + /// ⚠️ **Every field is either a repair that was applied or a breach that was + /// refused, and the caller must be able to tell which.** A report that collapsed the + /// two would leave an operator unable to answer the only question that matters — + /// *is the tally I am about to trust correct now?* + public type Reconciliation = { + /// Every tracked count as it stands **after** the pass, in `trackedStatuses` order. + counts : [(Text, Nat)]; + /// Tallies that disagreed and were **raised** to the recount. + adopted : [Drift]; + /// Tallies that disagreed where the recount was **lower**, so it was refused and + /// the maintained value stands. See `adoptOnlyIncreases`. + refused : [Drift]; + /// The promise tally: what it said, what the non-terminal set adds up to, and + /// whether the recount was adopted. Same one-directional rule. + promisedWas : Nat; + promisedIs : Nat; + promisedAdopted : Bool; + /// Members of `promiseHolders` whose order is terminal, or absent from the store. + /// **Dropped by this pass** — reading the order's real status is authoritative, so + /// this repair is not a guess. + staleHolders : [Types.OrderId]; + /// Members of `unresolvedProblems` with no unresolved problem. Dropped, same + /// authority. + staleProblemIds : [Types.OrderId]; + /// `#expired` monotonicity: the previous high water, and the tally now. `is < + /// was` is a breach (see `Store.expiredHighWater`). + expiredWas : Nat; + expiredIs : Nat; + /// `countOf(#expired) + |promiseHolders| > |orders|`, which is arithmetically + /// impossible — the two sets are disjoint subsets of the store. A cheap upper + /// bound on the one tally monotonicity cannot bound from above. + expiredOverflow : Bool; + /// How many orders this pass read. **Bounded by the two index sizes**, never by + /// lifetime sales — that is the whole point of the pass. + ordersRead : Nat; + }; + + /// ⚠️ **The rule that makes an index-derived recount safe to adopt, stated once + /// because three tallies follow it.** + /// + /// A recount taken over `promiseHolders` is **exact if the index is complete and too + /// low otherwise** — never too high, because `reconcileBounded` drops a member whose + /// order it reads as terminal before counting it. So: + /// + /// - recount **>** tally → the tally lost an adjustment. Adopt: every reader of + /// these tallies is safer with the larger value. `promised` higher means + /// `available` lower, so fewer sales are admitted. `#created` and `#paid` higher + /// means the recovery sweeps *run* — both short-circuit on a zero tally, so an + /// under-count is what silently stops money-out and stranded-order recovery. + /// - recount **<** tally → indistinguishable from an incomplete index, so it is + /// **not** evidence. Refuse it and audit; the maintained value stands. + /// + /// ⚠️ **The asymmetry is the safety property, not a convenience.** Adopting a + /// decrease is the only way an index bug could lower `promised` and oversell the + /// reserve, and this rule makes that unreachable: the direction the index can be + /// wrong in is the direction the reconcile will not follow. + /// + /// **A refused decrease has two causes and the daily pass cannot separate them** — + /// either the index is missing a member (the tally is right) or the tally gained an + /// adjustment it should not have (the index is right). Both are bugs in this file and + /// both are the same P2 response, *find the writer*; and `scanChunk` distinguishes + /// them, by naming any order outside the index that holds a promise. Daily detection, + /// rotating attribution. + func adoptOnlyIncreases(was : Nat, is_ : Nat) : Bool { is_ > was }; + + /// Reconcile every maintained tally **without reading history**. + /// + /// **Bounded by the two indexes plus one O(1) size read, so its cost is set by + /// flow rather than by lifetime sales.** The pass it replaced summed every order ever + /// created in a single message; under indefinite retention that is on a path to the + /// instruction limit, and a reconcile that traps leaves the tallies *unverified* + /// rather than known-good. + /// + /// ⚠️ **It still takes no `await`, and that is still load-bearing.** A global sum + /// cannot be chunked across messages — mutations between chunks manufacture false + /// drift — so the answer was never "page it". It was to stop the sums needing history + /// at all. `scanChunk` may be chunked precisely because it evaluates a **per-order + /// predicate** instead of a sum: what it compares is read atomically within one + /// message, so a mutation to an order it has not reached yet changes coverage, never + /// the verdict. That distinction — *sums cannot be chunked, per-order predicates can* + /// — is what splits this file's two mechanisms. + /// + /// Four things are checked, each by the cheapest sound means available: + /// + /// 1. `#created`, `#paid`, `#needsReview` and `promised` are **recounted exactly** + /// over `promiseHolders`. All three statuses are non-terminal, so the index is + /// the whole population. + /// 2. `#expired` — the one tracked terminal status — is checked by **monotonicity** + /// plus a disjointness bound. It is not re-summed, because that needs history. + /// ⚠️ Nothing decides anything on it (`reserve_status.expiredOrders` is an + /// operator metric), which is what makes a non-exact check acceptable *here* and + /// not for the three above. + /// 3. Both indexes are verified in the **inside** direction: every member really + /// satisfies its predicate. Cheap, because the indexes are small. + /// 4. The **outside** direction of both — nothing beyond a set satisfies its + /// predicate — is the only part that genuinely needs every order, and it is + /// `scanChunk`'s job, on a coverage window rather than daily. + public func reconcileBounded(store : Store) : Reconciliation { + var ordersRead = 0; + + // ---- (3) inside direction, and (1) the recount, in one pass ------------------- + // Collect first, mutate after: removing from a Set while iterating it is not safe. + let stale = List.empty(); + let rebuilt = Map.empty(); + var promisedIs = 0; + for (id in store.promiseHolders.values()) { + switch (store.orders.get(id)) { + case (?order) { + ordersRead += 1; + if (Reserve.holdsPromise(order.status)) { + promisedIs += order.lockedCycles; + if (isTracked(order.status)) { + let key = Types.statusToText(order.status); + let seen = switch (rebuilt.get(key)) { case (?n) n; case null 0 }; + rebuilt.add(key, seen + 1); + }; + } else { + // The order's own status is the authority; this member is a leftover. + stale.add(id); + }; + }; + // Unreachable: orders are never deleted. Dropping is still the right + // answer if the impossible happens — an id with no order can contribute + // nothing to a tally, so keeping it would only under-count forever. + case null stale.add(id); + }; + }; + for (id in stale.values()) store.promiseHolders.remove(id); + + let staleProblems = List.empty(); + for (id in store.unresolvedProblems.values()) { + switch (store.orders.get(id)) { + case (?order) { + ordersRead += 1; + if (Problems.unresolvedCount(order.problems) == 0) staleProblems.add(id); + }; + case null staleProblems.add(id); + }; + }; + for (id in staleProblems.values()) store.unresolvedProblems.remove(id); + + // ---- (1) adopt increases only ------------------------------------------------- + let adopted = List.empty(); + let refused = List.empty(); + for (status in trackedStatuses.values()) { + // `#expired` is terminal, so `promiseHolders` says nothing about it — case (2). + if (Reserve.holdsPromise(status)) { + let key = Types.statusToText(status); + let was = countOf(store, status); + let is_ = switch (rebuilt.get(key)) { case (?n) n; case null 0 }; + if (was != is_) { + let drift = { status = key; was; is = is_ }; + if (adoptOnlyIncreases(was, is_)) { + store.counts.add(key, is_); + adopted.add(drift); + } else { refused.add(drift) }; + }; + }; + }; + + let promisedWas = store.promised; + let promisedAdopted = adoptOnlyIncreases(promisedWas, promisedIs); + if (promisedAdopted) store.promised := promisedIs; + + // ---- (2) `#expired`: monotonicity and disjointness ---------------------------- + let expiredIs = countOf(store, #expired); + let expiredWas = store.expiredHighWater; + // Follows the tally down after a decrease so the breach is reported once, not + // daily until someone fixes it. + store.expiredHighWater := expiredIs; + // `#expired` orders and `promiseHolders` are disjoint by construction — the first + // is terminal, the second is not — and both are subsets of the store, so their + // sizes cannot exceed it. `Map.size`/`Set.size` are O(1), so this costs nothing. + let expiredOverflow = expiredIs + store.promiseHolders.size() > store.orders.size(); + + { + counts = snapshotCounts(store); + adopted = adopted.toArray(); + refused = refused.toArray(); + promisedWas; + promisedIs; + promisedAdopted; + staleHolders = stale.toArray(); + staleProblemIds = staleProblems.toArray(); + expiredWas; + expiredIs; + expiredOverflow; + ordersRead; + }; + }; + + /// One bounded chunk of the rotating pass that verifies the **outside** direction of + /// both indexes: that nothing beyond a set satisfies the set's predicate. + /// + /// ⚠️ **This is the only check that needs every order, so it is the only one with a + /// coverage window instead of a daily guarantee.** What it reports must therefore name + /// that window — a passing check over a subset is not evidence about the rest, which + /// is the truncated-gate-run fault in a different substrate. The caller owns the + /// cursor and the reporting; this function owns one chunk. + /// + /// **Chunking is sound here and is not for the reconcile, for a reason worth + /// keeping straight.** Each verdict is a **per-order predicate** — the order's own + /// status or problems against its membership — read within one message, so it is + /// evaluated against a consistent view no matter what happens to other orders + /// between chunks. A mutation to an order this chunk has not reached changes *which + /// cycle covers it*, never whether a verdict was right. A global sum has no such + /// property, which is why `reconcileBounded` stays in one message. + /// + /// ⚠️ **It repairs, and the repair is authoritative rather than a guess**: it has + /// read the order, and the order is the record. Adding a missing member also feeds the + /// fix through to the tallies without this function touching them — the next + /// `reconcileBounded` recounts a larger index, and a larger recount is exactly what + /// `adoptOnlyIncreases` adopts. + public type ScanChunk = { + /// Orders visited by this chunk. + visited : Nat; + /// Orders that hold a promise and were **not** in `promiseHolders` — added here. + /// + /// ⚠️ **This is the money-critical finding of the whole scan.** It is the one + /// inconsistency the daily pass cannot see: a member missing from the index while + /// `promised` is missing its cycles too, so index and tally agree and are both + /// low — the reserve reads as more available than it is. + unindexedHolders : [Types.OrderId]; + /// Orders with an unresolved problem that were **not** in `unresolvedProblems` — + /// added here. The obligation existed and the worklist did not show it. + unindexedProblems : [Types.OrderId]; + /// Where the next chunk resumes. **`null` means the cycle completed** — every + /// order that existed when the cycle began has now been visited. + nextCursor : ?Types.OrderId; + }; + + /// Orders per chunk. Sized so a chunk is a small fraction of one message's + /// instruction budget: the work per order is a status read, a problems-array length, + /// and two O(log n) set lookups. + public let scanChunkSize : Nat = 2_000; + + public func scanChunk(store : Store, from : ?Types.OrderId, limit : Nat) : ScanChunk { + let capped = if (limit == 0 or limit > scanChunkSize) scanChunkSize else limit; + let holders = List.empty(); + let problems = List.empty(); + var visited = 0; + var last : ?Types.OrderId = null; + var exhausted = true; + // `entriesFrom` seeks in O(log n) and is INCLUSIVE of the key, so the cursor — the + // last id already visited — is skipped explicitly. Seeking by re-walking from the + // start would make every chunk cost what the whole pass was supposed to stop + // costing, which is how a "bounded" scan quietly is not one. + let it = switch (from) { + case (?cursor) store.orders.entriesFrom(cursor); + case null store.orders.entries(); + }; + // ⚠️ **`break`, not a flag.** Letting the loop run on after the chunk fills would + // read every remaining order to do nothing with it — the bound would be on what is + // reported, not on what is touched, which is the same conflation of *response size* + // with *work per message*. + label chunk for ((id, order) in it) { + let isCursor = switch (from) { case (?cursor) id == cursor; case null false }; + if (not isCursor) { + if (visited == capped) { exhausted := false; break chunk }; + visited += 1; + last := ?id; + if (Reserve.holdsPromise(order.status) and not store.promiseHolders.contains(id)) { + holders.add(id); + }; + if (Problems.unresolvedCount(order.problems) > 0 and not store.unresolvedProblems.contains(id)) { + problems.add(id); + }; + }; + }; + // ⚠️ **Applied on BOTH exits.** An earlier draft returned from inside the loop when + // the chunk filled and skipped these two lines — so the one repair the scan exists + // to perform was silently dropped on every chunk except the last of a cycle. + for (id in holders.values()) store.promiseHolders.add(id); + for (id in problems.values()) store.unresolvedProblems.add(id); + { + visited; + unindexedHolders = holders.toArray(); + unindexedProblems = problems.toArray(); + nextCursor = if (exhausted) null else last; + }; + }; + + /// Which shape a buyer's cancel takes, given the order's status. + /// + /// ⚠️ **Extracted from `cancel_order` so the whole status space is checked in one + /// place.** Only `#created` proceeds; every other status has a different + /// answer, and the difference matters to a buyer — an expired order was never charged, + /// a paid one will deliver. Encoding it in the endpoint as a `switch` + /// whose completeness could only be tested by driving seven statuses through PocketIC. + /// + /// The wording stays in the endpoint: a buyer reads `cancel_order`'s error verbatim and + /// §4.3 puts that sentence in the backend deliberately. This decides WHICH answer, + /// not how it reads. + public type CancelShape = { + /// Expire the session, then transition. The only shape that changes anything. + #proceed; + /// Idempotent: the buyer already gave up on this one. + #alreadyCancelled; + /// ⚠️ Distinct from `#notCancellable` because the reason is the opposite one: + /// nothing was charged and nothing will deliver. A stale tab reaches this. + #alreadyExpired; + /// Money has moved or is moving. Carries the status so the answer can name it. + #notCancellable : Types.OrderStatus; + }; + + /// ⚠️ **`#created` is the only cancellable status, and that is a money property.** + /// `#paid`, `#delivered` and `#needsReview` all hold or have spent cycles; `#abandoned` + /// is the operator's terminal decision. Letting a buyer cancel any of them would either + /// release a promise against cycles that moved, or contradict a refund made by hand. + /// The transition matrix refuses those independently — this classification exists so a + /// buyer gets an answer that explains which case they are in rather than a bare refusal. + public func cancelShape(status : Types.OrderStatus) : CancelShape { + switch (status) { + case (#created) #proceed; + case (#cancelled) #alreadyCancelled; + case (#expired) #alreadyExpired; + case (#paid or #delivered or #needsReview or #abandoned) #notCancellable(status); }; }; @@ -85,30 +660,102 @@ module { #illegalTransition : { from : Types.OrderStatus; to : Types.OrderStatus }; }; - /// The §4 diagram plus the two error-queue edges it implies: - /// `#minting → #errorQueue` (stale intent without a block_index, §5.1) and - /// `#icpAtCmc → #errorQueue` (failed forward → Type 2, §4.1/§5). - /// `#delivered` and `#errorQueue` are terminal — error-queue resolution is - /// human/off-chain on the queue entry (§4.1), not an order transition. + /// Why `expire_order` refused. Operator meaning and action per arm: §7.1. The three Stripe arms are distinct because they + /// demand different operator actions: `#sessionNotOpen` says wait, `#stripeUnauthorized` + /// says rotate the key, `#stripeFailed` says read the detail. + /// + /// ⚠️ **`#stripeFailed.detail` stays `Text` on purpose.** It is Stripe's own wording, + /// which this codebase deliberately does not paraphrase — an external system owns that + /// string, which is the case T1 leaves alone. + public type ExpireError = { + #notFound : Types.OrderId; + /// Only a `#created` order can be expired; `#expired` is idempotent and returns `#ok`. + #notCreated : { id : Types.OrderId; status : Types.OrderStatus }; + /// Stripe says the session is no longer open — completed or already expired, and + /// those demand opposite actions, so nothing is changed here. + #sessionNotOpen : Types.OrderId; + /// 401/403. The restricted key needs WRITE on Checkout Sessions; an operator has + /// been notified through the latch. + #stripeUnauthorized : Types.OrderId; + #stripeFailed : { id : Types.OrderId; detail : Text }; + /// A second tab settled the order while the outcall was in flight. Nothing changed. + #movedInFlight : { id : Types.OrderId; status : Types.OrderStatus }; + }; + + /// Why `abandon_order` refused. Operator meaning and action per arm: §7.1. + public type AbandonError = { + #notFound : Types.OrderId; + /// Only a paid or under-review order can be abandoned. + #notAbandonable : { id : Types.OrderId; status : Types.OrderStatus }; + /// ⚠️ **A wait, not a refusal.** The transfer is outstanding, so whether the cycles + /// moved is unknown; abandoning now would refund a buyer who may already hold them. + /// The ~24 h dedup fuse escalates it to `#needsReview`, where abandoning is allowed. + #deliveryOutstanding : Types.OrderId; + /// The audit trail must record *why*, alongside *who*. + #reasonRequired; + #transitionRefused : Types.OrderId; + }; + + /// Why `record_delivered` refused. Operator meaning and action per arm: §7.1. + public type RecordDeliveredError = { + #notFound : Types.OrderId; + /// Only an under-review order can be recorded as delivered — a live order delivers + /// on its own. `#delivered` is idempotent and returns `#ok`. + #notUnderReview : { id : Types.OrderId; status : Types.OrderStatus }; + #transitionRefused : Types.OrderId; + }; + + /// The §4 diagram plus the escalation edges it implies, all of which land in + /// `#needsReview` — the order still owes cycles, so its promise stays held. + /// + /// **The terminal set is `#delivered`, `#cancelled`, `#expired`, `#abandoned`.** + /// Resolving an orphan *entry* is human and off-chain (§4.1) and never + /// transitions an order; `abandon_order` is the only thing that ends one, and + /// `#needsReview → #abandoned` is its single outgoing edge. + /// + /// ⚠️ **A guard mirrors this matrix and the compiler does not check it**: + /// `Card.handleWebhook`'s status switch, which feeds `markPaid`, so anything it + /// admits that this refuses is a bug — and on that path a trap, i.e. a 5xx + /// Stripe retries for ~3 days. ⚠️ **Change this and check `markPaid`'s CALLERS**, + /// not the trap: the compiler does not check that the two agree. public func isLegalTransition(from : Types.OrderStatus, to : Types.OrderStatus) : Bool { switch (from, to) { - case (#created, #expired) true; // never paid; advisory only (§4) + case (#created, #cancelled) true; // the buyer gave up before paying + case (#created, #expired) true; // never paid (§4) case (#created, #paid) true; // webhook verified, deduped, amount honored - case (#expired, #paid) true; // late real payment still honored (§4) - case (#paid, #minting) true; // ICP float sufficient - case (#paid, #awaitingTreasury) true; // float short (§5.3) - case (#awaitingTreasury, #minting) true; // float refilled - case (#awaitingTreasury, #errorQueue) true; // max-wait exceeded (§5.3) - case (#minting, #icpAtCmc) true; // block_index recorded (§5) - case (#minting, #errorQueue) true; // intent aged past dedup window (§5.1) - case (#icpAtCmc, #delivered) true; // notify + forward succeeded - case (#icpAtCmc, #errorQueue) true; // forward failed → Type 2 (§4.1) + // Delivery is ONE transfer out of the cycles reserve, so this single edge is + // the whole money-out path. ⚠️ **Deleting it fails silently in the worst + // direction:** `tryTransition` returns null *after* the transfer has landed, so + // the buyer holds their cycles and the order sits `#paid` forever, looking + // undelivered to every sweep that comes past. + case (#paid, #delivered) true; + case (#paid, #needsReview) true; // paid, undelivered past max wait (§5) + // The operator read the ledger and the transfer HAD landed. + // + // ⚠️ **Added because its absence made the operator record a lie.** Until this + // edge existed, `#needsReview`'s only exit was `#abandoned`, so an escalated + // order whose cycles the buyer demonstrably has could only be filed as + // abandoned — auditing a refund that never happened. Money-correct, + // record-wrong, and the record is what this codebase trusts to keep illegal + // states unrepresentable. + // + // Not a routine path: `#needsReview` means the money position is unknown, and + // reaching it at all takes a ~24 h cycles-ledger outage. Only `record_delivered` + // drives it, admin-only, and it demands the ledger block as evidence — no + // automatic route to `#delivered` from an unknown position exists, because that + // is the double-delivery this status prevents. + case (#needsReview, #delivered) true; + // `abandon_order` — the operator ends it, having refunded by hand. The + // `#needsReview` edge exists because that status holds its promise, so + // abandoning it is what releases it. + case (#paid, #abandoned) true; + case (#needsReview, #abandoned) true; case _ false; }; }; /// Pure transition: legal → updated copy, illegal → error. Callers decide - /// *when* to ask (expiry timers, float checks); this decides *whether*. + /// *when* to ask (expiry timers, solvency checks); this decides *whether*. public func transition( order : Types.Order, to : Types.OrderStatus, @@ -144,15 +791,37 @@ module { lockedCycles; pricing; status = #created; + paidUsdCents = null; + // All four are the session's, and no session exists yet: they are stamped + // from the Checkout Session the create path makes. `expiredBy` stays null unless the + // order reaches `#expired` with a known cause. + expiredBy = null; + expiresAtNs = null; + stripeSessionId = null; + stripeSessionUrl = null; + delayedAtNs = null; + abandonedReason = null; + problems = []; createdAtNs = nowNs; updatedAtNs = nowNs; }; store.orders.add(id, order); + bump(store, #created, 1); + // The non-terminal index, entered here for the same reason `promised` is — + // an order APPEARS in the held set rather than transitioning into it. Expressed + // through `holdsPromise` rather than as "`#created` always holds" so that this + // site and `commitTransition` share one definition of membership. + if (Reserve.holdsPromise(order.status)) store.promiseHolders.add(id); + // The hold at creation. ⚠️ **Not a transition** — an order appears in the counted + // set rather than moving into it — so it lives outside the transition machinery + // entirely, and `create` is an adjustment site in its own right. Missing this is + // the one leak a recount cannot attribute to a status change. + store.promised += lockedCycles; let principal = switch (owner) { case (#ii(p)) p }; switch (store.principalsToOrders.get(principal)) { case (?ids) ids.add(id); case null { - let ids = List.empty(); + let ids = Set.empty(); ids.add(id); store.principalsToOrders.add(principal, ids); }; @@ -160,7 +829,84 @@ module { #ok(order); }; - /// Look up, validate, and persist a transition in one step. + /// ⚠️ **The ONLY way a status reaches the store.** Writes the record, both per-status + /// counters, the promise tally and the non-terminal index, in one place — so a new + /// writer cannot forget them, because writing a status *is* calling this. + /// + /// A comment naming "the only three writers" was once false **in the same file**: two + /// more wrote status with hand-rolled tally updates, and one of them is the most common + /// release in the design (every unpaid order expires). Every expired order would have + /// left its `lockedCycles` in the tally forever, ratcheting `available` down until the + /// rail closed on a full reserve. Proximity was not enough; the invariant is structural + /// now. (`attachSession` legitimately does not call this — it changes no status.) + /// + /// ⚠️ **Returns the order AS STORED, and callers must hand that back** rather than the + /// value they passed in: this mutates the order, not just the tallies, so the two can + /// disagree. + func commitTransition(store : Store, before : Types.Order, after : Types.Order) : Types.Order { + // ⚠️ **Clear the pay link on the way into a terminal state, HERE and not at + // each terminal site.** It is by far the largest field on an order — a Stripe + // checkout URL runs to a couple of hundred characters — and it is worthless + // thirty minutes after creation, so dropping it roughly halves the long-term size + // of an order under indefinite retention. + // + // Structural for the same reason the tally is: a future terminal path added + // elsewhere cannot forget, because writing a status *is* calling this. Doing it at + // the call sites would work today and silently stop working on the next one. + // + // Safe against every reader: the recovery sweep reads `stripeSessionId`, not the + // url, and the frontend only offers the pay link while the order is `#created`. A + // terminal order should not be carrying a payable link at all. + // ⚠️ **`Reserve.holdsPromise` is the authority on terminality, so this reuses it + // rather than listing the statuses again.** Its own comment reads "terminal, and + // nothing else is" — a second list here would be a place for the two to disagree, + // which is how `#needsReview` (promise still held, not terminal) would eventually + // get its pay link dropped by one of them and not the other. + let settled = if (Reserve.holdsPromise(after.status)) { after } else { + { after with stripeSessionUrl = null }; + }; + store.orders.add(settled.id, settled); + bump(store, before.status, -1); + bump(store, after.status, 1); + // The non-terminal index moves with the promise tally, on the same predicate, + // in the same function — so a seventh status writer cannot update one and forget + // the other. Unconditional rather than delta-driven: `add` and `remove` are both + // idempotent, so a within-set transition (`#created → #paid`) is a no-op without + // needing to ask whether it was one. + if (Reserve.holdsPromise(after.status)) { + store.promiseHolders.add(settled.id); + } else { + store.promiseHolders.remove(settled.id); + }; + // Zero when the transition stays inside the counted set — `#created → #paid` + // is the live example — and called anyway, so no writer is a special case. + let moved = Reserve.applyDelta( + store.promised, + Reserve.tallyDelta(before.status, after.status), + before.lockedCycles, + ); + store.promised := moved.total; + // ⚠️ Surfaced, not swallowed. Saturation means the tally was ALREADY wrong + // before this order reached here, and a silent zero is indistinguishable from + // an exact release — the first evidence would otherwise be the daily recount, + // up to 24 h of a wrong tally gating real sales. `Main` audits this. + if (moved.saturated) store.tallySaturations += 1; + // The cumulative delivery figures, counted HERE for the reason at the fields: + // six sites move an order to `#delivered`, and `#delivered` has no outbound edge, so + // this is the only place the count is both unforgettable and unable to double. + if (after.status == #delivered) { + store.deliveredOrders += 1; + store.deliveredCycles += before.lockedCycles; + switch (before.paidUsdCents) { + case (?cents) store.deliveredUsdCents += cents; + // Unreachable: `markPaid` sets this before any delivery. Recorded rather than + // ignored, so an understated public total has a matching counter to explain it. + case null store.deliveredNullPaid += 1; + }; + }; + settled; + }; + public func applyTransition( store : Store, id : Types.OrderId, @@ -172,8 +918,14 @@ module { case (?order) { switch (transition(order, to, nowNs)) { case (#ok(updated)) { - store.orders.add(id, updated); - #ok(updated); + // The tally moves only when a transition ACTUALLY succeeds. + // That is where idempotency comes from and why no extra guard is + // needed anywhere — `transition` refuses an illegal edge, so a + // redelivered `checkout.session.expired` for an already-cancelled + // order, a sweep racing a webhook, or a delivery retry answering + // `#Duplicate` after the transition already committed all no-op here + // rather than double-releasing. + #ok(commitTransition(store, order, updated)); }; case (#err(e)) #err(e); }; @@ -181,14 +933,595 @@ module { }; }; - /// Webhook money-in (§6.1): `#created`/`#expired` → `#paid`, honoring the - /// **actual** paid amount — `lockedCycles` is replaced by `honoredCycles` - /// (equal to the original when the paid amount matches the quoted tier; - /// repriced from the order's `pricing` snapshot when it doesn't). + /// Attach a created Checkout Session to an order. + /// + /// ⚠️ **Refuses unless the order is still `#created`, and that refusal is the + /// point.** `create_order` commits the order and *then* awaits the outcall, so + /// another ingress message can interleave — specifically `cancel_order` from a + /// second tab, whose sessionless branch fires because no session id exists yet. + /// Storing the URL anyway would hand the buyer a **payable link for an order + /// they were told was cancelled**. Money-safe (the matrix rejects + /// `#cancelled → #paid`, so a payment lands as a refundable obligation) but it + /// recreates the exact "told cancelled, tab still charges" wart that making + /// cancellation atomic exists to eliminate. + /// + /// `expiresAtNs` is Stripe's own deadline and is stamped **once** — there is no + /// session retry, so nothing ever re-stamps it. + public func attachSession( + store : Store, + id : Types.OrderId, + sessionId : Text, + sessionUrl : Text, + expiresAtNs : Int, + nowNs : Int, + ) : Result.Result { + switch (store.orders.get(id)) { + case null #err(#notFound(id)); + case (?order) { + if (order.status != #created) { + // Not a transition failure in the matrix sense — the order is fine, it + // just is not the order this session was created for any more. Reported + // as an illegal `#created` transition so the caller has one shape to + // handle and the audit line names the status it actually found. + return #err(#illegalTransition({ from = order.status; to = #created })); + }; + let attached = { + order with + stripeSessionId = ?sessionId; + stripeSessionUrl = ?sessionUrl; + expiresAtNs = ?expiresAtNs; + updatedAtNs = nowNs; + }; + store.orders.add(id, attached); + // No `bump`: the status did not change, so the tallies are untouched. + #ok(attached); + }; + }; + }; + + /// `#created → #expired` because **Stripe closed the session**, with the + /// session id backfilled if the order did not have one. + /// + /// The backfill is what lets a residue order heal: if the session-create + /// response was lost (a trap or upgrade between the order commit and the + /// continuation), the order holds no session id — and this event, arriving ~30 + /// minutes later, is the thing that tells it which session it had. + /// Settle an order whose session Stripe says is dead, attributing it to whoever + /// actually caused it. + /// + /// ⚠️ **The buyer's intent decides the attribution, not who wins the race.** + /// `cancel_order` expires the session at Stripe BEFORE recording the cancel — nothing + /// is ever half-cancelled — so between those two steps Stripe's + /// honest answer to anyone asking is "expired" — and the `checkout.session.expired` + /// webhook it fires arrives in that window. Three different writers could reach the + /// order first: that webhook, the recovery sweep, and the admin expire. + /// + /// Whoever arrives, the state must be the same. So the caller passes the set of + /// orders whose OWNER has asked to cancel, and this function reads it: a requested + /// cancel settles as `#cancelled` with no `expiredBy`, anything else as `#expired` + /// with its cause. `cancel_order`'s own transition then finds the order already + /// `#cancelled` and returns success through its idempotent branch. + /// + /// ⚠️ **One owner for the decision, deliberately.** An earlier fix guarded only the + /// sweep, from a transient set the webhook's module could not see, and the webhook + /// went on winning. A guard that each writer has to remember is a guard the next + /// writer forgets; this is the only place `#expired` gets chosen over `#cancelled`. + public func settleUnpayable( + store : Store, + requestedCancels : Set.Set, + id : Types.OrderId, + cause : Types.ExpiredBy, + nowNs : Int, + ) : Result.Result { + switch (store.orders.get(id)) { + case null #err(#notFound(id)); + case (?order) { + if (requestedCancels.contains(id)) { + // The owner asked for this and Stripe has confirmed the session is dead, so + // it is provably unpayable AND the buyer's own decision. `expiredBy` stays + // null: nothing expired, someone cancelled. + switch (transition(order, #cancelled, nowNs)) { + case (#ok(updated)) { + // Pruned here rather than by the caller: the intent has been honoured, so + // holding it would let a LATER expiry of a re-used id read as a cancel. + requestedCancels.remove(id); + #ok(commitTransition(store, order, updated)); + }; + case (#err(e)) #err(e); + }; + } else { + expireWithCause(store, id, cause, nowNs); + }; + }; + }; + }; + + /// ⚠️ **Takes `requestedCancels`, and that parameter is the fix.** This is the + /// `checkout.session.expired` WEBHOOK's path, and it is the writer that actually wins + /// the race with `cancel_order`: our own expire call makes Stripe fire this event, and + /// it arrives before the cancel is recorded. An earlier attempt routed the recovery + /// sweep and the admin expire through the attribution and left this one calling + /// straight through, so the buyer's cancellation went on being recorded as + /// `#sessionExpired` — the same mistake twice, because the fix was applied to the two + /// paths that were not the problem. + /// + /// A required parameter rather than an optional one on purpose: a caller cannot reach + /// this function without deciding what it knows about the buyer's intent. + public func expireBySession( + store : Store, + requestedCancels : Set.Set, + id : Types.OrderId, + sessionId : Text, + nowNs : Int, + ) : Result.Result { + switch (store.orders.get(id)) { + case null #err(#notFound(id)); + case (?order) { + if (requestedCancels.contains(id)) { + // The owner asked to cancel and Stripe has now confirmed the session is dead + // — which is what the cancel itself caused. Attribute it to them. + switch (transition(order, #cancelled, nowNs)) { + case (#ok(updated)) { + requestedCancels.remove(id); + #ok(commitTransition(store, order, { updated with stripeSessionId = ?sessionId })); + }; + case (#err(e)) #err(e); + }; + } else { + switch (transition(order, #expired, nowNs)) { + case (#ok(updated)) { + let expired = { + updated with + expiredBy = ?(#sessionExpired : Types.ExpiredBy); + stripeSessionId = ?sessionId; + }; + // Release point 1, and the most common one: Stripe says the + // session died unpaid, so every unpaid order releases here. + #ok(commitTransition(store, order, expired)); + }; + case (#err(e)) #err(e); + }; + }; + }; + }; + }; + + /// `#created → #expired` **with a recorded cause** in `expiredBy`. + /// + /// Routed through `transition`, never a direct status write, for two protections + /// at once: the matrix no-ops `#cancelled → #expired` (a second tab may have + /// cancelled while the outcall was in flight), and the tallies stay coupled to + /// the status change. A direct write would bypass both — and on the reserve path it + /// would release an already-released promise. + /// Record that this order has crossed `alertAfterNs` while waiting to deliver. + /// + /// Returns true only on the **first** crossing, so the caller can announce once + /// without keeping any state of its own — which is what retires the + /// `delayedAlerts` map rather than relocating it. + /// + /// ⚠️ **`updatedAtNs` is deliberately left alone.** It is the held-since clock + /// `Delivery.waitStage` reads; moving it here would reset the wait being recorded, + /// and an order that keeps resetting its own clock can never reach `maxHoldNs` — + /// it would be alerted about forever and escalated never. + /// Move an order to `#abandoned` AND record why, in one step. + /// + /// ⚠️ **One function, so the status and the reason cannot diverge.** They are two + /// halves of one operator decision; a caller that transitioned and then set the + /// reason separately could leave an `#abandoned` order with no explanation, which + /// is precisely the gap the dropped `#abandoned` queue entry used to paper over. + public func abandonWithReason( + store : Store, + id : Types.OrderId, + reason : Text, + nowNs : Int, + ) : Result.Result { + switch (applyTransition(store, id, #abandoned, nowNs)) { + case (#err(e)) #err(e); + case (#ok(order)) { + let withReason = { order with abandonedReason = ?reason }; + store.orders.add(id, withReason); + #ok(withReason); + }; + }; + }; + + // ── Problems on the order ──────────────────────────────────────────── + + /// File a problem on an order. Returns false if the order is gone, or if an + /// unresolved problem of the same shape is already there. + /// + /// ⚠️ **Dedup lives in `Problems.file`, and it needs no second structure.** The + /// order carries its own answer to "have I already filed this", which is what the + /// `delayedAlerts` map used to do for one kind — badly, because a separate map can + /// fall out of step with the orders it points at, and leaking one was a real + /// failure mode. + public func fileProblem( + store : Store, + id : Types.OrderId, + kind : Types.ProblemKind, + detail : Text, + nowNs : Int, + ) : Bool { + let ?order = store.orders.get(id) else return false; + let result = Problems.file(order.problems, kind, detail, nowNs); + let updated = result.problems; + // ⚠️ `updatedAtNs` is deliberately untouched: it is the held-since clock + // `Delivery.waitStage` reads, and filing a problem is not a state transition. + store.orders.add(id, { order with problems = updated }); + store.unresolvedProblems.add(id); + // ⚠️ **False on a refresh, and that is what the callers want.** A refreshed + // problem must not re-audit: the audit line marks the transition into trouble, + // not every observation of it. The *payload* is still updated, so an operator + // reading the order sees the current figure — which is why this returns "was it + // newly filed" rather than "did anything change". + result.filed; + }; + + /// Resolve every unresolved problem on one order matching `pred`. + public func resolveProblems( + store : Store, + id : Types.OrderId, + pred : Types.ProblemKind -> Bool, + nowNs : Int, + ) : Nat { + let ?order = store.orders.get(id) else return 0; + let result = Problems.resolveWhere(order.problems, pred, nowNs); + if (result.closed == 0) return 0; + store.orders.add(id, { order with problems = result.problems }); + // Leaves the index the moment nothing is outstanding — the resolved problems + // stay on the order, because nothing drops; only the worklist shrinks. + if (Problems.unresolvedCount(result.problems) == 0) { + store.unresolvedProblems.remove(id); + }; + result.closed; + }; + + /// Every unresolved problem on this order matching `kindTag`, and their identifying + /// references — what an operator needs to disambiguate before closing one. + public func unresolvedOfKind( + store : Store, + id : Types.OrderId, + tag : Types.ProblemKindTag, + ) : [{ kind : Types.ProblemKind; detail : Text; ref : ?Text }] { + let ?order = store.orders.get(id) else return []; + let out = List.empty<{ kind : Types.ProblemKind; detail : Text; ref : ?Text }>(); + for (p in order.problems.values()) { + // ⚠️ Compared as TAGS, not as rendered text. The old + // `kindToText(p.kind) == kindTag` could never be wrong about a kind it knew, but + // it also could not be wrong about one it did not — a fifth `ProblemKind` would + // have compared false here and reported "nothing to resolve". + if (Problems.isUnresolved(p) and Problems.tagOf(p.kind) == tag) { + out.add({ kind = p.kind; detail = p.detail; ref = Problems.identifyingRef(p.kind) }); + }; + }; + out.toArray(); + }; + + /// Close every problem a `charge.refunded` for `paymentRef` settles on its own. + /// + /// ⚠️ **Walks `unresolvedProblems`, NOT the order store.** This runs synchronously + /// inside the `charge.refunded` handler, where a trap is a 5xx Stripe retries for + /// three days — so it must not be O(every order ever created). The set is small and + /// attacker-priced: every problem in it required a real payment event. + /// + /// ⚠️ **Only kinds where `paymentRefOf` gives a match are touched**, so + /// `#refundAfterDelivery` and `#paidNotCredited` are never closed here even though + /// both carry a `paymentRef`. + public func resolveByPaymentRef(store : Store, paymentRef : Text, nowNs : Int) : Nat { + var closed = 0; + // Snapshot the ids: `resolveProblems` mutates the set we would otherwise be + // iterating. + let candidates = List.empty(); + for (id in store.unresolvedProblems.values()) candidates.add(id); + for (id in candidates.values()) { + closed += resolveProblems( + store, + id, + func(k) { + Problems.refundResolvable(k) and Problems.paymentRefOf(k) == ?paymentRef; + }, + nowNs, + ); + }; + closed; + }; + + // ── Filtered, cursor-paginated reads ───────────────────────────────── + + /// Cap on one page, matching `Orphans.maxPageSize` so an operator learns one number. + public let maxPageSize : Nat = 200; + + /// What an operator narrows by. Every field is optional and they AND together. + /// + /// ⚠️ **`withUnresolvedProblems` is folded in here rather than living as its own + /// query.** "Everything outstanding" is a + /// *filter* over orders, so it composes with status and time range instead of being a + /// parallel list that answers a different question. + /// One page of promise-holding orders that `keep` accepts, in id order. + /// + /// The boundary rules are the reason this is not inline: `nextCursor` is the last + /// KEPT id (not the last scanned), it is null when the scan runs out, and `limit == 0` + /// or an over-large limit means `maxPageSize`. `test/orders.test.mo` pins each. + public func holderPage( + store : Store, + afterId : ?Types.OrderId, + limit : Nat, + keep : (Types.Order) -> ?T, + ) : { items : [T]; nextCursor : ?Types.OrderId } { + let capped = if (limit == 0 or limit > maxPageSize) maxPageSize else limit; + let collected = List.empty(); + var last : ?Types.OrderId = null; + let ids = switch (afterId) { + case (?cursor) promiseHolderIdsFrom(store, cursor); + case null promiseHolderIds(store); + }; + label scan for (id in ids) { + let ?order = get(store, id) else continue scan; + let past = switch (afterId) { case (?cursor) id > cursor; case null true }; + if (not past) continue scan; + let ?item = keep(order) else continue scan; + if (collected.size() == capped) { + return { items = collected.toArray(); nextCursor = last }; + }; + collected.add(item); + last := ?order.id; + }; + { items = collected.toArray(); nextCursor = null }; + }; + + public type Filter = { + status : ?Types.OrderStatus; + /// Matches `Owner`'s principal. + owner : ?Principal; + createdFromNs : ?Int; + createdToNs : ?Int; + withUnresolvedProblems : Bool; + }; + + /// ⚠️ **Ordered by order ID, which is NOT time order — and that is deliberate.** + /// Order ids are random hex, so id order is arbitrary. Sorting the filtered set by + /// `createdAtNs` would mean materialising it first, which is an unbounded scan; + /// a secondary time index would be one more piece of derived state + /// needing adjudication. So the traversal is **stable and complete** rather than + /// recent-first, and an operator who wants recency narrows with `createdFromNs` + /// instead. Saying so here is the point: a caller that assumes newest-first gets a + /// wrong answer silently. + /// + /// Cursor-based, like `Orphans.page`, and for a stronger reason: ids are never reused + /// and orders are never deleted, so a cursor cannot be invalidated at all. + /// + /// `nextCursor` is set **only when further matching orders remain**, so a caller + /// stops the moment it is null and never makes a wasted final request. + public type Page = { orders : [Types.Order]; nextCursor : ?Types.OrderId }; + + public func page( + store : Store, + filter : Filter, + afterId : ?Types.OrderId, + limit : Nat, + ) : Page { + let capped = if (limit == 0 or limit > maxPageSize) maxPageSize else limit; + let collected = List.empty(); + var last : ?Types.OrderId = null; + // O(log n) seek to the cursor rather than re-walking the store to skip past it. + // `entriesFrom` is inclusive, so the `id > cursor` test below still does the + // skipping; this only removes the wasted prefix. + // + // ⚠️ **This bounds the RESUME, not the page.** A selective `filter` still + // walks until it fills a page, so a status filter matching few orders out of many is + // O(store) in one message, and an owner filter walks every principal's orders to + // find one principal's. The reconcile and the timer scans are bounded; this is not. + // Do not read a paged query as a bounded one — the page caps the ~2 MB response, and + // the limit this hits is instructions per message. + let it = switch (afterId) { + case (?cursor) store.orders.entriesFrom(cursor); + case null store.orders.entries(); + }; + for ((id, order) in it) { + let past = switch (afterId) { case (?cursor) id > cursor; case null true }; + if (past and matchesFilter(order, filter)) { + if (collected.size() == capped) return { orders = collected.toArray(); nextCursor = last }; + collected.add(order); + last := ?id; + }; + }; + { orders = collected.toArray(); nextCursor = null }; + }; + + /// One buyer's own orders, paged, walking **their** index rather than the whole store. + /// + /// ⚠️ **`Orders.page` bounds the RESUME, not the work.** Its owner filter seeks to the + /// cursor in the global store and then walks every principal's orders looking for one + /// principal's, so a buyer's history page costs O(all orders ever created) in a single + /// message. A query's instruction budget is well below an update's, so that cliff + /// arrives sooner than the equivalent on an update path — and when it arrives, an + /// ordinary buyer's history page traps. The failure is availability on a routine + /// action, not a drain: both callers are queries, free to the caller and off consensus. + /// + /// **Returns `scanned` because there is no instruction counter a query can read.** + /// "The work does not grow with other principals' orders" is otherwise unassertable, + /// and the half that stays assertable — that the rows are right — passes for a + /// function returning nothing. `Main.list_orders` calls THIS and projects the page + /// out; a separate uninstrumented path would put the bound on code nobody runs. + /// + /// ⚠️ Two invariants live in the loop below, and they are what a cursor walk gets + /// wrong. Neither follows from the key type: + /// + /// 1. **The seek is inclusive**, so `id > cursor` still does the skipping. + /// 2. **`last` is the last id RETURNED, never one merely scanned past.** A cursor + /// taken from a scanned id silently skips every id between it and the last row + /// actually handed out — a buyer's missing orders, with no error anywhere. + /// + /// `scanned <= limit + 2`, and the three terms are: at most one id skipped by the + /// inclusive seek, `limit` rows collected, and one lookahead that finds the page full. + /// The lookahead is what makes `nextCursor = null` mean "no more" rather than "ask + /// again and get nothing", which is the semantics `admin_orders` already has. + public func ownerPage( + store : Store, + owner : Principal, + afterId : ?Types.OrderId, + limit : Nat, + ) : { page : Page; scanned : Nat } { + let capped = if (limit == 0 or limit > maxPageSize) maxPageSize else limit; + let ?ids = store.principalsToOrders.get(owner) else { + return { page = { orders = []; nextCursor = null }; scanned = 0 }; + }; + let collected = List.empty(); + var scanned = 0; + let it = switch (afterId) { + case (?cursor) ids.valuesFrom(cursor); + case null ids.values(); + }; + for (id in it) { + // Counted for every id the iterator yields, including the inclusive-seek skip and + // the lookahead — the honest measure of work, not of rows. + scanned += 1; + let past = switch (afterId) { case (?cursor) id > cursor; case null true }; + if (past) { + if (collected.size() == capped) { + // ⚠️ **The cursor is read OFF the rows returned**, so "the cursor is an id we + // actually handed out" is unrepresentable rather than merely true. + // + // ⚠️ **Not because a `var last` assigned in the loop would differ today — but + // because if it ever DID differ, this is the correct one and that would be the + // bug.** The two coincide only while `case null` below is unreachable; a + // phantom index entry is exactly the state where a `last` captured in the + // `past` branch would name an id that produced no row, and the next page would + // silently skip everything between it and the last real row. So the reason + // holds after a change that makes that branch reachable — retention, deletion + // — where "they are identical" would read as invalidated while the code is + // still right. `capped >= 1`, since a + // zero or oversized limit becomes `maxPageSize`. + // `last()` rather than `get(size - 1)`: no Nat subtraction to trap on, and no + // index to get wrong. + let cursor = switch (collected.last()) { case (?row) ?row.id; case null null }; + return { page = { orders = collected.toArray(); nextCursor = cursor }; scanned }; + }; + switch (store.orders.get(id)) { + case (?order) collected.add(order); + // Indexed without a record. Nothing removes from either structure, so this is + // unreachable; skipping rather than trapping keeps a bookkeeping fault off a + // buyer's read path. + case null {}; + }; + }; + }; + { page = { orders = collected.toArray(); nextCursor = null }; scanned }; + }; + + public func matchesFilter(order : Types.Order, filter : Filter) : Bool { + switch (filter.status) { + case (?want) if (order.status != want) return false; + case null {}; + }; + switch (filter.owner) { + case (?want) switch (order.owner) { + case (#ii(p)) if (p != want) return false; + }; + case null {}; + }; + switch (filter.createdFromNs) { + case (?from) if (order.createdAtNs < from) return false; + case null {}; + }; + switch (filter.createdToNs) { + case (?to) if (order.createdAtNs > to) return false; + case null {}; + }; + if (filter.withUnresolvedProblems and Problems.unresolvedCount(order.problems) == 0) { + return false; + }; + true; + }; + + /// Every order carrying at least one unresolved problem — **the worklist, as a + /// filter rather than a structure**. + public func withUnresolvedProblems(store : Store) : [Types.Order] { + let out = List.empty(); + for (id in store.unresolvedProblems.values()) { + switch (store.orders.get(id)) { + case (?order) out.add(order); + // An id in the index with no order cannot happen — orders are never deleted — + // so this arm is unreachable rather than defensive. Skipping is + // still the right response if the impossible occurs: the rebuild fixes it. + case null {}; + }; + }; + out.toArray(); + }; + + /// How many ORDERS carry at least one unresolved problem. O(1). + /// + /// ⚠️ The index's size IS that number, so this replaces + /// `withUnresolvedProblems(store).size()` — which materialised the entire worklist as an + /// array to read its length, on a query anyone can call. + public func unresolvedProblemOrderCount(store : Store) : Nat { + store.unresolvedProblems.size(); + }; + + /// Total unresolved problems across all orders — the number an operator watches. + public func unresolvedProblemCount(store : Store) : Nat { + var n = 0; + for (id in store.unresolvedProblems.values()) { + switch (store.orders.get(id)) { + case (?order) n += Problems.unresolvedCount(order.problems); + case null {}; + }; + }; + n; + }; + + public func markDelayed(store : Store, id : Types.OrderId, nowNs : Int) : Bool { + let ?order = store.orders.get(id) else return false; + switch (order.delayedAtNs) { + case (?_) false; // already recorded; idempotent by construction + case null { + store.orders.add(id, { order with delayedAtNs = ?nowNs }); + true; + }; + }; + }; + + public func expireWithCause( + store : Store, + id : Types.OrderId, + cause : Types.ExpiredBy, + nowNs : Int, + ) : Result.Result { + switch (store.orders.get(id)) { + case null #err(#notFound(id)); + case (?order) { + switch (transition(order, #expired, nowNs)) { + case (#ok(updated)) { + let expired = { updated with expiredBy = ?cause }; + // Release point 4: in-call session-creation failure. + #ok(commitTransition(store, order, expired)); + }; + case (#err(e)) #err(e); + }; + }; + }; + }; + + /// Webhook money-in (§6.1): `#created → #paid`, and only that. There is no + /// `#expired → #paid` edge, so this REFUSES an order that stopped being payable. + /// + /// ⚠️ `Card.handleWebhook` guards the status before reaching here and `-Werror` + /// does not check that guard against the matrix. It **traps** on this error + /// rather than swallowing it, so a guard that drifts open is a 5xx Stripe + /// retries for ~3 days rather than a silent delivery. One caller is not a reason + /// to relax the trap. + /// + /// **`lockedCycles` is not written here.** The webhook honours only the quoted + /// amount, so the quantity locked at creation is the quantity delivered — + /// immutable for the order's whole life, which is what makes the outstanding-promise + /// tally exact rather than conservative. `paidUsdCents` records what arrived, + /// and it can only equal `pricing.usdCents`; it is stored because "what Stripe + /// said" and "what we asked for" being the same is worth being able to check. public func markPaid( store : Store, id : Types.OrderId, - honoredCycles : Nat, + paidUsdCents : Nat, nowNs : Int, ) : Result.Result { switch (store.orders.get(id)) { @@ -196,9 +1529,10 @@ module { case (?order) { switch (transition(order, #paid, nowNs)) { case (#ok(updated)) { - let paid = { updated with lockedCycles = honoredCycles }; - store.orders.add(id, paid); - #ok(paid); + let paid = { updated with paidUsdCents = ?paidUsdCents }; + // ⚠️ Release is at DELIVERY, never at payment, so this delta is ZERO. + // `Reserve.tallyDelta` carries the two-orders-one-reserve argument. + #ok(commitTransition(store, order, paid)); }; case (#err(e)) #err(e); }; @@ -210,6 +1544,58 @@ module { store.orders.get(id); }; + /// Count of this principal's still-open orders — the input to the `Gate` admission cap. + /// + /// `#created` **and not past its own deadline**. Anything past `#paid` is a record of + /// money rather than an open slot, and an `#expired` or `#cancelled` order is finished. + /// + /// ⚠️ **The deadline check is what makes a cap of 1 safe, and the reason it needs no + /// Stripe call is the whole distinction.** A slot is **our** resource: we may grant it + /// on our own clock, because being generous with it cannot lose money — at worst a + /// buyer gets a second slot a little early, and the order they abandoned is unpayable + /// anyway once Stripe expires its session. Reserve *capacity* is money, so releasing it + /// needs Stripe's authority, which is what the recovery sweep asks for. + /// Same input, two resources, two standards of proof. + /// + /// Without it, one missed `checkout.session.expired` locks a buyer out **permanently** + /// at a cap of 1 — not for the 35 minutes the session lasts, because nothing else moves + /// a `#created` order. That is why the cap and this check land together and why + /// shipping the cap alone would have been the harmful half. + /// + /// ⚠️ **`expiresAtNs` is null until the session-create response lands**, and a null + /// deadline counts as open: an order with no session is one whose fate we do not know + /// yet, and the residue case (a lost create response) is a canister-level fault whose + /// lever is `expire_order`. Counting it as free would hand out a slot on the strength + /// of an order we cannot even expire. + public func openOrderCount(store : Store, caller : Principal, nowNs : Int) : Nat { + switch (store.principalsToOrders.get(caller)) { + case null 0; + case (?ids) { + var open = 0; + for (id in ids.values()) { + switch (store.orders.get(id)) { + case (?order) { + if (order.status == #created and not pastDeadline(order, nowNs)) open += 1; + }; + case null {}; // id indexed without a record — cannot happen + }; + }; + open; + }; + }; + }; + + /// Is this order past the deadline Stripe set for its own session? + /// + /// Null answers **false** — see `openOrderCount`. Separate and named so the one + /// comparison has one home and one test, the way `Session.secondsToNs` does. + public func pastDeadline(order : Types.Order, nowNs : Int) : Bool { + switch (order.expiresAtNs) { + case (?deadline) nowNs > deadline; + case null false; + }; + }; + /// Authz-guarded lookup for the user API: `caller == order.owner` (§2). public func getOwned(store : Store, id : Types.OrderId, caller : Principal) : ?Types.Order { switch (store.orders.get(id)) { @@ -221,13 +1607,20 @@ module { }; /// Order history for a principal, newest-last (insertion order). - public func ordersFor(store : Store, caller : Principal) : [Types.Order] { - switch (store.principalsToOrders.get(caller)) { - case null []; - case (?ids) { - ids.values().filterMap(func(id) = store.orders.get(id)).toArray(); - }; - }; + /// Every order — **the test oracle for the bounded tallies, and nothing else**. + /// + /// ⚠️ **No production path may call this.** A full scan in one message is + /// unbounded: under indefinite retention it is on a path to the instruction limit, and + /// `reconcileBounded` recounts the same quantities from `promiseHolders` instead. + /// + /// What the full scan is still good for is being the **independent** definition the + /// bounded pass is checked against — `Reserve.recount(all(store))` derives the promise + /// total from the orders themselves, with no index in the chain, which is exactly the + /// property a test needs and a timer cannot afford. Deliberately unpaged for the same + /// reason: a partial scan under-counts what is owed, so as an oracle it must see + /// everything or say nothing. + public func all(store : Store) : [Types.Order] { + store.orders.values().toArray(); }; }; diff --git a/src/backend/Orphans.mo b/src/backend/Orphans.mo new file mode 100644 index 0000000..ad0d703 --- /dev/null +++ b/src/backend/Orphans.mo @@ -0,0 +1,341 @@ +/// Payments that cannot be attributed to any order (§4.1). +/// +/// ⚠️ **Two kinds, and only two: `#unattributed` and `#unprocessable`.** What is filed +/// here has, by definition, no order to attach to. Do not re-add an order-bound kind; +/// `Problems.mo` is where those live. +/// +/// **Nothing is evicted and nothing is bounded** — see `add` for why that is acceptable, +/// and `maxPageSize` for what it costs the read API. +/// +/// Every dollar that arrives resolves to delivery, or to an obligation — on the order, or +/// on this list. There is no third outcome and no silent one. +/// +/// ⚠️ **Only fiat can be stranded, never cycles.** Delivery is one transfer out of a +/// reserve the gateway already holds, so a failed delivery leaves the cycles where they +/// were and the order retries. That is what makes the taxonomy one-sided: every obligation +/// is *money we took and have not delivered against*, and the remedy is either finishing +/// the delivery or refunding in Stripe. +/// +/// **Resolution lives on the entry, never on the order.** Resolving here transitions +/// nothing: an order's status says where the *order* got to, an entry says what the +/// *operator* still owes, and an entry may name no order at all. The two exits that do +/// move an order are `abandon_order` and `record_delivered`, both explicit. +import Map "mo:core/Map"; +import Int "mo:core/Int"; +import List "mo:core/List"; +import Iter "mo:core/Iter"; +import Nat "mo:core/Nat"; +import Result "mo:core/Result"; +import Text "mo:core/Text"; +import Types "Types"; + +module { + + /// §4.1 — **`refundResolvable` and `paymentRefOf` are one property seen from two + /// sides**, and `test/orphans.test.mo` pins their agreement across all seven + /// kinds: a refund can settle an entry exactly when `paymentRefOf` gives + /// `resolveByPaymentRef` something to match on. + /// + /// ⚠️ **That is the accessor, not the payload.** `#refundAfterDelivery` carries a + /// `paymentRef` field and `paymentRefOf` returns null for it deliberately — the + /// refund is what created the entry, so matching on it would close the recorded loss + /// the instant it was recorded. + /// + /// No kind carries a stranded cycle quantity, because cycles cannot strand (above). + /// `#refundAfterDelivery.cycles` is the opposite case — cycles that *were* + /// delivered, recorded as an operator loss. + public type Kind = { + /// Refund-resolvable — the payment could not be attributed to an order that can accept + /// it: `client_reference_id` resolved to no order (claimed, not trusted — it + /// is an attacker-editable URL param), or it resolved to an order that is + /// `#cancelled` or `#expired`, neither of which is payable. + #unattributed : { claimedRef : Text; paymentRef : Text }; + /// A **verified** Stripe event the canister cannot process — a required + /// field is absent (e.g. a checkout session with no `payment_intent`, which + /// a subscription-mode link or a 100%-off promo code produces). + /// + /// Queued rather than refused: parsing is deterministic, so answering non-2xx + /// would fail identically on every retry for Stripe's full retry horizon and + /// risk the endpoint being disabled — which would then lose every legitimate + /// webhook. Money position is unknown from here; the event id is what the + /// operator looks up in the Dashboard. + /// + /// Growth is bounded by Stripe, not by a caller: reaching this requires a body + /// that passes HMAC verification, so only the holder of the signing secret can + /// produce one, and `unresolvedUnprocessable` keeps a resend of the same event + /// from filing twice. Like every unresolved entry it is never evicted — the + /// event id is the only pointer the canister keeps to a dollar it could not + /// account for. + #unprocessable : { eventId : Text; field : Text }; + }; + + /// Can a `charge.refunded` for the same payment settle this on its own? + /// + /// ⚠️ **True only where the remedy is exactly "refund the fiat".** Anything else + /// needs a decision a webhook cannot make: `#deliveryStuck` may be an unknown money + /// position (refunding blind pays a buyer who already holds their cycles), + /// and `#refundAfterDelivery` is money that moved both ways. + public func refundResolvable(kind : Kind) : Bool { + switch (kind) { + case (#unattributed(_)) true; + + case (#unprocessable(_)) false; + // ⚠️ **False even though a refund is a legal thing for the operator to do here.** + // The question this answers is "does a `charge.refunded` settle this entry *on its + // own*", and it does not: refunding returns the buyer's money and leaves the order + // stranded in `#created` holding reserve capacity, with no event left that can + // release it. Answering true would auto-close the only worklist item pointing at + // that. The remedy that settles it whole is a resend. + + // NOT refund-resolvable. That answer means "fiat in, nothing delivered" — a + // *settled* position the operator refunds. Here whether the cycles moved is + // unknown, so answering true would tell them to refund a buyer who may already + // hold their cycles. + }; + }; + + /// Bound on attacker-supplied text stored in an entry. `claimedRef` comes + /// straight off a URL parameter, so without a cap an attacker could stuff + /// arbitrary data into admin-visible stable state one webhook at a time + /// (`canister-security`: validate input sizes). Long enough for a legitimate + /// `_` (≈ 63 + 1 + 32) with room to show what was sent. + public let maxClaimedRefBytes : Nat = 128; + + public func truncateClaimedRef(claimedRef : Text) : Text { + if (claimedRef.size() <= maxClaimedRefBytes) return claimedRef; + Text.fromIter(claimedRef.chars().take(maxClaimedRefBytes)) # "…(truncated)"; + }; + + /// The payment reference a `charge.refunded` resolves — refund-resolvable kinds only. + /// (`#deliveryStuck` carries no paymentRef: the order store doesn't retain the + /// payment_intent, and a refund alone doesn't settle an uncertain delivery.) + public func paymentRefOf(kind : Kind) : ?Text { + switch (kind) { + + case (#unattributed({ paymentRef; claimedRef = _ })) ?paymentRef; + // #refundAfterDelivery carries a paymentRef but must NOT be reachable + // from `resolveByPaymentRef`: the refund is what created the entry, so + // auto-resolving on it would close the loss the instant it was recorded. + // Only a human closes this one. + + // Neither is settled by a refund landing: an abandonment was already a + // conscious decision, and an unprocessable event has no established money + // position to settle. + case (#unprocessable(_)) null; + // Same reasoning as #deliveryStuck, and sharper: a refund arriving does not + // tell us whether the cycles left the reserve, which is the only open + // question. Auto-resolving on it would close an entry whose answer nobody + // has looked up. + }; + }; + + public type Entry = { + /// Monotonic, never reused — the stable handle `resolve_orphan` takes. + id : Nat; + rail : Types.Rail; + kind : Kind; + detail : Text; + createdAtNs : Int; + resolvedAtNs : ?Int; + }; + + public type Store = { + /// Keyed by monotonic id → `entries()` iterates oldest-first. + entries : Map.Map; + var nextId : Nat; + }; + + public func emptyStore() : Store { + { entries = Map.empty(); var nextId = 0 }; + }; + + public type AddResult = { + entry : Entry; + }; + + /// Append an entry. **Nothing is evicted.** + /// + /// ⚠️ **Unbounded on purpose, and acceptable only because growth is attacker-priced.** + /// Adding an entry needs either a verified payment nobody can attribute or an event + /// that passes HMAC verification, so it costs a real dollar or the signing secret. Do + /// not reintroduce a capacity: eviction can only ever discard *resolved* history, and + /// a bound that could drop an unresolved obligation is the one thing this list exists + /// to prevent. + public func add( + store : Store, + rail : Types.Rail, + kind : Kind, + detail : Text, + nowNs : Int, + ) : AddResult { + let entry : Entry = { + id = store.nextId; + rail; + kind; + detail; + createdAtNs = nowNs; + resolvedAtNs = null; + }; + store.nextId += 1; + store.entries.add(entry.id, entry); + { entry }; + }; + + /// The unresolved `#unprocessable` entry for this Stripe event, if one is + /// already on the worklist. + /// + /// Webhook ingestion dedups on the event id, but that dedup set is pruned at + /// ~7 days (§4.2). A Dashboard resend after that window is a *new* event to the + /// dedup set and would file a second entry for one event — two worklist items + /// an operator has to recognise as the same thing and resolve twice. + /// + /// Scans the queue: `#unprocessable` is reached only from a signature-verified + /// event that failed to parse, which is rare and not attacker-drivable, so this + /// is not on any hot path. + public func unresolvedUnprocessable(store : Store, eventId : Text) : ?Entry { + for ((_, entry) in store.entries.entries()) { + if (entry.resolvedAtNs == null) { + switch (entry.kind) { + case (#unprocessable({ eventId = existing; field = _ })) { + if (existing == eventId) return ?entry; + }; + case (_) {}; + }; + }; + }; + null; + }; + + /// Live obligations. The operator's worklist depth, and the number that must + /// not be allowed to grow unbounded — not because state is precious, but + /// because each one is money someone is owed an answer about. + public func unresolvedCount(store : Store) : Nat { + var open = 0; + for ((_, entry) in store.entries.entries()) { + if (entry.resolvedAtNs == null) open += 1; + }; + open; + }; + + public type ResolveError = { #notFound : Nat; #alreadyResolved : Nat }; + + /// Manual operator resolution (refund issued / cycles re-delivered). + public func resolve(store : Store, id : Nat, nowNs : Int) : Result.Result { + switch (store.entries.get(id)) { + case null #err(#notFound(id)); + case (?entry) { + switch (entry.resolvedAtNs) { + case (?_) #err(#alreadyResolved(id)); + case null { + let updated = { entry with resolvedAtNs = ?nowNs }; + store.entries.add(id, updated); + #ok(updated); + }; + }; + }; + }; + }; + + /// `charge.refunded` auto-resolve (§4.1): marks every unresolved refund-resolvable + /// entry carrying this `payment_intent` resolved; returns them. The other kinds never + /// match, because they carry no paymentRef — which is the structural half of + /// `refundResolvable` doing the work. + /// Empty result = refund for something not in the queue (fine: operators may + /// refund proactively). + /// Unresolved entries carrying this `payment_intent`, without touching them. + /// Used by the partial-refund path, which must report what is still owed + /// rather than settle it. + public func unresolvedByPaymentRef(store : Store, paymentRef : Text) : [Entry] { + store.entries.values().filter( + func(e) = e.resolvedAtNs == null and paymentRefOf(e.kind) == ?paymentRef + ).toArray(); + }; + + public func resolveByPaymentRef(store : Store, paymentRef : Text, nowNs : Int) : [Entry] { + let matches = store.entries.values().filter( + func(e) = e.resolvedAtNs == null and paymentRefOf(e.kind) == ?paymentRef + ).toArray(); + let resolved = List.empty(); + for (entry in matches.values()) { + let updated = { entry with resolvedAtNs = ?nowNs }; + store.entries.add(entry.id, updated); + resolved.add(updated); + }; + resolved.toArray(); + }; + + /// Hard cap on a page. Entries are a few hundred bytes, and a Candid message + /// is capped at 2 MB — so an unpaginated read of a list that is allowed to + /// grow without bound would eventually become unreturnable. This + /// bounds the response instead of bounding the record. + public let maxPageSize : Nat = 200; + + /// One page of entries, oldest first. + /// + /// Cursor-based rather than offset-based on purpose: resolved history is + /// trimmed as new entries arrive, so positional offsets shift under a client + /// mid-scan and would silently skip entries. Ids are monotonic and never + /// reused, so a cursor is stable. + /// + /// `nextCursor` is set **only when further matching entries remain**, so a + /// caller stops the moment it is null and never makes a wasted final request. + /// A page can therefore come back exactly full with no cursor. + public type Page = { entries : [Entry]; nextCursor : ?Nat }; + + func pageWhere( + store : Store, + afterId : ?Nat, + limit : Nat, + matches : Entry -> Bool, + ) : Page { + let capped = if (limit == 0 or limit > maxPageSize) maxPageSize else limit; + let collected = List.empty(); + var last : ?Nat = null; + for ((id, entry) in store.entries.entries()) { + let past = switch (afterId) { case (?cursor) id > cursor; case null true }; + if (past and matches(entry)) { + if (collected.size() == capped) return { entries = collected.toArray(); nextCursor = last }; + collected.add(entry); + last := ?id; + }; + }; + // Ran out of entries, so this is the last page regardless of how full it is. + { entries = collected.toArray(); nextCursor = null }; + }; + + /// Everything still retained, paged (resolved entries included — they are the + /// audit history). + public func page(store : Store, afterId : ?Nat, limit : Nat) : Page { + pageWhere(store, afterId, limit, func(_) = true); + }; + + /// Open obligations only, paged. This is the operator's worklist: filtering + /// server-side means they never page through resolved history to find the + /// dollars that still need an answer. + public func unresolvedPage(store : Store, afterId : ?Nat, limit : Nat) : Page { + pageWhere(store, afterId, limit, func(entry) = entry.resolvedAtNs == null); + }; + + public func get(store : Store, id : Nat) : ?Entry { + store.entries.get(id); + }; + + /// Open obligations, oldest first — the operator's worklist. + public func unresolved(store : Store) : [Entry] { + store.entries.values().filter(func(e) = e.resolvedAtNs == null).toArray(); + }; + + /// Everything retained — which is everything ever filed — oldest first. + public func all(store : Store) : [Entry] { + store.entries.values().toArray(); + }; + + public func size(store : Store) : Nat { + store.entries.size(); + }; + + + + + +}; diff --git a/src/backend/Pricing.mo b/src/backend/Pricing.mo new file mode 100644 index 0000000..04a505f --- /dev/null +++ b/src/backend/Pricing.mo @@ -0,0 +1,404 @@ +/// How USD becomes cycles (§3) — the pure half. +/// +/// ``` +/// cycles = netCents × xdrPermyriadPerIcp × 10¹² / usdPerIcpMicros +/// ``` +/// +/// Two inputs, both from on-chain canisters and cached together: `usdPerIcpMicros` (USD +/// per ICP × 10⁶, from the Exchange Rate Canister) and `xdrPermyriadPerIcp` (XDR per ICP +/// × 10⁴, from the Cycles Minting Canister — the protocol's own published rate, which is +/// why it is read there and nowhere else). +/// +/// In units: `netCents` is worth `netCents × 10⁴ / usdPerIcpMicros` ICP, and one ICP is +/// `xdrPermyriadPerIcp × 10⁸` cycles at the protocol rate. +/// +/// **ICP is an intermediate unit here, not a position** — it cancels, and the gateway +/// never holds it. Why this is derived rather than priced off a market USD/XDR rate, and +/// where the operator's exposure actually sits: `docs/DESIGN.md` §3.1. +/// +/// ⚠️ **Fail-closed: every implausible, stale or missing input collapses to "no price", +/// and no price blocks order creation.** Nothing is ever priced on a guess. `Main.mo` owns +/// the impure half — the XRC/CMC calls, the timer and the backoff — so everything here +/// unit-tests with no IC environment. +import Int "mo:core/Int"; +import Nat "mo:core/Nat"; +import Result "mo:core/Result"; + +module { + + /// Plausibility band for USD per ICP, in micros: $0.10 … $10,000. Wide on + /// purpose — its job is rejecting decimal-point disasters, not filtering + /// market moves. `maxRateDeltaBps` is the tighter guard. + public let minUsdPerIcpMicros : Nat = 100_000; + public let maxUsdPerIcpMicros : Nat = 10_000_000_000; + + public func plausibleUsdPerIcp(micros : Nat) : Bool { + minUsdPerIcpMicros <= micros and micros <= maxUsdPerIcpMicros; + }; + + /// Plausibility band for the **implied** XDR/USD, in micros: 0.5 … 1.2. + /// + /// This is the cross-check that stops us trusting one rate provider. The CMC's + /// XDR/ICP and the XRC's USD/ICP are independently governed and independently + /// sourced views of ICP's value, so dividing them yields an implied XDR/USD — + /// and XDR/USD is the one quantity here that is genuinely stable, an IMF + /// basket that has sat in ~0.6–0.9 for decades. + /// + /// An XRC price wrong by a factor therefore shows up as an absurd implied + /// rate: $45.50/ICP against 3.5 XDR/ICP implies 0.077, and $0.455 implies 7.69. + /// The band leaves >30% headroom each way against the historical range while + /// catching anything off by roughly 1.5× or more — errors the wide ICP-price + /// band sails straight past. + /// + /// Two honest limits: this is a cross-check, not independence — correlated + /// drift in both sources would go unnoticed. And it assumes the CMC's rate is + /// the sound one; if the CMC is wrong instead we reject a good XRC price. That is + /// the right direction, because the CMC's rate is the protocol's own valuation of + /// a cycle, so a wrong one misprices cycles network-wide — a larger problem than + /// one refused quote, and not ours to correct at the till. + public let minImpliedXdrPerUsdMicros : Nat = 500_000; + public let maxImpliedXdrPerUsdMicros : Nat = 1_200_000; + + /// Implied XDR per USD × 10⁶ from the two rate inputs. + /// + /// `(P/10⁴ XDR per ICP) / (U/10⁶ USD per ICP) × 10⁶ = P · 10⁸ / U`. + /// Null on a zero ICP price. + public func impliedXdrPerUsdMicros(xdrPermyriadPerIcp : Nat, usdPerIcpMicros : Nat) : ?Nat { + if (usdPerIcpMicros == 0) return null; + ?(xdrPermyriadPerIcp * 100_000_000 / usdPerIcpMicros); + }; + + public func plausibleImpliedXdrPerUsd(micros : Nat) : Bool { + minImpliedXdrPerUsdMicros <= micros and micros <= maxImpliedXdrPerUsdMicros; + }; + + /// Quality signal the XRC publishes alongside a rate. Recorded on the order + /// so a buyer can see how well-sourced the price they were quoted was — a + /// rate assembled from two exchanges is not the same product as one from + /// twelve, and that distinction is otherwise invisible. + public type Quality = { + /// XRC `metadata.standard_deviation`, in the rate's own decimals. + standardDeviation : Nat; + /// How many sources answered, out of how many were asked. + receivedRates : Nat; + queriedSources : Nat; + }; + + /// Both rates plus when they were read. One record because they are only ever + /// meaningful together: a quote built from an ICP price and a CMC rate taken + /// at different moments is wrong by however much ICP moved in between. + public type Rates = { + usdPerIcpMicros : Nat; + xdrPermyriadPerIcp : Nat; + fetchedAtNs : Int; + quality : Quality; + }; + + public type Cache = { var rates : ?Rates }; + + public func emptyCache() : Cache { + { var rates = null }; + }; + + /// §3 fee formula plus the freshness and sanity bounds. + public type Config = { + /// ≈2.9% + $0.30, recovering Stripe's cut at cost. + feeBps : Nat; + feeFixedCents : Nat; + /// How long a cached pair may price orders. + /// + /// This is a **security control, not a tuning knob**. The refresh runs on a + /// timer, and a dead timer is only safe because a stale cache refuses to + /// price: an unbounded window would let orders be priced indefinitely off a + /// frozen rate, which is exactly the gap that timer-reinstantiation + /// guidance warns about. Bounded by `maxAllowedMaxAgeNs`. + maxAgeNs : Int; + /// Largest relative move accepted against the last good ICP price, in basis + /// points. A refreshed rate outside this is rejected and the previous one + /// keeps serving until it goes stale — a coarse guard against a source + /// glitch, deliberately loose enough never to reject a real market move + /// inside one refresh interval. + maxRateDeltaBps : Nat; + /// Minimum number of exchange sources that must have answered for the ICP + /// price to be usable. + /// + /// The XRC's own `InconsistentRatesReceived` fires when the rates it + /// collected disagree too much — but **a single rate cannot disagree with + /// itself**, so the degenerate one-exchange case (a thin or manipulated + /// market) arrives as a clean `Ok`. This is the only guard that sees it. + /// + /// Kept low on purpose: every increment buys a little more confidence and + /// costs availability, because falling short means refusing to quote. + minRateSources : Nat; + /// Simulation scale: deliver `1/divisor` of the cycles a purchase buys. + /// **`1` is production and means the arithmetic below is bit-identical to + /// having no divisor at all**. + /// + /// ⚠️ **`divisor > 1` IS the simulation-mode signal — there is no second + /// flag.** A separate boolean could disagree with this number, and then two + /// places would answer "are we simulating?" differently. One value has no + /// inconsistent state. + /// + /// ⚠️ Applied HERE, in `quote`, because this is the single derivation of a + /// cycle quantity and it has exactly one caller. That is what keeps the + /// quote the buyer sees, the `lockedCycles` on the order, the promise tally, + /// the reserve-floor decrement and the transfer all the *same* scaled + /// number. **Scaling at delivery instead would make every reconcile report + /// an unexplained shortfall** — the one signal that means an outflow we did + /// not cause. + divisor : Nat; + }; + + /// ICP/USD is volatile, so the window is short. The cost of a tighter window + /// is one extra XRC call per interval, which is negligible next to the + /// mispricing a long window allows. + public let defaultMaxAgeNs : Int = 300_000_000_000; // 5 min + + /// Ceiling on any operator-set window (1 h). See `Config.maxAgeNs`. + public let maxAllowedMaxAgeNs : Int = 3_600_000_000_000; + + public func defaultConfig() : Config { + { + feeBps = 290; + feeFixedCents = 30; + maxAgeNs = defaultMaxAgeNs; + maxRateDeltaBps = 5_000; // 50% + minRateSources = 2; + divisor = 1; + }; + }; + + public type ConfigError = { + /// Fee ≥ 100% can never net out. + #feeBpsTooHigh; + /// A zero window would refresh on every order. + #nonPositiveMaxAge; + /// Above `maxAllowedMaxAgeNs` — see `Config.maxAgeNs`. + #maxAgeTooLong : { maxAgeNs : Int; allowedNs : Int }; + /// A zero delta bound would reject every refresh after the first. + #zeroRateDelta; + /// Requiring zero sources would defeat the guard entirely. + #zeroRateSources; + /// A zero divisor would divide the whole quote away; `1` is "off". + #zeroDivisor; + /// This divisor scales the smallest purchase we sell down below what the + /// cycles-ledger deposit fee would eat. Carries both figures so an + /// operator can see how far past the band they went. + #divisorUndeliverable : { scaledCycles : Nat; ledgerFee : Nat }; + /// ⚠️ **`divisor > 1` requires `expectLivemode == ?false` EXACTLY** — not + /// merely "not live", because `null` means "either mode" and would accept live + /// payments while under-delivering. The mutual half lives in + /// `set_expected_livemode`. + #divisorNeedsSandbox : { expectLivemode : ?Bool }; + /// ⚠️ **The divisor is global rather than recorded per order**, which is only + /// safe because it cannot change under stored orders: every earlier receipt + /// would otherwise recompute against the new divisor and report a mismatch — + /// exactly the claim the landing page makes. Reinstall to change it. + #divisorChangeWithOrders : { stored : Nat }; + }; + + // ⚠️ **`validateConfig` does NOT decide every `ConfigError`.** The three divisor + // cases above need context this module cannot see — the Stripe mode, the order + // store, and the live ledger fee — so `Main.set_pricing_config` decides those and + // returns them through this same type. One error type, two deciding sites; the + // alternative is a second error type that callers have to union by hand. + + public func validateConfig(config : Config) : Result.Result<(), ConfigError> { + if (config.feeBps >= 10_000) return #err(#feeBpsTooHigh); + if (config.maxAgeNs <= 0) return #err(#nonPositiveMaxAge); + if (config.maxAgeNs > maxAllowedMaxAgeNs) { + return #err(#maxAgeTooLong({ maxAgeNs = config.maxAgeNs; allowedNs = maxAllowedMaxAgeNs })); + }; + if (config.maxRateDeltaBps == 0) return #err(#zeroRateDelta); + if (config.minRateSources == 0) return #err(#zeroRateSources); + if (config.divisor == 0) return #err(#zeroDivisor); + #ok; + }; + + // ── The cycles-ledger deposit fee, and why the divisor has a ceiling ────── + // + // ⚠️ **The ledger's fee is FLAT and it is not ours.** It charges a fixed + // amount to accept a deposit whatever the deposit is, so it does not scale + // with the divisor and it is the same in simulation mode as in production. + // + // ⚠️ **An over-scaled gateway makes an "unreachable" state reachable.** The + // delivery path documents the one state the stored fee cannot correct itself + // out of: a fee above a whole order's locked quantity means nothing ever + // reaches the ledger, so no `#BadFee` ever arrives to fix the stored copy, and + // there is deliberately no admin lever to reset it. In production that needs a + // ~70,000x fee rise. A divisor attacks exactly that ratio, so the guard below + // is what keeps the unrecoverable state unreachable. + + /// How many times the ledger fee a scaled quote must clear. + /// + /// **Headroom rather than a bare `>`, because rates move.** A quote that + /// clears the fee by one cycle today walks into the stall on the next ICP + /// move; ten times over does not. At the $10 floor this puts the practical + /// divisor ceiling around 7,200 — comfortably above the recommended 1,000 and + /// below the scale at which a tester is mostly verifying the ledger's fee. + public let ledgerFeeHeadroom : Nat = 10; + + public func clearsLedgerFee(scaledCycles : Nat, ledgerFee : Nat) : Bool { + scaledCycles >= ledgerFee * ledgerFeeHeadroom; + }; + + /// Would the **smallest purchase this gateway sells** still clear the ledger + /// fee at this divisor? Used by the config setter so an operator learns at set + /// time rather than through a buyer's refusal. + /// + /// ⚠️ **A config-time check alone is NOT enough, and rates are why**: this + /// passes against today's rate and goes stale as ICP moves. `quote` re-checks + /// on every order and is the authoritative guard. + /// + /// ⚠️ **Absent rates return `#ok`, deliberately.** "Cannot tell" must not + /// refuse a config change — on a cold canister the refresh timer may simply + /// not have run yet, and refusing would make the setting unreachable exactly + /// when it is being set up. + public func divisorDeliverable( + cache : Cache, + fee : { feeBps : Nat; feeFixedCents : Nat }, + minGrossCents : Nat, + divisor : Nat, + ledgerFee : Nat, + ) : Result.Result<(), ConfigError> { + if (divisor == 0) return #err(#zeroDivisor); + let ?net = netCents(fee, minGrossCents) else return #ok; + let ?rates = cache.rates else return #ok; + let ?gross = cyclesForCents(net, rates.xdrPermyriadPerIcp, rates.usdPerIcpMicros) else return #ok; + let scaled = gross / divisor; + if (clearsLedgerFee(scaled, ledgerFee)) return #ok; + #err(#divisorUndeliverable({ scaledCycles = scaled; ledgerFee })); + }; + + /// The cached pair iff younger than `maxAgeNs` (age ≥ window = stale, the + /// convention shared with Idempotency pruning and the CMC guard). Null = the + /// caller fails closed. + public func freshRates(cache : Cache, maxAgeNs : Int, nowNs : Int) : ?Rates { + let ?rates = cache.rates else return null; + if (nowNs - rates.fetchedAtNs >= maxAgeNs) return null; + ?rates; + }; + + /// The cached pair regardless of age — for status reporting, so an operator can + /// see *what* is cached and how old it is even when it is too stale to price. + /// + /// Not the delta-guard baseline: that uses `freshRates`, because a stale rate is + /// not evidence about the current market and comparing against one deadlocks + /// pricing after an outage spanning a large move. + public func lastRates(cache : Cache) : ?Rates { + cache.rates; + }; + + public func record(cache : Cache, rates : Rates) { + cache.rates := ?rates; + }; + + /// Is `next` within `maxDeltaBps` of `previous`? True when there is no + /// previous rate — the first observation has nothing to be compared against, + /// and the plausibility band is what guards it. + public func withinDelta(previous : ?Nat, next : Nat, maxDeltaBps : Nat) : Bool { + let ?prev = previous else return true; + if (prev == 0) return true; + // Via Int so no subtraction can trap. The `if` below guarantees both branches + // are non-negative, but that is a fact about the guard rather than the types, + // and moc is right not to take it on trust (M0155). + let diff = Int.abs(next.toInt() - prev.toInt()); + // diff/prev ≤ maxDeltaBps/10_000, without floating point. + diff * 10_000 <= prev * maxDeltaBps; + }; + + /// §3 net-of-fees: gross minus (⌈gross·bps/10⁴⌉ + fixed). Rounds the fee + /// **up**, so rounding never favours the buyer on the fee and the operator's + /// at-cost posture absorbs the ≤1¢ variance. Null when the fee swallows the + /// whole amount (an amount priced below the fee floor). + /// + /// The fee argument is the narrowed shape so both a live `Config` and an + /// order's creation-time snapshot fit. The snapshot caller is `receipt`, which + /// recomputes what a delivered order was charged from the rates it was priced + /// at. The webhook does not reprice: it requires the paid amount to equal the + /// quoted one, so a mismatch delivers nothing. + public func netCents(fee : { feeBps : Nat; feeFixedCents : Nat }, grossCents : Nat) : ?Nat { + let total = feeCents(fee, grossCents); + if (total >= grossCents) return null; + ?(grossCents - total); + }; + + /// The fee itself, split out so it can be shown to a buyer without + /// recomputing it: `netCents` is defined as `grossCents - feeCents` whenever + /// that is positive, and the two must never disagree. + public func feeCents(fee : { feeBps : Nat; feeFixedCents : Nat }, grossCents : Nat) : Nat { + (grossCents * fee.feeBps + 9_999) / 10_000 + fee.feeFixedCents; + }; + + /// The §3 locked cycle quantity for an already-netted amount. Integer + /// division **floors**, so a quote is never larger than the money actually + /// buys; the sub-cycle remainder stays operator-side. Null on a zero ICP + /// price (a broken rate must fail the quote, not divide by zero). + public func cyclesForCents( + netCents : Nat, + xdrPermyriadPerIcp : Nat, + usdPerIcpMicros : Nat, + ) : ?Nat { + if (usdPerIcpMicros == 0) return null; + ?(netCents * xdrPermyriadPerIcp * 1_000_000_000_000 / usdPerIcpMicros); + }; + + /// Why a quote could not be produced at all, as distinct from being stale. + /// + /// ⚠️ **Two causes, not one, and conflating them tells a buyer the wrong + /// thing.** Collapsing them into a bare `#unpriceable` is what the + /// frontend renders as *"Payment processing would exceed X. Pick a larger + /// amount."* That is right for the first cause and wrong for the second: a + /// buyer refused because the **simulation scale** is too small has not been + /// charged too much for processing, and picking a larger amount may not help. + public type Unpriceable = { + /// The gross does not clear the Stripe fee — or, defensively, the cached + /// rates cannot produce a cycle figure. The buyer's fix is a larger amount. + #stripeFee; + /// The SCALED cycles would not clear the flat cycles-ledger deposit fee, so + /// this gateway's simulation scale is too small for this purchase. Not the + /// buyer's fault and not fixable by them. + #simulationScale : { scaledCycles : Nat; ledgerFee : Nat }; + }; + + /// One-shot quote from a snapshot of fee config and rates. `#stale` = the + /// caller fails closed (§3.1); `#unpriceable` carries which of the two causes + /// fired. + /// + /// ⚠️ **The Stripe fee is taken BEFORE the divisor and the ledger fee is + /// checked AFTER it**, and that asymmetry is not for symmetry's sake: the buyer + /// really is charged the gross and Stripe really keeps its cut, so those are + /// real dollars and scaling them would misreport what Stripe took. The ledger + /// really charges a flat fee to accept whatever deposit arrives, so it comes + /// off the scaled amount. + /// + /// **Where the division sits in the formula does not matter for correctness**: + /// `floor(floor(a/b)/d) == floor(a/(b*d))` for positive integers. It is + /// written as one division after the rate conversion for clarity, and + /// `checkReceipt` divides at the same point so the recomputation a buyer can + /// follow still reconciles. + public func quote( + cache : Cache, + fee : { feeBps : Nat; feeFixedCents : Nat }, + maxAgeNs : Int, + grossCents : Nat, + nowNs : Int, + divisor : Nat, + ledgerFee : Nat, + ) : { #ok : { cycles : Nat; rates : Rates }; #stale; #unpriceable : Unpriceable } { + let ?net = netCents(fee, grossCents) else return #unpriceable(#stripeFee); + let ?rates = freshRates(cache, maxAgeNs, nowNs) else return #stale; + let ?gross = cyclesForCents(net, rates.xdrPermyriadPerIcp, rates.usdPerIcpMicros) else { + return #unpriceable(#stripeFee); + }; + // ⚠️ `divisor == 1` leaves this exactly `gross`, so production arithmetic is + // bit-identical to having no divisor at all. `validateConfig` rejects 0, so + // this cannot divide by zero. + let cycles = gross / divisor; + if (not clearsLedgerFee(cycles, ledgerFee)) { + return #unpriceable(#simulationScale({ scaledCycles = cycles; ledgerFee })); + }; + #ok({ cycles; rates }); + }; + +}; diff --git a/src/backend/Problems.mo b/src/backend/Problems.mo new file mode 100644 index 0000000..19ac884 --- /dev/null +++ b/src/backend/Problems.mo @@ -0,0 +1,228 @@ +import Array "mo:core/Array"; +import Types "Types"; + +/// Problems that belong to an order, and the resolution semantics for them. +/// +/// **A problem that belongs to an order lives on the order. Nothing drops.** Payments +/// with no order to attach to keep a narrow list of their own (`Orphans.mo`). +/// +/// ## ⚠️ The admission rule +/// +/// > A problem earns its place only if it holds **information that exists nowhere else**, +/// > AND **an action nobody has taken yet**. +/// +/// Both halves are load-bearing. A reading that self-resolves fails the second (a delivery +/// running late is `delayed_deliveries`, not a problem); a record whose every field is +/// copied from the order fails the first. +/// +/// ⚠️ **Identity fields are structural here, not carried.** The order supplies "which +/// order this is about", so a kind carries only **what it says, plus resolution state** — +/// anything already on the order or its journal entry is duplication that can disagree. + +module { + /// The kinds and the record live in `Types.mo`, because `Types.Order` carries them + /// and a module cannot import the module that imports it. This module owns the + /// *semantics*: what a refund can settle, how filing dedups, and the admission rule + /// above. + public type Kind = Types.ProblemKind; + public type Problem = Types.Problem; + + /// Can a `charge.refunded` for the same payment settle this on its own? + /// + /// ⚠️ **True only where the remedy is exactly "refund the fiat".** Anything else + /// needs a decision a webhook cannot make. + public func refundResolvable(kind : Kind) : Bool { + switch (kind) { + case (#duplicate(_)) true; + // Refunding blind here could pay a buyer who already holds their cycles: the + // money position is unknown until someone reads the ledger. + case (#deliveryStuck(_)) false; + // The refund is what CREATED this, so matching on it would close the recorded + // loss the instant it was recorded. + case (#refundAfterDelivery(_)) false; + // ⚠️ **False even though refunding is a legal thing for the operator to do + // here.** The question is whether a `charge.refunded` settles it *on its own*, + // and it does not: the refund returns the money and leaves the order stranded + // in `#created` holding reserve capacity, with no event left that can release + // it. Answering true would auto-close the only record pointing at that. The + // remedy that settles it whole is a **resend**. + case (#paidNotCredited(_)) false; + }; + }; + + /// The payment reference a `charge.refunded` resolves — refund-resolvable kinds + /// only. + /// + /// ⚠️ **That is the accessor, not the payload.** `#refundAfterDelivery` and + /// `#paidNotCredited` both carry a `paymentRef` and both return null here, + /// deliberately and for different reasons — see `refundResolvable`. The two must + /// agree: a refund can settle a problem exactly when this gives the closer + /// something to match on. + public func paymentRefOf(kind : Kind) : ?Text { + switch (kind) { + case (#duplicate({ paymentRef })) ?paymentRef; + case (#deliveryStuck(_) or #refundAfterDelivery(_) or #paidNotCredited(_)) null; + }; + }; + + public func isUnresolved(problem : Problem) : Bool { + problem.resolvedAtNs == null; + }; + + /// File a problem — or **refresh** the unresolved one of the same shape that is + /// already there. + /// + /// ⚠️ **Dedup is by kind-SHAPE, not by equality**, so re-filing on every sweep does not + /// flood — and the order itself carries the guard, so no second structure is needed. + /// + /// ⚠️ **A match REFRESHES the payload; suppressing instead is a regression.** + /// `#refundAfterDelivery`'s `refundedCents` is *cumulative*, so suppression leaves an + /// operator reading the **stale, smaller** figure — and reconciling against Stripe by + /// amount is the whole reason that field is sized. Same for `#deliveryStuck`'s `stage`: + /// it is the money position, and an operator acts on the *current* one. + /// + /// Two separate concerns, then: **shape** decides whether this is the same problem, and + /// a match **updates** what it says. `filedAtNs` is preserved — when the trouble started + /// is not something a refresh may overwrite. + public func file( + problems : [Problem], + kind : Kind, + detail : Text, + nowNs : Int, + ) : { problems : [Problem]; filed : Bool } { + var found = false; + let refreshed = problems.map( + func(p) { + if (not found and isUnresolved(p) and sameShape(p.kind, kind)) { + found := true; + // `filedAtNs` deliberately kept: first seen, not last seen. + { p with kind; detail }; + } else { p }; + } + ); + if (found) return { problems = refreshed; filed = false }; + { + problems = problems.concat([{ kind; detail; filedAtNs = nowNs; resolvedAtNs = null }]); + filed = true; + }; + }; + + /// The reference that identifies **which** problem of a kind this is, where the kind + /// has one. + /// + /// ⚠️ **This is the PAYLOAD, not `paymentRefOf`'s accessor**, and the two must not be + /// confused. `paymentRefOf` answers "can a refund close this", so it withholds the + /// ref from `#refundAfterDelivery` and `#paidNotCredited` on purpose. This answers + /// "which one are we talking about", which those kinds can and must answer. + /// + /// ⚠️ **Null for `#deliveryStuck` because there can only be one.** `sameShape` + /// matches it on the discriminator alone, so a second unresolved one on the same + /// order is unrepresentable — nothing needs distinguishing. + public func identifyingRef(kind : Kind) : ?Text { + switch (kind) { + case (#duplicate({ paymentRef })) ?paymentRef; + case (#refundAfterDelivery({ paymentRef })) ?paymentRef; + case (#paidNotCredited({ paymentRef })) ?paymentRef; + case (#deliveryStuck(_)) null; + }; + }; + + /// Two kinds describe the same problem for dedup purposes. + /// + /// ⚠️ **The discriminator plus the identifying reference, never every field.** Comparing + /// `#refundAfterDelivery`'s cumulative `refundedCents` would file a fresh problem per + /// partial refund; comparing nothing would swallow a genuinely different payment. + /// + /// ⚠️ **Expressed through `identifyingRef` so the dedup key and the operator's selector + /// cannot drift apart.** Problems in an array have no id, so **the key IS the handle** — + /// when they were separate, `resolve_problem` was coarser than the dedup and closed + /// every problem of a kind at once. One definition, both users. + public func sameShape(a : Kind, b : Kind) : Bool { + kindToText(a) == kindToText(b) and identifyingRef(a) == identifyingRef(b); + }; + + /// Mark every unresolved problem matching `pred` resolved. Returns the updated + /// array and how many closed. + public func resolveWhere( + problems : [Problem], + pred : Kind -> Bool, + nowNs : Int, + ) : { problems : [Problem]; closed : Nat } { + var closed = 0; + let out = problems.map( + func(p) { + if (isUnresolved(p) and pred(p.kind)) { + closed += 1; + { p with resolvedAtNs = ?nowNs }; + } else { p }; + } + ); + { problems = out; closed }; + }; + + public func unresolvedCount(problems : [Problem]) : Nat { + var n = 0; + for (p in problems.values()) { if (isUnresolved(p)) n += 1 }; + n; + }; + + /// Short tag for audit lines and operator output. + public func kindToText(kind : Kind) : Text { + switch (kind) { + case (#duplicate(_)) "duplicate"; + case (#deliveryStuck(_)) "deliveryStuck"; + case (#refundAfterDelivery(_)) "refundAfterDelivery"; + case (#paidNotCredited(_)) "paidNotCredited"; + }; + }; + + /// Why `resolve_problem` refused. + /// + /// ⚠️ **A variant, because the three refusals need three different actions** and a + /// caller could previously tell them apart only by matching prose. `#ambiguous` is the + /// one that carries data the operator must act on — the candidate references — and as + /// a `Text` message that list had to be parsed back out of a sentence. + /// + /// `#noSuchOrder` is new information rather than a rename: an unknown id used to + /// produce the same "no unresolved problem" answer as a known order with nothing to + /// close, so a typo in the id read as "already dealt with". + public type ResolveProblemError = { + #noSuchOrder : { orderId : Text }; + #noSuchProblem : { tag : Types.ProblemKindTag }; + /// Several unresolved problems of that kind, so resolving without a reference would + /// close all of them. The identifying reference of each candidate, in store order. + #ambiguous : { tag : Types.ProblemKindTag; candidates : [Text] }; + /// ⚠️ **`?Text`, not `Text`.** This arm is only reachable WITH a reference — the + /// no-reference path either found one candidate or refused as `#ambiguous` — so an + /// empty string here would be a fact that is false rather than one that is missing. + /// An option cannot lie about it, and costs nothing. + #referenceNotFound : { tag : Types.ProblemKindTag; reference : ?Text }; + }; + + /// The kind's discriminator, payload dropped. + /// + /// ⚠️ **Exhaustive by construction, unlike the `Text` comparison it replaces.** This + /// switch and `kindToText` are the same shape, but only this one is checked: adding a + /// fifth `ProblemKind` case makes both a compile error here, where the old + /// `kindToText(kind) == kindTag` comparison would have silently answered false for the + /// new kind and reported "no unresolved problem of that kind". + public func tagOf(kind : Kind) : Types.ProblemKindTag { + switch (kind) { + case (#duplicate(_)) #duplicate; + case (#deliveryStuck(_)) #deliveryStuck; + case (#refundAfterDelivery(_)) #refundAfterDelivery; + case (#paidNotCredited(_)) #paidNotCredited; + }; + }; + + /// The tag as text, for an audit line or an error message. + public func tagToText(tag : Types.ProblemKindTag) : Text { + switch (tag) { + case (#duplicate) "duplicate"; + case (#deliveryStuck) "deliveryStuck"; + case (#refundAfterDelivery) "refundAfterDelivery"; + case (#paidNotCredited) "paidNotCredited"; + }; + }; + +}; diff --git a/src/backend/Purchase.mo b/src/backend/Purchase.mo new file mode 100644 index 0000000..d22c82f --- /dev/null +++ b/src/backend/Purchase.mo @@ -0,0 +1,133 @@ +// ⚠️ `Nat` looks unused: it is what makes `cents.toText()` resolve (M0070 without it). +import Nat "mo:core/Nat"; +import Result "mo:core/Result"; +import Gate "Gate"; +import Pricing "Pricing"; +import Tiers "Tiers"; +import Types "Types"; + +/// The purchase decision: what a buyer's request costs, whether it is admitted, and what +/// it locks — **decided before anything is committed and before any outcall**. +/// +/// ⚠️ **Extracted from `create_order` so the ERROR PRECEDENCE is testable without an IC +/// environment.** Which refusal a buyer sees when several conditions hold at +/// once was previously observable only through PocketIC: an unknown tier and a stale rate +/// both apply, and only the sequence decides which one is reported. That sequence is now +/// a signature with unit tests rather than a run of `switch` blocks in an endpoint. +/// +/// ⚠️ **What deliberately did NOT move: the commit, the outcall, and the order between +/// them.** `create_order` commits the order and takes the reserve hold in one block with +/// no `await`, and only then creates the Stripe session — the order id IS the +/// `client_reference_id`, so it cannot be otherwise. That ordering needs actor +/// capabilities (`raw_rand`, the outcall) and is guarded by integration scenario 67b, +/// which observes the hold while the outcall is parked. Moving it here would make it +/// harder to see, not easier. +module { + + /// Everything the commit and the session need, decided once. + /// + /// ⚠️ **`lockedCycles` is carried, never recomputed.** The commit takes the hold for + /// this figure and the delivery pays it out, with an `await` in between — so a second + /// quote taken after that await would deliver a different number of cycles than the + /// reserve reserved for it. That is the oversell this whole ordering exists to prevent, + /// wearing the costume of a tidier refactor. Integration scenario 67c pins it: the + /// rate is moved while the outcall is parked, and what is delivered is what was held. + public type Plan = { + usdCents : Nat; + /// How the amount was expressed, for the refusals that name it back. + quoteLabel : Text; + lockedCycles : Nat; + pricing : Types.Pricing; + owner : Types.Owner; + }; + + /// Why no plan could be made. + /// + /// ⚠️ **A structural SUBTYPE of `create_order`'s error type, which is why no mapping + /// layer exists.** Motoko variant subtyping lets `#err(e)` return straight out of the + /// endpoint. The arms this cannot produce — `#anonymous`, `#idGeneration`, + /// `#sessionUnavailable`, `#cancelledDuringCreation`, `#destinationNotOwned` — are + /// deliberately absent rather than declared and unreachable (T4). + public type PlanError = { + #unknownTier : Text; + #notAdmitted : Gate.Reason; + #tierBelowFees : Text; + #simulationScaleTooSmall : { scaledCycles : Nat; ledgerFee : Nat }; + #rateUnavailable; + #quoteChanged : { quoted : Nat; minimum : Nat }; + }; + + /// The quote, as the caller's own state provides it. A function rather than the rate + /// cache itself, so this module needs no view of pricing state or of the clock. + public type Quoter = (Nat) -> { + #ok : (Nat, Types.Pricing); + #stale; + #unpriceable : Pricing.Unpriceable; + }; + + /// Admission, as the caller's own gate provides it. Also a function, and for a sharper + /// reason: it reads the live order count and the reserve floor, so passing those as + /// values would make them a snapshot taken before the decision they are meant to gate. + public type Admitter = (Nat) -> Result.Result<(), Gate.Reason>; + + /// ⚠️ **The order of these four steps is the behaviour, not the implementation.** + /// + /// 1. **the amount** — a preset resolves against the tier list, a custom amount is + /// taken as given. Both collapse to gross USD cents here, and everything after is + /// identical for the two, which is the point of the variant. + /// 2. **admission** — a cheap pre-refusal, so a spamming principal is turned away + /// before this does any pricing work. ⚠️ It can only refuse, never admit: the + /// authoritative decision is the one inside the commit block, which additionally + /// checks solvency with no `await` between check and hold. + /// 3. **the quote** — §3's fee formula and §3.1's rate freshness. + /// 4. **the caller's floor** — `minCycles`, checked last because it compares against + /// the quote produced in 3. + /// + /// Step 2 sits between 1 and 3 rather than first because it needs the resolved cents, + /// and before 3 rather than after because pricing is the expensive part. Reordering + /// them changes which refusal a buyer sees when several apply, which is why + /// `test/purchase.test.mo` pins each pair. + public func plan( + caller : Principal, + amount : Types.Amount, + minCycles : ?Nat, + tiers : [Tiers.Tier], + admit : Admitter, + quote : Quoter, + ) : Result.Result { + let (usdCents, quoteLabel) = switch (amount) { + case (#tier(tierId)) { + let ?tier = Tiers.find(tiers, tierId) else return #err(#unknownTier(tierId)); + (tier.usdCents, tierId); + }; + // NOT validated against the presets: a custom amount is any amount the gate + // admits, and the gate is the only bound. Checking it against the tier list would + // make presets a constraint again. + case (#custom(cents)) (cents, cents.toText() # " cents"); + }; + + switch (admit(usdCents)) { + case (#err(reason)) return #err(#notAdmitted(reason)); + case (#ok) {}; + }; + + let (lockedCycles, pricing) = switch (quote(usdCents)) { + case (#ok(quoted)) quoted; + case (#unpriceable(#stripeFee)) return #err(#tierBelowFees(quoteLabel)); + case (#unpriceable(#simulationScale(figures))) { + return #err(#simulationScaleTooSmall(figures)); + }; + case (#stale) return #err(#rateUnavailable); + }; + + switch (minCycles) { + case (?minimum) if (lockedCycles < minimum) { + return #err(#quoteChanged({ quoted = lockedCycles; minimum })); + }; + case null {}; + }; + + #ok({ usdCents; quoteLabel; lockedCycles; pricing; owner = #ii(caller) }); + }; + +}; diff --git a/src/backend/Receipts.mo b/src/backend/Receipts.mo new file mode 100644 index 0000000..c66aecf --- /dev/null +++ b/src/backend/Receipts.mo @@ -0,0 +1,65 @@ +/// The buyer-verifiable receipt: what an order recorded, plus the two rate inputs that +/// make its price reproducible from first principles. +/// +/// ⚠️ **A module rather than a type in `Main.mo`, because TWO endpoints return it** — +/// `receipt` (owner-scoped, a query) and `admin_receipt` (audited, an update) — and since +/// those live in different mixins. A type declared in one mixin is not visible to +/// the other, and a copy in each is exactly the drift the single builder below exists to +/// prevent. +/// +/// ⚠️ Pure: no state, no caller, no authorization. Both endpoints authorize first and +/// then call `of`, so this cannot become a way to read a receipt without one. +import Pricing "Pricing"; +import Types "Types"; + +module { + + public type Receipt = { + order : Types.Order; + /// What the buyer actually paid, if they have. + paidUsdCents : ?Nat; + /// The **cycles-ledger** block the delivery transfer landed in — the on-chain + /// proof, checkable by anyone against that ledger by the order id in the + /// transfer's memo. + deliveryBlockIndex : ?Nat; + /// Cycles delivered to the buyer's account. + cyclesDelivered : ?Nat; + /// Recompute the quote from these and it must equal `order.lockedCycles`: + /// netCents × xdrPermyriadPerIcp × 10¹² / usdPerIcpMicros + /// where netCents = usdCents − (⌈usdCents·feeBps/10⁴⌉ + feeFixedCents). + /// Both rate inputs are queryable from the XRC and the CMC, so the price is + /// reproducible from first principles rather than merely asserted by us. + verification : { + netCents : ?Nat; + usdPerIcpMicros : Nat; + xdrPermyriadPerIcp : Nat; + rateReceivedRates : Nat; + rateQueriedSources : Nat; + }; + }; + + /// One receipt, from an order and its journal entry. + /// + /// ⚠️ **One owner, because there are two endpoints and they must not drift.** `receipt` + /// and `admin_receipt` differ only in who may call and whether the read is audited — + /// the record itself is the same object, and it was built twice, field for field. A + /// verification figure that disagreed between the buyer's copy and the operator's copy + /// would be the worst possible place for a copy-paste divergence. + public func of(order : Types.Order, journal : ?Types.JournalEntry) : Receipt { + { + order; + paidUsdCents = order.paidUsdCents; + deliveryBlockIndex = switch (journal) { case (?entry) entry.blockIndex; case null null }; + cyclesDelivered = switch (journal) { case (?entry) entry.cyclesDelivered; case null null }; + verification = { + // `??`, because the fallback is exactly "unpaid, so quote the order's own figure". + netCents = Pricing.netCents(order.pricing, order.paidUsdCents ?? order.pricing.usdCents); + usdPerIcpMicros = order.pricing.usdPerIcpMicros; + xdrPermyriadPerIcp = order.pricing.xdrPermyriadPerIcp; + rateReceivedRates = order.pricing.rateReceivedRates; + rateQueriedSources = order.pricing.rateQueriedSources; + }; + }; + }; + +}; diff --git a/src/backend/Recovery.mo b/src/backend/Recovery.mo index 4ae456d..54be892 100644 --- a/src/backend/Recovery.mo +++ b/src/backend/Recovery.mo @@ -9,12 +9,14 @@ import Types "Types"; module { - /// Default sweep cadence: 1 h. Well inside the §5.1 bound enforced by - /// `validateInterval`, and it sizes the retry budget: with - /// `maxMintRetries = 25` (Main.mo) an order survives more than a full - /// day of consecutive retriable failures before the notify loop - /// escalates — an outage shorter than a day never strands an order. - public let defaultIntervalNs : Nat = 3_600_000_000_000; + /// Default sweep cadence: **15 min**, well inside the §5.1 ceiling `validateInterval` + /// enforces. + /// + /// ⚠️ **There is no retry budget to pair it with.** A delivery replay is provably + /// safe, so what bounds retrying is time — the ledger's dedup window and §5.3's 72 h + /// max-wait — and this cadence only has to be fast enough to use that window well + /// (see `validateInterval`). + public let defaultIntervalNs : Nat = 900_000_000_000; // 15 min public type IntervalError = { #zeroInterval; @@ -24,12 +26,18 @@ module { }; /// §5.1: recovery cadence must be ≪ the ledger's ~24 h dedup window — a - /// stuck `#minting` order needs several replay attempts while its intent + /// stuck `#paid` order needs several replay attempts while its intent /// can still deduplicate; one sweep per window would burn the only /// chance on a single transient failure. "Several" is pinned to ≥ 4 /// sweeps per window (interval ≤ window / 4). + /// + /// ⚠️ **An upper bound only — a fast cadence is simply a fast cadence.** There is + /// nothing a too-short interval can break: with no retry budget to exhaust, extra + /// sweeps cost gas and find nothing to do. Do not add a lower bound without a + /// failure it prevents. public func validateInterval(intervalNs : Nat, ledgerDedupWindowNs : Int) : Result.Result<(), IntervalError> { - let maxNs = Int.abs(ledgerDedupWindowNs) / 4; + let window = Int.abs(ledgerDedupWindowNs); + let maxNs = window / 4; if (intervalNs == 0) return #err(#zeroInterval); if (intervalNs > maxNs) return #err(#intervalTooLong({ maxNs })); #ok; @@ -39,11 +47,162 @@ module { /// captured) but money-out hasn't finished. `#created` waits on the user /// (expiry is per-rail policy, seam §11.1.4), and the terminal states /// plus `#expired` have nothing left to drive. + /// Is a tally reconcile due? Gated on the last **attempt**, never on the last + /// success. + /// + /// ⚠️ A trap rolls back every state change in its own message, so a reconcile that traps + /// cannot record that it ran. Gating on success would leave a trapping reconcile due on + /// *every* tick, burning a full instruction budget each time. `lastSuccessNs` is + /// deliberately **not a parameter**: taking it invites that bug back. + public func reconcileDue(lastAttemptNs : Int, nowNs : Int, intervalNs : Nat) : Bool { + nowNs - lastAttemptNs >= intervalNs; + }; + public func isSweepable(status : Types.OrderStatus) : Bool { switch (status) { - case (#paid or #minting or #icpAtCmc or #awaitingTreasury) true; - case (#created or #expired or #delivered or #errorQueue) false; + // ⚠️ **Exactly one sweepable status, and that is the design.** Money-out is a + // single transfer out of `#paid`, so there is one status the sweep can drive. + // Adding a second means a second way an order can owe cycles, which is the + // thing this shape exists to prevent. + case (#paid) true; + // `#needsReview` is NOT sweepable: its whole meaning is that the money + // position is unknown and a human must look. Re-driving it automatically + // is the double-spend this status exists to prevent. + case (#created or #cancelled or #expired or #delivered or #needsReview or #abandoned) false; }; }; + + // ── Stranded `#created` capacity ─────────────────────────────────────── + // + // A `#created` order holds its reserve promise from the moment it exists, and two + // things release it: Stripe's `checkout.session.expired`, and the buyer's own + // `cancel_order`. If the expiry event never arrives, the promise holds capacity + // against a session nobody can ever pay — and nothing sweeps `#created`, because + // There is no retention sweep, deliberately. These predicates decide when to ask Stripe. + + /// How long past a session's own deadline to wait before asking Stripe about it. + /// + /// ⚠️ **This margin decides when to ASK, never what is TRUE.** A margin that decided the + /// *outcome* was rejected: releasing capacity on our own copy of the deadline releases + /// cycles a buyer may still be owed. Here the release comes from Stripe's answer either + /// way, so being wrong costs a delayed release, never a wrong one. + /// + /// 30 min = two sweep intervals at the default cadence. Stripe fires the expiry event + /// within seconds, so a healthy gateway is already `#expired` first — the grace makes + /// the outcall count **zero in normal operation** and every firing a real missed event. + public let expiryGraceNs : Nat = 1_800_000_000_000; // 30 min + + /// Cadence for the stranded-`#created` scan, distinct from the delivery sweep's. + /// + /// The delivery sweep is free when idle because `countOf(#paid)` is usually 0 and the + /// O(total orders) scan is skipped. `#created` is usually **non**-zero, so putting this + /// on the 15-minute cadence would make that scan run continuously and throw away the + /// property the delivery sweep was tuned for. A strand needs a sustained failure, so + /// hourly is ample. + public let expiryScanIntervalNs : Nat = 3_600_000_000_000; // 1 h + + /// How often the sweep reconciles the per-status tallies against the order store. + /// + /// Daily, not per-sweep: the reconcile is O(orders) while the tallies exist precisely + /// so the hot queries are O(1), and drift can only come from a bookkeeping bug, which + /// does not need a 15-minute detection window. + public let countReconcileIntervalNs : Nat = 86_400_000_000_000; // 24 h + + /// How often the sweep reconciles the reserve floor against the cycles ledger. + /// + /// Hourly rather than per-sweep because it costs a ledger round trip plus two journal + /// scans, and because what it detects — an unobserved top-up — is an operator action + /// that has `refresh_reserve` for immediacy. The cost of the delay is bounded and + /// one-directional: a floor below the truth under-sells, never over-sells. + public let reserveReconcileIntervalNs : Nat = 3_600_000_000_000; // 1 h + + /// Retrieves per scan pass, resuming on the next pass. + /// + /// ⚠️ **The stranded population is CORRELATED, which is why a per-order cost argument + /// is not enough.** One unprovisioned webhook secret or one frozen canister strands + /// every order in that window at once, so "rare" describes incidents, not orders per + /// incident. Without a cap one incident turns into N outcalls an hour for as long as it + /// lasts. Bounded and resumed, the backlog drains at a known rate instead. + public let maxRetrievesPerPass : Nat = 10; + + /// How long to let Stripe keep trying before a completed-but-uncredited session becomes + /// a human's problem. **4 days** — Stripe redelivers for ~3 (§4.2), plus margin. + /// + /// ⚠️ **A heuristic about when to bother a human, NOT a claim about money.** Stripe never + /// tells us it has given up, so this cannot be derived. The asymmetry makes it safe: + /// wrong-late files an obligation a day later than ideal, while wrong-early buries real + /// obligations under noise in a worklist that drops nothing. **Tightening it is the wrong + /// instinct.** + /// + /// The delay costs nothing on the capacity axis: capacity held against an order the + /// buyer genuinely paid for is *correctly committed* and releases at delivery. The leak + /// is the `expired` case, where capacity is held for a session nobody can ever pay — + /// which is why the two branches legitimately have different urgencies. + public let paidRetryHorizonNs : Nat = 345_600_000_000_000; // 4 days + + /// Is the stranded-`#created` scan due? + public func expiryScanDue(lastAttemptNs : Int, nowNs : Int, intervalNs : Nat) : Bool { + nowNs - lastAttemptNs >= intervalNs; + }; + + /// Should this order's session be asked about? + /// + /// A null `expiresAtNs` answers **false** and that is not an oversight: an order whose + /// session-create response was lost has no deadline *and* no session id, so there is + /// nothing to trigger on and nothing to query with. `expire_order` is the lever for + /// that class, and it is a canister-level fault rather than an operating state. + public func expiryCheckDue( + status : Types.OrderStatus, + expiresAtNs : ?Int, + nowNs : Int, + graceNs : Nat, + ) : Bool { + switch (status, expiresAtNs) { + case (#created, ?deadline) nowNs - deadline >= graceNs; + case _ false; + }; + }; + + /// Has Stripe had long enough that a completed-but-uncredited session is now a + /// standing obligation rather than an event in flight? + /// How long one full coverage cycle of the rotating index scan is expected to + /// take: `⌈stored ÷ chunkSize⌉ × intervalNs`. + /// + /// ⚠️ **This is the detection latency for `orders.unindexedHolders`**, which is P1 + /// because it means the reserve was oversellable. The scan converts unbounded *work* into + /// latency that still grows linearly in stored orders, and this is the number that + /// says how much — reported on `recovery_status` rather than documented, because a + /// documented window rots (the RUNBOOK claimed a 1 h sweep default for months while + /// the code said 15 min). + /// + /// ⚠️ **`intervalNs` is operator-tunable and its ceiling is 24× the default**, so this + /// takes the live cadence as a parameter rather than reading a constant. At 2,000 per + /// chunk and the 15-minute default a 365k store is one cycle in ~1.9 days; at the + /// §5.1 ceiling of 6 h the same store takes ~46 days. + /// + /// **Pure and in this module so the MULTIPLIER can be tested.** As a private func in + /// `Main.mo` its only assertion compared the result to the sweep interval, on a store + /// smaller than one chunk — where the chunk count is always 1, so a version that dropped + /// the store-size factor entirely passed. `test/recovery.test.mo` pins `chunkSize + 1`. + /// + /// The chunk count floors at one: zero would report an instantaneous cycle for an empty + /// store, which reads as "already verified" at exactly the moment nothing has been. + public func indexScanCycleNs(stored : Nat, chunkSize : Nat, intervalNs : Nat) : Nat { + // A zero chunk size would divide by zero; it is a compile-time constant today, so + // this is a guard against a future edit rather than a reachable state, and one + // interval is the answer that cannot understate the window. + if (chunkSize == 0) return intervalNs; + // Ceiling division written without a subtraction: `a + b - 1` warns under M0155 + // (`operator may trap for inferred type Nat`) and the gate runs -Werror, so the + // unreachable underflow has to be unwritten rather than argued about. + let whole = stored / chunkSize; + let chunks = if (stored % chunkSize == 0) whole else whole + 1; + if (chunks == 0) intervalNs else chunks * intervalNs; + }; + + public func paidEscalationDue(createdAtNs : Int, nowNs : Int, horizonNs : Nat) : Bool { + nowNs - createdAtNs >= horizonNs; + }; + }; diff --git a/src/backend/Reserve.mo b/src/backend/Reserve.mo new file mode 100644 index 0000000..30da0ce --- /dev/null +++ b/src/backend/Reserve.mo @@ -0,0 +1,252 @@ +/// Solvency of the cycles reserve. +/// +/// available = icrc1_balance_of(reserveAccount) − promisedTotal +/// +/// The ledger owns the balance and anyone can read it, so this canister never caches +/// it. What only this canister knows is how much of that balance is already spoken for. +/// Selling the same cycles twice is the failure this module prevents. +/// +/// Why the gate can decide against a maintained floor with no ledger call, and the three +/// rules that keep the floor a lower bound: `docs/DESIGN.md` §5.4. +/// +/// `promised` has no fee term: the ledger charges its fee on top of the amount, so +/// delivering `lockedCycles − fee` moves the balance by exactly `lockedCycles`. Summing +/// `lockedCycles + fee` double-counts, under-reports `available`, and refuses sales that +/// would have worked. +/// +/// ⚠️ **`lockedCycles` must stay immutable after creation.** The amount an order promises +/// IS its locked quantity, so no second per-order copy is stored. Anything that mutates +/// it breaks this tally **silently**, and the design then needs a stored per-order amount. +import Result "mo:core/Result"; +import Delivery "Delivery"; +import Types "Types"; + +module { + + /// Is this order's promise still held? + /// + /// ⚠️ **Phrased as "not terminal", never as a list of the statuses that hold.** The two + /// phrasings fail in opposite directions when a status is added: an enumerated hold-list + /// omits the newcomer and **releases its promise while the order is still live**, which + /// oversells the reserve. This phrasing counts the newcomer, which at worst holds cycles + /// longer than needed and shows up in `availableToSell`. + public func holdsPromise(status : Types.OrderStatus) : Bool { + switch (status) { + case (#delivered or #expired or #cancelled or #abandoned) false; + case (_) true; + }; + }; + + /// How the tally moves for one status transition. A sign rather than an amount, so the + /// caller multiplies by the order's own `lockedCycles` and one place knows the + /// arithmetic. + /// + /// ⚠️ **`#created → #paid` is ZERO: release is at DELIVERY, not at payment.** Releasing + /// when the money arrives lets a second order be admitted against capacity the first + /// still needs, and both then have to be paid out of a reserve that only ever covered + /// one. + public func tallyDelta(from : Types.OrderStatus, to : Types.OrderStatus) : { #add; #release; #none } { + switch (holdsPromise(from), holdsPromise(to)) { + case (false, true) #add; + case (true, false) #release; + case (_, _) #none; + }; + }; + + /// Apply a delta to a total, reporting whether it had to saturate. + /// + /// Saturates rather than traps: an underflow means the tally had already diverged, and + /// trapping on the money path over a bookkeeping error is worse than reporting zero. + /// + /// ⚠️ **`saturated` must be surfaced, not dropped.** A silent saturation is + /// indistinguishable from an exact release, so the first evidence would wait for the + /// daily reconcile — up to a day of a wrong tally gating real sales. + public func applyDelta( + total : Nat, + delta : { #add; #release; #none }, + lockedCycles : Nat, + ) : { total : Nat; saturated : Bool } { + switch (delta) { + case (#add) ({ total = total + lockedCycles; saturated = false }); + case (#release) { + if (lockedCycles > total) { + ({ total = 0; saturated = true }); + } else { + ({ total = total - lockedCycles : Nat; saturated = false }); + }; + }; + case (#none) ({ total; saturated = false }); + }; + }; + + /// Why a withdrawal cannot proceed, or the amount it may move. + /// + /// ⚠️ **A structural subtype of `withdraw_reserve`'s error type**, minus `#transferFailed` + /// — which is not a decision, it is what the ledger said afterwards. So `#err(e)` returns + /// straight out of the endpoint with no mapping layer (T4). + public type WithdrawRefusal = { + #ordersOutstanding : { holders : Nat; promised : Nat }; + #nothingToWithdraw; + #belowLedgerFee : { floor : Nat; fee : Nat }; + }; + + /// The whole refusal ladder for `withdraw_reserve` (§5.4), as one decision. + /// + /// **The endpoint calls this TWICE — once before observing the ledger, once after — + /// and that repetition is the point.** An `await` sits inside the observe while the + /// floor is still full, so the holder count has to be re-tested afterwards: a create + /// queued in that window would otherwise be admitted against a reserve about to leave. + /// The re-check used to be a second inline `if`, which is a place for the two tests to + /// drift apart. One function called twice cannot drift. + /// + /// ⚠️ **`holders` must be the INDEX, never the tally.** `applyDelta` clamps a release + /// to zero when the tally has diverged low, so `promised` can read 0 while promise + /// holders still exist — the state `tallySaturations` surfaces. A ladder guarded on the + /// tally would permit a withdrawal with live orders outstanding, which is the one thing + /// it exists to prevent. `promised` is carried in the refusal for diagnosis only, and + /// the two figures disagreeing is itself the signal. + /// + /// Order matters and is asserted in `test/reserve.test.mo`: outstanding orders outrank + /// an empty floor, because "nothing to withdraw" invites a retry while orders + /// outstanding is a state the operator has to clear. + /// The holders gate on its own, because the endpoint needs it BEFORE the floor arms. + /// + /// ⚠️ **The floor cannot be judged until the ledger has been observed.** An unobserved + /// top-up leaves the floor reading 0, so a `#nothingToWithdraw` decided before the + /// observe would strand it — which is the whole reason `withdraw_reserve` observes + /// first. An earlier version of this extraction ran the full ladder in both passes and + /// moved that refusal ahead of the observe; this split is what keeps the original + /// ordering. `withdrawable` calls it too, so the test itself exists once. + public func ordersOutstanding(holders : Nat, promised : Nat) : ?WithdrawRefusal { + if (holders > 0) ?#ordersOutstanding({ holders; promised }) else null; + }; + + public func withdrawable( + holders : Nat, + promised : Nat, + floor : Nat, + fee : Nat, + ) : Result.Result<{ debited : Nat; amount : Nat }, WithdrawRefusal> { + switch (ordersOutstanding(holders, promised)) { + case (?refusal) return #err(refusal); + case null {}; + }; + if (floor == 0) return #err(#nothingToWithdraw); + // Draining means transferring `floor - fee`: the ledger charges the fee on top, so + // below the fee there is nothing recoverable at all. + // ⚠️ **`Delivery.deliverableCycles`, not a copy of it.** These are the TWO outflow + // classes of one account, and Reserve.mo's own framing is "two destination classes, + // ONE outflow mechanism" — a fee correction that diverged between delivery and + // withdrawal is exactly what that framing exists to prevent. An earlier version of + // this function re-implemented the arithmetic here while its comment claimed it was + // "the same function", which replaced a shared call site with a second place to + // change. `Delivery` does not import `Reserve`, so this direction is acyclic. + let ?amount = Delivery.deliverableCycles(floor, fee) else { + return #err(#belowLedgerFee({ floor; fee })); + }; + #ok({ debited = floor; amount }); + }; + + /// The promise total derived independently from the orders themselves. + /// + /// The TEST ORACLE only — no production path may call it, since it is O(every order ever + /// created). `Orders.reconcileBounded` derives the same total from the non-terminal + /// index; what makes this worth keeping is that no index sits in its chain. It shares + /// `holdsPromise` with the tally on purpose and must keep sharing it: that predicate *is* + /// the definition of a promise, and a second one here would make the check fire on + /// disagreements about the definition rather than on the bookkeeping bug. + /// + /// ⚠️ **Never derive it from a stored per-order promise.** A leaked promise would sit in + /// both sums, so the check could never fail — a detector that cannot fail. + public func recount(orders : [Types.Order]) : Nat { + var total = 0; + for (order in orders.values()) { + if (holdsPromise(order.status)) total += order.lockedCycles; + }; + total; + }; + + /// What is left to sell. Saturates at zero because an over-promised reserve is a real + /// state — a risen ledger fee is absorbed by the reserve, which can put the balance + /// fractionally under what is promised. The answer there is "sell nothing", not "trap". + public func available(balance : Nat, promised : Nat) : Nat { + if (promised >= balance) 0 else balance - promised; + }; + + // ── The reserve floor (§5.4) ───────────────────────────────────────────── + // + // ⚠️ **TWO destination classes, ONE outflow mechanism, and the enforcement is the + // actor type rather than this comment.** + // + // The mechanism is `icrc1_transfer` and nothing else. There is a second + // *destination class*, so the phrase "one outflow" must not be read as "one kind of + // recipient" — and the gate step greps declared METHODS, not destinations, so nothing + // else would catch that drift: + // + // 1. **Delivery** — to a buyer's own account, for an order they paid for. Bounded by + // the order's `lockedCycles`, which the gate admitted against the floor, and by + // §2's own-destination rule: `create_order` refuses any destination but the + // caller's. + // 2. **Withdrawal** — to a controller. Bounded by there being **no + // promise-holder at all**, so nothing is owed to any buyer, and it grants a + // controller no capability they lack: a controller can already move the reserve + // by upgrading this canister. `Main.withdraw_reserve` carries the full argument. + // + // Both decrement the floor by `amount + fee` before the transfer is issued (rule 2), + // so the accounting below is identical for either class. + // + // **The enforcement is the actor type, not this comment.** + // `Delivery.CyclesLedgerService` declares exactly `icrc1_transfer` and + // `icrc1_balance_of`. `icrc2_approve` and the ledger's `withdraw` are absent, so this + // canister *cannot* call them — not "does not plan to". A gate step greps the backend + // for such declarations, so adding one has to be deliberate. + // + // ⚠️ **A grep finds `icrc1_transfer` at THREE call sites, and that is still one outflow + // per execution.** Two of them are a delivery's attempt and its `#BadFee` re-issue of + // the same intent; at most one can debit, and the ledger deduplicates if an earlier + // attempt landed. Reading "two call sites" as "two outflows" and adding a second + // decrement double-counts every delivery. The third is `withdraw_reserve`, a + // separate execution entirely, which decrements once by the figure it debits. + + /// What the ledger should read after an outflow, at minimum. Every term is observed or + /// derivable, so the reconcile can recompute it and report drift. + public func floorAfterOutflow(floor : Nat, debited : Nat) : Nat { + if (debited >= floor) 0 else floor - debited : Nat; + }; + + /// Adopt a fresh observation — **and the guard that makes adopting safe.** + /// + /// ⚠️ **`quiet` is the safety property, not an optimisation.** Adopting a balance read + /// taken before an outflow erases that outflow's decrement while the transfer still + /// debits, leaving the floor optimistic with nothing left to re-decrement it. The caller + /// establishes `quiet` by checking nothing was in flight before the read, nothing after + /// it, and nothing was issued in between. Skipping is cheap: a top-up waits, never lost. + public func adoptObservation( + floor : Nat, + observed : Nat, + quiet : Bool, + ) : { floor : Nat; unexplainedShortfall : Nat; adopted : Bool } { + if (not quiet) { + return { floor; unexplainedShortfall = 0; adopted = false }; + }; + if (observed >= floor) { + { floor = observed; unexplainedShortfall = 0; adopted = true }; + } else { + // ⚠️ The ledger holding LESS than the floor with nothing in flight means an outflow + // this canister did not cause, which §5.4's asymmetry says is impossible. Report it + // and adopt anyway: selling against a bound the ledger contradicts is worse than + // under-selling. + { floor = observed; unexplainedShortfall = floor - observed : Nat; adopted = true }; + }; + }; + + /// Can the reserve cover one more order of this size? + /// + /// Inclusive: the ledger charges its fee on top of the amount, and the amount is what is + /// promised, so an order that exactly exhausts what is left is fine. An exclusive check + /// would strand the last order's cycles forever. + public func canCover(floor : Nat, promised : Nat, lockedCycles : Nat) : Bool { + available(floor, promised) >= lockedCycles; + }; + +}; diff --git a/src/backend/Sealed.mo b/src/backend/Sealed.mo new file mode 100644 index 0000000..4a33355 --- /dev/null +++ b/src/backend/Sealed.mo @@ -0,0 +1,213 @@ +/// vetKD sealing: what turns a ciphertext an operator sent into the plaintext +/// secret `Secret.Store` holds. +/// +/// The problem this solves is **provisioning exposure**, and only that. `set_stripe_api_key` +/// used to take the key as `text`, so the value crossed the TLS-terminating boundary node +/// in the clear — a shell history, a CI log and a boundary node all saw a live `rk_...`. +/// Now the operator encrypts to this canister's derived public key, computed offline, and +/// only this canister can open the result. +/// +/// ⚠️ **At rest is NOT addressed here, deliberately.** The decrypted secret lives in +/// canister memory, replicated and checkpointed like any other state. That is irreducible: +/// HMAC verification needs the plaintext, so no scheme keeps it out of memory. Sealing works +/// this through and rejects the alternatives; the posture is `docs/DESIGN.md` §7, and the +/// confidentiality layer is the confidential subnet, not this module. +/// +/// **What the unaudited dependency can and cannot cost us — `docs/DESIGN.md` §7.3.** +/// The encrypting is done client-side by `@icp-sdk/vetkeys`; this side only DECRYPTS. So a +/// bug here fails provisioning closed — the operator gets an error and no secret is stored +/// — rather than weakening a ciphertext that is already on the wire. That asymmetry is why +/// an experimental BLS12-381 is acceptable on this path and would not be on a path where a +/// bug could leak a plaintext. +// ⚠️ `mo:⛔` for `costVetkdDeriveKey` only. The escape hatch is warranted here because the +// alternative is a hardcoded fee that goes stale silently — see `derivationFee` below. +import Prim "mo:⛔"; +// `Array` for the receiver `.toBlob()` on the decrypted `[Nat8]` — the conversion is +// spelled on the source value, so the module for THAT type must be imported. +import Array "mo:core/Array"; +import Blob "mo:core/Blob"; +// ⚠️ Zero `Nat32.` uses and load-bearing: it is what makes `resultCode.toText()` +// resolve (M0070 without it). +import Nat32 "mo:core/Nat32"; +import Result "mo:core/Result"; +import Text "mo:core/Text"; +import G1 "mo:ic-bls12-381/G1"; +import G2 "mo:ic-bls12-381/G2"; +import Scalar "mo:ic-bls12-381/Scalar"; +import Ibe "mo:sealed-secrets-vetkeys/Ibe"; +import VetKey "mo:sealed-secrets-vetkeys/VetKey"; + +module { + + /// The vetKD key to ask for. `key_1` exists on mainnet **and** on a local network, so + /// one constant covers both. + /// + /// ⚠️ **The same NAME, backed by different master keys** — necessarily, since a local + /// network cannot hold mainnet's master secret. So the name does not identify the key, + /// and the client must choose its master key from the network it is talking to. That is + /// `scripts/seal-secret.sh`'s job and it derives the choice from the environment rather + /// than taking it as an argument, because a wrong choice produces a ciphertext nobody + /// can ever open. + public let keyName : Text = "key_1"; + + /// The vetKD **context**: what selects this canister's keypair. + /// + /// ⚠️ **Changing one byte gives this canister an entirely different keypair**, which + /// makes every ciphertext ever sealed to the old one unopenable. It is not a version + /// number and must not be edited to "rotate" anything — rotation is re-sealing the + /// secret, which `Secret.generation` counts. Must match the client exactly. + /// + /// A function rather than a `let` because a module-level binding must be a static + /// expression (M0014) and `encodeUtf8` is a call. Both strings are short, so + /// recomputing per use costs nothing worth caching. + public func context() : Blob = "cyclepay-secrets".encodeUtf8(); + + /// The IBE identity every secret here is sealed to. + /// + /// `(canister, context)` fixes a keypair; this picks one key under it, and **one key + /// opens every ciphertext sealed to it**. That is why holding two secrets costs one + /// derivation rather than two: the API key and the webhook secret share this identity, + /// and which store a plaintext lands in is decided by which endpoint was called — a + /// fact that never reaches vetKD. + /// + /// Named `keyLabel`, not `label`: `label` is a Motoko reserved word. + public func keyLabel() : Blob = "stripe-secrets".encodeUtf8(); + + /// Domain separator turning 32 random bytes into the transport scalar. Nothing + /// interoperates with this value — the subnet only ever sees the matching public key — + /// so it is ours to choose and never has to match the client. + let transportDomain = "cyclepay-vetkd-transport-key"; + + /// `bls12_381_g2`, as `costVetkdDeriveKey` encodes the curve. The management canister's + /// Candid type is a one-case variant (`#bls12_381_g2`); the system API takes a `Nat32`. + let CURVE_BLS12_381_G2 : Nat32 = 0; + + /// The derivation fee to attach, from the system rather than from a constant. + /// + /// **Never write the figure by hand** — the published one in the "VetKeys" section of + /// goes stale, and + /// moc 1.16.0 exposes the replica's own. That is the whole reason for the `mo:⛔` + /// import. + /// + /// ⚠️ **A non-zero result code means the key name or curve was rejected, and that is + /// returned rather than defaulted.** Falling back to a literal on rejection would make + /// a wrong curve encoding invisible: provisioning would keep working on the old constant + /// and nothing would say the system query had stopped answering. + /// + /// ⚠️ **Nothing needs to assert the VALUE.** Underpaying `vetkd_derive_key` is rejected + /// outright, so a wrong fee fails provisioning — which `sealed.spec.ts` already + /// exercises end to end on a real replica. That test is what proves this returns a + /// figure the management canister accepts; it is also the only place it can be proven, + /// since the `mops test` interpreter does not implement the primitive at all + /// (`Value.prim: costVetkdDeriveKey`). + public func derivationFee() : Result.Result { + let (resultCode, cost) = Prim.costVetkdDeriveKey(keyName, CURVE_BLS12_381_G2); + if (resultCode != 0) { + return #err(#vetkdUnavailable({ + detail = "the replica rejected a cost query for key " # keyName + # " (result code " # resultCode.toText() # ")"; + })); + }; + #ok(cost); + }; + + /// Why a provisioning call could not store a secret. + /// + /// ⚠️ **A structural SUPERTYPE of `Secret.SetError`, which is why no mapping layer + /// exists.** Motoko variant subtyping lets `Secret.set`'s `#err(#tooShort …)` return + /// straight out of an endpoint declared with this type. `#tooShort` is therefore listed + /// here without `Secret.mo` importing this module or the reverse. + /// + /// Every arm is a distinct operator action, which is the T4 bar: a bare `text` would + /// force whoever is provisioning to match on prose to tell "you sealed to the wrong + /// network" from "this subnet has no vetKD key". + public type ProvisionError = { + /// The decrypted plaintext was shorter than `Secret.minSecretBytes` — a truncated + /// paste, caught after decryption because that is the first point the real value exists. + #tooShort : { size : Nat; min : Nat }; + /// The argument is not an IBE ciphertext at all. Usually a plaintext secret sent to + /// the new endpoint, or a file that never went through the sealing step. + #notCiphertext; + /// It is a well-formed ciphertext, but not one this canister's key opens. + /// + /// ⚠️ **The wrong-network case lands here**, and so do a wrong `context`, a wrong + /// identity, and tampering — the authenticated check cannot tell them apart, and + /// reporting which failed would tell a prober which half of a forged input was wrong. + /// By far the most likely cause is sealing against the other network's master key. + #notSealedToThisCanister; + /// Decrypted, but the bytes are not UTF-8 — so not a Stripe secret. + #notUtf8; + /// The subnet reported a derived public key that is not a valid `G2` point. + #malformedPublicKey; + /// `vetkd_derive_key` replied with something that is not an encrypted vetKey. + #malformedReply; + /// The reply's halves disagree, or the key that fell out is not a valid signature + /// over `keyLabel`. This is the check that makes a forged reply useless. + #unverifiableReply; + /// `vetkd_derive_key` or `vetkd_public_key` rejected the call. `detail` is the + /// subnet's own wording, which is the T1 case for keeping a `Text` payload: an + /// external system owns the string, and the most common cause — no vetKD key on this + /// subnet — is only distinguishable by reading it. + #vetkdUnavailable : { detail : Text }; + /// `raw_rand` failed, so there is no transport keypair to ask with. + #entropyUnavailable : { detail : Text }; + }; + + /// The single-use transport secret, from `raw_rand` bytes. + /// + /// ⚠️ **The private half never leaves this canister, and that is the whole point.** Only + /// the matching public key is sent, and it goes into the share computation itself — so + /// each node produces a share already encrypted under it and **no node ever assembles + /// the plaintext vetKey**. + public func transportSecret(entropy : Blob) : Scalar.Scalar { + Scalar.hashToScalar(entropy.toArray(), transportDomain); + }; + + /// The public half to send with the derivation request. + public func transportPublicKey(secret : Scalar.Scalar) : Blob { + VetKey.transportPublicKey(secret); + }; + + /// Unwraps `vetkd_derive_key`'s reply into the key that opens our ciphertexts. + /// + /// Strips the transport blinding, then checks the result really is a BLS signature over + /// `keyLabel` under the reported public key — which is what makes a forged reply useless. + /// + /// ⚠️ **Verifying against the key the same subnet reported is circular, and cheap to + /// accept.** A subnet willing to lie here already holds the master key and could decrypt + /// everything regardless. What keeps the ARRANGEMENT honest is on the client: it derives + /// this canister's public key offline from a master key it ships and never asks us for + /// one, so there is no reply for anyone in between to substitute. + public func unwrap( + encryptedKey : Blob, + transport : Scalar.Scalar, + reportedPublicKey : Blob, + ) : Result.Result { + let ?derivedPublicKey = G2.fromCompressed(reportedPublicKey) else return #err(#malformedPublicKey); + let ?reply = VetKey.deserialize(encryptedKey.toArray()) else return #err(#malformedReply); + let ?key = VetKey.decryptAndVerify(reply, transport, derivedPublicKey, keyLabel().toArray()) else { + return #err(#unverifiableReply); + }; + #ok(key); + }; + + /// Opens a sealed secret with the unwrapped key. + /// + /// ⚠️ **Decrypting at provisioning time rather than storing the ciphertext is the + /// design.** It costs one derivation per call instead of one per use — and the + /// per-use derivation because the webhook path would pay ~3.5 cents per event — and it + /// means a ciphertext sealed to the wrong network fails **now**, in front of whoever is + /// seeding it, rather than being accepted and found unreadable at the first webhook. + /// ⚠️ **UTF-8 is checked HERE rather than at first use**, because at first use the only + /// available answer is `#railClosed` — `Main.sessionConfig` reads the store, calls + /// `decodeUtf8`, and has no way to say why. That would present a bad provisioning as a + /// closed rail hours later. The store holds bytes, so nothing downstream re-checks. + public func open(ciphertext : Blob, key : G1.Affine) : Result.Result { + let ?parsed = Ibe.deserialize(ciphertext.toArray()) else return #err(#notCiphertext); + let ?plaintext = Ibe.decrypt(parsed, key) else return #err(#notSealedToThisCanister); + let bytes = plaintext.toBlob(); + let ?_ = bytes.decodeUtf8() else return #err(#notUtf8); + #ok(bytes); + }; + +}; diff --git a/src/backend/Secret.mo b/src/backend/Secret.mo index fa49f6b..0397021 100644 --- a/src/backend/Secret.mo +++ b/src/backend/Secret.mo @@ -1,33 +1,36 @@ -/// Stripe webhook signing secret — the system's only stored secret (§7). +/// A stored Stripe secret (§7). **Two stores use this module**: the webhook signing +/// secret and the Checkout Sessions API key. /// -/// Stored **plaintext in canister state, by design** (no vetKeys). HMAC is -/// symmetric, so "verify" = "forge": whoever reads this blob can mint -/// "paid" webhooks. The documented posture (§7) is: +/// They share the rail's on/off switch: "live" means **both provisioned**, because +/// neither state can complete a purchase — no API key means no payable session, no +/// webhook secret means a buyer can pay and we cannot credit them. /// -/// - **Confidentiality layer: SEV-SNP**, i.e. hardware + attestation, not -/// cryptography in the canister — and only if the target subnet's -/// *checkpoints and state-sync* are confidential too (memory encryption -/// alone does not cover state at rest; verify this hardest, §7). -/// - **Provisioning exposure:** the set/rotate call's argument transits the -/// TLS-terminating boundary node as a plain ingress message. -/// - **Blast-radius backstop (always on):** a leaked secret only lets an -/// attacker mint cycles at operator expense; the per-period ICP burn cap -/// (§5.3, task 10) bounds the drain, off-chain reconciliation detects it, -/// and rotation recovers. Launch must not block on SEV availability. +/// ⚠️ **Stored plaintext in canister state, by design.** HMAC is symmetric, so +/// *verify = forge*: whoever reads the webhook blob can forge "paid" events, and +/// encrypting it would only move the problem to the key that decrypts it. The posture — +/// SEV-SNP as the confidentiality layer, the reserve balance as the always-on blast +/// radius, why a stock beats a per-period cap and the one way it does not — is +/// `docs/DESIGN.md` §7, with the confidential-subnet checklist in RUNBOOK. /// -/// Rotation needs no dual-secret window on this side: while a rolled Stripe -/// secret's predecessor is live, Stripe sends one `v1=` per active secret -/// and Card.verify accepts any single match — so swapping the stored blob -/// at any point during the overlap never drops a delivery. +/// ⚠️ **What a leak of each one buys an attacker differs, and that is why the key's SCOPE +/// matters more than its storage.** The webhook secret spends the reserve. A restricted +/// key scoped to Checkout Sessions = Write can only create sessions that pay **us**, and +/// read them back — which the recovery sweep needs. An unrestricted key, able to refund, +/// would be materially worse to leak. import Blob "mo:core/Blob"; import Result "mo:core/Result"; module { - /// Reject obviously truncated provisioning. Real Stripe secrets are - /// `whsec_` + ≥32 chars (the whole string, prefix included, is the HMAC - /// key); a fat-fingered short paste should fail loudly at set time, not - /// silently 401 every webhook. + /// Reject obviously truncated provisioning: a fat-fingered short paste should + /// fail loudly at set time, not silently reject every webhook. + /// + /// 16, deliberately **below** the length of a real Stripe secret (`whsec_` plus + /// ~32 chars — the whole string, prefix included, is the HMAC key). The floor + /// only has to catch a truncated paste; keeping it under Stripe's real length + /// leaves shorter test secrets usable on a local network, where nothing is being + /// protected. It is not a strength check, and no length here makes a leaked + /// secret safe — see §2 of the RUNBOOK for rotation. public let minSecretBytes : Nat = 16; public type Store = { diff --git a/src/backend/Tiers.mo b/src/backend/Tiers.mo index 4ddd8ae..18dfa8f 100644 --- a/src/backend/Tiers.mo +++ b/src/backend/Tiers.mo @@ -1,10 +1,20 @@ -/// Fixed card tiers (§3): one permanent static Stripe Payment Link per tier, -/// so the paid amount is structurally pinned by which link was used. Tiers -/// are operator config (controllers create the links in the Stripe Dashboard -/// and register them here, §7 "set tiers"); the canister never talks to the -/// Stripe API. `usdCents` is the quote input for net-of-fees pricing (§3, -/// Forex task 7); `paymentLinkUrl` is what the frontend appends -/// `?client_reference_id=` to. +/// Card presets (§3) — operator-configured amounts the UI offers as tiles. +/// +/// **Presentational.** A buyer can order any amount between +/// `Gate.Config`'s floor and ceiling, so a preset is a convenience, not a +/// constraint: it saves typing and nothing more. Two consequences worth stating +/// because both used to be false: +/// +/// - **An empty tier list no longer pauses the rail.** A custom amount is +/// orderable without any preset, so an empty list means only "no tiles shown". +/// The rail's switch is both Stripe secrets being provisioned. +/// - **The amount is no longer pinned by which link was used.** It is pinned by +/// the session the canister creates: `mode=payment`, one line item, inline +/// `price_data` with a fixed `unit_amount`, quantity 1 — see +/// `rails/Session.mo` for the settings that would break that. +/// +/// They stay backend config rather than moving to the frontend so the amounts a +/// gateway offers are on-chain and auditable. import Array "mo:core/Array"; import Result "mo:core/Result"; @@ -13,26 +23,39 @@ module { public type Tier = { /// Operator-chosen handle, referenced by `create_order` and the frontend. id : Text; - /// Gross tier price; the fee formula (task 7) nets this down (§3). + /// Gross preset price; the fee formula (task 7) nets this down (§3). usdCents : Nat; - /// The tier's permanent Stripe Payment Link. - paymentLinkUrl : Text; }; public type ValidateError = { #emptyTierId; #duplicateTierId : Text; #zeroUsdCents : Text; + /// Above `Gate.Config.maxPurchaseUsdCents`. The ceiling exists to catch an + /// operator typo — a tier registered at 100× its intended price would otherwise + /// be quoted and sold without complaint, and the reserve would only notice by + /// emptying. + #aboveCeiling : { id : Text; usdCents : Nat; maxUsdCents : Nat }; + /// Below `Gate.Config.minPurchaseUsdCents`. Registering one would put a tile + /// on screen that `Gate.admit` then refuses — sellable but unpayable, the + /// same defect `#aboveCeiling` prevents at the other end. + #belowFloor : { id : Text; usdCents : Nat; minUsdCents : Nat }; }; /// Config-time sanity: ids non-empty and unique (lookup keys), amounts - /// non-zero (a $0 tier would mint on nothing). O(n²) is fine — tiers are a - /// handful of entries by design. - public func validate(tiers : [Tier]) : Result.Result<(), ValidateError> { + /// non-zero (a $0 tier would deliver on nothing) and within the per-purchase + /// ceiling. O(n²) is fine — tiers are a handful of entries by design. + public func validate(tiers : [Tier], minUsdCents : Nat, maxUsdCents : Nat) : Result.Result<(), ValidateError> { var i = 0; for (tier in tiers.values()) { if (tier.id == "") return #err(#emptyTierId); if (tier.usdCents == 0) return #err(#zeroUsdCents(tier.id)); + if (tier.usdCents > maxUsdCents) { + return #err(#aboveCeiling({ id = tier.id; usdCents = tier.usdCents; maxUsdCents })); + }; + if (tier.usdCents < minUsdCents) { + return #err(#belowFloor({ id = tier.id; usdCents = tier.usdCents; minUsdCents })); + }; var j = 0; for (other in tiers.values()) { if (j > i and other.id == tier.id) { diff --git a/src/backend/Treasury.mo b/src/backend/Treasury.mo deleted file mode 100644 index fa5ebcb..0000000 --- a/src/backend/Treasury.mo +++ /dev/null @@ -1,199 +0,0 @@ -/// Treasury management + ICP burn cap (§5.3) — the pure half. -/// -/// Money-out is funded from one operator-refilled ICP float. This module -/// owns the policy around that float: the **per-period rolling burn cap** -/// (the primary blast-radius bound if the webhook secret leaks — forged -/// "paid" webhooks can't drain more ICP than the cap per window before -/// detection + rotation; it doubles as a safety limit against any mint-path -/// bug), the **pre-gate decision** that sends a Paid order to -/// `#awaitingTreasury` instead of minting when the cap is consumed or the -/// float is short, the **max-wait** that escalates a held order to the error -/// queue, and the **low-float soft-gate signal** the frontend uses to -/// disable tiers. Main.mo owns the impure half — the `icrc1_balance_of` -/// call, the persistent ledger/config, and the driver wiring. -/// -/// Cap accounting is conservative by construction: a mint consumes window -/// budget when its transfer intent commits (before the ledger await), and a -/// later failure never refunds it — over-counting pauses mints early, which -/// is the fail-safe direction for a blast-radius bound. The window "resets" -/// by burns aging out (rolling), or by the operator's manual override. -import Queue "mo:core/Queue"; -import Result "mo:core/Result"; -import Nat "mo:core/Nat"; - -module { - - /// §5.3 treasury policy (§7: controllers may adjust). - public type Config = { - /// Hard ceiling on ICP (e8s of transfer amount) converted to cycles per - /// rolling window. **0 = all mints hold** — the fail-closed default and - /// an explicit operator pause lever. - burnCapE8s : Nat; - /// Rolling window length for the cap. - burnWindowNs : Int; - /// Max time a held order may sit in `#awaitingTreasury` before it - /// escalates to the error queue (operator refunds). - maxHoldNs : Int; - /// Soft-gate / balance-alert threshold on the observed float. - /// 0 = signal disarmed. - lowFloatThresholdE8s : Nat; - }; - - /// burnCapE8s = 0: mints pause until the operator consciously sizes the - /// blast-radius bound — same no-invented-numbers stance as the empty tier - /// list (a default cap in ICP would be a money decision made up here). - /// 24 h window; 72 h max hold (a weekend refill outage recovers, a forgotten - /// order still reaches the operator's worklist); alert disarmed until set. - public func defaultConfig() : Config { - { - burnCapE8s = 0; - burnWindowNs = 86_400_000_000_000; - maxHoldNs = 259_200_000_000_000; - lowFloatThresholdE8s = 0; - }; - }; - - public type ConfigError = { - /// A zero/negative window makes the cap meaningless (nothing ever counts). - #nonPositiveBurnWindow; - /// A zero/negative max hold would escalate every held order instantly. - #nonPositiveMaxHold; - }; - - public func validateConfig(config : Config) : Result.Result<(), ConfigError> { - if (config.burnWindowNs <= 0) return #err(#nonPositiveBurnWindow); - if (config.maxHoldNs <= 0) return #err(#nonPositiveMaxHold); - #ok; - }; - - /// One cap-consuming mint commitment. - public type Burn = { atNs : Int; e8s : Nat }; - - /// Rolling-window accounting, time-ordered (burns append as they commit). - /// Persistent in Main — an upgrade must not reset the blast-radius bound. - public type Ledger = { burns : Queue.Queue }; - - public func emptyLedger() : Ledger { - { burns = Queue.empty() }; - }; - - /// Window consumption: burns younger than the window (age ≥ window = out, - /// the house staleness convention) — so consumption "resets next window" - /// (§5.3) by aging out, no timer needed. - public func burnedInWindow(ledger : Ledger, windowNs : Int, nowNs : Int) : Nat { - var total = 0; - for (burn in ledger.burns.values()) { - if (nowNs - burn.atNs < windowNs) total += burn.e8s; - }; - total; - }; - - /// Record a cap consumption; prunes aged-out burns from the front (the - /// queue is time-ordered, so pruning stops at the first in-window entry). - public func recordBurn(ledger : Ledger, windowNs : Int, e8s : Nat, nowNs : Int) { - ledger.burns.pushBack({ atNs = nowNs; e8s }); - label prune loop { - switch (ledger.burns.peekFront()) { - case (?burn) { - if (nowNs - burn.atNs >= windowNs) { ignore ledger.burns.popFront() } else break prune; - }; - case null break prune; - }; - }; - }; - - /// §5.3 manual override: the operator confirms the window's traffic was - /// legitimate (or has rotated a leaked secret) and clears the consumption. - public func reset(ledger : Ledger) { - ledger.burns.clear(); - }; - - public func size(ledger : Ledger) : Nat { - ledger.burns.size(); - }; - - /// Why a mint was held instead of proceeding (§5.3). - public type HoldReason = { - /// The rolling cap can't absorb this mint. Mints pause until the window - /// rolls or the operator overrides — the §5.3 "pause + alert" state. - #burnCapReached : { burnedE8s : Nat; capE8s : Nat }; - /// The float can't fund the transfer (amount + ledger fee). Clears when - /// the operator refills. - #floatShort : { floatE8s : Nat; neededE8s : Nat }; - }; - - public func holdReasonToText(reason : HoldReason) : Text { - switch (reason) { - case (#burnCapReached({ burnedE8s; capE8s })) { - "burn cap reached: " # burnedE8s.toText() # " of " # capE8s.toText() # " e8s consumed this window"; - }; - case (#floatShort({ floatE8s; neededE8s })) { - "float short: " # floatE8s.toText() # " e8s available, " # neededE8s.toText() # " needed"; - }; - }; - }; - - /// §5.3 pre-gate, decided in one synchronous read. The burn cap is checked - /// **first**: it is the blast-radius bound, so it must hold mints even when - /// the float could fund them (a leaked-secret drain has a full float). - /// Proceed iff the cap absorbs the mint amount (reaching the cap exactly is - /// allowed) AND the float covers amount + transfer fee. - public func gate( - ledger : Ledger, - config : Config, - floatE8s : Nat, - mintE8s : Nat, - transferFeeE8s : Nat, - nowNs : Int, - ) : { #proceed; #hold : HoldReason } { - let burned = burnedInWindow(ledger, config.burnWindowNs, nowNs); - if (burned + mintE8s > config.burnCapE8s) { - return #hold(#burnCapReached({ burnedE8s = burned; capE8s = config.burnCapE8s })); - }; - let needed = mintE8s + transferFeeE8s; - if (floatE8s < needed) { - return #hold(#floatShort({ floatE8s; neededE8s = needed })); - }; - #proceed; - }; - - /// What the driver does with an `#awaitingTreasury` order (§5.3): retry - /// the pre-gate (refill/window-roll may have cleared it), or — at/after - /// the max wait (age ≥ bound, house convention) — escalate to the error - /// queue. `heldSinceNs` is the order's `updatedAtNs`: the hold transition - /// is the last one it took. - public func holdStage(heldSinceNs : Int, nowNs : Int, maxHoldNs : Int) : { #retry; #escalate } { - if (nowNs - heldSinceNs >= maxHoldNs) #escalate else #retry; - }; - - /// Float observation cache shape — queries can't call the ledger, so the - /// balance-alert query reads the last value the mint path (or an admin - /// refresh) saw. - public type FloatObservation = { e8s : Nat; atNs : Int }; - - public func isLowFloat(config : Config, floatE8s : Nat) : Bool { - config.lowFloatThresholdE8s > 0 and floatE8s < config.lowFloatThresholdE8s; - }; - - /// §5.3 soft-gate signal for the frontend (disable tiers when low). An - /// armed threshold with no observation yet reads as low — conservative - /// until the first balance fetch proves otherwise. - public func lowFloatSignal(config : Config, observation : ?FloatObservation) : Bool { - if (config.lowFloatThresholdE8s == 0) return false; - switch (observation) { - case (?seen) isLowFloat(config, seen.e8s); - case null true; - }; - }; - - /// §5.3 balance-alert / soft-gate query payload, assembled by Main. - public type Status = { - config : Config; - burnedInWindowE8s : Nat; - lastObservedFloat : ?FloatObservation; - lowFloat : Bool; - /// Orders currently sitting in `#awaitingTreasury`. - heldOrders : Nat; - }; - -}; diff --git a/src/backend/Types.mo b/src/backend/Types.mo index 75b4f74..2efce46 100644 --- a/src/backend/Types.mo +++ b/src/backend/Types.mo @@ -1,125 +1,374 @@ /// Core shared types for the cycles gateway. /// -/// Decision record: design-docs/ONCHAIN_GATEWAY_SPEC.md — §2 (ownership), -/// §3 (locked cycle quantity), §4 (state machine), §4.2 (data model), -/// §5.1 (transfer intent), §11.1 (binding Base seams). +/// Decision record: `docs/DESIGN.md` — §2 ownership, §3 the locked quantity, +/// §4 the state machine, §4.2 this data model, §5.1 transfer intent, +/// §11.1 the binding seams. import Principal "mo:core/Principal"; module { - /// Seam §11.1.1 — single-case variant from day one. If Base/x402 returns, - /// `#evmAddress : Blob` is a migration-free, compatible extension; a bare - /// `Principal` field would force a stable-state migration plus an audit of - /// every authz site. Pattern-match at every authz check. + /// Seam §11.1.1 — a single-case variant from day one, and it must stay one. + /// Adding a case is a migration-free extension; widening to a bare `Principal` + /// forces a stable-state migration plus an audit of every authz site. public type Owner = { #ii : Principal }; - /// Query authz is `caller == order.owner` (§2). Centralised pattern match; - /// new owner cases make this a compile error rather than a silent assumption. + /// Query authz is `caller == order.owner` (§2). Centralised so that adding an + /// `Owner` case cannot compile until every match on it is updated — ask + /// `mops check -- -Werror` for the list rather than maintaining one here. + /// + /// ⚠️ **That guarantee rests on `-Werror`, not on the language.** A non-exhaustive + /// match is M0145, a *warning*, so a plain `mops check` reports every one and still + /// succeeds — leaving each unhandled site to trap at runtime. On the webhook path + /// that trap is a 5xx Stripe retries for ~3 days. + /// + /// ⚠️ **What `-Werror` does NOT catch:** `Orders.openOrderCount` and + /// `Orders.ownerPage` look owners up through `principalsToOrders`, a + /// Principal-keyed index, and never pattern-match. A non-principal owner would + /// compile clean and silently return nothing for those. Fail-closed, but the seam + /// work has to **reindex**, not just re-match. public func isOwnedBy(owner : Owner, caller : Principal) : Bool { switch (owner) { case (#ii(p)) p == caller; }; }; - /// Money-in rails in scope (§6). Base/x402 deferred (§6.3, §11). - public type Rail = { #card; #ckUsdc }; + /// Money-in rail (§1). Single-case variant for the same reason `Owner` is: it names + /// the dimension, so a second rail is additive rather than a schema-wide edit. + public type Rail = { #card }; /// ICRC-1 account (cycles ledger destination). public type Account = { owner : Principal; subaccount : ?Blob }; - /// Where minted cycles are forwarded (§5, mint-to-self-then-forward). - /// `#canister` deposits to a canister's cycle balance and can fail (deleted - /// target → error queue Type 2); `#cyclesLedgerAccount` essentially never - /// fails (§5, "no pre-validation" decision). + /// Where cycles are delivered (§5) — the buyer's **own** cycles-ledger account, + /// default subaccount. `create_order` refuses anything else, so a crafted call + /// cannot send cycles to a third party. + /// + /// ⚠️ **Depositing straight to a canister's cycle balance is not the case to add.** + /// It fails on a deleted or refusing target *after* the cycles have left — a + /// send-then-lose class this app does not have. public type Destination = { - #canister : Principal; #cyclesLedgerAccount : Account; }; - /// Random, raw_rand-derived (hex text), not a monotonic counter (§2) — the - /// ID sits in the public `client_reference_id`. Not a bearer secret. + /// Whether a destination delivers to `caller` and nobody else. + /// + /// **`null` is the one accepted spelling of the default subaccount, and equivalent + /// forms are refused rather than normalised.** ICRC-1 makes an all-zero + /// 32-byte subaccount the *same account* as `null`, so this rejects a request + /// naming an account the caller does own. Deliberate: a destination is stored, + /// compared and rendered, and two stable values for one account is a defect source + /// — an audit line and a receipt disagreeing about the same account. + /// + /// So `#destinationNotOwned` is imprecise for exactly that input. Accepted + /// because nothing but this app calls it; if a third-party integrator ever does, + /// canonicalise at the edge rather than widening this predicate. + /// + /// A genuinely different subaccount is refused for the plain reason: it is not the + /// balance the app shows or that `icp cycles balance` reads by default, so + /// delivering there strands the buyer's cycles somewhere they will not look. + public func isOwnDestination(destination : Destination, caller : Principal) : Bool { + switch (destination) { + case (#cyclesLedgerAccount(account)) account.owner == caller and account.subaccount == null; + }; + }; + + /// Random, `raw_rand`-derived hex, not a monotonic counter (§2) — the id sits in the + /// public `client_reference_id`. Not a bearer secret. public type OrderId = Text; - /// §4 — one Order, one state machine. Transitions live in Orders.mo; - /// expiry *policy* is per-rail money-in behavior and stays out of the core - /// (seam §11.1.4). + /// §4 — one order, one state machine. Transitions live in `Orders.mo`; expiry + /// *policy* is per-rail money-in behaviour and stays out of the core (§11.1.4). + /// + /// **A state no order can enter must not be representable** (§4). An unreachable + /// status has to be carried by every `switch` and asserted unreachable by a test + /// that passes whether or not the code is right. Deleting the state is the stronger + /// guarantee — the same move as `#cancelled → #paid` being absent from the matrix + /// rather than guarded. public type OrderStatus = { #created; + /// The buyer gave up before paying. Terminal. + #cancelled; #expired; #paid; - #minting; - #icpAtCmc; #delivered; - #awaitingTreasury; - #errorQueue; + /// A money position whose outcome is not known — typically a transfer past the + /// ledger's ~24 h dedup window. A human checks the ledger. The promise is **still + /// held**. + #needsReview; + /// The operator ended the order, having refunded by hand. Terminal, and the + /// promise is **released**. + #abandoned; }; public func statusToText(status : OrderStatus) : Text { switch (status) { case (#created) "Created"; + case (#cancelled) "Cancelled"; case (#expired) "Expired"; case (#paid) "Paid"; - case (#minting) "Minting"; - case (#icpAtCmc) "IcpAtCMC"; case (#delivered) "Delivered"; - case (#awaitingTreasury) "AwaitingTreasury"; - case (#errorQueue) "ErrorQueue"; + case (#needsReview) "NeedsReview"; + case (#abandoned) "Abandoned"; }; }; - /// §3/§6.1 pricing snapshot captured at order creation. `lockedCycles` - /// is the §3 quantity for the *expected* (tier) amount; this snapshot is - /// what lets the webhook honor a **different actual paid amount** at the - /// same locked rate + fee formula instead of a fresh rate — "no quote - /// drift" holds even off the happy path. + /// §3 pricing snapshot captured at order creation, carrying the gross amount, both + /// rate inputs and the fee formula from one consistent epoch. + /// + /// ⚠️ **The webhook does not reprice from it.** The session carries the amount we + /// set, so the webhook requires the paid amount to *equal* the quote and a mismatch + /// delivers nothing. The snapshot is **evidence**: what a buyer recomputes their own + /// price from, and the record of which rates a delivered order was priced at. public type Pricing = { - /// Gross USD cents the order was quoted for (the tier price). + /// Gross USD cents the order was quoted for. usdCents : Nat; - /// XDR-per-USD rate (micros, Forex.mo) locked at creation. - xdrPerUsdMicros : Nat; + /// USD per ICP × 10⁶ from the Exchange Rate Canister at creation. + usdPerIcpMicros : Nat; + /// XDR per ICP × 10⁴ from the Cycles Minting Canister at creation. + /// + /// **Both rate inputs are stored, never the derived result.** Anyone can query + /// the XRC and the CMC and recompute the quote from first principles; storing only + /// the derived number makes the price checkable but not *auditable*. + xdrPermyriadPerIcp : Nat; + /// XRC quality signal for the ICP price: how many sources answered out of how many + /// were asked, and their spread. A price from two sources is not the same product + /// as one from twelve. + rateStandardDeviation : Nat; + rateReceivedRates : Nat; + rateQueriedSources : Nat; /// §3 fee formula at creation. feeBps : Nat; feeFixedCents : Nat; + /// When the rate pair was **read**, not when the order was created. + /// + /// Quotes are served from a timer-refreshed cache, so these rates predate the + /// order by up to the staleness window. Without this field an auditor comparing + /// the stored rates against XRC/CMC history at `createdAtNs` concludes the quote + /// used the wrong rate. + ratesFetchedAtNs : Int; }; - /// Immutable order record; status changes go through Orders.transition, - /// which returns an updated copy. `lockedCycles` is the cycle *quantity* - /// locked at creation (§3) — fulfillment delivers exactly this many cycles - /// regardless of later rate movement; the operator absorbs ICP-cost drift. - /// (Webhook ingestion replaces it via Orders.markPaid when the actual paid - /// amount differs from the quoted tier, repriced from `pricing`.) + /// Immutable order record; status changes go through `Orders.transition`, which + /// returns an updated copy. public type Order = { id : OrderId; owner : Owner; rail : Rail; destination : Destination; + /// The cycle *quantity* locked at creation (§3) — delivery sends exactly this many + /// regardless of later rate movement. + /// + /// ⚠️ **Nothing may write it after creation.** The promise tally is exact rather + /// than conservative because of that; a second writer takes it away **silently**. lockedCycles : Nat; pricing : Pricing; status : OrderStatus; + /// What the buyer **actually paid**, in USD cents; null until paid. Distinct from + /// `pricing.usdCents`, which is what the order was *quoted*. Recorded on the money + /// record so "what did this buyer pay?" is answerable from state. + paidUsdCents : ?Nat; + /// Why an order is `#expired`; null for every other status. + /// + /// Buyer cancellation is **not** here — it is `#cancelled`, a status. "Can this + /// still be paid?" is a transition-matrix question, and answering it with a status + /// plus a runtime check on a provenance field puts two owners on one decision. + expiredBy : ?ExpiredBy; + /// Stripe's `expires_at` for this order's session, in **nanoseconds** (Stripe + /// reports Unix seconds). Null until the session exists — an order still null + /// minutes after creation is a stranded-capacity signal. + /// + /// ⚠️ **Stamped once and never re-stamped.** Expiry used to be recomputed from the + /// *current* global TTL, so changing that config retroactively re-dated every + /// existing order. This is the deadline, singular. + expiresAtNs : ?Int; + /// The Checkout Session this order is paid through. + /// + /// The URL must survive a reload while the order is payable: without it a buyer + /// who closed the tab has no route back, and the open-order cap then refuses them + /// a second attempt. + stripeSessionId : ?Text; + stripeSessionUrl : ?Text; createdAtNs : Int; updatedAtNs : Int; + /// When this order first crossed the delivery alert threshold while waiting, or + /// null if it never did. + /// + /// ⚠️ **Setting this must NOT move `updatedAtNs`.** `Delivery.waitStage` reads + /// `updatedAtNs` as the held-since clock, so touching it here resets the very wait + /// being recorded and the order can never reach the max-hold bound. + delayedAtNs : ?Int; + /// Why an operator ended this order; set only on `#abandoned`. + abandonedReason : ?Text; + /// Problems that belong to this order, with their resolution state. See + /// `Problems.mo` for the kinds and the admission rule they must pass. + problems : [Problem]; + }; + + /// Why an `#expired` order expired. **Two producers and only two** — Stripe's + /// `checkout.session.expired`, and a failure to create the session at all. Nothing + /// else can reach `#expired`, so null means "not `#expired`". + public type ExpiredBy = { + #sessionExpired; + #sessionFailed; + }; + + /// An order-bound problem. The order it hangs off supplies the identity, so no arm + /// carries an `orderId`. + /// Which KIND of problem, without its payload — the discriminator on its own. + /// + /// ⚠️ **Tag-only, and that asymmetry is the design.** `ProblemKind`'s cases carry + /// different payloads: `#deliveryStuck` a stage, the other three a `paymentRef` plus + /// figures. A caller naming *which kind to resolve* has none of that — and + /// `resolve_problem` already takes the reference as its own parameter — so mirroring + /// the full variant would demand data the caller cannot have. + /// + /// ⚠️ **This replaces a `Text` argument.** The set is closed and always was: + /// `Problems.kindToText` rendered these four names and `Orders.unresolvedOfKind` + /// compared against that rendering, so a misspelled tag resolved nothing and said so. + /// Fail-closed, but the compiler checked nothing — now a wrong tag cannot be encoded + /// at all, and the operator console types its command off the actor rather than + /// composing a string. + public type ProblemKindTag = { + #duplicate; + #deliveryStuck; + #refundAfterDelivery; + #paidNotCredited; + }; + + public type ProblemKind = { + /// Refund-resolvable — a genuine second, distinct payment for an order already + /// handled. + #duplicate : { paymentRef : Text }; + /// A delivery stopped where it cannot continue automatically. Not + /// refund-resolvable: the fiat is in, and what happens next depends on the money + /// position rather than on the reason it stopped. + /// + /// ⚠️ **`stage` is a stringly-typed discriminator and must stay advisory.** It is + /// safe only because the journal is the authority. **Never branch a money decision + /// on comparing it** — ask the journal the way `Delivery.terminationFor` does, or + /// make it a variant first. + #deliveryStuck : { stage : Text }; + /// Not refund-resolvable — the money moved *both* ways. A `charge.refunded` + /// arrived for a payment already delivered as cycles, so the fiat went back and the + /// cycles are irreversibly gone. This **records a loss to reconcile** rather than + /// starting a recovery flow. + #refundAfterDelivery : { + paymentRef : Text; + cycles : Nat; + /// Cumulative cents returned to the payer. Sized, because a partial refund is a + /// partial loss and the operator reconciles against Stripe by **amount**. + refundedCents : Nat; + /// Whether that settled the whole charge. + fullRefund : Bool; + }; + /// Stripe says this session was paid and we never credited it. + /// + /// ⚠️ **The order stays `#created` deliberately.** `#created → #paid` is the only + /// edge a resent event can travel, so keeping it there is what preserves the remedy + /// that delivers to the buyer. Route it anywhere else and the operator's only exit + /// becomes a refund. + #paidNotCredited : { paymentRef : Text; sessionId : Text }; }; - /// §5.1 — deterministic transfer args persisted *before* the ledger call - /// (write-intent-before-call). Replaying the identical args is safe: the - /// ledger dedups on `created_at_time` within its ~24h window. + public type Problem = { + kind : ProblemKind; + detail : Text; + filedAtNs : Int; + resolvedAtNs : ?Int; + }; + + /// §5.1 — deterministic transfer args persisted *before* the ledger call. Replaying + /// the identical args is safe: the ledger dedups on `created_at_time` within its + /// ~24 h window. public type TransferIntent = { createdAtTimeNs : Nat64; - amountE8s : Nat; + amountCycles : Nat; to : Account; memo : Blob; }; - /// §4.2 — per-order money-out journal: transfer intent, block_index, - /// minted cycles, retries, timestamps, destination. + /// Every `OrderStatus`, and its index — **the pair a test needs to iterate the status + /// space, kept HERE so `-Werror` can enforce it.** + /// + /// **Do not move this into `test/`, where it cannot do its job.** Measured: + /// `mops check -- -Werror` does not compile `test/` at all, and `mops test` passes + /// `--hide-warnings` (see `mops.toml`), so a non-exhaustive match in a test file is a + /// suppressed M0145 — the array stays at its old length while the type grows, and every + /// suite iterating it quietly tests less than it claims. In `src/` the switch below is + /// compiled by the gate, so an eighth status **fails the build** until it is handled. + /// + /// ⚠️ **What this buys, exactly: `-Werror` forces the VISIT, and the array is two + /// lines below it.** Adding a status fails the build at five sites, this being one, so + /// the developer lands here — and `allStatuses` and `statusCount` are the next thing + /// they read. That is a real improvement on the previous arrangement, where the switch + /// sat in a test file and nothing in the gate compiled it. + /// + /// ⚠️ **What it does NOT buy: a mechanical tie.** Handle the new case here, leave the + /// array at seven, and the bitmask test still passes — it sees seven statuses each + /// exactly once and has no way to ask the type how many there are. A variant omitted + /// from BOTH remains undetectable, as `test/orders.test.mo` said before this moved and + /// as it still says. Motoko cannot express the enumeration, so the guarantee is + /// "you cannot compile without reading this", not "you cannot get it wrong". + /// + /// @test-oracle — no production caller by design: production switches on a status, it + /// never enumerates the space. The enumeration exists so a test can. + public func statusIndex(status : OrderStatus) : Nat { + switch (status) { + case (#created) 0; + case (#cancelled) 1; + case (#expired) 2; + case (#paid) 3; + case (#delivered) 4; + case (#needsReview) 5; + case (#abandoned) 6; + }; + }; + + /// The status space as a value. Hand-written, and tied to the type by `statusIndex` + /// above plus the bitmask test that consumes both. + public let allStatuses : [OrderStatus] = [ + #created, + #cancelled, + #expired, + #paid, + #delivered, + #needsReview, + #abandoned, + ]; + + /// The size the bitmask expects, stated once rather than spelled `2 ** 7 - 1` at the + /// assertion — where a stale literal reads as arithmetic rather than as a claim. + public let statusCount : Nat = 7; + + /// What a buyer asked to buy: a configured preset, or an amount they typed. + /// + /// ⚠️ **In `Types` rather than the mixin because `Purchase.plan` takes it**. + /// Candid derives the name from this declaration, so the interface is unchanged. + public type Amount = { + #tier : Text; + /// Gross USD cents, straight from the buyer. Bounded by + /// `Gate.Config`'s floor and ceiling like any other amount — and bounded + /// **here**, not only in the frontend, because a frontend-only bound is not a + /// bound. + #custom : Nat; + }; + + /// §4.2 — per-order money-out journal. public type JournalEntry = { orderId : OrderId; status : OrderStatus; destination : Destination; transferIntent : ?TransferIntent; blockIndex : ?Nat; - cyclesMinted : ?Nat; + cyclesDelivered : ?Nat; retries : Nat; + /// The ledger's own words from the most recent failed attempt, or null if nothing + /// has failed. + /// + /// **Overwritten, not accumulated.** An operator acts on the *current* obstacle; + /// a growing list of every historical rejection is unbounded state fed by retries, + /// and `retries` already says how many there were. + lastError : ?Text; createdAtNs : Int; updatedAtNs : Int; }; diff --git a/src/backend/Util.mo b/src/backend/Util.mo index c24e1f5..5b97055 100644 --- a/src/backend/Util.mo +++ b/src/backend/Util.mo @@ -1,7 +1,9 @@ /// Small shared helpers (spec §9 layout). Currently: hex codec, used by the /// Stripe signature path (`v1=` values are lowercase hex) and by tests that /// pin known HMAC vectors. -import Blob "mo:core/Blob"; +// `Array` for the receiver `.toBlob()`: the conversion is spelled on the source value, +// and the import is what lets the method resolve. +import Array "mo:core/Array"; import Char "mo:core/Char"; import List "mo:core/List"; import Nat8 "mo:core/Nat8"; @@ -29,7 +31,7 @@ module { }; }; if (pendingHigh != null) return null; - ?Blob.fromArray(bytes.toArray()); + ?bytes.toArray().toBlob(); }; func hexDigit(n : Nat8) : Text { diff --git a/src/backend/Xrc.mo b/src/backend/Xrc.mo new file mode 100644 index 0000000..5e64d78 --- /dev/null +++ b/src/backend/Xrc.mo @@ -0,0 +1,192 @@ +/// Exchange Rate Canister (XRC) binding — the ICP/USD half of §3 pricing. +/// +/// The XRC is an NNS-governed protocol canister that queries multiple exchanges +/// and forex sources, aggregates them, and publishes the result with quality +/// metadata (`standard_deviation`, sources queried vs. answered). It refuses +/// with `InconsistentRatesReceived` rather than returning a number it doesn't +/// trust. +/// +/// **This does not remove the off-chain dependency, it delegates it.** The XRC +/// makes HTTPS outcalls on our behalf — that is exactly what its fee encodes +/// (20 M cycles served from its cache, more when it has to fetch). What we gain +/// is that *this* canister makes no outcall: no `transform`, no +/// replica-divergence problem, no coarse rounding, no retry-for-boundary-split, +/// no IPv6 requirement, and no operator-settable source URL. And the aggregation +/// is done by something better at it than a single free endpoint, with a result +/// any third party can independently query and reproduce. +/// +/// Call contract: **update call**, and exactly 1 B cycles must be attached. +/// Unused cycles are refunded; a minimum is charged even on error. The 1 B is a +/// *liquidity* requirement rather than a cost — the canister must be able to +/// attach it, so `Gate.Config.minCanisterCycles` has to stay comfortably above +/// it or pricing stops working (fail-closed, but the symptom looks like an +/// unexplained rate outage). +import Int "mo:core/Int"; +import Nat "mo:core/Nat"; +import Nat32 "mo:core/Nat32"; +import Nat64 "mo:core/Nat64"; + +module { + + /// Mainnet XRC, and the default whenever nothing overrides it. + /// + /// The **override is a canister environment variable** + /// (`PUBLIC_CANISTER_ID:xrc`), which `icp deploy` stamps into canister settings + /// per environment — not an admin method. That distinction is what keeps this + /// from being the money lever the old settable forex URL was: changing it + /// requires controller rights, which already imply the power to upgrade and + /// drain, so it adds no new authority to anyone. + /// + /// Defaulting to mainnet means **silence is correct**: a production deploy that + /// injects nothing prices off the real XRC. The effective id is reported by + /// `pricing_status` so a monitor can assert it, because the failure that would + /// matter — a mainnet deploy pointed at a mock — is otherwise silent. + public let mainnetCanisterId : Text = "uf6dk-hyaaa-aaaaq-qaaaq-cai"; + + /// The settings key `icp deploy` injects for a project canister named `xrc`. + public let canisterIdEnvVar : Text = "PUBLIC_CANISTER_ID:xrc"; + + /// Exactly this must be attached per request; the remainder is refunded. + public let callCycles : Nat = 1_000_000_000; + + public type AssetClass = { #Cryptocurrency; #FiatCurrency }; + + /// Candid `class` is a Motoko keyword, so the field is `class_` — the Candid + /// name is preserved by the field-name mangling Motoko applies. + public type Asset = { symbol : Text; class_ : AssetClass }; + + public type GetExchangeRateRequest = { + base_asset : Asset; + quote_asset : Asset; + /// Omitted = the current minute. Timestamps have 1-minute granularity. + timestamp : ?Nat64; + }; + + public type ExchangeRateMetadata = { + decimals : Nat32; + base_asset_num_received_rates : Nat64; + base_asset_num_queried_sources : Nat64; + quote_asset_num_received_rates : Nat64; + quote_asset_num_queried_sources : Nat64; + standard_deviation : Nat64; + forex_timestamp : ?Nat64; + }; + + public type ExchangeRate = { + base_asset : Asset; + quote_asset : Asset; + timestamp : Nat64; + /// Scaled by `metadata.decimals`. + rate : Nat64; + metadata : ExchangeRateMetadata; + }; + + public type ExchangeRateError = { + #AnonymousPrincipalNotAllowed; + /// Still gathering — retriable on the next refresh. + #Pending; + #CryptoBaseAssetNotFound; + #CryptoQuoteAssetNotFound; + #StablecoinRateNotFound; + #StablecoinRateTooFewRates; + #StablecoinRateZeroRate; + #ForexInvalidTimestamp; + #ForexBaseAssetNotFound; + #ForexQuoteAssetNotFound; + #ForexAssetsNotFound; + /// We are calling too often — backoff, do not hammer. + #RateLimited; + #NotEnoughCycles; + #FailedToAcceptCycles; + /// Sources disagreed beyond tolerance. The XRC declining to guess is the + /// behaviour we want; treat as retriable. + #InconsistentRatesReceived; + #Other : { code : Nat32; description : Text }; + }; + + public type GetExchangeRateResult = { #Ok : ExchangeRate; #Err : ExchangeRateError }; + + public type Service = actor { + get_exchange_rate : shared GetExchangeRateRequest -> async GetExchangeRateResult; + }; + + /// ICP priced in USD — crypto base, fiat quote. + public func icpUsdRequest() : GetExchangeRateRequest { + { + base_asset = { symbol = "ICP"; class_ = #Cryptocurrency }; + quote_asset = { symbol = "USD"; class_ = #FiatCurrency }; + // Current minute. Asking for a specific past minute is only needed to + // align against another source's timestamp, and here both rates are read + // on the same tick instead. + timestamp = null; + }; + }; + + /// Rescale the XRC's `rate` (which is scaled by `metadata.decimals`) to + /// micro-USD per ICP. Both directions are handled: `decimals` above 6 divides + /// (truncating sub-micro precision, far below any pricing significance) and + /// below 6 multiplies. Null if the rate is zero — a zero price must fail the + /// quote rather than propagate. + public func toMicros(rate : ExchangeRate) : ?Nat { + if (rate.rate == 0) return null; + let value = rate.rate.toNat(); + let decimals = rate.metadata.decimals.toNat(); + if (decimals >= 6) { + // Int-subtract then abs: inside this branch `decimals >= 6`, but that is a + // property of the guard and not of the type, so the Nat form warns (M0155). + // + // ⚠️ M0155 is syntactically selective — verified against moc 1.9.0, it flags + // the var-bound subtraction here but NOT the one in exponent position in the + // `else` branch below, which carries the identical guard-dependent trap + // class. Both are written the safe way for consistency; do not read a + // warning-free build as "no trapping arithmetic". + let divisor = 10 ** Int.abs(decimals.toInt() - 6); + let micros = value / divisor; + if (micros == 0) return null; // rounded away entirely + ?micros; + } else { + // Safe inside this branch (`decimals < 6`) and unflagged by moc — written + // the same way as its sibling above so neither reads as the special case. + ?(value * 10 ** Int.abs(6 - decimals.toInt())); + }; + }; + + /// The quality signal, normalised for storage on an order. + public func qualityOf(rate : ExchangeRate) : { + standardDeviation : Nat; + receivedRates : Nat; + queriedSources : Nat; + } { + { + standardDeviation = rate.metadata.standard_deviation.toNat(); + receivedRates = rate.metadata.base_asset_num_received_rates.toNat(); + queriedSources = rate.metadata.base_asset_num_queried_sources.toNat(); + }; + }; + + /// Whether a failed refresh is worth trying again on the next tick. All XRC + /// errors are transient or configuration problems rather than permanent + /// verdicts, but `RateLimited` specifically means *back further off*, which + /// the caller's backoff handles. + public func errorToText(error : ExchangeRateError) : Text { + switch (error) { + case (#AnonymousPrincipalNotAllowed) "AnonymousPrincipalNotAllowed"; + case (#Pending) "Pending"; + case (#CryptoBaseAssetNotFound) "CryptoBaseAssetNotFound"; + case (#CryptoQuoteAssetNotFound) "CryptoQuoteAssetNotFound"; + case (#StablecoinRateNotFound) "StablecoinRateNotFound"; + case (#StablecoinRateTooFewRates) "StablecoinRateTooFewRates"; + case (#StablecoinRateZeroRate) "StablecoinRateZeroRate"; + case (#ForexInvalidTimestamp) "ForexInvalidTimestamp"; + case (#ForexBaseAssetNotFound) "ForexBaseAssetNotFound"; + case (#ForexQuoteAssetNotFound) "ForexQuoteAssetNotFound"; + case (#ForexAssetsNotFound) "ForexAssetsNotFound"; + case (#RateLimited) "RateLimited"; + case (#NotEnoughCycles) "NotEnoughCycles"; + case (#FailedToAcceptCycles) "FailedToAcceptCycles"; + case (#InconsistentRatesReceived) "InconsistentRatesReceived"; + case (#Other({ code; description })) "Other(" # code.toText() # ": " # description # ")"; + }; + }; + +}; diff --git a/src/backend/dist/backend.did b/src/backend/dist/backend.did index 8501501..6474376 100644 --- a/src/backend/dist/backend.did +++ b/src/backend/dist/backend.did @@ -1,12 +1,41 @@ +type Withdrawn = + record { + debited: nat; + to: Account; + withdrawn: nat; + }; +type WithdrawError = + variant { + belowLedgerFee: record { + fee: nat; + floor: nat; + }; + nothingToWithdraw; + ordersOutstanding: record { + holders: nat; + promised: nat; + }; + transferFailed: text; + }; type ValidateError = variant { + aboveCeiling: record { + id: text; + maxUsdCents: nat; + usdCents: nat; + }; + belowFloor: record { + id: text; + minUsdCents: nat; + usdCents: nat; + }; duplicateTierId: text; emptyTierId; zeroUsdCents: text; }; type TransferIntent = record { - amountE8s: nat; + amountCycles: nat; createdAtTimeNs: nat64; memo: blob; to: Account; @@ -14,55 +43,42 @@ type TransferIntent = type Tier = record { id: text; - paymentLinkUrl: text; usdCents: nat; }; -type Status__1 = - record { - burnedInWindowE8s: nat; - config: Config; - heldOrders: nat; - lastObservedFloat: opt FloatObservation; - lowFloat: bool; - }; type Status = record { generation: nat; isSet: bool; setAtNs: opt int; }; -type SetError = variant {tooShort: record { - min: nat; - size: nat; - };}; type Result_9 = variant { - err: CreateOrderError; - ok: CreatedOrder; + err: ResolveProblemError; + ok: nat; }; type Result_8 = variant { - err: ProcessOrderError; - ok: Order; + err: ValidateError; + ok; }; type Result_7 = variant { - err: ResolveError; - ok: Entry; + err: ConfigError__2; + ok; }; type Result_6 = variant { - err: ValidateError; + err: LivemodeError; ok; }; type Result_5 = variant { - err: ConfigError__2; + err: ConfigError__1; ok; }; type Result_4 = variant { - err: ConfigError__1; + err: ConfigError; ok; }; type Result_3 = @@ -72,28 +88,63 @@ type Result_3 = }; type Result_2 = variant { - err: ConfigError; + err: OriginError; ok; }; -type Result_11 = +type Result_18 = + variant { + err: AbandonError; + ok: Order; + }; +type Result_17 = + variant { + err: Reason; + ok; + }; +type Result_16 = + variant { + err: CancelOrderError; + ok: Order; + }; +type Result_15 = + variant { + err: CreateOrderError; + ok: CreatedOrder; + }; +type Result_14 = + variant { + err: ExpireError; + ok: Order; + }; +type Result_13 = + variant { + err: ProcessOrderError; + ok: Order; + }; +type Result_12 = variant { - err: ClaimCkUsdcError; + err: RecordDeliveredError; ok: Order; }; +type Result_11 = + variant { + err: ListError; + ok; + }; type Result_10 = variant { - err: CreateCkUsdcOrderError; - ok: CreatedCkUsdcOrder; + err: ResolveError; + ok: Entry; }; type Result_1 = variant { - err: SetError; + err: ProvisionError; ok; }; type Result = variant { - err: text; - ok: nat; + err: WithdrawError; + ok: Withdrawn; }; type Response = record { @@ -102,6 +153,19 @@ type Response = status_code: nat16; upgrade: opt bool; }; +type ResolveProblemError = + variant { + ambiguous: record { + candidates: vec text; + tag: ProblemKindTag; + }; + noSuchOrder: record {orderId: text;}; + noSuchProblem: record {tag: ProblemKindTag;}; + referenceNotFound: record { + reference: opt text; + tag: ProblemKindTag; + }; + }; type ResolveError = variant { alreadyResolved: nat; @@ -114,95 +178,242 @@ type Request = method: text; url: text; }; -type Rate = +type RefusalCounts = record { - fetchedAtNs: int; - xdrPerUsdMicros: nat; + amountAboveMax: nat; + amountBelowMin: nat; + buyerNotAllowed: nat; + canisterCyclesLow: nat; + railClosed: nat; + reserveShort: nat; + stripeApiFailed: nat; + tooManyOpenOrders: nat; + unboundedGiveaway: nat; }; -type Rail = +type RecordDeliveredError = variant { - card; - ckUsdc; + notFound: OrderId; + notUnderReview: record { + id: OrderId; + status: OrderStatus; + }; + transitionRefused: OrderId; }; -type PullIntent = +type Receipt = record { - amountUnits: nat; - createdAtTimeNs: nat64; - feeUnits: nat; - fromOwner: principal; - memo: blob; + cyclesDelivered: opt nat; + deliveryBlockIndex: opt nat; + order: Order; + paidUsdCents: opt nat; + verification: + record { + netCents: opt nat; + rateQueriedSources: nat; + rateReceivedRates: nat; + usdPerIcpMicros: nat; + xdrPermyriadPerIcp: nat; + }; }; -type PullEntry = +type Reason = + variant { + amountAboveMax: record { + maxUsdCents: nat; + usdCents: nat; + }; + amountBelowMin: record { + minUsdCents: nat; + usdCents: nat; + }; + buyerNotAllowed; + canisterCyclesLow: record { + balance: nat; + min: nat; + }; + reserveShort: record { + available: nat; + requested: nat; + }; + tooManyOpenOrders: record { + max: nat; + open: nat; + }; + unboundedGiveaway: record {reserveFloor: nat;}; + }; +type Rates = record { - blockIndex: opt nat; - createdAtNs: int; - escalatedAtNs: opt int; - intent: PullIntent; - orderId: OrderId; - updatedAtNs: int; + fetchedAtNs: int; + quality: Quality; + usdPerIcpMicros: nat; + xdrPermyriadPerIcp: nat; + }; +type RailStateLatch = + record { + canisterCyclesLow: bool; + railClosed: bool; + reserveShort: bool; + stripeApiFailing: bool; + unboundedGiveaway: bool; + }; +type Rail = variant {card;}; +type QuotePreviews = + record { + quotes: vec QuotePreview; + rates: opt Rates; + }; +type QuotePreview = + record { + cycles: opt nat; + feeCents: nat; + netCents: opt nat; + usdCents: nat; + }; +type Quality = + record { + queriedSources: nat; + receivedRates: nat; + standardDeviation: nat; + }; +type ProvisionError = + variant { + entropyUnavailable: record {detail: text;}; + malformedPublicKey; + malformedReply; + notCiphertext; + notSealedToThisCanister; + notUtf8; + tooShort: record { + min: nat; + size: nat; + }; + unverifiableReply; + vetkdUnavailable: record {detail: text;}; }; type ProcessOrderError = variant { inFlight; notFound; }; +type ProblemKindTag = + variant { + deliveryStuck; + duplicate; + paidNotCredited; + refundAfterDelivery; + }; +type ProblemKind = + variant { + deliveryStuck: record {stage: text;}; + duplicate: record {paymentRef: text;}; + paidNotCredited: record { + paymentRef: text; + sessionId: text; + }; + refundAfterDelivery: + record { + cycles: nat; + fullRefund: bool; + paymentRef: text; + refundedCents: nat; + }; + }; +type Problem = + record { + detail: text; + filedAtNs: int; + kind: ProblemKind; + resolvedAtNs: opt int; + }; type Pricing = record { feeBps: nat; feeFixedCents: nat; + rateQueriedSources: nat; + rateReceivedRates: nat; + rateStandardDeviation: nat; + ratesFetchedAtNs: int; usdCents: nat; - xdrPerUsdMicros: nat; + usdPerIcpMicros: nat; + xdrPermyriadPerIcp: nat; + }; +type Page__2 = + record { + events: vec Event; + nextCursor: opt nat; + }; +type Page__1 = + record { + nextCursor: opt OrderId; + orders: vec Order; + }; +type Page = + record { + entries: vec Entry; + nextCursor: opt nat; }; type Owner = variant {ii: principal;}; +type OriginError = + variant { + "empty"; + hasQueryOrFragment; + noHost; + notHttps; + }; type OrderStatus = variant { - awaitingTreasury; + abandoned; + cancelled; created; delivered; - errorQueue; expired; - icpAtCmc; - minting; + needsReview; paid; }; type OrderId = text; type Order = record { + abandonedReason: opt text; createdAtNs: int; + delayedAtNs: opt int; destination: Destination; + expiredBy: opt ExpiredBy; + expiresAtNs: opt int; id: OrderId; lockedCycles: nat; owner: Owner; + paidUsdCents: opt nat; pricing: Pricing; + problems: vec Problem; rail: Rail; status: OrderStatus; + stripeSessionId: opt text; + stripeSessionUrl: opt text; updatedAtNs: int; }; +type LivemodeError = variant {simulationDivisorSet: record {divisor: nat;};}; +type ListError = + variant { + alreadyPresent: record {"principal": principal;}; + anonymousNotAllowed; + notPresent: record {"principal": principal;}; + }; type Kind = variant { - duplicate: record { - orderId: OrderId; - paymentRef: text; - }; - stuckMint: record { - orderId: OrderId; - stage: text; - }; unattributed: record { claimedRef: text; paymentRef: text; }; - undeliverable: record { - cycles: nat; - orderId: OrderId; + unprocessable: record { + eventId: text; + field: text; }; }; type JournalEntry = record { blockIndex: opt nat; createdAtNs: int; - cyclesMinted: opt nat; + cyclesDelivered: opt nat; destination: Destination; + lastError: opt text; orderId: OrderId; retries: nat; status: OrderStatus; @@ -230,10 +441,36 @@ type HeaderField = text; text; }; -type FloatObservation = +type Filter = record { - atNs: int; - e8s: nat; + createdFromNs: opt int; + createdToNs: opt int; + owner: opt principal; + status: opt OrderStatus; + withUnresolvedProblems: bool; + }; +type ExpiredBy = + variant { + sessionExpired; + sessionFailed; + }; +type ExpireError = + variant { + movedInFlight: record { + id: OrderId; + status: OrderStatus; + }; + notCreated: record { + id: OrderId; + status: OrderStatus; + }; + notFound: OrderId; + sessionNotOpen: OrderId; + stripeFailed: record { + detail: text; + id: OrderId; + }; + stripeUnauthorized: OrderId; }; type Event = record { @@ -251,136 +488,298 @@ type Entry = rail: Rail; resolvedAtNs: opt int; }; -type Destination = - variant { - canister: principal; - cyclesLedgerAccount: Account; - }; -type CreatedOrder = +type Drift = record { - clientReferenceId: text; - order: Order; + is: nat; + status: text; + was: nat; }; -type CreatedCkUsdcOrder = +type Destination = variant {cyclesLedgerAccount: Account;}; +type DelayedDelivery = record { - amountUnits: nat; - approveUnits: nat; - order: Order; + delayedAtNs: opt int; + heldSinceNs: int; + orderId: OrderId; + pastMaxHold: bool; + retries: nat; + status: OrderStatus; + waitedNs: int; }; +type CreatedOrder = record {order: Order;}; type CreateOrderError = variant { anonymous; + cancelledDuringCreation; + destinationNotOwned; idGeneration; + notAdmitted: Reason; + quoteChanged: record { + minimum: nat; + quoted: nat; + }; rateUnavailable; + reserveUnavailable; + sessionUnavailable: text; + simulationScaleTooSmall: record { + ledgerFee: nat; + scaledCycles: nat; + }; tierBelowFees: text; unknownTier: text; }; -type CreateCkUsdcOrderError = - variant { - aboveMaximum: nat; - amountBelowFees; - anonymous; - belowMinimum: nat; - idGeneration; - railDisabled; - rateUnavailable; - zeroAmount; - }; type Config__2 = record { - feeBps: nat; - feeFixedCents: nat; - ledgerFeeUnits: nat; - maxUsdCents: nat; - minUsdCents: nat; + alertAfterNs: int; + maxHoldNs: int; }; type Config__1 = record { - feeBps: nat; - feeFixedCents: nat; - maxAgeNs: int; - url: text; + maxOpenOrdersPerPrincipal: nat; + maxPurchaseUsdCents: nat; + minCanisterCycles: nat; + minPurchaseUsdCents: nat; }; type ConfigError__2 = variant { - feeBpsTooHigh; - minAboveMax; + alertNotBeforeMaxHold: record { + alertAfterNs: int; + maxHoldNs: int; + }; + nonPositiveAlertAfter; + nonPositiveMaxHold; }; type ConfigError__1 = variant { - feeBpsTooHigh; - nonPositiveMaxAge; - notHttps; + floorAboveCeiling: record { + maxUsdCents: nat; + minUsdCents: nat; + }; + floorUndeliverableAtDivisor: + record { + divisor: nat; + ledgerFee: nat; + minUsdCents: nat; + scaledCycles: nat; + }; + tierAboveCeiling: record { + maxUsdCents: nat; + tierId: text; + usdCents: nat; + }; + tierBelowFloor: record { + minUsdCents: nat; + tierId: text; + usdCents: nat; + }; + zeroOpenOrderCap; + zeroPurchaseCeiling; }; type ConfigError = variant { - nonPositiveBurnWindow; - nonPositiveMaxHold; + divisorChangeWithOrders: record {stored: nat;}; + divisorNeedsSandbox: record {expectLivemode: opt bool;}; + divisorUndeliverable: record { + ledgerFee: nat; + scaledCycles: nat; + }; + feeBpsTooHigh; + maxAgeTooLong: record { + allowedNs: int; + maxAgeNs: int; + }; + nonPositiveMaxAge; + zeroDivisor; + zeroRateDelta; + zeroRateSources; }; type Config = record { - burnCapE8s: nat; - burnWindowNs: int; - lowFloatThresholdE8s: nat; - maxHoldNs: int; + divisor: nat; + feeBps: nat; + feeFixedCents: nat; + maxAgeNs: int; + maxRateDeltaBps: nat; + minRateSources: nat; }; -type ClaimCkUsdcError = +type CancelOrderError = variant { - anonymous; - badFee: record {expectedFee: nat;}; - inFlight; - insufficientAllowance: record { - allowance: nat; - required: nat; - }; - insufficientFunds: record { - balance: nat; - required: nat; - }; - ledgerRejected: text; - notClaimable: text; + alreadyExpired; + credentialsRefused; + notCancellable: record {status: OrderStatus;}; notFound; - retryable: text; - staleIntent; - wrongRail; + sessionNotClosed; + settledInFlight: record {status: OrderStatus;}; + stripeUnavailable; + }; +type Amount = + variant { + custom: nat; + tier: text; }; type Account = record { owner: principal; subaccount: opt blob; }; +type AbandonError = + variant { + deliveryOutstanding: OrderId; + notAbandonable: record { + id: OrderId; + status: OrderStatus; + }; + notFound: OrderId; + reasonRequired; + transitionRefused: OrderId; + }; /// Fully on-chain cycles gateway — composition root. /// -/// See design-docs/ONCHAIN_GATEWAY_SPEC.md (spec v2.1) and PRD.md for the -/// module layout this actor grows into: Orders.mo wiring, rails/, Cmc.mo, -/// Forex.mo, Treasury.mo, ErrorQueue.mo, Auth.mo. +/// The module layout this actor composes: Orders.mo, Delivery.mo, Pricing.mo, +/// Reserve.mo, Gate.mo, Problems.mo, Orphans.mo, Auth.mo, Card.mo, Http.mo. +/// Decision record for the `§N` comments: `docs/DESIGN.md`. service : { - /// §4.2 operational trail, newest-last. Admin: details reference payment - /// intents. Readers detect ring-buffer drops via gaps in `seq`. - audit_log: () -> (vec Event) query; - /// Public — the frontend renders tiers and their Payment Links from this. + /// Stop trying to deliver an order (admin, §7) — **the only path to a + /// terminal non-delivered state.** + /// + /// Nothing in the system gives up on a purchase automatically: a delay raises + /// `delayed_deliveries` and keeps retrying, because its causes are all + /// operator-fixable. This is the deliberate human decision that a purchase + /// will not be completed, and it demands a reason so the trail records *why* + /// alongside *who*. + /// + /// Only reachable from a pre-delivery money-bearing state. A `#created` order + /// has taken no money and needs no decision; a `#delivered` one is done. + abandon_order: (id: OrderId, reason: text) -> (Result_18); + /// Grant the CASES tier to a principal (controller only, audited). + /// + /// ⚠️ **The grant is on a PRINCIPAL, and an admin's principal comes from the origin + /// they signed in at.** The flow is: the admin reads their own principal from + /// `admin_status`, gives it to a controller, and then acts from a CLI identity linked to + /// the same Internet Identity — `icp identity link web --app `. ⚠️ Without + /// `--app` the CLI links a principal derived from the auth domain's own default + /// (`cli.id.ai`), which is not this app, so the grant would sit on a principal the + /// admin never sees. + add_admin: (p: principal) -> (Result_11); + /// Allow a principal to buy while this gateway accepts free test payments + /// (controller only, audited). + add_allowed_buyer: (p: principal) -> (Result_11); + /// Read **any** order by id (admin). + /// + /// ⚠️ **A deliberate exception to §2's "existence is not revealed to non-owners", so it + /// audits itself on every use.** `get_order` is owner-scoped with no admin bypass, so + /// without this an operator could identify *which* order a Stripe receipt named and + /// then not look at it. + /// + /// ⚠️ **The audit line is the price of the exception, not decoration.** It is an + /// `auditAdmin` write, so it names *who* looked, and it fires on the read whether or + /// not the order exists — a probe for existence is exactly what §2 withholds from + /// everyone else, so a miss has to be as visible as a hit. + admin_order: (id: OrderId) -> (opt Order); + /// Filtered, cursor-paginated order list (admin). + /// + /// ⚠️ **Do not sort by `createdAtNs`.** Ordering is by order id, which is arbitrary + /// because ids are random — and time-ordering would mean materialising the filtered set + /// first, which is an unbounded scan. Narrow with `createdFromNs` instead. + /// + /// ⚠️ **Deliberately NOT audited, unlike `admin_order` and `admin_receipt` — do not + /// "fix" the inconsistency.** Their line records *"an operator looked at THIS person's + /// order"*, and that targeted act is the accountable one; a line per page would record + /// the work rather than the intrusion and bury the targeted reads. It would also make + /// this an update, since audits write state, on the call an operator makes repeatedly. + /// Same reasoning keeps `orphans`, `orphan_depth` and `problem_depth` unaudited. + /// + /// **What would change that:** a filter narrowing to a *single named principal* as + /// the normal way to drive this. `owner : ?Principal` makes it possible today; it is not + /// the intended use, and if it becomes one the list inherits the audit. + admin_orders: (filter: Filter, afterId: opt OrderId, limit: nat) -> + (Page__1) query; + /// The same receipt, for **any** order (admin) — and **audited**, which is the + /// whole reason it is a separate method. + /// + /// ⚠️ **The audit is not about existence disclosure; it is about an operator leaving a + /// record of having looked.** `Receipt` embeds the whole `Order`, so an *unaudited* + /// admin path returns exactly what `admin_order` returns with no trace — which makes + /// `admin_order`'s audit **bypassable by calling the other method**. Reading an operator + /// read as harmless because the data is reachable elsewhere is the mistake to avoid. + /// + /// **A separate method rather than a branch, because auditing writes state.** An + /// audited read cannot be a `query`, and folding this into `receipt` would make **every + /// buyer's** receipt read an update — the common path through consensus to serve the + /// rare one. + /// + /// ⚠️ **Auditing is the mitigation for lifting the owner boundary at all.** A path that + /// lifts it without the audit is not a smaller version of the change; it is the change + /// without its safeguard. + admin_receipt: (id: OrderId) -> (opt Receipt); + /// "Is MY principal granted?" — public and caller-scoped. + /// + /// ⚠️ **Deliberately ungated.** An admin who is NOT yet granted has to be able to read + /// their own principal and see that it is not granted; a guarded version would reject + /// exactly the caller who needs the answer, and the UI could not tell "not granted" from + /// "not reachable". It discloses nothing about anyone else: the answer is about `caller`. + admin_status: () -> + (record { + caller: principal; + granted: bool; + isController: bool; + }) query; + /// Who holds the CASES tier (controller only). + /// + /// ⚠️ Controllers are NOT listed — they pass `checkAdmin` without being granted, so an + /// empty list does not mean nobody can act. + admins: () -> (vec principal) query; + /// Who may buy while test payments are accepted (controller only). + /// + /// ⚠️ An empty list does not mean "everyone" — see `allowedBuyers`. + allowed_buyers: () -> (vec principal) query; + /// The operational trail, **paginated**. + /// + /// ⚠️ **Retention is total, which is why this has to paginate.** With no bound on the + /// store, an unpaginated read is on a path to a response nobody can receive. + /// + /// Cursor on `seq`, which now has **no gaps**: gaps used to be how a reader detected + /// drops, and there are no drops. + audit_log: (afterSeq: opt nat, limit: nat) -> (Page__2) query; + /// **Admin: the audit trail, newest first**. + /// + /// The same events `audit_log` returns, in the order an operator reads them: someone + /// opening the console wants what just happened, and the ascending view starts at the + /// first line ever written. Both exist because they answer different questions, and the + /// store stays ascending because an append-only log is. + /// + /// ⚠️ **The cursor is the MIRROR of `audit_log`'s.** `nextCursor` here is the oldest + /// `seq` in the page and is passed back as `beforeSeq` to walk further into the past; + /// there it is the newest, passed back as `afterSeq`. Same `Page` type, opposite + /// meanings. `AuditLog.recentPage` carries the reasoning and the unit tests pin it. + audit_log_recent: (beforeSeq: opt nat, limit: nat) -> (Page__2) query; + /// Admission preflight, public: lets the frontend disable the buy button with + /// a real reason (and lets an operator ask "would a purchase go through right + /// now?") without creating an order. `usdCents` is the gross amount to test. + /// + /// The answer is advisory — it can go stale between this call and + /// `create_order`, which re-checks. It is not an authorization decision, so + /// anonymous callers may ask: it reveals only operational state that + /// `reserve_status` already publishes. Answered for the *calling* principal, + /// so the open-order cap it reports is the caller's own. + can_purchase: (usdCents: nat) -> (Result_17) query; + /// Let a buyer give up on their own unpaid order (owner-scoped). + /// + /// ⚠️ **Load-bearing on the open-order cap**, whose refusal tells the buyer to pay or + /// abandon one — advice they cannot follow without this, since `abandon_order` is + /// admin-only and takes *paid* orders. Remove it and a buyer who opened the cap's worth + /// of checkouts is locked out until their sessions expire. + /// + /// ⚠️ **Nothing is stranded, and the reason is the ORDERING**: the session is expired + /// on Stripe *before* the order moves, so an in-flight payment either wins that race + /// (and the order is not cancelled at all) or it cannot start. `#cancelled → #paid` is + /// absent from the matrix, so a cancelled order is unpayable by construction. + /// + /// No problem filed: nothing is owed, and filing an obligation for an order where no + /// money moved is exactly the noise the worklist must not accumulate. + cancel_order: (id: OrderId) -> (Result_16); + /// Public — the frontend renders the amount tiles from this. There is no link + /// to render: the canister creates a session per order. card_tiers: () -> (vec Tier) query; - /// Public — bounds and fee formula are what users are charged (the same - /// transparency stance as forex_status); the frontend renders the rail - /// panel (or its disabled state) from this. - ck_usdc_config: () -> (Config__2) query; - /// Money-IN journal for one order (admin, ops parity with mint_journal). - ck_usdc_pull: (id: OrderId) -> (opt PullEntry) query; - /// §6.2 pull: after `icrc2_approve` (≥ the order's approveUnits, spender = - /// this canister), the owner claims and the canister pulls the exact quoted - /// price. Idempotent against double-clicks (single-flight) and safe against - /// lost responses (the persisted intent replays bit-identically; the ledger - /// dedups on created_at_time). On success the order is `#paid` and the - /// mint pipeline is kicked — the same money-out path as the card rail. - claim_ck_usdc_order: (id: OrderId) -> (Result_11); - /// Create a ck-USDC order (§6.2): user-chosen amount within operator - /// bounds — nothing structural pins the amount the way a card Payment Link - /// does, and the canister pulls the exact price itself. Same §3 quote path - /// as the card rail (locked cycle quantity, fail-closed on a stale rate), - /// priced with this rail's own fee formula. The flow after this: - /// `icrc2_approve` (user → ledger, ≥ approveUnits) then `claim_ck_usdc_order`. - create_ck_usdc_order: (usdCents: nat, destination: Destination) -> - (Result_10); /// Create a card-rail order: II caller becomes the owner (ownership is /// captured here at the API edge, seam §11.1.3), the tier's USD amount is /// quoted into a locked cycle *quantity* (§3, net of fees at the cached @@ -389,25 +788,121 @@ service : { /// before the refresh await, so one order is always priced from one /// consistent epoch even when a refresh interleaves with a config change; /// the store write after the awaits is atomic. - create_order: (tierId: text, destination: Destination) -> (Result_9); - /// §4.1 operator worklist + retained history. Admin: entries carry - /// payment references and claimed-but-bogus URL params. - error_queue: () -> (vec Entry) query; - /// Cache + params, public: the rate is market data and the fee formula is - /// what users are charged — nothing here is secret, and transparency is - /// the product thesis. - forex_status: () -> (record { - config: Config__1; - rate: opt Rate; - }) query; - /// §3.1 coarse-rounding transform: every replica reduces its raw response - /// to a tiny canonical body (rounded micro-XDR/USD, or empty on parse - /// failure), so consensus is over `"737000"`, not 4 KiB of live JSON. - forex_transform: (args: - record { - context: blob; - response: HttpRequestResult; - }) -> (HttpRequestResult) query; + /// `minCycles` pins the quantity the caller was shown (§3). + /// + /// The rate refresh runs on a timer, so a figure quoted to a buyer can move + /// before they commit — and a client-side re-check cannot close that window, + /// because a query and this update are separate messages. Pinning the + /// expectation here makes the check atomic with the lock, so no order can ever + /// be created at a quantity the buyer was not shown. + /// + /// A **minimum**, deliberately, not an equality: a rate move in the buyer's + /// favour passes through and they keep the extra cycles. The guard can only + /// ever protect the buyer. `null` opts out entirely. + create_order: (amount: Amount, destination: Destination, minCycles: + opt nat) -> (Result_15); + /// §5.2 liveness observability, public (operational transparency, same stance as + /// `reserve_status`): cadence + last completed timer sweep. A null or stale + /// `lastSweep` means recovery is not running. + /// This canister's OWN cycle balance and the floor the admission gate holds it + /// against (public — the same operational-transparency stance as `reserve_status`; + /// it is visible via `canister_status` regardless). + /// + /// ⚠️ **Gas, not stock.** This is what the canister spends to run; the cycles it + /// sells live in its cycles-ledger account and are reported by `reserve_status`. + /// Below the freezing threshold the canister stops accepting updates; at zero it is + /// uninstalled and the order store, journals, and dedup sets go with it. Monitor it + /// separately, and alert well above `minCanisterCycles` — that gate stops *sales*, + /// it does not stop the burn. A sudden acceleration here is the + /// signature of a cycle-drain attempt. + cycles_status: () -> (record { + balance: nat; + floor: nat; + }) query; + /// Orders past `alertAfterNs` and still undelivered (admin, paged). + /// + /// The worklist behind `operator_summary.deliveriesDelayed`: one entry per order, + /// with the journal figures a human needs to decide whether it is stuck or slow. + delayed_deliveries: (afterId: opt OrderId, limit: nat) -> + (record { + entries: vec DelayedDelivery; + nextCursor: opt OrderId; + }) query; + /// Money-out journal for one order (admin, §4.2) — intent, block_index, cycles + /// delivered, retries. + delivery_journal: (id: OrderId) -> (opt JournalEntry) query; + /// The public trust figures — anonymous, safe on a landing page. + /// + /// **The admission test is "what does a POLLER learn from the deltas?", not "does this + /// field name a buyer?"** Cumulative counters are differentiable: anyone sampling this + /// query recovers each delivery's cycles, USD and timing from the increments. That is + /// accepted here because it is not new — `reserve_status` is already public and its + /// `promisedTotal` leaks per-order amounts the same way, and USD is near-derivable from + /// the public `card_tiers`/`quote_previews` — but the field-level reading of the test is + /// what will wave through the field that *does* add something. Do not add a + /// most-recent-order field, a largest-purchase field, or anything per-principal. + /// + /// **`refusingNow` is REUSED, not re-derived.** It is the same `gateState.latch` + /// `refusal_counts` reports, so "is the rail accepting orders" has one definition and + /// cannot come out differently on two surfaces. + /// + /// **The counters read zero on a fresh install and that is correct, not a bug.** + /// Orders are never deleted, but a reinstall replaces the state, so a launch-day figure + /// starts at zero whichever way it is built. + /// + /// **The renderer must show that zero — do NOT add a threshold.** Saying "0 + /// orders delivered is worse than no badge" and that was rejected: an absent number is + /// indistinguishable from a withheld one, and a rule that hides the figure exactly when + /// the news is bad is a misleading presentation rather than a neutral one. This comment + /// used to instruct the opposite, which would have had a future implementer build the + /// thing the decision removed. + /// + /// `nullPaid` should always be 0. It counts delivered orders whose `paidUsdCents` was + /// unset, which `markPaid` makes unreachable — a non-zero value means the USD total is + /// understated and the reason is a bug in this canister, not in the display. + /// **One call, because it is the landing page's whole backend.** `availableToSell` + /// and `refusingNow` also appear on `reserve_status` and `refusal_counts` — that is + /// duplication of the READER, not of the definition: both are read here from the same + /// state those queries read, never recomputed. Folding them in keeps a first paint to a + /// single round trip and a single mock in the test harness. + /// + /// **`availableToSell` leads, and it is a different KIND of number from the + /// others.** It is derived from a balance on the cycles ledger that anyone can query + /// without this canister's cooperation, so a visitor can check it rather than believe + /// it. The delivered totals are ours to report. Do not present them as equivalent. + delivery_stats: () -> + (record { + availableToSell: nat; + deliveredCycles: nat; + deliveredOrders: nat; + deliveredUsdCents: nat; + nullPaid: nat; + refusingNow: RailStateLatch; + }) query; + /// Which Stripe mode this gateway declares it serves, or null while unset. + /// + /// Public because the page reads it: a sandbox deployment says so on every view, and + /// a mismatch against the mode a webhook arrives in is what `Card.handleWebhook` + /// refuses on. `set_expected_livemode` is the controller-only setter. + expected_livemode: () -> (opt bool) query; + /// **Admin: expire one `#created` order, releasing its reserve capacity**. + /// + /// ⚠️ **The lever for the class the sweep structurally CANNOT see**, so do not delete it + /// as redundant with the sweep: an order whose session-create response was lost carries + /// neither `expiresAtNs` (nothing to trigger on) nor `stripeSessionId` (nothing to query + /// with), and Stripe's session list cannot be filtered by `client_reference_id`. + /// + /// **Expire-first, exactly as `cancel_order` does, and for exactly that reason.** + /// Nothing is ever half-expired: if the session is still live on Stripe, the order does + /// not move. A successful expire is what makes "expired" mean *provably unpayable* + /// rather than assumed, because Stripe guarantees a session ends in exactly one of + /// completed/expired. + /// + /// **`#notOpen` changes nothing, and that is not timidity.** It means the session + /// completed or already expired, and those demand opposite actions — expiring an order + /// whose buyer just paid would strand a real payment. Let the webhook (or the sweep) + /// settle it on Stripe's answer. + expire_order: (id: OrderId) -> (Result_14); /// §2 query authz: `caller == order.owner`, null otherwise — existence is /// not revealed to non-owners. Anonymous callers own nothing by /// construction (create_order rejects them), so they always get null. @@ -422,79 +917,528 @@ service : { /// dispatcher re-applies every guard; the route handlers themselves are /// payload-authenticated (HMAC), never caller-authenticated. http_request_update: (req: Request) -> (Response); + /// Public: the frontend needs the bounds to size its amount input and to say + /// what it will accept before the buyer types. Same transparency stance as + /// `pricing_status` and `reserve_status` — these are the rules users are held + /// to, not secrets. + /// + /// **`delivery` is here because `set_delivery_config` had NO reader at all.** + /// `maxHoldNs` decides when a paid order escalates to `#needsReview` and `alertAfterNs` + /// is the delay-alert threshold — both global policy of ours, and both write-only until + /// now: they appeared in the setter's argument and its error variant and in no return + /// type anywhere, so an operator could set them and never read them back. + /// + /// The comment this replaces said there was "no lifecycle *policy* of ours to add" — + /// true of an order's DEADLINE, which is the Stripe session's `expires_at` and lives on + /// the order, and untrue as the general claim it had become. `scripts/check-config-readers.py` + /// is what stops the next write-only parameter, rather than this comment. + lifecycle_config: () -> + (record { + delivery: Config__2; + gate: Config__1; + }) query; /// Order history for the caller (§2, fixes the lost-receipt problem). - list_orders: () -> (vec Order) query; - /// Money-out journal for one order (admin, §4.2) — intent, block_index, - /// minted cycles, retries. - mint_journal: (id: OrderId) -> (opt JournalEntry) query; - /// Manual mint kick (admin, §7) — ops lever for a stuck-looking order; - /// safe to spam, every step is deduped/idempotent/single-flighted. - process_order: (id: OrderId) -> (Result_8); - /// §5.2 liveness observability, public (operational transparency, same - /// stance as treasury_status): cadence + last completed timer sweep. A - /// null or stale `lastSweep` means recovery is not running. + /// The caller's own orders, **paginated**. + /// + /// **This was a latent trap on the BUYER path, not an ergonomic wart.** It returned + /// every order the caller owns, unbounded, and a query response is capped at ~2 MB — + /// so an oversized read does not degrade, it **traps**. The open-order cap of 1 means + /// a buyer accumulates them slowly, but nothing bounded it, and nothing drops orders + /// on the order. + /// + /// **Paging bounded the RESPONSE; `Orders.ownerPage` bounds the WORK.** The + /// admin pager's owner filter walks every principal's orders to find one principal's, + /// so this used to cost O(all orders ever created) in a single message — a page cap on + /// a ~2 MB response, against a limit that is actually instructions. `ownerPage` walks + /// the caller's own index from the cursor instead, and its cost is that buyer's page. + list_orders: (afterId: opt OrderId, limit: nat) -> (Page__1) query; + /// "Is anything wrong right now" in ONE call. + /// + /// **Public is a decision, not a default: alerting needs no credentials.** What + /// reaches a human at 03:00 is a cron on the public queries, and an admin-gated summary + /// would put that back on a credentialed cron. Everything here is a COUNT, never an + /// entry, and `reserve_status` already publishes `totalOrders`, `openOrders`, + /// `expiredOrders`, `promisedTotal` and `availableToSell` — so there is no new exposure + /// class, only one fewer round trip. + /// + /// ⚠️ **The two delivery numbers are measured over DIFFERENT populations, and neither + /// contains the other.** `deliveriesOutstanding` means a transfer has been ISSUED, so it + /// needs the journal entry that records the intent. `deliveriesDelayed` reads the + /// ORDER's own clock and needs neither. + /// + /// Both directions are reachable, so `deliveriesDelayed` is **not** a subset: + /// - outstanding, not delayed: a transfer issued seconds ago. + /// - delayed, not outstanding: a delivery that bailed before issuing — short reserve, + /// stale rate, gas floor — so there is no intent to be outstanding about. + /// - both: a transfer issued long enough ago that the clock ran out, including one that + /// landed without its block recorded. + /// + /// That last case is the CANONICAL outstanding shape (`intent` set, `blockIndex` + /// null), not a delayed-only one — it is what `unsettledDeliveries` exists to detect and + /// what freezes the reconcile's quiet window. Filing it under "delayed, not outstanding" + /// would tell an operator that `outstanding = 0` means no transfer is in flight, when a + /// transfer of unknown fate is exactly what it means. + /// + /// So `outstanding = 0, delayed = 1` is a real state, not the summary contradicting + /// itself — and a UI that presented one as a subset of the other would be wrong exactly + /// where it matters. + /// + /// They also differ in what they ask of a human: `deliveriesOutstanding` self-clears — + /// it is money-out in flight and the answer is wait — while `ordersNeedingReview`, + /// `orphansUnresolved` and `problemsUnresolved` are the three that mean a human is + /// needed. A summary that flattened those would make waiting look like work. + /// + /// **`deliveriesOutstanding` is exactly the reserve reconcile's quiet-window + /// predicate**, deliberately: it is also the answer to "why does the reconcile keep + /// skipping", and sharing the definition means the number an operator reads cannot + /// disagree with the number the reconcile acted on. + /// + /// **What bounds each number, stated because "bounded" alone would hide a difference:** + /// `ordersNeedingReview`, `ordersWithProblems` and `availableToSell` are O(1) tallies. + /// `deliveriesOutstanding` and `deliveriesDelayed` are bounded by `promiseHolders`, i.e. + /// by flow (§5.4). `problemsUnresolved` is bounded by the unresolved-problem index and + /// `orphansUnresolved` walks retained orphan history — both grow only while obligations + /// go uncleared, and an orphan costs a real payment or the signing secret to create + /// (`Orphans.add`), so neither is attacker-inflatable. Not O(1), and not the + /// grows-with-successful-business shape this avoids. + operator_summary: () -> + (record { + availableToSell: nat; + deliveriesDelayed: nat; + deliveriesOutstanding: nat; + ordersNeedingReview: nat; + ordersWithProblems: nat; + orphansUnresolved: nat; + problemsUnresolved: nat; + refusingNow: RailStateLatch; + reserveObservedAtNs: opt int; + }) query; + /// Which order did this Stripe `payment_intent` pay for (admin, §4.2)? The + /// reconciliation lookup: given a charge in the Stripe Dashboard, find the + /// order it funded. Null means the payment was never attributed to an order + /// here — check the order's problems and the orphan list for an obligation carrying it. + order_for_payment: (paymentRef: text) -> (opt OrderId) query; + /// Open-obligation depth, public. + /// + /// Nothing is evicted, so this only comes down by the operator working it. A climbing + /// value means dollars are arriving that nobody has dealt with — the most important + /// operational number on the money path, and public because operational state is not + /// secret (§8). + orphan_depth: () -> (record { + retained: nat; + unresolved: nat; + }) query; + /// §4.1 retained history, oldest first, **paged**. Admin: entries carry + /// payment references and claimed-but-bogus URL params. + /// + /// Paged because unresolved obligations are never evicted, so the queue can + /// grow — and an unpaginated read would eventually exceed Candid's 2 MB + /// message limit, i.e. the record would become unreadable exactly when it + /// mattered most. Pass `null` to start; feed `nextCursor` back until it is + /// null. `limit` is capped at `Orphans.maxPageSize`. + orphans: (afterId: opt nat, limit: nat) -> (Page) query; + /// The operator worklist: open obligations only, paged. Filtered server-side + /// so a large body of resolved history never stands between the operator and + /// the dollars that still need an answer. + orphans_unresolved: (afterId: opt nat, limit: nat) -> (Page) query; + /// Every delivery with money-out work outstanding, right now (admin). + /// + /// The immediate answer to "is a delivery failing?", and it **self-clears by + /// construction** — an entry leaves the moment delivery lands, because landing records + /// the block and moves the status. No resolve step to forget. Read `retries` as "how + /// many times this has already failed"; `0` is a first attempt, not a problem. + /// + /// ⚠️ **Admin even now that it is bounded.** A public version would hand an + /// unauthenticated caller the operator's in-flight worklist; `reserve_status` is the + /// public answer, and O(1) on purpose. + /// + /// ⚠️ The `#paid` subset is **exactly** the reconcile's quiet-window predicate, so this + /// is also how "the reserve reconcile keeps skipping" gets diagnosed. `#needsReview` + /// orders are included because an operator asking "what is wrong right now" wants them, + /// and they are deliberately NOT in that predicate — see `unsettledDeliveries`. + pending_deliveries: () -> (vec JournalEntry) query; + /// Rates + params + refresh liveness, public: both rates are market data any + /// third party can query for themselves, and the fee formula is what users + /// are charged. Nothing here is secret, and reproducibility is the point. + pricing_status: () -> + (record { + config: Config; + lastAttempt: opt record { + atNs: int; + detail: text; + ok: bool; + }; + rates: opt Rates; + xrcCanisterId: opt text; + }) query; + /// How much is outstanding, as two numbers rather than a collection. + /// + /// ⚠️ **The shape to poll, and the reason it exists separately from the list.** A + /// paginated detail query needs a cheap total beside it or a monitor pages through + /// everything to learn one number — the same split `orphan_depth` already has, and + /// the same reason RUNBOOK says to alert on the depth and fetch details only when it + /// fires. + /// + /// ⚠️ **`unresolved` is NOT `orders`.** One order can carry several problems, so a + /// caller reading the order count undercounts the work. + problem_depth: () -> (record { + orders: nat; + unresolved: nat; + }) query; + /// Manual delivery kick — **admin, or the order's own owner**. + /// + /// Safe to spam by construction: every step is journalled, deduplicated, idempotent + /// and single-flighted. A page refresh heals a stuck order in seconds rather than + /// waiting a sweep interval. + /// + /// ⚠️ **Owner-scoped, not public.** `getOwned` is the guard, so a caller can only kick + /// their OWN order — one order per kick, serialised, on an order they paid real money + /// to create. *Unauthenticated* traffic triggering a sweep over **every** order is a + /// different shape and stays refused. + /// + /// ⚠️ **This does not replace the recovery sweep and must not be read as making it + /// optional.** The sweep is the *guarantee* — we took the money, so we deliver whether + /// or not the buyer comes back; this is the *latency fix*. A retry that only exists in + /// the UI makes fulfilling an obligation depend on the buyer returning, and whoever + /// closed the tab is exactly who most needs us to finish. + /// + /// **An owner kicking their own order is NOT audited**, because the log drops + /// nothing and a refresh loop would be permanent state growth driven by a + /// caller. An admin kick is audited — it is an ops action on someone else's order. + process_order: (id: OrderId) -> (Result_13); + /// Batch pre-purchase quote, public. + /// + /// ⚠️ **The price a buyer is shown is computed by the SAME function that prices the + /// order.** A client reimplementing the formula would be one refactor away from quoting + /// a number the gateway does not honour, with no way for the buyer to tell which was + /// wrong. Batched because the tier grid needs every price in one round trip. + /// + /// **Unbounded input on purpose**, unlike the paged queries. The work is constant + /// per element the caller already transmitted — no state scan, no amplification — so + /// the ingress size limit already bounds it. A cap would only buy **silent truncation**, + /// which is worse than what it prevents. + /// + /// Does not disclose the cycles-ledger fee: that is the ledger's number and the + /// operator's cost — `docs/DESIGN.md` §3.2 for the split and why a stored copy would + /// be wrong here. + quote_previews: (amounts: vec nat) -> (QuotePreviews) query; + /// Everything the **buyer** needs to verify their own purchase (§2 authz: + /// `caller == order.owner`). + /// + /// The buyer can **check** the claim rather than take it: recompute the quote from the + /// two recorded rate inputs, and look up the block index on the ledger. + /// ⚠️ **Owner-only, and a `query`, which is why the admin path is a separate method.** + /// See `admin_receipt`. Auditing writes state, so an audited read cannot be a query — + /// and folding the admin case in here would have made **every buyer's** receipt read + /// an update, putting the common path through consensus to serve the rare one. + receipt: (id: OrderId) -> (opt Receipt) query; + /// Record that an escalated order's cycles **did** reach the buyer (admin, §7). + /// + /// The counterpart to `abandon_order`, and the reason the + /// `#needsReview → #delivered` edge exists. `#needsReview` means "we could not establish + /// whether the transfer landed"; when the operator establishes on the cycles + /// ledger that it did, this is how they say so. Without it their only lever was + /// `abandon_order`, which files a delivered order as abandoned and audits a refund + /// that never happened. + /// + /// ⚠️ **The block index is required, and it is not decoration.** It is the evidence + /// that this call is a *finding* rather than a guess, it goes into the journal so + /// the receipt shows the same proof any other delivered order shows, and demanding + /// it means the operator has actually looked. The order id is in the transfer's + /// memo, so the lookup is a search on the ledger, not a reconstruction. + /// + /// ⚠️ It moves no money and must not: the cycles are already gone. It also does not + /// credit the reserve floor back — the floor already assumed the debit when the + /// transfer was issued (rule 2), and this call is the confirmation that the + /// assumption was right. + record_delivered: (id: OrderId, blockIndex: nat) -> (Result_12); + /// Run the reconcile now rather than waiting for the daily one (admin, §7). + /// + /// The tallies are maintained incrementally so the public status queries stay O(1); + /// this is the on-demand lever for the case where they are ever suspected of having + /// drifted. Returns the counts as they stand after the pass. + /// + /// ⚠️ **It is the same bounded pass the timer runs, with the same one-directional + /// rule — it is NOT a stronger repair, and an operator must not reach for it as one.** + /// `Orders.adoptOnlyIncreases` refuses a recount lower than the maintained tally, + /// here exactly as on the timer, because a lower recount is indistinguishable from an + /// incomplete index and adopting it is the only way an index bug could oversell the + /// reserve. There is deliberately **no** force flag and no full-scan rebuild: a lever + /// for adopting the unsafe direction would be a lever for the bug. + /// + /// **No longer the expensive path**, and it stays admin-only anyway — it writes + /// tallies the gate reads. + recount_orders: () -> (vec record { + text; + nat; + }); + /// The recovery machinery's own clocks and its last findings, public. + /// + /// Four independent passes report here — the stranded sweep, the tally reconcile, the + /// reserve reconcile and the rotating index scan — because each can stop running + /// without any of the others noticing. RUNBOOK's monitoring section alerts on the gaps between them. recovery_status: () -> (record { + indexScan: + record { + chunkSize: nat; + expectedFullCycleNs: nat; + inFlightCycle: + record { + ordersRead: nat; + repairs: nat; + startedAtNs: int; + }; + lastCompletedCycle: + opt + record { + completedAtNs: int; + ordersRead: nat; + repairs: nat; + startedAtNs: int; + }; + storedOrders: nat; + }; intervalNs: nat; + lastCountReconcile: + opt + record { + atNs: int; + drift: vec Drift; + ordersRead: nat; + refused: vec Drift; + }; + lastCountReconcileAttemptNs: int; + lastReserveReconcileAttemptNs: int; lastSweep: opt record { atNs: int; pending: nat; }; sweepInFlight: bool; }) query; - /// On-demand float refresh (admin — public would let anyone spend our - /// cycles on ledger calls). The mint pre-gate refreshes the observation as - /// a side effect; this is the ops lever between mints. - refresh_float: () -> (nat); - /// §5.3 manual override: clear the rolling window's consumption after - /// confirming the traffic was legitimate (or rotating a leaked secret). - /// Returns the e8s of consumption cleared. Held orders resume on the next - /// sweep, not here — the pre-gate stays the single decision point. - reset_burn_window: () -> (nat); - /// Clear a stuck pull intent so the order becomes claimable again — ONLY - /// after verifying on the ck-USDC ledger that no transaction matches the - /// intent (a fresh intent after an executed-but-unrecorded pull would debit - /// the user twice). Refuses when a block is recorded (money moved). False = - /// nothing cleared. - reset_ck_usdc_pull: (id: OrderId) -> (bool); - /// Manual resolution (§4.1/§7): refund issued off-chain, or Type 2 cycles - /// re-delivered/refunded. (`charge.refunded` resolves Type 1 entries - /// automatically; this is the fallback and the only path for Type 2.) - resolve_error: (id: nat) -> (Result_7); - /// Replace the card tier config (§3/§7 — admin, validated atomically: a - /// bad config never partially applies). - set_card_tiers: (tiers: vec Tier) -> (Result_6); - /// Adjust the rail config (§7): amount bounds, fee formula, ledger fee. - /// Validated atomically — a bad config never partially applies. - set_ck_usdc_config: (config: Config__2) -> (Result_5); - /// Adjust forex params (§7): fee formula, staleness window, source URL. + /// Force a rate refresh now (admin) — the ops lever after retuning config or + /// while diagnosing a stale rate, without waiting for the next tick. + refresh_rates: () -> (opt Rates); + /// On-demand reserve refresh (admin — a public one would let anyone spend our + /// cycles on ledger calls). The sweep does this hourly; this is the lever for + /// right after `icp cycles transfer`, so a top-up is sellable immediately. + /// + /// **`scripts/local-dev-seed.sh` and RUNBOOK's top-up step call this**, and + /// forgetting it is invisible to every typecheck: the floor stays at zero, so the + /// gateway refuses every sale against a fully funded reserve and nothing anywhere + /// says why. + refresh_reserve: () -> (nat); + /// Refusal tallies, and whether the gate is refusing right now. + /// + /// ⚠️ **Public, like every other monitoring surface here** — operational state + /// is public by design; the webhook secret is the only secret in the system. + /// + /// ⚠️ **This query is the point of the counters.** A tally nobody reads is the + /// `Orders.tallySaturations` failure over again, so RUNBOOK's monitoring section carries a row + /// per counter with the response — the counters mean different things: + /// `amountBelowMin` climbing is a UI bug or an attacker probing, while + /// `reserveShort` climbing is a refill. Same shape, opposite actions. + refusal_counts: () -> + (record { + counts: RefusalCounts; + refusingNow: RailStateLatch; + }) query; + /// Revoke the CASES tier (controller only, audited). + remove_admin: (p: principal) -> (Result_11); + /// Revoke a buyer's allowance (controller only, audited). + /// + /// ⚠️ **Removing the LAST entry does not open the gateway up — it closes it.** + /// An empty list plus a funded reserve plus test payments is + /// `Gate.Reason.unboundedGiveaway`, which refuses everyone. The audit line says + /// so, because "revoked the last buyer" and "the gateway stopped selling" are + /// the same event and an operator should not have to connect them later. + remove_allowed_buyer: (p: principal) -> (Result_11); + /// Reserve solvency and order counters, public. + /// + /// `reserveFloor` − `promisedTotal` = `availableToSell`, in one answer, so "the ledger + /// says 100 T and the gateway will sell 0" is diagnosable at a glance (§3.2). + /// + /// ⚠️ **`reserveFloor` is a maintained lower BOUND, not the balance, and must not cache + /// one.** Caching invents a staleness class over a number the caller can read from the + /// source. A floor far below the ledger's balance means nothing has reconciled since the + /// last top-up — `reserveObservedAtNs` says when it last did, `refresh_reserve` is the + /// lever. + /// + /// ⚠️ **Uncertified query answers, and nothing may be wired to decide on them.** + /// `create_order` decides solvency from the same state *synchronously*, inside the + /// order-creating message; that is what stops the decision being raced into an + /// over-sale. + reserve_status: () -> + (record { + availableToSell: nat; + canisterCycles: nat; + cyclesLedgerFee: nat; + expiredOrders: nat; + minCanisterCycles: nat; + openOrders: nat; + paidIntentsIndexed: nat; + promiseHolders: nat; + promisedTotal: nat; + reserveAccount: Account; + reserveFloor: nat; + reserveObservedAtNs: opt int; + tallySaturations: nat; + totalOrders: nat; + }) query; + /// Mark one orphaned payment settled off-chain (admin, §4.1). + /// + /// The operator has dealt with it in Stripe; this records that they did. Audited with + /// the entry's own detail, because nothing else in the system can tell afterwards that + /// the obligation was met rather than forgotten. Nothing re-opens it. + resolve_orphan: (id: nat) -> (Result_10); + /// Manual resolution (§4.1/§7) — the operator marking an obligation settled after + /// acting off-chain: a refund issued in the Stripe Dashboard, or a delivery whose + /// fate they established on the cycles ledger. + /// + /// **This closes ORPHAN entries only — `#unattributed` and `#unprocessable`.** + /// Everything order-bound lives on the order and is closed by + /// `resolve_problem`; pointing an operator here for those would be pointing them at the + /// wrong method. Resolving an entry never transitions the order — see `Orphans`'s + /// header. + /// Close **one** order-bound problem an operator has dealt with. + /// + /// ⚠️ **`paymentRef` is the selector, and dropping it over-resolves.** `sameShape` + /// deliberately allows two unresolved `#duplicate` problems on one order with different + /// payment references (a buyer who pays three times), so closing by tag alone marks an + /// obligation settled that nobody has settled. + /// + /// **Problems in an array have no stable handle, so the dedup key IS the handle** — + /// `(kindTag, identifyingRef)`, the same pair `sameShape` uses. One definition, both + /// users. + /// + /// ⚠️ **And it refuses rather than guesses when the selector is ambiguous**, which is + /// this codebase's posture wherever a lever might act on the wrong thing — the + /// abandon guard and `cancel_order`'s `#notOpen` do the same. The refusal lists the + /// references so the operator can disambiguate, because declining without a way + /// through is a dead end rather than a safeguard. + resolve_problem: (orderId: OrderId, tag: ProblemKindTag, paymentRef: + opt text) -> (Result_9); + /// Replace the card presets (§3/§7 — admin, validated atomically: a bad config + /// never partially applies). + /// + /// **This is no longer the rail's on/off switch.** An empty list used to + /// pause the rail, and the audit line said "CARD RAIL PAUSED". With custom + /// amounts a buyer can order without any preset, so an empty list stops + /// nothing — it just shows no tiles. The switch is both Stripe secrets being + /// provisioned; `railsLive` is where that lives. + set_card_tiers: (tiers: vec Tier) -> (Result_8); + /// Tune the delivery timeline (admin, §7). + /// + /// ⚠️ Validated rather than trusted: an alert at or after the terminal bound would + /// tell the operator at the moment the decision was already taken, and a + /// non-positive bound would escalate every order instantly. + set_delivery_config: (config: Config__2) -> (Result_7); + /// Declare which Stripe mode this gateway serves (controller only). + /// + /// Set it to `?true` before taking real payments and `?false` on a sandbox + /// deployment. `null` restores "accept either", which only makes sense while + /// nothing of value is at stake — and it is the default, so a fresh canister + /// starts there. + /// + /// ⚠️ **The other half of the divisor's mutual refusal.** While a + /// simulation divisor is set, this refuses anything but `?false`: live mode + /// takes real money and delivers scaled cycles, and `null` accepts live + /// payments too. Mutual, so **neither order of operations** reaches the state + /// that shorts a paying buyer — it is unrepresentable rather than discouraged. + set_expected_livemode: (expected: opt bool) -> (Result_6); + /// Adjust the admission gate (§7): open-order cap, own-cycles floor, + /// per-purchase ceiling. Validated atomically — a bad config never partially + /// applies. Lowering `maxPurchaseUsdCents` below an existing tier does NOT + /// retroactively invalidate that tier's registration, but the next + /// `set_card_tiers` will reject it and the webhook will refuse to deliver a + /// payment above the new ceiling. + set_gate_config: (config: Config__1) -> (Result_5); + /// Adjust pricing params (§7): fee formula, staleness window, delta guard. /// Validated atomically — a bad config never partially applies. - set_forex_config: (config: Config__1) -> (Result_4); + /// **Three divisor guards live here rather than in `Pricing.validateConfig`, + /// because each needs context that module cannot see**. All three are + /// checked before anything is written, so a bad config never partially applies. + set_pricing_config: (config: Config) -> (Result_4); /// Tune the sweep cadence (admin, §7) — re-arms immediately, no redeploy. /// Validated against the §5.1 bound: the cadence must stay well inside /// the ledger dedup window or replay loses its safety margin. set_recovery_interval: (intervalNs: nat) -> (Result_3); - /// Adjust treasury policy (§5.3/§7): burn cap, window, max hold, alert - /// threshold. Validated atomically — a bad config never partially applies. - set_treasury_config: (config: Config) -> (Result_2); - /// Provision or rotate the Stripe webhook signing secret (§7). Pass the - /// full `whsec_...` string from the Stripe dashboard — the whole string, - /// prefix included, is the HMAC key. NOTE: the argument transits the - /// TLS-terminating boundary node as plain ingress (§7 provisioning - /// exposure); rotate after provisioning over an untrusted path. - set_webhook_secret: (secret: text) -> (Result_1); - /// §5.3 balance alert + soft UI gate, public: the frontend disables tiers - /// off `lowFloat`, and cap consumption is operational transparency (the - /// thesis), not a secret. The float observation may be stale — `atNs` says - /// how stale; `refresh_float` is the admin lever for a fresh read. - treasury_status: () -> (Status__1) query; + /// Provision or rotate the restricted Stripe API key, sealed exactly as + /// `set_webhook_secret` is — same vetKey, one derivation for both. + /// + /// The key to seal is a **restricted key** (`rk_...`) with *Checkout Sessions = Write* + /// and everything else None; `Secret.mo` records why that scope, not this storage, is + /// what bounds a leak. + set_stripe_api_key: (ciphertext: blob) -> (Result_1); + /// Set the origin Stripe returns buyers to — admin. + /// + /// Validated at set time rather than at session-create time, so a bad value + /// fails in front of the operator who typed it instead of breaking every + /// purchase later. Until a domain is chosen this is the canister's + /// own asset origin. + set_stripe_origin: (origin: text) -> (Result_2); + /// **Declared inside the mixin body, not above it and not in `Types.mo`.** Only + /// imports may precede a `mixin` block (M0228, "mixins may only be declared at the + /// top-level"), and Candid type names come from the Motoko declaration — moving this + /// to `Types.mo` would risk renaming it in the interface, which is the one thing this + /// relocation must not do. + /// Provision or rotate the Stripe webhook signing secret (§7). + /// + /// Takes the full `whsec_...` string — the whole string, prefix included, is the HMAC + /// key — **sealed to this canister's vetKD public key**. `scripts/seal-secret.sh` + /// produces the argument; the plaintext never travels. + /// + /// **This closed the §7 provisioning exposure, and the note that used to sit here + /// saying otherwise is gone rather than softened.** The ingress argument is now + /// ciphertext, so the boundary node that terminates TLS sees nothing usable. What + /// remains is the at-rest exposure, which is the confidential subnet's job and not + /// something rotation can help with. + set_webhook_secret: (ciphertext: blob) -> (Result_1); + /// Whether the restricted Stripe key is provisioned — **never the key**. + /// + /// The console offers this read and no command for the setter: a rendered + /// `set_stripe_api_key` would put the key in a page's DOM and clipboard, which is what + /// `scripts/check-admin-commands.py` fails on. Admin-gated like every other read of + /// operational state that names a secret's presence. + stripe_api_key_status: () -> (Status) query; + /// The origin, readable back because it is not a secret — it is the URL + /// buyers are sent to, and an operator needs to confirm it. + stripe_origin: () -> (opt text) query; + /// The outcall transform. Referenced by name in the request, so it has to + /// be a public `shared query` on the actor even though nothing should ever call + /// it directly. + /// + /// Its whole job is `Session.strip`: **remove every response header.** Stripe + /// returns a unique `request-id` per HTTP request, and each replica issues its + /// own request — so passing headers through fails consensus on *every* call, not + /// occasionally. Replication-count independent: any `n > 1` breaks. + transform_stripe_response: (args: + record { + context: blob; + response: HttpRequestResult; + }) -> (HttpRequestResult) query; /// Provisioning state only — the secret itself is never readable back /// out, even by controllers. `generation` confirms a rotation landed. webhook_secret_status: () -> (Status) query; - /// §6.2 hold-ckUSDC treasury posture: pulled ck-USDC accrues in this - /// canister's ledger account; the operator withdraws it here, converts to - /// ICP off-chain, and refills the float. Attended admin lever — no - /// created_at_time dedup; on an ambiguous failure check the ledger before - /// retrying. (Balance is public on the ck-USDC ledger; no query needed.) - withdraw_ck_usdc: (to: Account, amountUnits: nat) -> (Result); + /// Return the reserve to the caller, refusing while anything is owed. + /// + /// **Why this exists at all.** A funded mainnet reserve is real money in a ledger + /// account with no way back, so without this lever decommissioning — or over-funding + /// once — is a permanent loss. *"An over-funded local reserve costs nothing"* is true + /// of a local network and of nothing else. + /// + /// ⚠️ **It grants a controller NO new capability, which is what makes it safe.** A + /// controller can install arbitrary code, so they can already move the reserve + /// anywhere by upgrading — `Auth.mo`'s tier note says exactly that. There is a cheaper + /// existing path too: rotate the webhook secret, sign a completed-session event for an + /// order you created, and take delivery having paid nothing. The trust boundary does + /// not move; an off-the-books capability becomes **one audited call**, which is more + /// visible than a wasm deploy and more visible than a run of forged orders. + /// + /// ⚠️ **A DECOMMISSIONING lever, not an incident lever, and the guard is why.** During + /// a forged-webhook drain the forged orders are open, so this refuses. The evacuation + /// path is three steps, not one: rotate the webhook secret (which closes the rail to + /// new orders), `abandon_order` the ones in flight (releasing their promises), then + /// withdraw. RUNBOOK's suspected-leak section carries that sequence. + withdraw_reserve: () -> (Result); } diff --git a/src/backend/mixins/AdminOrders.mo b/src/backend/mixins/AdminOrders.mo new file mode 100644 index 0000000..bce0b3d --- /dev/null +++ b/src/backend/mixins/AdminOrders.mo @@ -0,0 +1,618 @@ +// `Array` for the receiver `.map()` on a candidate list. +import Array "mo:core/Array"; +import List "mo:core/List"; +import Map "mo:core/Map"; +import Nat "mo:core/Nat"; +import Principal "mo:core/Principal"; +import Result "mo:core/Result"; +import Set "mo:core/Set"; +import Text "mo:core/Text"; +import Time "mo:core/Time"; +import AuditLog "../AuditLog"; +import Idempotency "../Idempotency"; +import Delivery "../Delivery"; +import Orders "../Orders"; +import Orphans "../Orphans"; +import Problems "../Problems"; +import Receipts "../Receipts"; +import Session "../rails/Session"; +import Types "../Types"; + +/// The operator's worklist: read any order, settle an obligation, end a purchase, and +/// the paged views the runbook triages from (§4.1, §7). +/// +/// ⚠️ **Every read here lifts §2's owner boundary, and every one is audited for exactly +/// that reason.** `admin_order`, `admin_orders` and `admin_receipt` answer about orders +/// the caller does not own, which is what §2 withholds from everyone else — so the audit +/// line is the price of the exception rather than decoration, and it fires on a miss as +/// well as a hit, because a probe for existence is the thing being permitted. +/// +/// ⚠️ **That is also why `admin_receipt` is an update while `receipt` is a query.** +/// Auditing writes state. Folding the admin case into the buyer's method would put every +/// buyer's receipt read through consensus to serve the rare one — which is why the two +/// live in different mixins and share one builder, `Receipts.of`. +mixin ( + orderStore : Orders.Store, + dedup : Idempotency.Store, + orphanStore : Orphans.Store, + auditLog : AuditLog.Log, + deliveryJournal : Delivery.Journal, + paidIntents : Map.Map, + cancelRequests : Set.Set, + ops : { + audit : (Text, Text) -> (); + auditAdmin : (Principal, Text, Text) -> (); + requireAdmin : (Principal) -> (); + isGrantedAdmin : (Principal) -> Bool; + noteStripeApiFailed : (Text) -> (); + tryTransition : (Types.OrderId, Types.OrderStatus) -> ?Types.Order; + expireStripeSession : (Text) -> async* Session.ExpireOutcome; + /// The delivery-stage predicates these paged views share with the sweep that + /// maintains them. Shared rather than reimplemented so "is this delayed" has one + /// definition — a second copy is how a worklist starts disagreeing with the timer + /// that feeds it. + deliveryStage : (Types.Order, Int) -> ?{ #retry; #alert; #terminate }; + stageIsDelayed : ({ #retry; #alert; #terminate }) -> Bool; + openTransfer : (Types.JournalEntry) -> Bool; + forEachPromisedDelivery : ((Types.Order, Types.JournalEntry) -> ()) -> (); + }, +) { + + /// Deliveries outstanding past `alertAfterNs` — a **reading**, not an obligation, which + /// is why it is a query rather than a filed problem. + /// + /// ⚠️ **`alertAfterNs` is this predicate's THRESHOLD, not a trigger.** Lowering a + /// filter costs nothing; lowering a trigger would file worklist entries for orders that + /// deliver themselves. + /// One delayed delivery, as `delayed_deliveries` reports it, paginated. + /// + /// ⚠️ **`pastMaxHold` is a transient window, at most one sweep interval wide** — past + /// `maxHoldNs` the next sweep escalates the order out of `#paid` and out of this set. + /// Useful to read; **do not assert it in an integration scenario**, because pinning + /// that window without ticking the clock is the shape that produces a flaky test. The + /// boundary is unit-pinned on `Delivery.waitStage`, which is where it belongs. + type DelayedDelivery = { + orderId : Types.OrderId; + status : Types.OrderStatus; + /// `order.updatedAtNs` — retries deliberately do not move it, so the clock is + /// pinned to the moment the order entered its current state. + heldSinceNs : Int; + waitedNs : Int; + /// How many times delivery has already failed. `0` is an order simply waiting. + retries : Nat; + pastMaxHold : Bool; + /// When it FIRST crossed the threshold, read off the order — the permanent record, + /// where every other field here is a live reading. + delayedAtNs : ?Int; + }; + + /// "Is MY principal granted?" — public and caller-scoped. + /// + /// ⚠️ **Deliberately ungated.** An admin who is NOT yet granted has to be able to read + /// their own principal and see that it is not granted; a guarded version would reject + /// exactly the caller who needs the answer, and the UI could not tell "not granted" from + /// "not reachable". It discloses nothing about anyone else: the answer is about `caller`. + public shared query ({ caller }) func admin_status() : async { + caller : Principal; + granted : Bool; + isController : Bool; + } { + { + caller; + granted = ops.isGrantedAdmin(caller); + isController = caller.isController(); + }; + }; + + /// Read **any** order by id (admin). + /// + /// ⚠️ **A deliberate exception to §2's "existence is not revealed to non-owners", so it + /// audits itself on every use.** `get_order` is owner-scoped with no admin bypass, so + /// without this an operator could identify *which* order a Stripe receipt named and + /// then not look at it. + /// + /// ⚠️ **The audit line is the price of the exception, not decoration.** It is an + /// `auditAdmin` write, so it names *who* looked, and it fires on the read whether or + /// not the order exists — a probe for existence is exactly what §2 withholds from + /// everyone else, so a miss has to be as visible as a hit. + public shared ({ caller }) func admin_order(id : Types.OrderId) : async ?Types.Order { + ops.requireAdmin(caller); + let found = Orders.get(orderStore, id); + ops.auditAdmin( + caller, + "order.adminRead", + id # (switch (found) { case (?_) ""; case null " (no such order)" }), + ); + found; + }; + + /// Filtered, cursor-paginated order list (admin). + /// + /// ⚠️ **Do not sort by `createdAtNs`.** Ordering is by order id, which is arbitrary + /// because ids are random — and time-ordering would mean materialising the filtered set + /// first, which is an unbounded scan. Narrow with `createdFromNs` instead. + /// + /// ⚠️ **Deliberately NOT audited, unlike `admin_order` and `admin_receipt` — do not + /// "fix" the inconsistency.** Their line records *"an operator looked at THIS person's + /// order"*, and that targeted act is the accountable one; a line per page would record + /// the work rather than the intrusion and bury the targeted reads. It would also make + /// this an update, since audits write state, on the call an operator makes repeatedly. + /// Same reasoning keeps `orphans`, `orphan_depth` and `problem_depth` unaudited. + /// + /// **What would change that:** a filter narrowing to a *single named principal* as + /// the normal way to drive this. `owner : ?Principal` makes it possible today; it is not + /// the intended use, and if it becomes one the list inherits the audit. + public shared query ({ caller }) func admin_orders( + filter : Orders.Filter, + afterId : ?Types.OrderId, + limit : Nat, + ) : async Orders.Page { + ops.requireAdmin(caller); + Orders.page(orderStore, filter, afterId, limit); + }; + + /// §4.1 retained history, oldest first, **paged**. Admin: entries carry + /// payment references and claimed-but-bogus URL params. + /// + /// Paged because unresolved obligations are never evicted, so the queue can + /// grow — and an unpaginated read would eventually exceed Candid's 2 MB + /// message limit, i.e. the record would become unreadable exactly when it + /// mattered most. Pass `null` to start; feed `nextCursor` back until it is + /// null. `limit` is capped at `Orphans.maxPageSize`. + public shared query ({ caller }) func orphans( + afterId : ?Nat, + limit : Nat, + ) : async Orphans.Page { + ops.requireAdmin(caller); + Orphans.page(orphanStore, afterId, limit); + }; + + /// Manual resolution (§4.1/§7) — the operator marking an obligation settled after + /// acting off-chain: a refund issued in the Stripe Dashboard, or a delivery whose + /// fate they established on the cycles ledger. + /// + /// **This closes ORPHAN entries only — `#unattributed` and `#unprocessable`.** + /// Everything order-bound lives on the order and is closed by + /// `resolve_problem`; pointing an operator here for those would be pointing them at the + /// wrong method. Resolving an entry never transitions the order — see `Orphans`'s + /// header. + /// Close **one** order-bound problem an operator has dealt with. + /// + /// ⚠️ **`paymentRef` is the selector, and dropping it over-resolves.** `sameShape` + /// deliberately allows two unresolved `#duplicate` problems on one order with different + /// payment references (a buyer who pays three times), so closing by tag alone marks an + /// obligation settled that nobody has settled. + /// + /// **Problems in an array have no stable handle, so the dedup key IS the handle** — + /// `(kindTag, identifyingRef)`, the same pair `sameShape` uses. One definition, both + /// users. + /// + /// ⚠️ **And it refuses rather than guesses when the selector is ambiguous**, which is + /// this codebase's posture wherever a lever might act on the wrong thing — the + /// abandon guard and `cancel_order`'s `#notOpen` do the same. The refusal lists the + /// references so the operator can disambiguate, because declining without a way + /// through is a dead end rather than a safeguard. + public shared ({ caller }) func resolve_problem( + orderId : Types.OrderId, + tag : Types.ProblemKindTag, + /// Which one, when the kind can have several. `#deliveryStuck` never can — it is + /// matched on the discriminator alone — so null is always right for it. + paymentRef : ?Text, + ) : async Result.Result { + ops.requireAdmin(caller); + // ⚠️ Separate from "nothing to resolve", and it must stay separate: one answer for + // both makes a mistyped id read as "already done". + if (Orders.get(orderStore, orderId) == null) return #err(#noSuchOrder({ orderId })); + let candidates = Orders.unresolvedOfKind(orderStore, orderId, tag); + if (candidates.size() == 0) return #err(#noSuchProblem({ tag })); + switch (paymentRef) { + case null { + if (candidates.size() > 1) { + // ⚠️ The candidate list travels as DATA now, not inside a sentence. It is what + // the operator disambiguates with, and a caller had to parse it back out of + // the prose to offer a choice. + let refs = candidates.map( + func(c) = switch (c.ref) { case (?r) r; case null "(none)" } + ); + return #err(#ambiguous({ tag; candidates = refs })); + }; + }; + case (?_) {}; + }; + let closed = Orders.resolveProblems( + orderStore, + orderId, + func(k) { + Problems.tagOf(k) == tag + and ( + switch (paymentRef) { + case null true; // exactly one candidate, checked above + case (?want) Problems.identifyingRef(k) == ?want; + } + ); + }, + Time.now(), + ); + if (closed == 0) { + // Reachable only with a reference given: the no-reference path either found one + // candidate or refused as ambiguous above. + // Passed as the option it is, so the error cannot report an empty string as + // though a reference had been supplied. + return #err(#referenceNotFound({ tag; reference = paymentRef })); + }; + ops.auditAdmin( + caller, + "order.problemResolved", + orderId # ": " # Problems.tagToText(tag) + # (switch (paymentRef) { case (?r) " (" # r # ")"; case null "" }) + # " — " # closed.toText() # " closed", + ); + #ok(closed); + }; + + /// Mark one orphaned payment settled off-chain (admin, §4.1). + /// + /// The operator has dealt with it in Stripe; this records that they did. Audited with + /// the entry's own detail, because nothing else in the system can tell afterwards that + /// the obligation was met rather than forgotten. Nothing re-opens it. + public shared ({ caller }) func resolve_orphan(id : Nat) : async Result.Result { + ops.requireAdmin(caller); + let resolved = Orphans.resolve(orphanStore, id, Time.now()); + switch (resolved) { + case (#ok(entry)) ops.auditAdmin(caller, "orphanStore.resolved", "entry " # id.toText() # ": " # entry.detail); + case (#err(_)) {}; + }; + resolved; + }; + + /// The operational trail, **paginated**. + /// + /// ⚠️ **Retention is total, which is why this has to paginate.** With no bound on the + /// store, an unpaginated read is on a path to a response nobody can receive. + /// + /// Cursor on `seq`, which now has **no gaps**: gaps used to be how a reader detected + /// drops, and there are no drops. + public shared query ({ caller }) func audit_log( + afterSeq : ?Nat, + limit : Nat, + ) : async AuditLog.Page { + ops.requireAdmin(caller); + AuditLog.page(auditLog, afterSeq, limit); + }; + + /// **Admin: the audit trail, newest first**. + /// + /// The same events `audit_log` returns, in the order an operator reads them: someone + /// opening the console wants what just happened, and the ascending view starts at the + /// first line ever written. Both exist because they answer different questions, and the + /// store stays ascending because an append-only log is. + /// + /// ⚠️ **The cursor is the MIRROR of `audit_log`'s.** `nextCursor` here is the oldest + /// `seq` in the page and is passed back as `beforeSeq` to walk further into the past; + /// there it is the newest, passed back as `afterSeq`. Same `Page` type, opposite + /// meanings. `AuditLog.recentPage` carries the reasoning and the unit tests pin it. + public shared query ({ caller }) func audit_log_recent( + beforeSeq : ?Nat, + limit : Nat, + ) : async AuditLog.Page { + ops.requireAdmin(caller); + AuditLog.recentPage(auditLog, beforeSeq, limit); + }; + + /// Orders past `alertAfterNs` and still undelivered (admin, paged). + /// + /// The worklist behind `operator_summary.deliveriesDelayed`: one entry per order, + /// with the journal figures a human needs to decide whether it is stuck or slow. + public shared query ({ caller }) func delayed_deliveries( + afterId : ?Types.OrderId, + limit : Nat, + ) : async { + entries : [DelayedDelivery]; + nextCursor : ?Types.OrderId; + } { + ops.requireAdmin(caller); + // ⚠️ **Bounded by the non-terminal index, not by lifetime sales.** `#paid` + // holds its promise, so the index is a superset of the population and the filter is + // exact — and the index is capped by the reserve rather than growing with sales. + // + // ⚠️ **The page bounds the RESPONSE; the index bounds the WORK. Both are needed + // and they are different limits** — ~2 MB for the response, instructions per + // message for the walk — so paginating this does NOT make it bounded. + let now = Time.now(); + let page = Orders.holderPage( + orderStore, + afterId, + limit, + func(order) { + // `deliveryDelayed` is the ONLY status gate. An outer `order.status == #paid` + // once survived alongside it, so widening the predicate moved this page and the + // summary's count differently while both claimed one shared definition. + let ?stage = ops.deliveryStage(order, now) else return null; + if (not ops.stageIsDelayed(stage)) return null; + ?{ + orderId = order.id; + status = order.status; + heldSinceNs = order.updatedAtNs; + waitedNs = now - order.updatedAtNs; + retries = switch (deliveryJournal.get(order.id)) { + case (?entry) entry.retries; + case null 0; + }; + pastMaxHold = stage == #terminate; + delayedAtNs = order.delayedAtNs; + }; + }, + ); + { entries = page.items; nextCursor = page.nextCursor }; + }; + + /// Every delivery with money-out work outstanding, right now (admin). + /// + /// The immediate answer to "is a delivery failing?", and it **self-clears by + /// construction** — an entry leaves the moment delivery lands, because landing records + /// the block and moves the status. No resolve step to forget. Read `retries` as "how + /// many times this has already failed"; `0` is a first attempt, not a problem. + /// + /// ⚠️ **Admin even now that it is bounded.** A public version would hand an + /// unauthenticated caller the operator's in-flight worklist; `reserve_status` is the + /// public answer, and O(1) on purpose. + /// + /// ⚠️ The `#paid` subset is **exactly** the reconcile's quiet-window predicate, so this + /// is also how "the reserve reconcile keeps skipping" gets diagnosed. `#needsReview` + /// orders are included because an operator asking "what is wrong right now" wants them, + /// and they are deliberately NOT in that predicate — see `unsettledDeliveries`. + public shared query ({ caller }) func pending_deliveries() : async [Types.JournalEntry] { + ops.requireAdmin(caller); + let out = List.empty(); + ops.forEachPromisedDelivery( + func(order, entry) { + switch (order.status) { + case (#paid) { if (ops.openTransfer(entry)) out.add(entry) }; + case (#needsReview) { if (entry.blockIndex == null) out.add(entry) }; + case (_) {}; + }; + } + ); + out.toArray(); + }; + + /// Which order did this Stripe `payment_intent` pay for (admin, §4.2)? The + /// reconciliation lookup: given a charge in the Stripe Dashboard, find the + /// order it funded. Null means the payment was never attributed to an order + /// here — check the order's problems and the orphan list for an obligation carrying it. + public shared query ({ caller }) func order_for_payment(paymentRef : Text) : async ?Types.OrderId { + ops.requireAdmin(caller); + paidIntents.get(paymentRef); + }; + + /// **Admin: expire one `#created` order, releasing its reserve capacity**. + /// + /// ⚠️ **The lever for the class the sweep structurally CANNOT see**, so do not delete it + /// as redundant with the sweep: an order whose session-create response was lost carries + /// neither `expiresAtNs` (nothing to trigger on) nor `stripeSessionId` (nothing to query + /// with), and Stripe's session list cannot be filtered by `client_reference_id`. + /// + /// **Expire-first, exactly as `cancel_order` does, and for exactly that reason.** + /// Nothing is ever half-expired: if the session is still live on Stripe, the order does + /// not move. A successful expire is what makes "expired" mean *provably unpayable* + /// rather than assumed, because Stripe guarantees a session ends in exactly one of + /// completed/expired. + /// + /// **`#notOpen` changes nothing, and that is not timidity.** It means the session + /// completed or already expired, and those demand opposite actions — expiring an order + /// whose buyer just paid would strand a real payment. Let the webhook (or the sweep) + /// settle it on Stripe's answer. + public shared ({ caller }) func expire_order(id : Types.OrderId) : async Result.Result { + ops.requireAdmin(caller); + let ?order = Orders.get(orderStore, id) else return #err(#notFound(id)); + switch (order.status) { + case (#created) {}; + case (#expired) return #ok(order); // idempotent + case (status) { + return #err(#notCreated({ id; status })); + }; + }; + switch (order.stripeSessionId) { + case (?sessionId) { + switch (await* ops.expireStripeSession(sessionId)) { + case (#ok) {}; + case (#notOpen(detail)) { + // The body travels into the audit line because this module deliberately + // does not guess Stripe's wording: recording what it actually said is how + // the next reader learns it. See `Session.expireOutcome`. + ops.audit("order.expireRaced", id # ": session " # sessionId # " is no longer open. Stripe said: " # detail); + return #err(#sessionNotOpen(id)); + }; + case (#unauthorized) { + // ⚠️ 401/403 is the ONE expire answer that means "rotate the key", so it is + // the only one that latches. A 400 latched it before, which filed a P1 + // saying the key was refused for a key that was fine. + ops.noteStripeApiFailed( + "expire REFUSED (401/403): the restricted key needs WRITE on Checkout Sessions — rotate it" + ); + return #err(#stripeUnauthorized(id)); + }; + case (#failed(detail)) { + ops.audit("order.expireFailed", id # ": " # detail); + return #err(#stripeFailed({ id; detail })); + }; + }; + }; + case null { + // The residue class this method exists for. No session id means no URL ever left + // the canister, so the order is provably unpayable with no outcall needed. + ops.audit("order.expiredSessionless", id # " had no session; expired without an outcall"); + }; + }; + // Through the machinery, never a status write: a second tab may have cancelled this + // order while the outcall was in flight, and the matrix no-ops `#cancelled → #expired` + // for free — which is what keeps the buyer's own decision, and its `expiredBy` + // provenance, from being overwritten. + switch (Orders.settleUnpayable(orderStore, cancelRequests, id, #sessionExpired, Time.now())) { + case (#ok(updated)) { + ops.auditAdmin(caller, "order.expiredByAdmin", id # ": reserve capacity released"); + #ok(updated); + }; + case (#err(_)) { + let ?fresh = Orders.get(orderStore, id) else return #err(#notFound(id)); + #err(#movedInFlight({ id; status = fresh.status })); + }; + }; + }; + + /// Stop trying to deliver an order (admin, §7) — **the only path to a + /// terminal non-delivered state.** + /// + /// Nothing in the system gives up on a purchase automatically: a delay raises + /// `delayed_deliveries` and keeps retrying, because its causes are all + /// operator-fixable. This is the deliberate human decision that a purchase + /// will not be completed, and it demands a reason so the trail records *why* + /// alongside *who*. + /// + /// Only reachable from a pre-delivery money-bearing state. A `#created` order + /// has taken no money and needs no decision; a `#delivered` one is done. + public shared ({ caller }) func abandon_order( + id : Types.OrderId, + reason : Text, + ) : async Result.Result { + ops.requireAdmin(caller); + let ?order = Orders.get(orderStore, id) else return #err(#notFound(id)); + switch (order.status) { + case (#paid or #needsReview) {}; + case (status) { + return #err(#notAbandonable({ id; status })); + }; + }; + // ── ⚠️ A PAID order with an unsettled delivery cannot be abandoned ──────── + // + // **Otherwise this lever pays the buyer twice.** The order is `#paid` with a + // transfer issued and no block recorded, so the money position is UNKNOWN. + // Abandoning releases the promise and files a refund-by-hand obligation, while + // the transfer either lands afterwards or has already landed with its reply + // lost — and after `#abandoned` nothing sweeps the order, so nothing ever + // discovers which. The buyer keeps the cycles and gets the refund. + // + // It is not only a race with a call in flight. The wider case is the one that + // needs no timing at all: an intent whose transfer executed and whose reply was + // lost looks exactly like one that never executed, and this lever would decide + // between them by guessing. **Deciding an unknown money position is precisely + // what `#needsReview` exists to prevent**, and without this guard + // `abandon_order` reached the same outcome directly from `#paid`, skipping it. + // + // Second symptom, worth knowing for the post-mortem: the late reply patches the + // journal to `#delivered` while `tryTransition` no-ops against `#abandoned`, so + // the journal and the order end up contradicting each other. + // + // **Bounded, so this is a wait and not a refusal:** the ~24 h dedup fuse moves + // such an order to `#needsReview` on its own, where abandonment is allowed and + // the documented procedure is establish-the-fate-first. `#needsReview` is + // therefore untouched by this guard — escalation implies no outstanding call. + // + // ⚠️ **`unsettledDeliveries` now depends on this refusal for its completeness**, so + // this guard holds up more than the double-payout it was added for. It is what + // keeps a transfer-in-flight order inside `promiseHolders`; relaxing it for operator + // ergonomics would let the quiet window read quiet across an in-flight transfer, + // which is the oversell direction. Integration scenario 78 owns it. + if (order.status == #paid) { + switch (deliveryJournal.get(id)) { + case (?entry) { + if (ops.openTransfer(entry)) { + return #err(#deliveryOutstanding(id)); + }; + }; + case null {}; + }; + }; + if (reason.size() == 0) return #err(#reasonRequired); + // Status and reason in one step, so they cannot diverge — an `#abandoned` order + // with no explanation is the gap the dropped queue entry used to paper over. + let abandoned = switch (Orders.abandonWithReason(orderStore, id, reason, Time.now())) { + case (#ok(o)) o; + case (#err(_)) return #err(#transitionRefused(id)); + }; + Delivery.patch(deliveryJournal, id, { status = ?#abandoned; blockIndex = null; cyclesDelivered = null; bumpRetries = false; lastError = null }, Time.now()); + // ⚠️ **No queue entry.** It was the fourth copy of one decision — the status, the + // journal patch and this audit line already carry it, and nothing about it was + // outstanding. Review established the refund is tracked separately, via + // `stripe.refundOfEscalated` on the `charge.refunded` for the intent. + ops.auditAdmin(caller, "order.abandoned", id # ": " # reason); + #ok(abandoned); + }; + + /// Record that an escalated order's cycles **did** reach the buyer (admin, §7). + /// + /// The counterpart to `abandon_order`, and the reason the + /// `#needsReview → #delivered` edge exists. `#needsReview` means "we could not establish + /// whether the transfer landed"; when the operator establishes on the cycles + /// ledger that it did, this is how they say so. Without it their only lever was + /// `abandon_order`, which files a delivered order as abandoned and audits a refund + /// that never happened. + /// + /// ⚠️ **The block index is required, and it is not decoration.** It is the evidence + /// that this call is a *finding* rather than a guess, it goes into the journal so + /// the receipt shows the same proof any other delivered order shows, and demanding + /// it means the operator has actually looked. The order id is in the transfer's + /// memo, so the lookup is a search on the ledger, not a reconstruction. + /// + /// ⚠️ It moves no money and must not: the cycles are already gone. It also does not + /// credit the reserve floor back — the floor already assumed the debit when the + /// transfer was issued (rule 2), and this call is the confirmation that the + /// assumption was right. + public shared ({ caller }) func record_delivered( + id : Types.OrderId, + blockIndex : Nat, + ) : async Result.Result { + ops.requireAdmin(caller); + let ?order = Orders.get(orderStore, id) else return #err(#notFound(id)); + switch (order.status) { + case (#needsReview) {}; + case (#delivered) return #ok(order); // idempotent: already recorded + case (status) { + return #err(#notUnderReview({ id; status })); + }; + }; + let ?delivered = ops.tryTransition(id, #delivered) else { + return #err(#transitionRefused(id)); + }; + Delivery.patch(deliveryJournal, id, { status = ?#delivered; blockIndex = ?blockIndex; cyclesDelivered = null; bumpRetries = false; lastError = null }, Time.now()); + ops.auditAdmin(caller, "order.recordedDelivered", id # ": operator confirmed cycles-ledger block " # blockIndex.toText()); + #ok(delivered); + }; + + /// The same receipt, for **any** order (admin) — and **audited**, which is the + /// whole reason it is a separate method. + /// + /// ⚠️ **The audit is not about existence disclosure; it is about an operator leaving a + /// record of having looked.** `Receipt` embeds the whole `Order`, so an *unaudited* + /// admin path returns exactly what `admin_order` returns with no trace — which makes + /// `admin_order`'s audit **bypassable by calling the other method**. Reading an operator + /// read as harmless because the data is reachable elsewhere is the mistake to avoid. + /// + /// **A separate method rather than a branch, because auditing writes state.** An + /// audited read cannot be a `query`, and folding this into `receipt` would make **every + /// buyer's** receipt read an update — the common path through consensus to serve the + /// rare one. + /// + /// ⚠️ **Auditing is the mitigation for lifting the owner boundary at all.** A path that + /// lifts it without the audit is not a smaller version of the change; it is the change + /// without its safeguard. + public shared ({ caller }) func admin_receipt(id : Types.OrderId) : async ?Receipts.Receipt { + ops.requireAdmin(caller); + let ?order = Orders.get(orderStore, id) else { + // Audited on a miss too, like `admin_order`: an id probe by an operator is exactly + // what §2 withholds from everyone else, so a miss must be as visible as a hit. + ops.auditAdmin(caller, "order.adminRead", id # " (receipt; no such order)"); + return null; + }; + ops.auditAdmin(caller, "order.adminRead", order.id # " (receipt)"); + let journal = deliveryJournal.get(id); + ?Receipts.of(order, journal); + }; + + /// Money-out journal for one order (admin, §4.2) — intent, block_index, cycles + /// delivered, retries. + public shared query ({ caller }) func delivery_journal(id : Types.OrderId) : async ?Types.JournalEntry { + ops.requireAdmin(caller); + deliveryJournal.get(id); + }; +}; diff --git a/src/backend/mixins/Buying.mo b/src/backend/mixins/Buying.mo new file mode 100644 index 0000000..fd9973d --- /dev/null +++ b/src/backend/mixins/Buying.mo @@ -0,0 +1,425 @@ +// `Array` for the receiver `.map()` on the amounts a preview is asked for. +import Array "mo:core/Array"; +// ⚠️ `Nat` has no `Nat.` call here and is required: it resolves `.toText()` on a cents +// figure. Removing it and `Int` and `Principal` together compiled individually and +// failed as a set — an "unused" verdict per import does not compose, because one module +// can cover another's receiver methods. +import Nat "mo:core/Nat"; +import Result "mo:core/Result"; +import Time "mo:core/Time"; +import Auth "../Auth"; +import Gate "../Gate"; +import Orders "../Orders"; +import Purchase "../Purchase"; +import Pricing "../Pricing"; +import Session "../rails/Session"; +import Tiers "../Tiers"; +import Types "../Types"; + +/// Money in: the quote a buyer is shown, the admission preflight, the price tiles, and +/// `create_order` itself (§3, §6.1). +/// +/// ⚠️ **`create_order` is the most entangled endpoint in the system, and `ops` is that +/// entanglement made visible rather than hidden.** Three of the things it needs cannot +/// live in a mixin at all: +/// +/// * `createStripeSession` issues the HTTPS outcall — `Call.httpRequest`, and a +/// reference to the actor's own transform function. +/// * `createOrderWithFreshId` calls `management.raw_rand()`, so it needs the +/// management-canister actor reference. +/// * `noteRailClosed` latches a `Gate.RailCondition` and writes an audit line, which +/// the create path shares with the recovery sweep. +/// +/// All three stay in the composition root and arrive as closures. That is `A3` read +/// honestly: an endpoint body should be authorize → delegate → map, and the delegate +/// target belongs in the root when it is actor-bound. Passing them also keeps ONE +/// implementation shared with the timers, which is what stops the create path and the +/// sweep from diverging. +/// +/// ⚠️ **`quoteCents` moved in, and the others did not, for one reason: it is pure.** It +/// reads the rate cache and the pricing config and returns arithmetic — no await, no +/// capability, no actor reference. +mixin ( + orderStore : Orders.Store, + rateCache : Pricing.Cache, + reserveState : { var floor : Nat; var cyclesLedgerFee : Nat }, + gateState : { var config : Gate.Config; var latch : Gate.RailStateLatch }, + pricingState : { var config : Pricing.Config }, + tierState : { var cards : [Tiers.Tier] }, + ops : { + audit : (Text, Text) -> (); + admit : (Principal, Nat) -> Result.Result<(), Gate.Reason>; + gateObservation : (Principal) -> Gate.Observation; + admitOrder : (Principal, Nat, Nat) -> Result.Result<(), Gate.Reason>; + noteStripeApiFailed : (Text) -> (); + /// Actor-bound: the outcall, the transform reference, and the rail latch. + sessionConfig : () -> { #ok : { apiKey : Text; origin : Text }; #err : Session.Error }; + createStripeSession : ({ apiKey : Text; origin : Text }, Types.OrderId, Text, Nat) -> async* { #ok : Session.Created; #err : Session.Error }; + createOrderWithFreshId : (Principal, Nat, Types.Owner, Types.Rail, Types.Destination, Nat, Types.Pricing) -> async* Result.Result; + noteRailClosed : (Session.Error) -> (); + sessionErrorToText : (Session.Error) -> Text; + }, +) { + + /// What the buyer is paying for: a preset, or an amount they typed. + /// + /// A variant rather than a second method, so the quote, the gate and the + /// session path stay single. Everything downstream keys off gross USD cents, + /// which is what both cases resolve to — the floor and the ceiling then apply + /// uniformly instead of one bound per entry point. + /// What a given amount buys right now (§3), before anyone commits to an order. + type QuotePreview = { + usdCents : Nat; + /// Payment-processing fee at the rail's current formula, rounded up. + feeCents : Nat; + /// What is left to buy cycles with. Null when the fee swallows the amount. + netCents : ?Nat; + /// The §3 quantity this amount would lock. Null exactly when `create_order` + /// would refuse to price it — no rates, stale rates, or fee-swallowed — so a + /// caller that shows `cycles` never promises a purchase that cannot happen. + cycles : ?Nat; + }; + + type CreateOrderError = { + /// Anonymous principal — a shared identity can't own orders (§7). + #anonymous; + /// No configured preset with this id. Only reachable for `#tier`. + #unknownTier : Text; + /// §3 fee formula swallows the tier's gross amount — a tier/fee config + /// problem for the operator, not something a retry fixes. + /// + /// ⚠️ **The buyer's fix is "pick a larger amount", which is why the + /// simulation-scale case below is NOT folded in here.** Telling a buyer that + /// payment processing is too large, when the cause is this gateway's + /// simulation divisor, names the wrong party and prescribes a fix that may + /// not work. + #tierBelowFees : Text; + /// The SCALED cycles would not clear the flat cycles-ledger deposit fee: this + /// gateway's simulation divisor is too large for this purchase. + /// + /// Not the buyer's fault and not fixable by them — it is an operator's + /// configuration. Carries both figures so the refusal is diagnosable. + #simulationScaleTooSmall : { scaledCycles : Nat; ledgerFee : Nat }; + /// The reserve balance could not be read, so solvency is unknown. + /// Fails closed on purpose — selling against an unknown balance is exactly + /// what the check exists to prevent. (A short reserve is reported through + /// `#notAdmitted(#reserveShort)`, which carries both figures.) + #reserveUnavailable; + /// §3.1 fail-closed: no fresh rate and the refresh failed (or one is + /// already in flight), so no price, so no order. Retry shortly. + #rateUnavailable; + /// The caller pinned a minimum cycle quantity and the current rate no + /// longer clears it. Carries what the amount buys now, so the caller can + /// show the buyer the real figure and let them decide. + #quoteChanged : { quoted : Nat; minimum : Nat }; + /// Entropy source misbehaved (short blob or repeated collisions). + #idGeneration; + /// Gate.mo admission refusal — carries the observed value and the bound so + /// the frontend can say *why* rather than failing generically. + #notAdmitted : Gate.Reason; + /// The destination is not the caller's own default-subaccount cycles-ledger + /// account. Cycles go to the buyer and nowhere else, and that is a property + /// of the canister rather than of whichever frontend called it. + #destinationNotOwned; + /// No payable Checkout Session could be created: the API key or the + /// origin is unset, Stripe refused, or the outcall failed. Carries a reason + /// so the operator can tell "not provisioned yet" from "Stripe is down" + /// without reading the audit log. The order was created and then failed, so + /// the buyer's open-order slot is already free — they retry, they do not wait. + /// + /// Deliberately distinct from `#notAdmitted`: this is the operator's problem, + /// that one is the buyer's. + #sessionUnavailable : Text; + /// The order was cancelled from another tab while its session was being + /// created. The session exists at Stripe but its URL never left the canister, + /// so it is unreachable and dies at its own `expires_at`. + #cancelledDuringCreation; + }; + + type CreatedOrder = { + /// The order, carrying `stripeSessionUrl` — which is the only thing the + /// caller needs to send the buyer to Stripe. + /// + /// ⚠️ `clientReferenceId` used to be here. It existed so the frontend could + /// append `?client_reference_id=` to a **Payment Link URL**; the canister now + /// sets it through the API, so it was a Payment-Link relic sitting in a public + /// response type. It is derivable — `_` — and the + /// frontend computes it for the receipt field rather than being handed it. + order : Types.Order; + }; + + type QuotePreviews = { + quotes : [QuotePreview]; + /// The rate pair the quotes came from, so a caller can reproduce the + /// arithmetic without a second call. Null when nothing usable is cached. + rates : ?Pricing.Rates; + }; + + /// One quote = one consistent epoch: the caller snapshots the rail's fee + /// formula *before* any await, and both rates are read once from the cache + /// here. The §6.1 pricing snapshot persisted on the order carries both rate + /// inputs from that same epoch — which is what a buyer recomputes their own + /// price from, and what a delivered order is auditable against. (It was also + /// what the webhook would reprice a mismatched paid amount from, before it became a + /// mismatch deliver nothing.) + /// + /// Synchronous and awaitless by design: the rates come from the cache the + /// refresh timer maintains, never from a call. That is what keeps a + /// user-facing method from being able to trigger a paid XRC request. + /// + /// The fee is a parameter because each rail prices with its own formula (card + /// = the Stripe formula in `pricing.config`) over + /// the one shared rate cache. + func quoteCents(fee : { feeBps : Nat; feeFixedCents : Nat }, usdCents : Nat) : { + #ok : (Nat, Types.Pricing); + #stale; + #unpriceable : Pricing.Unpriceable; + } { + switch ( + Pricing.quote( + rateCache, + fee, + pricingState.config.maxAgeNs, + usdCents, + Time.now(), + pricingState.config.divisor, + reserveState.cyclesLedgerFee, + ) + ) { + case (#stale) #stale; + case (#unpriceable(cause)) #unpriceable(cause); + case (#ok({ cycles; rates })) { + #ok(( + cycles, + { + usdCents; + usdPerIcpMicros = rates.usdPerIcpMicros; + xdrPermyriadPerIcp = rates.xdrPermyriadPerIcp; + rateStandardDeviation = rates.quality.standardDeviation; + rateReceivedRates = rates.quality.receivedRates; + rateQueriedSources = rates.quality.queriedSources; + feeBps = fee.feeBps; + feeFixedCents = fee.feeFixedCents; + // The one field of `Pricing.Rates` this copy used to drop. Without + // it `createdAtNs` is the only timestamp on the record, and it is + // not when these rates were read. + ratesFetchedAtNs = rates.fetchedAtNs; + }, + )); + }; + }; + }; + + /// Create a card-rail order: II caller becomes the owner (ownership is + /// captured here at the API edge, seam §11.1.3), the tier's USD amount is + /// quoted into a locked cycle *quantity* (§3, net of fees at the cached + /// rate — a stale cache refreshes lazily, a failed refresh fails closed + /// §3.1), and the ID comes from raw_rand. The fee config is snapshotted + /// before the refresh await, so one order is always priced from one + /// consistent epoch even when a refresh interleaves with a config change; + /// the store write after the awaits is atomic. + /// `minCycles` pins the quantity the caller was shown (§3). + /// + /// The rate refresh runs on a timer, so a figure quoted to a buyer can move + /// before they commit — and a client-side re-check cannot close that window, + /// because a query and this update are separate messages. Pinning the + /// expectation here makes the check atomic with the lock, so no order can ever + /// be created at a quantity the buyer was not shown. + /// + /// A **minimum**, deliberately, not an equality: a rate move in the buyer's + /// favour passes through and they keep the extra cycles. The guard can only + /// ever protect the buyer. `null` opts out entirely. + public shared ({ caller }) func create_order( + amount : Types.Amount, + destination : Types.Destination, + minCycles : ?Nat, + ) : async Result.Result { + switch (Auth.checkUser(caller)) { + case (#err(#anonymous)) return #err(#anonymous); + case (#ok) {}; + }; + // Argument validation before any work, and before the gate: cycles go to the + // caller's own account or the order is not created. Checked HERE + // rather than in the frontend, because a hand-crafted call reaches this + // method too — "the cycles come to you" is only true if the gateway enforces + // it. + if (not Types.isOwnDestination(destination, caller)) { + return #err(#destinationNotOwned); + }; + // The rail's own state, before anything about this particular request. If no + // session can be created then no tier matters, and "card payments are not + // available yet" is a more useful answer than "unknown tier" — as well as a + // cheaper one. Ordering: caller, then arguments that depend only on the + // caller, then the RAIL, then this request's tier and admission. + let config = switch (ops.sessionConfig()) { + case (#ok(c)) c; + case (#err(e)) { + ops.noteRailClosed(e); + return #err(#sessionUnavailable(ops.sessionErrorToText(e))); + }; + }; + // ── The decision: amount, admission, quote, the caller's floor ─────────── + // + // One call, and the ORDER of those four steps is documented on + // `Purchase.plan` with a unit test per adjacent pair — which is what this + // extraction bought. It decides everything the commit and the session + // need and touches no state: admission and quoting come in as functions, so + // the live order count and the rate cache stay behind the closures that own + // them. + // + // ⚠️ **`#err(e)` returns straight out.** `Purchase.PlanError` is a structural + // subtype of this method's error type, so there is no mapping layer to drift. + let plan = switch ( + Purchase.plan( + caller, + amount, + minCycles, + tierState.cards, + func(cents) = ops.admit(caller, cents), + func(cents) = quoteCents( + { feeBps = pricingState.config.feeBps; feeFixedCents = pricingState.config.feeFixedCents }, + cents, + ), + ) + ) { + case (#ok(p)) p; + case (#err(e)) return #err(e); + }; + + // ── The order, held against the reserve floor ──────────────────────────── + // + // ⚠️ **No ledger call here, and the decision must stay synchronous.** + // `reserveState.floor` is a maintained lower bound moved only by our own outflows + // (§5.4), so the check and the hold sit in ONE block inside + // `createOrderWithFreshId` with no await between them. Motoko messages do not + // interleave except at an await, so whichever block runs second sees the first + // one's hold. **Splitting them across an await lets two concurrent creates + // promise the same cycles** — which is what an earlier version did, and no + // fresher balance read can fix it: an awaited value is historical the moment + // the continuation resumes. + let order = switch ( + await* ops.createOrderWithFreshId(caller, plan.usdCents, plan.owner, #card, destination, plan.lockedCycles, plan.pricing) + ) { + case (#ok(o)) o; + case (#err(#idGeneration)) return #err(#idGeneration); + case (#err(#notAdmitted(reason))) return #err(#notAdmitted(reason)); + }; + let clientReferenceId = Orders.clientReferenceId(plan.owner, order.id); + + // ── The session, after the order exists ───────────────────────────────── + // The ordering is forced: the order id IS the `client_reference_id`, so the + // order must be committed before the session can name it. Which means an + // await sits between them, and everything below is about that gap. + switch (await* ops.createStripeSession(config, order.id, clientReferenceId, order.pricing.usdCents)) { + case (#err(e)) { + // Fail the order in the same call and let the buyer start over. There is + // no retry method by design: a `payment_session(orderId)` retry was the + // only thing that created orders in a sessionless state, and every + // downstream complication chained off that — a promise no + // `checkout.session.expired` can release, a sweep promoted into a release + // path, a per-attempt idempotency key Stripe rejects on a changed body. + // + // ⚠️ Through `expireWithCause`, never a direct status write: a second tab + // may have cancelled this order while the outcall was in flight, and the + // matrix no-ops `#cancelled → #expired` for free. + switch (Orders.expireWithCause(orderStore, order.id, #sessionFailed, Time.now())) { + case (#ok(_)) {}; + case (#err(_)) {}; // already cancelled or gone; nothing to undo + }; + ops.noteStripeApiFailed("create: " # ops.sessionErrorToText(e)); + return #err(#sessionUnavailable(ops.sessionErrorToText(e))); + }; + case (#ok(created)) { + // Stripe answered, so the outcall is working again. This is the ONLY + // evidence that bears on `#stripeApiFailing` — `latchAdmission` cannot + // clear it, because admission runs *before* this call and says nothing + // about it. + gateState.latch := Gate.latchStripeApiOk(gateState.latch); + // ⚠️ Re-check the status before storing. `create_order` committed the + // order as `#created` and then awaited, so `cancel_order` from a second + // tab can have run in between — its sessionless branch fires, because no + // session id existed yet. Storing the URL anyway would hand the buyer a + // payable link for an order they were told was cancelled. Money-safe (the + // matrix rejects `#cancelled → #paid`) but it recreates exactly the + // "told cancelled, tab still charges" wart atomic cancellation exists to + // eliminate. `attachSession` enforces this; the branch below reports it. + switch ( + Orders.attachSession( + orderStore, + order.id, + created.id, + created.url, + Session.secondsToNs(created.expiresAtSeconds), + Time.now(), + ) + ) { + case (#ok(withSession)) #ok({ order = withSession }); + case (#err(_)) { + // The session is unreachable either way: its URL never left the + // canister, so nobody can pay it, and it dies at its own expires_at. + ops.audit("stripe.sessionOrphaned", order.id # ": cancelled during creation; session " # created.id # " left to expire"); + #err(#cancelledDuringCreation); + }; + }; + }; + }; + }; + + /// Admission preflight, public: lets the frontend disable the buy button with + /// a real reason (and lets an operator ask "would a purchase go through right + /// now?") without creating an order. `usdCents` is the gross amount to test. + /// + /// The answer is advisory — it can go stale between this call and + /// `create_order`, which re-checks. It is not an authorization decision, so + /// anonymous callers may ask: it reveals only operational state that + /// `reserve_status` already publishes. Answered for the *calling* principal, + /// so the open-order cap it reports is the caller's own. + public shared query ({ caller }) func can_purchase(usdCents : Nat) : async Result.Result<(), Gate.Reason> { + Gate.admit(gateState.config, ops.gateObservation(caller), usdCents); + }; + + /// Public — the frontend renders the amount tiles from this. There is no link + /// to render: the canister creates a session per order. + public query func card_tiers() : async [Tiers.Tier] { + tierState.cards; + }; + + /// Batch pre-purchase quote, public. + /// + /// ⚠️ **The price a buyer is shown is computed by the SAME function that prices the + /// order.** A client reimplementing the formula would be one refactor away from quoting + /// a number the gateway does not honour, with no way for the buyer to tell which was + /// wrong. Batched because the tier grid needs every price in one round trip. + /// + /// **Unbounded input on purpose**, unlike the paged queries. The work is constant + /// per element the caller already transmitted — no state scan, no amplification — so + /// the ingress size limit already bounds it. A cap would only buy **silent truncation**, + /// which is worse than what it prevents. + /// + /// Does not disclose the cycles-ledger fee: that is the ledger's number and the + /// operator's cost — `docs/DESIGN.md` §3.2 for the split and why a stored copy would + /// be wrong here. + public query func quote_previews(amounts : [Nat]) : async QuotePreviews { + let fee : { feeBps : Nat; feeFixedCents : Nat } = { + feeBps = pricingState.config.feeBps; + feeFixedCents = pricingState.config.feeFixedCents; + }; + let quotes = amounts.map( + func(usdCents) { + { + usdCents; + feeCents = Pricing.feeCents(fee, usdCents); + netCents = Pricing.netCents(fee, usdCents); + cycles = switch (quoteCents(fee, usdCents)) { + case (#ok((cycles, _))) ?cycles; + case (#stale or #unpriceable(_)) null; + }; + }; + }, + ); + { + quotes; + rates = Pricing.lastRates(rateCache); + }; + }; +}; diff --git a/src/backend/mixins/Config.mo b/src/backend/mixins/Config.mo new file mode 100644 index 0000000..d0376db --- /dev/null +++ b/src/backend/mixins/Config.mo @@ -0,0 +1,330 @@ +// ⚠️ Several of these carry no `Module.` call and are required anyway: they are what +// make the receiver methods resolve — `.toText()` on the audited figures, `.map()` on a +// tier array, `Time.now()` for the validation clock. +import Array "mo:core/Array"; +import Int "mo:core/Int"; +import Time "mo:core/Time"; +import Delivery "../Delivery"; +import Gate "../Gate"; +import Orders "../Orders"; +import Pricing "../Pricing"; +import Recovery "../Recovery"; +import Result "mo:core/Result"; +import Tiers "../Tiers"; + +/// The operator's policy levers: pricing, the admission gate, delivery bounds, the price +/// tiles, the Stripe mode, and the sweep cadence — plus the one public read of the +/// bounds a buyer's UI needs (§7). +/// +/// ⚠️ **Every setter validates atomically and writes nothing on failure**, which is why +/// they are grouped: the pattern is one `validate` then one assignment, and a setter that +/// half-applied a config would leave a policy nobody chose. +/// +/// ⚠️ **Timers are re-armed through `ops`, not here.** Two of these setters change a +/// cadence and must take effect without waiting out the old interval. The composition +/// root owns the `TimerId`s — transient, so a value passed by `include` would be a stale +/// copy — and the `` capability. Keeping the cancel/re-arm there also means a +/// mixin cannot stop the rate refresh or the recovery sweep from an endpoint. +/// +/// **State parameters name only the fields these setters touch** (`docs/DESIGN.md` +/// §9.1), so the compiler enforces the slice rather than a comment claiming it. +mixin ( + orderStore : Orders.Store, + rateCache : Pricing.Cache, + reserveState : { var cyclesLedgerFee : Nat }, + gateState : { var config : Gate.Config }, + pricingState : { var config : Pricing.Config }, + stripeState : { var expectLivemode : ?Bool }, + tierState : { var cards : [Tiers.Tier] }, + deliveryState : { var config : Delivery.Config }, + recoveryState : { var sweepIntervalNs : Nat }, + ops : { + requireController : (Principal) -> (); + auditAdmin : (Principal, Text, Text) -> (); + expectedIndexScanCycleNs : () -> Nat; + /// Cancel and re-arm, at the cadence the new config implies. + /// + /// ⚠️ **`` is part of the TYPE, not decoration.** Arming a timer needs the + /// system capability, so the closure carries it and the call site here must be in a + /// context that has it — which a `shared` function body is. Declaring it as a plain + /// `() -> ()` fails with M0096, and that is the compiler refusing to let a mixin + /// smuggle timer control into a context without the capability. + rearmRateTimer : () -> (); + /// Cancel and re-arm at exactly this interval, which the caller has validated. + rearmRecoveryTimer : (Nat) -> (); + }, +) { + + /// Why the expected Stripe mode could not be set. + /// + /// ⚠️ **One case, and it stays a variant rather than collapsing to `()`.** The refusal + /// is not "bad argument" — it is a *conflict with another setting*, and the caller + /// needs the conflicting value to explain it. A second reason to refuse is plausible + /// (a live mode with no key provisioned, say), and a variant admits one without + /// changing the shape callers already match on. + type LivemodeError = { + /// A simulation divisor is set, so only `?false` is accepted: taking live or + /// either-mode payments while scaling cycles down would short a paying buyer. + /// Clearing the divisor needs a reinstall — see `set_pricing_config`. + #simulationDivisorSet : { divisor : Nat }; + }; + + /// Adjust pricing params (§7): fee formula, staleness window, delta guard. + /// Validated atomically — a bad config never partially applies. + /// **Three divisor guards live here rather than in `Pricing.validateConfig`, + /// because each needs context that module cannot see**. All three are + /// checked before anything is written, so a bad config never partially applies. + public shared ({ caller }) func set_pricing_config(config : Pricing.Config) : async Result.Result<(), Pricing.ConfigError> { + ops.requireController(caller); + // 1. ⚠️ **`?false` EXACTLY, not `!= ?true`.** `null` means "either mode", so + // it accepts live payments — and `null` is the default. A guard keyed on + // `?true` would leave the state every freshly installed canister is in wide + // open, and that state takes real money and under-delivers. + if (config.divisor > 1 and stripeState.expectLivemode != ?false) { + return #err(#divisorNeedsSandbox({ expectLivemode = stripeState.expectLivemode })); + }; + // 2. The divisor is global, so it must not move under stored orders — every + // earlier receipt would recompute against the new value and report a + // mismatch. `storedCount` is `orders.size()`, so this costs one comparison. + if (config.divisor != pricingState.config.divisor) { + let stored = Orders.storedCount(orderStore); + if (stored > 0) return #err(#divisorChangeWithOrders({ stored })); + }; + // 3. Would the smallest purchase we sell still clear the ledger's flat + // deposit fee at this divisor? Advisory-at-set-time only: `Pricing.quote` + // re-checks every order, because this one goes stale as rates move. + switch ( + Pricing.divisorDeliverable( + rateCache, + { feeBps = config.feeBps; feeFixedCents = config.feeFixedCents }, + gateState.config.minPurchaseUsdCents, + config.divisor, + reserveState.cyclesLedgerFee, + ) + ) { + case (#err(e)) return #err(e); + case (#ok) {}; + }; + switch (Pricing.validateConfig(config)) { + case (#ok) { + pricingState.config := config; + // The cadence is derived from maxAgeNs, so re-arm rather than waiting + // for the old interval to elapse under the new window. ⚠️ Re-armed by the + // composition root: it owns the TimerId (transient) and the `` + // capability, and a mixin that could cancel timers would be able to stop the + // rate refresh from an endpoint. + ops.rearmRateTimer(); + ops.auditAdmin( + caller, + "rates.configSet", + "maxAgeNs=" # config.maxAgeNs.toText() + # " deltaBps=" # config.maxRateDeltaBps.toText() + # " divisor=" # config.divisor.toText(), + ); + #ok; + }; + case (#err(e)) #err(e); + }; + }; + + /// Replace the card presets (§3/§7 — admin, validated atomically: a bad config + /// never partially applies). + /// + /// **This is no longer the rail's on/off switch.** An empty list used to + /// pause the rail, and the audit line said "CARD RAIL PAUSED". With custom + /// amounts a buyer can order without any preset, so an empty list stops + /// nothing — it just shows no tiles. The switch is both Stripe secrets being + /// provisioned; `railsLive` is where that lives. + public shared ({ caller }) func set_card_tiers(tiers : [Tiers.Tier]) : async Result.Result<(), Tiers.ValidateError> { + ops.requireController(caller); + switch (Tiers.validate(tiers, gateState.config.minPurchaseUsdCents, gateState.config.maxPurchaseUsdCents)) { + case (#ok) { + tierState.cards := tiers; + ops.auditAdmin(caller, "tiers.set", tiers.size().toText() # " preset(s)" # (if (tiers.size() == 0) " — no presets shown; the rail is unaffected" else "")); + #ok; + }; + case (#err(e)) #err(e); + }; + }; + + /// Declare which Stripe mode this gateway serves (controller only). + /// + /// Set it to `?true` before taking real payments and `?false` on a sandbox + /// deployment. `null` restores "accept either", which only makes sense while + /// nothing of value is at stake — and it is the default, so a fresh canister + /// starts there. + /// + /// ⚠️ **The other half of the divisor's mutual refusal.** While a + /// simulation divisor is set, this refuses anything but `?false`: live mode + /// takes real money and delivers scaled cycles, and `null` accepts live + /// payments too. Mutual, so **neither order of operations** reaches the state + /// that shorts a paying buyer — it is unrepresentable rather than discouraged. + public shared ({ caller }) func set_expected_livemode(expected : ?Bool) : async Result.Result<(), LivemodeError> { + ops.requireController(caller); + if (expected != ?false and pricingState.config.divisor > 1) { + // The divisor travels as DATA: a console can say which value is blocking + // this without parsing it back out of a sentence, and the remedy is one lever. + return #err(#simulationDivisorSet({ divisor = pricingState.config.divisor })); + }; + stripeState.expectLivemode := expected; + ops.auditAdmin( + caller, + "stripe.expectLivemodeSet", + switch (expected) { + case (?true) "true — only live-mode payments deliver"; + case (?false) "false — only test-mode payments deliver"; + case null "unset — either mode delivers"; + }, + ); + #ok; + }; + + /// Adjust the admission gate (§7): open-order cap, own-cycles floor, + /// per-purchase ceiling. Validated atomically — a bad config never partially + /// applies. Lowering `maxPurchaseUsdCents` below an existing tier does NOT + /// retroactively invalidate that tier's registration, but the next + /// `set_card_tiers` will reject it and the webhook will refuse to deliver a + /// payment above the new ceiling. + public shared ({ caller }) func set_gate_config(config : Gate.Config) : async Result.Result<(), Gate.ConfigError> { + ops.requireController(caller); + // Cross-check against live tiers: lowering the ceiling under a registered tier + // would leave it sellable but unpayable (see Gate.ConfigError.tierAboveCeiling). + let tierPrices = tierState.cards.map(func(t) = (t.id, t.usdCents)); + // ⚠️ **The divisor's ceiling is a function of the FLOOR, so lowering the floor + // has to be checked against the divisor**. `set_pricing_config` refuses a + // divisor the current minimum purchase cannot survive; without this, the same + // configuration is reachable from the other direction — set the divisor at a + // $10 floor, then drop the floor to $1. Mutual, like the livemode guard, so + // neither order of operations gets there. + // + // Checked before anything writes, and only when a divisor is actually set, so a + // production gateway's gate config is unaffected. + if (pricingState.config.divisor > 1) { + switch ( + Pricing.divisorDeliverable( + rateCache, + { feeBps = pricingState.config.feeBps; feeFixedCents = pricingState.config.feeFixedCents }, + config.minPurchaseUsdCents, + pricingState.config.divisor, + reserveState.cyclesLedgerFee, + ) + ) { + case (#err(#divisorUndeliverable({ scaledCycles; ledgerFee }))) { + return #err(#floorUndeliverableAtDivisor({ + minUsdCents = config.minPurchaseUsdCents; + divisor = pricingState.config.divisor; + scaledCycles; + ledgerFee; + })); + }; + // ⚠️ **Every other `Pricing.ConfigError` is DROPPED here, and each by name so + // the discard is a decision rather than a catch-all.** A bare `case (#err(_)) + // {}` is the shape this repo has removed four times from the seed script, + // where a swallowed reason was the whole defect. + // + // - `#zeroDivisor` — unreachable: the divisor is already stored, and + // `set_pricing_config` refused zero before storing it. + // - `#divisorNeedsSandbox`, `#divisorChangeWithOrders` — not this setter's + // questions. Both are about *changing the divisor*, which is not happening. + // - `#divisorUndeliverable` is handled above; it is the only one that is + // about the floor. + // + // ⚠️ **And `divisorDeliverable` returns `#ok` when the floor is below the + // STRIPE fee**, because there is then no net amount to scale and it declines + // to judge. That is deliberately NOT refused here: a floor that cannot clear + // the processing fee is not a divisor problem, and `Pricing.quote` refuses + // such an order at creation with `#unpriceable(#stripeFee)`. Worth knowing, + // because a test that used a 2-cent floor passed for exactly this reason + // while looking like it exercised the guard. + case (#err(#zeroDivisor or #divisorNeedsSandbox(_) or #divisorChangeWithOrders(_))) {}; + // The five fee/rate cases are `validateConfig`'s, and `divisorDeliverable` + // never returns one — it does not look at the fee formula or the rate bounds. + // Listed rather than wildcarded so a new `ConfigError` has to decide here too. + case ( + #err( + #feeBpsTooHigh or #nonPositiveMaxAge or #maxAgeTooLong(_) + or #zeroRateDelta or #zeroRateSources + ) + ) {}; + case (#ok) {}; + }; + }; + switch (Gate.validateConfig(config, tierPrices)) { + case (#ok) { + gateState.config := config; + ops.auditAdmin(caller, "gate.configSet", "openOrderCap=" # config.maxOpenOrdersPerPrincipal.toText() + # " minCycles=" # config.minCanisterCycles.toText() + # " maxPurchaseCents=" # config.maxPurchaseUsdCents.toText()); + #ok; + }; + case (#err(e)) #err(e); + }; + }; + + /// Public: the frontend needs the bounds to size its amount input and to say + /// what it will accept before the buyer types. Same transparency stance as + /// `pricing_status` and `reserve_status` — these are the rules users are held + /// to, not secrets. + /// + /// **`delivery` is here because `set_delivery_config` had NO reader at all.** + /// `maxHoldNs` decides when a paid order escalates to `#needsReview` and `alertAfterNs` + /// is the delay-alert threshold — both global policy of ours, and both write-only until + /// now: they appeared in the setter's argument and its error variant and in no return + /// type anywhere, so an operator could set them and never read them back. + /// + /// The comment this replaces said there was "no lifecycle *policy* of ours to add" — + /// true of an order's DEADLINE, which is the Stripe session's `expires_at` and lives on + /// the order, and untrue as the general claim it had become. `scripts/check-config-readers.py` + /// is what stops the next write-only parameter, rather than this comment. + public query func lifecycle_config() : async { + gate : Gate.Config; + delivery : Delivery.Config; + } { + { gate = gateState.config; delivery = deliveryState.config }; + }; + + /// Tune the delivery timeline (admin, §7). + /// + /// ⚠️ Validated rather than trusted: an alert at or after the terminal bound would + /// tell the operator at the moment the decision was already taken, and a + /// non-positive bound would escalate every order instantly. + public shared ({ caller }) func set_delivery_config(config : Delivery.Config) : async Result.Result<(), Delivery.ConfigError> { + ops.requireController(caller); + switch (Delivery.validateConfig(config)) { + case (#err(e)) return #err(e); + case (#ok) {}; + }; + deliveryState.config := config; + ops.auditAdmin(caller, "delivery.configSet", "alert after " # config.alertAfterNs.toText() # " ns, terminate after " # config.maxHoldNs.toText() # " ns"); + #ok; + }; + + /// Tune the sweep cadence (admin, §7) — re-arms immediately, no redeploy. + /// Validated against the §5.1 bound: the cadence must stay well inside + /// the ledger dedup window or replay loses its safety margin. + public shared ({ caller }) func set_recovery_interval(intervalNs : Nat) : async Result.Result<(), Recovery.IntervalError> { + ops.requireController(caller); + switch (Recovery.validateInterval(intervalNs, Delivery.ledgerDedupWindowNs)) { + case (#err(e)) #err(e); + case (#ok) { + recoveryState.sweepIntervalNs := intervalNs; + ops.rearmRecoveryTimer(intervalNs); + // ⚠️ **This knob also sets the index scan's coverage window, which its + // name does not say.** The rotating scan runs one chunk per sweep, so coarsening + // the cadence multiplies the detection latency for `orders.unindexedHolders` — + // a finding that means the reserve was oversellable. Audited with the resulting + // window so the consequence is in the same line as the cause, rather than + // something an operator has to go and derive. + ops.auditAdmin( + caller, + "recovery.intervalSet", + "sweep cadence set to " # intervalNs.toText() # " ns." + # " The index scan rides this cadence, so a full coverage cycle now takes about " + # (ops.expectedIndexScanCycleNs() / 1_000_000_000 / 3_600).toText() + # " h at the current store size — that is the detection latency for orders.unindexedHolders.", + ); + #ok; + }; + }; + }; +}; diff --git a/src/backend/mixins/Maintenance.mo b/src/backend/mixins/Maintenance.mo new file mode 100644 index 0000000..3c5d26b --- /dev/null +++ b/src/backend/mixins/Maintenance.mo @@ -0,0 +1,250 @@ +// `Array` for the receiver `.map()` over the recount's counts. +import Array "mo:core/Array"; +import Error "mo:core/Error"; +import Nat "mo:core/Nat"; +import Principal "mo:core/Principal"; +import Result "mo:core/Result"; +import Set "mo:core/Set"; +import Text "mo:core/Text"; +import Time "mo:core/Time"; +import Auth "../Auth"; +import Delivery "../Delivery"; +import Gate "../Gate"; +import Orders "../Orders"; +import Pricing "../Pricing"; +import Reserve "../Reserve"; +import Types "../Types"; + +/// The four levers an operator pulls by hand: refresh the rates, refresh the reserve +/// observation, recount the tallies, and return the reserve (§7). +/// +/// ⚠️ **Every one does on demand what a timer already does on a cadence**, and that is +/// the design: each shares the sweep's implementation through `ops` rather than carrying +/// its own. A second copy of "reconcile the tallies" or "observe the reserve" would be a +/// second answer to a question that must have exactly one — and `recount_orders` in +/// particular is NOT a stronger repair than the timer's, which an operator has to be able +/// to rely on. +/// +/// ⚠️ **`withdraw_reserve` is the one irreversible lever in the system.** Refused while +/// any order still holds a promise, so nothing owed to a buyer can leave; once it runs the +/// gateway sells nothing until the reserve is funded again, and no lever brings the cycles +/// back. +mixin ( + orderStore : Orders.Store, + rateCache : Pricing.Cache, + /// ⚠️ The cycles-ledger actor reference passes directly, like `routes` in the webhook + /// mixin: it is a transient `let` built during initialisation — which is when `include` + /// evaluates its arguments — and an actor reference is an immutable value. §9.1's rule + /// is about MUTABLE state. + cyclesLedger : Delivery.CyclesLedgerService, + reserveState : { var floor : Nat; var outflowsIssued : Nat; var cyclesLedgerFee : Nat }, + ops : { + auditAdmin : (Principal, Text, Text) -> (); + requireAdmin : (Principal) -> (); + requireController : (Principal) -> (); + /// The same three passes the timers run. Shared, never reimplemented. + refreshRates : () -> async* (); + observeReserve : () -> async* { observed : Nat; quiet : Bool; holdersAfter : Nat }; + reportReconciliation : (Orders.Reconciliation) -> (); + }, +) { + + /// What a withdrawal moved. `debited` is `withdrawn + fee` — the figure the reserve + /// actually fell by, which is what reconciles against the ledger. + type Withdrawn = { withdrawn : Nat; debited : Nat; to : Types.Account }; + type WithdrawError = { + /// Cycles are still owed: at least one order is non-terminal, so a buyer either + /// holds a payable session or has paid and not been delivered. + /// + /// ⚠️ Carries the holder COUNT and the tally so an operator knows what to clear — + /// a bare refusal on a decommissioning lever is a dead end. The two figures + /// disagreeing is itself the signal that the tally has saturated. + #ordersOutstanding : { holders : Nat; promised : Nat }; + /// The observed floor is zero. Not an error state, but distinguishable from a + /// successful withdrawal of nothing. + #nothingToWithdraw; + /// The whole floor would not clear the ledger's flat deposit fee, so there is + /// nothing recoverable. + #belowLedgerFee : { floor : Nat; fee : Nat }; + /// The ledger refused or the call failed. ⚠️ The floor has already been decremented + /// and is deliberately not restored — see the call site. + #transferFailed : Text; + }; + + /// Force a rate refresh now (admin) — the ops lever after retuning config or + /// while diagnosing a stale rate, without waiting for the next tick. + public shared ({ caller }) func refresh_rates() : async ?Pricing.Rates { + ops.requireAdmin(caller); + await* ops.refreshRates(); + Pricing.lastRates(rateCache); + }; + + /// Run the reconcile now rather than waiting for the daily one (admin, §7). + /// + /// The tallies are maintained incrementally so the public status queries stay O(1); + /// this is the on-demand lever for the case where they are ever suspected of having + /// drifted. Returns the counts as they stand after the pass. + /// + /// ⚠️ **It is the same bounded pass the timer runs, with the same one-directional + /// rule — it is NOT a stronger repair, and an operator must not reach for it as one.** + /// `Orders.adoptOnlyIncreases` refuses a recount lower than the maintained tally, + /// here exactly as on the timer, because a lower recount is indistinguishable from an + /// incomplete index and adopting it is the only way an index bug could oversell the + /// reserve. There is deliberately **no** force flag and no full-scan rebuild: a lever + /// for adopting the unsafe direction would be a lever for the bug. + /// + /// **No longer the expensive path**, and it stays admin-only anyway — it writes + /// tallies the gate reads. + public shared ({ caller }) func recount_orders() : async [(Text, Nat)] { + ops.requireAdmin(caller); + let report = Orders.reconcileBounded(orderStore); + ops.reportReconciliation(report); + let rendered = report.counts.map(func((status, n)) = status # "=" # n.toText()); + ops.auditAdmin(caller, "orders.recounted", rendered.values().join(", ")); + report.counts; + }; + + /// On-demand reserve refresh (admin — a public one would let anyone spend our + /// cycles on ledger calls). The sweep does this hourly; this is the lever for + /// right after `icp cycles transfer`, so a top-up is sellable immediately. + /// + /// **`scripts/local-dev-seed.sh` and RUNBOOK's top-up step call this**, and + /// forgetting it is invisible to every typecheck: the floor stays at zero, so the + /// gateway refuses every sale against a fully funded reserve and nothing anywhere + /// says why. + public shared ({ caller }) func refresh_reserve() : async Nat { + ops.requireAdmin(caller); + (await* ops.observeReserve()).observed; + }; + + /// Return the reserve to the caller, refusing while anything is owed. + /// + /// **Why this exists at all.** A funded mainnet reserve is real money in a ledger + /// account with no way back, so without this lever decommissioning — or over-funding + /// once — is a permanent loss. *"An over-funded local reserve costs nothing"* is true + /// of a local network and of nothing else. + /// + /// ⚠️ **It grants a controller NO new capability, which is what makes it safe.** A + /// controller can install arbitrary code, so they can already move the reserve + /// anywhere by upgrading — `Auth.mo`'s tier note says exactly that. There is a cheaper + /// existing path too: rotate the webhook secret, sign a completed-session event for an + /// order you created, and take delivery having paid nothing. The trust boundary does + /// not move; an off-the-books capability becomes **one audited call**, which is more + /// visible than a wasm deploy and more visible than a run of forged orders. + /// + /// ⚠️ **A DECOMMISSIONING lever, not an incident lever, and the guard is why.** During + /// a forged-webhook drain the forged orders are open, so this refuses. The evacuation + /// path is three steps, not one: rotate the webhook secret (which closes the rail to + /// new orders), `abandon_order` the ones in flight (releasing their promises), then + /// withdraw. RUNBOOK's suspected-leak section carries that sequence. + public shared ({ caller }) func withdraw_reserve() : async Result.Result { + ops.requireController(caller); + // ⚠️ **The INDEX, not the tally.** `Reserve.applyDelta` clamps a release to zero + // when the tally has diverged low, so `promised` can read 0 while promise-holders + // still exist — the state `tallySaturations` exists to surface. Guarding on the + // tally would permit a withdraw with live orders outstanding, which is the one + // thing this guard is for. `promiseHolderCount` is `Set.size`: O(1), and set + // emptiness cannot saturate. + // + // The index is authoritative here for the same reason `reconcileBounded` treats it + // that way — a recount over `promiseHolders` is exact if the index is complete and + // too low otherwise, never too high. + // ⚠️ **The holders gate ONLY, not the whole ladder.** A withdrawal with live orders + // outstanding costs no ledger call this way — and the floor arms deliberately wait + // for the observe below, because an unobserved top-up leaves the floor reading 0 and + // refusing here would strand it. + switch ( + Reserve.ordersOutstanding( + Orders.promiseHolderCount(orderStore), + Orders.promised(orderStore), + ) + ) { + case (?refusal) return #err(refusal); + case null {}; + }; + // Observe before withdrawing, or an unobserved top-up is stranded — which defeats + // the lever. **And an empty promise index is exactly what makes the observation + // adoptable**: `unsettledDeliveries` is a walk over `promiseHolders`, so no + // holders means no unsettled deliveries means the reconcile's quiet window holds. + // The withdraw guard and the observation guard are the same structure, so they + // cannot disagree. + // ⚠️ **RE-CHECK, because `observeReserve` contains an await and the floor is still + // FULL across it.** The decrement below is what refuses a concurrent create, and it + // has not happened yet — so a `create_order` queued during the balance read sees a + // full reserve, `Gate.solvent` admits it, and the buyer walks away with a payable + // session against a reserve that is about to leave. If they pay, they have paid for + // cycles that are gone. + // + // ⚠️ **The count comes BACK from the observe rather than being re-read here**, so + // the guard cannot be forgotten by anyone editing this function: `observeReserve` + // computes it after its own await, alongside the `quiet` flag it computes for the + // same reason. A comment saying "re-read this" would be the weakest guard available, + // and comments are exactly what this codebase keeps finding insufficient. + let { holdersAfter } = await* ops.observeReserve(); + // ⚠️ **The same ladder again, and that is the guard.** An await just happened with + // the floor still full, so the holder count is re-tested — and the observe may have + // moved the floor, so the arithmetic is redone on what it now says. Two call sites, + // one decision: the previous shape repeated the holder test inline, where the two + // copies could drift. + let fee = reserveState.cyclesLedgerFee; + let { debited; amount } = switch ( + Reserve.withdrawable(holdersAfter, Orders.promised(orderStore), reserveState.floor, fee) + ) { + case (#ok(figures)) figures; + case (#err(e)) return #err(e); + }; + let to : Types.Account = { owner = caller; subaccount = null }; + // ── Rule 2 (§5.4): the floor drops when the transfer is ISSUED ────────── + // + // ⚠️ **Synchronously, before the await, or this is a TOCTOU on the money path.** + // `create_order` interleaves on the await below: it would check `Gate.solvent` + // against a reserve being emptied, admit an order, and leave a buyer paying for + // cycles that are gone. With the floor at zero first, every concurrent create + // refuses for free — no new lock and no new lever. + // + // ⚠️ **By `amount + fee`, not `amount`** — the figure actually debited, the same + // correction §5.4 carries for delivery. Decrementing by the transferred amount + // alone would leave the floor overstating the account by exactly the fee, and the + // next observation would report an unexplained shortfall: the one signal that is + // supposed to mean an outflow we did not cause. + reserveState.floor := Reserve.floorAfterOutflow(reserveState.floor, amount + fee); + reserveState.outflowsIssued += 1; + let result = try { + await cyclesLedger.icrc1_transfer( + Delivery.withdrawArgs(to, amount, fee, Time.now()) + ); + } catch (e) { + // ⚠️ The floor is NOT credited back. A call that failed without a reply says + // nothing about whether the ledger acted, and rule 2 exists to be pessimistic + // about exactly that; a reconcile heals it if the transfer never happened. + ops.auditAdmin(caller, "reserve.withdrawFailed", "to " # to.owner.toText() # ": " # e.message()); + return #err(#transferFailed(e.message())); + }; + switch (result) { + case (#Ok(block)) { + // ⚠️ The destination is IN the audit line, not just the fact of a withdrawal. + // A controller destination is the new class in this whole design, so a record + // that omits it is weaker than it looks. + ops.auditAdmin( + caller, + "reserve.withdrawn", + amount.toText() # " cycles to " # to.owner.toText() + # " (debited " # (amount + fee).toText() # " incl. fee " # fee.toText() + # ", block " # block.toText() # ")", + ); + #ok({ withdrawn = amount; debited = amount + fee; to }); + }; + case (#Err(e)) { + // ⚠️ **The floor stays decremented here, and that is safe and self-correcting + // rather than a bookkeeping hole.** Understating the floor only ever sells + // *less*, and adoption is increase-only, so the next quiet observation raises it + // back to the real balance. Crediting it back would be the unsafe direction: a + // refusal we misread as definitive would leave the floor optimistic with nothing + // left to re-decrement it. + let detail = Delivery.transferErrorToText(e); + ops.auditAdmin(caller, "reserve.withdrawRefused", "to " # to.owner.toText() # ": " # detail); + #err(#transferFailed(detail)); + }; + }; + }; +}; diff --git a/src/backend/mixins/Monitoring.mo b/src/backend/mixins/Monitoring.mo new file mode 100644 index 0000000..452f95c --- /dev/null +++ b/src/backend/mixins/Monitoring.mo @@ -0,0 +1,501 @@ +import Cycles "mo:core/Cycles"; +import Map "mo:core/Map"; +import Time "mo:core/Time"; +import Delivery "../Delivery"; +import Gate "../Gate"; +import Idempotency "../Idempotency"; +import Orders "../Orders"; +import Orphans "../Orphans"; +import Pricing "../Pricing"; +import Reserve "../Reserve"; +import Types "../Types"; +import Card "../rails/Card"; + +/// Every read-only operational surface: is anything wrong right now, and what the +/// counters say (§8). +/// +/// ⚠️ **Public and unauthenticated except where noted, deliberately.** Operational state +/// is public by design here — the reserve is an account on a public ledger, and a monitor +/// should not need a controller key. The one admin read carries `requireAdmin` for the +/// reason stated at it. +/// +/// ⚠️ **Nothing in this mixin writes**, which is exactly why the state records are passed +/// rather than accessor pairs (`docs/DESIGN.md` §9.1): a `var` passed by value would +/// answer its install-time value forever, and for a monitoring surface that is worse than +/// an error — it looks like a healthy system. +/// +/// **Each state parameter declares only the FIELDS these reads touch**, not the whole +/// record. Motoko's width subtyping accepts the fuller record, so the signature is the +/// A6 "pass only the slice it uses" rule enforced by the compiler rather than asserted in +/// a comment — adding a field elsewhere cannot silently widen what this mixin can reach. +mixin ( + orderStore : Orders.Store, + dedup : Idempotency.Store, + orphanStore : Orphans.Store, + paidIntents : Map.Map, + rateCache : Pricing.Cache, + reserveState : { var floor : Nat; var observedAtNs : ?Int; var cyclesLedgerFee : Nat }, + gateState : { + var config : Gate.Config; + var refusals : Gate.RefusalCounts; + var latch : Gate.RailStateLatch; + }, + pricingState : { + var config : Pricing.Config; + var lastAttempt : ?{ atNs : Int; ok : Bool; detail : Text }; + }, + stripeState : { var expectLivemode : ?Bool }, + recoveryState : { + var sweepIntervalNs : Nat; + var lastSweep : ?{ atNs : Int; pending : Nat }; + var lastCountReconcile : ?{ + atNs : Int; + drift : [Orders.Drift]; + refused : [Orders.Drift]; + ordersRead : Nat; + }; + var lastReserveReconcileAttemptNs : Int; + var lastCountReconcileAttemptNs : Int; + var indexScanCycle : { startedAtNs : Int; ordersRead : Nat; repairs : Nat }; + var lastIndexScanCycle : ?{ + startedAtNs : Int; + completedAtNs : Int; + ordersRead : Nat; + repairs : Nat; + }; + }, + /// The private helpers these reads share with the sweeps that maintain them, passed as + /// closures: each is actor-bound — they read several stores at once, and one needs the + /// canister's own principal. + ops : { + requireAdmin : (Principal) -> (); + selfPrincipal : () -> Principal; + unsettledDeliveries : () -> Nat; + delayedDeliveryCount : (Int) -> Nat; + expectedIndexScanCycleNs : () -> Nat; + /// ⚠️ Two TRANSIENT vars, read through getters. Transient state is rebuilt on every + /// start, so passing the field would hand this mixin a value frozen at include time + /// — and both exist precisely to say "has this run since the canister started", + /// which a frozen copy answers wrongly and confidently. + lastXrcCanisterId : () -> ?Text; + recoverySweepInFlight : () -> Bool; + }, +) { + + /// Rates + params + refresh liveness, public: both rates are market data any + /// third party can query for themselves, and the fee formula is what users + /// are charged. Nothing here is secret, and reproducibility is the point. + public query func pricing_status() : async { + rates : ?Pricing.Rates; + config : Pricing.Config; + lastAttempt : ?{ atNs : Int; ok : Bool; detail : Text }; + /// Which Exchange Rate Canister the last refresh actually priced from. On + /// mainnet this MUST read `uf6dk-hyaaa-aaaaq-qaaaq-cai`; anything else means + /// the deploy injected `PUBLIC_CANISTER_ID:xrc` and prices are coming from + /// somewhere else. Alert on it (RUNBOOK's monitoring section). + /// + /// **Null means no refresh has resolved it yet** — not that it is the mainnet + /// canister. Null is the expected reading for the first seconds after an + /// install or upgrade, and it is a "check again", never a pass. + xrcCanisterId : ?Text; + } { + { + rates = Pricing.lastRates(rateCache); + config = pricingState.config; + lastAttempt = pricingState.lastAttempt; + xrcCanisterId = ops.lastXrcCanisterId(); + }; + }; + + /// Which Stripe mode this gateway declares it serves, or null while unset. + /// + /// Public because the page reads it: a sandbox deployment says so on every view, and + /// a mismatch against the mode a webhook arrives in is what `Card.handleWebhook` + /// refuses on. `set_expected_livemode` is the controller-only setter. + public query func expected_livemode() : async ?Bool { + stripeState.expectLivemode; + }; + + /// How much is outstanding, as two numbers rather than a collection. + /// + /// ⚠️ **The shape to poll, and the reason it exists separately from the list.** A + /// paginated detail query needs a cheap total beside it or a monitor pages through + /// everything to learn one number — the same split `orphan_depth` already has, and + /// the same reason RUNBOOK says to alert on the depth and fetch details only when it + /// fires. + /// + /// ⚠️ **`unresolved` is NOT `orders`.** One order can carry several problems, so a + /// caller reading the order count undercounts the work. + public query func problem_depth() : async { unresolved : Nat; orders : Nat } { + { + unresolved = Orders.unresolvedProblemCount(orderStore); + // ⚠️ O(1) — the index's size. This built the whole worklist as an array to read its + // length, on a query anyone can call. + orders = Orders.unresolvedProblemOrderCount(orderStore); + }; + }; + + /// Refusal tallies, and whether the gate is refusing right now. + /// + /// ⚠️ **Public, like every other monitoring surface here** — operational state + /// is public by design; the webhook secret is the only secret in the system. + /// + /// ⚠️ **This query is the point of the counters.** A tally nobody reads is the + /// `Orders.tallySaturations` failure over again, so RUNBOOK's monitoring section carries a row + /// per counter with the response — the counters mean different things: + /// `amountBelowMin` climbing is a UI bug or an attacker probing, while + /// `reserveShort` climbing is a refill. Same shape, opposite actions. + public query func refusal_counts() : async { + counts : Gate.RefusalCounts; + /// True while that rail-state condition is refusing. Each flips to true with + /// exactly one `gate.startedRefusing` audit line and clears on the next + /// successful admission. + refusingNow : Gate.RailStateLatch; + } { + { counts = gateState.refusals; refusingNow = gateState.latch }; + }; + + /// Open-obligation depth, public. + /// + /// Nothing is evicted, so this only comes down by the operator working it. A climbing + /// value means dollars are arriving that nobody has dealt with — the most important + /// operational number on the money path, and public because operational state is not + /// secret (§8). + public query func orphan_depth() : async { unresolved : Nat; retained : Nat } { + { + unresolved = Orphans.unresolvedCount(orphanStore); + retained = Orphans.size(orphanStore); + }; + }; + + /// The operator worklist: open obligations only, paged. Filtered server-side + /// so a large body of resolved history never stands between the operator and + /// the dollars that still need an answer. + public shared query ({ caller }) func orphans_unresolved( + afterId : ?Nat, + limit : Nat, + ) : async Orphans.Page { + ops.requireAdmin(caller); + Orphans.unresolvedPage(orphanStore, afterId, limit); + }; + + /// Reserve solvency and order counters, public. + /// + /// `reserveFloor` − `promisedTotal` = `availableToSell`, in one answer, so "the ledger + /// says 100 T and the gateway will sell 0" is diagnosable at a glance (§3.2). + /// + /// ⚠️ **`reserveFloor` is a maintained lower BOUND, not the balance, and must not cache + /// one.** Caching invents a staleness class over a number the caller can read from the + /// source. A floor far below the ledger's balance means nothing has reconciled since the + /// last top-up — `reserveObservedAtNs` says when it last did, `refresh_reserve` is the + /// lever. + /// + /// ⚠️ **Uncertified query answers, and nothing may be wired to decide on them.** + /// `create_order` decides solvency from the same state *synchronously*, inside the + /// order-creating message; that is what stops the decision being raced into an + /// over-sale. + public query func reserve_status() : async { + reserveFloor : Nat; + promisedTotal : Nat; + /// How many orders still hold a promise — `promiseHolders.size()`, O(1). + /// + /// ⚠️ **This is what `withdraw_reserve` guards on, so it must be readable before + /// calling it**: the refusal names a count an operator then has to go and + /// find, and a decommissioning lever that cannot tell you what is blocking it is a + /// dead end. + /// + /// ⚠️ **It is also the direct read on a saturated tally.** `promisedTotal` clamps to + /// zero on release when it has diverged low, so `promisedTotal == 0` with + /// `promiseHolders > 0` is exactly the state `tallySaturations` counts — visible here + /// side by side rather than inferred from a counter. + promiseHolders : Nat; + availableToSell : Nat; + reserveObservedAtNs : ?Int; + cyclesLedgerFee : Nat; + tallySaturations : Nat; + reserveAccount : Types.Account; + canisterCycles : Nat; + minCanisterCycles : Nat; + openOrders : Nat; + expiredOrders : Nat; + totalOrders : Nat; + paidIntentsIndexed : Nat; + } { + { + reserveFloor = reserveState.floor; + promiseHolders = Orders.promiseHolderCount(orderStore); + /// Null means **no reconcile has ever run**, which is also why a freshly + /// installed canister sells nothing until the operator refreshes: the floor + /// starts at zero and only a look at the ledger can raise it. + reserveObservedAtNs = reserveState.observedAtNs; + /// Exactly what the gate computes, from the same two numbers, so a refused + /// sale and this figure can never tell different stories. + availableToSell = Reserve.available(reserveState.floor, Orders.promised(orderStore)); + /// The fee the NEXT delivery will use, and the only way to see that `#BadFee` + /// self-correction actually happened. ⚠️ Nothing but the ledger + /// writes it — there is deliberately no admin lever — so a value at or above an + /// order's locked quantity stalls delivery loudly and the answer is a redeploy; + /// at that fee the rail cannot sell anyway. + cyclesLedgerFee = reserveState.cyclesLedgerFee; + // O(1): maintained counters, not a scan of the order store. + openOrders = Orders.countOf(orderStore, #created); + expiredOrders = Orders.countOf(orderStore, #expired); + totalOrders = orderStore.orders.size(); + paidIntentsIndexed = paidIntents.size(); + promisedTotal = Orders.promised(orderStore); + /// ⚠️ **Any non-zero value means the tally has diverged.** A saturation is a + /// release asking to remove more than was held, so it says the tally was + /// already wrong *before* that order got there — strictly worse than a fault + /// in the order being released. The daily recount reports drift's SIZE; this + /// reports its EXISTENCE, same day. RUNBOOK's monitoring section alerts on any increment. + tallySaturations = orderStore.tallySaturations; + // Named so an operator (or the frontend) can point a ledger query at the + // right account without reconstructing it. + reserveAccount = Delivery.reserveAccount(ops.selfPrincipal()); + // The gas half, which is a different pot from the reserve: what the canister + // spends to run, gated by `minCanisterCycles`. + canisterCycles = Cycles.balance(); + minCanisterCycles = gateState.config.minCanisterCycles; + }; + }; + + /// §5.2 liveness observability, public (operational transparency, same stance as + /// `reserve_status`): cadence + last completed timer sweep. A null or stale + /// `lastSweep` means recovery is not running. + /// This canister's OWN cycle balance and the floor the admission gate holds it + /// against (public — the same operational-transparency stance as `reserve_status`; + /// it is visible via `canister_status` regardless). + /// + /// ⚠️ **Gas, not stock.** This is what the canister spends to run; the cycles it + /// sells live in its cycles-ledger account and are reported by `reserve_status`. + /// Below the freezing threshold the canister stops accepting updates; at zero it is + /// uninstalled and the order store, journals, and dedup sets go with it. Monitor it + /// separately, and alert well above `minCanisterCycles` — that gate stops *sales*, + /// it does not stop the burn. A sudden acceleration here is the + /// signature of a cycle-drain attempt. + public query func cycles_status() : async { balance : Nat; floor : Nat } { + { balance = Cycles.balance(); floor = gateState.config.minCanisterCycles }; + }; + + /// The public trust figures — anonymous, safe on a landing page. + /// + /// **The admission test is "what does a POLLER learn from the deltas?", not "does this + /// field name a buyer?"** Cumulative counters are differentiable: anyone sampling this + /// query recovers each delivery's cycles, USD and timing from the increments. That is + /// accepted here because it is not new — `reserve_status` is already public and its + /// `promisedTotal` leaks per-order amounts the same way, and USD is near-derivable from + /// the public `card_tiers`/`quote_previews` — but the field-level reading of the test is + /// what will wave through the field that *does* add something. Do not add a + /// most-recent-order field, a largest-purchase field, or anything per-principal. + /// + /// **`refusingNow` is REUSED, not re-derived.** It is the same `gateState.latch` + /// `refusal_counts` reports, so "is the rail accepting orders" has one definition and + /// cannot come out differently on two surfaces. + /// + /// **The counters read zero on a fresh install and that is correct, not a bug.** + /// Orders are never deleted, but a reinstall replaces the state, so a launch-day figure + /// starts at zero whichever way it is built. + /// + /// **The renderer must show that zero — do NOT add a threshold.** Saying "0 + /// orders delivered is worse than no badge" and that was rejected: an absent number is + /// indistinguishable from a withheld one, and a rule that hides the figure exactly when + /// the news is bad is a misleading presentation rather than a neutral one. This comment + /// used to instruct the opposite, which would have had a future implementer build the + /// thing the decision removed. + /// + /// `nullPaid` should always be 0. It counts delivered orders whose `paidUsdCents` was + /// unset, which `markPaid` makes unreachable — a non-zero value means the USD total is + /// understated and the reason is a bug in this canister, not in the display. + /// **One call, because it is the landing page's whole backend.** `availableToSell` + /// and `refusingNow` also appear on `reserve_status` and `refusal_counts` — that is + /// duplication of the READER, not of the definition: both are read here from the same + /// state those queries read, never recomputed. Folding them in keeps a first paint to a + /// single round trip and a single mock in the test harness. + /// + /// **`availableToSell` leads, and it is a different KIND of number from the + /// others.** It is derived from a balance on the cycles ledger that anyone can query + /// without this canister's cooperation, so a visitor can check it rather than believe + /// it. The delivered totals are ours to report. Do not present them as equivalent. + public query func delivery_stats() : async { + availableToSell : Nat; + deliveredOrders : Nat; + deliveredCycles : Nat; + deliveredUsdCents : Nat; + nullPaid : Nat; + refusingNow : Gate.RailStateLatch; + } { + let totals = Orders.deliveryTotals(orderStore); + { + availableToSell = Reserve.available(reserveState.floor, Orders.promised(orderStore)); + deliveredOrders = totals.orders; + deliveredCycles = totals.cycles; + deliveredUsdCents = totals.usdCents; + nullPaid = totals.nullPaid; + refusingNow = gateState.latch; + }; + }; + + /// "Is anything wrong right now" in ONE call. + /// + /// **Public is a decision, not a default: alerting needs no credentials.** What + /// reaches a human at 03:00 is a cron on the public queries, and an admin-gated summary + /// would put that back on a credentialed cron. Everything here is a COUNT, never an + /// entry, and `reserve_status` already publishes `totalOrders`, `openOrders`, + /// `expiredOrders`, `promisedTotal` and `availableToSell` — so there is no new exposure + /// class, only one fewer round trip. + /// + /// ⚠️ **The two delivery numbers are measured over DIFFERENT populations, and neither + /// contains the other.** `deliveriesOutstanding` means a transfer has been ISSUED, so it + /// needs the journal entry that records the intent. `deliveriesDelayed` reads the + /// ORDER's own clock and needs neither. + /// + /// Both directions are reachable, so `deliveriesDelayed` is **not** a subset: + /// - outstanding, not delayed: a transfer issued seconds ago. + /// - delayed, not outstanding: a delivery that bailed before issuing — short reserve, + /// stale rate, gas floor — so there is no intent to be outstanding about. + /// - both: a transfer issued long enough ago that the clock ran out, including one that + /// landed without its block recorded. + /// + /// That last case is the CANONICAL outstanding shape (`intent` set, `blockIndex` + /// null), not a delayed-only one — it is what `unsettledDeliveries` exists to detect and + /// what freezes the reconcile's quiet window. Filing it under "delayed, not outstanding" + /// would tell an operator that `outstanding = 0` means no transfer is in flight, when a + /// transfer of unknown fate is exactly what it means. + /// + /// So `outstanding = 0, delayed = 1` is a real state, not the summary contradicting + /// itself — and a UI that presented one as a subset of the other would be wrong exactly + /// where it matters. + /// + /// They also differ in what they ask of a human: `deliveriesOutstanding` self-clears — + /// it is money-out in flight and the answer is wait — while `ordersNeedingReview`, + /// `orphansUnresolved` and `problemsUnresolved` are the three that mean a human is + /// needed. A summary that flattened those would make waiting look like work. + /// + /// **`deliveriesOutstanding` is exactly the reserve reconcile's quiet-window + /// predicate**, deliberately: it is also the answer to "why does the reconcile keep + /// skipping", and sharing the definition means the number an operator reads cannot + /// disagree with the number the reconcile acted on. + /// + /// **What bounds each number, stated because "bounded" alone would hide a difference:** + /// `ordersNeedingReview`, `ordersWithProblems` and `availableToSell` are O(1) tallies. + /// `deliveriesOutstanding` and `deliveriesDelayed` are bounded by `promiseHolders`, i.e. + /// by flow (§5.4). `problemsUnresolved` is bounded by the unresolved-problem index and + /// `orphansUnresolved` walks retained orphan history — both grow only while obligations + /// go uncleared, and an orphan costs a real payment or the signing secret to create + /// (`Orphans.add`), so neither is attacker-inflatable. Not O(1), and not the + /// grows-with-successful-business shape this avoids. + public query func operator_summary() : async { + deliveriesOutstanding : Nat; + deliveriesDelayed : Nat; + ordersNeedingReview : Nat; + orphansUnresolved : Nat; + problemsUnresolved : Nat; + ordersWithProblems : Nat; + refusingNow : Gate.RailStateLatch; + availableToSell : Nat; + reserveObservedAtNs : ?Int; + } { + { + deliveriesOutstanding = ops.unsettledDeliveries(); + deliveriesDelayed = ops.delayedDeliveryCount(Time.now()); + ordersNeedingReview = Orders.countOf(orderStore, #needsReview); + orphansUnresolved = Orphans.unresolvedCount(orphanStore); + problemsUnresolved = Orders.unresolvedProblemCount(orderStore); + ordersWithProblems = Orders.unresolvedProblemOrderCount(orderStore); + refusingNow = gateState.latch; + availableToSell = Reserve.available(reserveState.floor, Orders.promised(orderStore)); + reserveObservedAtNs = reserveState.observedAtNs; + }; + }; + + /// The recovery machinery's own clocks and its last findings, public. + /// + /// Four independent passes report here — the stranded sweep, the tally reconcile, the + /// reserve reconcile and the rotating index scan — because each can stop running + /// without any of the others noticing. RUNBOOK's monitoring section alerts on the gaps between them. + public query func recovery_status() : async { + intervalNs : Nat; + lastSweep : ?{ atNs : Int; pending : Nat }; + sweepInFlight : Bool; + /// Last **successful** tally reconciliation. A non-empty `drift` means the + /// incremental counts had diverged and were **raised** to the recount — the tallies + /// are correct again, but the bug that moved them is not fixed. A non-empty + /// `refused` means the recount came out **lower** and the pass would not adopt it, + /// so those tallies are still suspect. `recount_orders` is the on-demand form + /// of the same pass, with the same rule. + lastCountReconcile : ?{ + atNs : Int; + drift : [Orders.Drift]; + refused : [Orders.Drift]; + ordersRead : Nat; + }; + /// When one was last *attempted*. Reported alongside the success timestamp so + /// "due tomorrow" and "attempted today and failed" are distinguishable without + /// correlating against the sweep clock: an attempt materially newer than the + /// success means the reconcile is trapping (RUNBOOK's monitoring section). + lastCountReconcileAttemptNs : Int; + /// When the RESERVE reconcile was last attempted. Its success clock + /// is `reserve_status.reserveObservedAtNs`, and the two diverging is the one + /// signal that says "the floor is stale on purpose": either the ledger read is + /// failing, or every attempt has landed on a non-quiet window. Both under-sell + /// rather than over-sell, so this is a P3 that explains refusals — not an + /// incident. + lastReserveReconcileAttemptNs : Int; + /// The rotating index scan's **coverage** — the reader without which a clean + /// scan says nothing. + /// + /// ⚠️ **Read `lastCompletedCycle` before reading the absence of an audit line as + /// "no drift".** The scan verifies the one property that needs every order — that + /// nothing *outside* an index satisfies the index's predicate — so it can only + /// speak for what it has visited. Silence plus a recent `completedAtNs` means + /// verified clean; silence with no completed cycle, or one much older than the + /// window below, means **unverified**, which is not the same thing and carries the + /// opposite response: wait for the pass rather than hunt for a writer. + /// + /// `inFlightCycle.ordersRead` against `storedOrders` is how far the current cycle + /// has walked. `chunkSize` and the sweep interval give the expected window: + /// `storedOrders ÷ (chunkSize × sweeps per day)` days per full cycle. + indexScan : { + chunkSize : Nat; + storedOrders : Nat; + /// How long a full coverage cycle is **expected** to take at the current store + /// size and the current sweep cadence. + /// + /// ⚠️ **Computed, not configured, so it moves when either input does** — and the + /// sweep cadence is one `set_recovery_interval` away from 24× the default. This is + /// the detection latency for `orders.unindexedHolders`, which is a money finding, + /// so it is reported rather than left as arithmetic an operator has to know to do. + /// Compare `lastCompletedCycle.completedAtNs` against this: much older means the + /// scan is behind its own expectation, not merely mid-cycle. + expectedFullCycleNs : Nat; + inFlightCycle : { startedAtNs : Int; ordersRead : Nat; repairs : Nat }; + lastCompletedCycle : ?{ + startedAtNs : Int; + completedAtNs : Int; + ordersRead : Nat; + repairs : Nat; + }; + }; + } { + { + intervalNs = recoveryState.sweepIntervalNs; + lastSweep = recoveryState.lastSweep; + sweepInFlight = ops.recoverySweepInFlight(); + lastCountReconcile = recoveryState.lastCountReconcile; + lastCountReconcileAttemptNs = recoveryState.lastCountReconcileAttemptNs; + lastReserveReconcileAttemptNs = recoveryState.lastReserveReconcileAttemptNs; + indexScan = { + chunkSize = Orders.scanChunkSize; + storedOrders = Orders.storedCount(orderStore); + expectedFullCycleNs = ops.expectedIndexScanCycleNs(); + inFlightCycle = recoveryState.indexScanCycle; + lastCompletedCycle = recoveryState.lastIndexScanCycle; + }; + }; + }; + + /// Liveness probe; also used by the scaffold smoke test path. + public query func health() : async Bool { + true; + }; +}; diff --git a/src/backend/mixins/Orders.mo b/src/backend/mixins/Orders.mo new file mode 100644 index 0000000..cb35f24 --- /dev/null +++ b/src/backend/mixins/Orders.mo @@ -0,0 +1,314 @@ +import Principal "mo:core/Principal"; +import Result "mo:core/Result"; +import Set "mo:core/Set"; +import Text "mo:core/Text"; +import Auth "../Auth"; +import Delivery "../Delivery"; +// `Map` for the receiver `.get()` on the delivery journal's map. +import Map "mo:core/Map"; +import Orders "../Orders"; +import Receipts "../Receipts"; +import Session "../rails/Session"; +import Types "../Types"; + +/// What a buyer can do with their own orders: read them, read the receipt, give up on an +/// unpaid one, and ask for a stalled delivery to be driven again (§2). +/// +/// ⚠️ **Owner-scoped, not admin-scoped, and the distinction is the whole point of §2.** +/// Every read here goes through `Orders.getOwned` or the caller's own index, so existence +/// is not revealed to a non-owner. The admin equivalents are separate methods in +/// `mixins/AdminOrders.mo` precisely so that lifting the owner boundary is a visible, +/// audited act rather than a branch inside these. +/// +/// ⚠️ **`cancelRequests` is passed as the `Set` itself** — a heap object, so the intent +/// this mixin records is the same intent the webhook reads (`docs/DESIGN.md` §4.3). An +/// accessor pair would have worked too; a copy would have silently broken attribution. +/// +/// **The two `async*` helpers stay in the composition root.** `expireStripeSession` +/// makes the Stripe outcall and `processDelivery` drives money out of the reserve; both +/// are actor-bound and both are shared with the timers. Passing them as +/// `async*`-returning closures keeps one implementation for the endpoint and the sweep, +/// which is what stops "cancel" and "the sweep cancelled" from diverging. +mixin ( + orderStore : Orders.Store, + deliveryJournal : Delivery.Journal, + cancelRequests : Set.Set, + ops : { + audit : (Text, Text) -> (); + auditAdmin : (Principal, Text, Text) -> (); + noteStripeApiFailed : (Text) -> (); + tryTransition : (Types.OrderId, Types.OrderStatus) -> ?Types.Order; + expireStripeSession : (Text) -> async* Session.ExpireOutcome; + processDelivery : (Types.OrderId) -> async* (); + /// Is this principal a granted admin? Passed rather than reimplemented so + /// `process_order` decides admin-ness by the same predicate `requireAdmin` does. + isGrantedAdmin : (Principal) -> Bool; + /// ⚠️ A TRANSIENT set, read through a closure. `deliveriesInFlight` is the + /// single-flight guard for money-out, rebuilt on every start — a copy taken at + /// include time would report an empty set forever and let a second kick in. + deliveryInFlight : (Types.OrderId) -> Bool; + }, +) { + + /// Why a manual delivery kick did nothing. + type ProcessOrderError = { #notFound; #inFlight }; + + /// §2 query authz: `caller == order.owner`, null otherwise — existence is + /// not revealed to non-owners. Anonymous callers own nothing by + /// construction (create_order rejects them), so they always get null. + public shared query ({ caller }) func get_order(id : Types.OrderId) : async ?Types.Order { + Orders.getOwned(orderStore, id, caller); + }; + + /// Why a buyer's cancel refused (§4.3). + /// + /// The wording moved to the frontend; the payloads carry every fact the sentences + /// asserted, which is the condition under which copy may leave the canister — see + /// §7.2. + type CancelOrderError = { + #notFound; + /// Nothing was charged and nothing will deliver — the opposite case from + /// `#notCancellable`, which is why they are separate arms. + #alreadyExpired; + /// Money has moved or is moving. Carries the status the answer names back. + #notCancellable : { status : Types.OrderStatus }; + /// ⚠️ Stripe answered but would not close the session, and this arm cannot tell + /// WHICH of three causes it was: the payment completed, the session had already + /// expired, or Stripe refused the request. One tag for all three is the point + /// — anything more specific would be a diagnosis this arm does not have. + #sessionNotClosed; + /// A 5xx or the outcall itself failing. The order stays payable and uncancelled. + #stripeUnavailable; + /// 401/403 — the operator has been notified through the latch. + #credentialsRefused; + /// Someone else settled the order while this call was in flight. Carries the status + /// it settled to, which the sentence used to defer to the page for. + #settledInFlight : { status : Types.OrderStatus }; + }; + + /// Order history for the caller (§2, fixes the lost-receipt problem). + /// The caller's own orders, **paginated**. + /// + /// **This was a latent trap on the BUYER path, not an ergonomic wart.** It returned + /// every order the caller owns, unbounded, and a query response is capped at ~2 MB — + /// so an oversized read does not degrade, it **traps**. The open-order cap of 1 means + /// a buyer accumulates them slowly, but nothing bounded it, and nothing drops orders + /// on the order. + /// + /// **Paging bounded the RESPONSE; `Orders.ownerPage` bounds the WORK.** The + /// admin pager's owner filter walks every principal's orders to find one principal's, + /// so this used to cost O(all orders ever created) in a single message — a page cap on + /// a ~2 MB response, against a limit that is actually instructions. `ownerPage` walks + /// the caller's own index from the cursor instead, and its cost is that buyer's page. + public shared query ({ caller }) func list_orders( + afterId : ?Types.OrderId, + limit : Nat, + ) : async Orders.Page { + // ⚠️ The SAME function the unit test's `scanned` bound is asserted on, with the + // count projected away here. A separate uninstrumented path for production + // would put that bound on code nobody runs. + Orders.ownerPage(orderStore, caller, afterId, limit).page; + }; + + /// Manual delivery kick — **admin, or the order's own owner**. + /// + /// Safe to spam by construction: every step is journalled, deduplicated, idempotent + /// and single-flighted. A page refresh heals a stuck order in seconds rather than + /// waiting a sweep interval. + /// + /// ⚠️ **Owner-scoped, not public.** `getOwned` is the guard, so a caller can only kick + /// their OWN order — one order per kick, serialised, on an order they paid real money + /// to create. *Unauthenticated* traffic triggering a sweep over **every** order is a + /// different shape and stays refused. + /// + /// ⚠️ **This does not replace the recovery sweep and must not be read as making it + /// optional.** The sweep is the *guarantee* — we took the money, so we deliver whether + /// or not the buyer comes back; this is the *latency fix*. A retry that only exists in + /// the UI makes fulfilling an obligation depend on the buyer returning, and whoever + /// closed the tab is exactly who most needs us to finish. + /// + /// **An owner kicking their own order is NOT audited**, because the log drops + /// nothing and a refresh loop would be permanent state growth driven by a + /// caller. An admin kick is audited — it is an ops action on someone else's order. + public shared ({ caller }) func process_order(id : Types.OrderId) : async Result.Result { + let isAdmin = Auth.checkAdmin(caller, Principal.isController, ops.isGrantedAdmin).isOk(); + if (isAdmin) { + ops.auditAdmin(caller, "delivery.manualKick", id); + } else { + // Not an admin, so this must be the owner's own order. `getOwned` answers + // "not found" for someone else's, which is also the right answer to give: + // whether an id exists is not a stranger's business. No separate anonymous + // check is needed — `create_order` refuses the anonymous principal, so it + // owns no order and every id answers `#notFound` for it. + if (Orders.getOwned(orderStore, id, caller) == null) return #err(#notFound); + }; + if (Orders.get(orderStore, id) == null) return #err(#notFound); + if (ops.deliveryInFlight(id)) return #err(#inFlight); + await* ops.processDelivery(id); + switch (Orders.get(orderStore, id)) { + case (?order) #ok(order); + case null #err(#notFound); + }; + }; + + /// Let a buyer give up on their own unpaid order (owner-scoped). + /// + /// ⚠️ **Load-bearing on the open-order cap**, whose refusal tells the buyer to pay or + /// abandon one — advice they cannot follow without this, since `abandon_order` is + /// admin-only and takes *paid* orders. Remove it and a buyer who opened the cap's worth + /// of checkouts is locked out until their sessions expire. + /// + /// ⚠️ **Nothing is stranded, and the reason is the ORDERING**: the session is expired + /// on Stripe *before* the order moves, so an in-flight payment either wins that race + /// (and the order is not cancelled at all) or it cannot start. `#cancelled → #paid` is + /// absent from the matrix, so a cancelled order is unpayable by construction. + /// + /// No problem filed: nothing is owed, and filing an obligation for an order where no + /// money moved is exactly the noise the worklist must not accumulate. + public shared ({ caller }) func cancel_order(id : Types.OrderId) : async Result.Result { + let ?order = Orders.getOwned(orderStore, id, caller) else return #err(#notFound); + // WHICH answer is `Orders.cancelShape`'s decision, over the whole status space and + // unit-tested there; how it READS stays here, because a buyer sees these words + // verbatim (§4.3). + switch (Orders.cancelShape(order.status)) { + case (#proceed) {}; + case (#alreadyCancelled) return #ok(order); + case (#alreadyExpired) { + return #err(#alreadyExpired); + }; + case (#notCancellable(status)) return #err(#notCancellable({ status })); + }; + // `#cancelled`, not `#expired`: the buyer's own decision is a distinct state, + // so a reload shows them "Cancelled" rather than telling them their order + // expired. And `#cancelled → #paid` is absent from the matrix, which is + // what makes a cancelled order unpayable by construction rather than by a + // runtime check somebody has to remember. + // + // ── Atomic with Stripe ───────────────────────────────────────────────── + // Expire the session FIRST, then mark the order. Nothing is ever *half* + // cancelled: if the session is still live on Stripe, the order is not + // cancelled. That ordering is the whole reason `#cancelled → #paid` never + // needs to be legal — Stripe guarantees a session ends in exactly one of + // completed/expired, so a successful expire proves no payment completed. + // + // An earlier draft made this outcall non-fatal (audit and return success + // anyway). Rejected: it recreates the half-cancelled state — order says + // cancelled, session still charges the buyer — that `#cancelled` exists to + // eliminate. + switch (order.stripeSessionId) { + case null { + // No session ever existed, so no URL left the canister and the order is + // provably unpayable. This is the residue case: a trap or upgrade landed + // between the order commit and the outcall response, so the in-call + // failure handler never ran. Cancel with no outcall. + ops.audit("order.cancelledSessionless", id # " had no session; cancelled without an outcall"); + }; + case (?sessionId) { + // ⚠️ Recorded BEFORE the await, and deliberately NOT cleared in a `finally`: + // this is the buyer's intent, not a lock. If this call traps, the intent has to + // survive, because that is the window in which some other writer settles the + // order. It is pruned once the order is terminal. + cancelRequests.add(id); + switch (await* ops.expireStripeSession(sessionId)) { + case (#ok) {}; + case (#notOpen(_)) { + // THREE causes, and this arm cannot tell them apart: the session completed + // (the payment won the race), it had already expired, or Stripe refused the + // request itself. The first two settle without us, so the right move is to + // change nothing and let the incoming `checkout.session.completed` or + // `checkout.session.expired` resolve it. + // + // ⚠️ **The third cause is why this no longer claims a diagnosis.** A + // malformed request also answers 400, and "already settled or has expired" + // is then false: the order is still `#created` and payable, so the buyer + // refreshes onto an order that is still there and clicks again. The wording + // below is true of all three, and it names what happens next in each case + // instead of asserting which one it was. + // + // ⚠️ **No audit line, and the earlier reason for that was WRONG.** It said + // the information exists in the resolving event — true of the first two + // causes, and there is no resolving event for the third. The rule that does + // apply is `AuditLog.mo`'s, which is explicit about this shape: a buyer can + // retry, so "a caller decides" how often it fires, and that is a counter + // with a monitoring row rather than a line. `Gate.RefusalCounts` cannot gain + // one without an upgrade-incompatible change to the stable shape, so the + // counter waits for a change entitled to make one. + // + // Until then the operator's lever is `expire_order`, which takes this same + // path, is admin-authenticated — so the same rule admits its line — and + // audits Stripe's body verbatim as `order.expireRaced`. A malformed request + // is not per-order: it fails every cancel, so running it once against a live + // `#created` order surfaces the cause. RUNBOOK's monitoring section carries the row. + return #err(#sessionNotClosed); + }; + case (#failed(detail)) { + // The order stays payable and uncancelled, which is the safe side: + // the buyer can retry, or it expires on its own. + // + // ⚠️ **And because the buyer CAN retry, this line was caller-bounded** — + // the comment above invites exactly the loop that made it fail + // `AuditLog.mo`'s admission rule. It is the same Stripe-API-failing + // condition `create_order` latches, with the same cause and the same lever, + // so it routes through the same latch: one line when the API starts + // refusing us, a counter for the volume. + ops.noteStripeApiFailed("expire: " # detail); + // ⚠️ **NOT "could not reach Stripe".** This arm is now only genuine unknowns + // (a 5xx, or the outcall itself failing), and a 5xx means Stripe was reached + // and something went wrong at its end. The old wording claimed a diagnosis + // this arm does not have, and it was the wording a buyer saw for an + // already-paid order, which took a different branch entirely. + return #err(#stripeUnavailable); + }; + case (#unauthorized) { + // ⚠️ The one expire answer that means "rotate the key", and the only one + // that should latch. A 400 latched this before, filing a P1 that said the + // key was refused when the key was fine. + ops.noteStripeApiFailed( + "expire REFUSED (401/403): the restricted key needs WRITE on Checkout Sessions — rotate it" + ); + return #err(#credentialsRefused); + }; + }; + }; + }; + let ?cancelled = ops.tryTransition(id, #cancelled) else { + // ⚠️ **Reaching here now means the race was WON by someone else and settled + // correctly**, which is the normal path rather than a failure: the + // `checkout.session.expired` webhook Stripe fires from our own expire call + // routinely lands first, reads `cancelRequests`, and records `#cancelled`. So + // report the buyer's own order back to them rather than an error. + let ?fresh = Orders.get(orderStore, id) else return #err(#notFound); + switch (fresh.status) { + case (#cancelled) { + cancelRequests.remove(id); + ops.audit("order.cancelled", id # " cancelled by owner (settled by the expiry event)"); + return #ok(fresh); + }; + case (_) { + // Genuinely something else: paid in the window, or an admin ended it. The + // page re-renders from this response, so it shows what actually happened. + cancelRequests.remove(id); + return #err(#settledInFlight({ status = fresh.status })); + }; + }; + }; + cancelRequests.remove(id); + ops.audit("order.cancelled", id # " cancelled by owner"); + #ok(cancelled); + }; + + /// Everything the **buyer** needs to verify their own purchase (§2 authz: + /// `caller == order.owner`). + /// + /// The buyer can **check** the claim rather than take it: recompute the quote from the + /// two recorded rate inputs, and look up the block index on the ledger. + /// ⚠️ **Owner-only, and a `query`, which is why the admin path is a separate method.** + /// See `admin_receipt`. Auditing writes state, so an audited read cannot be a query — + /// and folding the admin case in here would have made **every buyer's** receipt read + /// an update, putting the common path through consensus to serve the rare one. + public shared query ({ caller }) func receipt(id : Types.OrderId) : async ?Receipts.Receipt { + let ?order = Orders.getOwned(orderStore, id, caller) else return null; + let journal = deliveryJournal.get(id); + ?Receipts.of(order, journal); + }; +}; diff --git a/src/backend/mixins/Principals.mo b/src/backend/mixins/Principals.mo new file mode 100644 index 0000000..ae985ae --- /dev/null +++ b/src/backend/mixins/Principals.mo @@ -0,0 +1,135 @@ +// ⚠️ `Iter` carries no `Iter.` call here and is required anyway: it is what makes +// `.toArray()` resolve on the iterator `Set.values()` returns. +import Iter "mo:core/Iter"; +import Principal "mo:core/Principal"; +import Result "mo:core/Result"; +import Set "mo:core/Set"; + +/// The two principal lists: who may act as an admin, and who may buy while this +/// gateway accepts free test payments. +/// +/// ⚠️ **Both arrive as the `Set` itself, not as accessors.** A `Set` is a heap object, so +/// passing it shares it — `add`/`remove` write through to the actor's own field. Only a +/// `var` holding an immutable value needs the accessor treatment; see `mixins/Secrets.mo` +/// for the one that does, and `Main.mo`'s include block for the rule. +/// +/// ⚠️ **`reserveState` and `stripeState` are here for ONE audit line**, in +/// `remove_allowed_buyer`: emptying the list against a funded reserve while test payments +/// are accepted is `Gate.Reason.unboundedGiveaway`, and the line says so rather than +/// leaving an operator to discover the gateway has closed. They are passed read-only in +/// spirit — this mixin writes neither. +mixin ( + adminPrincipals : Set.Set, + allowedBuyers : Set.Set, + reserveState : { var floor : Nat }, + stripeState : { var expectLivemode : ?Bool }, + requireController : (Principal) -> (), + auditAdmin : (Principal, Text, Text) -> (), +) { + + /// Why a principal could not be added to, or removed from, one of the two lists + /// (§4.3). + /// + /// ⚠️ **One type for both lists and both directions**, because the four methods refuse + /// for exactly these three reasons and a caller acts on the reason, not on which list + /// it was. `#alreadyPresent` and `#notPresent` carry the principal so a console can + /// name it without re-deriving it from the argument it just sent. + /// + /// ⚠️ **`#anonymousNotAllowed` is not "invalid input".** The anonymous principal is a + /// real, callable identity that every unauthenticated caller shares, so granting it + /// admin would grant the world admin, and allow-listing it would let anyone buy while + /// test payments are on. It is refused for a specific reason, and the tag says which. + type ListError = { + #anonymousNotAllowed; + #alreadyPresent : { principal : Principal }; + #notPresent : { principal : Principal }; + }; + + /// Grant the CASES tier to a principal (controller only, audited). + /// + /// ⚠️ **The grant is on a PRINCIPAL, and an admin's principal comes from the origin + /// they signed in at.** The flow is: the admin reads their own principal from + /// `admin_status`, gives it to a controller, and then acts from a CLI identity linked to + /// the same Internet Identity — `icp identity link web --app `. ⚠️ Without + /// `--app` the CLI links a principal derived from the auth domain's own default + /// (`cli.id.ai`), which is not this app, so the grant would sit on a principal the + /// admin never sees. + public shared ({ caller }) func add_admin(p : Principal) : async Result.Result<(), ListError> { + requireController(caller); + // Belt and braces: `Auth.checkAdmin` rejects anonymous before consulting either + // predicate, so a granted `2vxsx-fae` would be inert — but a list that contains it + // reads as though it were not. + if (p.isAnonymous()) return #err(#anonymousNotAllowed); + if (adminPrincipals.contains(p)) return #err(#alreadyPresent({ principal = p })); + adminPrincipals.add(p); + auditAdmin(caller, "admin.granted", p.toText()); + #ok; + }; + + /// Revoke the CASES tier (controller only, audited). + public shared ({ caller }) func remove_admin(p : Principal) : async Result.Result<(), ListError> { + requireController(caller); + // ⚠️ Not "not an admin": that is the authz trap's wording, and an operator reading it + // back cannot tell whether THEY were refused or the target simply was not listed. + if (not adminPrincipals.contains(p)) return #err(#notPresent({ principal = p })); + adminPrincipals.remove(p); + auditAdmin(caller, "admin.revoked", p.toText()); + #ok; + }; + + /// Who holds the CASES tier (controller only). + /// + /// ⚠️ Controllers are NOT listed — they pass `checkAdmin` without being granted, so an + /// empty list does not mean nobody can act. + public shared query ({ caller }) func admins() : async [Principal] { + requireController(caller); + adminPrincipals.values().toArray(); + }; + + /// Allow a principal to buy while this gateway accepts free test payments + /// (controller only, audited). + public shared ({ caller }) func add_allowed_buyer(p : Principal) : async Result.Result<(), ListError> { + requireController(caller); + // The anonymous principal is a shared identity: `create_order` rejects it + // before the gate, so a listed `2vxsx-fae` would be inert — but a list that + // contains it reads as though it were not. + if (p.isAnonymous()) return #err(#anonymousNotAllowed); + if (allowedBuyers.contains(p)) return #err(#alreadyPresent({ principal = p })); + allowedBuyers.add(p); + auditAdmin(caller, "buyer.allowed", p.toText()); + #ok; + }; + + /// Revoke a buyer's allowance (controller only, audited). + /// + /// ⚠️ **Removing the LAST entry does not open the gateway up — it closes it.** + /// An empty list plus a funded reserve plus test payments is + /// `Gate.Reason.unboundedGiveaway`, which refuses everyone. The audit line says + /// so, because "revoked the last buyer" and "the gateway stopped selling" are + /// the same event and an operator should not have to connect them later. + public shared ({ caller }) func remove_allowed_buyer(p : Principal) : async Result.Result<(), ListError> { + requireController(caller); + if (not allowedBuyers.contains(p)) return #err(#notPresent({ principal = p })); + allowedBuyers.remove(p); + let emptied = allowedBuyers.size() == 0; + auditAdmin( + caller, + "buyer.disallowed", + p.toText() + # ( + if (emptied and stripeState.expectLivemode != ?true and reserveState.floor > 0) { + " — ⚠️ the list is now EMPTY against a funded reserve, so the gateway refuses every buyer (unboundedGiveaway)"; + } else if (emptied) { " — the list is now empty" } else { "" } + ), + ); + #ok; + }; + + /// Who may buy while test payments are accepted (controller only). + /// + /// ⚠️ An empty list does not mean "everyone" — see `allowedBuyers`. + public shared query ({ caller }) func allowed_buyers() : async [Principal] { + requireController(caller); + allowedBuyers.values().toArray(); + }; +}; diff --git a/src/backend/mixins/Secrets.mo b/src/backend/mixins/Secrets.mo new file mode 100644 index 0000000..f2d0e39 --- /dev/null +++ b/src/backend/mixins/Secrets.mo @@ -0,0 +1,150 @@ +// ⚠️ `Nat` and `Text` carry no `Nat.`/`Text.` call in this file and are still required: +// they are what makes the receiver methods resolve — `.toText()` on a generation, +// `.startsWith`/`.contains`/`.trimEnd` on the origin. Nothing reports an unused import +// here either way, so both directions are a judgement the compiler makes, not a lint. +import Session "../rails/Session"; +import Nat "mo:core/Nat"; +import Result "mo:core/Result"; +import Text "mo:core/Text"; +import Sealed "../Sealed"; +import Secret "../Secret"; + +/// The two Stripe secrets and the buyer-return origin: provision, rotate, and read back +/// only what is safe to read back (§7). +/// +/// ⚠️ **State reaches a mixin two different ways and picking the wrong one is silent.** +/// `include` evaluates its arguments ONCE, so: +/// +/// * `Secret.Store` is a record of `var` fields — a heap object — so passing it shares +/// it by reference and `Secret.set` writes through to the actor's own field. +/// * `stripeOrigin` is a `var` holding an immutable `?Text`. Passing the field itself +/// would hand this mixin a snapshot taken at include time: `stripe_origin()` would +/// answer `null` forever and `set_stripe_origin` would mutate a copy nobody reads. +/// So it arrives as an accessor pair instead. +/// +/// ⚠️ **Accessors rather than a `{ var current : ?Text }` wrapper**, which is the other +/// way to make a `var` shareable. Wrapping changes the actor's STABLE SHAPE — the field +/// stops being `stable var stripeOrigin : ?Text` — and with no migration chain +/// that costs a reinstall. Closures are mixin parameters, never stable state, so +/// `deployed/backend.most` does not move for this split. +mixin ( + webhookSecret : Secret.Store, + stripeApiKey : Secret.Store, + /// ⚠️ Named `originAccess`, not `origin`, so `set_stripe_origin` can keep its own + /// parameter called `origin`: **Candid records argument names**, and renaming one + /// moves the published signature. `scripts/check-did-signatures.sh` caught exactly + /// that when this parameter was called `origin` and the endpoint's became `candidate`. + originAccess : { get : () -> ?Text; set : (?Text) -> () }, + requireController : (Principal) -> (), + requireAdmin : (Principal) -> (), + auditAdmin : (Principal, Text, Text) -> (), + nowNs : () -> Int, + /// Decrypts a sealed argument. A closure rather than the vetKey itself: the key is + /// derived lazily through the management canister and cached in the actor, so handing + /// this mixin a value would hand it a snapshot of an empty cache. + openSealed : (Blob) -> async* Result.Result, +) { + + /// **Declared inside the mixin body, not above it and not in `Types.mo`.** Only + /// imports may precede a `mixin` block (M0228, "mixins may only be declared at the + /// top-level"), and Candid type names come from the Motoko declaration — moving this + /// to `Types.mo` would risk renaming it in the interface, which is the one thing this + /// relocation must not do. + /// Provision or rotate the Stripe webhook signing secret (§7). + /// + /// Takes the full `whsec_...` string — the whole string, prefix included, is the HMAC + /// key — **sealed to this canister's vetKD public key**. `scripts/seal-secret.sh` + /// produces the argument; the plaintext never travels. + /// + /// **This closed the §7 provisioning exposure, and the note that used to sit here + /// saying otherwise is gone rather than softened.** The ingress argument is now + /// ciphertext, so the boundary node that terminates TLS sees nothing usable. What + /// remains is the at-rest exposure, which is the confidential subnet's job and not + /// something rotation can help with. + public shared ({ caller }) func set_webhook_secret(ciphertext : Blob) : async Result.Result<(), Sealed.ProvisionError> { + requireController(caller); + let plaintext = switch (await* openSealed(ciphertext)) { + case (#ok(bytes)) bytes; + case (#err(e)) { + auditAdmin(caller, "secret.setRejected", "the sealed argument could not be opened; the working secret is untouched"); + return #err(e); + }; + }; + let result = Secret.set(webhookSecret, plaintext, nowNs()); + switch (result) { + case (#ok) { + // The secret itself is never logged — only that it changed, by whom, + // and to which generation, which is what a rotation audit needs. + auditAdmin(caller, "secret.set", "generation " # Secret.status(webhookSecret).generation.toText()); + }; + case (#err(_)) auditAdmin(caller, "secret.setRejected", "rejected as too short; the working secret is untouched"); + }; + result; + }; + + /// Provisioning state only — the secret itself is never readable back + /// out, even by controllers. `generation` confirms a rotation landed. + public shared query ({ caller }) func webhook_secret_status() : async Secret.Status { + requireAdmin(caller); + Secret.status(webhookSecret); + }; + + /// Provision or rotate the restricted Stripe API key, sealed exactly as + /// `set_webhook_secret` is — same vetKey, one derivation for both. + /// + /// The key to seal is a **restricted key** (`rk_...`) with *Checkout Sessions = Write* + /// and everything else None; `Secret.mo` records why that scope, not this storage, is + /// what bounds a leak. + public shared ({ caller }) func set_stripe_api_key(ciphertext : Blob) : async Result.Result<(), Sealed.ProvisionError> { + requireController(caller); + let plaintext = switch (await* openSealed(ciphertext)) { + case (#ok(bytes)) bytes; + case (#err(e)) { + auditAdmin(caller, "stripe.apiKeyRejected", "the sealed argument could not be opened; the working key is untouched"); + return #err(e); + }; + }; + let result = Secret.set(stripeApiKey, plaintext, nowNs()); + switch (result) { + case (#ok) auditAdmin(caller, "stripe.apiKeySet", "generation " # Secret.status(stripeApiKey).generation.toText()); + case (#err(_)) auditAdmin(caller, "stripe.apiKeyRejected", "rejected as too short; the working key is untouched"); + }; + result; + }; + + /// Whether the restricted Stripe key is provisioned — **never the key**. + /// + /// The console offers this read and no command for the setter: a rendered + /// `set_stripe_api_key` would put the key in a page's DOM and clipboard, which is what + /// `scripts/check-admin-commands.py` fails on. Admin-gated like every other read of + /// operational state that names a secret's presence. + public shared query ({ caller }) func stripe_api_key_status() : async Secret.Status { + requireAdmin(caller); + Secret.status(stripeApiKey); + }; + + /// Set the origin Stripe returns buyers to — admin. + /// + /// Validated at set time rather than at session-create time, so a bad value + /// fails in front of the operator who typed it instead of breaking every + /// purchase later. Until a domain is chosen this is the canister's + /// own asset origin. + public shared ({ caller }) func set_stripe_origin(origin : Text) : async Result.Result<(), Session.OriginError> { + requireController(caller); + // Validation and normalisation are `Session.validateOrigin`'s, so the parsing has + // unit tests — `http://` is accepted for loopback hosts only. + let trimmed = switch (Session.validateOrigin(origin)) { + case (#ok(value)) value; + case (#err(e)) return #err(e); + }; + originAccess.set(?trimmed); + auditAdmin(caller, "stripe.originSet", trimmed); + #ok; + }; + + /// The origin, readable back because it is not a secret — it is the URL + /// buyers are sent to, and an operator needs to confirm it. + public shared query func stripe_origin() : async ?Text { + originAccess.get(); + }; +}; diff --git a/src/backend/mixins/Webhook.mo b/src/backend/mixins/Webhook.mo new file mode 100644 index 0000000..c3e8eb4 --- /dev/null +++ b/src/backend/mixins/Webhook.mo @@ -0,0 +1,76 @@ +import IC "mo:ic/Types"; +import Http "../Http"; +import Session "../rails/Session"; +import Types "../Types"; + +/// The HTTP surface: the two halves of `http_request` and the outcall transform (§6.0). +/// +/// ⚠️ **These three are the only endpoints here that are not authenticated by a caller**, +/// and two of them cannot be: Stripe cannot sign in, and the transform is invoked by the +/// replica. Authentication is on the PAYLOAD instead — the webhook route verifies an +/// HMAC over the body — so the dispatcher re-applies every guard on the update half, +/// because anyone can call it directly through Candid. +/// +/// **`routes` and `maxRequestBodyBytes` pass directly and that is safe**, unlike a +/// mutable `var`: both are transient `let`s built during initialisation, which is also +/// when `include` evaluates its arguments, and neither ever changes afterwards. The +/// rule in `docs/DESIGN.md` §9.1 is about MUTABLE state; an immutable snapshot of an +/// immutable value is the value. +/// +/// ⚠️ **`paidOrder` is a take-once handoff, not a getter.** The dispatcher sets it inside +/// the update half and this mixin consumes it in the same message; reading and clearing +/// as one operation is what stops a second delivery kick from a stale value, and it is +/// why the accessor is `take` rather than a get/set pair. +mixin ( + routes : [Http.Route], + maxRequestBodyBytes : Nat, + paidOrder : { take : () -> ?Types.OrderId }, + ops : { + processDelivery : (Types.OrderId) -> async* (); + }, +) { + + /// The outcall transform. Referenced by name in the request, so it has to + /// be a public `shared query` on the actor even though nothing should ever call + /// it directly. + /// + /// Its whole job is `Session.strip`: **remove every response header.** Stripe + /// returns a unique `request-id` per HTTP request, and each replica issues its + /// own request — so passing headers through fails consensus on *every* call, not + /// occasionally. Replication-count independent: any `n > 1` breaks. + public shared query func transform_stripe_response(args : { context : Blob; response : IC.HttpRequestResult }) : async IC.HttpRequestResult { + Session.strip(args.response); + }; + + /// §6.0 query half: the boundary node calls this first; a matched + /// upgrade route answers `upgrade = ?true` and the gateway re-issues the + /// request to `http_request_update` through consensus. + public query func http_request(req : Http.Request) : async Http.Response { + Http.handleQuery(routes, req, maxRequestBodyBytes); + }; + + /// §6.0 update half. Anyone can call this directly via Candid, so the + /// dispatcher re-applies every guard; the route handlers themselves are + /// payload-authenticated (HMAC), never caller-authenticated. + public func http_request_update(req : Http.Request) : async Http.Response { + let response = Http.handleUpdate(routes, req, maxRequestBodyBytes); + // Kick money-out (§5) as a detached self-message ONLY when this delivery + // actually marked an order #paid, and drive just that order rather than + // sweeping every one. `webhookPaidOrder` is set inside the dispatch above + // and consumed here. + // + // This route is unauthenticated by necessity (Stripe cannot sign in), so + // anything it triggers is free for anyone on the internet to invoke. A + // sweep over all orders — which makes paid inter-canister calls per + // sweepable order — must therefore never be reachable from a 404, a bad + // signature, or an unprovisioned-secret 503. The §5.2 recovery timer + // remains the backstop if this detached message dies. + switch (paidOrder.take()) { + case (?orderId) { + ignore async { await* ops.processDelivery(orderId) }; + }; + case null {}; + }; + response; + }; +}; diff --git a/src/backend/rails/Card.mo b/src/backend/rails/Card.mo index 111b681..92738a0 100644 --- a/src/backend/rails/Card.mo +++ b/src/backend/rails/Card.mo @@ -8,25 +8,27 @@ /// captured-and-replayed delivery stays valid; inside the window, replay is /// the dedup sets' job (Idempotency.mo — `event.id` / `payment_intent`). /// -/// Ingestion invariants (§4.1): dedup gates the mint; every verified dollar -/// resolves to a `#paid` order or a Type 1 error-queue entry (Type 2 only -/// exists after minting, §5); `client_reference_id` is claimed, not trusted; -/// the *actual* paid amount is honored, repriced from the order's creation -/// pricing snapshot when it differs from the quoted tier. The whole path is +/// Ingestion invariants (§4.1): dedup gates delivery; every verified dollar +/// resolves to a `#paid` order or an error-queue obligation (§5); +/// `client_reference_id` is claimed, not trusted; +/// the paid amount must EQUAL the quoted one — the session carried our figure, +/// so a difference is a misconfiguration, not a choice. The whole path is /// synchronous — no awaits, so no interleaving between check and write. /// State lives in `Deps` (Main.mo's stores, injected) so the path unit-tests /// without an IC environment. import Int "mo:core/Int"; +import Problems "../Problems"; import Iter "mo:core/Iter"; import List "mo:core/List"; +import Map "mo:core/Map"; +import Set "mo:core/Set"; import Nat "mo:core/Nat"; import Principal "mo:core/Principal"; import Result "mo:core/Result"; import Runtime "mo:core/Runtime"; import Text "mo:core/Text"; import AuditLog "../AuditLog"; -import ErrorQueue "../ErrorQueue"; -import Forex "../Forex"; +import Orphans "../Orphans"; import Hmac "../Hmac"; import Http "../Http"; import Idempotency "../Idempotency"; @@ -59,16 +61,34 @@ module { #signatureMismatch; }; + /// Cap on the `Stripe-Signature` header. A real one is ~100 bytes per `v1=` + /// plus the timestamp; 4 KiB leaves room for a deep rotation overlap. + public let maxSignatureHeaderBytes : Nat = 4_096; + + /// Cap on `v1=` candidates actually verified. Stripe documents at most one per + /// active secret, and only two are active during a rotation. + public let maxV1Candidates : Nat = 8; + /// Parse a `Stripe-Signature` header: comma-separated `key=value` elements, /// e.g. `t=1492774577,v1=5257a8...,v0=6ffbb5...`. Per Stripe's reference /// parsers: unknown schemes (`v0=`...) and unparseable elements are /// ignored; only the first `t=` counts. Null when no usable `t=`/`v1=` /// remain — the verifier treats that as malformed. + /// + /// ⚠️ **Bounded on both header length and candidate count.** The route's 64 KiB + /// guard covers only the *body*, so a caller could otherwise send a ~2 MB + /// signature header stuffed with tens of thousands of `v1=` values and make an + /// unauthenticated update call do orders of magnitude more hashing than any + /// real delivery — each candidate costs a constant-time compare against a + /// freshly computed MAC. Stripe sends one `v1=` normally and a handful during a + /// rotation overlap, so both bounds are far above legitimate traffic. public func parseSignatureHeader(header : Text) : ?ParsedSignature { + if (header.size() > maxSignatureHeaderBytes) return null; var timestampSeconds : ?Nat = null; var sawTimestamp = false; let v1 = List.empty(); for (element in header.split(#char ',')) { + if (v1.size() >= maxV1Candidates) continue; let parts = element.trim(#char ' ').split(#char '=').toArray(); if (parts.size() != 2) continue; if (parts[0] == "t") { @@ -127,27 +147,74 @@ module { /// `amount_total` in the currency's smallest unit (cents for usd). amountTotalCents : Nat; currency : Text; - /// `payment_status == "paid"`. False = async payment method still - /// pending — out of scope for card-only Payment Links (§6.1). + /// `payment_status == "paid"`. False = an async payment method has not + /// settled yet; the money may still arrive, and when it does Stripe sends + /// `checkout.session.async_payment_succeeded`, which is parsed into this + /// same shape (see `parseEvent`). paid : Bool; + /// Stripe's `livemode`. A test-mode event carries `false`; accepting one on a + /// canister with a funded reserve would deliver real cycles for a payment that + /// never happened. + livemode : Bool; + }; + + /// `charge.refunded` carries a **charge**, so `amount` is the charge total and + /// `amount_refunded` is the cumulative amount refunded against it. + public type ChargeRefunded = { + eventId : Text; + paymentIntent : Text; + /// Cumulative refunded, smallest currency unit. + amountRefundedCents : Nat; + /// The charge's own total, for deciding whether the refund is complete. + chargeAmountCents : Nat; + }; + + /// A refund settles the whole charge only when the cumulative refunded amount + /// reaches the charge total. A partial refund must never be read as a full one: + /// auto-resolving an obligation on a $5 courtesy refund of a $500 + /// payment would discard the record of the remaining $495. + public func isFullRefund(refund : ChargeRefunded) : Bool { + refund.chargeAmountCents > 0 and refund.amountRefundedCents >= refund.chargeAmountCents; }; public type Event = { #checkoutCompleted : CheckoutCompleted; - #chargeRefunded : { eventId : Text; paymentIntent : Text }; + #chargeRefunded : ChargeRefunded; + /// An async payment method failed for good — the session will never pay. + #asyncPaymentFailed : { eventId : Text; paymentIntent : Text }; + /// Stripe closed the session unpaid. **The only mechanism that expires + /// an order** — there is no TTL sweep, deliberately: a sweep would flip a + /// stuck order to `#expired` while its promise stayed held, so a broken order + /// would look like a correctly expired one and the reserve would leak + /// silently. Without it, a missed event leaves the order visibly `#created` + /// past its `expiresAtNs`, which IS the detection signal. + #sessionExpired : { + eventId : Text; + /// The session's own id, so the event can be bound to the order that + /// stored it rather than trusted on the reference alone. + sessionId : Text; + /// `_`. Attribution when the order has no session id + /// recorded — the residue case whose create-response was lost. + clientReferenceId : ?Text; + }; + /// A chargeback. **Audit only**: cycles are delivered and irreversible + /// and the card network pulls the funds, so there is nothing to react to — but + /// without the subscription a dispute is invisible to the operator. + #disputeCreated : { eventId : Text; paymentIntent : Text; amountCents : ?Nat }; /// Recognized envelope, event type we don't handle — acked and dropped - /// (Stripe endpoints should be subscribed to only the two above, but an - /// extra type must not look like a delivery failure). + /// (an extra subscribed type must not look like a delivery failure). #unhandled : { eventId : Text; eventType : Text }; }; public type ParseError = { #invalidJson; /// A handled event type missing a field we require — e.g. a checkout - /// session without a `payment_intent` (subscription-mode link). 400s - /// so the failure is visible in the Stripe dashboard, not silently - /// acked into the void. - #missingField : Text; + /// session without a `payment_intent` (reachable via a subscription-mode + /// link or a 100%-off promo code). + /// + /// `eventId` when the envelope itself parsed, which is what lets the + /// handler dedup the entry it queues across Stripe's retries. + #missingField : { field : Text; eventId : ?Text }; }; /// Parse a verified webhook body. Only called *after* `verify` — the JSON @@ -157,15 +224,26 @@ module { public func parseEvent(body : Blob) : Result.Result { let ?text = body.decodeUtf8() else return #err(#invalidJson); let ?json = Json.parse(text) else return #err(#invalidJson); - let ?eventId = Json.textAt(json, "id") else return #err(#missingField("id")); - let ?eventType = Json.textAt(json, "type") else return #err(#missingField("type")); + // The envelope itself: without an id there is nothing to dedup on, so these + // two carry no eventId of their own. + let ?eventId = Json.textAt(json, "id") else return #err(#missingField({ field = "id"; eventId = null })); + let ?eventType = Json.textAt(json, "type") else { + return #err(#missingField({ field = "type"; eventId = null })); + }; func required(path : Text) : Result.Result { switch (Json.textAt(json, path)) { case (?value) #ok(value); - case (null) #err(#missingField(path)); + case (null) #err(#missingField({ field = path; eventId = ?eventId })); }; }; - if (eventType == "checkout.session.completed") { + // `async_payment_succeeded` carries the same session object as `completed` + // and is how a delayed payment method reports that money actually arrived. + // Without it, a session acked as unpaid would settle later with no trace + // anywhere: fiat in, nothing delivered, nothing on the worklist. + if ( + eventType == "checkout.session.completed" + or eventType == "checkout.session.async_payment_succeeded" + ) { let paymentIntent = switch (required("data.object.payment_intent")) { case (#ok(v)) v; case (#err(e)) return #err(e); @@ -179,11 +257,12 @@ module { case (#err(e)) return #err(e); }; let ?amountTotalCents = Json.natAt(json, "data.object.amount_total") else { - return #err(#missingField("data.object.amount_total")); + return #err(#missingField({ field = "data.object.amount_total"; eventId = ?eventId })); }; #ok(#checkoutCompleted({ eventId; paymentIntent; + livemode = Json.boolAt(json, "livemode") == ?true; // Absent and JSON-null both mean "no reference" (Stripe sends null // when the link was opened without the param). clientReferenceId = Json.textAt(json, "data.object.client_reference_id"); @@ -192,8 +271,45 @@ module { paid = paymentStatus == "paid"; })); } else if (eventType == "charge.refunded") { + let paymentIntent = switch (required("data.object.payment_intent")) { + case (#ok(v)) v; + case (#err(e)) return #err(e); + }; + let ?amountRefundedCents = Json.natAt(json, "data.object.amount_refunded") else { + return #err(#missingField({ field = "data.object.amount_refunded"; eventId = ?eventId })); + }; + let ?chargeAmountCents = Json.natAt(json, "data.object.amount") else { + return #err(#missingField({ field = "data.object.amount"; eventId = ?eventId })); + }; + #ok(#chargeRefunded({ eventId; paymentIntent; amountRefundedCents; chargeAmountCents })); + } else if (eventType == "checkout.session.async_payment_failed") { switch (required("data.object.payment_intent")) { - case (#ok(paymentIntent)) #ok(#chargeRefunded({ eventId; paymentIntent })); + case (#ok(paymentIntent)) #ok(#asyncPaymentFailed({ eventId; paymentIntent })); + case (#err(e)) #err(e); + }; + } else if (eventType == "checkout.session.expired") { + // The session id is required — it is what binds the event to an order. + // `client_reference_id` is optional here because a session can legitimately + // carry none, and the handler falls back to the id match. + switch (required("data.object.id")) { + case (#ok(sessionId)) { + #ok(#sessionExpired({ + eventId; + sessionId; + clientReferenceId = Json.textAt(json, "data.object.client_reference_id"); + })); + }; + case (#err(e)) #err(e); + }; + } else if (eventType == "charge.dispute.created") { + switch (required("data.object.payment_intent")) { + case (#ok(paymentIntent)) { + #ok(#disputeCreated({ + eventId; + paymentIntent; + amountCents = Json.natAt(json, "data.object.amount"); + })); + }; case (#err(e)) #err(e); }; } else { @@ -208,114 +324,556 @@ module { public type Deps = { orders : Orders.Store; dedup : Idempotency.Store; - errorQueue : ErrorQueue.Store; - errorQueueCapacity : Nat; + orphanStore : Orphans.Store; + /// Operator's declared Stripe mode; null = unset, which accepts either and + /// says so in the audit trail. The go-live checklist sets it. + expectLivemode : ?Bool; auditLog : AuditLog.Log; - auditLogCapacity : Nat; + /// `payment_intent` → order it paid for. Written when an order is marked + /// paid; the only way a later `charge.refunded` can tell whether the + /// refunded payment had already been delivered as cycles. A financial + /// record — never pruned, and bounded by real volume (every entry corresponds to + /// a payment that was delivered out of a funded reserve), unlike the + /// `#unattributed` spam the dedup sets absorb. + paidIntents : Map.Map; + /// Orders whose OWNER has asked to cancel, from `Main.cancelRequests`. + /// + /// ⚠️ **This module is the writer that wins the race with `cancel_order`.** Our own + /// expire call makes Stripe fire `checkout.session.expired`, and it lands here + /// before the cancel is recorded. Without this the buyer's own decision is stored + /// as `expiredBy = #sessionExpired`. + /// + /// A `Set` value rather than a callback: `Deps` is rebuilt per call, so this is the + /// live set, and passing it keeps the attribution decision inside `Orders` where + /// both writers share one copy of it. + cancelRequests : Set.Set; + /// Per-purchase ceiling (`Gate.Config.maxPurchaseUsdCents`) — defence in + /// depth now that the webhook honours only the quoted amount: an order + /// created under a higher ceiling still matches its own quote after the + /// ceiling is lowered, and this is what refuses it. + maxPurchaseUsdCents : Nat; + }; + + /// What the webhook path produced: the HTTP response Stripe sees, plus + /// whether this delivery actually created money-out work. + /// + /// `paidOrder` is non-null on exactly one path — a verified, deduped, + /// attributed payment that transitioned an order to `#paid`. Everything else + /// (guard rejections, duplicates, queued obligations, refunds, unhandled event + /// types) leaves it null. The caller uses it to decide whether to kick delivery: + /// an anonymous request that reaches no order must not be able to trigger a sweep + /// over every order, which is otherwise an operation that is free to invoke and + /// expensive to run. + public type Outcome = { + response : Http.Response; + paidOrder : ?Types.OrderId; + }; + + /// Outcome for a delivery that created no money-out work. + func ack(response : Http.Response) : Outcome { + { response; paidOrder = null }; }; func audit(deps : Deps, nowNs : Int, tag : Text, detail : Text) { - ignore AuditLog.append(deps.auditLog, deps.auditLogCapacity, nowNs, tag, detail); + ignore AuditLog.append(deps.auditLog, nowNs, tag, detail); + }; + + /// Whether a paid amount may be honoured for this order (§3/§6.1) — since + /// An **equality check, not a computation**. + /// + /// Repricing existed because a fixed Payment Link could legitimately be paid + /// for an amount the order was not created for: the buyer picked the link, + /// not the order. A per-order Checkout Session carries the amount *we* set, + /// so `amount_total` can now differ from the quote only if a Stripe feature + /// that moves the total is enabled — the list is next to `Session.createBody`, + /// and each entry is one Dashboard toggle away. That is a misconfiguration + /// for the operator to look at, not a choice the buyer made, so a mismatch + /// files a refundable obligation and delivers nothing. + /// + /// ⚠️ The collapse is what makes `lockedCycles` immutable after creation, and + /// The reserve accounting is exact rather than conservative because of it. Anything + /// that reintroduces "honour a different amount" reintroduces both problems. + public type Honored = { + /// The amount matched the quote, so the locked quantity stands verbatim. + #asQuoted : Nat; + /// Stripe reported an amount we never asked for. Refund-resolvable: the operator + /// refunds and fixes the session configuration. + #mismatch : { paidUsdCents : Nat; quotedUsdCents : Nat }; + /// Above the per-purchase ceiling, kept as defence in depth even though the + /// session pins the amount. It is reachable without any tampering: an order + /// created under a higher ceiling still matches its own quote after the + /// ceiling is lowered. + #aboveCeiling : { paidUsdCents : Nat; maxUsdCents : Nat }; }; - /// Queue a Type 1 entry (§4.1: fiat exists, nothing minted — operator + public func honoredCycles( + order : Types.Order, + paidUsdCents : Nat, + maxPurchaseUsdCents : Nat, + ) : Honored { + if (paidUsdCents > maxPurchaseUsdCents) { + return #aboveCeiling({ paidUsdCents; maxUsdCents = maxPurchaseUsdCents }); + }; + if (paidUsdCents != order.pricing.usdCents) { + return #mismatch({ paidUsdCents; quotedUsdCents = order.pricing.usdCents }); + }; + #asQuoted(order.lockedCycles); + }; + + /// Queue a refund-resolvable entry (§4.1: fiat exists, nothing delivered — operator /// refunds in the Stripe Dashboard). Always 200: the payment is handled, /// just not by delivery; a non-2xx would make Stripe redeliver an event /// we have already routed. - func queueType1(deps : Deps, kind : ErrorQueue.Kind, detail : Text, nowNs : Int) : Http.Response { - let result = ErrorQueue.add(deps.errorQueue, deps.errorQueueCapacity, #card, kind, detail, nowNs); - for (victim in result.evicted.values()) { - if (victim.resolvedAtNs == null) { - // §4.1: an unresolved eviction is a live money obligation dropped - // from on-chain state — the one thing the audit trail must show. - audit(deps, nowNs, "errorQueue.evictedUnresolved", "entry " # victim.id.toText() # ": " # victim.detail); - }; - }; + func queueRefundable(deps : Deps, kind : Orphans.Kind, detail : Text, nowNs : Int) : Http.Response { + // ⚠️ **Nothing can be evicted, so there is no eviction to report.** §4.1's one + // unforgivable event is "a live money obligation dropped from on-chain state", and + // with no bound on the store there is nothing to drop. Do not add a tag for it back: + // a tag that cannot fire is a + // reader's false reassurance that someone is watching for it. + let result = Orphans.add(deps.orphanStore, #card, kind, detail, nowNs); audit(deps, nowNs, "stripe.type1", "entry " # result.entry.id.toText() # ": " # detail); Http.text(200, "queued for operator review"); }; - /// `charge.refunded` (§4.1): auto-resolve every unresolved Type 1 entry - /// carrying this payment_intent. No matches is fine — operators may - /// refund payments that never queued. - func handleRefund(deps : Deps, refund : { eventId : Text; paymentIntent : Text }, nowNs : Int) : Http.Response { + /// File a refund-resolvable problem **on the order** and answer 200. + /// + /// ⚠️ **The order-bound sibling of `queueRefundable`, and the split is the point.** + /// That function now serves only `#unattributed`, which by definition has no order + /// to attach to. Everything with an `orderId` lives on the order, so there is no + /// eviction to audit here either: orders are never evicted. + /// + /// Always 200, for the same reason: the payment is handled, just not by delivery, and + /// a non-2xx would make Stripe redeliver an event we have already routed. + func fileOrderProblem( + deps : Deps, + orderId : Types.OrderId, + paymentRef : Text, + kind : Types.ProblemKind, + detail : Text, + nowNs : Int, + ) : Http.Response { + switch (deps.orders.orders.get(orderId)) { + case (?_) { + let filed = Orders.fileProblem(deps.orders, orderId, kind, detail, nowNs); + if (filed) { + audit(deps, nowNs, "stripe.type1", orderId # " [" # Problems.kindToText(kind) # "]: " # detail); + }; + Http.text(200, "queued for operator review"); + }; + // ⚠️ **No such order, so the problem has no home — and it must NOT be dropped.** + // §4.1's invariant is that every verified dollar resolves to a delivery or to an + // obligation. Moving problems onto orders introduced a way to lose one: the + // queue's `add` filed regardless of whether the order existed, and + // `Orders.fileProblem` cannot. + // + // Money that cannot be attached to an order is money **held for nobody**, which + // is exactly what `#unattributed` means — so it goes to the orphan list, where + // it is refund-resolvable and names the payment. Reachable via a corrupted + // intent-to-order link; a test pins it, which is how it was found. + case null { + audit( + deps, + nowNs, + "stripe.problemOrphaned", + "order " # orderId # " is not in the store, so a " # Problems.kindToText(kind) + # " problem for intent " # paymentRef # " was filed as unattributed instead", + ); + queueRefundable( + deps, + #unattributed({ claimedRef = orderId; paymentRef }), + detail # " ⚠️ Filed here rather than on the order because order " # orderId + # " is not in the store — the reference and our records disagree.", + nowNs, + ); + }; + }; + }; + + /// `charge.refunded` (§4.1): auto-resolve every unresolved refund-resolvable + /// obligation carrying this payment_intent — **on both sides now**. No matches is + /// fine: operators may refund payments that never filed anything. + func handleRefund(deps : Deps, refund : ChargeRefunded, nowNs : Int) : Outcome { if (not Idempotency.recordStripeEvent(deps.dedup, refund.eventId, nowNs)) { - return Http.text(200, "duplicate event"); + return ack(Http.text(200, "duplicate event")); + }; + let full = isFullRefund(refund); + let amounts = + refund.amountRefundedCents.toText() # " of " # refund.chargeAmountCents.toText() # " cents"; + + // Only a full refund settles the obligation. A partial one leaves the + // remainder owed, so auto-resolving on it would erase the record of what is + // still outstanding — the entry stays open and the operator decides. + if (not full) { + let open = Orphans.unresolvedByPaymentRef(deps.orphanStore, refund.paymentIntent); + for (entry in open.values()) { + audit( + deps, + nowNs, + "stripe.refundPartial", + "entry " # entry.id.toText() # " left OPEN: partial refund of " # refund.paymentIntent + # " (" # amounts # ") does not settle it", + ); + }; + if (open.size() > 0) { + // Symmetric with the full-refund path: having reported the obligations + // this refund does NOT settle, stop. Continuing would also audit + // "matched no obligation", contradicting the line just written. + return ack(Http.text(200, "partial refund recorded; obligation left open")); + }; + audit(deps, nowNs, "stripe.refundPartial", "partial refund of " # refund.paymentIntent # " (" # amounts # ") matched no open entry"); + // Fall through only when nothing was open: a partial refund on a + // *delivered* order is still a realised loss and must be recorded as one, + // sized to what went back. + }; + + if (full) { + // ⚠️ **Two stores to close against**, and forgetting either is a + // silent failure: an obligation left open after the refund that settles it is + // exactly the false worklist entry the queue's own rule forbids. + let resolved = Orphans.resolveByPaymentRef(deps.orphanStore, refund.paymentIntent, nowNs); + for (entry in resolved.values()) { + audit(deps, nowNs, "stripe.refundResolved", "entry " # entry.id.toText() # " auto-resolved by full refund of " # refund.paymentIntent # " (" # amounts # ")"); + }; + let closedOnOrders = Orders.resolveByPaymentRef(deps.orders, refund.paymentIntent, nowNs); + if (closedOnOrders > 0) { + audit(deps, nowNs, "stripe.refundResolved", closedOnOrders.toText() # " order problem(s) auto-resolved by full refund of " # refund.paymentIntent # " (" # amounts # ")"); + }; + if (resolved.size() > 0 or closedOnOrders > 0) return ack(Http.text(200, "ok")); + }; + + // Nothing resolved. Usually benign — operators may refund a payment that + // never queued — but it is also how a refund of an already-delivered order + // arrives, so the two are separated below and each leaves a trace. + switch (deps.paidIntents.get(refund.paymentIntent)) { + case null { + // Genuinely unknown payment — never attributed to an order here. + audit(deps, nowNs, "stripe.refundUnmatched", "refund of " # refund.paymentIntent # " (" # amounts # ") matched no obligation and no paid order"); + }; + case (?orderId) { + switch (Orders.get(deps.orders, orderId)) { + case null { + audit(deps, nowNs, "stripe.refundUnmatched", "refund of " # refund.paymentIntent # " maps to order " # orderId # ", which is no longer in the store"); + }; + case (?order) { + switch (order.status) { + case (#delivered) { + // The loss case: fiat returned, cycles irreversibly gone. + // ⚠️ **A second partial refund REFRESHES this rather than filing + // again.** `refundedCents` is cumulative, so the figure an operator + // reconciles by is the latest one — `Problems.file` matches on + // `paymentRef` and updates the payload, which is why suppressing + // instead would have frozen the total at the first partial. + ignore Orders.fileProblem( + deps.orders, + orderId, + #refundAfterDelivery({ + paymentRef = refund.paymentIntent; + cycles = order.lockedCycles; + refundedCents = refund.amountRefundedCents; + fullRefund = full; + }), + (if (full) "FULL" else "PARTIAL") # " refund/chargeback on a DELIVERED order: " # amounts + # " returned against " # order.lockedCycles.toText() + # " cycles already forwarded, which cannot be recovered — reconcile against Stripe and decide whether to restrict the payer", + nowNs, + ); + audit(deps, nowNs, "stripe.refundAfterDelivery", orderId # ": " # refund.paymentIntent # " refunded (" # amounts # ") after " # order.lockedCycles.toText() # " cycles were delivered"); + }; + case (#needsReview or #abandoned) { + // On the worklist, or already ended by the operator: a refund + // here is the *expected* resolution, not a race to investigate. + // Saying "the delivery may still be mid-flight" would send an + // operator looking for one that cannot exist. + audit(deps, nowNs, "stripe.refundOfEscalated", orderId # ": " # refund.paymentIntent # " refunded (" # amounts # ") — the expected resolution for an escalated order; close its problem with resolve_problem once reconciled"); + }; + case (status) { + // Paid but not yet delivered — the money-out pipeline may + // still be mid-flight, so this is a race the operator must + // look at rather than a settled loss. + audit(deps, nowNs, "stripe.refundBeforeDelivery", orderId # ": " # refund.paymentIntent # " refunded (" # amounts # ") while order was " # Types.statusToText(status)); + }; + }; + }; + }; + }; + }; + ack(Http.text(200, "ok")); + }; + + /// An intent that has already funded an order arrived again. Two shapes: + /// + /// - **names the same order** → a redelivery past the dedup retention. Ack it. + /// - **names anything else** → the reference and our record disagree. + /// nothing writes an attribution but the webhook itself and nothing but the + /// canister sets `client_reference_id`, so this should be unreachable — which + /// is exactly why it stays: an unreachable contradiction that fires means a + /// bug in attribution or something odd on Stripe's side, and it used to be + /// entirely silent. Never deliver (the money is spent), but surface it. + /// + /// Reads the order id straight from `clientReferenceId` rather than taking a + /// resolved order, so it stays reachable when the reference is unusable. That is + /// precisely the case that previously filed a false `#unattributed` obligation. + func alreadyCredited( + deps : Deps, + session : CheckoutCompleted, + credited : Types.OrderId, + nowNs : Int, + ) : Http.Response { + let named = switch (session.clientReferenceId) { + case (?ref) switch (Orders.parseClientReferenceId(ref)) { + case (?(_, orderId)) ?orderId; + case null null; + }; + case null null; }; - let resolved = ErrorQueue.resolveByPaymentRef(deps.errorQueue, refund.paymentIntent, nowNs); - for (entry in resolved.values()) { - audit(deps, nowNs, "stripe.refundResolved", "entry " # entry.id.toText() # " auto-resolved by refund of " # refund.paymentIntent); + if (named == ?credited) { + audit( + deps, + nowNs, + "stripe.replayedAfterPruning", + "intent " # session.paymentIntent # " was already credited to order " # credited + # " and its dedup keys have been pruned — treated as a redelivery, not a second payment", + ); + return Http.text(200, "already credited"); }; - Http.text(200, "ok"); + let namedText = switch (named) { case (?o) o; case null "(no usable reference)" }; + audit( + deps, + nowNs, + "stripe.creditedElsewhere", + "intent " # session.paymentIntent # " names " # namedText + # " but was already credited to order " # credited # " — nothing delivered a second time", + ); + fileOrderProblem( + deps, + credited, + session.paymentIntent, + #duplicate({ paymentRef = session.paymentIntent }), + "intent " # session.paymentIntent # " was credited to order " # credited + # " but its Stripe session names " # namedText + # " — the reference and our record disagree, which should not be reachable. Nothing was delivered twice. Decide which order the buyer paid for; there is no way to credit the other one, so settle it by refunding in Stripe.", + nowNs, + ); }; /// `checkout.session.completed` (§6.1): dedup → attribute (claimed, not - /// trusted) → honor the actual paid amount → `#paid`, or Type 1. - func handleCheckout(deps : Deps, session : CheckoutCompleted, nowNs : Int) : Http.Response { + /// trusted) → honor the actual paid amount → `#paid`, or a refundable obligation. + func handleCheckout(deps : Deps, session : CheckoutCompleted, nowNs : Int) : Outcome { // event.id first: catches Stripe redelivering this exact event. if (not Idempotency.recordStripeEvent(deps.dedup, session.eventId, nowNs)) { - return Http.text(200, "duplicate event"); + return ack(Http.text(200, "duplicate event")); }; if (not session.paid) { - // Async payment method on a link that should be card-only — money may - // arrive later via an event type we don't handle, so make the config - // problem visible instead of silently acking it. - audit(deps, nowNs, "stripe.unpaidSession", "payment_status not paid for intent " # session.paymentIntent); - return Http.text(200, "ignored: payment not completed"); + // An async payment method has not settled. Money may still arrive, and it + // is caught then: `checkout.session.async_payment_succeeded` parses into + // this same shape and runs this same handler. + // + // Checked BEFORE the livemode gate: an unsettled session is not money in + // any Stripe mode, so raising a livemode obligation here would claim a + // payment that has not happened and may never happen. + audit(deps, nowNs, "stripe.unpaidSession", "payment_status not paid for intent " # session.paymentIntent # " — awaiting async settlement"); + return ack(Http.text(200, "ignored: payment not completed")); + }; + // A test-mode event on a canister with a funded reserve would deliver real + // cycles for a payment that never happened. The secret is the only thing + // standing between the two, and provisioning the wrong one is a plausible + // operator slip, so the event says which world it came from and we check. + switch (deps.expectLivemode) { + case (?expected) if (session.livemode != expected) { + audit( + deps, + nowNs, + "stripe.livemodeMismatch", + "intent " # session.paymentIntent # ": livemode=" # (if (session.livemode) "true" else "false") + # " but this gateway expects " # (if (expected) "true" else "false") # " — nothing delivered", + ); + // Queue it only when real money is involved: a live payment reaching a + // test-configured gateway is an obligation. The reverse is a test event + // and owes nobody anything. + if (session.livemode) { + ignore Orphans.add( + deps.orphanStore, + #card, + // The real reference, not a placeholder: it is the only field that + // identifies WHICH order to rescue once the config is fixed, and + // discarding it would turn a recoverable misconfiguration into a + // manual hunt through the Stripe Dashboard. + #unattributed({ + claimedRef = Orphans.truncateClaimedRef( + switch (session.clientReferenceId) { case (?r) r; case null "(no client_reference_id)" } + ); + paymentRef = session.paymentIntent; + }), + "LIVE payment arrived but this gateway is configured for test mode — money is in the live Stripe account and nothing was delivered; fix set_expected_livemode, then RESEND this event from the Stripe Dashboard so the referenced order is credited, or refund", + nowNs, + ); + }; + return ack(Http.text(200, "acknowledged: livemode mismatch logged")); + }; + case null { + // Nothing is being refused here — but a gateway taking payments without + // having declared its Stripe mode has no defence against a test-mode + // secret spending the reserve, and the operator should see that in the + // trail rather than discover it later. Self-extinguishing: setting the + // expectation stops the line. + audit( + deps, + nowNs, + "stripe.livemodeUnset", + "intent " # session.paymentIntent # " honoured without a declared Stripe mode — set_expected_livemode is unset", + ); + }; }; - // payment_intent second: one mint per payment even across distinct + // payment_intent second: one delivery per payment even across distinct // event deliveries for the same intent (§4.2). if (not Idempotency.recordStripeIntent(deps.dedup, session.paymentIntent, nowNs)) { - return Http.text(200, "duplicate payment intent"); + return ack(Http.text(200, "duplicate payment intent")); }; - // ── Attribution (§4.1: claimed, not trusted). Failures are Type 1 - // #unattributed: fiat arrived, nothing will be minted. - let claimedRef = switch (session.clientReferenceId) { case (?r) r; case (null) "" }; - func unattributed(detail : Text) : Http.Response { - queueType1(deps, #unattributed({ claimedRef; paymentRef = session.paymentIntent }), detail, nowNs); + // ⚠️ **One intent, one credit — asked before the reference is even read.** + // + // `paidIntents` is the permanent record of which intent funded which order, and + // answering from it needs only the payment_intent. Asking here rather than after + // attribution matters when BOTH are wrong: an intent already credited elsewhere, + // arriving with an unusable reference, would otherwise fall through to the + // unattributed path and file an obligation for money that is already spent. + // + // The dedup sets prune at ~7 days, which automatic Stripe retries (~3 days) + // cannot outlive — but a manual Dashboard resend can, and resending an event to + // confirm it was processed is an ordinary operator move. + switch (deps.paidIntents.get(session.paymentIntent)) { + case (?credited) return ack(alreadyCredited(deps, session, credited, nowNs)); + case null {}; + }; + // ── Attribution (§4.1: claimed, not trusted). Failures are refund-resolvable + // #unattributed: fiat arrived, nothing will be delivered. + let claimedRef = Orphans.truncateClaimedRef( + switch (session.clientReferenceId) { case (?r) r; case (null) "" } + ); + func unattributed(detail : Text) : Outcome { + ack(queueRefundable(deps, #unattributed({ claimedRef; paymentRef = session.paymentIntent }), detail, nowNs)); }; let ?ref = session.clientReferenceId else return unattributed("missing client_reference_id"); let ?(claimedOwnerText, orderId) = Orders.parseClientReferenceId(ref) else { return unattributed("malformed client_reference_id"); }; + // Orders are never deleted, so an unresolvable id means the reference was + // never valid — not that we forgot the order. let ?order = Orders.get(deps.orders, orderId) else return unattributed("no order " # orderId); - let #ii(owner) = order.owner; + // A `switch` rather than a refutable `let #ii(owner) = …`, matching every other + // authz site. Both forms warn (M0145) and both trap at runtime when a second + // `Owner` case reaches them, so this buys **consistency, not safety** — the + // safety would come from making M0145 an error, tracked separately. + // + // Why it matters here at all: a trap on this path is a 5xx, which Stripe retries + // for ~3 days, so whoever takes up the §11.1.1 Base seam must update this site. + let owner = switch (order.owner) { case (#ii(p)) p }; if (owner.toText() != claimedOwnerText) return unattributed("claimed owner does not match order " # orderId); if (order.rail != #card) return unattributed("order " # orderId # " is not a card order"); + // The already-credited question was answered BEFORE attribution (see the + // `paidIntents` lookup above), so reaching here means this intent has never + // funded an order. + // ⚠️ THIS GUARD IS WHAT KEEPS `markPaid`'s trap unreachable. It must admit + // exactly the statuses the matrix allows into `#paid`, and nothing else: a + // status that passes here and is then refused by `isLegalTransition` traps, + // and a trap on this path is a 5xx that Stripe retries for ~3 days. + // + // ⚠️ `-Werror` does NOT protect this. Both arms typecheck whatever the matrix + // says, so the coupling is a comment and a test, not a compile error: change the + // matrix and this guard has to move with it. switch (order.status) { - case (#created or #expired) {}; // §4: late payment on an expired order is still honored + case (#created) {}; + case (#cancelled or #expired) { + // Real money against an order that can no longer be paid. The Stripe session + // outlives both states, so this is reachable: a buyer who + // cancels and pays anyway, or who pays a link after the sweep expired the + // order. + // + // Filed as `#unattributed` rather than `#duplicate`, because nothing was + // ever paid — there is no first payment for this to be a second of. Both + // are refund-resolvable and both carry the `paymentRef` a `charge.refunded` resolves, + // so the operator's lever is the same: refund in Stripe. + return unattributed( + "order " # orderId # " is " # Types.statusToText(order.status) + # " and cannot be paid — refund " # session.paymentIntent # " in Stripe" + ); + }; case (status) { - // Distinct payment_intent for an already-handled order = a genuine - // second payment (§4.1: Stripe dedup ≠ double-pay protection). - return queueType1( + // A genuinely distinct payment for an already-handled order (§4.1: + // Stripe dedup is redelivery protection, not double-pay protection). + // Reaching here means the intent is NOT in paidIntents, so it is new + // money against an order that has already been paid. + return ack(fileOrderProblem( deps, - #duplicate({ orderId; paymentRef = session.paymentIntent }), + orderId, + session.paymentIntent, + #duplicate({ paymentRef = session.paymentIntent }), "second payment for order " # orderId # " (status " # Types.statusToText(status) # ")", nowNs, - ); + )); }; }; if (session.currency != "usd") { return unattributed("unexpected currency " # session.currency # " for order " # orderId); }; - // ── §3/§6.1: honor the ACTUAL paid amount. The expected tier amount - // uses the locked quantity verbatim; anything else (user pasted the - // reference onto a different tier's link) is repriced from the order's - // creation pricing snapshot — never from a fresh rate. - let honoredCycles = if (session.amountTotalCents == order.pricing.usdCents) { - order.lockedCycles; - } else { - let ?net = Forex.netCents(order.pricing, session.amountTotalCents) else { - return unattributed("paid amount " # session.amountTotalCents.toText() # " cents is below the fee floor of order " # orderId); + // ── §3/§6.1: the session carried our amount, so the only thing to decide is + // whether Stripe reported it back unchanged. + switch (honoredCycles(order, session.amountTotalCents, deps.maxPurchaseUsdCents)) { + case (#asQuoted(_)) {}; + case (#mismatch({ paidUsdCents; quotedUsdCents })) { + return unattributed( + "paid amount " # paidUsdCents.toText() # " cents is not the " # quotedUsdCents.toText() + # " cents order " # orderId # " asked Stripe for — a session feature that moves the total is" + # " enabled (see Session.createBody); nothing delivered, refund and fix the configuration" + ); + }; + case (#aboveCeiling({ paidUsdCents; maxUsdCents })) { + return unattributed( + "paid amount " # paidUsdCents.toText() # " cents exceeds the per-purchase ceiling of " + # maxUsdCents.toText() # " cents for order " # orderId # " — nothing delivered; refund or raise the ceiling" + ); }; - Forex.cyclesForCents(net, order.pricing.xdrPerUsdMicros); }; - switch (Orders.markPaid(deps.orders, orderId, honoredCycles, nowNs)) { + switch (Orders.markPaid(deps.orders, orderId, session.amountTotalCents, nowNs)) { case (#ok(_)) { - if (honoredCycles != order.lockedCycles) { - audit(deps, nowNs, "stripe.amountMismatch", "order " # orderId # " honored at " # session.amountTotalCents.toText() # " cents = " # honoredCycles.toText() # " cycles (quoted " # order.pricing.usdCents.toText() # " cents)"); - }; - Http.text(200, "ok"); + // Link the payment to the order it funded, so a later refund of this + // intent can tell whether cycles were already delivered. + deps.paidIntents.add(session.paymentIntent, orderId); + // ⚠️ **The payment won, so a pending cancel intent is dead.** `#cancelled` is + // reachable only from `#created`, so once an order is `#paid` nothing can honour + // the intent and the entry would sit in stable memory for the life of the + // canister. `cancel_order`'s `#notOpen` arm is exactly how one gets here: the + // expire answered 400 *because* this session completed. + // + // ⚠️ **This is the third and last exit from `#created`, which is what bounds the + // set.** The matrix gives `#created` three: `#cancelled` and `#expired`, both + // settled through `Orders.settleUnpayable` / `expireBySession`, which remove the + // id as they decide with it — and `#paid`, whose only writer is this call. So + // membership implies `#created`, and the `#created` set is bounded by the gate's + // own-order cap and the reserve. `expireWithCause` needs no removal for a + // matching reason: it fires only for an order whose session never attached, and + // `cancel_order` cannot record an intent for one of those (no session id, so it + // takes the sessionless branch without adding). + deps.cancelRequests.remove(orderId); + // ⚠️ **Close any `#paidNotCredited` obligation for this order.** The + // recovery sweep files that when Stripe reports a paid session we never + // credited; this is the resend landing, which is the remedy the entry asks for. + // The rule every closer follows here: **an open worklist entry + // must describe a live problem**, and the moment the order is credited this one + // does not. + // + // ⚠️ Its closer is deliberately **the order being credited, not the money + // moving** — a `charge.refunded` must not close it, because refunding settles + // the money and leaves the order stranded in `#created` with no event left to + // release its capacity. That is why the kind withholds its `paymentRef` from + // `paymentRefOf`, and why the sweep's do-not-re-file guard is safe. + ignore Orders.resolveProblems( + deps.orders, + orderId, + func(k) { switch (k) { case (#paidNotCredited(_)) true; case (_) false } }, + nowNs, + ); + // The one path that creates money-out work. + { response = Http.text(200, "ok"); paidOrder = ?orderId }; }; // Unreachable: status was checked above and nothing awaits in // between. A trap rolls the dedup writes back and 5xxs, so Stripe @@ -333,24 +891,174 @@ module { req : Http.Request, nowNs : Int, toleranceSeconds : Nat, - ) : Http.Response { - let ?key = secret else return Http.text(503, "webhook secret not provisioned"); + ) : Outcome { + let ?key = secret else return ack(Http.text(503, "webhook secret not provisioned")); let ?header = Http.headerValue(req.headers, "stripe-signature") else { - return Http.text(400, "missing Stripe-Signature header"); + return ack(Http.text(400, "missing Stripe-Signature header")); }; switch (verify(key, header, req.body, nowNs, toleranceSeconds)) { - case (#err(_)) return Http.text(400, "signature verification failed"); + case (#err(_)) return ack(Http.text(400, "signature verification failed")); case (#ok) {}; }; // §4.2 opportunistic pruning: every verified delivery sweeps Stripe - // dedup keys past the ~7-day retention. + // dedup keys past the ~7-day retention. Reached only after the MAC + // verifies, so unauthenticated traffic cannot drive it. ignore Idempotency.pruneStripe(deps.dedup, nowNs); switch (parseEvent(req.body)) { - case (#err(_)) Http.text(400, "unparseable event"); - case (#ok(#unhandled(_))) Http.text(200, "ignored"); + case (#err(#invalidJson)) { + // The MAC verified, so this really came from Stripe — but we cannot read + // it and never will. 200 for the same reason as `#missingField` below. + audit(deps, nowNs, "stripe.unparseable", "verified event body is not valid JSON"); + ack(Http.text(200, "acknowledged: unreadable body logged for operator review")); + }; + case (#err(#missingField({ field; eventId }))) handleUnprocessable(deps, field, eventId, nowNs); + case (#ok(#unhandled({ eventId; eventType }))) { + // Audited rather than silently dropped: an event type arriving here that + // nobody subscribed to is a Stripe-config surprise, and the only way an + // operator learns of it is this line. + if (Idempotency.recordStripeEvent(deps.dedup, eventId, nowNs)) { + audit(deps, nowNs, "stripe.unhandledType", eventType # " (" # eventId # ")"); + }; + ack(Http.text(200, "ignored")); + }; + case (#ok(#asyncPaymentFailed({ eventId; paymentIntent }))) { + if (Idempotency.recordStripeEvent(deps.dedup, eventId, nowNs)) { + audit(deps, nowNs, "stripe.asyncPaymentFailed", "intent " # paymentIntent # " will never pay"); + }; + ack(Http.text(200, "ok")); + }; + case (#ok(#disputeCreated({ eventId; paymentIntent; amountCents }))) { + // Audit only, deliberately. A chargeback is the one loss nothing can + // prevent: the cycles are delivered and irreversible while the card + // network pulls the funds back. There is no automated response worth + // making — but an operator has to be able to see it happened. + if (Idempotency.recordStripeEvent(deps.dedup, eventId, nowNs)) { + let amount = switch (amountCents) { + case (?cents) cents.toText() # " cents"; + case null "an unstated amount"; + }; + audit(deps, nowNs, "stripe.disputeCreated", "intent " # paymentIntent # " disputed for " # amount # " — reconcile in Stripe; cycles cannot be recovered"); + }; + ack(Http.text(200, "ok")); + }; + case (#ok(#sessionExpired(expired))) handleSessionExpired(deps, expired, nowNs); case (#ok(#chargeRefunded(refund))) handleRefund(deps, refund, nowNs); case (#ok(#checkoutCompleted(session))) handleCheckout(deps, session, nowNs); }; }; + /// Stripe closed a session unpaid. + /// + /// ⚠️ **This handler must never trap.** A trap here is a 5xx, which Stripe + /// retries for about three days. Three reachable cases, all of which end 200: + /// + /// 1. **A `#cancelled` order — the NORMAL path.** Cancelling expires the + /// session, so Stripe fires this event for *every* cancel. "Mark it expired" + /// is an illegal transition there, so it degrades to a status no-op through + /// `applyTransition` rather than being treated as an error. + /// 2. **A session we do not recognise** — an order from before a reinstall, or + /// another integration pointed at this endpoint. Audit and ack. + /// 3. **A redelivery.** Deduped on the event id, so the status and the tally are + /// both no-ops. + /// + /// **Binding:** if the order has a `stripeSessionId`, the event's must match it + /// — a mismatch is audited and treated as unattributed. If it is null, attribute + /// by `client_reference_id` and backfill the id. That null-accept half is + /// load-bearing: it is how the residue order whose session-create response was + /// lost heals itself when that session's own expiry arrives ~30 minutes later. + func handleSessionExpired( + deps : Deps, + expired : { eventId : Text; sessionId : Text; clientReferenceId : ?Text }, + nowNs : Int, + ) : Outcome { + if (not Idempotency.recordStripeEvent(deps.dedup, expired.eventId, nowNs)) { + // A redelivery. Everything below is idempotent anyway, but returning here + // keeps the audit log from filling with copies. + return ack(Http.text(200, "duplicate event")); + }; + let ?ref = expired.clientReferenceId else { + audit(deps, nowNs, "stripe.expiredUnattributed", "session " # expired.sessionId # " expired with no client_reference_id"); + return ack(Http.text(200, "ok")); + }; + let ?(_, orderId) = Orders.parseClientReferenceId(ref) else { + audit(deps, nowNs, "stripe.expiredUnattributed", "session " # expired.sessionId # " expired with a malformed reference"); + return ack(Http.text(200, "ok")); + }; + let ?order = Orders.get(deps.orders, orderId) else { + audit(deps, nowNs, "stripe.expiredUnattributed", "session " # expired.sessionId # " names order " # orderId # ", which this gateway does not hold"); + return ack(Http.text(200, "ok")); + }; + switch (order.stripeSessionId) { + case (?stored) { + if (stored != expired.sessionId) { + audit(deps, nowNs, "stripe.expiredSessionMismatch", "order " # orderId # " holds session " # stored # " but the event names " # expired.sessionId); + return ack(Http.text(200, "ok")); + }; + }; + // Null is accepted and backfilled — see the note above. + case null {}; + }; + switch (Orders.expireBySession(deps.orders, deps.cancelRequests, orderId, expired.sessionId, nowNs)) { + case (#ok(_)) { + audit(deps, nowNs, "stripe.sessionExpired", "order " # orderId # " expired by Stripe (session " # expired.sessionId # ")"); + }; + case (#err(_)) { + // Case 1 above, almost always: the order is already `#cancelled`, which + // is what cancelling it did. Not an error, and not silent. + audit(deps, nowNs, "stripe.sessionExpiredNoop", "order " # orderId # " was already " # Types.statusToText(order.status)); + }; + }; + ack(Http.text(200, "ok")); + }; + + /// A verified event we cannot process — e.g. a checkout session with no + /// `payment_intent`, reachable through a subscription-mode link or a 100%-off + /// promo code. + /// + /// **Acked 200, not 400.** Parsing is deterministic, so a 400 here would repeat + /// on every Stripe retry for the full ~3-day horizon; Stripe warns about and can + /// **disable** an endpoint that keeps failing, and a disabled endpoint loses + /// every *legitimate* webhook after it. Refusing delivery of a message that can + /// never succeed trades a permanent outage for no benefit. Non-2xx stays for + /// input we cannot authenticate and for the unprovisioned-secret case, where + /// retrying is exactly what we want. + /// + /// The obligation goes on the worklist instead, deduped on the event id so + /// Stripe's retries do not pile up copies. + func handleUnprocessable(deps : Deps, field : Text, eventId : ?Text, nowNs : Int) : Outcome { + let detail = "verified Stripe event is missing " # field + # " — cannot be processed; inspect it in the Stripe Dashboard and reconcile by hand"; + switch (eventId) { + case (?id) { + if (not Idempotency.recordStripeEvent(deps.dedup, id, nowNs)) { + return ack(Http.text(200, "duplicate event")); + }; + // Past the ~7-day dedup retention the check above no longer recognises a + // resend, so the worklist itself is the second line of defence: one + // unreadable event must not become two items to reconcile. + switch (Orphans.unresolvedUnprocessable(deps.orphanStore, id)) { + case (?_) { + audit(deps, nowNs, "stripe.unprocessableResend", id # " already on the worklist"); + return ack(Http.text(200, "already queued")); + }; + case null {}; + }; + ignore Orphans.add( + deps.orphanStore, + #card, + #unprocessable({ eventId = id; field }), + detail, + nowNs, + ); + audit(deps, nowNs, "stripe.unprocessable", id # ": " # detail); + }; + case null { + // No id parsed, so nothing to dedup on and nothing stable to key an + // entry to. The audit line is the whole trail here. + audit(deps, nowNs, "stripe.unprocessable", detail); + }; + }; + ack(Http.text(200, "acknowledged: queued for operator review")); + }; + }; diff --git a/src/backend/rails/CkUsdc.mo b/src/backend/rails/CkUsdc.mo deleted file mode 100644 index fc4800a..0000000 --- a/src/backend/rails/CkUsdc.mo +++ /dev/null @@ -1,395 +0,0 @@ -/// ck-USDC rail (§6.2) — the pure half. -/// -/// ICRC-1/2 Candid types for the ck-USDC ledger, the approve→pull flow's -/// deterministic pull-intent construction, `icrc2_transfer_from` result -/// interpretation, the per-order pull journal, and the claim resume decision. -/// Everything here is synchronous and unit-testable; Main.mo owns the awaits, -/// the order store, and the §4.2 `processedCkUsdcBlocks` dedup set. -/// -/// This is the money-IN twin of Cmc.mo's §5.1 invariant: `icrc2_transfer_from` -/// debits the *user's* ck-USDC, so it gets the same write-intent-before-call -/// treatment — the pull args (including `created_at_time`) are persisted in -/// one sync block before the ledger await. A call whose response is lost -/// (upgrade mid-pull) is recovered by replaying the *identical* args: the -/// ledger either performs the pull once or answers `#Duplicate` with the -/// original block. An intent older than the ledger's ~24 h dedup window -/// without a recorded block is never auto-replayed with fresh args (the -/// original pull's fate is unknowable — fresh args could debit the user -/// twice); it escalates to the operator, who reads the ck-USDC ledger. -/// -/// Definite-rejection rule used by `interpretPull`: ic-icrc1 ledgers check -/// `created_at_time` dedup *before* balance/allowance checks, so an identical -/// transaction that already executed answers `#Duplicate`, never -/// `#InsufficientAllowance`/`#InsufficientFunds`/`#BadFee`. Those errors -/// therefore prove no attempt with these args has ever moved money — the -/// intent can be dropped and rebuilt fresh, which is what turns "approved too -/// little" (§6.2 amount-short mismatch) into a clean retryable user error -/// instead of a stuck order. -/// -/// Treasury posture (§6.2, hold-ckUSDC): pulled ck-USDC accrues in the -/// canister's own ledger account — no per-order DEX swap. Mints come from the -/// shared ICP float; the operator periodically withdraws the accrued ck-USDC -/// (admin lever in Main.mo) and converts it to ICP off-chain to refill. -import Map "mo:core/Map"; -import Nat "mo:core/Nat"; -import Nat64 "mo:core/Nat64"; -import Principal "mo:core/Principal"; -import Result "mo:core/Result"; -import Text "mo:core/Text"; -import Types "../Types"; - -module { - - /// ck-USDC ledger (mainnet; PocketIC suites deploy their own and override). - public let ledgerId : Text = "xevnm-gaaaa-aaaar-qafnq-cai"; - - /// ck-USDC has 6 decimals and is 1:1 USD by construction, so one US cent - /// is exactly 10^4 ledger units. Pricing in `usdCents` is exact on this - /// rail — no forex enters the money-in side (only the cycles quote). - public let unitsPerCent : Nat = 10_000; - - /// ICRC ledgers (ic-icrc1) deduplicate on `created_at_time` for ~24 h. - /// At/after this age a pull intent without a block must escalate, never - /// replay. (Same constant as Cmc.ledgerDedupWindowNs; kept local so the - /// rail module stays self-contained.) - public let ledgerDedupWindowNs : Int = 86_400_000_000_000; - - // ── Candid interfaces (only the methods this rail calls) ───────────────── - - public type TransferArg = { - from_subaccount : ?Blob; - to : Types.Account; - amount : Nat; - fee : ?Nat; - memo : ?Blob; - created_at_time : ?Nat64; - }; - - public type TransferError = { - #BadFee : { expected_fee : Nat }; - #BadBurn : { min_burn_amount : Nat }; - #InsufficientFunds : { balance : Nat }; - #TooOld; - #CreatedInFuture : { ledger_time : Nat64 }; - #Duplicate : { duplicate_of : Nat }; - #TemporarilyUnavailable; - #GenericError : { error_code : Nat; message : Text }; - }; - - public type TransferResult = { #Ok : Nat; #Err : TransferError }; - - public type TransferFromArgs = { - spender_subaccount : ?Blob; - from : Types.Account; - to : Types.Account; - amount : Nat; - fee : ?Nat; - memo : ?Blob; - created_at_time : ?Nat64; - }; - - public type TransferFromError = { - #BadFee : { expected_fee : Nat }; - #BadBurn : { min_burn_amount : Nat }; - #InsufficientFunds : { balance : Nat }; - #InsufficientAllowance : { allowance : Nat }; - #TooOld; - #CreatedInFuture : { ledger_time : Nat64 }; - #Duplicate : { duplicate_of : Nat }; - #TemporarilyUnavailable; - #GenericError : { error_code : Nat; message : Text }; - }; - - public type TransferFromResult = { #Ok : Nat; #Err : TransferFromError }; - - public type LedgerService = actor { - icrc2_transfer_from : shared TransferFromArgs -> async TransferFromResult; - icrc1_transfer : shared TransferArg -> async TransferResult; - icrc1_balance_of : shared query Types.Account -> async Nat; - }; - - // ── Rail config (§7 operator config) ───────────────────────────────────── - - /// `minUsdCents`/`maxUsdCents` bound the user-chosen amount (no fixed - /// tiers here — nothing structural pins the amount the way a card Payment - /// Link does, and the canister pulls the exact price itself). `feeBps`/ - /// `feeFixedCents` is the §3 net-of-fees formula for this rail. - /// `ledgerFeeUnits` is the ck-USDC ledger transfer fee the user's approval - /// must also cover (a protocol fact, not a pricing decision; a drift - /// surfaces as `#BadFee` → operator updates the config). - public type Config = { - minUsdCents : Nat; - maxUsdCents : Nat; - feeBps : Nat; - feeFixedCents : Nat; - ledgerFeeUnits : Nat; - }; - - /// Fail closed: `maxUsdCents = 0` keeps the rail disabled until the - /// operator consciously sizes it (the empty-tier-list stance — bounds on a - /// money rail are an operator decision, not a default invented here). The - /// fee formula defaults to zero — unlike Stripe's 2.9% + 30¢ there is no - /// structural processor fee on this rail (ledger fees are paid by the - /// user); the operator absorbs off-chain conversion costs per the §3 - /// at-cost posture, and can set a fee here if that changes. - public func defaultConfig() : Config { - { - minUsdCents = 0; - maxUsdCents = 0; - feeBps = 0; - feeFixedCents = 0; - ledgerFeeUnits = 10_000; - }; - }; - - public type ConfigError = { - /// Fee ≥ 100% can never net out. - #feeBpsTooHigh; - /// An enabled rail (max > 0) with min > max can never accept an order. - #minAboveMax; - }; - - public func validateConfig(config : Config) : Result.Result<(), ConfigError> { - if (config.feeBps >= 10_000) return #err(#feeBpsTooHigh); - if (config.maxUsdCents > 0 and config.minUsdCents > config.maxUsdCents) { - return #err(#minAboveMax); - }; - #ok; - }; - - public type AmountError = { - /// `maxUsdCents = 0` — the operator has not enabled this rail. - #railDisabled; - /// A $0 order would mint on nothing. - #zeroAmount; - /// Carries the bound so the frontend can say what *would* be accepted. - #belowMinimum : Nat; - #aboveMaximum : Nat; - }; - - public func validateAmount(config : Config, usdCents : Nat) : Result.Result<(), AmountError> { - if (config.maxUsdCents == 0) return #err(#railDisabled); - if (usdCents == 0) return #err(#zeroAmount); - if (usdCents < config.minUsdCents) return #err(#belowMinimum(config.minUsdCents)); - if (usdCents > config.maxUsdCents) return #err(#aboveMaximum(config.maxUsdCents)); - #ok; - }; - - // ── Pure derivations ───────────────────────────────────────────────────── - - /// Ledger units the claim pulls for a quoted gross amount. - public func unitsForCents(usdCents : Nat) : Nat { - usdCents * unitsPerCent; - }; - - /// The §5.1-style deterministic pull args, persisted *before* the ledger - /// call. Everything the ledger dedups on is fixed here; replaying this - /// intent can never debit the user twice. The memo is the order ID's UTF-8 - /// (32 hex chars = exactly the 32-byte ICRC-1 memo bound), tying the - /// ledger transaction to the order for off-chain audit. - public type PullIntent = { - createdAtTimeNs : Nat64; - amountUnits : Nat; - feeUnits : Nat; - fromOwner : Principal; - memo : Blob; - }; - - public func buildPullIntent( - fromOwner : Principal, - orderId : Types.OrderId, - amountUnits : Nat, - feeUnits : Nat, - nowNs : Int, - ) : PullIntent { - { - createdAtTimeNs = Nat64.fromIntWrap(nowNs); - amountUnits; - feeUnits; - fromOwner; - memo = orderId.encodeUtf8(); - }; - }; - - /// The intent's wire form. A pure projection — replay maps the *stored* - /// intent through this, so the replayed call is bit-identical to the first. - /// `gateway` (the spender = recipient) is the canister's own principal, - /// constant across upgrades. - public func transferFromArgs(gateway : Principal, intent : PullIntent) : TransferFromArgs { - { - spender_subaccount = null; - from = { owner = intent.fromOwner; subaccount = null }; - to = { owner = gateway; subaccount = null }; - amount = intent.amountUnits; - fee = ?intent.feeUnits; - memo = ?intent.memo; - created_at_time = ?intent.createdAtTimeNs; - }; - }; - - // ── Result interpretation ──────────────────────────────────────────────── - - /// Why a definite rejection happened — each maps to a user-actionable - /// error, and per the module-doc dedup-first rule each *proves* nothing - /// has ever moved under these args, so the caller drops the intent. - public type DropReason = { - /// §6.2 amount-short mismatch: the approval doesn't cover amount + fee. - #insufficientAllowance : { allowance : Nat }; - /// The user's ck-USDC balance doesn't cover amount + fee. - #insufficientFunds : { balance : Nat }; - /// `ledgerFeeUnits` config drifted from the ledger's actual fee. - #badFee : { expectedFee : Nat }; - /// Structurally impossible rejections (e.g. BadBurn on a transfer_from). - #rejected : Text; - }; - - /// What the claim driver does next. - public type PullOutcome = { - /// Block recovered — `#Ok` or `#Duplicate` (the §5.1 replay payoff). - #pulled : Nat; - /// Definite rejection: drop the intent, surface the reason. Nothing moved. - #drop : DropReason; - /// Transient: keep the intent, replay on the next claim (always safe - /// within the dedup window). - #retry : Text; - /// The ledger can no longer answer for these args (`#TooOld`) — a lost - /// earlier attempt could have executed. Escalate; never rebuild fresh. - #uncertain : Text; - }; - - public func interpretPull(result : TransferFromResult) : PullOutcome { - switch (result) { - case (#Ok(block)) #pulled(block); - case (#Err(#Duplicate({ duplicate_of }))) #pulled(duplicate_of); - // Definite rejections — dedup-first ledger semantics prove no prior - // lost attempt executed either (it would have answered #Duplicate). - case (#Err(#InsufficientAllowance({ allowance }))) #drop(#insufficientAllowance({ allowance })); - case (#Err(#InsufficientFunds({ balance }))) #drop(#insufficientFunds({ balance })); - case (#Err(#BadFee({ expected_fee }))) #drop(#badFee({ expectedFee = expected_fee })); - case (#Err(#BadBurn(_))) #drop(#rejected("ledger answered BadBurn to a transfer_from")); - // Transient: identical args can succeed later; replay is safe. - case (#Err(#TemporarilyUnavailable)) #retry("ledger temporarily unavailable"); - case (#Err(#CreatedInFuture(_))) #retry("created_at_time ahead of ledger clock"); - case (#Err(#GenericError({ error_code; message }))) #retry("ledger error " # error_code.toText() # ": " # message); - // TooOld: past the dedup window the ledger can't distinguish "never - // executed" from "executed, then forgotten" — the §5.1 stale-intent - // uncertainty, money-in edition. - case (#Err(#TooOld)) #uncertain("pull intent aged past the ledger dedup window"); - }; - }; - - /// Admin-facing rendering for the withdraw lever's ICRC-1 errors. - public func transferErrorToText(error : TransferError) : Text { - switch (error) { - case (#BadFee({ expected_fee })) "BadFee: ledger expects " # expected_fee.toText(); - case (#BadBurn(_)) "BadBurn"; - case (#InsufficientFunds({ balance })) "InsufficientFunds: balance " # balance.toText() # " units"; - case (#TooOld) "TooOld"; - case (#CreatedInFuture(_)) "CreatedInFuture"; - case (#Duplicate({ duplicate_of })) "Duplicate of block " # duplicate_of.toText(); - case (#TemporarilyUnavailable) "TemporarilyUnavailable"; - case (#GenericError({ error_code; message })) "GenericError " # error_code.toText() # ": " # message; - }; - }; - - // ── Pull journal (§4.2-adjacent: the money-IN record for this rail) ────── - - /// One pull per order, keyed by order ID. Financial record — kept for - /// years, never pruned (it is what proves which ledger block paid which - /// order, alongside `processedCkUsdcBlocks`). - public type PullEntry = { - orderId : Types.OrderId; - intent : PullIntent; - blockIndex : ?Nat; - /// Set when the stale-intent uncertainty was queued for the operator — - /// guards against re-queueing on every subsequent claim attempt. - escalatedAtNs : ?Int; - createdAtNs : Int; - updatedAtNs : Int; - }; - - public type PullJournal = Map.Map; - - public func emptyPullJournal() : PullJournal { - Map.empty(); - }; - - /// Persist the intent — same sync block as its construction, before the - /// transfer_from await. - public func openPull(journal : PullJournal, orderId : Types.OrderId, intent : PullIntent, nowNs : Int) : PullEntry { - let entry : PullEntry = { - orderId; - intent; - blockIndex = null; - escalatedAtNs = null; - createdAtNs = nowNs; - updatedAtNs = nowNs; - }; - journal.add(orderId, entry); - entry; - }; - - /// No-op on a missing id — a pipeline bug must degrade to a stuck claim, - /// never a trap mid-money-flow (the Cmc.patch convention). - public func recordPullBlock(journal : PullJournal, orderId : Types.OrderId, blockIndex : Nat, nowNs : Int) { - let ?entry = journal.get(orderId) else return; - journal.add(orderId, { entry with blockIndex = ?blockIndex; updatedAtNs = nowNs }); - }; - - public func markEscalated(journal : PullJournal, orderId : Types.OrderId, nowNs : Int) { - let ?entry = journal.get(orderId) else return; - journal.add(orderId, { entry with escalatedAtNs = ?nowNs; updatedAtNs = nowNs }); - }; - - /// Remove the intent — only legal when nothing moved under it (definite - /// rejection, or operator-verified via the ledger). - public func dropPull(journal : PullJournal, orderId : Types.OrderId) { - journal.remove(orderId); - }; - - // ── Claim resume decision ──────────────────────────────────────────────── - - /// The claim driver's next move for one order, derived from status + pull - /// journal — the whole §5.1-style resume decision as one pure function. - public type ClaimStage = { - /// Order status is past money-in (or terminal) — nothing to claim. - #notClaimable; - /// No intent yet: build one, persist it, pull. - #fresh; - /// Intent persisted, no block, within the dedup window: (re)issue the - /// identical pull — first attempt and recovery replay are the same move. - #replay : PullIntent; - /// Block recorded but the `#paid` transition never committed (a healed - /// regression path, unreachable today — both commit in one sync block). - #recoverBlock : Nat; - /// Intent aged past the dedup window without a block — queue for the - /// operator (once), then answer #alreadyEscalated. - #escalate : PullIntent; - #alreadyEscalated; - }; - - public func claimStage( - status : Types.OrderStatus, - entry : ?PullEntry, - nowNs : Int, - dedupWindowNs : Int, - ) : ClaimStage { - switch (status) { - // #expired stays claimable: §4 expiry is advisory — a late real - // payment still completes at the locked quantity. - case (#created or #expired) {}; - case (_) return #notClaimable; - }; - let ?e = entry else return #fresh; - switch (e.blockIndex) { - case (?block) return #recoverBlock(block); - case null {}; - }; - if (e.escalatedAtNs != null) return #alreadyEscalated; - if (nowNs - e.intent.createdAtTimeNs.toNat() >= dedupWindowNs) { - return #escalate(e.intent); - }; - #replay(e.intent); - }; - -}; diff --git a/src/backend/rails/Session.mo b/src/backend/rails/Session.mo new file mode 100644 index 0000000..150a635 --- /dev/null +++ b/src/backend/rails/Session.mo @@ -0,0 +1,612 @@ +/// Stripe Checkout Session creation and expiry, as pure functions. +/// +/// Everything here is deterministic and unit-testable: building the +/// form-encoded request, stripping the response for consensus, and parsing the +/// two fields we keep. The outcall itself lives in `Main.mo`, because the +/// transform has to be a `shared query` on the actor. +/// +/// **Why sessions at all.** A Payment Link has no expiry field — the object +/// carries `active`, `inactive_message` and a completed-count `restrictions` +/// cap, and its URL accepts only UTM codes and `client_reference_id`. Per-order +/// expiry therefore requires a per-order session, and once you have one you also +/// get a per-order `success_url` and an arbitrary amount. +// `mo:ic` re-exports only the `ic` actor; the request/response types live in +// its `Types` module. +import IC "mo:ic/Types"; +// These four are imported for their DOT NOTATION, not to be named: `c.toText()`, +// `n.toText()` and `blob.decodeUtf8()` resolve through the imported module, so +// dropping an "unused" import here is a compile error rather than a tidy-up. +import Char "mo:core/Char"; +import Result "mo:core/Result"; +import Iter "mo:core/Iter"; +import Nat "mo:core/Nat"; +import Nat8 "mo:core/Nat8"; +import Nat64 "mo:core/Nat64"; +import Text "mo:core/Text"; +import Json "../Json"; + +module { + /// Why creating a Checkout Session failed. + /// + /// ⚠️ **Lives with the rail rather than the composition root:** it + /// describes a Stripe-session failure, and two mixins plus `Main.mo` need to name it. + /// Not part of the Candid interface — `create_order` answers with `CreateOrderError`, + /// which is the buyer-facing narrowing of this. + public type Error = { + /// The API key is not provisioned. The rail cannot produce a payable session. + #railClosed; + /// No origin set, so there is no URL to return the buyer to. + #originUnset; + #outcallFailed : Text; + #stripeRejected : { status : Nat }; + #unparseableResponse; + #missingField : Text; + #livemodeMismatch : { sessionLivemode : Bool; expected : Bool }; + }; + + + /// Stripe's Checkout Sessions collection. + public let createUrl : Text = "https://api.stripe.com/v1/checkout/sessions"; + + /// `POST /v1/checkout/sessions/{id}/expire` — what `cancel_order` calls to + /// make an order provably unpayable before marking it `#cancelled`. + /// Why an origin was refused. + /// + /// In this module rather than the mixin so the parsing below is unit-testable; the + /// Candid name comes from this declaration, so the interface is unchanged (§9.1). + public type OriginError = { + /// Non-loopback `http://`. A plain-HTTP return URL after a card payment is not a + /// thing to offer, and Stripe would render it. + #notHttps; + /// A query string or fragment would collide with the `#/order/` route appended + /// to it, producing a URL that does not resolve to the order. + #hasQueryOrFragment; + /// No authority at all — `https://`, `http:///path`. The scheme is fine and there + /// is nothing to return a buyer to. + #noHost; + #empty; + }; + + /// The origin Stripe returns buyers to, validated and normalised. + /// + /// ⚠️ **`http://` is accepted for LOOPBACK hosts only, and that is Stripe-correct.** + /// `success_url` is a redirect target for the buyer's own browser — Stripe never + /// fetches it — and Stripe's API reference states no scheme requirement, while its own + /// Checkout quickstart uses `http://localhost:4242/success.html`. The https rule is + /// ours, and its reason (never send a buyer to a plaintext page after paying) does not + /// apply to traffic that never leaves the machine. + /// + /// ⚠️ **The host is PARSED, not substring-matched.** `http://localhost.evil.com` is + /// the trap: it contains "localhost" and is not loopback. Only an exact `localhost`, + /// `127.0.0.1`, `[::1]`, or a `.localhost` subdomain qualifies — the last because a + /// local `icp network` serves the frontend at `http://frontend.local.localhost:8000`. + public func validateOrigin(origin : Text) : Result.Result { + if (origin.size() == 0) return #err(#empty); + // Scheme, then host, then the loopback question — in that order, so each refusal + // names the first thing actually wrong. Deciding loopback first made `http://` + // answer `#notHttps` while `https://` answered `#noHost`, for the same defect. + let https = if (origin.startsWith(#text "https://")) true else if (origin.startsWith(#text "http://")) false else { + return #err(#notHttps); + }; + let host = hostOf(origin.trimStart(#text (if (https) "https://" else "http://"))); + if (host.size() == 0) return #err(#noHost); + if (not https and not isLoopbackHost(host)) return #err(#notHttps); + if (origin.contains(#char '?') or origin.contains(#char '#')) { + return #err(#hasQueryOrFragment); + }; + // Trailing slash trimmed here rather than at every use site, so + // `origin # "/#/order/" # id` cannot produce a double slash. + #ok(origin.trimEnd(#char '/')); + }; + + /// The host: authority minus userinfo, minus port. + /// + /// ⚠️ **Userinfo is stripped FIRST, and skipping it accepted a public host as + /// loopback.** `http://localhost:8000@evil.com` has host `evil.com` — a browser reads + /// everything before the last `@` as credentials — while a port-first parse sees the + /// text before the first colon and answers `localhost`. Four of the five userinfo + /// spellings passed that way; only the one without a port was refused, which is + /// precisely the member the trap test happened to contain. + /// + /// The LAST `@` is the separator, because userinfo may itself contain one. + func hostOf(afterScheme : Text) : Text { + let authority = afterScheme.split(#char '/').next() ?? ""; + let parts = authority.split(#char '@').toArray(); + // ⚠️ **Guarded, because `"".split(#char '@')` yields ZERO elements and `size() - 1` + // underflows on `Nat`.** `set_stripe_origin("http://")` trapped here rather than + // returning its declared `Result` — a method that promises a typed refusal had a + // path around it. The line this replaced was option-returning (`.next() ?? ""`) and + // could not underflow; swapping it for an array index is what introduced it. + let hostPort = if (parts.size() == 0) "" else parts[parts.size() - 1]; + // An IPv6 literal keeps its brackets: the colons inside are not a port separator. + if (hostPort.startsWith(#text "[")) { + return (hostPort.split(#char ']').next() ?? "") # "]"; + }; + hostPort.split(#char ':').next() ?? ""; + }; + + func isLoopbackHost(host : Text) : Bool { + host == "localhost" or host == "127.0.0.1" or host == "[::1]" + or host.endsWith(#text ".localhost"); + }; + + public func expireUrl(sessionId : Text) : Text { + createUrl # "/" # sessionId # "/expire"; + }; + + /// The response reservation, in bytes. + /// + /// ⚠️ **The cap counts HEADERS as well as the body, and is checked TWICE against the + /// same number.** Headers first, then the body against what remains; then the + /// transform's *Candid-encoded* output against the same value. Two consequences the + /// next editor would get wrong: **stripping headers in the transform cannot buy room** + /// (the first two checks run before it), and a raw response that only just fits can + /// still fail *after* the transform. Sizing at ~2× a measured response is the margin + /// those checks need, not padding. + /// + /// A session response is ~3.5–6 KB of JSON plus 1–2 KB of headers, so 16 KB is ~2× + /// headroom — an estimate; a captured fixture would let this be pinned, and that check is + /// `cap ≥ 2 × measured`. + /// + /// ⚠️ **The asymmetry decides the value: too small takes the rail DOWN, too large only + /// costs cycles** (~10,400 per byte at n = 13). Never `null` — the `ic` package + /// substitutes 2,000,000 and the call costs ~20.85 B. + /// + /// **Three distinct failure messages, and the middle one is actively + /// misleading** — worth recognising before diagnosing the wrong thing: + /// + /// | Reject message | What actually happened | + /// |---|---| + /// | `Header size exceeds specified response size limit ` | the headers **alone** exceeded the cap | + /// | `Http body exceeds size limit of bytes.` | the body exceeded what was **left after** the headers. It prints the full cap, not the remainder, so **the body that failed can be well under ``** | + /// | `Transformed http response exceeds limit: ` | the transform's Candid-encoded output exceeded the cap | + /// + /// Raising the cap fixes all three. Stripping headers in the transform fixes + /// only the last, because the first two are checked before it runs. + public let maxResponseBytes : Nat64 = 16_384; + + /// Session lifetime handed to Stripe, in seconds. + /// + /// ⚠️ **35 minutes, not 30.** Stripe's floor is 30 minutes evaluated against + /// *Stripe's* clock when the request arrives, so `now + 30 min` sits exactly on + /// it: a few seconds of clock skew or consensus latency puts the value below the + /// floor, Stripe rejects it, and **every** session creation fails — the whole + /// rail, not one order. Five minutes of slack costs a promise held ~17% longer. + /// + /// This is only what we *ask* for. `expiresAtNs` is stamped from Stripe's + /// response, so nothing downstream depends on this number. + public let requestedLifetimeSeconds : Nat = 2_100; // 35 min + + /// Percent-encode for `application/x-www-form-urlencoded`. + /// + /// Unreserved set from RFC 3986 (`ALPHA / DIGIT / "-" / "." / "_" / "~"`); + /// everything else is escaped, and a space becomes `%20` rather than `+` — + /// both are accepted in a form body and `%20` needs no special case. + /// + /// Encoding is not optional here even though the current inputs look tame: the + /// product name carries an amount, `success_url` carries `#/order/`, and a + /// bare `#` would truncate the value at the fragment. It is also the difference + /// between an operator-set origin and a parameter-injection bug. + public func formEncode(text : Text) : Text { + var out = ""; + for (c in text.chars()) { + if (isUnreserved(c)) { + out #= c.toText(); + } else { + // Per BYTE, not per char: a multi-byte character is escaped as each of + // its UTF-8 bytes, which is what the form encoding expects. + for (b in c.toText().encodeUtf8().values()) { + out #= "%" # hexByte(b); + }; + }; + }; + out; + }; + + func isUnreserved(c : Char) : Bool { + (c >= 'A' and c <= 'Z') or (c >= 'a' and c <= 'z') or (c >= '0' and c <= '9') + or c == '-' or c == '.' or c == '_' or c == '~'; + }; + + func hexByte(b : Nat8) : Text { + let digits = "0123456789ABCDEF"; + let hi = Nat8.toNat(b / 16); + let lo = Nat8.toNat(b % 16); + charAt(digits, hi) # charAt(digits, lo); + }; + + func charAt(t : Text, i : Nat) : Text { + var n = 0; + for (c in t.chars()) { + if (n == i) return c.toText(); + n += 1; + }; + ""; + }; + + /// Everything the session body needs, gathered so the caller cannot pass them + /// in the wrong order. + public type CreateArgs = { + orderId : Text; + /// `_` — the attribution mechanism, unchanged from the + /// Payment Link flow, so webhook handling is untouched. + clientReferenceId : Text; + /// Gross USD cents. Stripe's `unit_amount`. + usdCents : Nat; + /// Operator-configured asset origin, e.g. `https://.icp.net`. + /// Admin config, never a caller parameter — a caller-supplied `success_url` + /// is an open redirect Stripe renders after a real payment. + origin : Text; + /// Absolute Unix seconds. Stripe's clock decides whether it clears the floor. + expiresAtSeconds : Nat; + }; + + /// The form-encoded create body. + /// + /// ⚠️ **`amount_total` is guaranteed to equal `unit_amount` only because of + /// what is ABSENT here.** `mode=payment`, one line item, inline `price_data`, + /// quantity 1 — Stripe Checkout then exposes no editable amount. Enabling any + /// of these breaks that equality, and every one defaults to off: + /// + /// | Setting | Effect | + /// |---|---| + /// | `automatic_tax` | tax on top → total > unit_amount | + /// | `adjustable_quantity` | buyer picks a quantity → a multiple | + /// | `allow_promotion_codes` / `discounts` | buyer or we apply a code → less | + /// | shipping options | adds a shipping amount | + /// | `optional_items` | buyer adds items in the Checkout UI | + /// | `line_items[][tax_rates]` | manual tax | + /// | `after_expiration[recovery]` | the expired event carries a URL that spawns a payable copy-session for 30 days — a direct tally bypass | + /// + /// `adaptive_pricing[enabled]=false` is passed explicitly because it is + /// Dashboard-toggleable, and pinning it costs one parameter. Inline `price_data` mints a + /// fresh Product per session with nothing attached, so cross-sells are structurally + /// neutralised and there are no Dashboard objects to keep in sync. + /// + /// ⚠️ **Keep the `amount_total == usdCents` check even though this shape makes it + /// unreachable.** Stripe keeps adding Dashboard-side amount changers, and the table + /// above is a snapshot of the ones that exist today. + public func createBody(args : CreateArgs) : Text { + let fields : [(Text, Text)] = [ + ("mode", "payment"), + ("payment_method_types[]", "card"), + ("line_items[0][quantity]", "1"), + ("line_items[0][price_data][currency]", "usd"), + ("line_items[0][price_data][unit_amount]", args.usdCents.toText()), + ("line_items[0][price_data][product_data][name]", "Cycles for the Internet Computer"), + ("client_reference_id", args.clientReferenceId), + ("expires_at", args.expiresAtSeconds.toText()), + // Back to the buyer's own order page, both ways. Without `cancel_url` a + // buyer who backs out of Stripe lands somewhere we did not choose. + ("success_url", args.origin # "/#/order/" # args.orderId), + ("cancel_url", args.origin # "/#/order/" # args.orderId), + ("adaptive_pricing[enabled]", "false"), + ]; + var body = ""; + var first = true; + for ((k, v) in fields.values()) { + if (not first) body #= "&"; + first := false; + body #= formEncode(k) # "=" # formEncode(v); + }; + body; + }; + + /// Request headers for a session create. + /// + /// ⚠️ **`Idempotency-Key` does double duty, and the second job is easy to + /// miss.** Every replica performs the outcall independently. Without the key, + /// each one creates a *distinct* session with a distinct `id` and `url`, so + /// (a) one order spawns many sessions and (b) **consensus can never be + /// reached** — and no transform can repair that, because a transform strips + /// variation, it cannot invent agreement. With the key, Stripe returns the + /// same object to every replica. + /// + /// The order id is the natural key: one order, one session, for the life of + /// the order. There is no session retry, so it + /// is never reused with a changed body — which Stripe rejects. + public func createHeaders(apiKey : Text, orderId : Text) : [IC.HttpHeader] { + [ + { name = "Authorization"; value = "Bearer " # apiKey }, + { name = "Content-Type"; value = "application/x-www-form-urlencoded" }, + { name = "Idempotency-Key"; value = orderId }, + ]; + }; + + /// Headers for the expire call. Idempotent by nature — expiring an already + /// expired session is not an error we need a key to deduplicate. + /// Authorization and nothing else — shared by the expire POST and the retrieve GET. + /// + /// ⚠️ **No idempotency key here, deliberately.** `createHeaders` carries one because + /// creating a session twice would charge a buyer twice; expiring or reading one is + /// idempotent by nature, and Stripe ignores the header on a GET anyway. + public func authHeaders(apiKey : Text) : [IC.HttpHeader] { + [{ name = "Authorization"; value = "Bearer " # apiKey }]; + }; + + /// The transform's whole job: **strip every response header.** + /// + /// Not "reduce" — strip all of them. Stripe returns a unique `request-id` per + /// HTTP request, plus `Date`, the rate-limit family and Cloudflare headers. + /// Each replica issues its own request, so **every** replica sees a different + /// `request-id`: passing headers through fails consensus on *every single + /// call*, not occasionally. + /// + /// ⚠️ **Replication-count independent.** Any `n > 1` breaks on unstripped + /// per-request headers, so this is not something to tune against a node count. + /// + /// ⚠️ **The PocketIC suite cannot catch a mistake here.** It *mocks* outcalls, + /// handing the canister whatever response the test constructs — so a transform + /// that leaks a header passes the suite and fails only against real Stripe, + /// where the replicas disagree. First observable in a manual run. + public func strip(response : IC.HttpRequestResult) : IC.HttpRequestResult { + { status = response.status; body = response.body; headers = [] }; + }; + + public type Created = { + id : Text; + url : Text; + /// Stripe's own deadline, in **seconds**. The caller multiplies by 10⁹. + expiresAtSeconds : Nat; + /// Whether the API key that created this is a live key. Checked against + /// `expectLivemode` here, at creation, rather than at webhook time — before + /// any money moves. + livemode : Bool; + }; + + public type ParseError = { + /// Not JSON at all, or not an object. + #unparseable; + /// JSON, but a field we require is absent or the wrong type. Names the + /// field so an operator is not left guessing. + #missingField : Text; + }; + + /// Pull the four fields we keep out of a session-create response. + /// + /// Deliberately strict: a session we cannot address (`id`) or hand to the + /// buyer (`url`) is not a partial success, and a missing `expires_at` would + /// leave the order with no deadline at all. + public func parseCreated(body : Blob) : { #ok : Created; #err : ParseError } { + let ?text = body.decodeUtf8() else return #err(#unparseable); + let ?json = Json.parse(text) else return #err(#unparseable); + let ?id = Json.textAt(json, "id") else return #err(#missingField("id")); + let ?url = Json.textAt(json, "url") else return #err(#missingField("url")); + let ?expires = Json.natAt(json, "expires_at") else return #err(#missingField("expires_at")); + // `livemode` is required rather than defaulted: defaulting it either way + // would silently pick a side of the test/live check it exists to drive. + let ?livemode = Json.boolAt(json, "livemode") else return #err(#missingField("livemode")); + #ok({ id; url; expiresAtSeconds = expires; livemode }); + }; + + /// Seconds → nanoseconds for `Order.expiresAtNs`. + /// + /// ⚠️ **The single most likely implementation bug in this plan.** Stripe's + /// `expires_at` is Unix **seconds**; IC time is **nanoseconds**. Store the raw + /// value and every order looks expired since 1970: the open-order cap frees + /// instantly, both reserve detection predicates fire on everything, and the UI + /// shows every order expired. It exists as a named function so the conversion + /// has one home and one test. + public func secondsToNs(seconds : Nat) : Int { + seconds * 1_000_000_000; + }; + + /// How an outcall failed, in terms an operator can act on. + /// + /// The reject messages are the replica's, matched as substrings — the exact + /// strings, not paraphrases. The distinction that matters is **retryable or + /// not**, because there is no retry method here by design: the buyer + /// retries, and an audit line saying "outcall failed" leaves the operator + /// unable to tell a transient subnet hiccup from Stripe being down from a bug + /// in our own transform. + public type FailureKind = { + /// The subnet did not produce a response within 60 s. `SysTransient` — the + /// retryable one. Nothing is known about whether Stripe created the session. + #subnetTimeout; + /// Stripe did not answer within 30 s. `SysFatal`. + #remoteTimeout; + /// ⚠️ **Replicas disagreed, which almost always means OUR transform.** The + /// signature of a header that is not being stripped, or a body field that + /// varies per request. This is the failure the PocketIC suite structurally + /// cannot catch — it mocks outcalls, so a leaky transform passes there and + /// only ever fails against the real API. + #noConsensus; + /// The response, or the transform's output, exceeded `max_response_bytes`. + /// See the table on `maxResponseBytes`: the body message is misleading. + #tooLarge; + /// Attached fewer cycles than required — only reachable by hand-attaching, + /// which this code never does. + #insufficientCycles; + #other; + }; + + /// Classify a reject message. Pure, so the mapping is unit-tested rather than + /// discovered during an outage. + public func classifyFailure(message : Text) : FailureKind { + if (message.contains(#text "Canister http request timed out")) return #subnetTimeout; + if (message.contains(#text "Deadline Exceeded")) return #subnetTimeout; + if (message.contains(#text "Timeout expired")) return #remoteTimeout; + if (message.contains(#text "No consensus could be reached")) return #noConsensus; + if (message.contains(#text "exceeds specified response size limit")) return #tooLarge; + if (message.contains(#text "exceeds size limit of")) return #tooLarge; + if (message.contains(#text "Transformed http response exceeds limit")) return #tooLarge; + if (message.contains(#text "cycles are required")) return #insufficientCycles; + #other; + }; + + /// What an operator should do about it, for the audit line. + public func failureAdvice(kind : FailureKind) : Text { + switch (kind) { + case (#subnetTimeout) "transient: the subnet did not answer in 60 s — retryable"; + case (#remoteTimeout) "Stripe did not answer in 30 s"; + case (#noConsensus) "REPLICAS DISAGREED — almost certainly the transform is leaking a per-request value; no test suite can catch this, check the transform"; + case (#tooLarge) "the response exceeded max_response_bytes (headers count, and the body message understates it) — raise the cap"; + case (#insufficientCycles) "too few cycles attached — Call.httpRequest should make this unreachable"; + case (#other) "unclassified"; + }; + }; + + /// Whether Stripe's response says the session is no longer open. + /// + /// Used by the expire path, where "not open" is a distinguishable outcome + /// rather than a failure: the session either completed or expired already, and + /// we must not guess which from our own clock. + /// + /// ⚠️ **This matches Stripe's error PROSE, so the unit test checks our code + /// against our own assumption of those strings, not against Stripe.** Getting it + /// wrong is safe — a misclassified "not open" degrades to "could not cancel, try + /// again", and the session expires on its own within 35 minutes — but it is an + /// assumption, not a fact. + /// + /// A captured fixture would carry the real expire-on-completed response; pin this against it + /// body then, or switch to matching the structured `error.code` instead of the + /// message, which is what Stripe actually guarantees stable. + public type ExpireOutcome = { + /// Stripe expired the session. + #ok; + /// Stripe understood us and will not expire it: the session is already complete or + /// expired, or there is no such session. All of those mean the same thing to the + /// caller, which is *change nothing and let the webhook decide*. + #notOpen : Text; + /// The key is present but Stripe refuses it. Same diagnosis and same lever as a + /// refused create, which is why `Gate.RailCondition.stripeApiFailing` covers both. + #unauthorized; + /// We do not know. The order must stay payable and uncancelled. + #failed : Text; + }; + + /// Classify Stripe's answer to `POST /v1/checkout/sessions/{id}/expire`. + /// + /// ⚠️ **Keyed on the STATUS, never on the error prose, and matching prose is what + /// broke it.** This function used to look for three hand-written phrases - + /// "No such checkout.session", "in a status of complete", "in a status of expired" - + /// and real Stripe answers a completed session's expire with a 400 whose body + /// contains none of them. So the branch that exists to recognise "already paid" could + /// never fire, and cancelling a paid order reported "could not reach Stripe" while + /// latching `stripeApiFailing`: a P1 saying "rotate the key" for a key that was fine. + /// + /// **And the unit test asserted the INVENTED wording**, so it passed against a + /// string Stripe never sends. A matcher can only be tested against prose if the prose + /// is real; a status code needs no such faith. + /// + /// ⚠️ **400 and 404 specifically, not "4xx that is not 401/403".** Those two are the + /// expire endpoint understanding the request and declining it — no such session, or a + /// session that is no longer open — and every other 4xx must stay `#failed` so the + /// order remains payable. A 429 is the case that makes it matter: a rate limit says + /// nothing about the session, and bucketing it as `#notOpen` would tell a buyer their + /// live order was settled. + /// + /// **A 400 is not proof of the session's state either.** A malformed request from + /// this module would answer 400 as well, and this function cannot separate the two. + /// The body travels in `#notOpen` so a caller that is allowed to log it can record + /// what Stripe actually said, rather than this module guessing the wording in + /// advance — see `Main.expire_order`, and `cancel_order` for why the buyer-facing + /// arm claims no diagnosis. + public func expireOutcome(status : Nat, body : Blob) : ExpireOutcome { + if (status == 200) return #ok; + if (status == 401 or status == 403) return #unauthorized; + if (status == 400 or status == 404) { + let detail = switch (body.decodeUtf8()) { + // The repo's truncation idiom, same as `Orphans.truncateClaimedRef`. + case (?text) if (text.size() > 300) { + Text.fromIter(text.chars().take(300)) # "…(truncated)"; + } else { text }; + case (null) "non-UTF-8 body"; + }; + return #notOpen(detail); + }; + #failed("Stripe answered " # status.toText()); + }; + + + /// `GET /v1/checkout/sessions/{id}` — the read the recovery sweep uses to settle a + /// `#created` order whose expiry event never arrived. + /// + /// ⚠️ **The id is Stripe's, never a caller's.** It is stamped into + /// `Order.stripeSessionId` from the create response and read back from our own store, + /// so nothing attacker-controlled reaches this URL. Said explicitly because a value + /// interpolated into a request URL is exactly what a reviewer should check. + public func retrieveUrl(sessionId : Text) : Text { + createUrl # "/" # sessionId; + }; + + /// Cap for the retrieve, **larger than `maxResponseBytes`** and not a copy of it. + /// + /// Two reasons it must be bigger than the create response's cap. A *completed* session + /// carries fields a freshly created one does not — `customer_details`, a resolved + /// `payment_intent`, `total_details` — and the limit is enforced on the **raw** + /// response, so Stripe's ~1–2 KB of response headers count against it before a byte of + /// body arrives. `strip` cannot rescue an over-cap response: that check runs first. + /// + /// ⚠️ **The failure is total, not truncation** — an over-cap response fails the call + /// every time — so this is sized with margin rather than measured to the byte. A + /// fixture capture turns `cap >= 2x measured` into a check instead of an estimate. + public let retrieveMaxResponseBytes : Nat64 = 32_768; + + /// What Stripe says about a session, reduced to the only distinction the sweep acts on. + public type Status = { + /// Still payable. The sweep does nothing: our clock decided when to ask, and Stripe + /// says the deadline has not actually passed. + #open; + /// Complete **and paid**. The buyer's money is in and we never credited it. + /// + /// Carries the `payment_intent`, because **this is the only place it can be learned** + /// — and the compiler proved it. A first draft read the intent off the order; the + /// order never reached `#paid`, so nothing ever indexed the payment against it, and + /// `paidIntents` is written *by* crediting. The retrieve is the sole source. It is + /// what an operator looks up in Stripe and what a filed obligation records, so a + /// complete+paid response without one classifies as `#unknown` rather than becoming + /// an obligation nobody can reconcile. + #completePaid : { paymentIntent : Text }; + /// Stripe expired it. Nobody can pay this session, so the order's promise is holding + /// capacity against a sale that can never happen — the leak the sweep exists to close. + #expired; + /// Anything we do not recognise, including a body we cannot parse. Carries the text + /// for the audit line. + #unknown : Text; + }; + + /// Classify a retrieve response. + /// + /// ⚠️ **`#unknown` is the safe answer and every unrecognised shape lands there.** The + /// sweep treats it exactly like `#open` — do nothing, ask again — so a Stripe response + /// change makes this feature inert rather than wrong. A classifier that guessed would + /// be guessing about whether a buyer has been paid. + /// + /// ⚠️ **`complete` is split on `payment_status`, and that is not defensive padding.** + /// A session can be `complete` with `payment_status = "unpaid"`: the buyer finished + /// checkout with a delayed-notification method (SEPA, ACH, boleto), the session closed, + /// and the money settles days later. **Our sessions cannot reach it** — `createBody` + /// pins `payment_method_types[]=card` and cards settle synchronously — but folding it + /// into the paid case would make the sweep file an operator-facing obligation claiming + /// a buyer paid when nobody has. So it classifies as `#unknown`: silence, which is + /// correct, because `checkout.session.async_payment_succeeded`/`_failed` already own + /// that lifecycle if the card pin is ever removed. + public func classify(body : Blob) : Status { + let ?text = body.decodeUtf8() else return #unknown("response body is not UTF-8"); + let ?json = Json.parse(text) else return #unknown("response body is not JSON"); + let ?status = Json.textAt(json, "status") else return #unknown("no status field"); + switch (status) { + case ("open") #open; + case ("expired") #expired; + case ("complete") { + switch (Json.textAt(json, "payment_status")) { + case (?"paid") { + // The intent is required rather than optional: an obligation that cannot + // name the payment is one an operator cannot reconcile, and a complete+paid + // session without one would mean Stripe changed shape — which is exactly + // what `#unknown` is for. + switch (Json.textAt(json, "payment_intent")) { + case (?intent) #completePaid({ paymentIntent = intent }); + case null #unknown("complete and paid with no payment_intent"); + }; + }; + case (?other) #unknown("complete but payment_status=" # other); + case null #unknown("complete with no payment_status"); + }; + }; + case (other) #unknown("unrecognised status=" # other); + }; + }; + +}; diff --git a/src/frontend/index.html b/src/frontend/index.html index b207dfe..1dfcdb0 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -3,126 +3,1111 @@ - CyclePay — cycles, on-chain + From zero to deployed on the Internet Computer + + + + + + + -
-
-

CyclePay

-

Card or ck-USDC → cycles, verifiably on-chain

+
- + +
+ + + + + + +
+ +
+ +
+
+ +

From zero to deployed on the Internet Computer.

+

+ Your canisters live on mainnet in a few minutes, paid for with a credit card. +

+ +
+ +

+ At the protocol exchange rate, plus card processing. +

+
-

+ + +
    +
  1. + 1 + + Sign in + Google, Apple, or Microsoft + +
  2. +
  3. + 2 + + Pay with credit card + No exchange account needed + +
  4. +
  5. + 3 + + Link the CLI + icp identity link web + +
  6. +
  7. + 4 + + Deploy + icp deploy -e ic + +
  8. +
-
-
-
Rail
-
Cycles
-
Price
-
Destination
- -
+

Select a row to see the order and its receipt.

+ + + + + + + + + -