From 3bac2db3133b101586ef38a31e240457a807f2fd Mon Sep 17 00:00:00 2001 From: Deleo <152405392+Greaker@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:54:38 +0200 Subject: [PATCH 01/18] Dependabot rollup: zod, typescript-eslint, plugin-react, RTL (#291) Land four open Dependabot bumps as one rollup with the inventory rows the bot cannot write: zod 4.5.4 -> 4.6.5 and typescript-eslint 8.69.0 -> 8.70.1 in nb-bond-api, @vitejs/plugin-react 6.0.3 -> 6.1.1 and @testing-library/react 16.3.2 -> 16.3.3 in nb-ui. typescript-eslint goes to 8.70.1 rather than the 8.70.0 the bot proposed. The lockfile changes 16 entries and adds or removes none: the four packages, the ten exact-pinned @typescript-eslint/* siblings, and the two workspace entries with exact ranges. Incidental movements from the scratch resolve (ignore 7.0.10, a plugin-react hoist, and removals caused by legacy peer resolution) are left out. Root overrides still resolve at their pins. No source changes were needed: lint adds no findings, openapi.json regenerates byte-identical, nb-bond-api 249 jest and nb-ui 125 vitest tests pass, and both builds pass. The better-sqlite3 13 major stays out for its own PR. The plan folder is archived in this change. --- THIRD_PARTY_LICENSES.md | 8 +- docs/DOCUMENTATION_INDEX.md | 1 + .../dependabot-rollup-2026-09-25/design.md | 82 ++++++++++ .../dependabot-rollup-2026-09-25/intent.md | 71 ++++++++ .../dependabot-rollup-2026-09-25/plan.md | 68 ++++++++ .../dependabot-rollup-2026-09-25/progress.md | 37 +++++ package-lock.json | 152 +++++++++--------- services/nb-bond-api/package.json | 4 +- services/nb-ui/package.json | 4 +- 9 files changed, 345 insertions(+), 82 deletions(-) create mode 100644 docs/plans/archive/dependabot-rollup-2026-09-25/design.md create mode 100644 docs/plans/archive/dependabot-rollup-2026-09-25/intent.md create mode 100644 docs/plans/archive/dependabot-rollup-2026-09-25/plan.md create mode 100644 docs/plans/archive/dependabot-rollup-2026-09-25/progress.md diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index 5f49064..6f78957 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -61,7 +61,7 @@ License labels and deployment-time notes remain curated review items. | `helmet` | `8.3.0` | MIT | | `jose` | `6.2.8` | MIT | | `winston` | `3.19.0` | MIT | -| `zod` | `4.5.4` | MIT | +| `zod` | `4.6.5` | MIT | | `zod-openapi` | `6.0.1` | MIT | | `@babel/core` | `7.29.7` | MIT | | `@babel/preset-env` | `7.29.5` | MIT | @@ -79,7 +79,7 @@ License labels and deployment-time notes remain curated review items. | `prettier` | `3.9.6` | MIT | | `tsx` | `4.23.11` | MIT | | `typescript` | `7.0.2` | Apache-2.0 | -| `typescript-eslint` | `8.69.0` | MIT | +| `typescript-eslint` | `8.70.1` | MIT | ### `scripts/bid-encryption` @@ -109,9 +109,9 @@ License labels and deployment-time notes remain curated review items. | `react` | `19.2.8` | MIT | | `react-dom` | `19.2.8` | MIT | | `@testing-library/jest-dom` | `7.0.1` | MIT | -| `@testing-library/react` | `16.3.2` | MIT | +| `@testing-library/react` | `16.3.3` | MIT | | `@testing-library/user-event` | `14.6.1` | MIT | -| `@vitejs/plugin-react` | `6.0.3` | MIT | +| `@vitejs/plugin-react` | `6.1.1` | MIT | | `eslint` | `9.39.5` | MIT | | `eslint-config-prettier` | `10.1.8` | MIT | | `eslint-plugin-react` | `7.37.5` | MIT | diff --git a/docs/DOCUMENTATION_INDEX.md b/docs/DOCUMENTATION_INDEX.md index a023d2e..8f51898 100644 --- a/docs/DOCUMENTATION_INDEX.md +++ b/docs/DOCUMENTATION_INDEX.md @@ -27,6 +27,7 @@ This file lists the documentation that is most likely to need updates when behav - `docs/plans/sandbox-entity-directory-and-name-service-plan.md`: Standalone sandbox entity-directory/name-service plan with its own persistent database, backend API, search/admin UI, synthetic profile/contact enrichment, optional provenance-aware on-chain registry readers, and a BENS-compatible Blockscout adapter backed by the same directory data. - `docs/plans/erc-3643-incremental-adoption-plan.md`: **Superseded in direction by `docs/decisions/0002-adopt-erc-3643-for-tokenized-securities.md`** (full migration to canonical ERC-3643 for bonds + equities, retiring the partitioned `BondToken` and the ERC-20 `StockToken`). Retained as the record of the *incremental* approach considered and rejected — a shared external identity registry + modular compliance bolted onto the existing partitioned `BondToken` and cash (`Wnok`), closing the bond transfer-eligibility gap first, then agent powers, compliance modules, and full ONCHAINID. Its eligibility-gap analysis and phase mechanics remain a useful reference. - `docs/plans/archive/`: Reference-only archive of completed implementation plans. Status lines inside each plan record the implementing PR and its state where relevant: + - `docs/plans/archive/dependabot-rollup-2026-09-25/`: Plan folder that landed four Dependabot bumps (zod 4.6.5, typescript-eslint 8.70.1 with its siblings, `@vitejs/plugin-react` 6.1.1, `@testing-library/react` 16.3.3) as one rollup with the inventory rows and a classified lockfile transplant; OpenAPI output unchanged, and the `better-sqlite3` 13 major left for its own PR. Shipped with the change itself. - `docs/plans/archive/dependabot-rollup-2026-09/`: Plan folder that landed the five open non-security Dependabot bumps (typescript-eslint 8.69.0 with its siblings, `@types/node` 26.5.1 with the Node types pin, `@testing-library/jest-dom` 7.0.1, zod 4.5.4, prettier 3.9.6) as one rollup with the inventory rows and a classified lockfile transplant; records the OpenAPI nullable-shape change from zod and the pre-existing typescript-eslint peer gap. Shipped with the change itself. - `docs/plans/archive/dependabot-security-rollup-2026-09/`: Plan folder that closed Dependabot alerts #78–#81 with one lockfile change (root `js-yaml` override to 4.3.2; nb-ui `vitest` and its `@vitest/*` siblings to 4.1.11), transplanted with a structural diff and override audit; records the constructed `js-yaml` entry and the excluded incidental `tinyrainbow` bump. Shipped via #281. - `docs/plans/archive/coupon-closure-review-fixes/`: Plan folder that fixed the review findings on the wNOK cash-leg and maturity-closure work: integer coupon preview and BigInt reserve check in the UI, `withdrawFailedIssuance` removed, zero-supply bonds closable through the coupon route, duplicate holders rejected on-chain and in the body, a per-period `CouponPeriodPaid` event driving the projected count, a `BondManager` compatibility flag in health, and two replay fixes the fresh-sandbox run exposed. Shipped via #278. diff --git a/docs/plans/archive/dependabot-rollup-2026-09-25/design.md b/docs/plans/archive/dependabot-rollup-2026-09-25/design.md new file mode 100644 index 0000000..5317e6c --- /dev/null +++ b/docs/plans/archive/dependabot-rollup-2026-09-25/design.md @@ -0,0 +1,82 @@ +# Dependabot rollup (late September 2026) — Design + +**Status:** Approved (2026-09-25) +**Created:** 2026-09-25 +**Intent:** [`intent.md`](intent.md) +**Builds on:** `docs/plans/archive/dependabot-rollup-2026-09/` (transplant discipline, +classification of moved entries, legacy peer handling) and +`docs/plans/archive/dependabot-security-rollup-2026-09/` (override audit). + +## Decision Summary + +Apply the four bumps in the manifests, resolve once in a scratch copy, and transplant into the +tracked lockfile the bumped packages plus the exact-pinned siblings their declared ranges +require, rejecting any other movement. Update the four inventory rows in the same commit, then +let the tool changes prove themselves: typescript-eslint lint, zod OpenAPI regeneration, and the +nb-ui suite and build on the new React plugin and Testing Library. + +## Current-State Evidence + +| Finding | Evidence | Consequence | +|---|---|---| +| **Verified.** All four packages are exact pins in their workspaces and listed in the inventory at those versions | workspace `package.json` files, `THIRD_PARTY_LICENSES.md` | Four inventory rows change | +| **Verified.** All four bot PRs pass lint, format, tests and the other checks and fail only `validate-inventory` | PR checks on #287–#290 | The code is compatible; the missing edit is the inventory | +| **Verified.** The bot branches write `^x.y.z` into the lockfile's workspace entries while the manifests stay exact | PR diffs | The rollup writes exact ranges, as the tracked lockfile has today | +| **Verified.** `zod-openapi` 6.0.1 peers `zod ^4.0.0`; the OpenAPI document is generated from the zod schemas | registry metadata, `services/nb-bond-api/package.json` | Regenerate `openapi.json`; any diff is reviewed, not assumed | +| **Verified.** `typescript-eslint` pins its `@typescript-eslint/*` family exactly | registry metadata, lockfile | Sibling lifts are required, not incidental | +| **Verified.** `@typescript-eslint/eslint-plugin` 8.70.1 keeps `ignore ^7.0.5`; the bot branch lifts `ignore` 7.0.6 → 7.0.9 | registry metadata, #289 diff | Incidental; excluded, as in the previous rollup | +| **Verified.** `typescript-eslint` 8.70.1 still peers `typescript <6.1.0`; nb-bond-api pins 7.0.2 | registry metadata | Existing gap; the scratch resolve needs `--legacy-peer-deps` and its removals are ignored | +| **Verified.** `@vitejs/plugin-react` 6.1.1 keeps its single dependency (`@rolldown/pluginutils ^1.0.1`) and adds `oxc-transform-react ^0.145.0` as an optional peer; engines Node `^20.19.0 \|\| >=22.12.0` | registry metadata, #287 diff | No new package; the nb-ui build and suite are the behaviour gate | +| **Verified.** `@testing-library/react` 16.3.3 keeps its dependency and peer ranges | registry metadata | Patch-level; the nb-ui suite is the gate | +| **Verified.** No open Dependabot security alerts | GitHub | Nothing to fold in | + +## Target State + +- Manifests: `zod 4.6.5` and `typescript-eslint 8.70.1` in nb-bond-api; + `@vitejs/plugin-react 6.1.1` and `@testing-library/react 16.3.3` in nb-ui. +- Lockfile: those entries plus the required `@typescript-eslint/*` siblings; workspace ranges + exact; overrides untouched. +- Inventory: four rows. +- Any lint fixes or `openapi.json` regeneration that the new versions require, each called out + in the PR body. + +## Procedure + +1. Snapshot the tracked lockfile; edit the manifests. +2. Scratch copy of the manifests and lockfile; + `npm install --package-lock-only --ignore-scripts --legacy-peer-deps`. +3. Structural diff of the scratch resolution. Classify each moved entry as: a bumped package; an + exact-pinned sibling required by a bumped package's declared range; or incidental. Transplant + the first two classes only, and set the workspace ranges exact. +4. Override audit; `npm ci`. +5. nb-bond-api gate; if `typescript-eslint` 8.70.1 adds findings, fix the code minimally or + suppress per the repo's narrow-suppression rule, and say which. +6. `npm run regen:openapi`; diff `openapi.json`; explain or accept. +7. nb-ui gate on the new React plugin and Testing Library. +8. The licence, Node-version, hygiene and link checks. + +## Alternatives Considered + +- **Merge the bot PRs individually after pushing inventory commits to each.** Four PRs, four + one-line edits, and the caret ranges in the lockfile would still need correcting; a human + commit also stops Dependabot from rebasing that branch. The rollup is one reviewable diff. +- **Include #286 (`better-sqlite3` 13).** It adds a package, which needs its own approval, and it + changes how the native binary reaches the API image, which needs a live image check. Mixing it + in would hold the four low-risk bumps behind that work. +- **Keep `typescript-eslint` at the PR's 8.70.0.** It has longer soak time, but Dependabot would + retarget #289 to 8.70.1 at its next run. The operator chose 8.70.1, a fix-only patch release + with the same dependency and peer ranges. + +## Decisions + +| Decision | Recommendation | Operator action | +|---|---|---| +| `typescript-eslint` version | 8.70.1 | Decided at approval (2026-09-25) | +| Release afterwards | None (tooling and one in-range runtime bump covered by tests) | Say if a tag is wanted | +| Dependabot PRs | Close #287–#290 as superseded after merge, with a comment naming the rollup; leave #286 open | None | + +## Residual Risks + +- `zod` 4.6.5 could change generated OpenAPI text; the regen diff is the control. +- `typescript-eslint` 8.70.1 could report new findings; the lint gate is the control, and any + fix is listed. diff --git a/docs/plans/archive/dependabot-rollup-2026-09-25/intent.md b/docs/plans/archive/dependabot-rollup-2026-09-25/intent.md new file mode 100644 index 0000000..d317500 --- /dev/null +++ b/docs/plans/archive/dependabot-rollup-2026-09-25/intent.md @@ -0,0 +1,71 @@ +# Dependabot rollup (late September 2026) — Intent + +**Status:** Approved (2026-09-25) +**Created:** 2026-09-25 +**Requested by:** sandbox operator + +## Outcome + +Four of the five open Dependabot pull requests are landed as one reviewable rollup that also +carries the inventory rows Dependabot cannot write, so the checks pass and the four bot PRs are +closed as superseded. + +## Why This Change + +Dependabot opened #286–#290 on 2026-09-20. Every one fails `validate-inventory` because +`THIRD_PARTY_LICENSES.md` must list each direct dependency at its exact lockfile version and the +bot never edits it. The bot branches also write caret ranges into the lockfile's workspace +entries while the manifests stay exact-pinned. The other checks pass on all five. None is +security-related; this is housekeeping. + +| PR | Package | From | To | Manifest | +|---|---|---|---|---| +| #290 | `zod` | 4.5.4 | 4.6.5 | nb-bond-api (runtime dependency) | +| #289 | `typescript-eslint` | 8.69.0 | 8.70.1 (PR proposed 8.70.0) | nb-bond-api (dev) | +| #287 | `@vitejs/plugin-react` | 6.0.3 | 6.1.1 | nb-ui (dev) | +| #288 | `@testing-library/react` | 16.3.2 | 16.3.3 | nb-ui (dev) | + +## Scope + +### In Scope + +- The four version bumps in their workspace manifests, exact pins as today. +- `package-lock.json`: the bumped packages plus the exact-pinned siblings their declared ranges + require (the `@typescript-eslint/*` family), and the four workspace ranges written exact; + incidental movements excluded. +- `THIRD_PARTY_LICENSES.md`: four rows. +- Any lint finding `typescript-eslint` 8.70.1 adds, and a regenerated `openapi.json` if `zod` + 4.6.5 changes the emitted document. +- Closing #287–#290 after merge. + +### Out of Scope + +- #286, `better-sqlite3` 12.11.1 → 13.0.3. It is a major bump of the API's native database + driver that adds a new package (`node-addon-api`) and changes how the prebuilt binary is + delivered; it needs its own approval, a live image check, and its own PR. +- New packages: none. `@vitejs/plugin-react` 6.1.1 declares one more optional peer + (`oxc-transform-react`); nothing is installed for it. +- Runtime image or release: `zod` is the only runtime dependency and the API's behaviour is gated + by its tests; no release is required by this rollup. + +## Acceptance Criteria + +| Criterion | Risk addressed | Verification evidence | +|---|---|---| +| Structural lockfile diff contains only the four packages and their required siblings, nothing added or removed beyond them | Incidental churn | before/after diff listing, each entry classified | +| Workspace ranges in the lockfile stay exact | Caret drift the bot branches introduce | lockfile diff | +| Every root override still resolves at its pin | Un-applied overrides on install | override audit | +| `npm ci` succeeds; the licence, Node-version, hygiene and link checks pass | Inconsistent lockfile or inventory drift | script output | +| nb-bond-api and nb-ui gates pass; `openapi.json` unchanged or its diff explained | Behaviour change from zod, lint, the React plugin or Testing Library | package gates, regen diff | +| The four Dependabot PRs are closed with a pointer to the rollup | Stale bot PRs | GitHub | + +## Constraints + +- No new dependency; all four packages stay MIT. +- One PR against `development`; public-repo rules. + +## Resolved Questions + +- `typescript-eslint` 8.70.1 was published on 2026-09-21, after #289 opened for 8.70.0. The + operator chose 8.70.1 at approval (2026-09-25), so Dependabot can close #289 instead of + retargeting it. diff --git a/docs/plans/archive/dependabot-rollup-2026-09-25/plan.md b/docs/plans/archive/dependabot-rollup-2026-09-25/plan.md new file mode 100644 index 0000000..6693948 --- /dev/null +++ b/docs/plans/archive/dependabot-rollup-2026-09-25/plan.md @@ -0,0 +1,68 @@ +# Dependabot rollup (late September 2026) — Implementation Plan + +**Status:** Implemented — shipped in the same PR as the change; archived 2026-09-25 +**Created:** 2026-09-25 +**Scope:** `services/nb-bond-api/package.json`, `services/nb-ui/package.json`, `package-lock.json`, `THIRD_PARTY_LICENSES.md`, plus any lint-fixed files and `services/nb-bond-api/openapi.json` if regeneration changes it +**Intent:** [`intent.md`](intent.md) · **Design:** [`design.md`](design.md) · **Progress:** [`progress.md`](progress.md) + +## Plan Order + +```text +Phase 1 Manifests, lockfile transplant, override audit, inventory +Phase 2 Tool consequences: lint, openapi regen, test suites, builds +Phase 3 PR, merge, close #287-#290 +``` + +One PR, one commit (plus a fix-up only if CI disagrees with the local gate). The plan folder is +archived inside the same PR. + +## Phase 1: Manifests and lockfile + +1. Record the baseline: `npm ci`, then the nb-bond-api and nb-ui test counts on the unchanged + tree. +2. `git show HEAD:package-lock.json > /lock.before.json`. +3. Bump the four packages in their manifests. +4. Scratch resolve; structural diff; classify moved entries (bumped, required sibling, + incidental); transplant bumped plus required siblings only; workspace ranges exact. +5. Override audit (every root override at its pin, nested `brace-expansion` copies unchanged). +6. Inventory: four rows. + +Exit: diff listing recorded in `progress.md` with each entry's class. + +## Phase 2: Tool consequences + +1. `npm ci`. +2. `cd services/nb-bond-api && npm run lint && npm run format:check && npm test && npm run build`; + fix or narrowly suppress new lint findings. +3. `cd services/nb-bond-api && npm run regen:openapi`; `git diff openapi.json`; keep and + explain, or confirm empty. +4. `cd services/nb-ui && npm run format:check && npm run lint && npm test && npm run build`. +5. `python3 scripts/verification/check-third-party-licenses.py`, + `check-node-version-consistency.py`, `check-public-repo-hygiene.py`, `check-markdown-links.py`. + +Exit: all green; test counts equal the Phase 1 baseline or the change is explained. + +## Phase 3: PR and cleanup + +1. Archive this folder to `docs/plans/archive/` and update `docs/DOCUMENTATION_INDEX.md` in the + same commit. +2. PR against `development`: + `Dependabot rollup: zod 4.6.5, typescript-eslint, plugin-react, RTL` with the bump table, + the lockfile classification, and any lint or OpenAPI notes. +3. After merge: close #287–#290 with a comment pointing at the rollup. #286 stays open. + +## Test Matrix + +| Layer | Risk | Evidence | +|---|---|---| +| Lockfile | Incidental churn, caret ranges, un-applied overrides | structural diff with classification, override audit | +| Lint | typescript-eslint 8.70.1 rule changes | nb-bond-api lint | +| API contract | zod 4.6.5 emitted schema and validation | `regen:openapi` diff, jest | +| UI build and tests | plugin-react 6.1.1 transform, Testing Library 16.3.3 | nb-ui build and vitest suite | +| Inventory | Version drift | licence checker | + +## Done Criteria + +- [x] Four bumps landed; only required siblings moved; overrides intact. +- [x] All gates and verification scripts pass; consequences documented. +- [ ] #287–#290 closed as superseded; #286 left for its own PR. diff --git a/docs/plans/archive/dependabot-rollup-2026-09-25/progress.md b/docs/plans/archive/dependabot-rollup-2026-09-25/progress.md new file mode 100644 index 0000000..269adae --- /dev/null +++ b/docs/plans/archive/dependabot-rollup-2026-09-25/progress.md @@ -0,0 +1,37 @@ +# Dependabot rollup (late September 2026) — Progress + +**Plan:** [`plan.md`](plan.md) +**Last updated:** 2026-09-25 — Phases 1 and 2 done; PR opened with the plan archived in it +**Current phase:** Phase 3: PR, then close #287–#290 after merge +**Next action:** After merge, close Dependabot PRs #287–#290 as superseded; #286 stays open for its own PR + +## Phase Log + +| Phase | Status | Evidence | PR | +|---|---|---|---| +| 1 — Manifests and lockfile | Done | Baseline on the unchanged tree: nb-bond-api 249 jest tests, nb-ui 125 vitest tests. Four manifest pins bumped. Lockfile changed 16 entries, none added or removed: the four packages (`@vitejs/plugin-react` updated in place at its nested `services/nb-ui/node_modules/` path), ten `@typescript-eslint/*` siblings (exact pins of the bumped family), and the two workspace entries with exact ranges. Excluded as incidental: `ignore` 7.0.6→7.0.10 (`^7.0.5` unchanged), the scratch resolve hoisting `@vitejs/plugin-react` to the root, and seven removals caused by legacy peer resolution (`@testing-library/dom`, `@types/aria-query`, `dom-accessibility-api`, `lz-string`, `pretty-format` with its nested `ansi-styles`, `react-is` 17, `typescript` 6.0.3). Scalar overrides at their pins, five nested `brace-expansion` copies unchanged. `npm ci` installs 861 packages, as before. Four inventory rows; licence and Node-version checks pass (2026-09-25) | this PR | +| 2 — Tool consequences | Done | `npm ci` installs the new versions. nb-bond-api: `typescript-eslint` 8.70.1 added no lint findings; prettier clean; 249 jest tests pass (baseline 249); `tsc` build passes; `regen:openapi` on zod 4.6.5 rewrites `openapi.json` byte-identical. nb-ui: prettier and lint clean; 125 vitest tests pass on `@testing-library/react` 16.3.3 (baseline 125); Vite build passes on `@vitejs/plugin-react` 6.1.1. Licence, Node-version, hygiene, and link checks pass. No source file changed (2026-09-25) | this PR | +| 3 — PR and cleanup | In progress | Plan folder archived in the rollup PR itself; bot PRs closed after merge | this PR | + +## Deviations From the Plan + +- `typescript-eslint` 8.70.1 instead of the 8.70.0 that #289 proposed, chosen by the operator at + approval. +- Archived in the same PR as the change instead of a separate document-move PR. +- Local checks run on Node 25.8.0 (CI uses the pinned 26.5.0). `npm ci` warns that `jsdom` + 30.0.1 does not list Node 25; that warning predates this change. + +## Verified So Far + +- Bump list, bot-PR check results, workspace pins, inventory rows, `zod-openapi`'s peer range, + the `@typescript-eslint/*` exact pins, the incidental `ignore` lift, the new optional peer of + `@vitejs/plugin-react` 6.1.1, and the absence of open security alerts verified on 2026-09-25 + (see `design.md`). + +## Follow-ups Found Along the Way + +- #286 (`better-sqlite3` 13.0.3) needs its own PR: approval for the new `node-addon-api` + package, and a live check that the API image loads the bundled prebuilt binary and that a + projection resync works. +- `typescript-eslint` 8.x still peers `typescript <6.1.0` against the repo's 7.0.2, recorded by + the previous rollup and unchanged here. diff --git a/package-lock.json b/package-lock.json index 716191c..d01e0da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3733,9 +3733,9 @@ "license": "MIT" }, "node_modules/@testing-library/react": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", - "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", "dev": true, "license": "MIT", "dependencies": { @@ -4093,17 +4093,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.69.0.tgz", - "integrity": "sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.1.tgz", + "integrity": "sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.69.0", - "@typescript-eslint/type-utils": "8.69.0", - "@typescript-eslint/utils": "8.69.0", - "@typescript-eslint/visitor-keys": "8.69.0", + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/type-utils": "8.70.1", + "@typescript-eslint/utils": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -4116,7 +4116,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.69.0", + "@typescript-eslint/parser": "^8.70.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -4132,16 +4132,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.69.0.tgz", - "integrity": "sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.1.tgz", + "integrity": "sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.69.0", - "@typescript-eslint/types": "8.69.0", - "@typescript-eslint/typescript-estree": "8.69.0", - "@typescript-eslint/visitor-keys": "8.69.0", + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", "debug": "^4.4.3" }, "engines": { @@ -4157,14 +4157,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.69.0.tgz", - "integrity": "sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.1.tgz", + "integrity": "sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.69.0", - "@typescript-eslint/types": "^8.69.0", + "@typescript-eslint/tsconfig-utils": "^8.70.1", + "@typescript-eslint/types": "^8.70.1", "debug": "^4.4.3" }, "engines": { @@ -4179,14 +4179,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.69.0.tgz", - "integrity": "sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.1.tgz", + "integrity": "sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.69.0", - "@typescript-eslint/visitor-keys": "8.69.0" + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4197,9 +4197,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.69.0.tgz", - "integrity": "sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.1.tgz", + "integrity": "sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==", "dev": true, "license": "MIT", "engines": { @@ -4214,15 +4214,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.69.0.tgz", - "integrity": "sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.1.tgz", + "integrity": "sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.69.0", - "@typescript-eslint/typescript-estree": "8.69.0", - "@typescript-eslint/utils": "8.69.0", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/utils": "8.70.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -4239,9 +4239,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.69.0.tgz", - "integrity": "sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.1.tgz", + "integrity": "sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==", "dev": true, "license": "MIT", "engines": { @@ -4253,16 +4253,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.69.0.tgz", - "integrity": "sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.1.tgz", + "integrity": "sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.69.0", - "@typescript-eslint/tsconfig-utils": "8.69.0", - "@typescript-eslint/types": "8.69.0", - "@typescript-eslint/visitor-keys": "8.69.0", + "@typescript-eslint/project-service": "8.70.1", + "@typescript-eslint/tsconfig-utils": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -4294,16 +4294,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.69.0.tgz", - "integrity": "sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.1.tgz", + "integrity": "sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.69.0", - "@typescript-eslint/types": "8.69.0", - "@typescript-eslint/typescript-estree": "8.69.0" + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4318,13 +4318,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.69.0.tgz", - "integrity": "sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.1.tgz", + "integrity": "sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/types": "8.70.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -12745,16 +12745,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.69.0.tgz", - "integrity": "sha512-B3MltX0VqjUBNEe3b3sSuiRbfa6XrfHFtBiPamjT5AsW/dfq+y+bc0wyuS9DxAS1LyzCxRp2+rxzpLUvqM2BvA==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.1.tgz", + "integrity": "sha512-AcWG7KDjZ2THNXsgwttMaGmzVi0VFRlFYfqFHYQRbDpF3owuYbuiL8c7UUrd2k8s3PoSfIQrWfrGXfcElrWLYA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.69.0", - "@typescript-eslint/parser": "8.69.0", - "@typescript-eslint/typescript-estree": "8.69.0", - "@typescript-eslint/utils": "8.69.0" + "@typescript-eslint/eslint-plugin": "8.70.1", + "@typescript-eslint/parser": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/utils": "8.70.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -13616,9 +13616,9 @@ } }, "node_modules/zod": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", - "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -13759,7 +13759,7 @@ "helmet": "8.3.0", "jose": "6.2.8", "winston": "3.19.0", - "zod": "4.5.4", + "zod": "4.6.5", "zod-openapi": "6.0.1" }, "devDependencies": { @@ -13779,7 +13779,7 @@ "prettier": "3.9.6", "tsx": "4.23.11", "typescript": "7.0.2", - "typescript-eslint": "8.69.0" + "typescript-eslint": "8.70.1" } }, "services/nb-bond-api/node_modules/typescript": { @@ -13826,9 +13826,9 @@ }, "devDependencies": { "@testing-library/jest-dom": "7.0.1", - "@testing-library/react": "16.3.2", + "@testing-library/react": "16.3.3", "@testing-library/user-event": "14.6.1", - "@vitejs/plugin-react": "6.0.3", + "@vitejs/plugin-react": "6.1.1", "eslint": "9.39.5", "eslint-config-prettier": "10.1.8", "eslint-plugin-react": "7.37.5", @@ -13919,9 +13919,9 @@ } }, "services/nb-ui/node_modules/@vitejs/plugin-react": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", - "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", "dev": true, "license": "MIT", "dependencies": { @@ -13933,6 +13933,7 @@ "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "peerDependenciesMeta": { @@ -13941,6 +13942,9 @@ }, "babel-plugin-react-compiler": { "optional": true + }, + "oxc-transform-react": { + "optional": true } } }, diff --git a/services/nb-bond-api/package.json b/services/nb-bond-api/package.json index c65ab4f..8b17f3a 100644 --- a/services/nb-bond-api/package.json +++ b/services/nb-bond-api/package.json @@ -27,7 +27,7 @@ "helmet": "8.3.0", "jose": "6.2.8", "winston": "3.19.0", - "zod": "4.5.4", + "zod": "4.6.5", "zod-openapi": "6.0.1" }, "devDependencies": { @@ -47,6 +47,6 @@ "prettier": "3.9.6", "tsx": "4.23.11", "typescript": "7.0.2", - "typescript-eslint": "8.69.0" + "typescript-eslint": "8.70.1" } } diff --git a/services/nb-ui/package.json b/services/nb-ui/package.json index 4d8beaa..5202665 100644 --- a/services/nb-ui/package.json +++ b/services/nb-ui/package.json @@ -21,9 +21,9 @@ }, "devDependencies": { "@testing-library/jest-dom": "7.0.1", - "@testing-library/react": "16.3.2", + "@testing-library/react": "16.3.3", "@testing-library/user-event": "14.6.1", - "@vitejs/plugin-react": "6.0.3", + "@vitejs/plugin-react": "6.1.1", "eslint": "9.39.5", "eslint-config-prettier": "10.1.8", "eslint-plugin-react": "7.37.5", From 3b8cba7dbab34cc2b3d7e97204baccb459664d90 Mon Sep 17 00:00:00 2001 From: Deleo <152405392+Greaker@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:31:25 +0200 Subject: [PATCH 02/18] Upgrade better-sqlite3 to 13.0.3 (#292) Move the NB Bond API's SQLite driver from 12.11.1 to 13.0.3, the version Dependabot proposed. Version 13 is a Node-API addon: prebuilt binaries now ship inside the npm package, so prebuild-install and its build-time download are gone. The bundled SQLite moves from 3.53.2 to 3.53.4, which keeps the on-disk format. The lockfile changes better-sqlite3, adds node-addon-api 8.9.2 (MIT), removes the 29 entries only prebuild-install and bindings needed, and marks detect-libc dev-only; the workspace range stays exact. Root overrides still resolve at their pins. The inventory row is updated. No source change was needed: nb-bond-api 249 jest tests and nb-ui 125 vitest tests pass, and both builds pass. On the local sandbox the rebuilt image loads prebuilds/linux-arm64.node without compiling, and a resync from block 0 reproduces every table count and API response of the 12.11.1 baseline, including the system-of-record tables. The plan folder is archived in this change. --- THIRD_PARTY_LICENSES.md | 2 +- docs/DOCUMENTATION_INDEX.md | 1 + .../better-sqlite3-13-upgrade/design.md | 90 +++++ .../better-sqlite3-13-upgrade/intent.md | 72 ++++ .../archive/better-sqlite3-13-upgrade/plan.md | 94 +++++ .../better-sqlite3-13-upgrade/progress.md | 41 ++ package-lock.json | 375 +----------------- services/nb-bond-api/package.json | 2 +- 8 files changed, 312 insertions(+), 365 deletions(-) create mode 100644 docs/plans/archive/better-sqlite3-13-upgrade/design.md create mode 100644 docs/plans/archive/better-sqlite3-13-upgrade/intent.md create mode 100644 docs/plans/archive/better-sqlite3-13-upgrade/plan.md create mode 100644 docs/plans/archive/better-sqlite3-13-upgrade/progress.md diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index 6f78957..8529446 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -52,7 +52,7 @@ License labels and deployment-time notes remain curated review items. | Package | Version | License | | --- | --- | --- | | `@noble/secp256k1` | `3.1.0` | MIT | -| `better-sqlite3` | `12.11.1` | MIT | +| `better-sqlite3` | `13.0.3` | MIT | | `cors` | `2.8.6` | MIT | | `dotenv` | `17.4.2` | BSD-2-Clause | | `ethers` | `6.17.0` | MIT | diff --git a/docs/DOCUMENTATION_INDEX.md b/docs/DOCUMENTATION_INDEX.md index 8f51898..a43f376 100644 --- a/docs/DOCUMENTATION_INDEX.md +++ b/docs/DOCUMENTATION_INDEX.md @@ -27,6 +27,7 @@ This file lists the documentation that is most likely to need updates when behav - `docs/plans/sandbox-entity-directory-and-name-service-plan.md`: Standalone sandbox entity-directory/name-service plan with its own persistent database, backend API, search/admin UI, synthetic profile/contact enrichment, optional provenance-aware on-chain registry readers, and a BENS-compatible Blockscout adapter backed by the same directory data. - `docs/plans/erc-3643-incremental-adoption-plan.md`: **Superseded in direction by `docs/decisions/0002-adopt-erc-3643-for-tokenized-securities.md`** (full migration to canonical ERC-3643 for bonds + equities, retiring the partitioned `BondToken` and the ERC-20 `StockToken`). Retained as the record of the *incremental* approach considered and rejected — a shared external identity registry + modular compliance bolted onto the existing partitioned `BondToken` and cash (`Wnok`), closing the bond transfer-eligibility gap first, then agent powers, compliance modules, and full ONCHAINID. Its eligibility-gap analysis and phase mechanics remain a useful reference. - `docs/plans/archive/`: Reference-only archive of completed implementation plans. Status lines inside each plan record the implementing PR and its state where relevant: + - `docs/plans/archive/better-sqlite3-13-upgrade/`: Plan folder that moved the NB Bond API to `better-sqlite3` 13.0.3 (Node-API addon, prebuilt binaries shipped in the package, `prebuild-install` and its subtree removed, `node-addon-api` added) with a classified lockfile transplant and the inventory row; records the node-gyp step that compiles only without a matching prebuilt binary, and the local-sandbox evidence that the rebuilt image loads the linux binary and that a resync from block 0 reproduces the projection and keeps the system-of-record tables. Shipped with the change itself. - `docs/plans/archive/dependabot-rollup-2026-09-25/`: Plan folder that landed four Dependabot bumps (zod 4.6.5, typescript-eslint 8.70.1 with its siblings, `@vitejs/plugin-react` 6.1.1, `@testing-library/react` 16.3.3) as one rollup with the inventory rows and a classified lockfile transplant; OpenAPI output unchanged, and the `better-sqlite3` 13 major left for its own PR. Shipped with the change itself. - `docs/plans/archive/dependabot-rollup-2026-09/`: Plan folder that landed the five open non-security Dependabot bumps (typescript-eslint 8.69.0 with its siblings, `@types/node` 26.5.1 with the Node types pin, `@testing-library/jest-dom` 7.0.1, zod 4.5.4, prettier 3.9.6) as one rollup with the inventory rows and a classified lockfile transplant; records the OpenAPI nullable-shape change from zod and the pre-existing typescript-eslint peer gap. Shipped with the change itself. - `docs/plans/archive/dependabot-security-rollup-2026-09/`: Plan folder that closed Dependabot alerts #78–#81 with one lockfile change (root `js-yaml` override to 4.3.2; nb-ui `vitest` and its `@vitest/*` siblings to 4.1.11), transplanted with a structural diff and override audit; records the constructed `js-yaml` entry and the excluded incidental `tinyrainbow` bump. Shipped via #281. diff --git a/docs/plans/archive/better-sqlite3-13-upgrade/design.md b/docs/plans/archive/better-sqlite3-13-upgrade/design.md new file mode 100644 index 0000000..0257bca --- /dev/null +++ b/docs/plans/archive/better-sqlite3-13-upgrade/design.md @@ -0,0 +1,90 @@ +# better-sqlite3 13 upgrade — Design + +**Status:** Approved (2026-09-25) +**Created:** 2026-09-25 +**Intent:** [`intent.md`](intent.md) +**Builds on:** `docs/plans/archive/dependabot-rollup-2026-09-25/` (lockfile transplant and +classification), `services/nb-bond-api/DEVELOPMENT.md` §7.10 (data persistence). + +## Decision Summary + +Take 13.0.3 as Dependabot proposed, through the same scratch-resolve-and-transplant procedure as +the rollups, so the lockfile moves only where the upgrade requires it. Prove it in three layers: +the jest suite on the development machine (darwin binary), CI (linux-x64 glibc binary), and the +rebuilt API image on the local Kind cluster (linux binary for the host architecture), where a +resync from block 0 exercises every write path against real chain data. Capture the +system-of-record row counts before the image swap, because those tables cannot be rebuilt from +the chain. + +## Current-State Evidence + +### Repository Evidence + +| Finding | Evidence | Consequence | +|---|---|---| +| **Verified.** `better-sqlite3` is an exact runtime pin in nb-bond-api and listed in the inventory at 12.11.1 | `services/nb-bond-api/package.json`, `THIRD_PARTY_LICENSES.md` | One manifest pin and one inventory row change | +| **Verified.** The API uses a small surface: the constructor, `pragma('user_version')`, `pragma('journal_mode = WAL')`, `transaction()`, prepared statements, and `close()` | `src/ingestion-db.ts`, `src/ingestion.ts`, `src/projection/snapshots.ts`, `src/admin.ts` | None of it is changed by the v13 release notes | +| **Verified.** The import is typed by an ambient `declare module 'better-sqlite3'` and the code narrows the handle to its own `IngestionDatabase` interface | `src/types/better-sqlite3.d.ts`, `src/admin.ts` | `@types/better-sqlite3` need not move | +| **Verified.** Five jest files open real SQLite databases | `tests/app.test.ts`, `chain-identity`, `compose-auction-failure`, `ingestion-idempotency`, `compose-coupon` | The suite exercises the native binding, including the cross-realm object binding fixed in 13.0.1 | +| **Verified.** 13.0.3 declares no install script, but it ships a `binding.gyp`, and `npm ci` still runs node-gyp on it. The gyp file compiles only when `lib/binding.js` finds no `prebuilds/-.node` for the host (musl detected through `process.report`) or when `force_build=1`; otherwise the build is a no-op | npm tarball; `npm ci` on the development machine left only stamp files under `build/Release` and the process loaded `prebuilds/darwin-arm64.node` | A compile fallback still exists. The builder stage needs Python and `make` for the node-gyp configure step, as it did for 12.x's fallback. The `node:26.5.0` image (Debian 13) has Python 3.13.5, GNU Make 4.4.1 and g++ 14.2, and glibc 2.41 is newer than the Ubuntu 22.04 build host of the linux binaries; Phase 2 proves the image build end to end | +| **Verified.** Prebuilt binaries ship for darwin, linux (glibc), linuxmusl and win32, each x64 and arm64; the linux binaries are built on Ubuntu 22.04 | npm tarball, 13.0.3 release notes | The Debian-based `node:26.5.0` image is covered on both architectures | +| **Verified.** 13.0.3 depends only on `node-addon-api ^8.0.0` (MIT) and needs Node ≥22 | registry metadata | The runtime is 26.5.0; `node-addon-api` is headers only, but it is a production dependency, so `npm prune --omit=dev` keeps it in the image | +| **Verified.** The bundled SQLite moves 3.53.2 → 3.53.4 | installed 12.11.1, `deps/sqlite3/sqlite3.h` in 13.0.3 | Patch release; the on-disk format is unchanged, so an existing PVC database opens as is and a rollback can reopen it | +| **Verified.** The image builder runs `npm ci` and then `npm prune --omit=dev`, and copies `node_modules` into the runtime stage | `services/nb-bond-api/Dockerfile` | The prebuilt binaries arrive with the package; no network download from GitHub at build time any more | +| **Verified.** The image content hash includes the root `package-lock.json` | `common/helpers.sh` | `./sandbox.sh start` rebuilds and redeploys the API image after the change | +| **Verified.** `POST /v1/admin/restart-ingestion?fromBlock=0` drops only the projection tables and restarts ingestion | `src/app.ts`, `src/admin.ts` | The live resync leaves `bidders`, `banks`, `operation_attempts` in place | +| **Verified.** #286's own lockfile diff: `better-sqlite3` changed, `node-addon-api` added, 28 entries removed (`prebuild-install`, `bindings` and their subtree), but it writes `^13.0.3` into the workspace entry | #286 diff | Transplant the same set, with the workspace range exact | +| **Verified.** The removed packages are permissive and none is named in `docs/THIRD_PARTY_NOTES.md` | npm metadata, notes file | No notes change | + +### Runtime Evidence + +- **Verified (2026-09-25, read-only):** the `cluster-cbdc-monoledger` node container and the + `kind-registry` container are stopped, and the current `kubectl` context points at a different + cluster. The sandbox scripts pass `--context=kind-cluster-cbdc-monoledger` explicitly, so + manual `kubectl` commands in this plan must do the same. +- **Needs verification:** that the rebuilt image loads the linux binary, and that a full + resync completes. Both happen in Phase 2. + +## Invariants + +- System-of-record tables (`bidders`, `banks`, `operation_attempts`) keep every row across the + image swap and the resync. +- The projection rebuilt from block 0 matches what the API served before the upgrade. +- No lockfile movement beyond the `better-sqlite3` subtree. +- Every manual `kubectl` command names `--context=kind-cluster-cbdc-monoledger`; none relies on + the current context. + +## Target State + +- `better-sqlite3 13.0.3` pinned in nb-bond-api; `node-addon-api` 8.x in the lockfile as its + only dependency; the `prebuild-install` subtree gone. +- Inventory row `better-sqlite3 | 13.0.3 | MIT`. +- No source change expected. If one is needed, it is limited to the database module and listed + in the PR. + +## Alternatives Considered + +- **Merge #286 after pushing an inventory commit to it.** The bot branch writes a caret range + into the lockfile, and a human commit stops Dependabot from rebasing it. The transplant keeps + the repo's exact-pin convention. +- **Stay on 12.x.** It still works, but it depends on the deprecated `prebuild-install` and a + build-time download from GitHub, and each Node major needs a new binary. v13 removes both. +- **Remove the unused prebuilt binaries in the Dockerfile.** Saves about 15 MB in the image but + adds a platform-specific step to maintain; not worth it for a sandbox image. + +## Decisions + +| Decision | Recommendation | Operator action | +|---|---|---| +| `node-addon-api` as a new package | Approved | Given 2026-09-25 | +| Live check on the local sandbox | Required before the PR | Start the stack, or let the implementation start it (`./infra/infra.sh registry-start`, then `./sandbox.sh start`) | +| Release afterwards | None | Say if a tag is wanted | + +## Residual Risks + +- The image grows by roughly 15 MB (12 MB installed for 12.11.1, 27 MB for 13.0.3) because the + package carries eight prebuilt binaries instead of one downloaded binary. +- A host whose platform has no prebuilt binary compiles SQLite from source during `npm ci`, + which needs a C++20 toolchain. Every image and CI runner in use has a prebuilt binary. +- A non-local deployment built from the same Dockerfile gets the same binary as the local image + for its architecture, so it needs no extra step; this plan does not verify it. diff --git a/docs/plans/archive/better-sqlite3-13-upgrade/intent.md b/docs/plans/archive/better-sqlite3-13-upgrade/intent.md new file mode 100644 index 0000000..85fb0bb --- /dev/null +++ b/docs/plans/archive/better-sqlite3-13-upgrade/intent.md @@ -0,0 +1,72 @@ +# better-sqlite3 13 upgrade — Intent + +**Status:** Approved (2026-09-25) +**Created:** 2026-09-25 +**Requested by:** sandbox operator + +## Outcome + +The NB Bond API runs on `better-sqlite3` 13.0.3, the version Dependabot proposed in #286, with +the licence inventory updated and the upgrade proven on the local sandbox: the rebuilt API +image loads the new native binary, the projection rebuilds from block 0, and the +system-of-record tables survive the restart. #286 is closed as superseded. + +## Why This Change + +`better-sqlite3` is the API's only native dependency and holds both the chain projection and +the system-of-record tables (`bidders`, `banks`, `operation_attempts`). Version 13 is a major +release: + +- The addon moves to Node-API (`node-addon-api`), so one binary works across Node versions. +- `prebuild-install`, which downloaded a binary from GitHub during `npm ci`, is gone. Prebuilt + binaries for eight platforms now ship inside the npm package itself. +- The bundled SQLite moves from 3.53.2 to 3.53.4. +- New APIs (`db.explain()`, `statement.toString()`); 13.0.1 fixes a regression that rejected + plain objects created in another realm, such as inside Jest. + +#286 fails only `validate-inventory`; its lint, format and test checks pass. It was held out of +the #291 rollup because it adds a package and changes how the binary reaches the image. + +## Scope + +### In Scope + +- `services/nb-bond-api/package.json`: `better-sqlite3` `12.11.1 → 13.0.3`, exact pin. +- `package-lock.json`: the `better-sqlite3` entry, the new `node-addon-api` entry, removal of + `prebuild-install`, `bindings` and the packages only they required, and the workspace range + written exact. +- `THIRD_PARTY_LICENSES.md`: the `better-sqlite3` row. +- A local sandbox check of the rebuilt `nb-bond-api` image and a projection resync. +- Closing #286 after merge. + +### Out of Scope + +- `@types/better-sqlite3` (7.6.13): the API's own ambient module declaration types the import, + and none of the new v13 APIs are used. +- Using the new `explain()` or `toString()` APIs. +- Trimming the prebuilt binaries the image does not use; see the residual risks in + `design.md`. +- Release or tag: not required by this change. + +## Acceptance Criteria + +| Criterion | Risk addressed | Verification evidence | +|---|---|---| +| Lockfile diff is exactly: `better-sqlite3` changed, `node-addon-api` added, the `prebuild-install` subtree removed, workspace range exact; nothing else | Incidental churn | structural diff with every entry classified | +| Root overrides still resolve at their pins | Un-applied overrides | override audit | +| nb-bond-api lint, format, jest suite and build pass with the same test count | API behaviour change | package gate | +| The rebuilt image starts on the local cluster and its Node process loads `prebuilds/linux-.node` | Missing or incompatible binary, or an unexpected source compile in the builder | pod logs, in-pod `require` check, `/v1/health` | +| `POST /v1/admin/restart-ingestion?fromBlock=0` rebuilds the projection to the chain head | Behaviour change in transactions, WAL or pragmas on real data | health cursor at head, bond and auction reads match pre-upgrade | +| `bidders`, `banks` and `operation_attempts` row counts are unchanged across the image swap and the resync | Loss of unrecoverable system-of-record data | counts before and after | +| Licence and Node-version checks pass; `node-addon-api` is MIT | Inventory drift, licence surprise | script output, registry metadata | + +## Constraints + +- One new package, `node-addon-api`, approved by the operator on 2026-09-25. No other additions. +- The local sandbox cluster is `cluster-cbdc-monoledger`; every live command targets that + context explicitly. +- One PR against `development`; public-repo rules. + +## Open Questions + +None blocking. diff --git a/docs/plans/archive/better-sqlite3-13-upgrade/plan.md b/docs/plans/archive/better-sqlite3-13-upgrade/plan.md new file mode 100644 index 0000000..588ec1c --- /dev/null +++ b/docs/plans/archive/better-sqlite3-13-upgrade/plan.md @@ -0,0 +1,94 @@ +# better-sqlite3 13 upgrade — Implementation Plan + +**Status:** Implemented — shipped in the same PR as the change; archived 2026-09-25 +**Created:** 2026-09-25 +**Scope:** `services/nb-bond-api/package.json`, `package-lock.json`, `THIRD_PARTY_LICENSES.md`, plus any database-module fix the upgrade proves necessary +**Intent:** [`intent.md`](intent.md) · **Design:** [`design.md`](design.md) · **Progress:** [`progress.md`](progress.md) + +## Plan Order + +```text +Phase 1 Manifest, lockfile transplant, override audit, inventory, package gate +Phase 2 Local sandbox: baseline, image swap, binary check, resync from block 0 +Phase 3 PR, merge, close #286 +``` + +Phase 2 depends on Phase 1's lockfile, because the image is built from it. One PR, one commit +(plus a fix-up only if CI disagrees with the local gate). The plan folder is archived in the +same PR. + +## Phase 1: Manifest and lockfile + +1. Record the baseline: `npm ci`; nb-bond-api jest count on the unchanged tree. +2. `git show HEAD:package-lock.json > /lock.before.json`. +3. Pin `better-sqlite3` to `13.0.3` in `services/nb-bond-api/package.json`. +4. Scratch resolve (`npm install --package-lock-only --ignore-scripts --legacy-peer-deps`); + structural diff; classify moved entries as the bumped package, its new dependency + (`node-addon-api`), entries orphaned by the removal of `prebuild-install` and `bindings` + (check that nothing else in the tree still needs each one), or incidental. Transplant the + first three classes only; workspace range exact. +5. Override audit; `npm ci`; confirm `node_modules/better-sqlite3/prebuilds/` holds the + expected binaries. +6. Inventory row; `check-third-party-licenses.py`, `check-node-version-consistency.py`. +7. `cd services/nb-bond-api && npm run lint && npm run format:check && npm test && npm run build`. + +Recovery: an orphan still referenced elsewhere stays in the lockfile and is recorded as kept. A +jest failure is diagnosed against the v13 release notes before any source change; a source fix +stays inside the database module. + +Exit: classified diff and gate results recorded in `progress.md`; test count equals the baseline. + +## Phase 2: Local sandbox check + +All commands target `kind-cluster-cbdc-monoledger`; manual `kubectl` commands pass +`--context=kind-cluster-cbdc-monoledger` rather than relying on the current context. + +1. Start the stack if it is down: `./infra/infra.sh registry-start`, then `./sandbox.sh start` + **from the unchanged `development` tree**, so the running image is 12.11.1. +2. Baseline, read-only: `/v1/health` (chain head and ingestion cursor), the bond and auction + lists, and the row counts of `bidders`, `banks` and `operation_attempts` read through + `kubectl exec` with a read-only SQLite handle. +3. From the Phase 1 tree, `./sandbox.sh start` again. The content hash changes, so the API image + is rebuilt and rolled out; the other services are unchanged. +4. Binary check: the pod starts; in the pod, + `node -e "require('better-sqlite3'); console.log(require('better-sqlite3/package.json').version)"` + prints 13.0.3; the pod logs show no load error. +5. Before the resync: the system-of-record counts match step 2, and `/v1/health` reports + ingestion caught up. +6. `POST /v1/admin/restart-ingestion?fromBlock=0`; wait until the cursor reaches the head. +7. After the resync: the bond and auction lists match step 2, and the system-of-record counts + still match. + +Recovery: if the pod fails to load the binary or the resync diverges, redeploy the 12.11.1 +image by running `./sandbox.sh start` from the unchanged tree. SQLite 3.53.2 reopens the file, +and the projection can be resynced again. Record the failure in `progress.md` and stop for an +operator decision. + +Exit: steps 4–7 recorded in `progress.md` with the observed values. + +## Phase 3: PR and cleanup + +1. Archive this folder to `docs/plans/archive/` and update `docs/DOCUMENTATION_INDEX.md` in the + same commit. +2. PR against `development`: `Upgrade better-sqlite3 to 13.0.3`, with the lockfile + classification, the package gate, and the live evidence. +3. After merge: close #286 with a comment pointing at the PR if Dependabot has not closed it. + +## Test Matrix + +| Layer | Risk | Evidence | +|---|---|---| +| Lockfile | Incidental churn; orphan removed while still needed | structural diff with classification, `npm ci` | +| Inventory | Version drift | licence checker | +| Unit and integration | Binding behaviour: transactions, pragmas, parameter binding | nb-bond-api jest on darwin; CI jest on linux-x64 | +| Image | Missing or incompatible binary in the linux image | in-pod `require`, pod logs | +| Projection | Write-path behaviour on real chain data | resync from block 0 to head; reads equal to baseline | +| System of record | Loss of unrecoverable rows | row counts before and after | + +## Done Criteria + +- [x] `better-sqlite3` 13.0.3 landed; the lockfile moved only in its subtree; overrides intact. +- [x] Package gate and verification scripts pass; test count unchanged. +- [x] Rebuilt image loads the binary; resync from block 0 matches the baseline; system-of-record + counts unchanged. +- [ ] #286 closed as superseded. diff --git a/docs/plans/archive/better-sqlite3-13-upgrade/progress.md b/docs/plans/archive/better-sqlite3-13-upgrade/progress.md new file mode 100644 index 0000000..2bd301d --- /dev/null +++ b/docs/plans/archive/better-sqlite3-13-upgrade/progress.md @@ -0,0 +1,41 @@ +# better-sqlite3 13 upgrade — Progress + +**Plan:** [`plan.md`](plan.md) +**Last updated:** 2026-09-25 — Phases 1 and 2 done; PR opened with the plan archived in it +**Current phase:** Phase 3: PR, then close #286 after merge +**Next action:** After merge, close Dependabot PR #286 as superseded if the bot has not + +## Phase Log + +| Phase | Status | Evidence | PR | +|---|---|---|---| +| 1 — Manifest and lockfile | Done | Baseline on the unchanged tree: 249 jest tests. Lockfile: `better-sqlite3` 12.11.1 → 13.0.3; `node-addon-api` 8.9.2 added (MIT); 29 entries removed (`prebuild-install`, `bindings`, and the 27 entries only they required, including the nested `node-abi/node_modules/semver` and `rc/node_modules/strip-json-comments`; the root `semver` and `strip-json-comments` stay); `detect-libc` marked dev-only because its remaining dependent is `lightningcss`; workspace range exact. The result is identical to #286's own changes for these entries. Excluded as incidental: eight removals caused by legacy peer resolution, as in the previous rollups. Scalar overrides at their pins, five nested `brace-expansion` copies unchanged. `npm ci` installs 833 packages (861 before); the process loads `prebuilds/darwin-arm64.node` with SQLite 3.53.4. Inventory row updated; licence and Node-version checks pass. nb-bond-api lint, prettier, 249 jest tests, and build pass; nb-ui prettier, lint, 125 vitest tests, and build pass (the root lockfile also triggers its workflow). No source change (2026-09-25) | this PR | +| 2 — Local sandbox check | Done | Baseline from the running pod on `better-sqlite3` 12.11.1 / SQLite 3.53.2: head 340, lag 0; schema `user_version` 6, WAL; system of record `bidders` 3, `banks` 0, `operation_attempts` 23; projection 3 auctions, 2 bonds, 5 bids, 5 allocations. `./services/nb-bond-api/nb-bond-api.sh start` built and rolled out `nb-bond-api:494a5a819351`; the builder's `npm ci` took 13 s with no source compile. In the new pod, `better-sqlite3` 13.0.3 loads `prebuilds/linux-arm64.node` (SQLite 3.53.4, Node 26.5.0, arm64); no `.node` file outside `prebuilds/`, `prebuild-install` absent, `node-addon-api` present, no load errors in the pod log. After the swap, every table count and the `/v1/bonds`, `/v1/auctions`, `/v1/bidders`, `/v1/banking/banks`, `/v1/operations` and `/v1/central-bank` responses are byte-identical to the baseline. `POST /v1/admin/restart-ingestion?fromBlock=0` returned 200 and the log shows ingestion restarting at block 0; it caught up to head 341 with no failures, and every table count and response is again byte-identical to the baseline, including the system-of-record counts (2026-09-25) | this PR | +| 3 — PR and cleanup | In progress | Plan folder archived in the PR itself; #286 closed after merge | this PR | + +## Deviations From the Plan + +- The design first stated that 13.0.3 has no compile fallback. `npm ci` showed that npm still + runs node-gyp on the shipped `binding.gyp`, which compiles only when no prebuilt binary matches + the host. `design.md` is corrected, and the builder image's toolchain is recorded there. +- The nb-ui gate was added to Phase 1 because the root lockfile triggers its workflow. +- Archived in the same PR as the change instead of a separate document-move PR. +- Local checks run on Node 25.8.0 (CI uses the pinned 26.5.0). +- Phase 2 step 1 was skipped: the stack was already running a 12.11.1 image, which is the state + the step exists to produce. That image predates #283, so the swap also brought in the #283, + #284 and #291 changes; the API responses stayed identical regardless. +- Phase 2 step 3 used `./services/nb-bond-api/nb-bond-api.sh start` instead of + `./sandbox.sh start`, so only the API image was rebuilt and redeployed. +- The live SSE route `/v1/events` was left out of the snapshots because it never completes. + +## Verified So Far + +- The v13 release notes, the 13.0.3 tarball layout (no declared install script but a `binding.gyp` that compiles only without a matching prebuilt binary, eight prebuilt binaries, + loader in `lib/binding.js`), the SQLite versions, the API's use of `better-sqlite3`, the + Dockerfile flow, the image hash inputs, the resync route, and #286's lockfile diff, all + verified on 2026-09-25 (see `design.md`). +- `node-addon-api` approved by the operator on 2026-09-25. + +## Follow-ups Found Along the Way + +None yet. diff --git a/package-lock.json b/package-lock.json index d01e0da..a677c8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5614,26 +5614,6 @@ "node": "18 || 20 || >=22" } }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/baseline-browser-mapping": { "version": "2.11.21", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", @@ -5648,17 +5628,15 @@ } }, "node_modules/better-sqlite3": { - "version": "12.11.1", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", - "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", - "hasInstallScript": true, + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", + "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", "license": "MIT", "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" + "node-addon-api": "^8.0.0" }, "engines": { - "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + "node": ">=22" } }, "node_modules/bid-encryption": { @@ -5679,26 +5657,6 @@ "require-from-string": "^2.0.2" } }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "license": "MIT", - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -5793,30 +5751,6 @@ "node-int64": "^0.4.0" } }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -5959,12 +5893,6 @@ "node": ">=10" } }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" - }, "node_modules/ci-info": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", @@ -6362,21 +6290,6 @@ "dev": true, "license": "MIT" }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -6392,15 +6305,6 @@ } } }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -6477,6 +6381,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -6594,15 +6499,6 @@ "node": ">= 0.8" } }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, "node_modules/entities": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", @@ -7218,15 +7114,6 @@ "node": ">= 0.8.0" } }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } - }, "node_modules/expect": { "version": "30.4.1", "resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz", @@ -7385,12 +7272,6 @@ "node": ">=16.0.0" } }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT" - }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -7504,12 +7385,6 @@ "node": ">= 0.8" } }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT" - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -7680,12 +7555,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" - }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -8008,26 +7877,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -8123,12 +7972,6 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -10301,18 +10144,6 @@ "node": ">=6" } }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -10339,15 +10170,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -10358,12 +10180,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -10389,12 +10205,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" - }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -10435,28 +10245,13 @@ "node": ">= 0.6" } }, - "node_modules/node-abi": { - "version": "3.92.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", - "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, "engines": { - "node": ">=10" - } - }, - "node_modules/node-abi/node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" + "node": "^18 || ^20 || >= 21" } }, "node_modules/node-exports-info": { @@ -10993,33 +10788,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -11112,16 +10880,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -11189,30 +10947,6 @@ "node": ">= 0.10" } }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/rc/node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -11836,51 +11570,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -12317,34 +12006,6 @@ "url": "https://opencollective.com/synckit" } }, - "node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -12572,18 +12233,6 @@ "fsevents": "~2.3.3" } }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -13750,7 +13399,7 @@ "license": "Apache-2.0", "dependencies": { "@noble/secp256k1": "3.1.0", - "better-sqlite3": "12.11.1", + "better-sqlite3": "13.0.3", "cors": "2.8.6", "dotenv": "17.4.2", "ethers": "6.17.0", diff --git a/services/nb-bond-api/package.json b/services/nb-bond-api/package.json index 8b17f3a..be4c051 100644 --- a/services/nb-bond-api/package.json +++ b/services/nb-bond-api/package.json @@ -18,7 +18,7 @@ }, "dependencies": { "@noble/secp256k1": "3.1.0", - "better-sqlite3": "12.11.1", + "better-sqlite3": "13.0.3", "cors": "2.8.6", "dotenv": "17.4.2", "ethers": "6.17.0", From 00b9bf05b5d8c52a46489e9fafa002fd4555da35 Mon Sep 17 00:00:00 2001 From: Deleo <152405392+Greaker@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:11:47 +0200 Subject: [PATCH 03/18] Make stop keep sandbox state; only delete discards it (#293) ./sandbox.sh stop used to uninstall every Helm release and delete the Blockscout namespace, which removed the chain, the explorer database, and the NB Bond API database. It now stops the Kind node container gracefully with SANDBOX_STOP_TIMEOUT_SECONDS (default 330) and keeps everything; ./sandbox.sh start resumes a stopped node before its usual run and skips the contract deploy. ./sandbox.sh delete is unchanged. Component scripts follow the same rule: stop scales the component to zero and keeps its volumes and release, start restores it through Helm, and a new delete verb carries the old uninstall or namespace delete. contracts.sh stop is a no-op; contracts.sh delete removes the registry ConfigMap. The NB Bond API now handles SIGTERM and SIGINT: it closes the HTTP server and open connections, stops ingestion, waits for queued work with a 10-second bound, and exits. As PID 1 without a handler it used to ignore SIGTERM and be killed after its 30-second grace period; its pod now stops in about a second. Every helm and kubectl call now names the sandbox's own context. They used to follow the current kubectl context, so a component command, or the old stop, could act on another local cluster with same-named releases. Live proof on the local sandbox: three stop/start cycles kept every API response, API table count, and Blockscout count, with PostgreSQL starting cleanly each time. A stop takes about 90 seconds; workloads, PostgreSQL, and etcd exit within 8 seconds and the rest is systemd in the Kind node waiting for kube-apiserver and the containerd shims. Component stop/start and nb-ui delete/start were exercised too. Docs describe the new verbs wherever the old ones appeared, KNOWN_ISSUES explains stopping before Docker quits and the catch-up indexer gap, and the Blockscout debugging playbook documents recovering a PostgreSQL whose replication-origin checkpoint was zeroed by an unclean shutdown. The plan folder is archived in this change. --- README.md | 6 +- common/helpers.sh | 133 +++++++++++++++-- contracts/README.md | 8 +- contracts/contracts.sh | 11 +- contracts/docs/natspec/src/README.md | 8 +- docs/DOCUMENTATION_INDEX.md | 3 +- docs/KNOWN_ISSUES.md | 23 +++ .../sandbox-stop-keeps-state/design.md | 108 ++++++++++++++ .../sandbox-stop-keeps-state/intent.md | 87 +++++++++++ .../archive/sandbox-stop-keeps-state/plan.md | 139 ++++++++++++++++++ .../sandbox-stop-keeps-state/progress.md | 49 ++++++ infra/AGENTS.md | 5 +- infra/DEVELOPMENT.md | 12 +- infra/README.md | 5 + infra/infra.sh | 7 +- sandbox.sh | 42 ++---- services/AGENTS.md | 5 +- services/DEVELOPMENT.md | 23 ++- services/blockscout/blockscout.sh | 9 +- services/blockscout/debugging.md | 55 +++++++ services/nb-bond-api/DEVELOPMENT.md | 3 +- services/nb-bond-api/nb-bond-api.sh | 9 +- services/nb-bond-api/src/index.ts | 16 +- services/nb-bond-api/src/ingestion.ts | 8 + services/nb-bond-api/src/shutdown.ts | 64 ++++++++ services/nb-bond-api/tests/shutdown.test.ts | 61 ++++++++ services/nb-ui/AGENTS.md | 3 +- services/nb-ui/nb-ui.sh | 9 +- 28 files changed, 834 insertions(+), 77 deletions(-) create mode 100644 docs/plans/archive/sandbox-stop-keeps-state/design.md create mode 100644 docs/plans/archive/sandbox-stop-keeps-state/intent.md create mode 100644 docs/plans/archive/sandbox-stop-keeps-state/plan.md create mode 100644 docs/plans/archive/sandbox-stop-keeps-state/progress.md create mode 100644 services/nb-bond-api/src/shutdown.ts create mode 100644 services/nb-bond-api/tests/shutdown.test.ts diff --git a/README.md b/README.md index 226988f..aa16214 100644 --- a/README.md +++ b/README.md @@ -193,9 +193,9 @@ Use `/etc/hosts` on Linux/macOS or | Command | Purpose | | --- | --- | -| `./sandbox.sh start` | Create or update the local sandbox | -| `./sandbox.sh stop` | Stop workloads while keeping the cluster and cached images | -| `./sandbox.sh delete` | Tear down the Kind cluster. The local registry container and its cached images are kept (they live in the `kind-registry` Docker container, which is independent of the Kind cluster lifecycle). To reclaim that space, run `./sandbox.sh registry-reset` (or `docker rm -f kind-registry`). | +| `./sandbox.sh start` | Create or update the local sandbox; resumes a sandbox stopped with `stop` | +| `./sandbox.sh stop` | Stop the whole sandbox gracefully and keep all state: the chain, Blockscout's index and contract verifications, and the NB Bond API database. `start` brings it back as it was. Run it before quitting Docker or rebooting; see [unclean shutdowns](docs/KNOWN_ISSUES.md#sandbox-must-be-stopped-before-docker-quits-or-the-host-reboots). The Kind node gets `SANDBOX_STOP_TIMEOUT_SECONDS` (default 330) to shut down; a stop takes about 90 seconds | +| `./sandbox.sh delete` | Tear down the Kind cluster and **all sandbox state**; `delete` then `start` gives a fresh sandbox. The local registry container and its cached images are kept (they live in the `kind-registry` Docker container, which is independent of the Kind cluster lifecycle). To reclaim that space, run `./sandbox.sh registry-reset` (or `docker rm -f kind-registry`). | | `./sandbox.sh generate-config` | Create `.env.sandbox` with deploy toggles | | `./infra/infra.sh registry-start` | Start the local registry container (one-time setup; no-op if already running) | | `./infra/infra.sh registry-sync` | Optional pre-warm: push every pinned third-party image into the local registry up front so `sandbox.sh start` doesn't pull them on demand | diff --git a/common/helpers.sh b/common/helpers.sh index ab768c2..92a480d 100755 --- a/common/helpers.sh +++ b/common/helpers.sh @@ -268,6 +268,118 @@ function clusterExists() { fi } +# Node containers of this Kind cluster, running or stopped. +function clusterNodeContainers() { + kind get nodes --name "$CLUSTER_NAME" 2>/dev/null || true +} + +function nodeContainerRunning() { + [ "$(docker inspect --format '{{.State.Running}}' "$1" 2>/dev/null)" == "true" ] +} + +# Stops the cluster's node containers gracefully and keeps all state. Docker's default +# 10-second stop timeout is shorter than the pods' own shutdown, so the node gets +# SANDBOX_STOP_TIMEOUT_SECONDS; a stop that still ends in SIGKILL (exit 137) fails loudly, +# because it is the unclean shutdown this command exists to avoid. +function stopClusterNodes() { + local timeout="${SANDBOX_STOP_TIMEOUT_SECONDS:-330}" + local node started elapsed exit_code + + for node in $(clusterNodeContainers); do + if ! nodeContainerRunning "$node"; then + echo "Node '$node' is already stopped." + continue + fi + + echo "Stopping node '$node' (waits up to ${timeout}s for its pods to shut down)..." + started=$(date +%s) + docker stop --time "$timeout" "$node" >/dev/null + elapsed=$(( $(date +%s) - started )) + + exit_code=$(docker inspect --format '{{.State.ExitCode}}' "$node") + if [ "$exit_code" == "137" ]; then + echo "❌ Node '$node' was killed after ${elapsed}s: its shutdown did not finish within ${timeout}s." + echo " Raise SANDBOX_STOP_TIMEOUT_SECONDS, and check PostgreSQL in services/blockscout/debugging.md on the next start." + exit 1 + fi + echo "✅ Node '$node' stopped in ${elapsed}s. All state is kept; './sandbox.sh start' resumes it." + done +} + +# Starts any stopped node containers of the cluster and waits for its API server. +function startClusterNodes() { + local node started_any="false" + + for node in $(clusterNodeContainers); do + if ! nodeContainerRunning "$node"; then + echo "Starting stopped node '$node'..." + docker start "$node" >/dev/null + started_any="true" + fi + done + + if [ "$started_any" == "true" ]; then + waitForKubeApiServer + fi +} + +# Stops one component without deleting anything: scales every Deployment and +# StatefulSet in the given namespaces to zero and waits for their pods to go. +# Releases, ConfigMaps, and volumes stay, and the component's `start` restores +# the replica counts through `helm upgrade`. Completed job pods are left alone. +function scaleNamespacesToZero() { + local namespace i + local timeout_seconds="${SANDBOX_STOP_TIMEOUT_SECONDS:-330}" + + if [[ $(clusterExists) == "false" ]]; then + echo "Cluster '$CLUSTER_NAME' does not exist. Nothing to stop." + return 0 + fi + + for namespace in "$@"; do + if ! kubectl --context="kind-$CLUSTER_NAME" get namespace "$namespace" >/dev/null 2>&1; then + echo "Namespace '$namespace' does not exist. Nothing to stop there." + continue + fi + + echo "Scaling workloads in '$namespace' to zero (volumes and releases are kept)..." + kubectl --context="kind-$CLUSTER_NAME" -n "$namespace" scale deployment,statefulset --all --replicas=0 + + i=0 + while [ -n "$(kubectl --context="kind-$CLUSTER_NAME" -n "$namespace" get pods \ + --field-selector=status.phase!=Succeeded,status.phase!=Failed -o name)" ]; do + sleep 1 + i=$(( i+1 )) + if [ "$i" -ge "$timeout_seconds" ]; then + echo "❌ Pods in '$namespace' did not stop within ${timeout_seconds}s." + exit 1 + fi + done + echo "✅ '$namespace' stopped." + done +} + +function waitForKubeApiServer() { + local msg="Waiting for the Kubernetes API server..." + local i + local timeout_seconds=120 + + # waitMsg assigns the caller's `i`, so reset it after the start message. + waitMsg "$msg" start + i=0 + until kubectl --context="kind-$CLUSTER_NAME" get --raw /readyz >/dev/null 2>&1; do + waitMsg "$msg" $i + sleep 1 + i=$(( i+1 )) + if [ "$i" -ge "$timeout_seconds" ]; then + echo + echo "❌ The API server of '$CLUSTER_NAME' was not ready after ${timeout_seconds}s." + exit 1 + fi + done + waitMsg "$msg" end +} + function getImageValue() { local key=$1 local default=$2 @@ -643,10 +755,6 @@ data: EOF } -function clearContractsDeploymentMarker() { - kubectl --context=kind-$CLUSTER_NAME -n $CONTRACTS_DEPLOYMENT_NAMESPACE delete configmap $CONTRACTS_DEPLOYMENT_CONFIGMAP >/dev/null 2>&1 || true -} - function deployRegistryContractAddressToConfigmap() { registry_contract_address=$1 @@ -885,6 +993,7 @@ function createKindCluster() { kind create cluster --config $REPO_ROOT/infra/cluster/cluster-config.yaml --name $CLUSTER_NAME else echo "Cluster '$CLUSTER_NAME' already exists. Skipping cluster creation." + startClusterNodes fi # Ensure kubeconfig has the expected kind context (and fail fast if the cluster is unhealthy). @@ -918,14 +1027,14 @@ function deployApiGateway() { # removed in the NGF 2.x chart (the data-plane Service is provisioned # per Gateway by the control plane; the local NodePort Service in # infra/gateway selects the provisioned data-plane pods directly). - helm upgrade ngf oci://ghcr.io/nginx/charts/nginx-gateway-fabric \ + helm upgrade --kube-context "kind-$CLUSTER_NAME" ngf oci://ghcr.io/nginx/charts/nginx-gateway-fabric \ --install \ --version ${NGINX_GATEWAY_FABRIC_VERSION} \ --kube-context kind-$CLUSTER_NAME \ --namespace nginx-gateway \ --create-namespace - helm upgrade gateway $REPO_ROOT/infra/gateway \ + helm upgrade --kube-context "kind-$CLUSTER_NAME" gateway $REPO_ROOT/infra/gateway \ --install \ --kube-context kind-$CLUSTER_NAME \ --namespace nginx-gateway \ @@ -1240,7 +1349,7 @@ function syncImagesToRegistry() { # no cluster. Always succeeds. function currentDeployedTagFor() { local repo="$1" - kubectl get pods --all-namespaces \ + kubectl --context="kind-$CLUSTER_NAME" get pods --all-namespaces \ -o jsonpath='{range .items[*]}{range .spec.containers[*]}{.image}{"\n"}{end}{end}' 2>/dev/null \ | grep "/${repo}:" \ | sed "s#.*/${repo}:##" \ @@ -1266,7 +1375,7 @@ function reportSandboxImages() { echo "=== Running sandbox pod images ===" if [ "$(clusterExists)" == "true" ]; then - kubectl get pods --all-namespaces \ + kubectl --context="kind-$CLUSTER_NAME" get pods --all-namespaces \ -o jsonpath='{range .items[*]}{range .spec.containers[*]}{.image}{"\n"}{end}{end}' 2>/dev/null \ | grep -E "localhost:${KIND_REGISTRY_PORT}/(nb-ui|nb-bond-api|bens-microservice):" \ | sort -u \ @@ -1412,7 +1521,7 @@ function deployBesu() { BESU_IMAGE_OVERRIDE=$(kindRegistryImageFor "$BESU_IMAGE") echo "🔁 Using local registry image for Besu: $BESU_IMAGE_OVERRIDE" - helm upgrade besu $REPO_ROOT/infra/besu \ + helm upgrade --kube-context "kind-$CLUSTER_NAME" besu $REPO_ROOT/infra/besu \ --install \ --kube-context kind-$CLUSTER_NAME \ --namespace besu \ @@ -1545,7 +1654,7 @@ function deployBlockscout() { BLOCKSCOUT_BACKEND_TAG_OVERRIDE=$(imageTag "$BLOCKSCOUT_BACKEND_IMAGE_OVERRIDE") # deploy postgres db and blockscout - helm upgrade blockscout $BLOCKSCOUT_TMPDIR/blockscout-stack \ + helm upgrade --kube-context "kind-$CLUSTER_NAME" blockscout $BLOCKSCOUT_TMPDIR/blockscout-stack \ --install \ --kube-context kind-$CLUSTER_NAME \ --namespace $BLOCKSCOUT_NAMESPACE \ @@ -1692,7 +1801,7 @@ function deployNBBondAPI() { return 1 fi - helm upgrade nb-bond-api $REPO_ROOT/services/nb-bond-api/helm \ + helm upgrade --kube-context "kind-$CLUSTER_NAME" nb-bond-api $REPO_ROOT/services/nb-bond-api/helm \ --install \ --kube-context kind-$CLUSTER_NAME \ --namespace $NB_BOND_API_NAMESPACE \ @@ -1752,7 +1861,7 @@ function deployNBUI() { "NB_UI_BUILDER_IMAGE=${NB_UI_BUILDER_RESOLVED}" \ "NB_UI_NGINX_IMAGE=${NB_UI_NGINX_RESOLVED}")" || return 1 - helm upgrade nb-ui "$REPO_ROOT/services/nb-ui/helm" \ + helm upgrade --kube-context "kind-$CLUSTER_NAME" nb-ui "$REPO_ROOT/services/nb-ui/helm" \ --install \ --kube-context kind-$CLUSTER_NAME \ --namespace $NB_UI_NAMESPACE \ diff --git a/contracts/README.md b/contracts/README.md index 087cffc..0f6949c 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -142,12 +142,16 @@ Deploy and verify in Blockscout: ./contracts.sh start --verify ``` -Stop only the registry/configmap side of the contract deployment integration: +Remove only the registry/configmap side of the contract deployment +integration; the contracts stay deployed on the chain: ```console -./contracts.sh stop +./contracts.sh delete ``` +`./contracts.sh stop` does nothing: the contracts live on the Besu chain and +stop with it (`./sandbox.sh stop`). + Verify the latest full deployment: ```console diff --git a/contracts/contracts.sh b/contracts/contracts.sh index 29a7b02..4947f6e 100755 --- a/contracts/contracts.sh +++ b/contracts/contracts.sh @@ -10,7 +10,10 @@ source ../common/helpers.sh # print help message function printHelp() { echo "Usage is: " - echo " $(basename "$0") " + echo " $(basename "$0") " + echo + echo " stop: nothing to stop; the contracts live on the Besu chain." + echo " delete: removes the contract registry ConfigMap from the cluster; the contracts stay on the chain." echo echo " verify:" echo " --address (required)" @@ -95,8 +98,8 @@ fi IS_SUBTASK="false" VERIFY_CONTRACTS="" -# parse flags and options for start/stop only -if [ "$CMD" == "start" ] || [ "$CMD" == "stop" ]; then +# parse flags and options for start/stop/delete only +if [ "$CMD" == "start" ] || [ "$CMD" == "stop" ] || [ "$CMD" == "delete" ]; then while [[ $# -ge 1 ]] ; do key="$1" case $key in @@ -180,6 +183,8 @@ if [ "$CMD" == "start" ]; then deployContracts $NETWORK $CHAIN_ID $VERIFY_CONTRACTS elif [ "$CMD" == "stop" ]; then + echo "Nothing to stop: the contracts live on the Besu chain and stop with it." +elif [ "$CMD" == "delete" ]; then echo "deleting the contract registry from the cluster, but leaving contracts deployed and running in Besu" kubectl --context=kind-$CLUSTER_NAME -n $REGISTRY_CONTRACT_NAMESPACE delete configmap $REGISTRY_CONTRACT_CONFIGMAP || true diff --git a/contracts/docs/natspec/src/README.md b/contracts/docs/natspec/src/README.md index a7c1d29..e2901b3 100644 --- a/contracts/docs/natspec/src/README.md +++ b/contracts/docs/natspec/src/README.md @@ -142,12 +142,16 @@ Deploy and verify in Blockscout: ./contracts.sh start --verify ``` -Stop only the registry/configmap side of the contract deployment integration: +Remove only the registry/configmap side of the contract deployment +integration; the contracts stay deployed on the chain: ```console -./contracts.sh stop +./contracts.sh delete ``` +`./contracts.sh stop` does nothing: the contracts live on the Besu chain and +stop with it (`./sandbox.sh stop`). + Verify the latest full deployment: ```console diff --git a/docs/DOCUMENTATION_INDEX.md b/docs/DOCUMENTATION_INDEX.md index a43f376..fa8b373 100644 --- a/docs/DOCUMENTATION_INDEX.md +++ b/docs/DOCUMENTATION_INDEX.md @@ -27,6 +27,7 @@ This file lists the documentation that is most likely to need updates when behav - `docs/plans/sandbox-entity-directory-and-name-service-plan.md`: Standalone sandbox entity-directory/name-service plan with its own persistent database, backend API, search/admin UI, synthetic profile/contact enrichment, optional provenance-aware on-chain registry readers, and a BENS-compatible Blockscout adapter backed by the same directory data. - `docs/plans/erc-3643-incremental-adoption-plan.md`: **Superseded in direction by `docs/decisions/0002-adopt-erc-3643-for-tokenized-securities.md`** (full migration to canonical ERC-3643 for bonds + equities, retiring the partitioned `BondToken` and the ERC-20 `StockToken`). Retained as the record of the *incremental* approach considered and rejected — a shared external identity registry + modular compliance bolted onto the existing partitioned `BondToken` and cash (`Wnok`), closing the bond transfer-eligibility gap first, then agent powers, compliance modules, and full ONCHAINID. Its eligibility-gap analysis and phase mechanics remain a useful reference. - `docs/plans/archive/`: Reference-only archive of completed implementation plans. Status lines inside each plan record the implementing PR and its state where relevant: + - `docs/plans/archive/sandbox-stop-keeps-state/`: Plan folder that made `stop` keep all state and `delete` the only verb that discards it: `./sandbox.sh stop` stops the Kind node gracefully and `start` resumes it, component `stop` verbs scale to zero while new `delete` verbs carry the old uninstall behaviour, the NB Bond API shuts down on SIGTERM, and every `helm` and `kubectl` call names the sandbox's context; records the live stop/start evidence, the 90-second systemd wait inside the Kind node, and the Blockscout PostgreSQL recovery. Shipped with the change itself. - `docs/plans/archive/better-sqlite3-13-upgrade/`: Plan folder that moved the NB Bond API to `better-sqlite3` 13.0.3 (Node-API addon, prebuilt binaries shipped in the package, `prebuild-install` and its subtree removed, `node-addon-api` added) with a classified lockfile transplant and the inventory row; records the node-gyp step that compiles only without a matching prebuilt binary, and the local-sandbox evidence that the rebuilt image loads the linux binary and that a resync from block 0 reproduces the projection and keeps the system-of-record tables. Shipped with the change itself. - `docs/plans/archive/dependabot-rollup-2026-09-25/`: Plan folder that landed four Dependabot bumps (zod 4.6.5, typescript-eslint 8.70.1 with its siblings, `@vitejs/plugin-react` 6.1.1, `@testing-library/react` 16.3.3) as one rollup with the inventory rows and a classified lockfile transplant; OpenAPI output unchanged, and the `better-sqlite3` 13 major left for its own PR. Shipped with the change itself. - `docs/plans/archive/dependabot-rollup-2026-09/`: Plan folder that landed the five open non-security Dependabot bumps (typescript-eslint 8.69.0 with its siblings, `@types/node` 26.5.1 with the Node types pin, `@testing-library/jest-dom` 7.0.1, zod 4.5.4, prettier 3.9.6) as one rollup with the inventory rows and a classified lockfile transplant; records the OpenAPI nullable-shape change from zod and the pre-existing typescript-eslint peer gap. Shipped with the change itself. @@ -68,7 +69,7 @@ This file lists the documentation that is most likely to need updates when behav - `infra/besu/config/README.md`: Provenance note for the local `genesis.json` (based on upstream Besu templates). - `services/README.md`: Services overview and links to detailed docs. - `services/DEVELOPMENT.md`: Service deployment, URLs, and operational notes. -- `services/blockscout/debugging.md`: Blockscout/Besu debugging playbook. +- `services/blockscout/debugging.md`: Blockscout/Besu debugging playbook, including recovery of a PostgreSQL that will not start after an unclean shutdown. - `services/nb-bond-api/README.md`: NB Bond API overview and environment variables. - `services/nb-bond-api/DEVELOPMENT.md`: NB Bond API runbooks and OpenAPI usage. - `services/nb-ui/README.md`: NB UI operator frontend overview. diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md index 75d2775..6c1f524 100644 --- a/docs/KNOWN_ISSUES.md +++ b/docs/KNOWN_ISSUES.md @@ -34,6 +34,29 @@ broker-less issuance orders, and a decision on whether repeated unknown failures should evict the maker. +## Sandbox must be stopped before Docker quits or the host reboots +- If Docker Desktop quits, or the host reboots, while the sandbox runs, Docker + gives the Kind node container its default 10 seconds and then kills it. Pods + get no clean shutdown. In one observed case Blockscout's PostgreSQL lost a + freshly written checkpoint file, then refused to start with + `PANIC: replication checkpoint has wrong magic 0`, and Blockscout stayed down + until the file was moved aside. The recovery procedure is in + [`services/blockscout/debugging.md`](../services/blockscout/debugging.md#postgresql-does-not-start-after-an-unclean-shutdown). +- Run `./sandbox.sh stop` first. It stops the Kind node with + `SANDBOX_STOP_TIMEOUT_SECONDS` (default 330) and keeps all state; `./sandbox.sh start` + resumes it. A stop takes about 90 seconds: the workloads, PostgreSQL and etcd + included, exit within seconds, and the rest is systemd inside the Kind node + image waiting its 90-second timeout for `kube-apiserver` and the containerd + shims, which hold no state. +- Blockscout runs with its catch-up indexer disabled + (`DISABLE_CATCHUP_INDEXER` in `services/blockscout/values.backend.env.yaml`). + Blocks mined while Besu runs without Blockscout, for example after + `./services/blockscout/blockscout.sh stop` or while its database is broken, + are never indexed. `./sandbox.sh stop` avoids the gap by stopping the chain + too; `./sandbox.sh delete` and `./sandbox.sh start` rebuild a complete index. +- Follow-up: decide whether to enable the catch-up indexer so gaps heal + themselves. + ## Local QBFT topology has one validator - The default sandbox has one QBFT validator, so it has immediate deterministic finality but no Byzantine fault tolerance. diff --git a/docs/plans/archive/sandbox-stop-keeps-state/design.md b/docs/plans/archive/sandbox-stop-keeps-state/design.md new file mode 100644 index 0000000..235d115 --- /dev/null +++ b/docs/plans/archive/sandbox-stop-keeps-state/design.md @@ -0,0 +1,108 @@ +# Stop keeps state, delete deletes — Design + +**Status:** Approved (2026-09-25) +**Created:** 2026-09-25 +**Intent:** [`intent.md`](intent.md) +**Builds on:** the Blockscout PostgreSQL recovery and full sandbox reset of 2026-09-25. + +## Decision Summary + +`./sandbox.sh stop` becomes a graceful stop of the Kind node container with an explicit, long +timeout, so the node's systemd shutdown stops every pod with its own stop signal and every volume +stays. `./sandbox.sh start` starts a stopped node before its usual idempotent run. Component +scripts get the same split: `stop` scales the component's workloads to zero, and a new `delete` +takes over today's uninstall-and-remove behaviour. Phase 1 measures a graceful node stop on the +live sandbox before any code changes, because the design depends on the node's shutdown actually +reaching the pods; if it does not, the top-level `stop` scales the stateful workloads to zero +before stopping the node. The PostgreSQL recovery procedure is written down for the unclean +shutdowns a graceful stop cannot prevent. + +## Current-State Evidence + +### Repository Evidence + +| Finding | Evidence | Consequence | +|---|---|---| +| **Verified.** `sandbox.sh stop` calls each component's `stop` (nb-ui, nb-bond-api, contracts, Blockscout, infra) and then `clearContractsDeploymentMarker` | `sandbox.sh` | The whole top-level `stop` branch is replaced | +| **Verified.** Component `stop` verbs: `infra.sh` uninstalls `gateway`, `ngf`, and `besu`; `blockscout.sh` deletes the `blockscout` namespace; `nb-bond-api.sh` and `nb-ui.sh` uninstall their releases; `contracts.sh` deletes the contract-registry ConfigMap | the five scripts | Their current bodies move to new `delete` verbs | +| **Inferred.** The Besu and NB Bond API volumes are chart-templated PVCs without `helm.sh/resource-policy: keep`, so `helm uninstall` deletes them; the Blockscout PVC goes with its namespace | `infra/besu/templates/pvc.yaml`, `services/nb-bond-api/helm/templates/pvc.yaml`, `services/blockscout/templates/db-pvc.yaml` | Not verified live, because checking would destroy the state; `delete` is documented as discarding data on this basis | +| **Verified.** Only `sandbox.sh` calls the component `stop` verbs; no CI workflow uses any `stop` | repository search | Changing the verbs affects the operator's commands and the docs only | +| **Verified.** `clusterExists` uses `kind get clusters`, which also lists a cluster whose node container is stopped | `common/helpers.sh` | `start` must detect a stopped node and start it before any `kubectl` or Helm call | +| **Verified.** Every Deployment and StatefulSet template sets `replicas` explicitly (1, or `replicaCount` defaulting to 1) | chart templates | **Inferred:** a component `start` (`helm upgrade --install`) restores a workload scaled to zero; Phase 3 verifies it | +| **Verified.** The Blockscout backend has a 300-second termination grace period; every other workload has 30 seconds | live Deployments and StatefulSets | The node-stop timeout must exceed the longest stop the shutdown actually waits for | +| **Verified.** The Blockscout PostgreSQL image stops on `SIGINT` (fast shutdown) with default `fsync` | image config, `services/blockscout/templates/db-deployment.yaml` | A clean pod stop gives PostgreSQL a shutdown checkpoint | +| **Verified.** Besu's genesis sets `emptyblockperiodseconds: 300`, and the catch-up indexer is disabled | `infra/besu/config/genesis.json`, `services/blockscout/values.backend.env.yaml` | Stopping the whole node stops the chain too, so no blocks are mined while Blockscout is down; a component-level Blockscout stop still leaves a gap for blocks mined meanwhile, which the docs must say | + +### Runtime Evidence + +- **Verified:** the node container `cluster-cbdc-monoledger-control-plane` has stop signal + `SIGRTMIN+3` (a systemd shutdown), no stop timeout of its own (Docker's default of 10 seconds + applies), and restart policy `on-failure:1`. +- **Verified:** after the node container exited with 137, starting it again brought the cluster + back with its volumes; the NB Bond API database and the chain survived, and only PostgreSQL's + damaged file blocked recovery. +- **Needs verification:** how long a graceful node stop takes, whether each pod receives its stop + signal, and whether PostgreSQL logs a clean shutdown (Phase 1). + +## Invariants + +- The top-level `stop` and `start` act on `kind-$CLUSTER_NAME`'s node container only, named from + `CLUSTER_NAME`, never by a wildcard; every `kubectl` call passes `--context=kind-$CLUSTER_NAME`. +- No `stop` verb deletes or uninstalls anything. Only `delete` verbs do. +- A `stop` followed by `start` never redeploys contracts. +- `./sandbox.sh delete` keeps its current behaviour. + +## Target Architecture + +- `common/helpers.sh`: + - `stopClusterNode`: if the node container is running, `docker stop --time + "$SANDBOX_STOP_TIMEOUT_SECONDS" `; report elapsed time and exit code, and fail loudly + on 137. + - `startClusterNode`: if the node container exists and is stopped, `docker start` it and wait + for the API server; a no-op when it is already running. + - `nodeContainerState` helper used by both, and by `start` to decide. +- `sandbox.sh`: + - `stop`: `stopClusterNode`. No component verbs, no marker change. + - `start`: `startClusterNode` before the existing flow. + - `delete`: unchanged. +- Component scripts: `stop` scales the component's Deployments and StatefulSets to zero and waits + for their pods to go; `delete` holds today's `stop` body; `start` is unchanged and restores + replicas through Helm. `contracts.sh stop` prints that there is nothing to stop; `contracts.sh + delete` removes the registry ConfigMap and, when run through `sandbox.sh`, the deployment + marker, as `stop` did. +- `SANDBOX_STOP_TIMEOUT_SECONDS`: default set from Phase 1's measurement and at least 330 seconds + (Blockscout's 300-second grace period plus margin), with the required banner comment. + +**Fallback, only if Phase 1 shows the node shutdown does not stop pods gracefully:** the top-level +`stop` first calls each component's new `stop` (scale to zero), then stops the node; `start` starts +the node and lets each component's `start` restore replicas. + +## Alternatives Considered + +- **A separate `pause` verb, leaving `stop` as it is.** Rejected by the operator: `stop` should + mean stop. +- **Scale to zero at the top level always.** Deterministic, but slower and duplicates what the + node shutdown does if Phase 1 shows it works; kept as the fallback. +- **Leave the component verbs destructive.** Would keep two meanings of `stop` in one repo; the + component split costs little and keeps a one-command fresh-Blockscout path as `blockscout.sh + delete`. +- **Rely on Docker Desktop's quit.** Its stop timeout is not controlled from this repo; the + incident shows it is not enough. + +## Decisions + +| Decision | Recommendation | Operator action | +|---|---|---| +| Top-level `stop` keeps state; `delete` deletes | Decided | Given 2026-09-25 | +| Component verbs follow the same rule | Yes | Approved 2026-09-25 | +| Stop timeout default | From Phase 1, at least 330 s | None | +| Catch-up indexer | Out of scope; follow-up | Decide separately | + +## Residual Risks + +- A host crash, power loss, or forced Docker Desktop quit still stops the node uncleanly; the + recovery procedure in `services/blockscout/debugging.md` covers that case. +- Anyone used to `stop` then `start` as a quick reset now gets their old state back; the README + says to use `delete` then `start` for a fresh sandbox (about ten minutes, images come from the + local registry). +- Phase 1 and the live proof stop and start the running sandbox; each run takes a snapshot first. diff --git a/docs/plans/archive/sandbox-stop-keeps-state/intent.md b/docs/plans/archive/sandbox-stop-keeps-state/intent.md new file mode 100644 index 0000000..d9ccec0 --- /dev/null +++ b/docs/plans/archive/sandbox-stop-keeps-state/intent.md @@ -0,0 +1,87 @@ +# Stop keeps state, delete deletes — Intent + +**Status:** Approved (2026-09-25) +**Created:** 2026-09-25 +**Requested by:** sandbox operator + +## Outcome + +The lifecycle verbs mean what they say. `stop` stops the local sandbox, or one component of it, +and keeps every piece of state: the Besu chain, the Blockscout index and its contract +verifications, and the NB Bond API database, including the bidder keys that cannot be recovered +from the chain. `start` brings it back as it was. `delete` is the only verb that discards state. +An operator can therefore run `./sandbox.sh stop` before quitting Docker or rebooting and lose +nothing. + +## Why This Change + +On 2026-09-15 the Kind node container was killed rather than stopped (it later showed exit code +137). Blockscout's PostgreSQL had just written its replication-origin checkpoint file, and that +file never reached disk intact; it came back as eight zero bytes. PostgreSQL then aborted crash +recovery on every start (`PANIC: replication checkpoint has wrong magic 0`), and Blockscout was +down for ten days until the file was moved aside on 2026-09-25. Because the catch-up indexer is +disabled, the blocks mined in that window were never indexed, and the local sandbox was reset to +get a gap-free explorer. + +The command that should have prevented this discards state instead. `./sandbox.sh stop` is +described in the README as "Stop workloads while keeping the cluster and cached images", but it +uninstalls every Helm release, deletes the `blockscout` namespace, and clears the contracts +deployment marker, which removes the chain, explorer, and API volumes. The component scripts' +`stop` verbs do the same for their own component. There is no command that stops the sandbox and +keeps its state. + +## Scope + +### In Scope + +- `./sandbox.sh stop`: stop the Kind node container gracefully, with an explicit timeout long + enough for every pod's own shutdown. Cluster, releases, volumes, and markers stay. +- `./sandbox.sh start`: when the node container exists but is stopped, start it and wait for the + cluster before running the usual idempotent start; the contracts marker survives, so contracts + are not redeployed. +- `./sandbox.sh delete`: unchanged (removes the cluster; the registry and its images stay). +- Component scripts (`infra/infra.sh`, `services/blockscout/blockscout.sh`, + `services/nb-bond-api/nb-bond-api.sh`, `services/nb-ui/nb-ui.sh`, `contracts/contracts.sh`): + `stop` scales the component's workloads to zero and keeps its release, configuration, and + volumes; a new `delete` verb carries today's destructive behaviour (uninstall, namespace + delete, marker and ConfigMap removal), so a fresh Blockscout database stays one command away. + `contracts.sh` has no workloads, so its `stop` becomes a no-op that says so. +- The NB Bond API handles SIGTERM and shuts down cleanly, so no stop of its pod ends in a + SIGKILL (added after Phase 1). +- Every `helm` call names the sandbox's own context, so no lifecycle command can reach another + local cluster (added in Phase 2). +- Documentation of the new meanings wherever the verbs are described, a `KNOWN_ISSUES` entry, and + the PostgreSQL recovery procedure in `services/blockscout/debugging.md`. + +### Out of Scope + +- Enabling the Blockscout catch-up indexer; recorded as a follow-up. +- Protecting against a host crash, power loss, or a forced Docker Desktop quit. A graceful stop + only helps when it runs. +- Stopping automatically when Docker Desktop quits or macOS shuts down. +- Archived plans that mention the old `stop` behaviour; they are frozen records. +- The non-local deployment. + +## Acceptance Criteria + +| Criterion | Risk addressed | Verification evidence | +|---|---|---| +| `./sandbox.sh stop` stops the node container with exit code 0 inside its timeout, never 137 | The SIGKILL that caused the incident | `docker inspect` exit code, elapsed time | +| PostgreSQL logs a clean shutdown on `stop` and starts without crash recovery on `start` | Unclean database shutdown | PostgreSQL logs | +| After `stop` and `start`: same chain head (plus any new blocks), same bonds, auctions, and bidders from the API, same Blockscout blocks and verifications, no contract redeploy | Lost state | before/after snapshot, contracts step output | +| A component `stop` then `start` keeps that component's data and restores its pods | Component verbs still destructive | per-component snapshot | +| Component `delete` reproduces today's `stop` behaviour for that component | Lost reset path | release, namespace, and PVC listing after `delete` | +| `stop` and `start` act only on this repo's node container | Collateral effect on other local clusters | `docker ps -a` before and after | +| Every doc that describes the verbs states the new meaning | Operator reaches for the wrong command | doc diffs, link check | + +## Constraints + +- No new dependency; shell plus the tools the scripts already use (`docker`, `kubectl`, `kind`, + `helm`). +- New script flags get the banner comment the repo requires. +- Public-repo rules; the documents describe the local sandbox only. + +## Resolved Questions + +- Component-level verbs follow the same rule (approved 2026-09-25). +- The NB Bond API SIGTERM fix is part of this plan (operator, 2026-09-25). diff --git a/docs/plans/archive/sandbox-stop-keeps-state/plan.md b/docs/plans/archive/sandbox-stop-keeps-state/plan.md new file mode 100644 index 0000000..4161c1e --- /dev/null +++ b/docs/plans/archive/sandbox-stop-keeps-state/plan.md @@ -0,0 +1,139 @@ +# Stop keeps state, delete deletes — Implementation Plan + +**Status:** Implemented — shipped in the same PR as the change; archived 2026-09-25 +**Created:** 2026-09-25 +**Scope:** `sandbox.sh`, `common/helpers.sh`, `services/nb-bond-api/src/` (shutdown handling and its test), `infra/infra.sh`, `services/blockscout/blockscout.sh`, `services/nb-bond-api/nb-bond-api.sh`, `services/nb-ui/nb-ui.sh`, `contracts/contracts.sh`, and the docs that describe the lifecycle verbs +**Intent:** [`intent.md`](intent.md) · **Design:** [`design.md`](design.md) · **Progress:** [`progress.md`](progress.md) + +## Plan Order + +```text +Phase 1 Characterise a graceful node stop on the live sandbox (no code change) +Phase 2 Top-level stop / start (node container) +Phase 3 NB Bond API graceful shutdown on SIGTERM +Phase 4 Component stop (scale to zero) and delete (today's stop); explicit Helm context +Phase 5 Live proof: sandbox stop/start, component stop/start, component delete +Phase 6 Documentation +Phase 7 PR +``` + +Phase 2's shape depends on Phase 1. One PR; the plan folder is archived in it. + +## Phase 1: Characterise a graceful node stop + +All commands name `kind-cluster-cbdc-monoledger` and its node container explicitly. + +1. Snapshot: chain head, `/v1/bonds`, `/v1/auctions`, `/v1/bidders`, the NB Bond API table counts, + Blockscout block and verified-contract counts. +2. `time docker stop --time 330 cluster-cbdc-monoledger-control-plane`; record the elapsed time and + the exit code. +3. `docker start` it; when the pods are back, read the previous run of PostgreSQL (clean shutdown + line; next start says "was shut down at", not "was interrupted"), Besu, and the NB Bond API. +4. Compare with the snapshot. + +Decision rule: exit 0 well inside the timeout and a clean PostgreSQL shutdown means a plain node +stop; otherwise the fallback in `design.md`. + +Recovery: if PostgreSQL fails to start, apply the 2026-09-25 procedure (scale to zero, back up +`pgdata` on the node, move the damaged file aside, scale up). + +Exit: timings, exit code, and log excerpts in `progress.md`; design choice stated. + +## Phase 2: Top-level stop and start + +1. `common/helpers.sh`: `nodeContainerState`, `stopClusterNode`, `startClusterNode`, and + `SANDBOX_STOP_TIMEOUT_SECONDS` with its banner. +2. `sandbox.sh stop`: replace the component calls and `clearContractsDeploymentMarker` with + `stopClusterNode`; clear messages when the cluster does not exist or is already stopped. +3. `sandbox.sh start`: call `startClusterNode` before the existing flow. +4. Usage text. + +Exit: `bash -n`; `shellcheck` if already installed (nothing new is installed). + +## Phase 3: NB Bond API graceful shutdown + +Added at the operator's request after Phase 1 showed the API ignores SIGTERM. + +1. On `SIGTERM` and `SIGINT`: stop the ingestion loop, stop accepting HTTP connections and close + the server, close the SQLite handles, and exit 0; a second signal or a timeout below the pod's + 30 s grace period exits immediately. +2. Keep the handler in one small module wired from the entrypoint, so it can be tested without a + process signal. +3. One jest test for the shutdown sequence, sized like the neighbouring tests. +4. nb-bond-api gate (lint, format, tests, build). + +Exit: gate green; the live proof in Phase 5 shows the API pod terminating in seconds. + +## Phase 4: Component verbs and Helm context + +For each of `infra/infra.sh`, `services/blockscout/blockscout.sh`, +`services/nb-bond-api/nb-bond-api.sh`, `services/nb-ui/nb-ui.sh`: + +1. Move the current `stop` body to a new `delete` verb. +2. New `stop`: scale the component's Deployments and StatefulSets to zero in its namespaces and + wait for the pods to terminate. +3. Usage text. + +`contracts/contracts.sh`: `stop` prints that contracts live on the chain and there is nothing to +stop; `delete` removes the registry ConfigMap. `sandbox.sh` keeps no call to either. + +Every `helm` call in `common/helpers.sh` and the component scripts gains +`--kube-context "kind-$CLUSTER_NAME"`. Today they act on the current `kubectl` context, and +`sandbox.sh` only sets that context on its `start` path, so a component command run while the +context points at another local cluster reaches that cluster instead. + +Exit: `bash -n` on each script; no `helm` call without `--kube-context`. + +## Phase 5: Live proof + +1. Snapshot. +2. `./sandbox.sh stop`: exit 0, elapsed time (seconds, now the API handles SIGTERM), no other container changed; `./sandbox.sh start`: + no contract redeploy; compare the snapshot; PostgreSQL starts without recovery. +3. `./services/blockscout/blockscout.sh stop` then `start`: pods back, Blockscout counts unchanged + (apart from blocks mined meanwhile being missing, as the docs will say). +4. Same for nb-bond-api: bidders and bonds unchanged. +5. `./services/nb-ui/nb-ui.sh delete` then `start`: release removed, then restored (stateless, so + the safest `delete` to exercise live). +6. No-op paths: `stop` twice, `start` on a running sandbox. + +Exit: results in `progress.md`. + +## Phase 6: Documentation + +1. `README.md` lifecycle table: `stop` keeps all state; `start` resumes a stopped sandbox; `delete` + is the only verb that discards state, and `delete` then `start` is the fresh-sandbox path. + Recommend `stop` before quitting Docker or rebooting. +2. `infra/README.md`, `infra/DEVELOPMENT.md`, `infra/AGENTS.md`, `services/DEVELOPMENT.md`, + `services/AGENTS.md`, `services/nb-ui/AGENTS.md`, `contracts/README.md` and its generated copy + under `contracts/docs/natspec/` (hand-patched): `stop` and `delete` per component. +3. `docs/KNOWN_ISSUES.md`: unclean node shutdowns, the catch-up indexer gap, and the recovery + procedure. +4. `services/blockscout/debugging.md`: the `replorigin_checkpoint` recovery. +5. Hygiene and link checks. + +## Phase 7: PR + +Archive this folder, update `docs/DOCUMENTATION_INDEX.md`, and open the PR against `development` +with the Phase 1 and Phase 5 evidence. + +## Test Matrix + +| Layer | Risk | Evidence | +|---|---|---| +| Script syntax | Broken verb dispatch | `bash -n`, no-op runs | +| Node shutdown | SIGKILL, unclean PostgreSQL stop | exit code, elapsed time, PostgreSQL logs | +| Sandbox state | Lost chain, explorer, or API data; contract redeploy | before/after snapshot, contracts step output | +| Component state | Scale-to-zero not restored by `start` | per-component snapshot, pod listing | +| Component delete | Reset path lost | release and namespace listing | +| API shutdown | SIGKILL after the grace period | jest test, pod termination time | +| Helm context | Command reaches another local cluster | no `helm` call without `--kube-context` | +| Isolation | Other local containers affected | `docker ps -a` before and after | +| Docs | Wrong command recommended | doc diffs, link check | + +## Done Criteria + +- [x] Phase 1 measurement recorded and the design choice made. +- [x] Top-level `stop`/`start` keep all state; `delete` unchanged. +- [x] The NB Bond API shuts down cleanly on SIGTERM. +- [x] Component `stop` keeps state, component `delete` resets; every `helm` call names its context. +- [x] Docs, KNOWN_ISSUES, and the Blockscout debugging guide updated. diff --git a/docs/plans/archive/sandbox-stop-keeps-state/progress.md b/docs/plans/archive/sandbox-stop-keeps-state/progress.md new file mode 100644 index 0000000..ab8594a --- /dev/null +++ b/docs/plans/archive/sandbox-stop-keeps-state/progress.md @@ -0,0 +1,49 @@ +# Stop keeps state, delete deletes — Progress + +**Plan:** [`plan.md`](plan.md) +**Last updated:** 2026-09-25 — Phases 1–6 done; PR opened with the plan archived in it +**Current phase:** Phase 7: PR +**Next action:** Operator reviews and merges the PR + +## Phase Log + +| Phase | Status | Evidence | PR | +|---|---|---|---| +| 1 — Characterise a graceful node stop | Done | `docker stop --time 330` on the node returned after 92 s with container exit code 130 (not 137); the other local containers were unaffected. After `docker start`, the API server was ready in 6 s and every pod in 112 s. PostgreSQL's previous run ended with fast-shutdown connection terminations at 09:24:49 and its next start logged "database system was shut down at 2026-09-25 09:24:49" with no crash recovery; the Blockscout backend also stopped at 09:24:49. Before/after snapshot: API responses, API table counts, and Blockscout verified-contract and transaction counts identical; the chain gained one empty block (80 → 81), which Blockscout indexed. The 92 s matches systemd's 90 s stop timeout: timed pod deletes show the NB Bond API pod needs 31 s (its full 30 s grace period, then SIGKILL) because its PID 1 `node` process has no SIGTERM handler, while `besu-archive-0` exits in 1 s. Decision: plain node stop (exit well inside the timeout, clean PostgreSQL shutdown); the scale-to-zero fallback would not help the API, which ignores SIGTERM either way (2026-09-25) | this PR | +| 2 — Top-level stop and start | Done | `common/helpers.sh`: `clusterNodeContainers` (via `kind get nodes`, so stopped nodes are listed), `nodeContainerRunning`, `stopClusterNodes` (`docker stop --time $SANDBOX_STOP_TIMEOUT_SECONDS`, reports elapsed time, fails on exit 137 with a pointer to the PostgreSQL recovery), `startClusterNodes` and `waitForKubeApiServer` (`/readyz`, 120 s). `createKindCluster` resumes a stopped node instead of failing as unreachable. `sandbox.sh`: `stop` calls only `stopClusterNodes` (no component calls, no marker change); `start` resumes stopped nodes right after the registry check, so it also works with `DEPLOY_INFRA=false`; `SANDBOX_STOP_TIMEOUT_SECONDS` (default 330) with its banner line; usage text. `bash -n` passes on both files; `shellcheck` is not installed and was not added. No-op paths checked against the live cluster: resume of a running node and stop of a missing cluster do nothing. The full stop/start runs in Phase 5 (2026-09-25) | this PR | +| 3 — NB Bond API graceful shutdown | Done | New `src/shutdown.ts`: on the first `SIGTERM`/`SIGINT` it closes the HTTP server and drops open connections (SSE clients would otherwise hold it open), stops the ingestion loop, waits for queued ingestion work with a 10 s bound, and exits 0; a second signal exits 1. `src/ingestion.ts` exports `waitForIngestionIdle()`; `src/index.ts` keeps the server handle and installs the handlers. `tests/shutdown.test.ts` covers the normal path, the timeout path, and the second signal. nb-bond-api lint and prettier clean, 252 jest tests pass (249 + 3), build passes. Real-process check: the built API run locally against a scratch database and the live chain (ingestion at block 83, one SSE client open) exited 0.08 s after `SIGTERM`, logging `received SIGTERM; shutting down` and `shutdown complete` (2026-09-25) | this PR | +| 4 — Component verbs and Helm context | Done | `common/helpers.sh` gains `scaleNamespacesToZero` (scales every Deployment and StatefulSet in the given namespaces to zero, waits until no Running or Pending pods remain, so completed job pods such as `db-init-job` do not block; volumes, releases, and ConfigMaps stay). `stop` now scales to zero in `blockscout.sh` (namespace `blockscout`, with a comment that blocks mined meanwhile are not indexed), `nb-bond-api.sh`, `nb-ui.sh`, and `infra.sh` (`besu` and `nginx-gateway`). New `delete` verbs carry the old bodies: `blockscout.sh delete` deletes the namespace, `nb-bond-api.sh delete` and `nb-ui.sh delete` uninstall their releases. `contracts.sh stop` prints that there is nothing to stop; `contracts.sh delete` removes the registry ConfigMap as `stop` did, and accepts the same flags. Usage text updated in all five scripts. All six `helm upgrade` calls and both remaining `helm uninstall` calls pass `--kube-context "kind-$CLUSTER_NAME"`, and the two context-less `kubectl get pods --all-namespaces` reads behind `image-report` and `cleanup-images` now pass `--context`. `clearContractsDeploymentMarker` removed: its only caller was the old `sandbox.sh stop`. `bash -n` passes on all seven scripts; no `helm` or `kubectl` call without a context remains; image-hash-input, Node-version, and hygiene checks pass (2026-09-25) | this PR | +| 5 — Live proof | Done | Three full cycles from this branch. `./sandbox.sh stop`: exit 0 in 90–91 s each time, node exit code 130, other local containers unaffected; a second `stop` printed "already stopped". `./sandbox.sh start`: resumed the stopped node ("Starting stopped node", API server wait ✔), printed "Contracts already deployed … Skipping deploy", left the `contracts-deployed` marker and every contract address unchanged, and rolled out the API image with the SIGTERM handler (`nb-bond-api:29559e7aa199`); on a running node it skipped the resume. After each cycle, API responses, API table counts, and Blockscout verified-contract and transaction counts matched the snapshot, the chain gained one empty block per cycle, Blockscout indexed it (no gap), and PostgreSQL started with "database system was shut down at …". With the new image the API pod terminates in 1 s (31 s before). Sampling processes inside the node during a stop: every workload process, etcd included, exits within 8 s; `kube-apiserver` and 20 `containerd-shim` processes then stay until systemd's 90 s stop timeout, which is where the 90 s goes; they hold no state. Components: `blockscout.sh stop` in 3 s (all five Deployments 0/0, completed `db-init-job` pod left, PVC and release kept), `blockscout.sh start` restored every Deployment to 1 and PostgreSQL started cleanly; `nb-bond-api.sh stop` in 2 s with the PVC kept, `start` back to 1/1 in 9 s; `nb-ui.sh delete` uninstalled only this cluster's release while the current `kubectl` context pointed at another local cluster whose `nb-ui` release stayed, and `nb-ui.sh start` restored it (web 200); `contracts.sh stop` printed its no-op message and left both ConfigMaps. Final snapshot identical to the pre-component snapshot; the same Docker containers exist as before Phase 5 (2026-09-25) | this PR | +| 6 — Documentation | Done | `README.md` lifecycle table: `start` resumes, `stop` keeps all state (with the timeout flag, the 90 s, and a link to the new known issue), `delete` discards all state and `delete` then `start` is the fresh-sandbox path. `infra/README.md`, `infra/DEVELOPMENT.md`, `infra/AGENTS.md`, `services/DEVELOPMENT.md` (including the Blockscout stop gap and what `blockscout.sh delete` costs), `services/AGENTS.md`, `services/nb-ui/AGENTS.md`, `services/nb-bond-api/DEVELOPMENT.md` (what the SQLite volume survives), `contracts/README.md` and its generated copy under `contracts/docs/natspec/src/` (hand-patched): `stop` and `delete` per component. `docs/KNOWN_ISSUES.md`: new entry on stopping before Docker quits, the 90 s stop, and the catch-up gap. `services/blockscout/debugging.md`: `replorigin_checkpoint` recovery (symptom, cause, commands with explicit context, validation, fallback, prevention); its read-only steps were run against the live sandbox. `docs/DOCUMENTATION_INDEX.md` entry for the debugging playbook updated. Hygiene and link checks pass; anchors checked by hand (2026-09-25) | this PR | +| 7 — PR | In progress | Plan folder archived in the PR itself | this PR | + +## Deviations From the Plan + +- Phase 3 does not close the SQLite handles explicitly. Each better-sqlite3 transaction is + synchronous and committed before control returns, so exiting cannot interrupt a write; the + handles are local to `createApp` and `startIngestionLoop`, so closing them would add plumbing + through both. Exiting releases them, and the WAL is checkpointed on the next open. + +## Verified So Far + +- The lifecycle verbs' current behaviour and callers, `clusterExists`, the charts' replica + settings, the node container's stop signal, timeout and restart policy, the workloads' grace + periods, PostgreSQL's stop signal and `fsync` setting, Besu's empty-block period, and the + catch-up indexer setting, verified on 2026-09-25 (see `design.md`). +- Operator decision on 2026-09-25: `stop` stops and keeps state; `delete` deletes. + +## Follow-ups Found Along the Way + +- Decide whether to enable the Blockscout catch-up indexer so a gap heals itself. +- The NB Bond API does not handle SIGTERM: as PID 1 without a handler, `node` ignores it, so + every stop of its pod ends in a SIGKILL after the grace period, and a node stop takes about + 90 s instead of a few seconds. Folded into this plan as Phase 3 by the operator. +- No `helm` call in the scripts passes `--kube-context`; they act on the current `kubectl` + context, which only `sandbox.sh start` sets. Today's `sandbox.sh stop` would therefore uninstall + same-named releases in whichever local cluster is current. Folded into Phase 4. +- `stopIngestionLoop()` never closes the ingestion loop's write handle, so each + `POST /v1/admin/restart-ingestion` leaves one SQLite handle open until the process exits. Not + fixed here. +- `contracts/docs/natspec/src/README.md`, the generated copy of `contracts/README.md`, already + differed from its source in unrelated places (for example "redemption flow" against "maturity + closure"); only the `stop` section was patched here. A `forge doc` regeneration would resync it. diff --git a/infra/AGENTS.md b/infra/AGENTS.md index fe040e8..33bc7a7 100644 --- a/infra/AGENTS.md +++ b/infra/AGENTS.md @@ -12,8 +12,9 @@ Inherits the root `AGENTS.md`. This file covers infra-specific structure and sty ### How to run - Start: `./infra/infra.sh start` -- Stop: `./infra/infra.sh stop` (keeps cluster and image cache) -- Delete: `./infra/infra.sh delete` (full teardown) +- Stop: `./infra/infra.sh stop` (scales Besu and the gateway to zero; keeps the chain, cluster, and image cache) +- Delete: `./infra/infra.sh delete` (full teardown: removes the cluster and all sandbox state) +- Whole sandbox: `./sandbox.sh stop` stops everything and keeps all state; `./sandbox.sh delete` discards it - Most commands assume you run them from `infra/`. ### Commands (detail) diff --git a/infra/DEVELOPMENT.md b/infra/DEVELOPMENT.md index e5ee599..515b1f9 100644 --- a/infra/DEVELOPMENT.md +++ b/infra/DEVELOPMENT.md @@ -8,15 +8,15 @@ layer: Kind, the local registry workflow, Besu, and the gateway. From `infra/`: - start infra and create the Kind cluster if needed: `./infra.sh start` -- stop infra workloads but keep the cluster and cached images: - `./infra.sh stop` -- delete the Kind cluster while retaining the separate local-registry - container and its cached images: `./infra.sh delete` +- stop Besu and the gateway (scaled to zero) and keep the chain, the cluster, + and cached images: `./infra.sh stop` +- delete the Kind cluster and all sandbox state while retaining the separate + local-registry container and its cached images: `./infra.sh delete` - start the local registry container: `./infra.sh registry-start` - push pinned images into the local registry: `./infra.sh registry-sync` -If you want to preserve the Kind node's own image cache, prefer `stop` over -`delete`. The separate local-registry cache survives either command. +`stop` keeps the chain and the Kind node's own image cache; `delete` loses +both. The separate local-registry cache survives either command. ## Local Registry Workflow diff --git a/infra/README.md b/infra/README.md index cb89eea..d726092 100644 --- a/infra/README.md +++ b/infra/README.md @@ -29,6 +29,11 @@ From `infra/`: ./infra.sh registry-sync ``` +`stop` scales Besu and the gateway to zero and keeps the chain and the +cluster; `start` brings them back. `delete` removes the Kind cluster and all +sandbox state. To stop the whole sandbox, including Blockscout and the NB Bond +API, use `./sandbox.sh stop` from the repository root. + `registry-start` creates or reuses the persistent local registry container, and `registry-sync` pushes the sandbox's pinned images into that registry for Kind to pull. Sandbox deploy/build image pins are centralized in diff --git a/infra/infra.sh b/infra/infra.sh index eb12ed3..570422c 100755 --- a/infra/infra.sh +++ b/infra/infra.sh @@ -13,7 +13,8 @@ function printHelp() { echo " $(basename "$0") [--keep N] [--prune-build-cache]" echo echo "Notes:" - echo " stop keeps the kind cluster and its image cache; delete removes the cluster." + echo " stop scales Besu and the gateway to zero; the chain, cluster, and image cache are kept." + echo " delete removes the kind cluster and all sandbox state (the local registry is kept)." echo " registry-start starts the local registry container." echo " registry-sync pushes configured images to the local registry." echo " registry-reset recreates the registry container and re-syncs base images (reclaims registry disk)." @@ -83,9 +84,7 @@ if [ "$CMD" == "start" ]; then elif [ "$CMD" == "stop" ]; then checkPrereqs ensureLocalhostHostEntries - helm uninstall gateway -n nginx-gateway || true - helm uninstall ngf -n nginx-gateway || true - helm uninstall besu -n besu || true + scaleNamespacesToZero besu nginx-gateway elif [ "$CMD" == "registry-start" ]; then ensureKindRegistry elif [ "$CMD" == "registry-sync" ]; then diff --git a/sandbox.sh b/sandbox.sh index 999a91e..32f1bac 100755 --- a/sandbox.sh +++ b/sandbox.sh @@ -23,9 +23,9 @@ function printHelp() { echo " --prune-build-cache - cleanup-images: also run 'docker builder prune -f' (GLOBAL, all projects)" echo echo " Description:" - echo " start: Start the cluster and components of the cbdc sandbox" - echo " stop: Stop components of the cbdc sandbox (keeps cluster)" - echo " delete: Delete the kind cluster (full teardown)" + echo " start: Start the cluster and components of the cbdc sandbox (resumes a stopped sandbox)" + echo " stop: Stop the sandbox gracefully; keeps the chain, Blockscout, and NB Bond API state" + echo " delete: Delete the kind cluster and all sandbox state (full teardown)" echo " generate-config: Create $DEPLOYMENT_CONFIG_FILE config file with flags to control" echo " which components to deploy." echo " registry-start: Start the local registry container" @@ -46,6 +46,8 @@ function printHelp() { # - DEPLOY_NB_BOND_API: true deploys NB Bond API; false skips the API service. # - DEPLOY_NB_UI: true deploys the NB UI frontend; false skips the operator UI. # - WAIT_FOR_APP_TIMEOUT_SECONDS: max seconds to wait; lower fails faster. +# - SANDBOX_STOP_TIMEOUT_SECONDS: seconds `stop` lets the Kind node shut its pods +# down before Docker kills it; lower risks an unclean stop that can corrupt state. ################################################################################ export DEPLOY_INFRA="true" export DEPLOY_CONTRACTS="true" @@ -55,6 +57,7 @@ export DEPLOY_BLOCKSCOUT="true" export DEPLOY_NB_BOND_API="true" export DEPLOY_NB_UI="true" export WAIT_FOR_APP_TIMEOUT_SECONDS="${WAIT_FOR_APP_TIMEOUT_SECONDS:-60}" +export SANDBOX_STOP_TIMEOUT_SECONDS="${SANDBOX_STOP_TIMEOUT_SECONDS:-330}" function printServiceUrls() { title="SERVICE URLS (READY)" @@ -169,6 +172,11 @@ if [ "$CMD" == "start" ]; then requireKindRegistry + # resume a sandbox that `stop` left with its node container stopped + if [[ $(clusterExists) == "true" ]]; then + startClusterNodes + fi + # deploy infra if [ "$DEPLOY_INFRA" == "true" ]; then echo "Deploying cluster and besu network..." @@ -294,31 +302,9 @@ elif [ "$CMD" == "stop" ]; then exit 0 fi - if [ "$DEPLOY_NB_UI" == "true" ]; then - cd $SCRIPT_DIR/services/nb-ui - ./nb-ui.sh stop --as-subtask || true - fi - - if [ "$DEPLOY_NB_BOND_API" == "true" ]; then - cd $SCRIPT_DIR/services/nb-bond-api - ./nb-bond-api.sh stop --as-subtask || true - fi - - if [ "$DEPLOY_CONTRACTS" == "true" ]; then - cd $SCRIPT_DIR/contracts - ./contracts.sh stop --as-subtask || true - fi - - if [ "$DEPLOY_BLOCKSCOUT" == "true" ]; then - cd $SCRIPT_DIR/services/blockscout - ./blockscout.sh stop --as-subtask || true - fi - - if [ "$DEPLOY_INFRA" == "true" ]; then - cd $SCRIPT_DIR/infra - ./infra.sh stop --as-subtask || true - clearContractsDeploymentMarker - fi + # Stopping the whole node stops the chain with everything else, so Blockscout + # misses no blocks, and every volume, release, and marker stays for `start`. + stopClusterNodes elif [ "$CMD" == "delete" ]; then checkPrereqs ensureLocalhostHostEntries diff --git a/services/AGENTS.md b/services/AGENTS.md index e08fb3d..508a8d2 100644 --- a/services/AGENTS.md +++ b/services/AGENTS.md @@ -10,7 +10,8 @@ Inherits the root `AGENTS.md`. This file adds service-specific guidance. ### Commands (per service) - `services/blockscout/`: - Start: `./blockscout.sh start` - - Stop: `./blockscout.sh stop` + - Stop: `./blockscout.sh stop` (scales to zero; keeps the database) + - Delete: `./blockscout.sh delete` (removes the namespace and its database) - Name service: `cd services/blockscout/bens-microservice && ./bens-microservice.sh start` - BENS OpenAPI regen: `cd services/blockscout/bens-microservice && ./regen-openapi.sh` - Backend/frontend source tags: pinned in `common/images.yaml`; current @@ -19,6 +20,8 @@ Inherits the root `AGENTS.md`. This file adds service-specific guidance. - URLs: `http://blockscout.cbdc-sandbox.local/` - `services/nb-bond-api/`: - Start: `./nb-bond-api.sh start` + - Stop: `./nb-bond-api.sh stop` (scales to zero; keeps the SQLite volume) + - Delete: `./nb-bond-api.sh delete` (uninstalls the release and its volume; operator-added bidder keys are lost) - Local Helm values: generate `services/nb-bond-api/helm/values.local.yaml` with `node scripts/generate-local-sandbox-fixtures.mjs` before direct deploys if the start script has not already created it - Lint: `npm run lint` - Format check: `npm run format:check` diff --git a/services/DEVELOPMENT.md b/services/DEVELOPMENT.md index 9082ba5..bf89ad3 100644 --- a/services/DEVELOPMENT.md +++ b/services/DEVELOPMENT.md @@ -9,10 +9,14 @@ sandbox workflow, start from the repository root with `./sandbox.sh start`. From the repository root: - start the full sandbox: `./sandbox.sh start` -- stop services but keep the Kind cluster and image cache: - `./sandbox.sh stop` -- delete the Kind cluster while retaining the separate local-registry - container and its cached images: `./sandbox.sh delete` +- stop the whole sandbox and keep all state (chain, Blockscout index and + verifications, NB Bond API database); `start` resumes it: `./sandbox.sh stop` +- delete the Kind cluster and all sandbox state while retaining the separate + local-registry container and its cached images: `./sandbox.sh delete` + +Each service script follows the same rule: `stop` scales the service to zero +and keeps its data and Helm release, `start` brings it back, and `delete` +removes the service and its data. Use service-specific scripts only when you are working on one area in isolation and the infra layer is already available. @@ -28,8 +32,19 @@ Manual lifecycle from the repository root: cd services/blockscout ./blockscout.sh start ./blockscout.sh stop +./blockscout.sh delete ``` +`stop` keeps the PostgreSQL volume, so the index and contract verifications +survive. Besu keeps producing blocks meanwhile, and the catch-up indexer is +disabled, so blocks mined while Blockscout is stopped are never indexed; stop +the whole sandbox with `./sandbox.sh stop` to avoid the gap. `delete` removes +the `blockscout` namespace and its database; after the next `start` it indexes +only blocks mined from then on (earlier blocks stay unindexed while catch-up is +disabled), and contracts need re-verifying with +`./contracts/contracts.sh verify-latest`. For a complete explorer, reset the +whole sandbox with `./sandbox.sh delete` and `./sandbox.sh start`. + Primary URLs: - diff --git a/services/blockscout/blockscout.sh b/services/blockscout/blockscout.sh index 5521530..a151318 100755 --- a/services/blockscout/blockscout.sh +++ b/services/blockscout/blockscout.sh @@ -11,7 +11,10 @@ source ../../common/helpers.sh # print help message function printHelp() { echo "Usage is: " - echo " $(basename "$0") " + echo " $(basename "$0") " + echo + echo " stop: scales the component to zero; its data, configuration, and release are kept." + echo " delete: deletes the blockscout namespace, including its PostgreSQL volume (index and verifications)." } # parse command @@ -64,6 +67,10 @@ if [ "$CMD" == "start" ]; then composeBlockscoutChart deployBlockscout elif [ "$CMD" == "stop" ]; then + # Besu keeps producing blocks while Blockscout is stopped, and the catch-up + # indexer is disabled, so blocks mined in the meantime are not indexed. + scaleNamespacesToZero $BLOCKSCOUT_NAMESPACE +elif [ "$CMD" == "delete" ]; then echo "Deleting namespace..." kubectl --context=kind-$CLUSTER_NAME delete namespaces $BLOCKSCOUT_NAMESPACE echo "Shutdown completed successfully!" diff --git a/services/blockscout/debugging.md b/services/blockscout/debugging.md index ce1b772..bca4bce 100644 --- a/services/blockscout/debugging.md +++ b/services/blockscout/debugging.md @@ -65,6 +65,61 @@ IO.inspect(EthereumJSONRPC.fetch_blocks_by_numbers([506], args))"' ``` If this fails but curl works, the issue is in Blockscout client config. +## PostgreSQL does not start after an unclean shutdown + +**Symptom.** The `postgres` pod in `blockscout` is in `CrashLoopBackOff`, the +Blockscout backend waits in its `init-migrations` container, and the PostgreSQL +log ends with: + +```text +database system was not properly shut down; automatic recovery in progress +PANIC: replication checkpoint has wrong magic 0 instead of 307747550 +``` + +**Why.** The Kind node was killed rather than stopped (see +[`docs/KNOWN_ISSUES.md`](../../docs/KNOWN_ISSUES.md#sandbox-must-be-stopped-before-docker-quits-or-the-host-reboots)). +PostgreSQL had just written `pg_logical/replorigin_checkpoint`, its 8-byte list of +replication origins, and the file reached disk as zeros. Crash recovery reads it +before replaying the write-ahead log and aborts. Blockscout uses no replication +origins, so the file only ever says "none". + +**Recovery.** Confirm the PANIC above first; this procedure is for that message +only. Every command names the sandbox's context and node container explicitly. + +```sh +CTX=kind-cluster-cbdc-monoledger +NODE=cluster-cbdc-monoledger-control-plane + +# 1. Stop PostgreSQL so nothing restarts it while the volume is edited. +kubectl --context=$CTX -n blockscout scale deploy/postgres --replicas=0 + +# 2. Find the volume directory on the Kind node. +PV=$(kubectl --context=$CTX -n blockscout get pvc postgres-volume-claim -o jsonpath='{.spec.volumeName}') +DIR=$(kubectl --context=$CTX get pv "$PV" -o jsonpath='{.spec.hostPath.path}') + +# 3. Check the file is all zeros (a healthy one starts `de da 57 12`), +# back up the data directory, and move the file aside. +docker exec $NODE od -A d -t x1 "$DIR/pgdata/pg_logical/replorigin_checkpoint" +docker exec $NODE sh -c "cd '$DIR' && cp -a pgdata pgdata.bak && mv pgdata/pg_logical/replorigin_checkpoint replorigin_checkpoint.zeroed" + +# 4. Start PostgreSQL again. +kubectl --context=$CTX -n blockscout scale deploy/postgres --replicas=1 +``` + +**Validation.** The PostgreSQL log shows `redo done` and `database system is +ready to accept connections`, and PostgreSQL has written a new checkpoint file. +`select count(*) from pg_replication_origin;` returns `0`; the Blockscout backend +finishes its migrations and becomes Ready. Once Blockscout is healthy, remove +`pgdata.bak` and `replorigin_checkpoint.zeroed` from the volume directory. + +**If more is damaged**, recovery fails again with a different error. Then run +`./services/blockscout/blockscout.sh delete` and `start` to re-index, and +`./contracts/contracts.sh verify-latest` to restore verifications. Blocks mined +before the new start stay unindexed while the catch-up indexer is disabled; a +full `./sandbox.sh delete` and `start` gives a complete index instead. + +**Prevention.** Run `./sandbox.sh stop` before quitting Docker or rebooting. + ## Common pitfalls - **`ETHEREUM_JSONRPC_HTTP_INSECURE=true` on plain HTTP** diff --git a/services/nb-bond-api/DEVELOPMENT.md b/services/nb-bond-api/DEVELOPMENT.md index fb61fbc..5094c0e 100644 --- a/services/nb-bond-api/DEVELOPMENT.md +++ b/services/nb-bond-api/DEVELOPMENT.md @@ -628,6 +628,7 @@ To prevent that, the helm chart mounts a `PersistentVolumeClaim` The Kind local-path-provisioner stores the backing directory under the Kind node's filesystem. **Survives** helm upgrades, `./nb-bond-api.sh -start`, `kubectl rollout restart`. **Does not survive** `kind delete +start` and `stop`, `./sandbox.sh stop`, `kubectl rollout restart`. **Does not +survive** `./nb-bond-api.sh delete`, `./sandbox.sh delete`, or `kind delete cluster` — bidders will need to be re-added (or wait for re-seeding of the fixtures) after a full sandbox tear-down. diff --git a/services/nb-bond-api/nb-bond-api.sh b/services/nb-bond-api/nb-bond-api.sh index 3c9c785..d03e525 100755 --- a/services/nb-bond-api/nb-bond-api.sh +++ b/services/nb-bond-api/nb-bond-api.sh @@ -10,7 +10,10 @@ source ../../common/helpers.sh # print help message function printHelp() { echo "Usage is: " - echo " $(basename "$0") " + echo " $(basename "$0") " + echo + echo " stop: scales the component to zero; its data, configuration, and release are kept." + echo " delete: uninstalls the release, including its SQLite volume (bidder keys cannot be recovered)." } @@ -66,5 +69,7 @@ if [ "$CMD" == "start" ]; then fi elif [ "$CMD" == "stop" ]; then - helm uninstall nb-bond-api -n nb-bond-api || true + scaleNamespacesToZero $NB_BOND_API_NAMESPACE +elif [ "$CMD" == "delete" ]; then + helm uninstall --kube-context "kind-$CLUSTER_NAME" nb-bond-api -n nb-bond-api || true fi diff --git a/services/nb-bond-api/src/index.ts b/services/nb-bond-api/src/index.ts index 8d7f08a..2537b3a 100644 --- a/services/nb-bond-api/src/index.ts +++ b/services/nb-bond-api/src/index.ts @@ -2,11 +2,12 @@ import { createApp } from './app'; import { envVariables } from './env-vars'; import { logger } from './logger'; +import { installShutdownHandlers } from './shutdown'; const app = createApp(); const port = envVariables.EXPRESS_PORT; -app.listen(port, () => { +const server = app.listen(port, () => { logger.info(`nb-bond-api listening on ${port}`); }); @@ -15,10 +16,21 @@ app.listen(port, () => { // PC/Docker restart) — the loop self-heals once the chain comes back. // Chain-identity mismatches are also fatal: continuing would expose a // projection checkpoint produced by a different genesis. -import('./ingestion') +const ingestion = import('./ingestion'); +ingestion .then(({ startIngestionLoopWithRetry }) => startIngestionLoopWithRetry()) .catch((err) => { logger.error(`fatal ingestion startup failure: ${(err as Error).message}`); process.exitCode = 1; setImmediate(() => process.exit(1)); }); + +installShutdownHandlers({ + server, + stopIngestion: async () => { + const { stopIngestionLoop, waitForIngestionIdle } = await ingestion; + stopIngestionLoop(); + await waitForIngestionIdle(); + }, + exit: (code) => process.exit(code), +}); diff --git a/services/nb-bond-api/src/ingestion.ts b/services/nb-bond-api/src/ingestion.ts index 078ddaa..e0f313a 100644 --- a/services/nb-bond-api/src/ingestion.ts +++ b/services/nb-bond-api/src/ingestion.ts @@ -111,6 +111,14 @@ export function stopIngestionLoop(): void { loopRunning = false; } +/** + * Resolves once the queued ingestion work has settled. The queue never + * rejects, so graceful shutdown can await it after `stopIngestionLoop()`. + */ +export function waitForIngestionIdle(): Promise { + return ingestionQueue; +} + /** * Test seam — admin.test.ts forces specific module state to validate * the reset path without spinning up a real loop. Not part of the diff --git a/services/nb-bond-api/src/shutdown.ts b/services/nb-bond-api/src/shutdown.ts new file mode 100644 index 0000000..5d5aa35 --- /dev/null +++ b/services/nb-bond-api/src/shutdown.ts @@ -0,0 +1,64 @@ +/** + * Graceful shutdown on SIGTERM / SIGINT. + * + * In the container, `node` runs as PID 1, and PID 1 ignores SIGTERM unless it + * installs a handler, so without this every pod stop ended in SIGKILL after the + * grace period. The handler stops accepting requests, drops open connections + * (SSE clients would otherwise keep the server open), lets queued ingestion work + * settle within a bound below the pod's 30-second grace period, and exits. A + * second signal exits at once. + */ +import type { Server } from 'node:http'; +import { logger } from './logger'; + +export interface ShutdownDependencies { + server: Pick; + /** Stops the ingestion loop and resolves once its in-flight work has settled. */ + stopIngestion: () => Promise; + exit: (code: number) => void; + timeoutMs?: number; +} + +export function createShutdownHandler( + deps: ShutdownDependencies, +): (signal: NodeJS.Signals) => Promise { + const timeoutMs = deps.timeoutMs ?? 10_000; + let shuttingDown = false; + + return async (signal) => { + if (shuttingDown) { + logger.warn(`received ${signal} again during shutdown; exiting now`); + deps.exit(1); + return; + } + shuttingDown = true; + logger.info(`received ${signal}; shutting down`); + + deps.server.close(); + deps.server.closeAllConnections(); + + let timer: ReturnType | undefined; + const timedOut = new Promise<'timeout'>((resolve) => { + timer = setTimeout(() => resolve('timeout'), timeoutMs); + }); + const stopped = deps + .stopIngestion() + .then(() => 'stopped' as const) + .catch(() => 'stopped' as const); + const outcome = await Promise.race([stopped, timedOut]); + clearTimeout(timer); + + if (outcome === 'timeout') { + logger.warn(`ingestion did not settle within ${timeoutMs}ms; exiting anyway`); + } + logger.info('shutdown complete'); + deps.exit(0); + }; +} + +export function installShutdownHandlers(deps: ShutdownDependencies): void { + const handler = createShutdownHandler(deps); + for (const signal of ['SIGTERM', 'SIGINT'] as const) { + process.on(signal, (received) => void handler(received)); + } +} diff --git a/services/nb-bond-api/tests/shutdown.test.ts b/services/nb-bond-api/tests/shutdown.test.ts new file mode 100644 index 0000000..5f4daff --- /dev/null +++ b/services/nb-bond-api/tests/shutdown.test.ts @@ -0,0 +1,61 @@ +import { createShutdownHandler } from '../src/shutdown'; + +function fakeServer() { + return { close: jest.fn(), closeAllConnections: jest.fn() }; +} + +describe('createShutdownHandler', () => { + afterEach(() => { + jest.useRealTimers(); + }); + + it('closes the server, waits for ingestion to settle, then exits 0', async () => { + const server = fakeServer(); + let settle: () => void = () => undefined; + const stopIngestion = jest.fn(() => new Promise((resolve) => (settle = resolve))); + const exit = jest.fn(); + + const shutdown = createShutdownHandler({ server, stopIngestion, exit }); + const done = shutdown('SIGTERM'); + + expect(server.close).toHaveBeenCalledTimes(1); + expect(server.closeAllConnections).toHaveBeenCalledTimes(1); + expect(stopIngestion).toHaveBeenCalledTimes(1); + await Promise.resolve(); + expect(exit).not.toHaveBeenCalled(); + + settle(); + await done; + expect(exit).toHaveBeenCalledWith(0); + }); + + it('exits 0 after the timeout when ingestion does not settle', async () => { + jest.useFakeTimers(); + const exit = jest.fn(); + const shutdown = createShutdownHandler({ + server: fakeServer(), + stopIngestion: () => new Promise(() => undefined), + exit, + timeoutMs: 5_000, + }); + + const done = shutdown('SIGTERM'); + await jest.advanceTimersByTimeAsync(5_000); + await done; + expect(exit).toHaveBeenCalledWith(0); + }); + + it('exits 1 at once on a second signal', async () => { + const exit = jest.fn(); + const shutdown = createShutdownHandler({ + server: fakeServer(), + stopIngestion: () => new Promise(() => undefined), + exit, + timeoutMs: 60_000, + }); + + void shutdown('SIGTERM'); + await shutdown('SIGINT'); + expect(exit).toHaveBeenCalledWith(1); + }); +}); diff --git a/services/nb-ui/AGENTS.md b/services/nb-ui/AGENTS.md index 4629a48..effff68 100644 --- a/services/nb-ui/AGENTS.md +++ b/services/nb-ui/AGENTS.md @@ -45,7 +45,8 @@ This file covers the React + Vite operator frontend specifically. - Lint / format / test: `npm run lint`, `npm run format:check`, `npm test` - Deploy into the local Kind cluster: `./nb-ui.sh start` (called by `./sandbox.sh start` when `DEPLOY_NB_UI=true`) -- Stop the helm release: `./nb-ui.sh stop` +- Stop (scale to zero, keep the helm release): `./nb-ui.sh stop` +- Remove the helm release: `./nb-ui.sh delete` ### Style and conventions diff --git a/services/nb-ui/nb-ui.sh b/services/nb-ui/nb-ui.sh index a6a4a31..71bb69f 100755 --- a/services/nb-ui/nb-ui.sh +++ b/services/nb-ui/nb-ui.sh @@ -10,7 +10,10 @@ source ../../common/helpers.sh # print help message function printHelp() { echo "Usage is: " - echo " $(basename "$0") " + echo " $(basename "$0") " + echo + echo " stop: scales the component to zero; its data, configuration, and release are kept." + echo " delete: uninstalls the release (the UI keeps no data)." } @@ -67,5 +70,7 @@ if [ "$CMD" == "start" ]; then fi elif [ "$CMD" == "stop" ]; then - helm uninstall nb-ui -n nb-ui || true + scaleNamespacesToZero $NB_UI_NAMESPACE +elif [ "$CMD" == "delete" ]; then + helm uninstall --kube-context "kind-$CLUSTER_NAME" nb-ui -n nb-ui || true fi From db9240931cb98dfa65a397bba0f4f76dd7100488 Mon Sep 17 00:00:00 2001 From: Deleo <152405392+Greaker@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:02:06 +0200 Subject: [PATCH 04/18] Enable Blockscout's catch-up indexer so gaps heal themselves (#294) The local Blockscout ran with DISABLE_CATCHUP_INDEXER set, so it only indexed blocks as they arrived. Blocks mined while it was stopped, broken, or freshly deleted were never indexed, and neither was the genesis block, so block indexing never reported finished. No record says why it was disabled; the setting dates from the initial release. Catch-up is now on and every other indexer setting is unchanged. On the local sandbox, enabling it indexed block 0 and block indexing reports finished. Blocks mined while Blockscout was stopped were filled in within seconds of start, and after blockscout.sh delete a fresh start re-indexed all 109 blocks from block 0 within 77 seconds, with verify-latest restoring 13 of 13 verifications. Besu logged no warnings. Docs drop the gap warnings from KNOWN_ISSUES, services/DEVELOPMENT.md, the Blockscout debugging playbook, and the blockscout.sh stop comment. The plan folder is archived in this change. --- docs/DOCUMENTATION_INDEX.md | 1 + docs/KNOWN_ISSUES.md | 11 +-- .../blockscout-catchup-indexer/design.md | 55 ++++++++++++++ .../blockscout-catchup-indexer/intent.md | 63 ++++++++++++++++ .../blockscout-catchup-indexer/plan.md | 75 +++++++++++++++++++ .../blockscout-catchup-indexer/progress.md | 41 ++++++++++ services/DEVELOPMENT.md | 14 ++-- services/blockscout/blockscout.sh | 4 +- services/blockscout/debugging.md | 5 +- services/blockscout/values.backend.env.yaml | 6 +- 10 files changed, 253 insertions(+), 22 deletions(-) create mode 100644 docs/plans/archive/blockscout-catchup-indexer/design.md create mode 100644 docs/plans/archive/blockscout-catchup-indexer/intent.md create mode 100644 docs/plans/archive/blockscout-catchup-indexer/plan.md create mode 100644 docs/plans/archive/blockscout-catchup-indexer/progress.md diff --git a/docs/DOCUMENTATION_INDEX.md b/docs/DOCUMENTATION_INDEX.md index fa8b373..2947861 100644 --- a/docs/DOCUMENTATION_INDEX.md +++ b/docs/DOCUMENTATION_INDEX.md @@ -27,6 +27,7 @@ This file lists the documentation that is most likely to need updates when behav - `docs/plans/sandbox-entity-directory-and-name-service-plan.md`: Standalone sandbox entity-directory/name-service plan with its own persistent database, backend API, search/admin UI, synthetic profile/contact enrichment, optional provenance-aware on-chain registry readers, and a BENS-compatible Blockscout adapter backed by the same directory data. - `docs/plans/erc-3643-incremental-adoption-plan.md`: **Superseded in direction by `docs/decisions/0002-adopt-erc-3643-for-tokenized-securities.md`** (full migration to canonical ERC-3643 for bonds + equities, retiring the partitioned `BondToken` and the ERC-20 `StockToken`). Retained as the record of the *incremental* approach considered and rejected — a shared external identity registry + modular compliance bolted onto the existing partitioned `BondToken` and cash (`Wnok`), closing the bond transfer-eligibility gap first, then agent powers, compliance modules, and full ONCHAINID. Its eligibility-gap analysis and phase mechanics remain a useful reference. - `docs/plans/archive/`: Reference-only archive of completed implementation plans. Status lines inside each plan record the implementing PR and its state where relevant: + - `docs/plans/archive/blockscout-catchup-indexer/`: Plan folder that enabled Blockscout's catch-up indexer so blocks mined while it was stopped, broken, or deleted are indexed after it starts, the genesis block included; records the live proof (genesis, a component-stop gap filled within seconds, a full re-index from block 0 after `blockscout.sh delete`) and two follow-ups found along the way (BENS names missing from Blockscout's API, a BENS startup race on a fresh namespace). Shipped with the change itself. - `docs/plans/archive/sandbox-stop-keeps-state/`: Plan folder that made `stop` keep all state and `delete` the only verb that discards it: `./sandbox.sh stop` stops the Kind node gracefully and `start` resumes it, component `stop` verbs scale to zero while new `delete` verbs carry the old uninstall behaviour, the NB Bond API shuts down on SIGTERM, and every `helm` and `kubectl` call names the sandbox's context; records the live stop/start evidence, the 90-second systemd wait inside the Kind node, and the Blockscout PostgreSQL recovery. Shipped with the change itself. - `docs/plans/archive/better-sqlite3-13-upgrade/`: Plan folder that moved the NB Bond API to `better-sqlite3` 13.0.3 (Node-API addon, prebuilt binaries shipped in the package, `prebuild-install` and its subtree removed, `node-addon-api` added) with a classified lockfile transplant and the inventory row; records the node-gyp step that compiles only without a matching prebuilt binary, and the local-sandbox evidence that the rebuilt image loads the linux binary and that a resync from block 0 reproduces the projection and keeps the system-of-record tables. Shipped with the change itself. - `docs/plans/archive/dependabot-rollup-2026-09-25/`: Plan folder that landed four Dependabot bumps (zod 4.6.5, typescript-eslint 8.70.1 with its siblings, `@vitejs/plugin-react` 6.1.1, `@testing-library/react` 16.3.3) as one rollup with the inventory rows and a classified lockfile transplant; OpenAPI output unchanged, and the `better-sqlite3` 13 major left for its own PR. Shipped with the change itself. diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md index 6c1f524..9c692d1 100644 --- a/docs/KNOWN_ISSUES.md +++ b/docs/KNOWN_ISSUES.md @@ -48,14 +48,9 @@ included, exit within seconds, and the rest is systemd inside the Kind node image waiting its 90-second timeout for `kube-apiserver` and the containerd shims, which hold no state. -- Blockscout runs with its catch-up indexer disabled - (`DISABLE_CATCHUP_INDEXER` in `services/blockscout/values.backend.env.yaml`). - Blocks mined while Besu runs without Blockscout, for example after - `./services/blockscout/blockscout.sh stop` or while its database is broken, - are never indexed. `./sandbox.sh stop` avoids the gap by stopping the chain - too; `./sandbox.sh delete` and `./sandbox.sh start` rebuild a complete index. -- Follow-up: decide whether to enable the catch-up indexer so gaps heal - themselves. +- Blocks mined while Blockscout was down are filled in by its catch-up indexer + once it runs again, so an outage like this one leaves no permanent gap in + the explorer. ## Local QBFT topology has one validator - The default sandbox has one QBFT validator, so it has immediate deterministic diff --git a/docs/plans/archive/blockscout-catchup-indexer/design.md b/docs/plans/archive/blockscout-catchup-indexer/design.md new file mode 100644 index 0000000..057c6f8 --- /dev/null +++ b/docs/plans/archive/blockscout-catchup-indexer/design.md @@ -0,0 +1,55 @@ +# Blockscout catch-up indexer — Design + +**Status:** Approved (2026-09-25) +**Created:** 2026-09-25 +**Intent:** [`intent.md`](intent.md) +**Builds on:** `docs/plans/archive/sandbox-stop-keeps-state/`, `docs/plans/archive/blockscout-v11-upgrade-plan.md`, `docs/post-mortems/post-mortem-blockscout-besu-2026-01-22.md`. + +## Decision Summary + +Set `DISABLE_CATCHUP_INDEXER: "false"` and keep every other indexer setting. With catch-up on, +Blockscout's missing-ranges collector compares the database with `BLOCK_RANGES` (default +`FIRST_BLOCK..latest`, `FIRST_BLOCK` defaults to 0) and the catch-up fetcher fills each missing +range, alongside the realtime indexer. The chain is small, so the repo's conservative batch +settings (one block per batch, concurrency one) are kept; Phase 2 measures whether they are fast +enough. + +## Current-State Evidence + +| Finding | Evidence | Consequence | +|---|---|---| +| **Verified.** `DISABLE_CATCHUP_INDEXER: "true"`, internal-transaction and pending-transaction fetchers disabled, catch-up batch size, concurrency, and missing-ranges batch size all `1` | `services/blockscout/values.backend.env.yaml` | One line changes | +| **Verified.** The setting has been there since the initial release; the Blockscout v11 upgrade plan kept it and noted that an explorer-only restart loses history | `git log`, `docs/plans/archive/blockscout-v11-upgrade-plan.md` | No recorded reason to keep it | +| **Inferred.** It belongs to the same round of trace-timeout tuning as the batch sizes and RPC timeouts, which the 2026-01-22 post-mortem traced to `ETHEREUM_JSONRPC_HTTP_INSECURE` | values-file comments, post-mortem | Enabling catch-up does not reopen that issue; the flag stays `"false"` | +| **Verified.** In the running v11.2.6 release, `DISABLE_CATCHUP_INDEXER` switches `Indexer.Block.Catchup.Supervisor`; the missing-ranges collector and catch-up fetcher live under it; upstream defaults are batch 10, concurrency 10, missing-ranges batch 100 000; `BLOCK_RANGES` defaults to `FIRST_BLOCK..latest` with `FIRST_BLOCK` 0 | `/app/releases/11.2.6/runtime.exs` in the backend pod | Enabling it covers the whole chain from genesis | +| **Verified.** With catch-up disabled, `missing_block_ranges` stayed empty while blocks 106–344 were absent, and the realtime fetcher (`max_gap` 1 000) did not fill a 239-block gap across a restart | live database on 2026-09-25 | Realtime alone cannot heal a restart gap | +| **Verified.** Today Blockscout holds blocks 1–90 of 90, block 0 is missing, and it reports `finished_indexing: false` with ratio 0.98 | indexing-status endpoint, `blocks` table | Block 0 is the first visible effect | +| **Verified.** Internal transactions are not fetched, so catch-up issues block and receipt calls, not traces | values file | RPC load per block is small | +| **Verified.** `blockscout.sh start` applies the values through `helm upgrade`, which rolls the backend with the new environment | `services/blockscout/blockscout.sh`, `common/helpers.sh` | No database migration or reset is needed | + +## Target State + +- `DISABLE_CATCHUP_INDEXER: "false"` with a comment: catch-up fills blocks mined while Blockscout + was not running, including genesis. +- Docs describe a Blockscout-only stop or delete as self-healing, with the time it takes. + +## Alternatives Considered + +- **Leave it disabled and document the gap.** Already done in #293; it keeps the explorer + incomplete after every Blockscout-only interruption and never reaches `finished_indexing`. +- **Enable catch-up and restore upstream batch defaults (10/10).** Faster, but a second change + with no demonstrated need at this chain size; revisit if Phase 2 shows catch-up is slow. +- **Raise `INDEXER_REALTIME_FETCHER_MAX_GAP`.** Does not help: the realtime fetcher did not fill a + gap below its current limit across a restart. + +## Decisions + +| Decision | Recommendation | Operator action | +|---|---|---| +| Enable catch-up | Yes | Approve the plan | +| Batch settings | Keep at 1 unless Phase 2 shows a problem | None | + +## Residual Risks + +- A very long outage leaves a long gap to fill at one block per batch; Phase 2 measures the rate. +- `blockscout.sh delete` still loses contract verifications; `verify-latest` restores them. diff --git a/docs/plans/archive/blockscout-catchup-indexer/intent.md b/docs/plans/archive/blockscout-catchup-indexer/intent.md new file mode 100644 index 0000000..564cb37 --- /dev/null +++ b/docs/plans/archive/blockscout-catchup-indexer/intent.md @@ -0,0 +1,63 @@ +# Blockscout catch-up indexer — Intent + +**Status:** Approved (2026-09-25) +**Created:** 2026-09-25 +**Requested by:** sandbox operator + +## Outcome + +Blockscout indexes every block of the local chain, whatever happened while it was not running. +Blocks mined while Blockscout was stopped, broken, or freshly deleted are filled in after it +starts again, the genesis block included, and the explorer reports indexing as finished. + +## Why This Change + +The local Blockscout runs with `DISABLE_CATCHUP_INDEXER: "true"`, so it only indexes blocks as +they arrive. On 2026-09-25 that left blocks 106–344 permanently missing after a ten-day +PostgreSQL outage, and the only way to a complete explorer was a full sandbox reset. The same +gap follows any `./services/blockscout/blockscout.sh stop` while Besu keeps producing blocks +(an empty block every five minutes), and `./services/blockscout/blockscout.sh delete` followed by +`start` indexes nothing from before the new start. Even a healthy sandbox never indexes block 0, +so Blockscout reports `finished_indexing: false` permanently. + +No record says why catch-up was disabled. The setting dates from the initial release, next to +trace-timeout tuning that the 2026-01-22 post-mortem later traced to an unrelated flag. + +## Scope + +### In Scope + +- `services/blockscout/values.backend.env.yaml`: enable the catch-up indexer, with a comment on + why it is on. +- Live proof on the local sandbox: block 0 indexed, a gap created by a component stop filled, and + a full re-index after `blockscout.sh delete`. +- Documentation that currently describes the gap: the `docs/KNOWN_ISSUES.md` entry, + `services/DEVELOPMENT.md` (Blockscout stop and delete), and the fallback paragraph in + `services/blockscout/debugging.md`. + +### Out of Scope + +- The other indexer settings (internal-transaction and pending-transaction fetchers stay + disabled; batch and concurrency values stay as they are unless Phase 2 shows catch-up is too + slow to be useful). +- Besu configuration. +- Any non-local deployment; the values file is local-only by its own header. + +## Acceptance Criteria + +| Criterion | Risk addressed | Verification evidence | +|---|---|---| +| After the change, Blockscout indexes block 0 and reports `finished_indexing_blocks: true` | The genesis gap | indexing-status endpoint, `blocks` table | +| Blocks mined during a `blockscout.sh stop` are indexed after `start` | Component-stop gap | block numbers before, during, and after | +| After `blockscout.sh delete` and `start`, the whole chain is indexed again and `verify-latest` restores verifications | Explorer-only reset leaves history out | block count against chain head, verified-contract count | +| No new errors in the Blockscout backend or Besu logs during catch-up; the realtime indexer keeps up with the head | Load or RPC regressions | logs, head lag | +| Docs no longer describe the gap as current behaviour | Stale guidance | doc diffs | + +## Constraints + +- No new dependency; one values change. +- Public-repo rules. + +## Open Questions + +None blocking. diff --git a/docs/plans/archive/blockscout-catchup-indexer/plan.md b/docs/plans/archive/blockscout-catchup-indexer/plan.md new file mode 100644 index 0000000..8914c2f --- /dev/null +++ b/docs/plans/archive/blockscout-catchup-indexer/plan.md @@ -0,0 +1,75 @@ +# Blockscout catch-up indexer — Implementation Plan + +**Status:** Implemented — shipped in the same PR as the change; archived 2026-09-25 +**Created:** 2026-09-25 +**Scope:** `services/blockscout/values.backend.env.yaml`, `docs/KNOWN_ISSUES.md`, `services/DEVELOPMENT.md`, `services/blockscout/debugging.md` +**Intent:** [`intent.md`](intent.md) · **Design:** [`design.md`](design.md) · **Progress:** [`progress.md`](progress.md) + +## Plan Order + +```text +Phase 1 Enable catch-up and apply it to the running Blockscout +Phase 2 Live proof: genesis, a component-stop gap, a full re-index after delete +Phase 3 Documentation +Phase 4 PR +``` + +One PR; the plan folder is archived in it. + +## Phase 1: Enable catch-up + +1. Baseline: chain head, Blockscout block count, minimum and maximum block, `missing_block_ranges` + count, indexing status, verified-contract count. +2. `DISABLE_CATCHUP_INDEXER: "false"` with its comment. +3. `./services/blockscout/blockscout.sh start` from the branch (a `helm upgrade`); wait for the + backend to be Ready. +4. Confirm the backend environment carries the new value. + +Recovery: set the value back to `"true"` and run `start` again. + +Exit: backend running with catch-up enabled, no new errors in its log. + +## Phase 2: Live proof + +1. Genesis: block 0 appears in `blocks`; indexing status reports `finished_indexing_blocks: true`. +2. Component-stop gap: record the head, `blockscout.sh stop`, wait until Besu has produced at least + two empty blocks (about ten minutes), `blockscout.sh start`, and confirm those blocks are + indexed; record how long catch-up took. +3. Full re-index: `blockscout.sh delete`, `blockscout.sh start`, then confirm the block count + equals the chain head plus one and the range starts at 0; run + `./contracts/contracts.sh verify-latest` and confirm 13 of 13; record the time to finish. +4. During each step: no new errors in the backend log, Besu logs clean, realtime indexing keeps up + with the head. + +Exit: results recorded in `progress.md`; if catch-up is too slow to be useful, stop and decide on +the batch settings with the operator. + +## Phase 3: Documentation + +1. `docs/KNOWN_ISSUES.md`: drop the catch-up bullets and follow-up from the shutdown entry. +2. `services/DEVELOPMENT.md`: Blockscout `stop` and `delete` now self-heal; state the observed + times and that `delete` still needs `verify-latest`. +3. `services/blockscout/debugging.md`: the fallback paragraph no longer warns about unindexed + blocks. +4. Hygiene and link checks. + +## Phase 4: PR + +Archive this folder, update `docs/DOCUMENTATION_INDEX.md`, open the PR against `development` with +the Phase 2 evidence. + +## Test Matrix + +| Layer | Risk | Evidence | +|---|---|---| +| Config | Setting not applied | backend environment | +| Indexing | Gaps not filled | `blocks` against chain head, `missing_block_ranges` | +| Verification | Lost after delete | `verify-latest` result | +| Load | RPC or database errors | backend and Besu logs, head lag | +| Docs | Stale gap warnings | doc diffs, link check | + +## Done Criteria + +- [x] Catch-up enabled and applied. +- [x] Genesis, stop gap, and full re-index proven live. +- [x] Docs updated. diff --git a/docs/plans/archive/blockscout-catchup-indexer/progress.md b/docs/plans/archive/blockscout-catchup-indexer/progress.md new file mode 100644 index 0000000..a65a2a3 --- /dev/null +++ b/docs/plans/archive/blockscout-catchup-indexer/progress.md @@ -0,0 +1,41 @@ +# Blockscout catch-up indexer — Progress + +**Plan:** [`plan.md`](plan.md) +**Last updated:** 2026-09-25 — Phases 1–3 done; PR opened with the plan archived in it +**Current phase:** Phase 4: PR +**Next action:** Operator reviews and merges the PR + +## Phase Log + +| Phase | Status | Evidence | PR | +|---|---|---|---| +| 1 — Enable catch-up | Done | Baseline: chain head 104; Blockscout blocks 1–104 (block 0 missing), `missing_block_ranges` 0, 12 verified contracts, indexing ratio 0.99, `finished_indexing: false`. `DISABLE_CATCHUP_INDEXER: "false"` with its comment; `./services/blockscout/blockscout.sh start` from the branch (9 s, `helm upgrade`) rolled the backend, whose Deployment environment now carries `false`. Within a minute of boot the database held blocks 0–105 (106 blocks, genesis included) and the `block_catchup` fetcher logged "Index already caught up." every 10 s. The new pod logged five error records, all within 240 ms of boot and all from the web application (three Phoenix counters and event handlers starting before the endpoint's ETS table exists, the rate-limit config falling back to local settings, a missing static cache manifest); none from the indexer and none after boot (2026-09-25) | this PR | +| 2 — Live proof | Done | Genesis: indexing status `finished_indexing_blocks: true`, block ratio 1.00, blocks 0–105 of head 105. Component-stop gap: Blockscout stopped at head 105 (11:31:56 UTC); Besu produced blocks 106 and 107 by 11:37:51; `blockscout.sh start` at 11:38:01; block 106 indexed at 11:38:13 by the catch-up fetcher ("Index had to catch up", `missing_block_count` 1, range 106–106) and head block 107 at 11:38:28 by the realtime indexer; blocks 0–107 complete. Full re-index: `blockscout.sh delete` removed the namespace and its volume (167 s, mostly the backend's termination grace period); `blockscout.sh start` on an empty database had all 109 blocks (0–108) and all 74 transactions indexed within 77 s, by the time the backend was Ready, catch-up walking back from the head at about one block per second; `missing_block_ranges` 0; `./contracts/contracts.sh verify-latest` verified 13 of 13. Logs: both Besu nodes logged no WARN or ERROR in the last 20 minutes; Blockscout matched the chain head (108 of 108); each backend boot logged the same web-application startup errors within 250 ms of boot (endpoint ETS table not yet created, rate-limit config fallback, static manifest), none from the indexer. The fresh namespace also logged two BENS `500` responses at 11:42:46 and 11:42:50 (`relation "mapping" does not exist`: Blockscout indexed before BENS had created its table); none since, and the table now has 17 rows (2026-09-25) | this PR | +| 3 — Documentation | Done | `docs/KNOWN_ISSUES.md`: the shutdown entry's catch-up bullet and follow-up replaced by one line saying catch-up fills blocks mined while Blockscout was down. `services/DEVELOPMENT.md`: Blockscout `stop` gaps fill within seconds after `start`, and after `delete` catch-up re-indexes from block 0 in about a minute at this chain size; `verify-latest` still needed after `delete`. `services/blockscout/debugging.md`: the fallback paragraph says catch-up re-indexes from block 0. `services/blockscout/blockscout.sh`: the `stop` comment no longer says blocks are lost (`bash -n` passes). No stale gap wording left in the repo docs or scripts; hygiene and link checks pass (2026-09-25) | this PR | +| 4 — PR | In progress | Plan folder archived in the PR itself | this PR | + +## Deviations From the Plan + +- The intent spoke of the explorer reporting indexing as finished. Block indexing reports + finished (`finished_indexing_blocks: true`); the overall `finished_indexing` flag stays false + because it also counts internal transactions, whose fetcher is disabled and out of scope. +- Archived in the same PR as the change instead of a separate document-move PR. +- The full re-index took about as long as the backend's startup, so the batch settings stay at 1. +- Phase 3 also updated the `stop` comment in `services/blockscout/blockscout.sh`, which repeated + the old gap warning. + +## Verified So Far + +- The current indexer settings and their history, the v11.2.6 runtime configuration for catch-up, + the empty `missing_block_ranges` table during the 2026-09-25 gap, and today's coverage (blocks + 1–90 of 90, block 0 missing, ratio 0.98), verified on 2026-09-25 (see `design.md`). + +## Follow-ups Found Along the Way + +- Blockscout returns `ens_domain_name: null` for addresses that BENS resolves: BENS answers + `addresses:batch-resolve-names` correctly (for example `Operator1`, `Alice.sec`) and Blockscout + calls it on reads, but the names do not reach the API responses. **Inferred** pre-existing, + because nothing on that path depends on the catch-up setting; not verified against a + catch-up-off baseline. +- On a freshly created `blockscout` namespace, the indexer can call BENS before BENS has created + its `mapping` table, which returns `500` for the first few seconds. diff --git a/services/DEVELOPMENT.md b/services/DEVELOPMENT.md index bf89ad3..33a65f6 100644 --- a/services/DEVELOPMENT.md +++ b/services/DEVELOPMENT.md @@ -36,14 +36,12 @@ cd services/blockscout ``` `stop` keeps the PostgreSQL volume, so the index and contract verifications -survive. Besu keeps producing blocks meanwhile, and the catch-up indexer is -disabled, so blocks mined while Blockscout is stopped are never indexed; stop -the whole sandbox with `./sandbox.sh stop` to avoid the gap. `delete` removes -the `blockscout` namespace and its database; after the next `start` it indexes -only blocks mined from then on (earlier blocks stay unindexed while catch-up is -disabled), and contracts need re-verifying with -`./contracts/contracts.sh verify-latest`. For a complete explorer, reset the -whole sandbox with `./sandbox.sh delete` and `./sandbox.sh start`. +survive. Besu keeps producing blocks meanwhile; after `start`, Blockscout's +catch-up indexer fills them in within seconds. `delete` removes the +`blockscout` namespace and its database; after the next `start`, catch-up +re-indexes the whole chain from block 0 (about a minute for a chain of a +hundred blocks), and contracts need re-verifying with +`./contracts/contracts.sh verify-latest`. Primary URLs: diff --git a/services/blockscout/blockscout.sh b/services/blockscout/blockscout.sh index a151318..bdb9858 100755 --- a/services/blockscout/blockscout.sh +++ b/services/blockscout/blockscout.sh @@ -67,8 +67,8 @@ if [ "$CMD" == "start" ]; then composeBlockscoutChart deployBlockscout elif [ "$CMD" == "stop" ]; then - # Besu keeps producing blocks while Blockscout is stopped, and the catch-up - # indexer is disabled, so blocks mined in the meantime are not indexed. + # Besu keeps producing blocks while Blockscout is stopped; the catch-up + # indexer fills them in after the next start. scaleNamespacesToZero $BLOCKSCOUT_NAMESPACE elif [ "$CMD" == "delete" ]; then echo "Deleting namespace..." diff --git a/services/blockscout/debugging.md b/services/blockscout/debugging.md index bca4bce..d65b267 100644 --- a/services/blockscout/debugging.md +++ b/services/blockscout/debugging.md @@ -114,9 +114,8 @@ finishes its migrations and becomes Ready. Once Blockscout is healthy, remove **If more is damaged**, recovery fails again with a different error. Then run `./services/blockscout/blockscout.sh delete` and `start` to re-index, and -`./contracts/contracts.sh verify-latest` to restore verifications. Blocks mined -before the new start stay unindexed while the catch-up indexer is disabled; a -full `./sandbox.sh delete` and `start` gives a complete index instead. +`./contracts/contracts.sh verify-latest` to restore verifications. The catch-up +indexer re-indexes the whole chain from block 0. **Prevention.** Run `./sandbox.sh stop` before quitting Docker or rebooting. diff --git a/services/blockscout/values.backend.env.yaml b/services/blockscout/values.backend.env.yaml index 0648bcf..9145f6b 100644 --- a/services/blockscout/values.backend.env.yaml +++ b/services/blockscout/values.backend.env.yaml @@ -36,7 +36,11 @@ blockscout: INDEXER_RECEIPTS_BATCH_SIZE: "1" INDEXER_RECEIPTS_CONCURRENCY: "1" # DISABLE_INDEXER: "false" - DISABLE_CATCHUP_INDEXER: "true" + # Catch-up fills every block the database is missing (from FIRST_BLOCK, + # default 0, to the head): genesis, and blocks mined while Blockscout was + # stopped, broken, or freshly deleted. The realtime indexer alone never + # goes back for them. + DISABLE_CATCHUP_INDEXER: "false" # DISABLE_REALTIME_INDEXER: "true" # FIRST_BLOCK: "0" # LAST_BLOCK: "2" From 47a87ac3364b6ea50a759109f9a19cf902c426dc Mon Sep 17 00:00:00 2001 From: Deleo <152405392+Greaker@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:42:59 +0200 Subject: [PATCH 05/18] Tbd: bind cctFrom to the payer and remove the unused nomination path (#300) * Tbd: bind cctFrom to the payer and remove the unused nomination path cctFrom now moves only the caller's own deposit, unless the caller is the DvP contract passed to the Tbd constructor, which keeps settling on behalf of the payer. CCT_FROM_CALLER_ROLE is still required. Any other caller reverts with the new CctFromNotCaller error, so the payer rule lives in the contract rather than in how the role is assigned. The government-nomination path has had no consumer since bond cash legs moved to wNOK, so it is removed: the constructor loses its reserve argument, and govReserve, isGovernmentNominated, the reserve mint branch in _update and NotGovernmentNominated are gone. The TBD and wNOK setup scripts no longer allowlist the reserve on TBD Nordea or give it a wNOK allowance there. The DvP settlement fixture now constructs its TBDs with the DvP that settles, instead of deploying a second one afterwards. The NB Bond API deploys created banks with the six-argument constructor from refreshed Tbd ABI and bytecode artifacts, drops the government field from the TBD DTO (OpenAPI regenerated), and the Banking page drops the matching KPI. Contract reference, NatSpec pages and the TBD diagrams describe the caller rule. Tests: forge 393 passed (new: payer-only cctFrom and DvP settlement on behalf of the payer); nb-bond-api jest 254 passed; nb-ui vitest 125 passed. * ERC-1410: keep operator transfers in their partition (#301) operatorTransferByPartition no longer reads a destination partition from the first 32 bytes of data. Like transferByPartition, it now always moves units within the partition it is given and treats data and operatorData as opaque event payload. The internal _transferByPartition and _move helpers take a single partition, so a partition's supply changes only through mint and redemption, and ChangedPartition stays in IERC1410 as part of the standard surface but is never emitted. The bond stack never used partition changes: BondDvP always passes empty data. The transferByPartition NatSpec, which described a data-driven partition switch it never performed, is corrected. The BondToken invariant suite now runs two ISINs and a holder transfer action that names either partition in data, and checks supply against offering and holder balances per partition. Tests: forge 395 passed (new: an operator transfer stays in its source partition and emits no ChangedPartition; a unit moved with another partition in data does not reduce that partition's issuance headroom; extended invariants). The BondToken ABI is unchanged. --- contracts/docs/contracts-reference.md | 14 ++-- .../src/common/Errors.sol/library.Errors.md | 4 +- .../ERC1410.sol/contract.ERC1410Minimal.md | 14 ++-- .../private-bank/ITbd.sol/interface.ITbd.md | 20 +---- .../src/private-bank/Tbd.sol/contract.Tbd.md | 68 +++++----------- .../script/norges-bank/07_WnokSetup.s.sol | 8 -- contracts/script/private-bank/04_Tbd.s.sol | 17 +--- contracts/src/common/Errors.sol | 2 +- contracts/src/norges-bank/ERC1410/ERC1410.sol | 57 ++++++-------- contracts/src/private-bank/ITbd.sol | 11 +-- contracts/src/private-bank/Tbd.sol | 51 ++++-------- .../test/integration/Dvp-settlement.t.sol | 42 ++++++++-- contracts/test/integration/OrderBook.t.sol | 4 +- .../test/invariant/BondToken.invariant.t.sol | 78 ++++++++++++++----- .../test/norges-bank/BondOrderBook.t.sol | 2 +- contracts/test/norges-bank/BondToken.t.sol | 55 ++++++++++++- contracts/test/private-bank/Tbd.t.sol | 47 +++++++++-- .../architecture/contract-topology.md | 2 +- .../processes/bank-creation-sequence.md | 2 +- .../processes/tbd-cross-bank-transfer.md | 6 +- services/nb-bond-api/README.md | 2 +- services/nb-bond-api/openapi.json | 32 +------- services/nb-bond-api/src/abi/Tbd.json | 52 ++++--------- services/nb-bond-api/src/abi/TbdBytecode.json | 2 +- services/nb-bond-api/src/banking-tbd.ts | 24 ++---- services/nb-bond-api/src/banks.ts | 6 +- services/nb-bond-api/src/contracts/banking.ts | 13 +--- services/nb-bond-api/tests/banks.test.ts | 16 ++++ .../nb-bond-api/tests/custom-errors.test.ts | 10 +++ services/nb-ui/src/pages/BankingPage.jsx | 16 +--- services/nb-ui/tests/BankingPage.test.jsx | 2 - 31 files changed, 340 insertions(+), 339 deletions(-) diff --git a/contracts/docs/contracts-reference.md b/contracts/docs/contracts-reference.md index 1dedaaa..ee2ac69 100644 --- a/contracts/docs/contracts-reference.md +++ b/contracts/docs/contracts-reference.md @@ -244,6 +244,10 @@ Important notes: - The preferred integration path is usually through `BondManager`, not by calling `BondToken` mutation functions directly. - Coupon yield is set from the clearing rate of the initial RATE auction. +- `transferByPartition` and `operatorTransferByPartition` always keep units in + the source partition: `data` is opaque and never selects a destination + partition, so `ChangedPartition` is never emitted and a partition's supply + changes only through mint and redemption. - The implementation is ERC1410-inspired and partition-based, but external integrators should still review the repo-specific semantics before assuming drop-in compatibility with other security-token systems. @@ -289,13 +293,13 @@ Key functions: - `cctFrom(from, to, toTbdContract, value)`: moves value within the same bank or across banks via the `Wnok` bridge. + The caller needs `CCT_FROM_CALLER_ROLE` and must be the payer (`from`) or the + DvP contract passed to the constructor; otherwise it reverts with + `CctFromNotCaller`. - `cctSetToAddr(to)`: sets the payout address used by the receiving `Tbd` during cross-bank flows. - `getBankAddress()`: returns the bank address associated with this `Tbd`. -- `govReserve()` and `isGovernmentNominated()`: - expose whether the contract is configured for government-reserve-backed - flows. - `mint(account, value)` and `burn(account, value)`: privileged supply management. @@ -305,8 +309,8 @@ Important notes: prepares the receiver via `cctSetToAddr`, then moves `Wnok` into the target `Tbd` using `transferFromAndCall`. - The receiver-side mint path is driven by `onTransferReceived`. -- Government nomination changes behavior: transfers from the reserve can mint - from reserve-backed `Wnok` on demand. +- New TBD supply comes only from `mint` (bank `MINTER_ROLE`) or from that + receiver-side callback; the constructor takes no reserve account. ### `BondOrderBook` diff --git a/contracts/docs/natspec/src/src/common/Errors.sol/library.Errors.md b/contracts/docs/natspec/src/src/common/Errors.sol/library.Errors.md index 81b9268..f80abd9 100644 --- a/contracts/docs/natspec/src/src/common/Errors.sol/library.Errors.md +++ b/contracts/docs/natspec/src/src/common/Errors.sol/library.Errors.md @@ -237,10 +237,10 @@ error TokenTransferFailed(); error CctFailed(); ``` -### NotGovernmentNominated +### CctFromNotCaller ```solidity -error NotGovernmentNominated(); +error CctFromNotCaller(address from, address caller); ``` ### BondTokenAddressZero diff --git a/contracts/docs/natspec/src/src/norges-bank/ERC1410/ERC1410.sol/contract.ERC1410Minimal.md b/contracts/docs/natspec/src/src/norges-bank/ERC1410/ERC1410.sol/contract.ERC1410Minimal.md index 4655078..4885f07 100644 --- a/contracts/docs/natspec/src/src/norges-bank/ERC1410/ERC1410.sol/contract.ERC1410Minimal.md +++ b/contracts/docs/natspec/src/src/norges-bank/ERC1410/ERC1410.sol/contract.ERC1410Minimal.md @@ -358,9 +358,9 @@ function isOperatorForPartition(bytes32 partition, address operator, address tok ### transferByPartition -Transfer value from the caller out of a partition. +Transfer value from the caller within a partition. -If `data` is 32 bytes long, it is treated as the destination partition. +Units stay in `partition`; `data` is opaque and only echoed in the event. ```solidity @@ -374,6 +374,9 @@ function transferByPartition(bytes32 partition, address to, uint256 value, bytes Operator transfer respecting global and partition-level approvals. +Units stay in `partition`; `data` and `operatorData` are opaque and only echoed in +the event. Moving units between partitions is not supported. + ```solidity function operatorTransferByPartition( @@ -453,8 +456,7 @@ function revokeOperatorByPartition(bytes32 partition, address operator) external ```solidity function _transferByPartition( - bytes32 fromPartition, - bytes32 toPartition, + bytes32 partition, address operator, address from, address to, @@ -466,9 +468,11 @@ function _transferByPartition( ### _move +Moves `value` units of `partition` from `from` to `to`. Partition supply is unchanged. + ```solidity -function _move(bytes32 fromPartition, bytes32 toPartition, address from, address to, uint256 value) internal; +function _move(bytes32 partition, address from, address to, uint256 value) internal; ``` ### _mint diff --git a/contracts/docs/natspec/src/src/private-bank/ITbd.sol/interface.ITbd.md b/contracts/docs/natspec/src/src/private-bank/ITbd.sol/interface.ITbd.md index 112f2e1..167c557 100644 --- a/contracts/docs/natspec/src/src/private-bank/ITbd.sol/interface.ITbd.md +++ b/contracts/docs/natspec/src/src/private-bank/ITbd.sol/interface.ITbd.md @@ -7,6 +7,7 @@ Moves a `value` amount of tokens from the from account to `to` via the customer credit transfer (cct) settlement process, using the CBDC. +The caller must be the payer (`from`) or the DvP contract the TBD was constructed with. ```solidity @@ -42,22 +43,3 @@ function cctSetToAddr(address to) external; |----|----|-----------| |`to`|`address`|The client's TBD address to which tokens are being transferred during all following cct calls from the same sending TBC contract.| - -### govReserve - -Returns government reserve address if nominated. - - -```solidity -function govReserve() external view returns (address); -``` - -### isGovernmentNominated - -Returns if TBD has been government nominated for reserve access. - - -```solidity -function isGovernmentNominated() external view returns (bool); -``` - diff --git a/contracts/docs/natspec/src/src/private-bank/Tbd.sol/contract.Tbd.md b/contracts/docs/natspec/src/src/private-bank/Tbd.sol/contract.Tbd.md index 88f1525..7a36325 100644 --- a/contracts/docs/natspec/src/src/private-bank/Tbd.sol/contract.Tbd.md +++ b/contracts/docs/natspec/src/src/private-bank/Tbd.sol/contract.Tbd.md @@ -25,6 +25,16 @@ Wnok private immutable _WNOK ``` +### _DVP +The DvP contract given at construction; the only caller that may move another account's +funds through `cctFrom`. + + +```solidity +address private immutable _DVP +``` + + ## State Variables ### _supportedInterfaces ERC165 supported interfaces. @@ -45,15 +55,6 @@ mapping(address from => address to) private _cctFromToList ``` -### govReserve -Defined government reserve account; - - -```solidity -address public govReserve -``` - - ## Functions ### constructor @@ -61,15 +62,9 @@ Create a new TBD token. ```solidity -constructor( - address admin, - address bank, - address wnok, - address dvp, - string memory name_, - string memory symbol_, - address _govReserve -) Allowlist(admin) ERC20(name_, symbol_); +constructor(address admin, address bank, address wnok, address dvp, string memory name_, string memory symbol_) + Allowlist(admin) + ERC20(name_, symbol_); ``` **Parameters** @@ -78,10 +73,9 @@ constructor( |`admin`|`address`|The user to receive DEFAULT_ADMIN_ROLE| |`bank`|`address`|The bank which owns the token| |`wnok`|`address`|The global central bank contract| -|`dvp`|`address`|The global DvP contract| +|`dvp`|`address`|The global DvP contract; granted CCT_FROM_CALLER_ROLE and allowed to settle on behalf of the payer| |`name_`|`string`|of the TBD contract| |`symbol_`|`string`|of the TBD token| -|`_govReserve`|`address`|| ### supportsInterface @@ -140,6 +134,10 @@ function cctSetToAddr(address to) external; Moves a `value` amount of tokens from the from account to `to` via the customer credit transfer (cct) settlement process, using the CBDC. +The caller must be the payer (`from`) or the DvP contract the TBD was constructed with. + +The caller must hold CCT_FROM_CALLER_ROLE and be either the payer (`from`) or the DvP +contract given at construction. ```solidity @@ -179,36 +177,6 @@ function getBankAddress() external view returns (address); |``|`address`|The bank address.| -### isGovernmentNominated - -Is TBD used for government issuance. - - -```solidity -function isGovernmentNominated() public view returns (bool); -``` -**Returns** - -|Name|Type|Description| -|----|----|-----------| -|``|`bool`|Boolean if TBD has been nominated.| - - -### _mintFromGovReserve - -A mint function to convert gov. WNOK to TBD. - - -```solidity -function _mintFromGovReserve(uint256 _value) internal; -``` -**Parameters** - -|Name|Type|Description| -|----|----|-----------| -|`_value`|`uint256`|Value to mint in token units.| - - ### mint A mint function callable by MINTER_ROLE. diff --git a/contracts/script/norges-bank/07_WnokSetup.s.sol b/contracts/script/norges-bank/07_WnokSetup.s.sol index 7bc5d1f..b23502d 100644 --- a/contracts/script/norges-bank/07_WnokSetup.s.sol +++ b/contracts/script/norges-bank/07_WnokSetup.s.sol @@ -71,13 +71,5 @@ contract WnokSetupScript is RegistryScript { vm.startBroadcast(dnbKey); wnok.approve(tbdDnbAddr, type(uint256).max); vm.stopBroadcast(); - - /** - * @dev See {IERC20-approve} - * NOTE: grant nominated gov. bank infinite approval to reserve EOA - */ - vm.startBroadcast(govReserveKey); - wnok.approve(tbdNordeaAddr, type(uint256).max); - vm.stopBroadcast(); } } diff --git a/contracts/script/private-bank/04_Tbd.s.sol b/contracts/script/private-bank/04_Tbd.s.sol index bfa4683..31cb098 100644 --- a/contracts/script/private-bank/04_Tbd.s.sol +++ b/contracts/script/private-bank/04_Tbd.s.sol @@ -22,7 +22,6 @@ contract TbdScript is RegistryScript { uint256 key; string contractName; string contractSymbol; - address govReserveAddr; } /** @@ -34,8 +33,7 @@ contract TbdScript is RegistryScript { addr: vm.addr(vm.envUint("PK_NORDEA")), key: vm.envUint("PK_NORDEA"), contractName: vm.envString("TBD_NORDEA_CONTRACT_NAME"), - contractSymbol: vm.envString("TBD_NORDEA_CONTRACT_SYMBOL"), - govReserveAddr: vm.addr(vm.envUint("PK_GOV_RESERVE")) + contractSymbol: vm.envString("TBD_NORDEA_CONTRACT_SYMBOL") }); /// @dev Load DNB bank data from environment variables @@ -43,8 +41,7 @@ contract TbdScript is RegistryScript { addr: vm.addr(vm.envUint("PK_DNB")), key: vm.envUint("PK_DNB"), contractName: vm.envString("TBD_DNB_CONTRACT_NAME"), - contractSymbol: vm.envString("TBD_DNB_CONTRACT_SYMBOL"), - govReserveAddr: address(0) + contractSymbol: vm.envString("TBD_DNB_CONTRACT_SYMBOL") }); /// @dev Retrieve registry address from environment variables @@ -67,11 +64,9 @@ contract TbdScript is RegistryScript { /// @notice Deploy TBD contracts for Nordea and DNB banks vm.startBroadcast(deployerKey); - tbdNordea = new Tbd( - nordea.addr, nordea.addr, wnok, dvp, nordea.contractName, nordea.contractSymbol, nordea.govReserveAddr - ); + tbdNordea = new Tbd(nordea.addr, nordea.addr, wnok, dvp, nordea.contractName, nordea.contractSymbol); - tbdDnb = new Tbd(dnb.addr, dnb.addr, wnok, dvp, dnb.contractName, dnb.contractSymbol, dnb.govReserveAddr); + tbdDnb = new Tbd(dnb.addr, dnb.addr, wnok, dvp, dnb.contractName, dnb.contractSymbol); vm.stopBroadcast(); /// @notice Register deployed contract addresses in GlobalRegistry @@ -79,9 +74,5 @@ contract TbdScript is RegistryScript { registry.setContract(tbdNordea.name(), address(tbdNordea)); registry.setContract(tbdDnb.name(), address(tbdDnb)); vm.stopBroadcast(); - - vm.startBroadcast(nordea.key); - tbdNordea.add(nordea.govReserveAddr); - vm.stopBroadcast(); } } diff --git a/contracts/src/common/Errors.sol b/contracts/src/common/Errors.sol index 0b8f693..873cf61 100644 --- a/contracts/src/common/Errors.sol +++ b/contracts/src/common/Errors.sol @@ -52,7 +52,7 @@ library Errors { error InvalidReceiver(); error TokenTransferFailed(); error CctFailed(); - error NotGovernmentNominated(); + error CctFromNotCaller(address from, address caller); // --- BondOrderBookFactory --- error BondTokenAddressZero(); diff --git a/contracts/src/norges-bank/ERC1410/ERC1410.sol b/contracts/src/norges-bank/ERC1410/ERC1410.sol index 970eb8a..e0df7d2 100644 --- a/contracts/src/norges-bank/ERC1410/ERC1410.sol +++ b/contracts/src/norges-bank/ERC1410/ERC1410.sol @@ -172,19 +172,21 @@ contract ERC1410Minimal is IERC1410, ERC165 { //////////////////////////////////////////////////////////////*/ /** - * @notice Transfer value from the caller out of a partition. - * @dev If `data` is 32 bytes long, it is treated as the destination partition. + * @notice Transfer value from the caller within a partition. + * @dev Units stay in `partition`; `data` is opaque and only echoed in the event. */ function transferByPartition(bytes32 partition, address to, uint256 value, bytes calldata data) external override returns (bytes32) { - return _transferByPartition(partition, partition, msg.sender, msg.sender, to, value, data, ""); + return _transferByPartition(partition, msg.sender, msg.sender, to, value, data, ""); } /** * @notice Operator transfer respecting global and partition-level approvals. + * @dev Units stay in `partition`; `data` and `operatorData` are opaque and only echoed in + * the event. Moving units between partitions is not supported. */ function operatorTransferByPartition( bytes32 partition, @@ -196,9 +198,7 @@ contract ERC1410Minimal is IERC1410, ERC165 { ) external override returns (bytes32) { if (!isOperatorForPartition(partition, msg.sender, from)) revert Errors.UnauthorizedOperator(); - // forge-lint: disable-next-line(unsafe-typecast) - bytes32 toPartition = data.length >= 32 ? bytes32(data) : partition; - return _transferByPartition(partition, toPartition, msg.sender, from, to, value, data, operatorData); + return _transferByPartition(partition, msg.sender, from, to, value, data, operatorData); } /** @@ -244,8 +244,7 @@ contract ERC1410Minimal is IERC1410, ERC165 { //////////////////////////////////////////////////////////////*/ function _transferByPartition( - bytes32 fromPartition, - bytes32 toPartition, + bytes32 partition, address operator, address from, address to, @@ -255,43 +254,35 @@ contract ERC1410Minimal is IERC1410, ERC165 { ) internal returns (bytes32) { if (to == address(0)) revert Errors.InvalidRecipient(); - _move(fromPartition, toPartition, from, to, value); + _move(partition, from, to, value); - emit TransferByPartition(fromPartition, operator, from, to, value, data, operatorData); - if (fromPartition != toPartition) { - emit ChangedPartition(fromPartition, toPartition, value); - } + emit TransferByPartition(partition, operator, from, to, value, data, operatorData); - return toPartition; + return partition; } - function _move(bytes32 fromPartition, bytes32 toPartition, address from, address to, uint256 value) internal { + /** + * @dev Moves `value` units of `partition` from `from` to `to`. Partition supply is unchanged. + */ + function _move(bytes32 partition, address from, address to, uint256 value) internal { _enforceGranularity(value); if (_balances[from] < value) revert Errors.InsufficientBalance(); - if (_balanceOfByPartition[from][fromPartition] < value) revert Errors.InsufficientPartitionBalance(); - - if (from != to) { - _balances[from] -= value; - _balances[to] += value; - } + if (_balanceOfByPartition[from][partition] < value) revert Errors.InsufficientPartitionBalance(); - if (fromPartition == toPartition && from == to) { + if (from == to) { return; } - _balanceOfByPartition[from][fromPartition] -= value; - _totalSupplyByPartition[fromPartition] -= value; - if (_balanceOfByPartition[from][fromPartition] == 0) { - _removePartition(from, fromPartition); - } + _balances[from] -= value; + _balances[to] += value; - _balanceOfByPartition[to][toPartition] += value; - _totalSupplyByPartition[toPartition] += value; - _addPartition(to, toPartition); - _trackPartition(toPartition); - if (_totalSupplyByPartition[fromPartition] == 0 && fromPartition != toPartition) { - _untrackPartition(fromPartition); + _balanceOfByPartition[from][partition] -= value; + if (_balanceOfByPartition[from][partition] == 0) { + _removePartition(from, partition); } + + _balanceOfByPartition[to][partition] += value; + _addPartition(to, partition); } function _mint( diff --git a/contracts/src/private-bank/ITbd.sol b/contracts/src/private-bank/ITbd.sol index 9c89434..1e8379f 100644 --- a/contracts/src/private-bank/ITbd.sol +++ b/contracts/src/private-bank/ITbd.sol @@ -13,6 +13,7 @@ interface ITbd { /** * @dev Moves a `value` amount of tokens from the from account to `to` * via the customer credit transfer (cct) settlement process, using the CBDC. + * The caller must be the payer (`from`) or the DvP contract the TBD was constructed with. * @param from The TBD address from which tokens are being transferred. * @param to The TBD address to which tokens are being transferred. * @param toTbdContract The receiving TBD contract. @@ -27,14 +28,4 @@ interface ITbd { * cct calls from the same sending TBC contract. */ function cctSetToAddr(address to) external; - - /** - * @dev Returns government reserve address if nominated. - */ - function govReserve() external view returns (address); - - /** - * @dev Returns if TBD has been government nominated for reserve access. - */ - function isGovernmentNominated() external view returns (bool); } diff --git a/contracts/src/private-bank/Tbd.sol b/contracts/src/private-bank/Tbd.sol index e125e58..e028978 100644 --- a/contracts/src/private-bank/Tbd.sol +++ b/contracts/src/private-bank/Tbd.sol @@ -30,28 +30,25 @@ contract Tbd is ITbd, IERC1363Receiver, ERC20, AccessControl, Allowlist { Wnok private immutable _WNOK; /** - * Defined government reserve account; + * The DvP contract given at construction; the only caller that may move another account's + * funds through `cctFrom`. */ - address public govReserve; + address private immutable _DVP; /** * @dev Create a new TBD token. * @param admin The user to receive DEFAULT_ADMIN_ROLE * @param bank The bank which owns the token * @param wnok The global central bank contract - * @param dvp The global DvP contract + * @param dvp The global DvP contract; granted CCT_FROM_CALLER_ROLE and allowed to settle on + * behalf of the payer * @param name_ of the TBD contract * @param symbol_ of the TBD token */ - constructor( - address admin, - address bank, - address wnok, - address dvp, - string memory name_, - string memory symbol_, - address _govReserve - ) Allowlist(admin) ERC20(name_, symbol_) { + constructor(address admin, address bank, address wnok, address dvp, string memory name_, string memory symbol_) + Allowlist(admin) + ERC20(name_, symbol_) + { if (admin == address(0)) revert Errors.AdminAddressZero(); if (bank == address(0)) revert Errors.BankAddressZero(); if (wnok == address(0)) revert Errors.WnokAddressZero(); @@ -64,9 +61,9 @@ contract Tbd is ITbd, IERC1363Receiver, ERC20, AccessControl, Allowlist { // ITbd _supportedInterfaces[Tbd(address(0)).cctFrom.selector ^ Tbd(address(0)).cctSetToAddr.selector] = true; - govReserve = _govReserve; _BANK = bank; _WNOK = Wnok(wnok); + _DVP = dvp; _grantRole(DEFAULT_ADMIN_ROLE, admin); _grantRole(Roles.MINTER_ROLE, admin); _grantRole(Roles.BURNER_ROLE, admin); @@ -118,12 +115,16 @@ contract Tbd is ITbd, IERC1363Receiver, ERC20, AccessControl, Allowlist { /** * @inheritdoc ITbd + * @dev The caller must hold CCT_FROM_CALLER_ROLE and be either the payer (`from`) or the DvP + * contract given at construction. */ function cctFrom(address from, address to, address toTbdContrAddr, uint256 value) external onlyRole(Roles.CCT_FROM_CALLER_ROLE) returns (bool) { + if (from != msg.sender && msg.sender != _DVP) revert Errors.CctFromNotCaller(from, msg.sender); + bool success; if (toTbdContrAddr == address(this)) { @@ -150,25 +151,6 @@ contract Tbd is ITbd, IERC1363Receiver, ERC20, AccessControl, Allowlist { return _BANK; } - /** - * @dev Is TBD used for government issuance. - * @return Boolean if TBD has been nominated. - */ - function isGovernmentNominated() public view returns (bool) { - return govReserve != address(0); - } - - /** - * @dev A mint function to convert gov. WNOK to TBD. - * @param _value Value to mint in token units. - */ - function _mintFromGovReserve(uint256 _value) internal { - if (govReserve == address(0)) revert Errors.NotGovernmentNominated(); - bool ok = _WNOK.transferFrom(govReserve, address(this), _value); - if (!ok) revert Errors.TokenTransferFailed(); - _mint(govReserve, _value); - } - /** * @dev A mint function callable by MINTER_ROLE. * @param account Receiving account of the newly minted tokens @@ -202,11 +184,6 @@ contract Tbd is ITbd, IERC1363Receiver, ERC20, AccessControl, Allowlist { revert Errors.AllowlistViolation(ERC20.name(), recipient, ""); } - // Gov. reserve account will deposit and mint when transfer is requested - if (isGovernmentNominated() && spender == govReserve) { - _mintFromGovReserve(value); - } - return super._update(spender, recipient, value); } diff --git a/contracts/test/integration/Dvp-settlement.t.sol b/contracts/test/integration/Dvp-settlement.t.sol index 8c502de..8e56087 100644 --- a/contracts/test/integration/Dvp-settlement.t.sol +++ b/contracts/test/integration/Dvp-settlement.t.sol @@ -79,8 +79,8 @@ contract DvPSettlement is Test { // Create wNOK, DvP, TBDs wnok = new Wnok(wnokadmin, wnokName, wnokSymbol); dvp = new DvP(dvpadmin); - tbd1 = new Tbd(tbd1admin, bank1, address(wnok), address(dvp), tbd1Name, tbd1Symbol, address(0)); - tbd2 = new Tbd(tbd2admin, bank2, address(wnok), address(dvp), tbd2Name, tbd2Symbol, address(0)); + tbd1 = new Tbd(tbd1admin, bank1, address(wnok), address(dvp), tbd1Name, tbd1Symbol); + tbd2 = new Tbd(tbd2admin, bank2, address(wnok), address(dvp), tbd2Name, tbd2Symbol); vm.startPrank(tbd1admin); tbd1.add(investor1tbd); @@ -107,9 +107,7 @@ contract DvPSettlement is Test { vm.prank(bank2); wnok.approve(address(tbd2), type(uint256).max); - // Create DvP + Security (both owned by dvpadmin) - dvp = new DvP(dvpadmin); - + // Create Security (owned by dvpadmin); the TBDs above were constructed with this DvP params = StockParams({ name: "EquiNor", symbol: "EqNr", @@ -334,6 +332,40 @@ contract DvPSettlement is Test { _assertInitFunds(); } + /** + * A reserve account wired as the deploy scripts once did (wNOK- and TBD-allowlisted, with a + * max wNOK allowance to the TBD contract) cannot be drawn on through `cctFrom` by another + * CCT_FROM_CALLER_ROLE holder. + */ + function test_cctFrom_CannotDrawOnAnotherAccountsWnokAllowance() public { + address reserve = makeAddr("reserve"); + uint256 reserveWnok = 10_000_000; + Tbd tbd = new Tbd(tbd1admin, bank1, address(wnok), address(dvp), tbd1Name, tbd1Symbol); + + vm.startPrank(tbd1admin); + tbd.add(investor1tbd); + tbd.add(reserve); + tbd.grantRole(Roles.CCT_FROM_CALLER_ROLE, investor1tbd); + vm.stopPrank(); + + vm.startPrank(wnokadmin); + wnok.add(reserve); + wnok.add(address(tbd)); + wnok.grantRole(Roles.TRANSFER_FROM_ROLE, address(tbd)); + wnok.mint(reserve, reserveWnok); + vm.stopPrank(); + + vm.prank(reserve); + wnok.approve(address(tbd), type(uint256).max); + + vm.expectRevert(abi.encodeWithSelector(Errors.CctFromNotCaller.selector, reserve, investor1tbd)); + vm.prank(investor1tbd); + tbd.cctFrom(reserve, investor1tbd, address(tbd), reserveWnok); + + assertEq(wnok.balanceOf(reserve), reserveWnok); + assertEq(tbd.balanceOf(investor1tbd), 0); + } + /** * Common settle() call executed at the end of every test below. */ diff --git a/contracts/test/integration/OrderBook.t.sol b/contracts/test/integration/OrderBook.t.sol index 4948afc..8b8bf91 100644 --- a/contracts/test/integration/OrderBook.t.sol +++ b/contracts/test/integration/OrderBook.t.sol @@ -135,8 +135,8 @@ contract OrderBookIntegrationTest is Test { // Create wNOK, DvP, TBDs wnok = new Wnok(wnokadmin, wnokName, wnokSymbol); dvp = new DvP(csdadmin); - tbd1 = new Tbd(tbd1admin, bank1, address(wnok), address(dvp), tbd1Name, tbd1Symbol, address(0)); - tbd2 = new Tbd(tbd2admin, bank2, address(wnok), address(dvp), tbd2Name, tbd2Symbol, address(0)); + tbd1 = new Tbd(tbd1admin, bank1, address(wnok), address(dvp), tbd1Name, tbd1Symbol); + tbd2 = new Tbd(tbd2admin, bank2, address(wnok), address(dvp), tbd2Name, tbd2Symbol); vm.startPrank(tbd1admin); tbd1.add(investorATbd); diff --git a/contracts/test/invariant/BondToken.invariant.t.sol b/contracts/test/invariant/BondToken.invariant.t.sol index c5eacbe..16f4bec 100644 --- a/contracts/test/invariant/BondToken.invariant.t.sol +++ b/contracts/test/invariant/BondToken.invariant.t.sol @@ -12,6 +12,8 @@ contract BondTokenHandler is Test { string internal constant ISIN = "NO0001234567"; bytes32 internal constant PARTITION = keccak256(abi.encodePacked(ISIN)); + string internal constant ISIN_B = "NO0007654321"; + bytes32 internal constant PARTITION_B = keccak256(abi.encodePacked(ISIN_B)); address[4] internal holders = [address(0x101), address(0x102), address(0x103), address(0x104)]; @@ -20,27 +22,44 @@ contract BondTokenHandler is Test { controller = _controller; } - function createPartition(uint96 offeringSeed, uint32 maturitySeed) external { - if (bondToken.activePartitions(PARTITION)) return; + function createPartition(uint96 offeringSeed, uint32 maturitySeed, uint8 isinSeed) external { + (string memory isin, bytes32 partition) = _isin(isinSeed); + if (bondToken.activePartitions(partition)) return; uint256 offering = _boundNonZero(uint256(offeringSeed), 1, 1_000_000); uint256 maturityDuration = _boundNonZero(uint256(maturitySeed), 1 days, 10 * 365 days); vm.prank(controller); - bondToken.createPartition(ISIN, offering, maturityDuration); + bondToken.createPartition(isin, offering, maturityDuration); } - function mintToHolder(uint96 amountSeed, uint8 holderSeed) external { - if (!bondToken.activePartitions(PARTITION)) return; + function mintToHolder(uint96 amountSeed, uint8 holderSeed, uint8 isinSeed) external { + (string memory isin, bytes32 partition) = _isin(isinSeed); + if (!bondToken.activePartitions(partition)) return; - uint256 currentSupply = bondToken.totalSupplyByPartition(PARTITION); - uint256 offering = bondToken.partitionOffering(PARTITION); + uint256 currentSupply = bondToken.totalSupplyByPartition(partition); + uint256 offering = bondToken.partitionOffering(partition); if (currentSupply >= offering) return; uint256 amount = _boundNonZero(uint256(amountSeed), 1, offering - currentSupply); vm.prank(controller); - bondToken.mintByIsin(ISIN, _holder(holderSeed), amount); + bondToken.mintByIsin(isin, _holder(holderSeed), amount); + } + + /// @dev A holder moves its own units; `data` names either partition. + function holderTransfer(uint96 amountSeed, uint8 fromSeed, uint8 toSeed, uint8 dataSeed) external { + address from = _holder(fromSeed); + bytes32[] memory held = bondToken.partitionsOf(from); + if (held.length == 0) return; + + bytes32 partition = held[uint256(amountSeed) % held.length]; + uint256 balance = bondToken.balanceOfByPartition(partition, from); + uint256 amount = _boundNonZero(uint256(amountSeed), 1, balance); + (, bytes32 dataPartition) = _isin(dataSeed); + + vm.prank(from); + bondToken.operatorTransferByPartition(partition, from, _holder(toSeed), amount, abi.encode(dataPartition), ""); } function extendOffering(uint96 deltaSeed) external { @@ -112,6 +131,10 @@ contract BondTokenHandler is Test { bondToken.redeemFor(holder, ISIN, amount, controller); } + function _isin(uint8 isinSeed) internal pure returns (string memory isin, bytes32 partition) { + return isinSeed % 2 == 0 ? (ISIN, PARTITION) : (ISIN_B, PARTITION_B); + } + function _holder(uint8 holderSeed) internal view returns (address) { return holders[uint256(holderSeed) % holders.length]; } @@ -129,6 +152,8 @@ contract BondTokenInvariantTest is Test { address internal constant CONTROLLER = address(0x999); string internal constant ISIN = "NO0001234567"; bytes32 internal constant PARTITION = keccak256(abi.encodePacked(ISIN)); + string internal constant ISIN_B = "NO0007654321"; + bytes32 internal constant PARTITION_B = keccak256(abi.encodePacked(ISIN_B)); address[4] internal HOLDERS = [address(0x101), address(0x102), address(0x103), address(0x104)]; function setUp() public { @@ -137,7 +162,7 @@ contract BondTokenInvariantTest is Test { handler = new BondTokenHandler(bondToken, CONTROLLER); - bytes4[] memory selectors = new bytes4[](8); + bytes4[] memory selectors = new bytes4[](9); selectors[0] = handler.createPartition.selector; selectors[1] = handler.mintToHolder.selector; selectors[2] = handler.extendOffering.selector; @@ -146,26 +171,19 @@ contract BondTokenInvariantTest is Test { selectors[5] = handler.buybackRedeem.selector; selectors[6] = handler.markMatured.selector; selectors[7] = handler.redeemHolder.selector; + selectors[8] = handler.holderTransfer.selector; targetSelector(FuzzSelector({addr: address(handler), selectors: selectors})); } function invariant_TotalSupplyNeverExceedsOffering() public view { - if (!bondToken.activePartitions(PARTITION)) { - assertEq(bondToken.totalSupplyByPartition(PARTITION), 0); - return; - } - - assertLe(bondToken.totalSupplyByPartition(PARTITION), bondToken.partitionOffering(PARTITION)); + _assertSupplyWithinOffering(PARTITION); + _assertSupplyWithinOffering(PARTITION_B); } function invariant_SupplyMatchesTrackedHolderBalances() public view { - uint256 trackedBalance; - for (uint256 i = 0; i < HOLDERS.length; i++) { - trackedBalance += bondToken.balanceOfByPartition(PARTITION, HOLDERS[i]); - } - - assertEq(bondToken.totalSupplyByPartition(PARTITION), trackedBalance); + _assertSupplyMatchesHolders(PARTITION); + _assertSupplyMatchesHolders(PARTITION_B); } function invariant_CouponConfigRemainsSelfConsistent() public view { @@ -187,4 +205,22 @@ contract BondTokenInvariantTest is Test { assertTrue(bondToken.activePartitions(PARTITION)); } } + + function _assertSupplyWithinOffering(bytes32 partition) internal view { + if (!bondToken.activePartitions(partition)) { + assertEq(bondToken.totalSupplyByPartition(partition), 0); + return; + } + + assertLe(bondToken.totalSupplyByPartition(partition), bondToken.partitionOffering(partition)); + } + + function _assertSupplyMatchesHolders(bytes32 partition) internal view { + uint256 trackedBalance; + for (uint256 i = 0; i < HOLDERS.length; i++) { + trackedBalance += bondToken.balanceOfByPartition(partition, HOLDERS[i]); + } + + assertEq(bondToken.totalSupplyByPartition(partition), trackedBalance); + } } diff --git a/contracts/test/norges-bank/BondOrderBook.t.sol b/contracts/test/norges-bank/BondOrderBook.t.sol index 6bf9b6b..bbf7b15 100644 --- a/contracts/test/norges-bank/BondOrderBook.t.sol +++ b/contracts/test/norges-bank/BondOrderBook.t.sol @@ -46,7 +46,7 @@ contract BondOrderBookTest is Test { function setUp() public { wnok = new Wnok(admin, "Wholesale NOK", "WNOK"); - tbd = new Tbd(admin, address(0xAA), address(wnok), address(0xBB), "TBD", "TBD", address(0xCC)); + tbd = new Tbd(admin, address(0xAA), address(wnok), address(0xBB), "TBD", "TBD"); bondToken = new BondToken("Bond Token", "BOND"); // Roles and partition diff --git a/contracts/test/norges-bank/BondToken.t.sol b/contracts/test/norges-bank/BondToken.t.sol index 49c13d7..241396a 100644 --- a/contracts/test/norges-bank/BondToken.t.sol +++ b/contracts/test/norges-bank/BondToken.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 pragma solidity ^0.8.29; -import {Test} from "forge-std/Test.sol"; +import {Test, Vm} from "forge-std/Test.sol"; import {BondToken} from "@norges-bank/BondToken.sol"; import {Wnok} from "@norges-bank/Wnok.sol"; import {Errors} from "@common/Errors.sol"; @@ -17,6 +17,7 @@ contract BondTokenTest is Test { address holder2 = address(0x2); string constant ISIN = "NO0001234567"; + string constant ISIN_B = "NO0007654321"; uint256 constant OFFERING = 1000; uint256 constant MATURITY_DURATION = 4 * 365 days; // 4 years uint256 constant COUPON_DURATION = 365 days; // 1 year @@ -384,6 +385,58 @@ contract BondTokenTest is Test { bondToken.redeemFor(holder1, ISIN, 50, controller); } + // ============ operatorTransferByPartition Tests ============ + + function test_OperatorTransferByPartition_KeepsPartition() public { + vm.startPrank(controller); + bondToken.createPartition(ISIN, OFFERING, MATURITY_DURATION); + bondToken.createPartition(ISIN_B, OFFERING, MATURITY_DURATION); + bondToken.mintByIsin(ISIN, holder1, 100); + bondToken.mintByIsin(ISIN_B, holder2, 100); + vm.stopPrank(); + + bytes32 partitionA = bondToken.isinToPartition(ISIN); + bytes32 partitionB = bondToken.isinToPartition(ISIN_B); + + vm.recordLogs(); + vm.prank(holder1); + bytes32 landedIn = + bondToken.operatorTransferByPartition(partitionA, holder1, holder2, 60, abi.encode(partitionB), ""); + + assertEq(landedIn, partitionA); + assertEq(bondToken.balanceOfByPartition(partitionA, holder1), 40); + assertEq(bondToken.balanceOfByPartition(partitionA, holder2), 60); + assertEq(bondToken.balanceOfByPartition(partitionB, holder2), 100); + assertEq(bondToken.totalSupplyByPartition(partitionA), 100); + assertEq(bondToken.totalSupplyByPartition(partitionB), 100); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + bytes32 changedPartitionTopic = keccak256("ChangedPartition(bytes32,bytes32,uint256)"); + for (uint256 i = 0; i < logs.length; i++) { + assertNotEq(logs[i].topics[0], changedPartitionTopic); + } + } + + function test_OperatorTransferByPartition_CannotBlockIssuance() public { + vm.startPrank(controller); + bondToken.createPartition(ISIN, OFFERING, MATURITY_DURATION); + bondToken.mintByIsin(ISIN, holder1, 100); + bondToken.createPartition(ISIN_B, OFFERING, MATURITY_DURATION); + vm.stopPrank(); + + bytes32 partitionA = bondToken.isinToPartition(ISIN); + bytes32 partitionB = bondToken.isinToPartition(ISIN_B); + + vm.prank(holder1); + bondToken.operatorTransferByPartition(partitionA, holder1, holder2, 1, abi.encode(partitionB), ""); + + vm.prank(controller); + bondToken.mintByIsin(ISIN_B, holder2, OFFERING); + + assertEq(bondToken.totalSupplyByPartition(partitionB), OFFERING); + assertEq(bondToken.balanceOfByPartition(partitionA, holder2), 1); + } + // ============ isinToPartition Tests ============ function test_IsinToPartition() public view { diff --git a/contracts/test/private-bank/Tbd.t.sol b/contracts/test/private-bank/Tbd.t.sol index 7896641..c913820 100644 --- a/contracts/test/private-bank/Tbd.t.sol +++ b/contracts/test/private-bank/Tbd.t.sol @@ -22,7 +22,7 @@ contract TbdTest is Test { address dvpContract = address(0x6); function setUp() public { - tbd = new Tbd(admin, bank, cbContract, dvpContract, contractName, contractSymbol, address(0)); + tbd = new Tbd(admin, bank, cbContract, dvpContract, contractName, contractSymbol); } /** @@ -276,12 +276,49 @@ contract TbdTest is Test { tbd.cctFrom(investor1, investor2, address(tbd), amount); } + /** + * A CCT_FROM_CALLER_ROLE holder can move only its own funds with cctFrom + */ + function test_cctFrom_RevertIf_CallerIsNotPayer() public { + uint256 amount = 100; + + tbd.grantRole(Roles.CCT_FROM_CALLER_ROLE, investor2); + + tbd.add(investor1); + tbd.add(investor2); + tbd.mint(investor1, amount); + + vm.expectRevert(abi.encodeWithSelector(Errors.CctFromNotCaller.selector, investor1, investor2)); + vm.prank(investor2); + tbd.cctFrom(investor1, investor2, address(tbd), amount); + + assertEq(tbd.balanceOf(investor1), amount); + assertEq(tbd.balanceOf(investor2), 0); + } + + /** + * The DvP contract given at construction settles on behalf of the payer + */ + function test_cctFrom_DvpMovesOnBehalfOfPayer() public { + uint256 amount = 100; + + tbd.add(investor1); + tbd.add(investor2); + tbd.mint(investor1, amount); + + vm.prank(dvpContract); + tbd.cctFrom(investor1, investor2, address(tbd), amount); + + assertEq(tbd.balanceOf(investor1), 0); + assertEq(tbd.balanceOf(investor2), amount); + } + /** * The admin address may not be the zero address */ function test_revertIf_adminAddressZero() public { vm.expectRevert(Errors.AdminAddressZero.selector); - new Tbd(address(0), bank, cbContract, dvpContract, contractName, contractSymbol, address(0)); + new Tbd(address(0), bank, cbContract, dvpContract, contractName, contractSymbol); } /** @@ -289,7 +326,7 @@ contract TbdTest is Test { */ function test_revertIf_bankAddressZero() public { vm.expectRevert(Errors.BankAddressZero.selector); - new Tbd(admin, address(0), cbContract, dvpContract, contractName, contractSymbol, address(0)); + new Tbd(admin, address(0), cbContract, dvpContract, contractName, contractSymbol); } /** @@ -297,7 +334,7 @@ contract TbdTest is Test { */ function test_revertIf_cbContractAddressZero() public { vm.expectRevert(Errors.WnokAddressZero.selector); - new Tbd(admin, bank, address(0), dvpContract, contractName, contractSymbol, address(0)); + new Tbd(admin, bank, address(0), dvpContract, contractName, contractSymbol); } /** @@ -305,7 +342,7 @@ contract TbdTest is Test { */ function test_revertIf_dvpContractAddressZero() public { vm.expectRevert(Errors.DvpAddressZero.selector); - new Tbd(admin, bank, cbContract, address(0), contractName, contractSymbol, address(0)); + new Tbd(admin, bank, cbContract, address(0), contractName, contractSymbol); } /** diff --git a/docs/diagrams/architecture/contract-topology.md b/docs/diagrams/architecture/contract-topology.md index a44b206..0397001 100644 --- a/docs/diagrams/architecture/contract-topology.md +++ b/docs/diagrams/architecture/contract-topology.md @@ -10,7 +10,7 @@ flowchart TB subgraph money["Money layer"] wnok["WNOK
allowlisted wholesale cash token"] - tbdN["TBD instances per private bank
Nordea is government-nominated"] + tbdN["TBD instances per private bank
cctFrom by payer or bound DvP"] end subgraph primary["Primary bond market"] diff --git a/docs/diagrams/processes/bank-creation-sequence.md b/docs/diagrams/processes/bank-creation-sequence.md index ff1a6b8..372e618 100644 --- a/docs/diagrams/processes/bank-creation-sequence.md +++ b/docs/diagrams/processes/bank-creation-sequence.md @@ -23,7 +23,7 @@ sequenceDiagram API->>DB: Reject duplicate bank name or address API->>Registry: Check registry name and resolve WNOK + DvP - API->>Chain: Deploy TBD signed by bank key
admin = bank, bank = bank, govReserve = zero + API->>Chain: Deploy TBD signed by bank key
admin = bank, bank = bank, dvp = registered DvP Chain-->>API: TBD contract address API->>Registry: setContract("TBD name", address)
signed by central-bank registry owner diff --git a/docs/diagrams/processes/tbd-cross-bank-transfer.md b/docs/diagrams/processes/tbd-cross-bank-transfer.md index 0f5e625..f120981 100644 --- a/docs/diagrams/processes/tbd-cross-bank-transfer.md +++ b/docs/diagrams/processes/tbd-cross-bank-transfer.md @@ -7,7 +7,7 @@ deposit at the receiving bank through the ERC-1363 callback path. ```mermaid sequenceDiagram autonumber - participant Caller as DvP / authorised CCT caller + participant Caller as Payer or constructor-bound DvP actor Payer participant BuyerTBD as Sender-bank TBD participant WNOK as WNOK @@ -36,5 +36,9 @@ sequenceDiagram end ``` +The caller must hold `CCT_FROM_CALLER_ROLE` on the sender-bank TBD and be either +the payer itself or the DvP contract that TBD was constructed with; any other +caller is rejected with `CctFromNotCaller`. + All calls execute in one EVM transaction. Any allowlist, balance, role, allowance, or callback failure reverts the burn, reserve movement, and mint. diff --git a/services/nb-bond-api/README.md b/services/nb-bond-api/README.md index 52d77e9..ecc5e46 100644 --- a/services/nb-bond-api/README.md +++ b/services/nb-bond-api/README.md @@ -42,7 +42,7 @@ Two sandbox-only resource families sit alongside the bond tree: - **`banking`** — surface over the per-bank TBD (tokenized bank deposit) tokens. `GET /v1/banking/banks` lists the configured banks (the signer selector); `GET /v1/banking/tbd[/{address}]` returns each TBD with its - owning bank, supply, WNOK reserve backing, government-nomination, and holders; + owning bank, supply, WNOK reserve backing, and holders; `PUT/DELETE /v1/banking/tbd/{address}/allowlist/{holder}` and `POST /v1/banking/tbd/{address}/{mint,burn,transfer}` mutate it, signed by the token's owning bank. Open to both operator and tester roles (Central Bank diff --git a/services/nb-bond-api/openapi.json b/services/nb-bond-api/openapi.json index f78b84b..b816157 100644 --- a/services/nb-bond-api/openapi.json +++ b/services/nb-bond-api/openapi.json @@ -4212,9 +4212,6 @@ "reserve": { "$ref": "#/components/schemas/TbdReserve" }, - "government": { - "$ref": "#/components/schemas/TbdGovernment" - }, "holders": { "type": "array", "items": { @@ -4238,7 +4235,6 @@ "totalSupply", "bank", "reserve", - "government", "holders", "md5" ], @@ -4279,7 +4275,7 @@ }, "bankAllowlisted": { "type": "boolean", - "description": "True when the owning bank's address is on the WNOK allowlist — i.e. it can hold the WNOK reserve and settle cross-bank in WNOK. Distinct from government-nomination." + "description": "True when the owning bank's address is on the WNOK allowlist — i.e. it can hold the WNOK reserve and settle cross-bank in WNOK." } }, "required": [ @@ -4295,32 +4291,6 @@ ], "description": "WNOK reserve backing (informational). Null when WNOK is unreachable." }, - "TbdGovernment": { - "type": "object", - "properties": { - "nominated": { - "type": "boolean", - "description": "True when a government reserve account is set (enables the gov-reserve mint path)" - }, - "reserveAddress": { - "anyOf": [ - { - "$ref": "#/components/schemas/Address" - }, - { - "type": "null" - } - ], - "description": "The government reserve account, or null when not nominated" - } - }, - "required": [ - "nominated", - "reserveAddress" - ], - "additionalProperties": false, - "description": "Government-nomination state for this token" - }, "TbdHolder": { "type": "object", "properties": { diff --git a/services/nb-bond-api/src/abi/Tbd.json b/services/nb-bond-api/src/abi/Tbd.json index da5cb79..214887a 100644 --- a/services/nb-bond-api/src/abi/Tbd.json +++ b/services/nb-bond-api/src/abi/Tbd.json @@ -32,11 +32,6 @@ "name": "symbol_", "type": "string", "internalType": "string" - }, - { - "name": "_govReserve", - "type": "address", - "internalType": "address" } ], "stateMutability": "nonpayable" @@ -276,19 +271,6 @@ ], "stateMutability": "view" }, - { - "type": "function", - "name": "govReserve", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "address", - "internalType": "address" - } - ], - "stateMutability": "view" - }, { "type": "function", "name": "grantRole", @@ -331,19 +313,6 @@ ], "stateMutability": "view" }, - { - "type": "function", - "name": "isGovernmentNominated", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "bool", - "internalType": "bool" - } - ], - "stateMutability": "view" - }, { "type": "function", "name": "mint", @@ -738,6 +707,22 @@ "name": "CctFailed", "inputs": [] }, + { + "type": "error", + "name": "CctFromNotCaller", + "inputs": [ + { + "name": "from", + "type": "address", + "internalType": "address" + }, + { + "name": "caller", + "type": "address", + "internalType": "address" + } + ] + }, { "type": "error", "name": "DvpAddressZero", @@ -834,11 +819,6 @@ "name": "InvalidReceiver", "inputs": [] }, - { - "type": "error", - "name": "NotGovernmentNominated", - "inputs": [] - }, { "type": "error", "name": "TokenTransferFailed", diff --git a/services/nb-bond-api/src/abi/TbdBytecode.json b/services/nb-bond-api/src/abi/TbdBytecode.json index 094fa85..e33a731 100644 --- a/services/nb-bond-api/src/abi/TbdBytecode.json +++ b/services/nb-bond-api/src/abi/TbdBytecode.json @@ -1,4 +1,4 @@ { "_comment": "Generated artifact: creation bytecode for contracts/src/private-bank/Tbd.sol, extracted from contracts/out/Tbd.sol/Tbd.json after forge build. Regenerate whenever Tbd.sol (or its compiler settings) changes: cd contracts && forge soldeer install && forge build, then copy bytecode.object here.", - "bytecode": "0x60c060405234610551576120cd8038038061001981610556565b92833981019060e081830312610551576100328161057b565b9161003f6020830161057b565b9061004c6040840161057b565b906100596060850161057b565b60808501519094906001600160401b038111610551578261007b91830161058f565b60a082015190926001600160401b038211610551576100a160c0916100a893850161058f565b920161057b565b825190926001600160401b0382116104445760035490600182811c92168015610547575b60208310146104245781601f8493116104cd575b50602090601f83116001146104655760009261045a575b50508160011b916000199060031b1c1916176003555b8051906001600160401b0382116104445760045490600182811c9216801561043a575b60208310146104245781601f8493116103aa575b50602090601f831160011461034257600092610337575b50508160011b916000199060031b1c1916176004555b61017a856105fa565b5061018485610676565b506001600160a01b03851615610326576001600160a01b03831615610315576001600160a01b038216948515610304576001600160a01b038516156102f35760086020527f9c1077d48c1ee2b23ae7e47ca8a0ebdbffc921a368b14a8244a53034b67dcd47805460ff1990811660019081179092557fa4ec55a0f91c1bd941b947f3664109302d33d8214ed053e7cdbd357f3168cdf18054821683179055632cc34a4960e01b6000527f5b12e6a61e98ee390c0688e3558b11011b75a141de23ee96960039da1a2e01e680549091169091179055600a80546001600160a01b0319166001600160a01b039093169290921790915560809290925260a0939093526102b5926102af916102a990610299816105fa565b506102a38161070e565b506107a6565b50610862565b5061091e565b5060405161167690816109b7823960805181818161062b01528181610889015261097f015260a051818181610668015281816108cd01526114da0152f35b63da9fbf9b60e01b60005260046000fd5b636e06d40160e01b60005260046000fd5b632ad8024f60e21b60005260046000fd5b6339bfddb560e01b60005260046000fd5b01519050388061015b565b600460009081528281209350601f198516905b8181106103925750908460019594939210610379575b505050811b01600455610171565b015160001960f88460031b161c1916905538808061036b565b92936020600181928786015181550195019301610355565b82811115610144576004600052909150601f830160051c7f8a35acfbc15ff81a39ae7d344fd709f28e8600b4aa8c65c6b64bfe7fe36bd19b6020851061041b575b849392601f0160051c829003910160005b82811061040a575050610144565b6000818301558594506001016103fc565b600091506103eb565b634e487b7160e01b600052602260045260246000fd5b91607f1691610130565b634e487b7160e01b600052604160045260246000fd5b0151905038806100f7565b600360009081528281209350601f198516905b8181106104b5575090846001959493921061049c575b505050811b0160035561010d565b015160001960f88460031b161c1916905538808061048e565b92936020600181928786015181550195019301610478565b828111156100e0576003600052909150601f830160051c7fc2575a0e9e593c00f959f8c92f12db2869c3395a3b0502d05e2516446f71f85b6020851061053e575b849392601f0160051c829003910160005b82811061052d5750506100e0565b60008183015585945060010161051f565b6000915061050e565b91607f16916100cc565b600080fd5b6040519190601f01601f191682016001600160401b0381118382101761044457604052565b51906001600160a01b038216820361055157565b81601f82011215610551578051906001600160401b038211610444576105be601f8301601f1916602001610556565b92828452602083830101116105515760005b8281106105e557505060206000918301015290565b806020809284010151828287010152016105d0565b6001600160a01b038116600090815260008051602061208d833981519152602052604090205460ff16610670576001600160a01b0316600081815260008051602061208d83398151915260205260408120805460ff1916600117905533919060008051602061202d8339815191528180a4600190565b50600090565b6001600160a01b038116600090815260008051602061206d833981519152602052604090205460ff16610670576001600160a01b0316600081815260008051602061206d83398151915260205260408120805460ff191660011790553391907fe9ea3f660aa5a8eccd1bf9d16e6cdf3c1cf9a2b284b830f15bda4493942cb68f9060008051602061202d8339815191529080a4600190565b6001600160a01b038116600090815260008051602061204d833981519152602052604090205460ff16610670576001600160a01b0316600081815260008051602061204d83398151915260205260408120805460ff191660011790553391907f9f2df0fed2c77648de5860a4cc508cd0818c85b8b8a1ab4ceeef8d981c8956a69060008051602061202d8339815191529080a4600190565b6001600160a01b03811660009081527f847f481f687befb06ed3511f1a8dcef57e83007c0147ae5047583d7056170937602052604090205460ff16610670576001600160a01b031660008181527f847f481f687befb06ed3511f1a8dcef57e83007c0147ae5047583d705617093760205260408120805460ff191660011790553391907f3c11d16cbaffd01df69ce1c404f6340ee057498f5f00246190ea54220576a8489060008051602061202d8339815191529080a4600190565b6001600160a01b03811660009081527fe64565cc4b7cd2ac34c13d86754c6dca495937db2a7c42e0611d450d233c6d00602052604090205460ff16610670576001600160a01b031660008181527fe64565cc4b7cd2ac34c13d86754c6dca495937db2a7c42e0611d450d233c6d0060205260408120805460ff191660011790553391907fa0cfdd0509afed74106d56854c75c942e0168581ee9307520e2fdc582f04ec8c9060008051602061202d8339815191529080a4600190565b6001600160a01b03811660009081526000805160206120ad833981519152602052604090205460ff16610670576001600160a01b031660008181526000805160206120ad83398151915260205260408120805460ff191660011790553391907fafdd7cd628718f2817044c62552dbc57027030dc05a743df7adaba96928175a59060008051602061202d8339815191529080a460019056fe608080604052600436101561001357600080fd5b600090813560e01c90816301ffc9a714610ebe5750806306fdde0314610ea2578063095ea7b314610df85780630a3b0a4f14610d5a57806318160ddd14610d3c57806323b872dd14610c5b578063248a9ca314610c2557806329092d0e14610af65780632f2ff15d14610ab55780632f8f68ba14610a89578063313ce5671461025357806336568abe14610a445780634048f25c146109fe57806340c10f19146109ae57806366f780e9146109695780636c8bb815146107ab57806370a082311461077357806388a7ca5c1461052657806389b0138a1461045e57806391d148541461041257806395d89b41146103015780639dc29fac146102ae5780639fa6658d1461026f578063a217fddf14610253578063a9059cbb14610221578063c496b08c146101f8578063d547741f146101ac5763dd62ed3e1461015557600080fd5b346101a95760403660031901126101a9576040610170610f71565b91610179610f8c565b9260018060a01b031681526001602052209060018060a01b03166000526020526020604060002054604051908152f35b80fd5b50346101a95760403660031901126101a9576101f46004356101cc610f8c565b906101ef6101e882600052600560205260016040600020015490565b339061135c565b611218565b5080f35b50346101a957806003193601126101a957600a546040516001600160a01b039091168152602090f35b50346101a95760403660031901126101a95761024861023e610f71565b6024359033611134565b602060405160018152f35b50346101a957806003193601126101a957602090604051908152f35b50346101a95760203660031901126101a95760209060ff906040906001600160a01b0361029a610f71565b168152600684522054166040519015158152f35b50346101a95760403660031901126101a9576102fe6102cb610f71565b6102f5337f3c11d16cbaffd01df69ce1c404f6340ee057498f5f00246190ea54220576a84861135c565b602435906112b9565b80f35b50346101a957806003193601126101a9576040519080600454908160011c91600181168015610408575b6020841081146103f4578386529081156103cd5750600114610370575b61036c8461035881860382610fa2565b604051918291602083526020830190610f30565b0390f35b600481527f8a35acfbc15ff81a39ae7d344fd709f28e8600b4aa8c65c6b64bfe7fe36bd19b939250905b8082106103b35750909150810160200161035882610348565b91926001816020925483858801015201910190929161039a565b60ff191660208087019190915292151560051b850190920192506103589150839050610348565b634e487b7160e01b83526022600452602483fd5b92607f169261032b565b50346101a95760403660031901126101a957604061042e610f8c565b9160043581526005602052209060018060a01b0316600052602052602060ff604060002054166040519015158152f35b50346101a957806003193601126101a95760405180602060075491828152018091600785527fa66cc928b5edb82af9bd49922954155ab7b0942694bea4ce44661d9a8736c68890855b81811061050757505050826104bd910383610fa2565b604051928392602084019060208552518091526040840192915b8181106104e5575050500390f35b82516001600160a01b03168452859450602093840193909201916001016104d7565b82546001600160a01b03168452602090930192600192830192016104a7565b50346101a95760803660031901126101a957610540610f71565b610548610f8c565b506044359060643567ffffffffffffffff811161076f573660238201121561076f57806004013567ffffffffffffffff811161076b5736910160240111610767577fa0cfdd0509afed74106d56854c75c942e0168581ee9307520e2fdc582f04ec8c8352600560209081526040808520336000908152925290205460ff1615610730576001600160a01b03908116835260096020526040832054168015610721576001600160a01b03811660009081526006602052604090205460ff16156106f957816106149161129e565b60405163a9059cbb60e01b81526001600160a01b037f0000000000000000000000000000000000000000000000000000000000000000811660048301526024820192909252906020908290604490829086907f0000000000000000000000000000000000000000000000000000000000000000165af19081156106ee5782916106bf575b50156106b057604051632229f29760e21b8152602090f35b63022e258160e11b8152600490fd5b6106e1915060203d6020116106e7575b6106d98183610fa2565b8101906110e7565b38610698565b503d6106cf565b6040513d84823e3d90fd5b610701610fda565b61071d60405192839263263ee27b60e01b8452600484016110ff565b0390fd5b631e4ec46b60e01b8352600483fd5b63e2517d3f60e01b8352336004527fa0cfdd0509afed74106d56854c75c942e0168581ee9307520e2fdc582f04ec8c602452604483fd5b8280fd5b8480fd5b8380fd5b50346101a95760203660031901126101a9576020906040906001600160a01b0361079b610f71565b1681528083522054604051908152f35b50346101a95760803660031901126101a9576107c5610f71565b906107ce610f8c565b916044356001600160a01b03811692908381036109655760643593610813337fafdd7cd628718f2817044c62552dbc57027030dc05a743df7adaba96928175a561135c565b308103610827575050509161024892611134565b61083485849793956112b9565b803b15610767576040516310123c9760e21b81526001600160a01b0390921660048301529091908290602490829084905af1801561095a5761093f575b5060405163363efa6560e21b81526001600160a01b037f0000000000000000000000000000000000000000000000000000000000000000811660048301529091166024820152604481019190915260208180606481015b0381857f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03165af19081156106ee578291610920575b50156109115750610248565b632810c5e560e11b8152600490fd5b610939915060203d6020116106e7576106d98183610fa2565b38610905565b9183610951602093956108c895610fa2565b93915091610871565b6040513d86823e3d90fd5b5080fd5b50346101a957806003193601126101a9576040517f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03168152602090f35b50346101a95760403660031901126101a9576102fe6109cb610f71565b6109f5337f9f2df0fed2c77648de5860a4cc508cd0818c85b8b8a1ab4ceeef8d981c8956a661135c565b6024359061129e565b50346101a95760203660031901126101a957610a18610f71565b33825260096020526040822080546001600160a01b0319166001600160a01b0390921691909117905580f35b50346101a95760403660031901126101a957610a5e610f8c565b336001600160a01b03821603610a7a576101f490600435611218565b63334bd91960e11b8252600482fd5b50346101a957806003193601126101a95750600a546040516001600160a01b0390911615158152602090f35b50346101a95760403660031901126101a9576101f4600435610ad5610f8c565b90610af16101e882600052600560205260016040600020015490565b611189565b50346101a95760203660031901126101a957610b10610f71565b610b19336112d4565b6001600160a01b0316808252600660205260408220805460ff19169055815b60075480821015610c1f5782610b4d836110aa565b905460039190911b1c6001600160a01b031614610b6d5750600101610b38565b9091506000198101908111610c0b5790610ba4610b8c610bc8936110aa565b905460039190911b1c6001600160a01b0316916110aa565b81546001600160a01b0393841660039290921b91821b9390911b1916919091179055565b6007548015610bf75760001901610bde816110aa565b81549060018060a01b039060031b1b1916905560075580f35b634e487b7160e01b82526031600452602482fd5b634e487b7160e01b83526011600452602483fd5b50505080f35b50346101a95760203660031901126101a9576020610c53600435600052600560205260016040600020015490565b604051908152f35b50346101a95760603660031901126101a957610c75610f71565b610c7d610f8c565b6001600160a01b03821680845260016020908152604080862033875290915284205460443593916000198210610cba575b50506102489350611134565b848210610d21578015610d0d573315610cf95760408686926102489852600160205281812060018060a01b033316825260205220910390553880610cae565b634a1406b160e11b86526004869052602486fd5b63e602df0560e01b86526004869052602486fd5b6064868684637dc7a0d960e11b835233600452602452604452fd5b50346101a957806003193601126101a9576020600254604051908152f35b50346101a95760203660031901126101a957610d74610f71565b610d7d336112d4565b6001600160a01b0381168083526006602052604083205460ff1615610da0578280f35b8252600660205260408220805460ff1916600117905560075468010000000000000000811015610de45790610ba4826001610dde94016007556110aa565b38808280f35b634e487b7160e01b83526041600452602483fd5b50346101a95760403660031901126101a957610e12610f71565b602435903315610e8e576001600160a01b0316918215610e7a5760408291338152600160205281812085825260205220556040519081527f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b92560203392a3602060405160018152f35b634a1406b160e11b81526004819052602490fd5b63e602df0560e01b83526004839052602483fd5b50346101a957806003193601126101a95761036c610358610fda565b9050346109655760203660031901126109655760043563ffffffff60e01b81168091036107675760ff604084836020965260088652205416908115610f05575b5015158152f35b637965db0b60e01b811491508115610f1f575b5038610efe565b6301ffc9a760e01b14905038610f18565b919082519283825260005b848110610f5c575050826000602080949584010152601f8019910116010190565b80602080928401015182828601015201610f3b565b600435906001600160a01b0382168203610f8757565b600080fd5b602435906001600160a01b0382168203610f8757565b90601f8019910116810190811067ffffffffffffffff821117610fc457604052565b634e487b7160e01b600052604160045260246000fd5b604051906000600354908160011c916001811680156110a0575b6020841081146103f45783865285929181156110815750600114611021575b61101f92500383610fa2565b565b506003600090815290917fc2575a0e9e593c00f959f8c92f12db2869c3395a3b0502d05e2516446f71f85b5b81831061106557505090602061101f92820101611013565b602091935080600191548385890101520191019091849261104d565b6020925061101f94915060ff191682840152151560051b820101611013565b92607f1692610ff4565b6007548110156110c557600760005260206000200190600090565b634e487b7160e01b600052603260045260246000fd5b6040513d6000823e3d90fd5b90816020910312610f8757518015158103610f875790565b61111460209392606083526060830190610f30565b6001600160a01b0390921681840152808203604090910152600081520190565b91906001600160a01b03831615611173576001600160a01b0381161561115d5761101f926113ab565b63ec442f0560e01b600052600060045260246000fd5b634b637e8f60e11b600052600060045260246000fd5b60008181526005602090815260408083206001600160a01b038616845290915290205460ff166112115760008181526005602090815260408083206001600160a01b0395909516808452949091528120805460ff19166001179055339291907f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d9080a4600190565b5050600090565b60008181526005602090815260408083206001600160a01b038616845290915290205460ff16156112115760008181526005602090815260408083206001600160a01b0395909516808452949091528120805460ff19169055339291907ff6391f5c32d9c69d2a47ea670b442974b53935d1edc7fd64eb21e047a839171b9080a4600190565b906001600160a01b0382161561115d5761101f9160006113ab565b906001600160a01b0382161561117357600061101f926113ab565b6001600160a01b03811660009081527fd6d85cd20f67dc2b0a1d0f68aab780a049b7358f42bdddb5397a3bf2f1b0d13c602052604090205460ff16156113175750565b63e2517d3f60e01b60009081526001600160a01b03919091166004527fe9ea3f660aa5a8eccd1bf9d16e6cdf3c1cf9a2b284b830f15bda4493942cb68f602452604490fd5b90816000526005602052604060002060018060a01b03821660005260205260ff604060002054161561138c575050565b63e2517d3f60e01b60005260018060a01b031660045260245260446000fd5b91906001600160a01b03831680151580611465575b61145c576001600160a01b03821615158061142a575b61142157600a546001600160a01b03161515908161140c575b506113fe575b61101f92611575565b61140782611490565b6113f5565b600a546001600160a01b0316149050386113ef565b50610701610fda565b506001600160a01b038216600090815260066020526040902061145790611453905b5460ff1690565b1590565b6113d6565b83610701610fda565b506001600160a01b038416600090815260066020526040902061148b906114539061144c565b6113c0565b600a546001600160a01b03168015611564576040516323b872dd60e01b81526001600160a01b039091166004820152306024820152604481018290526020818060648101038160007f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03165af190811561155f57600091611540575b501561152f57600a5461101f91906001600160a01b031661129e565b63022e258160e11b60005260046000fd5b611559915060203d6020116106e7576106d98183610fa2565b38611513565b6110db565b631ede02d360e01b60005260046000fd5b6001600160a01b03169081611609576002548381018091116115f3577fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef916020916002555b6001600160a01b031693846115db5780600254036002555b604051908152a3565b846000526000825260406000208181540190556115d2565b634e487b7160e01b600052601160045260246000fd5b816000526000602052604060002054838110611659577fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef91846020928560005260008452036040600020556115ba565b91905063391434e360e21b60005260045260245260445260646000fd2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d15a28d26fa1bf736cf7edc9922607171ccb09c3c73b808e7772a3013e068a522d6d85cd20f67dc2b0a1d0f68aab780a049b7358f42bdddb5397a3bf2f1b0d13c05b8ccbb9d4d8fb16ea74ce3c29a41f1b461fbdaff4714a0d9a8eb05499746bc1aa180bd6b8fa8ca270f7e68a3586acf71b203d049d09fff62628d548ccdc5b6" + "bytecode": "0x60e06040523461050b57611f1d803803806100198161050f565b92833981019060c08183031261050b5761003281610534565b9161003f60208301610534565b9061004c60408401610534565b9061005960608501610534565b60808501519094906001600160401b03811161050b578261007b918301610548565b60a08201519092906001600160401b03811161050b5761009b9201610548565b815190916001600160401b0382116104065760035490600182811c92168015610501575b60208310146103e85781601f84931161048b575b50602090601f8311600114610425575f9261041a575b50508160011b915f199060031b1c1916176003555b8051906001600160401b0382116104065760045490600182811c921680156103fc575b60208310146103e85781601f849311610372575b50602090601f831160011461030c575f92610301575b50508160011b915f199060031b1c1916176004555b61016984610599565b506101738461060f565b506001600160a01b038416156102f2576001600160a01b038216156102e3576001600160a01b0381169182156102d4576001600160a01b038416156102c55760086020527f9c1077d48c1ee2b23ae7e47ca8a0ebdbffc921a368b14a8244a53034b67dcd478054600160ff1991821681179092557fa4ec55a0f91c1bd941b947f3664109302d33d8214ed053e7cdbd357f3168cdf18054821683179055632cc34a4960e01b5f527f5b12e6a61e98ee390c0688e3558b11011b75a141de23ee96960039da1a2e01e68054909116909117905560805260a09190915260c08290526102859261027f91906102799061026981610599565b50610273816106a2565b50610735565b506107c8565b5061085b565b5060405161154e90816108ef8239608051818181610625015281816108a00152610a02015260a05181818161066201526108df015260c051816109870152f35b63da9fbf9b60e01b5f5260045ffd5b636e06d40160e01b5f5260045ffd5b632ad8024f60e21b5f5260045ffd5b6339bfddb560e01b5f5260045ffd5b015190505f8061014b565b60045f9081528281209350601f198516905b81811061035a5750908460019594939210610342575b505050811b01600455610160565b01515f1960f88460031b161c191690555f8080610334565b9293602060018192878601518155019501930161031e565b828111156101355760045f52909150601f830160051c7f8a35acfbc15ff81a39ae7d344fd709f28e8600b4aa8c65c6b64bfe7fe36bd19b602085106103e0575b849392601f0160051c82900391015f5b8281106103d0575050610135565b5f818301558594506001016103c2565b5f91506103b2565b634e487b7160e01b5f52602260045260245ffd5b91607f1691610121565b634e487b7160e01b5f52604160045260245ffd5b015190505f806100e9565b60035f9081528281209350601f198516905b818110610473575090846001959493921061045b575b505050811b016003556100fe565b01515f1960f88460031b161c191690555f808061044d565b92936020600181928786015181550195019301610437565b828111156100d35760035f52909150601f830160051c7fc2575a0e9e593c00f959f8c92f12db2869c3395a3b0502d05e2516446f71f85b602085106104f9575b849392601f0160051c82900391015f5b8281106104e95750506100d3565b5f818301558594506001016104db565b5f91506104cb565b91607f16916100bf565b5f80fd5b6040519190601f01601f191682016001600160401b0381118382101761040657604052565b51906001600160a01b038216820361050b57565b81601f8201121561050b578051906001600160401b03821161040657610577601f8301601f191660200161050f565b928284526020838301011161050b57815f9260208093018386015e8301015290565b6001600160a01b0381165f9081525f516020611edd5f395f51905f52602052604090205460ff1661060a576001600160a01b03165f8181525f516020611edd5f395f51905f5260205260408120805460ff191660011790553391905f516020611e3d5f395f51905f528180a4600190565b505f90565b6001600160a01b0381165f9081525f516020611ebd5f395f51905f52602052604090205460ff1661060a576001600160a01b03165f8181525f516020611ebd5f395f51905f5260205260408120805460ff191660011790553391907fe9ea3f660aa5a8eccd1bf9d16e6cdf3c1cf9a2b284b830f15bda4493942cb68f905f516020611e3d5f395f51905f529080a4600190565b6001600160a01b0381165f9081525f516020611e5d5f395f51905f52602052604090205460ff1661060a576001600160a01b03165f8181525f516020611e5d5f395f51905f5260205260408120805460ff191660011790553391907f9f2df0fed2c77648de5860a4cc508cd0818c85b8b8a1ab4ceeef8d981c8956a6905f516020611e3d5f395f51905f529080a4600190565b6001600160a01b0381165f9081525f516020611e9d5f395f51905f52602052604090205460ff1661060a576001600160a01b03165f8181525f516020611e9d5f395f51905f5260205260408120805460ff191660011790553391907f3c11d16cbaffd01df69ce1c404f6340ee057498f5f00246190ea54220576a848905f516020611e3d5f395f51905f529080a4600190565b6001600160a01b0381165f9081525f516020611e7d5f395f51905f52602052604090205460ff1661060a576001600160a01b03165f8181525f516020611e7d5f395f51905f5260205260408120805460ff191660011790553391907fa0cfdd0509afed74106d56854c75c942e0168581ee9307520e2fdc582f04ec8c905f516020611e3d5f395f51905f529080a4600190565b6001600160a01b0381165f9081525f516020611efd5f395f51905f52602052604090205460ff1661060a576001600160a01b03165f8181525f516020611efd5f395f51905f5260205260408120805460ff191660011790553391907fafdd7cd628718f2817044c62552dbc57027030dc05a743df7adaba96928175a5905f516020611e3d5f395f51905f529080a460019056fe6080806040526004361015610012575f80fd5b5f905f3560e01c90816301ffc9a714610f315750806306fdde0314610f16578063095ea7b314610e945780630a3b0a4f14610dfe57806318160ddd14610de157806323b872dd14610d02578063248a9ca314610ccf57806329092d0e14610ba85780632f2ff15d14610b6a578063313ce56714610b5057806336568abe14610b0c5780634048f25c14610ac657806340c10f1914610a3157806366f780e9146109ed5780636c8bb815146107a557806370a082311461076d57806388a7ca5c1461052d57806389b0138a1461046557806391d148541461041b57806395d89b411461030a5780639dc29fac146102685780639fa6658d14610229578063a217fddf1461020d578063a9059cbb146101db578063d547741f146101915763dd62ed3e1461013c575f80fd5b3461018e57604036600319011261018e576040610157610fc7565b91610160610fdd565b9260018060a01b031681526001602052209060018060a01b03165f52602052602060405f2054604051908152f35b80fd5b503461018e57604036600319011261018e576101d76004356101b1610fdd565b906101d26101cb825f526005602052600160405f20015490565b3390611505565b6112e7565b5080f35b503461018e57604036600319011261018e576102026101f8610fc7565b602435903361116f565b602060405160018152f35b503461018e578060031936011261018e57602090604051908152f35b503461018e57602036600319011261018e5760209060ff906040906001600160a01b03610254610fc7565b168152600684522054166040519015158152f35b503461018e57604036600319011261018e57610282610fc7565b7f3c11d16cbaffd01df69ce1c404f6340ee057498f5f00246190ea54220576a8488252600560209081526040808420335f908152925290205460ff16156102d3576102d090602435906113e3565b80f35b63e2517d3f60e01b8252336004527f3c11d16cbaffd01df69ce1c404f6340ee057498f5f00246190ea54220576a848602452604482fd5b503461018e578060031936011261018e576040519080600454908160011c91600181168015610411575b6020841081146103fd578386529081156103d65750600114610379575b6103758461036181860382610ff3565b604051918291602083526020830190610fa3565b0390f35b600481527f8a35acfbc15ff81a39ae7d344fd709f28e8600b4aa8c65c6b64bfe7fe36bd19b939250905b8082106103bc5750909150810160200161036182610351565b9192600181602092548385880101520191019092916103a3565b60ff191660208087019190915292151560051b850190920192506103619150839050610351565b634e487b7160e01b83526022600452602483fd5b92607f1692610334565b503461018e57604036600319011261018e576040610437610fdd565b9160043581526005602052209060018060a01b03165f52602052602060ff60405f2054166040519015158152f35b503461018e578060031936011261018e5760405180602060075491828152018091600785527fa66cc928b5edb82af9bd49922954155ab7b0942694bea4ce44661d9a8736c68890855b81811061050e57505050826104c4910383610ff3565b604051928392602084019060208552518091526040840192915b8181106104ec575050500390f35b82516001600160a01b03168452859450602093840193909201916001016104de565b82546001600160a01b03168452602090930192600192830192016104ae565b503461018e57608036600319011261018e57610547610fc7565b61054f610fdd565b506044359060643567ffffffffffffffff8111610769573660238201121561076957806004013567ffffffffffffffff81116107655736910160240111610761577fa0cfdd0509afed74106d56854c75c942e0168581ee9307520e2fdc582f04ec8c8352600560209081526040808520335f908152925290205460ff161561072a576001600160a01b0390811683526009602052604083205416801561071b57808352600660205260ff604084205416156106f3578161060e9161136b565b60405163a9059cbb60e01b81526001600160a01b037f0000000000000000000000000000000000000000000000000000000000000000811660048301526024820192909252906020908290604490829086907f0000000000000000000000000000000000000000000000000000000000000000165af19081156106e85782916106b9575b50156106aa57604051632229f29760e21b8152602090f35b63022e258160e11b8152600490fd5b6106db915060203d6020116106e1575b6106d38183610ff3565b810190611123565b5f610692565b503d6106c9565b6040513d84823e3d90fd5b6106fb611015565b61071760405192839263263ee27b60e01b84526004840161113b565b0390fd5b631e4ec46b60e01b8352600483fd5b63e2517d3f60e01b8352336004527fa0cfdd0509afed74106d56854c75c942e0168581ee9307520e2fdc582f04ec8c602452604483fd5b8280fd5b8480fd5b8380fd5b503461018e57602036600319011261018e576020906040906001600160a01b03610795610fc7565b1681528083522054604051908152f35b5034610969576080366003190112610969576107bf610fc7565b6107c7610fdd565b6044356001600160a01b038116929083900361096957335f9081527f1aa180bd6b8fa8ca270f7e68a3586acf71b203d049d09fff62628d548ccdc5b66020526040902054606435939060ff16156109b6576001600160a01b0382163381141580610983575b61096d57503081036108435750610202935061116f565b92919082610850916113e3565b823b15610969576040516310123c9760e21b81526001600160a01b0390911660048201525f8160248183875af1801561095e57610949575b506040519163363efa6560e21b835260018060a01b037f0000000000000000000000000000000000000000000000000000000000000000166004840152602483015260448201526020816064818560018060a01b037f0000000000000000000000000000000000000000000000000000000000000000165af19081156106e857829161092a575b501561091b5750610202565b632810c5e560e11b8152600490fd5b610943915060203d6020116106e1576106d38183610ff3565b5f61090f565b6109569193505f90610ff3565b5f915f610888565b6040513d5f823e3d90fd5b5f80fd5b6330bfce4360e01b5f526004523360245260445ffd5b50337f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316141561082c565b63e2517d3f60e01b5f52336004527fafdd7cd628718f2817044c62552dbc57027030dc05a743df7adaba96928175a560245260445ffd5b34610969575f366003190112610969576040517f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03168152602090f35b3461096957604036600319011261096957610a4a610fc7565b335f9081527f15a28d26fa1bf736cf7edc9922607171ccb09c3c73b808e7772a3013e068a522602052604090205460ff1615610a8f57610a8d906024359061136b565b005b63e2517d3f60e01b5f52336004527f9f2df0fed2c77648de5860a4cc508cd0818c85b8b8a1ab4ceeef8d981c8956a660245260445ffd5b3461096957602036600319011261096957610adf610fc7565b335f90815260096020526040902080546001600160a01b0319166001600160a01b03909216919091179055005b3461096957604036600319011261096957610b25610fdd565b336001600160a01b03821603610b4157610a8d906004356112e7565b63334bd91960e11b5f5260045ffd5b34610969575f3660031901126109695760206040515f8152f35b3461096957604036600319011261096957610a8d600435610b89610fdd565b90610ba36101cb825f526005602052600160405f20015490565b61125b565b3461096957602036600319011261096957610bc1610fc7565b610bca3361147f565b6001600160a01b03165f818152600660205260408120805460ff191690555b60075480821015610a8d5782610bfe836110f7565b905460039190911b1c6001600160a01b031614610c1e5750600101610be9565b5f19810192508211610cbb57610c51610c39610c75936110f7565b905460039190911b1c6001600160a01b0316916110f7565b81546001600160a01b0393841660039290921b91821b9390911b1916919091179055565b6007548015610ca7575f1901610c8a816110f7565b81546001600160a01b0360039290921b9190911b19169055600755005b634e487b7160e01b5f52603160045260245ffd5b634e487b7160e01b5f52601160045260245ffd5b34610969576020366003190112610969576020610cfa6004355f526005602052600160405f20015490565b604051908152f35b3461096957606036600319011261096957610d1b610fc7565b610d23610fdd565b6001600160a01b0382165f818152600160209081526040808320338452909152902054909260443592915f198110610d61575b50610202935061116f565b838110610dc6578415610db3573315610da057610202945f52600160205260405f2060018060a01b0333165f526020528360405f209103905584610d56565b634a1406b160e11b5f525f60045260245ffd5b63e602df0560e01b5f525f60045260245ffd5b8390637dc7a0d960e11b5f523360045260245260445260645ffd5b34610969575f366003190112610969576020600254604051908152f35b3461096957602036600319011261096957610e17610fc7565b610e203361147f565b6001600160a01b0381165f8181526006602052604090205460ff1615610e4257005b5f52600660205260405f20600160ff198254161790556007549068010000000000000000821015610e8057610c51826001610a8d94016007556110f7565b634e487b7160e01b5f52604160045260245ffd5b3461096957604036600319011261096957610ead610fc7565b602435903315610db3576001600160a01b0316908115610da057335f52600160205260405f20825f526020528060405f20556040519081527f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b92560203392a3602060405160018152f35b34610969575f36600319011261096957610375610361611015565b34610969576020366003190112610969576004359063ffffffff60e01b821680920361096957816020925f526008835260ff60405f205416908115610f78575b5015158152f35b637965db0b60e01b811491508115610f92575b5083610f71565b6301ffc9a760e01b14905083610f8b565b805180835260209291819084018484015e5f828201840152601f01601f1916010190565b600435906001600160a01b038216820361096957565b602435906001600160a01b038216820361096957565b90601f8019910116810190811067ffffffffffffffff821117610e8057604052565b604051905f6003548060011c91600182169182156110ed575b6020841083146110d95783865285929081156110ba575060011461105b575b61105992500383610ff3565b565b5060035f90815290917fc2575a0e9e593c00f959f8c92f12db2869c3395a3b0502d05e2516446f71f85b5b81831061109e5750509060206110599282010161104d565b6020919350806001915483858901015201910190918492611086565b6020925061105994915060ff191682840152151560051b82010161104d565b634e487b7160e01b5f52602260045260245ffd5b92607f169261102e565b60075481101561110f5760075f5260205f2001905f90565b634e487b7160e01b5f52603260045260245ffd5b90816020910312610969575180151581036109695790565b61115060209392606083526060830190610fa3565b6001600160a01b03909216818401528082036040909101525f81520190565b916001600160a01b038316918215611248576001600160a01b03811693841561123557835f52600660205260ff60405f205416156106f35750835f52600660205260ff60405f205416156106f35750815f525f60205260405f205481811061121c57817fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef92602092855f525f84520360405f2055845f525f825260405f20818154019055604051908152a3565b8263391434e360e21b5f5260045260245260445260645ffd5b63ec442f0560e01b5f525f60045260245ffd5b634b637e8f60e11b5f525f60045260245ffd5b5f8181526005602090815260408083206001600160a01b038616845290915290205460ff166112e1575f8181526005602090815260408083206001600160a01b0395909516808452949091528120805460ff19166001179055339291907f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d9080a4600190565b50505f90565b5f8181526005602090815260408083206001600160a01b038616845290915290205460ff16156112e1575f8181526005602090815260408083206001600160a01b0395909516808452949091528120805460ff19169055339291907ff6391f5c32d9c69d2a47ea670b442974b53935d1edc7fd64eb21e047a839171b9080a4600190565b6001600160a01b0381169190821561123557825f52600660205260ff60405f205416156106f3575060025490808201809211610cbb5760207fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef915f9360025584845283825260408420818154019055604051908152a3565b9091906001600160a01b03811690811561124857815f52600660205260ff60405f205416156106f35750805f525f60205260405f2054838110611465576020845f94957fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef938587528684520360408620558060025403600255604051908152a3565b915063391434e360e21b5f5260045260245260445260645ffd5b6001600160a01b0381165f9081527fd6d85cd20f67dc2b0a1d0f68aab780a049b7358f42bdddb5397a3bf2f1b0d13c602052604090205460ff16156114c15750565b63e2517d3f60e01b5f9081526001600160a01b03919091166004527fe9ea3f660aa5a8eccd1bf9d16e6cdf3c1cf9a2b284b830f15bda4493942cb68f602452604490fd5b90815f52600560205260405f2060018060a01b0382165f5260205260ff60405f20541615611531575050565b63e2517d3f60e01b5f5260018060a01b031660045260245260445ffd2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d15a28d26fa1bf736cf7edc9922607171ccb09c3c73b808e7772a3013e068a522e64565cc4b7cd2ac34c13d86754c6dca495937db2a7c42e0611d450d233c6d00847f481f687befb06ed3511f1a8dcef57e83007c0147ae5047583d7056170937d6d85cd20f67dc2b0a1d0f68aab780a049b7358f42bdddb5397a3bf2f1b0d13c05b8ccbb9d4d8fb16ea74ce3c29a41f1b461fbdaff4714a0d9a8eb05499746bc1aa180bd6b8fa8ca270f7e68a3586acf71b203d049d09fff62628d548ccdc5b6" } diff --git a/services/nb-bond-api/src/banking-tbd.ts b/services/nb-bond-api/src/banking-tbd.ts index e947a15..c24faf9 100644 --- a/services/nb-bond-api/src/banking-tbd.ts +++ b/services/nb-bond-api/src/banking-tbd.ts @@ -33,8 +33,6 @@ import { withMd5 } from './http'; import { type IngestionDatabase, listBankRows } from './ingestion-db'; import { logger } from './logger'; -const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; - /** * The local-sandbox bank roster. `role` is the fixture key role used by * `scripts/generate-local-sandbox-fixtures.mjs` and bidders.ts — it drives @@ -96,16 +94,14 @@ export interface TbdHolderData { /** Read one TBD contract and compose its enriched, md5-stamped DTO. */ async function composeToken(bankName: string, address: string) { const tbd = new Contract(address, tbdAbi, provider); - const [name, symbol, decimalsRaw, totalSupplyRaw, bankAddrRaw, govReserveRaw, allowlistRaw] = - await Promise.all([ - tbd.name() as Promise, - tbd.symbol() as Promise, - tbd.decimals() as Promise, - tbd.totalSupply() as Promise, - tbd.getBankAddress() as Promise, - tbd.govReserve() as Promise, - tbd.allowlistQueryAll() as Promise, - ]); + const [name, symbol, decimalsRaw, totalSupplyRaw, bankAddrRaw, allowlistRaw] = await Promise.all([ + tbd.name() as Promise, + tbd.symbol() as Promise, + tbd.decimals() as Promise, + tbd.totalSupply() as Promise, + tbd.getBankAddress() as Promise, + tbd.allowlistQueryAll() as Promise, + ]); const bankAddress = getAddress(bankAddrRaw); @@ -132,9 +128,6 @@ async function composeToken(bankName: string, address: string) { }; } - const govReserve = getAddress(govReserveRaw); - const nominated = govReserve !== ZERO_ADDRESS; - return withMd5({ address: getAddress(address), name, @@ -143,7 +136,6 @@ async function composeToken(bankName: string, address: string) { totalSupply: totalSupplyRaw.toString(), bank: { name: bankName, address: bankAddress }, reserve, - government: { nominated, reserveAddress: nominated ? govReserve : null }, holders, }); } diff --git a/services/nb-bond-api/src/banks.ts b/services/nb-bond-api/src/banks.ts index c7d891a..ecc5131 100644 --- a/services/nb-bond-api/src/banks.ts +++ b/services/nb-bond-api/src/banks.ts @@ -47,8 +47,6 @@ import { } from './ingestion-db'; import { logger } from './logger'; -const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; - export class BankValidationError extends Error { constructor( message: string, @@ -197,14 +195,14 @@ export const defaultCreateBankChainOps: CreateBankChainOps = { }, async deployTbd(args) { // The bank's own wallet deploys, mirroring 04_Tbd.s.sol's - // (admin = bank, bank = bank, govReserve = 0) shape used for DNB. + // (admin = bank, bank = bank) shape. // Fresh keys start at nonce 0 and the zero-base-fee genesis means no // funding is needed, so no cross-request nonce coordination applies. const bankWallet = new Wallet(args.privateKey, provider); const factory = new ContractFactory(tbdAbi as InterfaceAbi, tbdBytecode, bankWallet); const contract = await withDecodedRevert('Tbd deploy', () => factory - .deploy(args.bank, args.bank, args.wnok, args.dvp, args.name, args.symbol, ZERO_ADDRESS) + .deploy(args.bank, args.bank, args.wnok, args.dvp, args.name, args.symbol) .then(async (c) => { await c.waitForDeployment(); return c; diff --git a/services/nb-bond-api/src/contracts/banking.ts b/services/nb-bond-api/src/contracts/banking.ts index 70911aa..67454f8 100644 --- a/services/nb-bond-api/src/contracts/banking.ts +++ b/services/nb-bond-api/src/contracts/banking.ts @@ -48,7 +48,7 @@ export const tbdTokenSchema = z bankAllowlisted: z.boolean().meta({ description: "True when the owning bank's address is on the WNOK allowlist — i.e. it can hold the " + - 'WNOK reserve and settle cross-bank in WNOK. Distinct from government-nomination.', + 'WNOK reserve and settle cross-bank in WNOK.', }), }) .nullable() @@ -56,17 +56,6 @@ export const tbdTokenSchema = z id: 'TbdReserve', description: 'WNOK reserve backing (informational). Null when WNOK is unreachable.', }), - government: z - .object({ - nominated: z.boolean().meta({ - description: - 'True when a government reserve account is set (enables the gov-reserve mint path)', - }), - reserveAddress: addressSchema.nullable().meta({ - description: 'The government reserve account, or null when not nominated', - }), - }) - .meta({ id: 'TbdGovernment', description: 'Government-nomination state for this token' }), holders: z .array(tbdHolderSchema) .meta({ description: 'Allowlisted addresses with their TBD balances (sandbox-scale)' }), diff --git a/services/nb-bond-api/tests/banks.test.ts b/services/nb-bond-api/tests/banks.test.ts index 25b67be..c5e9f66 100644 --- a/services/nb-bond-api/tests/banks.test.ts +++ b/services/nb-bond-api/tests/banks.test.ts @@ -17,6 +17,7 @@ import * as path from 'path'; import { getAddress } from 'ethers'; +import { tbdAbi } from '../src/abi'; import type { CreateBankChainOps } from '../src/banks'; import { deriveBidderAddress, generateBidderPrivateKey } from '../src/bidders'; import { type IngestionDatabase, insertBankRow, openDatabase } from '../src/ingestion-db'; @@ -39,6 +40,21 @@ function fakeOps(overrides: Partial = {}): CreateBankChainOp }; } +describe('Tbd deploy artifact', () => { + it('matches the six-argument constructor deployTbd passes', () => { + const ctor = tbdAbi.find((entry) => entry.type === 'constructor'); + expect(ctor?.inputs?.map((input) => input.name)).toEqual([ + 'admin', + 'bank', + 'wnok', + 'dvp', + 'name_', + 'symbol_', + ]); + expect(tbdAbi.some((entry) => 'name' in entry && entry.name === 'govReserve')).toBe(false); + }); +}); + describe('banks', () => { const originalKey = process.env.CENTRAL_BANK_PK; let tmpDir: string; diff --git a/services/nb-bond-api/tests/custom-errors.test.ts b/services/nb-bond-api/tests/custom-errors.test.ts index 67c4440..84c1cbd 100644 --- a/services/nb-bond-api/tests/custom-errors.test.ts +++ b/services/nb-bond-api/tests/custom-errors.test.ts @@ -95,6 +95,16 @@ describe('describeRevert', () => { expect(described).toContain('not allowed'); }); + it('decodes a TBD transfer rejected because the caller is not the payer', () => { + const payer = '0xe61a63Ef630b7B6FF9b9e595B61f641171a4eB97'; + const caller = '0x5B38Da6a701c568545dCfcB03FcB875f56beddC4'; + const data = tbdIface.encodeErrorResult('CctFromNotCaller', [payer, caller]); + const described = describeRevert({ data }, [tbdIface]); + expect(described).toContain('CctFromNotCaller'); + expect(described).toContain(payer); + expect(described).toContain(caller); + }); + it('returns null when nothing matches', () => { expect(describeRevert({ data: '0xdeadbeef' }, [managerIface, tbdIface])).toBeNull(); expect(describeRevert(new Error('boom'), [managerIface, tbdIface])).toBeNull(); diff --git a/services/nb-ui/src/pages/BankingPage.jsx b/services/nb-ui/src/pages/BankingPage.jsx index 7132bf3..9ddcc27 100644 --- a/services/nb-ui/src/pages/BankingPage.jsx +++ b/services/nb-ui/src/pages/BankingPage.jsx @@ -293,21 +293,7 @@ export function BankingPage() {
- Government-nominated ⓘ -
-
{token.government.nominated ? 'Yes' : 'No'}
-
- {token.government.nominated - ? Fmt.shortHex(token.government.reserveAddress, 8, 6) - : 'No gov-reserve mint path'} -
-
-
-
WNOK settlement ⓘ
diff --git a/services/nb-ui/tests/BankingPage.test.jsx b/services/nb-ui/tests/BankingPage.test.jsx index 33f9477..a316a5f 100644 --- a/services/nb-ui/tests/BankingPage.test.jsx +++ b/services/nb-ui/tests/BankingPage.test.jsx @@ -29,7 +29,6 @@ const nordea = { totalSupply: '10000', bank: { name: 'Nordea Bank', address: `0x${'a'.repeat(40)}` }, reserve: { wnokBalance: '1100000', backed: true, bankAllowlisted: true }, - government: { nominated: true, reserveAddress: `0x${'b'.repeat(40)}` }, holders: [{ address: `0x${'c'.repeat(40)}`, balance: '10000' }], md5: 'x', }; @@ -40,7 +39,6 @@ const dnb = { symbol: 'TBDdnb', bank: { name: 'DNB Bank', address: `0x${'d'.repeat(40)}` }, reserve: { wnokBalance: '1200000', backed: true, bankAllowlisted: true }, - government: { nominated: false, reserveAddress: null }, }; function renderPage() { From bef78a26dbf25b5510aae7200b366f76615eabe9 Mon Sep 17 00:00:00 2001 From: Deleo <152405392+Greaker@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:44:39 +0200 Subject: [PATCH 06/18] Honour testMode for operators only; keep server-held keys server-side (#304) The API now honours the testMode query flag (unsealing bids on open auctions, skipping the close end-time pre-check) only for operator callers. In the local none auth mode every caller counts as an operator, so local behaviour is unchanged; in entra mode a tester's flag is ignored rather than rejected, so a stale browser setting still yields a normal sealed read. The rule lives in a small test-mode module built on a new isOperatorRequest helper in auth.ts, and the OpenAPI description states it. Bidder DTOs return privateKey only in none mode and never for a fixture bidder whose key comes from a PK_* environment override; otherwise the field is null, matching how bank keys are already handled. The OpenAPI schema marks the field nullable and describes the rule. The UI mirrors this with a canOperate capability delivered through a CapabilitiesContext: the top-bar test-mode toggle is shown only to accounts that can operate, getTestMode() ignores a stored flag for anyone else, and the Bidders page shows "Held by the server" when no key is returned. The disabled "Place bid" hint mentions Test mode only to accounts that can use it. Docs describe the rule in both services and in ARCHITECTURE.md. Tests: jest 258 (39 suites), vitest 131 (26 files), all green. --- docs/ARCHITECTURE.md | 9 +- services/nb-bond-api/DEVELOPMENT.md | 9 ++ services/nb-bond-api/README.md | 4 +- services/nb-bond-api/openapi.json | 21 ++-- services/nb-bond-api/src/app.ts | 48 +++------ services/nb-bond-api/src/auth.ts | 23 +++- services/nb-bond-api/src/bidders.ts | 27 +++++ .../nb-bond-api/src/contracts/auctions.ts | 6 +- services/nb-bond-api/src/contracts/bidders.ts | 7 +- services/nb-bond-api/src/contracts/bonds.ts | 6 +- services/nb-bond-api/src/test-mode.ts | 29 +++++ services/nb-bond-api/tests/app.test.ts | 2 + services/nb-bond-api/tests/bidders.test.ts | 26 +++++ .../tests/route-authorization.test.ts | 101 ++++++++++++++++++ services/nb-bond-api/tests/test-mode.test.ts | 63 +++++++++++ services/nb-ui/DEVELOPMENT.md | 8 ++ services/nb-ui/README.md | 2 +- services/nb-ui/src/App.jsx | 27 ++--- services/nb-ui/src/auth/capabilities.js | 27 ++++- .../nb-ui/src/auth/capabilitiesContext.js | 19 ++++ services/nb-ui/src/components/Layout.jsx | 7 +- services/nb-ui/src/pages/BiddersPage.jsx | 36 +++++-- services/nb-ui/src/utils/debugSettings.js | 13 ++- services/nb-ui/tests/BiddersPage.test.jsx | 50 +++++++++ services/nb-ui/tests/authGate.test.jsx | 13 ++- services/nb-ui/tests/capabilities.test.js | 10 +- services/nb-ui/tests/debugSettings.test.js | 50 +++++++++ 27 files changed, 556 insertions(+), 87 deletions(-) create mode 100644 services/nb-bond-api/src/test-mode.ts create mode 100644 services/nb-bond-api/tests/route-authorization.test.ts create mode 100644 services/nb-bond-api/tests/test-mode.test.ts create mode 100644 services/nb-ui/src/auth/capabilitiesContext.js create mode 100644 services/nb-ui/tests/debugSettings.test.js diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5ced034..1e35b8d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -264,6 +264,9 @@ roster (`#/bidders`), and the Norges Bank operator surface against WNOK (`#/central-bank`). The two latter pages are **sandbox-only** — private keys for impersonable bidders and the CB operator are stored in plaintext server-side, and a visible banner says so on every render. +The API returns a bidder's key only in local `none` auth mode, and never +for keys supplied by the environment; otherwise the Bidders page shows the +key as held by the server. The UI is a thin client over the NB Bond API: - the only network seam is `services/nb-ui/src/api/`; the rest of the UI does @@ -283,8 +286,10 @@ The UI is a thin client over the NB Bond API: they are read from `window.__APP_CONFIG__` at startup - in `entra` mode the UI also enforces role-based access from Entra App Roles: operators get full access including the Central Bank page, testers get the UI - without it, and an unrecognised account sees an access-denied screen. The NB - Bond API enforces the same roles server-side; `none` mode is ungated + without it, and an unrecognised account sees an access-denied screen. The + sandbox test-mode toggle is operator-only, and the API ignores the + `testMode` flag from anyone else. The NB Bond API enforces the same roles + server-side; `none` mode is ungated - the operator pages include **Banking** (`#/tbd`) — per-bank tokenized bank deposits managed against the `/v1/banking/*` surface — alongside the Central Bank (WNOK) page; the top nav is grouped into dropdown categories diff --git a/services/nb-bond-api/DEVELOPMENT.md b/services/nb-bond-api/DEVELOPMENT.md index 5094c0e..49656fc 100644 --- a/services/nb-bond-api/DEVELOPMENT.md +++ b/services/nb-bond-api/DEVELOPMENT.md @@ -499,6 +499,15 @@ token, `entra` mode authorizes it against the `roles` claim (Entra App Roles): otherwise). `none` mode performs no role checks. The values must match the nb-ui `AUTH_OPERATOR_ROLES` / `AUTH_TESTER_ROLES` and the App Role values defined in Entra. +- The `testMode` query flag (unseal open bids on bond / auction reads, skip + the close end-time pre-check) is honoured only for operator callers + (`src/test-mode.ts`, using `isOperatorRequest` in `src/auth.ts`). A tester's + flag is ignored, not rejected: the response is the normal sealed view. +- Bidder DTOs (`GET` / `POST /v1/bidders`) carry `privateKey` only in `none` + mode, and never for a fixture bidder whose key comes from a `PK_NORDEA` / + `PK_DNB` / `PK_ALICE_TBD` override (`exposedBidderPrivateKey` in + `src/bidders.ts`); otherwise `privateKey` is `null`. The server keeps + holding the key and signs with it, as it does for bank keys. `/v1/health`, `/docs`, and `/v1/openapi.json` are mounted before the auth gate and stay public in both modes. Every other endpoint goes diff --git a/services/nb-bond-api/README.md b/services/nb-bond-api/README.md index ecc5e46..ff6a458 100644 --- a/services/nb-bond-api/README.md +++ b/services/nb-bond-api/README.md @@ -29,7 +29,9 @@ Two sandbox-only resource families sit alongside the bond tree: sealed bid on the bidder's behalf — the API constructs the plaintext, signs the EIP-712 `BidIntent`, dual-wraps with the auctioneer sealing key, and submits on-chain from a wallet bound to the bidder's stored - key. Private keys are stored in plaintext in the local SQLite DB; + key. Private keys are stored in plaintext in the local SQLite DB and are + returned by the API only in local `none` auth mode, never for keys supplied + by the `PK_*` overrides (`privateKey` is `null` otherwise); see [`docs/plans/archive/bidders-and-central-bank-plan.md`](../../docs/plans/archive/bidders-and-central-bank-plan.md). - **`central-bank`** — Norges Bank operator surface against the WNOK contract. `GET /v1/central-bank` returns the CB summary (address, diff --git a/services/nb-bond-api/openapi.json b/services/nb-bond-api/openapi.json index b816157..ebc6f68 100644 --- a/services/nb-bond-api/openapi.json +++ b/services/nb-bond-api/openapi.json @@ -226,7 +226,7 @@ "type": "boolean", "default": false }, - "description": "Sandbox-only umbrella \"test mode\" flag. The operator UI flips this from the top bar; ArgoCD- managed deployments should ignore it. Today `testMode=true` enables: (a) unsealing of bids on auctions still in the BIDDING phase on bond / auction GETs; (b) skipping the API-side end-time pre-check on PATCH /v1/auctions/{id} (close) so the operator can attempt close before the bidding window expires — the on-chain contract still enforces `block.timestamp > metadata.end` and reverts with `InBidPhase()` if the window is open. Future test affordances will plumb through this same flag." + "description": "Sandbox-only umbrella \"test mode\" flag. The operator UI flips this from the top bar. Honoured only in `none` auth mode or for operator callers; ignored otherwise (the caller gets the sealed view and the normal close pre-check, not an error). When honoured, `testMode=true` enables: (a) unsealing of bids on auctions still in the BIDDING phase on bond / auction GETs; (b) skipping the API-side end-time pre-check on PATCH /v1/auctions/{id} (close) so the operator can attempt close before the bidding window expires — the on-chain contract still enforces `block.timestamp > metadata.end` and reverts with `InBidPhase()` if the window is open. Future test affordances will plumb through this same flag." }, { "in": "query", @@ -400,7 +400,7 @@ "type": "boolean", "default": false }, - "description": "Sandbox-only umbrella \"test mode\" flag. The operator UI flips this from the top bar; ArgoCD- managed deployments should ignore it. Today `testMode=true` enables: (a) unsealing of bids on auctions still in the BIDDING phase on bond / auction GETs; (b) skipping the API-side end-time pre-check on PATCH /v1/auctions/{id} (close) so the operator can attempt close before the bidding window expires — the on-chain contract still enforces `block.timestamp > metadata.end` and reverts with `InBidPhase()` if the window is open. Future test affordances will plumb through this same flag." + "description": "Sandbox-only umbrella \"test mode\" flag. The operator UI flips this from the top bar. Honoured only in `none` auth mode or for operator callers; ignored otherwise (the caller gets the sealed view and the normal close pre-check, not an error). When honoured, `testMode=true` enables: (a) unsealing of bids on auctions still in the BIDDING phase on bond / auction GETs; (b) skipping the API-side end-time pre-check on PATCH /v1/auctions/{id} (close) so the operator can attempt close before the bidding window expires — the on-chain contract still enforces `block.timestamp > metadata.end` and reverts with `InBidPhase()` if the window is open. Future test affordances will plumb through this same flag." } ], "responses": { @@ -788,7 +788,7 @@ "type": "boolean", "default": false }, - "description": "Sandbox-only umbrella \"test mode\" flag. The operator UI flips this from the top bar; ArgoCD- managed deployments should ignore it. Today `testMode=true` enables: (a) unsealing of bids on auctions still in the BIDDING phase on bond / auction GETs; (b) skipping the API-side end-time pre-check on PATCH /v1/auctions/{id} (close) so the operator can attempt close before the bidding window expires — the on-chain contract still enforces `block.timestamp > metadata.end` and reverts with `InBidPhase()` if the window is open. Future test affordances will plumb through this same flag." + "description": "Sandbox-only umbrella \"test mode\" flag. The operator UI flips this from the top bar. Honoured only in `none` auth mode or for operator callers; ignored otherwise (the caller gets the sealed view and the normal close pre-check, not an error). When honoured, `testMode=true` enables: (a) unsealing of bids on auctions still in the BIDDING phase on bond / auction GETs; (b) skipping the API-side end-time pre-check on PATCH /v1/auctions/{id} (close) so the operator can attempt close before the bidding window expires — the on-chain contract still enforces `block.timestamp > metadata.end` and reverts with `InBidPhase()` if the window is open. Future test affordances will plumb through this same flag." } ], "responses": { @@ -868,7 +868,7 @@ "type": "boolean", "default": false }, - "description": "Sandbox-only umbrella \"test mode\" flag. The operator UI flips this from the top bar; ArgoCD- managed deployments should ignore it. Today `testMode=true` enables: (a) unsealing of bids on auctions still in the BIDDING phase on bond / auction GETs; (b) skipping the API-side end-time pre-check on PATCH /v1/auctions/{id} (close) so the operator can attempt close before the bidding window expires — the on-chain contract still enforces `block.timestamp > metadata.end` and reverts with `InBidPhase()` if the window is open. Future test affordances will plumb through this same flag." + "description": "Sandbox-only umbrella \"test mode\" flag. The operator UI flips this from the top bar. Honoured only in `none` auth mode or for operator callers; ignored otherwise (the caller gets the sealed view and the normal close pre-check, not an error). When honoured, `testMode=true` enables: (a) unsealing of bids on auctions still in the BIDDING phase on bond / auction GETs; (b) skipping the API-side end-time pre-check on PATCH /v1/auctions/{id} (close) so the operator can attempt close before the bidding window expires — the on-chain contract still enforces `block.timestamp > metadata.end` and reverts with `InBidPhase()` if the window is open. Future test affordances will plumb through this same flag." } ], "responses": { @@ -943,7 +943,7 @@ "type": "boolean", "default": false }, - "description": "Sandbox-only umbrella \"test mode\" flag. The operator UI flips this from the top bar; ArgoCD- managed deployments should ignore it. Today `testMode=true` enables: (a) unsealing of bids on auctions still in the BIDDING phase on bond / auction GETs; (b) skipping the API-side end-time pre-check on PATCH /v1/auctions/{id} (close) so the operator can attempt close before the bidding window expires — the on-chain contract still enforces `block.timestamp > metadata.end` and reverts with `InBidPhase()` if the window is open. Future test affordances will plumb through this same flag." + "description": "Sandbox-only umbrella \"test mode\" flag. The operator UI flips this from the top bar. Honoured only in `none` auth mode or for operator callers; ignored otherwise (the caller gets the sealed view and the normal close pre-check, not an error). When honoured, `testMode=true` enables: (a) unsealing of bids on auctions still in the BIDDING phase on bond / auction GETs; (b) skipping the API-side end-time pre-check on PATCH /v1/auctions/{id} (close) so the operator can attempt close before the bidding window expires — the on-chain contract still enforces `block.timestamp > metadata.end` and reverts with `InBidPhase()` if the window is open. Future test affordances will plumb through this same flag." } ], "requestBody": { @@ -3942,8 +3942,15 @@ "$ref": "#/components/schemas/HexString" }, "privateKey": { - "description": "Private key, hex (0x + 64 chars). Returned in plaintext because this surface is sandbox-only.", - "$ref": "#/components/schemas/HexString" + "anyOf": [ + { + "$ref": "#/components/schemas/HexString" + }, + { + "type": "null" + } + ], + "description": "Private key, hex (0x + 64 chars). Returned only in the unauthenticated local `none` auth mode, and only for keys the API generated or an operator imported. Null in `entra` mode and for fixture bidders whose key comes from a PK_* environment override: the server holds the key and signs with it, but never returns it." }, "ethBalance": { "description": "Current ETH balance in wei", diff --git a/services/nb-bond-api/src/app.ts b/services/nb-bond-api/src/app.ts index 166f822..0233c2f 100644 --- a/services/nb-bond-api/src/app.ts +++ b/services/nb-bond-api/src/app.ts @@ -21,7 +21,7 @@ import helmet from 'helmet'; import { Interface, keccak256, toUtf8Bytes } from 'ethers'; import { resetProjectionAndRestart, restartIngestionLoop } from './admin'; -import { authMiddleware, operatorRoles, recognizedRoles, requireAnyRole } from './auth'; +import { authMiddleware, authMode, operatorRoles, recognizedRoles, requireAnyRole } from './auth'; import { composeAllAuctions, composeAllBonds, @@ -94,12 +94,15 @@ import { wnokMintBurnBodySchema, wnokTransferBodySchema, } from './schemas'; +import { parseTestMode } from './test-mode'; import { validateRequest } from './validation'; import { BidderConflictError, BidderValidationError, + type BidderRecord, createBidder, deleteBidder, + exposedBidderPrivateKey, getBidderByAddress, listBidders, reconcileFixtureBidderOverrides, @@ -395,32 +398,12 @@ export function createApp(dependencies: AppDependencies = {}): express.Express { // #region Bonds ────────────────────────────────────────────────────── - /** - * Sandbox-only umbrella "test mode" flag. The operator UI flips this - * from the top-bar toggle and propagates it on every bond / auction - * read as well as on close / finalise. Today it gates: - * - * - composeBond / composeAuction unseal sealed bids on still-open - * auctions (`revealOpenBids` internally). - * - PATCH /v1/auctions/{id} (close) skips the end-time pre-check so - * the operator can attempt close before the bidding window expires. - * The on-chain contract still enforces `block.timestamp > - * metadata.end` and will revert with `InBidPhase()` if it's early. - * - * Future test affordances should plumb through this same parameter so - * a single toggle controls them all. - */ - function parseTestMode(req: express.Request): boolean { - const raw = req.query.testMode; - return raw === 'true' || raw === '1'; - } - app.get('/v1/bonds', async (req, res, next) => { try { const includeDisabled = req.query.includeDisabled === 'true' || req.query.includeDisabled === '1'; const bonds = await composeAllBonds(historyDb, { - revealOpenBids: parseTestMode(req), + revealOpenBids: parseTestMode(req, res), includeDisabled, }); okResponse(req, res, bonds); @@ -433,7 +416,7 @@ export function createApp(dependencies: AppDependencies = {}): express.Express { try { const { isin } = req.params as { isin: string }; const bond = await composeBond(historyDb, isin, { - revealOpenBids: parseTestMode(req), + revealOpenBids: parseTestMode(req, res), }); if (!bond) throw notFound(`bond ${isin} not found`); okResponse(req, res, bond); @@ -669,7 +652,7 @@ export function createApp(dependencies: AppDependencies = {}): express.Express { app.get('/v1/auctions', async (req, res, next) => { try { const auctions = await composeAllAuctions(historyDb, { - revealOpenBids: parseTestMode(req), + revealOpenBids: parseTestMode(req, res), }); okResponse(req, res, auctions); } catch (err) { @@ -684,7 +667,7 @@ export function createApp(dependencies: AppDependencies = {}): express.Express { try { const { auctionId } = req.params as { auctionId: string }; const auction = await composeAuction(historyDb, auctionId, { - revealOpenBids: parseTestMode(req), + revealOpenBids: parseTestMode(req, res), }); if (!auction) throw notFound(`auction ${auctionId} not found`); okResponse(req, res, auction); @@ -703,7 +686,7 @@ export function createApp(dependencies: AppDependencies = {}): express.Express { const { auctionId } = req.params as { auctionId: string }; const body = req.body as CloseAuctionBody; if (body.status !== 'closed') throw badRequest('only status="closed" is supported today'); - const auction = await auctionService.close(auctionId, parseTestMode(req)); + const auction = await auctionService.close(auctionId, parseTestMode(req, res)); okResponse(req, res, auction); } catch (err) { next(err); @@ -745,13 +728,10 @@ export function createApp(dependencies: AppDependencies = {}): express.Express { // #region Bidders ──────────────────────────────────────────────────── - async function composeBidderDto(record: { - address: string; - name: string; - publicKey: string; - privateKey: string; - createdAt: number; - }) { + // The private key leaves the server only in the unauthenticated local + // `none` mode and never for an env-override key (see + // exposedBidderPrivateKey); every other DTO carries `privateKey: null`. + async function composeBidderDto(record: BidderRecord) { const [ethBalance, wnok] = await Promise.all([ provider.getBalance(record.address).catch(() => 0n), getWnok().catch(() => null), @@ -763,7 +743,7 @@ export function createApp(dependencies: AppDependencies = {}): express.Express { address: record.address, name: record.name, publicKey: record.publicKey, - privateKey: record.privateKey, + privateKey: exposedBidderPrivateKey(record, authMode), ethBalance: ethBalance.toString(), wnokBalance: wnokBalanceRaw.toString(), createdAt: record.createdAt, diff --git a/services/nb-bond-api/src/auth.ts b/services/nb-bond-api/src/auth.ts index 3e6bf20..9643873 100644 --- a/services/nb-bond-api/src/auth.ts +++ b/services/nb-bond-api/src/auth.ts @@ -53,6 +53,26 @@ export const operatorRoles = parseRoleList(envVariables.NB_BOND_API_AUTH_ENTRA_O const testerRoles = parseRoleList(envVariables.NB_BOND_API_AUTH_ENTRA_TESTER_ROLES); export const recognizedRoles = Array.from(new Set([...operatorRoles, ...testerRoles])); +/** The configured auth mode: `none` (local sandbox default) or `entra`. */ +export const authMode = envVariables.NB_BOND_API_AUTH_MODE; + +/** Roles captured by authMiddleware for this request (empty when none). */ +function requestRoles(res: Response): string[] { + return Array.isArray(res.locals.authRoles) ? res.locals.authRoles : []; +} + +/** + * True when the request may use operator-only behaviour inside a route that + * is otherwise open to every recognised role (for example the `testMode` + * query flag). Always true in `none` mode, matching the unauthenticated + * sandbox; in `entra` mode true only when the token's roles intersect + * `operatorRoles`. Whole-route gates use `requireAnyRole(operatorRoles)`. + */ +export function isOperatorRequest(res: Response): boolean { + if (!entraConfig) return true; + return requestRoles(res).some((role) => operatorRoles.includes(role)); +} + /** * Extracts the bearer token from the Authorization header, or null. * @@ -126,8 +146,7 @@ export function requireAnyRole(allowed: string[]) { next(); return; } - const roles: string[] = Array.isArray(res.locals.authRoles) ? res.locals.authRoles : []; - if (roles.some((role) => allowed.includes(role))) { + if (requestRoles(res).some((role) => allowed.includes(role))) { next(); return; } diff --git a/services/nb-bond-api/src/bidders.ts b/services/nb-bond-api/src/bidders.ts index d44b0e7..436a17f 100644 --- a/services/nb-bond-api/src/bidders.ts +++ b/services/nb-bond-api/src/bidders.ts @@ -120,6 +120,33 @@ export function fixtureRoleKey(role: string): string { return FIXTURE_KEY_OVERRIDES[role] ?? deriveFixturePrivateKey(role); } +/** + * True when the record's key is a fixture role's env override: an override + * is set for a roster entry and derives the record's address. Such keys + * belong to the environment (its fixture contracts were deployed with them), + * not to the sandbox roster, so the API never returns them. + */ +export function isOverrideKey(record: Pick): boolean { + const address = record.address.toLowerCase(); + return FIXTURE_ROSTER.some(({ role }) => { + const override = fixtureRoleKeyOverride(role); + return override !== undefined && deriveBidderAddress(override).toLowerCase() === address; + }); +} + +/** + * The private key a bidder DTO may carry. The stored key is returned only in + * the unauthenticated local `none` auth mode and only when it is not an env + * override (`isOverrideKey`); otherwise null — the server keeps holding the + * key and signs with it, as it does for bank keys. + */ +export function exposedBidderPrivateKey( + record: BidderRecord, + authMode: 'none' | 'entra', +): string | null { + return authMode === 'none' && !isOverrideKey(record) ? record.privateKey : null; +} + export function derivePublicKey(privateKeyHex: string): string { const priv = normalizePrivateKey(privateKeyHex); const pub = secp256k1.getPublicKey(priv, true); diff --git a/services/nb-bond-api/src/contracts/auctions.ts b/services/nb-bond-api/src/contracts/auctions.ts index c5e2bf9..f4d0de6 100644 --- a/services/nb-bond-api/src/contracts/auctions.ts +++ b/services/nb-bond-api/src/contracts/auctions.ts @@ -203,8 +203,10 @@ const testModeQueryParam = { required: false, schema: { type: 'boolean' as const, default: false }, description: - 'Sandbox-only umbrella "test mode" flag. The operator UI flips this from the top bar; ArgoCD- ' + - 'managed deployments should ignore it. Today `testMode=true` enables: ' + + 'Sandbox-only umbrella "test mode" flag. The operator UI flips this from the top bar. ' + + 'Honoured only in `none` auth mode or for operator callers; ignored otherwise (the caller ' + + 'gets the sealed view and the normal close pre-check, not an error). ' + + 'When honoured, `testMode=true` enables: ' + '(a) unsealing of bids on auctions still in the BIDDING phase on bond / auction GETs; ' + '(b) skipping the API-side end-time pre-check on PATCH /v1/auctions/{id} (close) so the ' + 'operator can attempt close before the bidding window expires — the on-chain contract still ' + diff --git a/services/nb-bond-api/src/contracts/bidders.ts b/services/nb-bond-api/src/contracts/bidders.ts index c2b89b8..c30b444 100644 --- a/services/nb-bond-api/src/contracts/bidders.ts +++ b/services/nb-bond-api/src/contracts/bidders.ts @@ -20,9 +20,12 @@ export const bidderSchema = z publicKey: hexStringSchema.meta({ description: 'Compressed secp256k1 public key (33-byte hex)', }), - privateKey: hexStringSchema.meta({ + privateKey: hexStringSchema.nullable().meta({ description: - 'Private key, hex (0x + 64 chars). Returned in plaintext because this surface is sandbox-only.', + 'Private key, hex (0x + 64 chars). Returned only in the unauthenticated local `none` ' + + 'auth mode, and only for keys the API generated or an operator imported. Null in ' + + '`entra` mode and for fixture bidders whose key comes from a PK_* environment ' + + 'override: the server holds the key and signs with it, but never returns it.', }), ethBalance: bigIntStringSchema.meta({ description: 'Current ETH balance in wei' }), wnokBalance: bigIntStringSchema.meta({ diff --git a/services/nb-bond-api/src/contracts/bonds.ts b/services/nb-bond-api/src/contracts/bonds.ts index aecdd08..48e9f66 100644 --- a/services/nb-bond-api/src/contracts/bonds.ts +++ b/services/nb-bond-api/src/contracts/bonds.ts @@ -159,8 +159,10 @@ const testModeQueryParam = { required: false, schema: { type: 'boolean' as const, default: false }, description: - 'Sandbox-only umbrella "test mode" flag. The operator UI flips this from the top bar; ArgoCD- ' + - 'managed deployments should ignore it. Today `testMode=true` enables: ' + + 'Sandbox-only umbrella "test mode" flag. The operator UI flips this from the top bar. ' + + 'Honoured only in `none` auth mode or for operator callers; ignored otherwise (the caller ' + + 'gets the sealed view and the normal close pre-check, not an error). ' + + 'When honoured, `testMode=true` enables: ' + '(a) unsealing of bids on auctions still in the BIDDING phase on bond / auction GETs; ' + '(b) skipping the API-side end-time pre-check on PATCH /v1/auctions/{id} (close) so the ' + 'operator can attempt close before the bidding window expires — the on-chain contract still ' + diff --git a/services/nb-bond-api/src/test-mode.ts b/services/nb-bond-api/src/test-mode.ts new file mode 100644 index 0000000..8067b87 --- /dev/null +++ b/services/nb-bond-api/src/test-mode.ts @@ -0,0 +1,29 @@ +/** + * Sandbox-only umbrella "test mode" flag. The operator UI flips this from + * the top-bar toggle and propagates it as `?testMode=true` on every bond / + * auction read as well as on close. Today it gates: + * + * - composeBond / composeAuction unseal sealed bids on still-open + * auctions (`revealOpenBids` internally). + * - PATCH /v1/auctions/{id} (close) skips the end-time pre-check so the + * operator can attempt close before the bidding window expires. The + * on-chain contract still enforces `block.timestamp > metadata.end` and + * reverts with `InBidPhase()` if it is early. + * + * The flag is honoured only for operator callers (always in `none` mode, see + * `isOperatorRequest`). Anyone else's flag is ignored rather than rejected, so + * a stale browser setting never turns a read into a 403; they simply get the + * sealed view and the normal close pre-check. + * + * Future test affordances should plumb through this same function so a + * single toggle, and a single authorization rule, controls them all. + */ +import type { Request, Response } from 'express'; + +import { isOperatorRequest } from './auth'; + +export function parseTestMode(req: Request, res: Response): boolean { + const raw = req.query.testMode; + const requested = raw === 'true' || raw === '1'; + return requested && isOperatorRequest(res); +} diff --git a/services/nb-bond-api/tests/app.test.ts b/services/nb-bond-api/tests/app.test.ts index 5af344b..639e0ce 100644 --- a/services/nb-bond-api/tests/app.test.ts +++ b/services/nb-bond-api/tests/app.test.ts @@ -4,6 +4,8 @@ import type { Server } from 'node:http'; jest.mock('../src/auth', () => ({ authMiddleware: (_req: unknown, _res: unknown, next: () => void) => next(), requireAnyRole: () => (_req: unknown, _res: unknown, next: () => void) => next(), + isOperatorRequest: () => true, + authMode: 'none', operatorRoles: ['Sandbox.Operator'], recognizedRoles: ['Sandbox.Operator', 'Sandbox.Tester'], })); diff --git a/services/nb-bond-api/tests/bidders.test.ts b/services/nb-bond-api/tests/bidders.test.ts index 72e88e4..b62a789 100644 --- a/services/nb-bond-api/tests/bidders.test.ts +++ b/services/nb-bond-api/tests/bidders.test.ts @@ -11,6 +11,7 @@ import { deriveBidderAddress, deriveFixturePrivateKey, derivePublicKey, + exposedBidderPrivateKey, generateBidderPrivateKey, getBidderByAddress, getBidderByName, @@ -133,6 +134,23 @@ describe('bidders', () => { expect(mod.reconcileFixtureBidderOverrides(db)).toEqual({ migrated: 0 }); }); + it('exposes a generated key in none mode but never an override key', () => { + process.env.PK_NORDEA = OVERRIDE; + jest.resetModules(); + const mod = loadModule(); + + mod.seedFixtureBiddersIfEmpty(db); + const generated = mod.createBidder(db, { name: 'Generated' }); + const nordea = mod.getBidderByName(db, 'Nordea')!; + const dnb = mod.getBidderByName(db, 'DNB')!; + + expect(mod.isOverrideKey(nordea)).toBe(true); + expect(mod.isOverrideKey(dnb)).toBe(false); + expect(mod.exposedBidderPrivateKey(generated, 'none')).toBe(generated.privateKey); + expect(mod.exposedBidderPrivateKey(dnb, 'none')).toBe(dnb.privateKey); + expect(mod.exposedBidderPrivateKey(nordea, 'none')).toBeNull(); + }); + it('reconcile is a no-op without an override and respects deletion', () => { seedFixtureBiddersIfEmpty(db); expect(reconcileFixtureBidderOverrides(db)).toEqual({ migrated: 0 }); @@ -183,6 +201,14 @@ describe('bidders', () => { }); }); + describe('exposedBidderPrivateKey', () => { + it('never exposes a key in entra mode', () => { + const bidder = createBidder(db, { name: 'EntraHidden' }); + expect(exposedBidderPrivateKey(bidder, 'none')).toBe(bidder.privateKey); + expect(exposedBidderPrivateKey(bidder, 'entra')).toBeNull(); + }); + }); + describe('deleteBidder', () => { it('removes a bidder and returns true', () => { const bidder = createBidder(db, { name: 'ToDelete' }); diff --git a/services/nb-bond-api/tests/route-authorization.test.ts b/services/nb-bond-api/tests/route-authorization.test.ts new file mode 100644 index 0000000..59c8d45 --- /dev/null +++ b/services/nb-bond-api/tests/route-authorization.test.ts @@ -0,0 +1,101 @@ +/** + * Route-level authorization in `entra` mode, through the real auth middleware + * and the real route wiring in createApp. + * + * `jose` is mocked so a bearer token's text becomes its `roles` claim + * (`Bearer operator` → the operator role, `Bearer tester` → the tester role). + * The chain seam is mocked offline so no handler reaches a node. Env is set + * before requiring the app, because src/env-vars and src/auth read it at load. + */ +import type { AddressInfo } from 'node:net'; +import type { Server } from 'node:http'; + +import type { IngestionDatabase } from '../src/ingestion-db'; + +const OPERATOR_ROLE = 'Sandbox.Operator'; +const TESTER_ROLE = 'Sandbox.Tester'; + +jest.mock('jose', () => ({ + createRemoteJWKSet: jest.fn(() => 'mock-jwks'), + jwtVerify: jest.fn(async (token: string) => ({ + payload: { roles: token === 'operator' ? ['Sandbox.Operator'] : ['Sandbox.Tester'] }, + })), +})); + +jest.mock('../src/chain', () => { + const offline = async () => { + throw new Error('chain offline in this test'); + }; + return { + ...jest.requireActual('../src/chain'), + provider: { getBalance: async () => 0n }, + getWnok: offline, + getBondManager: offline, + getBondToken: offline, + getBondAuction: offline, + }; +}); + +const originalEnv = { ...process.env }; +process.env = { + ...originalEnv, + NB_BOND_API_AUTH_MODE: 'entra', + NB_BOND_API_AUTH_ENTRA_TENANT_ID: '11111111-1111-1111-1111-111111111111', + NB_BOND_API_AUTH_ENTRA_AUDIENCE: 'api://test', + NB_BOND_API_AUTH_ENTRA_OPERATOR_ROLES: OPERATOR_ROLE, + NB_BOND_API_AUTH_ENTRA_TESTER_ROLES: TESTER_ROLE, +}; + +/* eslint-disable @typescript-eslint/no-require-imports */ +const { createApp } = require('../src/app') as typeof import('../src/app'); +const { openDatabase } = require('../src/ingestion-db') as typeof import('../src/ingestion-db'); +const { createBidder } = require('../src/bidders') as typeof import('../src/bidders'); +/* eslint-enable @typescript-eslint/no-require-imports */ + +type Caller = 'operator' | 'tester'; + +describe('route authorization (entra mode)', () => { + const db = openDatabase({ dbPath: ':memory:', readonly: false }) as IngestionDatabase & { + close: () => void; + }; + let server: Server; + let baseUrl: string; + + beforeAll(async () => { + const app = createApp({ historyDb: db, biddersDb: db }); + server = await new Promise((resolve) => { + const listening = app.listen(0, '127.0.0.1', () => resolve(listening)); + }); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + + afterAll(async () => { + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); + db.close(); + process.env = { ...originalEnv }; + }); + + function call(caller: Caller, method: string, path: string, body?: unknown) { + return fetch(`${baseUrl}${path}`, { + method, + headers: { + Authorization: `Bearer ${caller}`, + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + } + + it('returns no bidder private key outside none mode, for testers and operators alike', async () => { + createBidder(db, { name: 'Generated' }); + for (const caller of ['tester', 'operator'] as const) { + const response = await call(caller, 'GET', '/v1/bidders'); + expect(response.status).toBe(200); + const bidders = (await response.json()) as Array<{ name: string; privateKey: unknown }>; + expect(bidders.map((b) => b.name)).toContain('Generated'); + for (const bidder of bidders) expect(bidder.privateKey).toBeNull(); + } + }); +}); diff --git a/services/nb-bond-api/tests/test-mode.test.ts b/services/nb-bond-api/tests/test-mode.test.ts new file mode 100644 index 0000000..0563708 --- /dev/null +++ b/services/nb-bond-api/tests/test-mode.test.ts @@ -0,0 +1,63 @@ +/** + * parseTestMode — the `testMode` flag is honoured in `none` mode and for + * operator callers in `entra` mode; a tester's flag is ignored (not rejected). + * Env is set before (re)importing so src/auth picks up the mode at load, as in + * tests/auth.test.ts. + */ +import type { Request, Response } from 'express'; + +jest.mock('jose', () => ({ + createRemoteJWKSet: jest.fn(() => 'mock-jwks'), + jwtVerify: jest.fn(), +})); + +const originalEnv = { ...process.env }; + +const entraEnv = { + NB_BOND_API_AUTH_MODE: 'entra', + NB_BOND_API_AUTH_ENTRA_TENANT_ID: '11111111-1111-1111-1111-111111111111', + NB_BOND_API_AUTH_ENTRA_AUDIENCE: 'api://test', + NB_BOND_API_AUTH_ENTRA_OPERATOR_ROLES: 'Sandbox.Operator', + NB_BOND_API_AUTH_ENTRA_TESTER_ROLES: 'Sandbox.Tester', +}; + +function loadParseTestMode(env: Record) { + jest.resetModules(); + process.env = { ...originalEnv, ...env }; + // eslint-disable-next-line @typescript-eslint/no-require-imports + return (require('../src/test-mode') as typeof import('../src/test-mode')).parseTestMode; +} + +function request(testMode: string | undefined, roles?: string[]) { + const req = { query: testMode === undefined ? {} : { testMode } } as unknown as Request; + const res = { locals: roles ? { authRoles: roles } : {} } as unknown as Response; + return { req, res }; +} + +afterEach(() => { + process.env = { ...originalEnv }; +}); + +describe('parseTestMode', () => { + it('honours the flag in none mode', () => { + const parseTestMode = loadParseTestMode({ NB_BOND_API_AUTH_MODE: 'none' }); + for (const value of ['true', '1']) { + const { req, res } = request(value); + expect(parseTestMode(req, res)).toBe(true); + } + const off = request(undefined); + expect(parseTestMode(off.req, off.res)).toBe(false); + }); + + it('honours the flag for an operator in entra mode', () => { + const parseTestMode = loadParseTestMode(entraEnv); + const { req, res } = request('true', ['Sandbox.Operator']); + expect(parseTestMode(req, res)).toBe(true); + }); + + it('ignores the flag for a tester in entra mode', () => { + const parseTestMode = loadParseTestMode(entraEnv); + const { req, res } = request('true', ['Sandbox.Tester']); + expect(parseTestMode(req, res)).toBe(false); + }); +}); diff --git a/services/nb-ui/DEVELOPMENT.md b/services/nb-ui/DEVELOPMENT.md index 429bd92..043b3f6 100644 --- a/services/nb-ui/DEVELOPMENT.md +++ b/services/nb-ui/DEVELOPMENT.md @@ -125,6 +125,14 @@ claims first, access-token decode as a fallback) and mapped to capabilities by screen (`src/components/AccessDeniedPage.jsx`) instead of the app. - The Central Bank nav item is hidden for non-operators and the `#/central-bank` route is guarded, so the page never mounts for them. +- Operator-only controls read `canOperate` from `CapabilitiesContext` + (`src/auth/capabilitiesContext.js`, provided by `App.jsx`). The top-bar + test-mode toggle is hidden for testers, and `getTestMode()` + (`src/utils/debugSettings.js`) ignores a stored flag for any account that + cannot operate, so a tester never sends `?testMode=true`. +- The Bidders page shows "Held by the server" when the API returns + `privateKey: null` (always in `entra` mode, and for keys supplied by the + API's `PK_*` overrides). This is UX only — the NB Bond API independently enforces the same roles and is the real boundary. `none` mode is never gated (the local sandbox is fully open). diff --git a/services/nb-ui/README.md b/services/nb-ui/README.md index c0376cb..52a488b 100644 --- a/services/nb-ui/README.md +++ b/services/nb-ui/README.md @@ -59,7 +59,7 @@ injects `Authorization: Bearer …` per request using whichever | `#/bonds/{isin}` | Single bond — coupon, maturity, holders, auction history. | | `#/auctions` | Flat list of every auction across bonds. | | `#/auctions/{id}` | Single auction — bids, allocation, lifecycle actions. "Place bid" button is visible while the auction is in `BIDDING`. | -| `#/bidders` | Sandbox bidder roster. Add / remove bidders, reveal stored keys, launch the impersonated-bid modal. | +| `#/bidders` | Sandbox bidder roster. Add / remove bidders, reveal stored keys (none mode), launch the impersonated-bid modal. | | `#/central-bank` | Norges Bank operator surface against WNOK — allowlist editor, mint / burn / transfer modals. | | `#/tbd` | Banking — per-bank tokenized bank deposits (TBD): overview, "acting as bank" selector, allowlist / mint / burn / transfer. | diff --git a/services/nb-ui/src/App.jsx b/services/nb-ui/src/App.jsx index 53f46c1..052af07 100644 --- a/services/nb-ui/src/App.jsx +++ b/services/nb-ui/src/App.jsx @@ -6,6 +6,7 @@ import { useEffect, useState } from 'react'; import { auth, authMode } from './auth/index.js'; import { capabilitiesForAccount } from './auth/capabilities.js'; +import { CapabilitiesContext } from './auth/capabilitiesContext.js'; import { useRoute } from './hooks/useRoute.js'; import { ToastProvider, EmptyState } from './components/ui.jsx'; import { Layout } from './components/Layout.jsx'; @@ -137,17 +138,19 @@ export function App() { else page = ; return ( - - - - {page} - - - + + + + + {page} + + + + ); } diff --git a/services/nb-ui/src/auth/capabilities.js b/services/nb-ui/src/auth/capabilities.js index abed311..e4cf495 100644 --- a/services/nb-ui/src/auth/capabilities.js +++ b/services/nb-ui/src/auth/capabilities.js @@ -1,6 +1,7 @@ /** * Capability policy — maps the signed-in account's App Roles to what the UI - * may show. Single source of truth for "who can see Central Bank". + * may show. Single source of truth for "who can see Central Bank" and "who + * can operate" (operator-only controls such as the test-mode toggle). * * In `none` mode (the local sandbox default) the UI is fully open: there are * no roles and no gating, matching the unauthenticated backend. Gating only @@ -22,14 +23,29 @@ function parseRoleList(value) { const operatorRoles = parseRoleList(AppConfig.AUTH_OPERATOR_ROLES); const testerRoles = parseRoleList(AppConfig.AUTH_TESTER_ROLES); -const FULL_ACCESS = { canUseApp: true, canAccessCentralBank: true, canAccessBanking: true }; -const NO_ACCESS = { canUseApp: false, canAccessCentralBank: false, canAccessBanking: false }; +const FULL_ACCESS = { + canUseApp: true, + canAccessCentralBank: true, + canAccessBanking: true, + canOperate: true, +}; +const NO_ACCESS = { + canUseApp: false, + canAccessCentralBank: false, + canAccessBanking: false, + canOperate: false, +}; /** * Derive capability flags for an account. * * @param {{ roles?: string[] } | null} account - * @returns {{ canUseApp: boolean, canAccessCentralBank: boolean, canAccessBanking: boolean }} + * @returns {{ + * canUseApp: boolean, + * canAccessCentralBank: boolean, + * canAccessBanking: boolean, + * canOperate: boolean, + * }} */ export function capabilitiesForAccount(account) { // Non-interactive auth (local sandbox) is fully open. @@ -44,5 +60,8 @@ export function capabilitiesForAccount(account) { // open to testers so they can exercise bank-money flows end-to-end. canAccessCentralBank: isOperator, canAccessBanking: isOperator || isTester, + // Operator-only controls (test mode today). Mirrors the API, which + // honours operator-only behaviour for operator roles only. + canOperate: isOperator, }; } diff --git a/services/nb-ui/src/auth/capabilitiesContext.js b/services/nb-ui/src/auth/capabilitiesContext.js new file mode 100644 index 0000000..4f8e5af --- /dev/null +++ b/services/nb-ui/src/auth/capabilitiesContext.js @@ -0,0 +1,19 @@ +/** + * CapabilitiesContext — delivers the signed-in account's capability flags + * (see capabilities.js) to components that show or hide role-dependent + * controls. App.jsx provides the value; `useCapabilities()` reads it. + * + * Without a provider (for example a page rendered on its own in a test) the + * hook derives the flags from the current auth account, so it grants exactly + * what the account holds: everything in `none` mode, nothing in `entra` mode + * without a signed-in account. + */ +import { createContext, useContext } from 'react'; +import { auth } from './index.js'; +import { capabilitiesForAccount } from './capabilities.js'; + +export const CapabilitiesContext = createContext(null); + +export function useCapabilities() { + return useContext(CapabilitiesContext) ?? capabilitiesForAccount(auth.getAccount()); +} diff --git a/services/nb-ui/src/components/Layout.jsx b/services/nb-ui/src/components/Layout.jsx index 10511db..7225c32 100644 --- a/services/nb-ui/src/components/Layout.jsx +++ b/services/nb-ui/src/components/Layout.jsx @@ -7,6 +7,7 @@ import { useEffect, useRef, useState } from 'react'; import { AppConfig } from '../config.js'; import { auth, authMode } from '../auth/index.js'; +import { useCapabilities } from '../auth/capabilitiesContext.js'; import { getTestMode, setTestMode, subscribeTestMode } from '../utils/debugSettings.js'; import { HttpClient } from '../api/httpClient.js'; import { HealthBadge } from './HealthBadge.jsx'; @@ -14,7 +15,8 @@ import { NorgesBankLogo } from './NorgesBankLogo.jsx'; import { Button } from './ui.jsx'; /** - * TestModeToggle — top-bar sandbox-only umbrella switch. + * TestModeToggle — top-bar sandbox-only umbrella switch. Rendered only for + * accounts that can operate (always in `none` mode). * * When ON, the API gets `?testMode=true` on bond/auction reads * (unsealing sealed bids on still-open auctions) and on close / @@ -241,6 +243,7 @@ export function Layout({ canAccessCentralBank = true, canAccessBanking = true, }) { + const { canOperate } = useCapabilities(); return (
@@ -265,7 +268,7 @@ export function Layout({ />
- + {canOperate && } API /v1
diff --git a/services/nb-ui/src/pages/BiddersPage.jsx b/services/nb-ui/src/pages/BiddersPage.jsx index 1544a89..b602b8a 100644 --- a/services/nb-ui/src/pages/BiddersPage.jsx +++ b/services/nb-ui/src/pages/BiddersPage.jsx @@ -28,6 +28,7 @@ import { import { AddBidderModal } from './AddBidderModal.jsx'; import { PlaceBidModal } from './PlaceBidModal.jsx'; import { getTestMode } from '../utils/debugSettings.js'; +import { useCapabilities } from '../auth/capabilitiesContext.js'; export function BiddersPage() { const { data, loading, error, reload } = useLiveQuery( @@ -48,6 +49,7 @@ export function BiddersPage() { const [placeBidFor, setPlaceBidFor] = useState(null); const [revealedKey, setRevealedKey] = useState(null); const toast = useToast(); + const { canOperate } = useCapabilities(); const deleteMut = useMutation((address) => BiddersApi.deleteBidder(address)); @@ -174,7 +176,10 @@ export function BiddersPage() { title={ placeBidEnabled ? undefined - : 'No auctions are currently accepting bids — either none are open or all have passed their end timestamp. Enable Test mode in the top bar to force submit (chain will reject expired).' + : 'No auctions are currently accepting bids — either none are open or all have passed their end timestamp.' + + (canOperate + ? ' Enable Test mode in the top bar to force submit (chain will reject expired).' + : '') } > Place bid @@ -230,10 +235,12 @@ export function BiddersPage() { function RevealKeyModal({ bidder, onClose }) { return ( - - The key below grants full impersonation of this bidder. Do not paste it anywhere outside - this sandbox. - + {bidder.privateKey && ( + + The key below grants full impersonation of this bidder. Do not paste it anywhere outside + this sandbox. + + )}
-
- {bidder.privateKey} -
+ {bidder.privateKey ? ( +
+ {bidder.privateKey} +
+ ) : ( +
+ Held by the server +
+ )}
); diff --git a/services/nb-ui/src/utils/debugSettings.js b/services/nb-ui/src/utils/debugSettings.js index b1fcabf..9d8b32e 100644 --- a/services/nb-ui/src/utils/debugSettings.js +++ b/services/nb-ui/src/utils/debugSettings.js @@ -12,16 +12,23 @@ * * Future test affordances should hang off the same toggle — read via * `getTestMode()` from any API module, append `?testMode=true` to the - * relevant requests, and the backend's `parseTestMode(req)` will pick - * it up. + * relevant requests, and the backend's `parseTestMode(req, res)` will + * pick it up. * - * Sandbox-only. ArgoCD-managed deployments should ignore this flag. + * Operator-only: `getTestMode()` reports the stored flag only when the + * current account can operate (always in `none` mode), so every consumer + * inherits the gate and a stale flag left by another account is ignored. + * The API applies the same rule and ignores the flag for everyone else. */ +import { auth } from '../auth/index.js'; +import { capabilitiesForAccount } from '../auth/capabilities.js'; + const LS_KEY = 'nbui.testMode'; const listeners = new Set(); export function getTestMode() { + if (!capabilitiesForAccount(auth.getAccount()).canOperate) return false; try { return window.localStorage.getItem(LS_KEY) === 'true'; } catch { diff --git a/services/nb-ui/tests/BiddersPage.test.jsx b/services/nb-ui/tests/BiddersPage.test.jsx index f9e0956..0005d70 100644 --- a/services/nb-ui/tests/BiddersPage.test.jsx +++ b/services/nb-ui/tests/BiddersPage.test.jsx @@ -128,6 +128,27 @@ describe('BiddersPage', () => { expect(screen.getByLabelText(/Import existing private key/i)).toBeInTheDocument(); }); + it('shows a server-held key when the API returns no private key', async () => { + const { BiddersApi } = await import('../src/api/biddersApi.js'); + BiddersApi.listBidders.mockResolvedValueOnce([{ ...FIXTURE_BIDDERS[0], privateKey: null }]); + const { BiddersPage } = await import('../src/pages/BiddersPage.jsx'); + const { ToastProvider } = await import('../src/components/ui.jsx'); + const user = userEvent.setup(); + + render( + + + , + ); + + await waitFor(() => screen.getByText('Nordea')); + const nordeaRow = screen.getByText('Nordea').closest('tr'); + await user.click(within(nordeaRow).getByRole('button', { name: /Reveal key/i })); + + expect(await screen.findByText('Held by the server')).toBeInTheDocument(); + expect(screen.queryByText('0xdead')).not.toBeInTheDocument(); + }); + it('opens the PlaceBid modal when "Place bid" is clicked on a row', async () => { const { BiddersPage } = await import('../src/pages/BiddersPage.jsx'); const { ToastProvider } = await import('../src/components/ui.jsx'); @@ -146,4 +167,33 @@ describe('BiddersPage', () => { expect(await screen.findByRole('heading', { name: /Place bid as Nordea/ })).toBeInTheDocument(); }); + + it.each([ + [true, true], + [false, false], + ])( + 'mentions Test mode on a disabled "Place bid" only when the account can operate (canOperate=%s)', + async (canOperate, mentionsTestMode) => { + const { BondsApi } = await import('../src/api/bondsApi.js'); + BondsApi.listBonds.mockResolvedValueOnce([]); + const { BiddersPage } = await import('../src/pages/BiddersPage.jsx'); + const { ToastProvider } = await import('../src/components/ui.jsx'); + const { CapabilitiesContext } = await import('../src/auth/capabilitiesContext.js'); + + render( + + + + + , + ); + + await waitFor(() => screen.getByText('Nordea')); + const nordeaRow = screen.getByText('Nordea').closest('tr'); + const placeBid = within(nordeaRow).getByRole('button', { name: /Place bid/i }); + await waitFor(() => expect(placeBid).toBeDisabled()); + expect(placeBid.getAttribute('title')).toMatch(/No auctions are currently accepting bids/); + expect(/Test mode/.test(placeBid.getAttribute('title'))).toBe(mentionsTestMode); + }, + ); }); diff --git a/services/nb-ui/tests/authGate.test.jsx b/services/nb-ui/tests/authGate.test.jsx index fc600ee..1751a60 100644 --- a/services/nb-ui/tests/authGate.test.jsx +++ b/services/nb-ui/tests/authGate.test.jsx @@ -4,10 +4,11 @@ import userEvent from '@testing-library/user-event'; // Feature: the auth gate in App.jsx. In entra mode a signed-out user sees ONLY // the login page; a signed-in user with a recognised role gets the app -// (operators also see the Central Bank tab; testers do not, and the route is -// guarded); a signed-in user with no recognised role sees the access-denied -// page; none mode is never gated; and a silent-renewal expiry mid-session -// swaps the app for the login page with the "session expired" notice. Roles +// (operators also see the Central Bank tab and the test-mode toggle; testers +// do not, and the route is guarded); a signed-in user with no recognised role +// sees the access-denied page; none mode is never gated; and a +// silent-renewal expiry mid-session swaps the app for the login page with the +// "session expired" notice. Roles // come from the account's ID-token claims — see src/auth/entraAuth.js and // src/auth/capabilities.js. @@ -133,6 +134,7 @@ describe('auth gate (entra mode)', () => { expect(screen.getByRole('button', { name: 'Sign out' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Securities/ })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Central Bank/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Test:/ })).toBeInTheDocument(); expect(screen.queryByText('You are signed out.')).not.toBeInTheDocument(); }); @@ -146,6 +148,8 @@ describe('auth gate (entra mode)', () => { // Banking is tester-accessible — Central Bank is the only // operator-locked surface. expect(screen.getByRole('button', { name: /Banking/ })).toBeInTheDocument(); + // The test-mode toggle is operator-only. + expect(screen.queryByRole('button', { name: /Test:/ })).not.toBeInTheDocument(); // Manually hitting the route shows the not-authorised panel, not the CB surface. expect(await screen.findByText('Not authorised')).toBeInTheDocument(); expect(screen.queryByText('WNOK actions')).not.toBeInTheDocument(); @@ -203,6 +207,7 @@ describe('auth gate (none mode)', () => { expect(await screen.findByRole('button', { name: /Securities/ })).toBeInTheDocument(); expect(screen.queryByText('You are signed out.')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Sign in' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Test:/ })).toBeInTheDocument(); }); it('groups nav into dropdown categories; opening Securities reveals its pages', async () => { diff --git a/services/nb-ui/tests/capabilities.test.js b/services/nb-ui/tests/capabilities.test.js index 656ef3b..cbe5999 100644 --- a/services/nb-ui/tests/capabilities.test.js +++ b/services/nb-ui/tests/capabilities.test.js @@ -3,7 +3,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; // Feature: the role -> capability policy in src/auth/capabilities.js. Role // lists come from runtime config (AUTH_OPERATOR_ROLES / AUTH_TESTER_ROLES), // read at module load — so each case re-imports against a fresh config. -// Central Bank and Banking are both operator-only surfaces. +// Central Bank and operator-only controls (canOperate) are operator-only; +// Banking is open to testers. async function loadCapabilities(config) { vi.resetModules(); @@ -30,11 +31,13 @@ describe('capabilitiesForAccount', () => { canUseApp: true, canAccessCentralBank: true, canAccessBanking: true, + canOperate: true, }); expect(cap({ roles: [] })).toEqual({ canUseApp: true, canAccessCentralBank: true, canAccessBanking: true, + canOperate: true, }); }); @@ -44,6 +47,7 @@ describe('capabilitiesForAccount', () => { canUseApp: true, canAccessCentralBank: true, canAccessBanking: true, + canOperate: true, }); }); @@ -53,6 +57,7 @@ describe('capabilitiesForAccount', () => { canUseApp: true, canAccessCentralBank: false, canAccessBanking: true, + canOperate: false, }); }); @@ -62,11 +67,13 @@ describe('capabilitiesForAccount', () => { canUseApp: false, canAccessCentralBank: false, canAccessBanking: false, + canOperate: false, }); expect(cap(null)).toEqual({ canUseApp: false, canAccessCentralBank: false, canAccessBanking: false, + canOperate: false, }); }); @@ -76,6 +83,7 @@ describe('capabilitiesForAccount', () => { canUseApp: true, canAccessCentralBank: true, canAccessBanking: true, + canOperate: true, }); }); }); diff --git a/services/nb-ui/tests/debugSettings.test.js b/services/nb-ui/tests/debugSettings.test.js new file mode 100644 index 0000000..64bb9fc --- /dev/null +++ b/services/nb-ui/tests/debugSettings.test.js @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// Feature: getTestMode() in src/utils/debugSettings.js reports the stored +// test-mode flag only for accounts that can operate (always in none mode), so +// a flag left in localStorage never reaches the API for a tester. + +async function loadDebugSettings({ authMode, account = null }) { + vi.resetModules(); + window.__APP_CONFIG__ = { + API_BASE_URL: 'http://test.local', + AUTH_MODE: authMode, + AUTH_OPERATOR_ROLES: 'Sandbox.Operator', + AUTH_TESTER_ROLES: 'Sandbox.Tester', + }; + vi.doMock('../src/auth/index.js', () => ({ auth: { getAccount: () => account }, authMode })); + return import('../src/utils/debugSettings.js'); +} + +afterEach(() => { + vi.doUnmock('../src/auth/index.js'); + vi.resetModules(); + window.localStorage.clear(); +}); + +describe('getTestMode', () => { + it('returns the stored flag in none mode', async () => { + const { getTestMode, setTestMode } = await loadDebugSettings({ authMode: 'none' }); + expect(getTestMode()).toBe(false); + setTestMode(true); + expect(getTestMode()).toBe(true); + }); + + it('returns the stored flag for an operator', async () => { + window.localStorage.setItem('nbui.testMode', 'true'); + const { getTestMode } = await loadDebugSettings({ + authMode: 'entra', + account: { roles: ['Sandbox.Operator'] }, + }); + expect(getTestMode()).toBe(true); + }); + + it('ignores a stored flag for a tester', async () => { + window.localStorage.setItem('nbui.testMode', 'true'); + const { getTestMode } = await loadDebugSettings({ + authMode: 'entra', + account: { roles: ['Sandbox.Tester'] }, + }); + expect(getTestMode()).toBe(false); + }); +}); From c744509e3d3ad94217527b4f7c37768f51e07073 Mon Sep 17 00:00:00 2001 From: Deleo <152405392+Greaker@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:51:53 +0200 Subject: [PATCH 07/18] Remove the unused BondOrderBook and its factory (#302) * Remove the unused BondOrderBook and its factory BondOrderBook and BondOrderBookFactory were never deployed by the sandbox scripts and have no caller in the API or UI. The broker-level venue in the closed-loop settlement plan is meant to replace them, so keeping and maintaining a second, holder-level bond secondary market in source is unused code rather than a supported feature. Both contracts, their test suites and their NatSpec pages are removed. TbdAddressZero and PartitionZero were only raised by the removed contracts and are dropped from Errors; BondTokenAddressZero moves under the BondManager heading. With this change the bond stack no longer imports anything from the private-bank contracts. The contract reference, the contract topology note, the NB Bond API development guide and the active closed-loop settlement plan now state that no on-chain bond secondary venue exists. Tests: forge 347 passed (the 48 removed tests covered only the deleted contracts). * Narrow bond and broker roles; hand deployer admin to the owner (#303) BondToken.addController now only makes an address an ERC-1410 controller (an operator over every holder's units); lifecycle rights come from BOND_CONTROLLER_ROLE, granted separately. A new removeController (BOND_ADMIN_ROLE) takes controller status away again. The bond setup script gives BondManager BOND_CONTROLLER_ROLE only, since it moves units solely through BondDvP, and makes BondDvP both a controller and a BOND_CONTROLLER_ROLE holder for settlement, redemption and buyback. Once wiring is done, the deployer key grants DEFAULT_ADMIN_ROLE on BondManager, BondAuction, BondToken and BondDvP (and BOND_ADMIN_ROLE on BondToken) to the Norges Bank owner key and renounces its own, so no deployment-only key keeps admin on the bond contracts while roles can still be rotated. The BondManager constructor now rejects a zero wNOK, controller, auction, bond token or DvP address, not only a zero reserve, adding BondAuctionAddressZero. OrderBook gains a getOrder view, and Broker lets a client revoke only orders placed for that client's own securities wallet (OrderNotOwnedByClient otherwise). The OrderBook script stops granting SUBMIT_ORDER_ROLE to the broker EOAs; only the Broker contracts hold it. The NB Bond API BondToken and BondManager ABIs are refreshed for the new function and errors. Contract reference, security notes and NatSpec pages describe the role shape. Tests: forge 358 passed (new: controller add without lifecycle role, controller removal and its effect on operator transfers, BondManager constructor zero checks in a separate small suite, and cross-client revoke rejection for buy and sell orders); nb-bond-api jest 254 passed. --- contracts/docs/contracts-reference.md | 54 +- contracts/docs/contracts-security.md | 6 +- contracts/docs/natspec/src/SUMMARY.md | 2 - .../src/broker/Broker.sol/contract.Broker.md | 17 +- .../src/common/Errors.sol/library.Errors.md | 24 +- .../csd/OrderBook.sol/contract.OrderBook.md | 9 + .../IOrderBook.sol/interface.IOrderBook.md | 9 + .../contract.BondOrderBook.md | 423 ----------- .../contract.BondOrderBookFactory.md | 104 --- .../BondToken.sol/contract.BondToken.md | 25 +- .../natspec/src/src/norges-bank/README.md | 2 - .../IBondToken.sol/interface.IBondToken.md | 7 + contracts/script/csd/06_OrderBook.s.sol | 10 +- .../script/norges-bank/11_BondSetup.s.sol | 43 +- contracts/src/broker/Broker.sol | 19 +- contracts/src/common/Errors.sol | 10 +- contracts/src/csd/OrderBook.sol | 7 + contracts/src/interfaces/IOrderBook.sol | 5 + contracts/src/norges-bank/BondManager.sol | 8 +- contracts/src/norges-bank/BondOrderBook.sol | 701 ------------------ .../src/norges-bank/BondOrderBookFactory.sol | 91 --- contracts/src/norges-bank/BondToken.sol | 31 +- .../src/norges-bank/interfaces/IBondToken.sol | 2 + contracts/test/broker/Broker.t.sol | 45 +- .../test/integration/BondLifecycle.t.sol | 1 - contracts/test/norges-bank/BondManager.t.sol | 1 - .../norges-bank/BondManagerConstructor.t.sol | 76 ++ .../test/norges-bank/BondOrderBook.t.sol | 382 ---------- .../norges-bank/BondOrderBookFactory.t.sol | 133 ---- contracts/test/norges-bank/BondToken.t.sol | 64 +- .../architecture/contract-topology.md | 8 +- ...oop-settlement-and-omnibus-custody-plan.md | 6 +- services/nb-bond-api/DEVELOPMENT.md | 4 +- services/nb-bond-api/src/abi/BondManager.json | 2 +- services/nb-bond-api/src/abi/BondToken.json | 2 +- 35 files changed, 405 insertions(+), 1928 deletions(-) delete mode 100644 contracts/docs/natspec/src/src/norges-bank/BondOrderBook.sol/contract.BondOrderBook.md delete mode 100644 contracts/docs/natspec/src/src/norges-bank/BondOrderBookFactory.sol/contract.BondOrderBookFactory.md delete mode 100644 contracts/src/norges-bank/BondOrderBook.sol delete mode 100644 contracts/src/norges-bank/BondOrderBookFactory.sol create mode 100644 contracts/test/norges-bank/BondManagerConstructor.t.sol delete mode 100644 contracts/test/norges-bank/BondOrderBook.t.sol delete mode 100644 contracts/test/norges-bank/BondOrderBookFactory.t.sol diff --git a/contracts/docs/contracts-reference.md b/contracts/docs/contracts-reference.md index ee2ac69..6f4f361 100644 --- a/contracts/docs/contracts-reference.md +++ b/contracts/docs/contracts-reference.md @@ -39,7 +39,7 @@ The current contract set falls into four main groups: - Cash and bank-money side: `Wnok`, `Tbd` - Secondary market and generic CSD settlement: - `BondOrderBook`, `OrderBook`, `DvP`, `BaseSecurityToken` + `OrderBook`, `DvP`, `BaseSecurityToken` ## Bond issuance lifecycle @@ -133,7 +133,14 @@ Key functions: Important notes: - `BondManager` depends on privileged role setup across `BondAuction`, - `BondToken`, `BondDvP`, and `Wnok`. + `BondToken`, `BondDvP`, and `Wnok`. The constructor rejects a zero address + for any wired contract, the controller, or the reserve. +- The deploy scripts give `BondManager` `BOND_CONTROLLER_ROLE` on `BondToken` + but no ERC-1410 controller status: it runs the lifecycle and moves units only + through `BondDvP`. Once wiring is done, the deployer key hands + `DEFAULT_ADMIN_ROLE` on `BondManager`, `BondAuction`, `BondToken`, and + `BondDvP` (and `BOND_ADMIN_ROLE` on `BondToken`) to the Norges Bank owner key + and renounces its own. - Every cash leg settles in `Wnok` against one government reserve account (`GOV_RESERVE`, fixed at deployment): issuance credits the reserve, while buyback and coupon payments debit it. The reserve must hold enough WNOK and @@ -238,6 +245,12 @@ Key functions: marks the partition matured after the final coupon payment. - `isinToPartition(isin)`, `partitionToIsin(partition)`, `getCouponDetails(isin)`: useful lookup helpers for integrations and operational tooling. +- `addController(controller)` and `removeController(controller)` + (`BOND_ADMIN_ROLE`): + manage ERC-1410 controllers, which act as operators for every holder. They + grant or revoke no lifecycle rights; `BOND_CONTROLLER_ROLE` is granted + separately. The deploy scripts make only `BondDvP` a controller, since it + moves units during settlement. Important notes: @@ -312,35 +325,6 @@ Important notes: - New TBD supply comes only from `mint` (bank `MINTER_ROLE`) or from that receiver-side callback; the constructor takes no reserve account. -### `BondOrderBook` - -Source: -[`contracts/src/norges-bank/BondOrderBook.sol`](../src/norges-bank/BondOrderBook.sol) - -Role in system: -Partition-specific bond order book for secondary trading against a `Tbd` -cash-side token. - -Key functions: - -- `buy(secContrAddr, amount, price, bondReceiver, cashPayer)`: - submits a buy order and immediately tries to match it. -- `sell(secContrAddr, amount, price, bondSeller, cashReceiver)`: - submits a sell order and immediately tries to match it. -- `initializeSellOrders(numIssuance, price, secContrAddr, tbdContrAddr, investorSecAddr, investorTbdAddr)`: - bootstraps issuance-side sell liquidity. -- `revokeBuyOrder(orderId)` and `revokeSellOrder(orderId)`: - remove an outstanding order. -- `getBuyOrders()`, `getSellOrders()`, `getAllBuyOrders()`, - `getAllSellOrders()`: - inspect open book state. - -Important notes: - -- The contract is simplified and partition-specific. -- Matching is immediate and uses maker price. -- This is separate from the auction-based primary issuance flow. - ### `DvP` Source: @@ -381,14 +365,18 @@ Key functions: - `initializeSellOrders(numIssuance, price, secContrAddr, tbdContrAddr, investorSecAddr, investorTbdAddr)`: seeds initial sell-side book state. - `revokeBuyOrder(orderId)` and `revokeSellOrder(orderId)`: - cancel open orders. + cancel open orders; only the broker that submitted the order may revoke it. - `getBuyOrders()`, `getSellOrders()`, `getAllBuyOrders()`, - `getAllSellOrders()`: + `getAllSellOrders()`, `getOrder(orderId)`: inspect open book state. Important notes: - The order book is tightly coupled to the settlement model in `DvP`. +- `SUBMIT_ORDER_ROLE` is granted only to the `Broker` contracts, which submit + orders on behalf of registered clients. A `Broker` lets a client revoke only + orders placed for that client's own securities wallet + (`OrderNotOwnedByClient` otherwise). - Unknown settlement errors are intentionally treated differently from buyer-side or seller-side faults so matching can continue where appropriate. A maker that fails with an unknown reason stays in the book; once a price diff --git a/contracts/docs/contracts-security.md b/contracts/docs/contracts-security.md index 57c7ef8..34b7135 100644 --- a/contracts/docs/contracts-security.md +++ b/contracts/docs/contracts-security.md @@ -40,10 +40,12 @@ in [`contracts/src/common/Roles.sol`](../src/common/Roles.sol). | Role | Used by | Why it matters | | --- | --- | --- | -| `DEFAULT_ADMIN_ROLE` | multiple contracts | Can grant and revoke other roles. This is the highest-trust role and should be tightly controlled. | +| `DEFAULT_ADMIN_ROLE` | multiple contracts | Can grant and revoke other roles. This is the highest-trust role and should be tightly controlled. On `BondManager`, `BondAuction`, `BondToken`, and `BondDvP` the deploy scripts hand it to the Norges Bank owner key and the deployer key renounces it. | +| `BOND_ADMIN_ROLE` | `BondToken` | Can add and remove ERC-1410 controllers, which act as operators over every holder's units. Only `BondDvP` is made a controller, because it moves units during settlement. | | `BOND_MANAGER_ROLE` | `BondManager` | Can open, close, cancel, and finalise auctions, recover failed issuance, pay coupons, and redeem bonds. | | `BOND_AUCTION_ADMIN_ROLE` | `BondAuction` | Can create, close, cancel, and finalise auctions. In practice this should align with the `BondManager` control path. | -| `BOND_CONTROLLER_ROLE` | `BondToken` | Can create partitions, extend or reduce offering, mint by ISIN, update coupon state, and mark bonds matured. | +| `BOND_CONTROLLER_ROLE` | `BondToken` | Can create partitions, extend or reduce offering, mint by ISIN, update coupon state, and mark bonds matured. Held by `BondManager` and by `BondDvP` (for redemption and buyback burns); granted separately from controller status. | +| `SUBMIT_ORDER_ROLE` | `OrderBook` | Can submit buy and sell orders. Granted only to the `Broker` contracts, which let a client revoke only its own orders. | | `SETTLE_ROLE` | `BondDvP` | Can execute settlement, including bond-leg and cash-leg transfers. Misconfiguration here directly affects issuance, buyback, coupon, and redemption flows. | | `MINTER_ROLE` / `BURNER_ROLE` / `TRANSFER_FROM_ROLE` | `Wnok` and related cash flows | Control the tokenized cash leg and the ability to move cash during settlement. `BondDvP` holds `TRANSFER_FROM_ROLE` and moves WNOK for every bond cash leg, bounded by the payer's allowance: the bidder's for issuance and the government reserve account's for buyback, coupon, and redemption. | diff --git a/contracts/docs/natspec/src/SUMMARY.md b/contracts/docs/natspec/src/SUMMARY.md index 9a4135f..fcf4381 100644 --- a/contracts/docs/natspec/src/SUMMARY.md +++ b/contracts/docs/natspec/src/SUMMARY.md @@ -34,8 +34,6 @@ - [BondAuction](src/norges-bank/BondAuction.sol/contract.BondAuction.md) - [BondDvP](src/norges-bank/BondDvP.sol/contract.BondDvP.md) - [BondManager](src/norges-bank/BondManager.sol/contract.BondManager.md) - - [BondOrderBook](src/norges-bank/BondOrderBook.sol/contract.BondOrderBook.md) - - [BondOrderBookFactory](src/norges-bank/BondOrderBookFactory.sol/contract.BondOrderBookFactory.md) - [BondToken](src/norges-bank/BondToken.sol/contract.BondToken.md) - [Wnok](src/norges-bank/Wnok.sol/contract.Wnok.md) - [❱ private-bank](src/private-bank/README.md) diff --git a/contracts/docs/natspec/src/src/broker/Broker.sol/contract.Broker.md b/contracts/docs/natspec/src/src/broker/Broker.sol/contract.Broker.md index bea2f44..ba02514 100644 --- a/contracts/docs/natspec/src/src/broker/Broker.sol/contract.Broker.md +++ b/contracts/docs/natspec/src/src/broker/Broker.sol/contract.Broker.md @@ -123,7 +123,8 @@ function sell(address secContrAddr, uint256 amount, uint256 askPrice) public ret Revokes a buy order. -The caller must be a registered client. Wallet addresses are resolved from ClientList. +The caller must be a registered client and the order must belong to the caller's +securities wallet. Wallet addresses are resolved from ClientList. ```solidity @@ -146,7 +147,8 @@ function revokeBuyOrder(bytes32 orderId) public returns (bool); Revokes a sell order. -The caller must be a registered client. Wallet addresses are resolved from ClientList. +The caller must be a registered client and the order must belong to the caller's +securities wallet. Wallet addresses are resolved from ClientList. ```solidity @@ -199,3 +201,14 @@ function getBuyOrders() public view returns (IOrderBook.Order[] memory); |``|`IOrderBook.Order[]`|Order[] -> An array of the caller's active buy orders from the order book.| +### _requireOwnOrder + +Reverts unless an existing order was placed for the caller's securities wallet. +An unknown id is left to the OrderBook, which reverts with OrderNotFound. + + +```solidity +function _requireOwnOrder(bytes32 orderId) internal view; +``` + + diff --git a/contracts/docs/natspec/src/src/common/Errors.sol/library.Errors.md b/contracts/docs/natspec/src/src/common/Errors.sol/library.Errors.md index f80abd9..7711558 100644 --- a/contracts/docs/natspec/src/src/common/Errors.sol/library.Errors.md +++ b/contracts/docs/natspec/src/src/common/Errors.sol/library.Errors.md @@ -141,18 +141,6 @@ error NotMultipleOfGranularity(); error UnauthorizedOperator(); ``` -### TbdAddressZero - -```solidity -error TbdAddressZero(); -``` - -### PartitionZero - -```solidity -error PartitionZero(); -``` - ### ContractNotFound ```solidity @@ -207,6 +195,12 @@ error MissingRole(bytes32 role, address account); error NotInAllowlist(string list, address addr); ``` +### OrderNotOwnedByClient + +```solidity +error OrderNotOwnedByClient(bytes32 orderId); +``` + ### BankAddressZero ```solidity @@ -249,6 +243,12 @@ error CctFromNotCaller(address from, address caller); error BondTokenAddressZero(); ``` +### BondAuctionAddressZero + +```solidity +error BondAuctionAddressZero(); +``` + ### DurationScalarZero ```solidity diff --git a/contracts/docs/natspec/src/src/csd/OrderBook.sol/contract.OrderBook.md b/contracts/docs/natspec/src/src/csd/OrderBook.sol/contract.OrderBook.md index c6a887b..4f0496b 100644 --- a/contracts/docs/natspec/src/src/csd/OrderBook.sol/contract.OrderBook.md +++ b/contracts/docs/natspec/src/src/csd/OrderBook.sol/contract.OrderBook.md @@ -364,6 +364,15 @@ function getSellOrders(address investorSecAddr) external view override returns ( function getAllBuyOrders() external view override returns (Order[] memory); ``` +### getOrder + +Returns one order by id; every field is zero when the order does not exist. + + +```solidity +function getOrder(bytes32 orderId) external view override returns (Order memory); +``` + ### getAllSellOrders diff --git a/contracts/docs/natspec/src/src/interfaces/IOrderBook.sol/interface.IOrderBook.md b/contracts/docs/natspec/src/src/interfaces/IOrderBook.sol/interface.IOrderBook.md index 4b09ebe..81e369d 100644 --- a/contracts/docs/natspec/src/src/interfaces/IOrderBook.sol/interface.IOrderBook.md +++ b/contracts/docs/natspec/src/src/interfaces/IOrderBook.sol/interface.IOrderBook.md @@ -73,6 +73,15 @@ function getAllBuyOrders() external view returns (Order[] memory); function getAllSellOrders() external view returns (Order[] memory); ``` +### getOrder + +Returns one order by id; every field is zero when the order does not exist. + + +```solidity +function getOrder(bytes32 orderId) external view returns (Order memory); +``` + ### revokeBuyOrder diff --git a/contracts/docs/natspec/src/src/norges-bank/BondOrderBook.sol/contract.BondOrderBook.md b/contracts/docs/natspec/src/src/norges-bank/BondOrderBook.sol/contract.BondOrderBook.md deleted file mode 100644 index 838935f..0000000 --- a/contracts/docs/natspec/src/src/norges-bank/BondOrderBook.sol/contract.BondOrderBook.md +++ /dev/null @@ -1,423 +0,0 @@ -# BondOrderBook -[Git Source](https://github.com/Norges-Bank-CBDC-Lab/cbdc-tokenization-sandbox/blob/e1ad13913c0726f3f8165cafaa1413435020decc/src/norges-bank/BondOrderBook.sol) - -**Inherits:** -AccessControl, ReentrancyGuard - -**Title:** -BondOrderBook - -Limit order book for ERC1410 bond partitions vs ERC20 cash (wNOK). - -Simplified matching: maker price, immediate matching with linked price levels. - - -## Constants -### _TBD - -```solidity -Tbd private immutable _TBD -``` - - -### _BOND - -```solidity -IBondToken private immutable _BOND -``` - - -### PARTITION - -```solidity -bytes32 public immutable PARTITION -``` - - -### BOND_TOKEN - -```solidity -address public immutable BOND_TOKEN -``` - - -## State Variables -### _supportedInterfaces - -```solidity -mapping(bytes4 => bool) internal _supportedInterfaces -``` - - -### bestBidPrice - -```solidity -uint256 public bestBidPrice -``` - - -### bestAskPrice - -```solidity -uint256 public bestAskPrice -``` - - -### orders - -```solidity -mapping(bytes32 => Order) public orders -``` - - -### buyLevels - -```solidity -mapping(uint256 => PriceLevel) internal buyLevels -``` - - -### sellLevels - -```solidity -mapping(uint256 => PriceLevel) internal sellLevels -``` - - -### _orderIdNonce - -```solidity -uint256 private _orderIdNonce -``` - - -## Functions -### constructor - - -```solidity -constructor(address admin, address tbdToken, address bondToken, bytes32 partition) ; -``` - -### buy - -submit buy: caller must have SUBMIT_ORDER_ROLE - - -```solidity -function buy(address secContrAddr, uint256 amount, uint256 price, address bondReceiver, address cashPayer) - external - nonReentrant - onlyRole(Roles.SUBMIT_ORDER_ROLE) - returns (bytes32); -``` - -### revokeBuyOrder - -Revoke a buy order - - -```solidity -function revokeBuyOrder(bytes32 orderId) external nonReentrant returns (bool); -``` - -### revokeSellOrder - -Revoke a sell order - - -```solidity -function revokeSellOrder(bytes32 orderId) external nonReentrant returns (bool); -``` - -### initializeSellOrders - -Initialize sell orders (1-unit each) for issuance bootstrap - - -```solidity -function initializeSellOrders( - uint256 numIssuance, - uint256 price, - address secContrAddr, - address tbdContrAddr, - address investorSecAddr, - address investorTbdAddr -) external nonReentrant onlyRole(Roles.ORDER_ADMIN_ROLE) returns (bool); -``` - -### getBuyOrders - -Get buy orders for caller broker - - -```solidity -function getBuyOrders() external view returns (Order[] memory); -``` - -### getSellOrders - -Get sell orders for caller broker - - -```solidity -function getSellOrders() external view returns (Order[] memory); -``` - -### getBuyOrders - -Get buy orders filtered by investor - - -```solidity -function getBuyOrders(address investorSecAddr) external view returns (Order[] memory); -``` - -### getSellOrders - -Get sell orders filtered by investor - - -```solidity -function getSellOrders(address investorSecAddr) external view returns (Order[] memory); -``` - -### getAllBuyOrders - -Get all buy orders (no broker/investor filter) - - -```solidity -function getAllBuyOrders() external view returns (Order[] memory); -``` - -### getAllSellOrders - -Get all sell orders (no broker/investor filter) - - -```solidity -function getAllSellOrders() external view returns (Order[] memory); -``` - -### sell - -submit sell: caller must have SUBMIT_ORDER_ROLE - - -```solidity -function sell(address secContrAddr, uint256 amount, uint256 price, address bondSeller, address cashReceiver) - external - nonReentrant - onlyRole(Roles.SUBMIT_ORDER_ROLE) - returns (bytes32); -``` - -### revoke - - -```solidity -function revoke(bytes32 orderId) external nonReentrant returns (bool); -``` - -### _settleBuy - - -```solidity -function _settleBuy(Order memory buyOrder) internal returns (bool settled, uint256 remaining); -``` - -### _settleSell - - -```solidity -function _settleSell(Order memory sellOrder) internal returns (bool settled, uint256 remaining); -``` - -### _dvpsettle - -Executes both legs atomically; reverts on unexpected errors. - - -```solidity -function _dvpsettle( - address sellerBondHolder, - address buyerBondHolder, - uint256 units, - address cashPayer, - address cashPayee, - uint256 cashAmount -) internal returns (bool, FailureReason); -``` - -### _safeTransferFrom - -helper to use try/catch with SafeERC20 - - -```solidity -function _safeTransferFrom(address from, address to, uint256 amount) external; -``` - -### _generateOrderId - - -```solidity -function _generateOrderId() internal returns (bytes32); -``` - -### _appendToBuyLevel - - -```solidity -function _appendToBuyLevel(bytes32 _orderId, uint256 _price) internal; -``` - -### _appendToSellLevel - - -```solidity -function _appendToSellLevel(bytes32 _orderId, uint256 _price) internal; -``` - -### _ensureBuyLevel - - -```solidity -function _ensureBuyLevel(uint256 _price) internal returns (PriceLevel storage); -``` - -### _ensureSellLevel - - -```solidity -function _ensureSellLevel(uint256 _price) internal returns (PriceLevel storage); -``` - -### _insertBuyLevel - - -```solidity -function _insertBuyLevel(uint256 _price) internal; -``` - -### _insertSellLevel - - -```solidity -function _insertSellLevel(uint256 _price) internal; -``` - -### _removeOrderFromLevel - - -```solidity -function _removeOrderFromLevel(bytes32 _orderId) internal; -``` - -### _removeBuyLevel - - -```solidity -function _removeBuyLevel(uint256 _price) internal; -``` - -### _removeSellLevel - - -```solidity -function _removeSellLevel(uint256 _price) internal; -``` - -### _fetchOrders - - -```solidity -function _fetchOrders(bool isBuySide, address broker, address investor) internal view returns (Order[] memory); -``` - -## Events -### OrderSubmitted - -```solidity -event OrderSubmitted( - bytes32 indexed orderId, bool indexed isBuy, uint256 amount, uint256 price, address bondHolder, address cashAddr -); -``` - -### OrderMatched - -```solidity -event OrderMatched(bytes32 indexed orderId); -``` - -### OrderRevoked - -```solidity -event OrderRevoked(bytes32 indexed orderId); -``` - -### DVPSuccess - -```solidity -event DVPSuccess(bytes32 indexed orderId); -``` - -### DVPFailed - -```solidity -event DVPFailed(bytes32 indexed orderId, FailureReason reason); -``` - -## Structs -### Order -Kept structurally consistent with IOrderBook.Order for ease of tooling. -- investorSecAddr: bond holder (receives on buy, sends on sell) -- secContrAddr: bond token address (immutable BOND_TOKEN) -- investorTbdAddr: cash address (pays on buy, receives on sell) -- tbdContrAddr: cash token address (immutable _cash) - - -```solidity -struct Order { - bytes32 id; - address broker; - address investorSecAddr; - address secContrAddr; - uint256 amount; - uint256 price; - address investorTbdAddr; - address tbdContrAddr; - bool isBuySide; - bytes32 next; - bytes32 prev; -} -``` - -### PriceLevel -Aligned with IOrderBook.PriceLevel field ordering for consistency. - - -```solidity -struct PriceLevel { - uint256 price; - bytes32 head; - bytes32 tail; - uint256 prev; - uint256 next; - uint256 volume; - bool exists; -} -``` - -## Enums -### FailureReason - -```solidity -enum FailureReason { - Buyer, - Seller, - Unknown -} -``` - diff --git a/contracts/docs/natspec/src/src/norges-bank/BondOrderBookFactory.sol/contract.BondOrderBookFactory.md b/contracts/docs/natspec/src/src/norges-bank/BondOrderBookFactory.sol/contract.BondOrderBookFactory.md deleted file mode 100644 index a6d1153..0000000 --- a/contracts/docs/natspec/src/src/norges-bank/BondOrderBookFactory.sol/contract.BondOrderBookFactory.md +++ /dev/null @@ -1,104 +0,0 @@ -# BondOrderBookFactory -[Git Source](https://github.com/Norges-Bank-CBDC-Lab/cbdc-tokenization-sandbox/blob/e1ad13913c0726f3f8165cafaa1413435020decc/src/norges-bank/BondOrderBookFactory.sol) - -**Inherits:** -AccessControl - -**Title:** -BondOrderBookFactory - -Deploys one BondOrderBook per (bondToken, partition) pair using CREATE2 for determinism. - - -## Constants -### TBD -Address of the Tbd contract (cash leg) - - -```solidity -address public immutable TBD -``` - - -### ADMIN -Admin for deployed order books - - -```solidity -address public immutable ADMIN -``` - - -## State Variables -### getOrderBook -key = keccak256(abi.encode(bondToken, partition)) - - -```solidity -mapping(bytes32 => address) public getOrderBook -``` - - -### allOrderBooks - -```solidity -address[] public allOrderBooks -``` - - -## Functions -### constructor - - -```solidity -constructor(address _admin, address _tbd) ; -``` - -### allOrderBooksLength - - -```solidity -function allOrderBooksLength() external view returns (uint256); -``` - -### createBondOrderBook - -Deploy a bond order book for a specific bond partition. - - -```solidity -function createBondOrderBook(address bondToken, bytes32 partition) - external - onlyRole(Roles.ORDER_ADMIN_ROLE) - returns (address orderBook); -``` -**Parameters** - -|Name|Type|Description| -|----|----|-----------| -|`bondToken`|`address`|Address of the BondToken (ERC1410) contract.| -|`partition`|`bytes32`|Partition (ISIN) identifier.| - -**Returns** - -|Name|Type|Description| -|----|----|-----------| -|`orderBook`|`address`|deployed address.| - - -### computeBondOrderBookAddress - -Precompute the order book address for (bondToken, partition). - - -```solidity -function computeBondOrderBookAddress(address bondToken, bytes32 partition) external view returns (address); -``` - -## Events -### BondOrderBookCreated - -```solidity -event BondOrderBookCreated(address indexed bondToken, bytes32 indexed partition, address indexed orderBook); -``` - diff --git a/contracts/docs/natspec/src/src/norges-bank/BondToken.sol/contract.BondToken.md b/contracts/docs/natspec/src/src/norges-bank/BondToken.sol/contract.BondToken.md index 12e6abd..e7632c5 100644 --- a/contracts/docs/natspec/src/src/norges-bank/BondToken.sol/contract.BondToken.md +++ b/contracts/docs/natspec/src/src/norges-bank/BondToken.sol/contract.BondToken.md @@ -137,9 +137,11 @@ constructor(string memory _name, string memory _symbol) ### addController -Add new controller +Add an ERC-1410 controller -To add dedicated manager role +A controller is an operator for every holder, so it can move any holder's units +through `operatorTransferByPartition`. This grants no lifecycle rights; those come +from BOND_CONTROLLER_ROLE, granted separately with `grantRole`. ```solidity @@ -149,7 +151,24 @@ function addController(address _controller) external onlyRole(Roles.BOND_ADMIN_R |Name|Type|Description| |----|----|-----------| -|`_controller`|`address`|of new controller| +|`_controller`|`address`|Address of the new controller| + + +### removeController + +Remove an ERC-1410 controller + +Leaves BOND_CONTROLLER_ROLE untouched; a no-op when the address is not a controller. + + +```solidity +function removeController(address _controller) external onlyRole(Roles.BOND_ADMIN_ROLE); +``` +**Parameters** + +|Name|Type|Description| +|----|----|-----------| +|`_controller`|`address`|Address of the controller to remove| ### supportsInterface diff --git a/contracts/docs/natspec/src/src/norges-bank/README.md b/contracts/docs/natspec/src/src/norges-bank/README.md index dbb5051..b1d4e3f 100644 --- a/contracts/docs/natspec/src/src/norges-bank/README.md +++ b/contracts/docs/natspec/src/src/norges-bank/README.md @@ -6,7 +6,5 @@ - [BondAuction](BondAuction.sol/contract.BondAuction.md) - [BondDvP](BondDvP.sol/contract.BondDvP.md) - [BondManager](BondManager.sol/contract.BondManager.md) -- [BondOrderBook](BondOrderBook.sol/contract.BondOrderBook.md) -- [BondOrderBookFactory](BondOrderBookFactory.sol/contract.BondOrderBookFactory.md) - [BondToken](BondToken.sol/contract.BondToken.md) - [Wnok](Wnok.sol/contract.Wnok.md) diff --git a/contracts/docs/natspec/src/src/norges-bank/interfaces/IBondToken.sol/interface.IBondToken.md b/contracts/docs/natspec/src/src/norges-bank/interfaces/IBondToken.sol/interface.IBondToken.md index a8f5a5e..4148e5e 100644 --- a/contracts/docs/natspec/src/src/norges-bank/interfaces/IBondToken.sol/interface.IBondToken.md +++ b/contracts/docs/natspec/src/src/norges-bank/interfaces/IBondToken.sol/interface.IBondToken.md @@ -92,6 +92,13 @@ function isMatured(bytes32 partition) external view returns (bool); function addController(address _controller) external; ``` +### removeController + + +```solidity +function removeController(address _controller) external; +``` + ### isinToPartition diff --git a/contracts/script/csd/06_OrderBook.s.sol b/contracts/script/csd/06_OrderBook.s.sol index 9994e8b..7c46a66 100644 --- a/contracts/script/csd/06_OrderBook.s.sol +++ b/contracts/script/csd/06_OrderBook.s.sol @@ -12,12 +12,9 @@ contract OrderBookScript is RegistryScript { function run() external { uint256 deployerKey = vm.envUint("PK_DEPLOYER"); uint256 ownerKey = vm.envUint("PK_NORGES_BANK"); - uint256 csdKey = vm.envUint("PK_CSD"); address owner = vm.addr(ownerKey); address csdAddr = vm.addr(vm.envUint("PK_CSD")); - address broker1Addr = vm.addr(vm.envUint("PK_BROKER1")); - address broker2Addr = vm.addr(vm.envUint("PK_BROKER2")); address registryAddr = vm.envAddress("REGISTRY_ADDR"); _ensureRegistry(registryAddr, owner); @@ -30,15 +27,12 @@ contract OrderBookScript is RegistryScript { (bool found, address stockTokenAddr) = stockTokenFactory.getDeployedStockToken("NO0001234567"); require(found, "Stock token NO0001234567 not found."); + // SUBMIT_ORDER_ROLE goes only to the broker contracts (09_BrokersSetup), which submit + // orders on behalf of their registered clients; broker EOAs get no direct order access. vm.startBroadcast(deployerKey); OrderBook orderBook = new OrderBook(csdAddr, wnokAddr, address(dvp), stockTokenAddr); vm.stopBroadcast(); - vm.startBroadcast(csdKey); - orderBook.grantRole(Roles.SUBMIT_ORDER_ROLE, broker1Addr); - orderBook.grantRole(Roles.SUBMIT_ORDER_ROLE, broker2Addr); - vm.stopBroadcast(); - vm.startBroadcast(ownerKey); dvp.grantRole(Roles.SETTLE_ROLE, address(orderBook)); registry.setContract(vm.envString("ORDERBOOK_CONTRACT_NAME"), address(orderBook)); diff --git a/contracts/script/norges-bank/11_BondSetup.s.sol b/contracts/script/norges-bank/11_BondSetup.s.sol index fc36567..38a9cc6 100644 --- a/contracts/script/norges-bank/11_BondSetup.s.sol +++ b/contracts/script/norges-bank/11_BondSetup.s.sol @@ -10,6 +10,7 @@ import {BondDvP} from "@norges-bank/BondDvP.sol"; import {Wnok} from "@norges-bank/Wnok.sol"; import {Roles} from "@common/Roles.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; contract BondSetupScript is RegistryScript { function setUp() public {} @@ -45,8 +46,13 @@ contract BondSetupScript is RegistryScript { vm.startBroadcast(deployerKey); bondAuction.grantRole(Roles.BOND_AUCTION_ADMIN_ROLE, bondManagerAddr); - bondToken.addController(bondManagerAddr); + + // BondManager runs the bond lifecycle but never moves holder units itself, so it gets + // BOND_CONTROLLER_ROLE only. BondDvP moves units in settlement (ERC-1410 controller) + // and burns them on redemption and buyback (BOND_CONTROLLER_ROLE). + bondToken.grantRole(Roles.BOND_CONTROLLER_ROLE, bondManagerAddr); bondToken.addController(address(bondDvp)); + bondToken.grantRole(Roles.BOND_CONTROLLER_ROLE, address(bondDvp)); bondDvp.grantRole(Roles.SETTLE_ROLE, bondManagerAddr); @@ -79,5 +85,40 @@ contract BondSetupScript is RegistryScript { vm.startBroadcast(dnbKey); // DNB wnok.approve(address(bondDvp), type(uint256).max); vm.stopBroadcast(); + + _handAdminToOwner(deployerKey, owner, bondManagerAddr, bondAuction, bondToken, bondDvp); + } + + /** + * @dev The deployer key is only needed to deploy and wire the bond contracts. Once wiring is + * done, the Norges Bank owner key takes over DEFAULT_ADMIN_ROLE (and BOND_ADMIN_ROLE on + * BondToken) so roles can still be rotated, and the deployer renounces its own. + */ + function _handAdminToOwner( + uint256 deployerKey, + address owner, + address bondManagerAddr, + BondAuction bondAuction, + BondToken bondToken, + BondDvP bondDvp + ) internal { + address deployer = vm.addr(deployerKey); + if (deployer == owner) return; + + IAccessControl[4] memory bondContracts = [ + IAccessControl(bondManagerAddr), + IAccessControl(address(bondAuction)), + IAccessControl(address(bondToken)), + IAccessControl(address(bondDvp)) + ]; + + vm.startBroadcast(deployerKey); + bondToken.grantRole(Roles.BOND_ADMIN_ROLE, owner); + bondToken.renounceRole(Roles.BOND_ADMIN_ROLE, deployer); + for (uint256 i = 0; i < bondContracts.length; i++) { + bondContracts[i].grantRole(Roles.DEFAULT_ADMIN_ROLE, owner); + bondContracts[i].renounceRole(Roles.DEFAULT_ADMIN_ROLE, deployer); + } + vm.stopBroadcast(); } } diff --git a/contracts/src/broker/Broker.sol b/contracts/src/broker/Broker.sol index 7086983..b6e4bd9 100644 --- a/contracts/src/broker/Broker.sol +++ b/contracts/src/broker/Broker.sol @@ -97,23 +97,27 @@ contract Broker is ClientList { /** * @notice Revokes a buy order. - * @dev The caller must be a registered client. Wallet addresses are resolved from ClientList. + * @dev The caller must be a registered client and the order must belong to the caller's + * securities wallet. Wallet addresses are resolved from ClientList. * @param orderId The unique identifier of the Order. * @return true/false representing the success of the transaction. */ function revokeBuyOrder(bytes32 orderId) public returns (bool) { clientExistsGuard(msg.sender); + _requireOwnOrder(orderId); return _ORDER_BOOK.revokeBuyOrder(orderId); } /** * @notice Revokes a sell order. - * @dev The caller must be a registered client. Wallet addresses are resolved from ClientList. + * @dev The caller must be a registered client and the order must belong to the caller's + * securities wallet. Wallet addresses are resolved from ClientList. * @param orderId The unique identifier of the Order. * @return true/false representing the success of the transaction. */ function revokeSellOrder(bytes32 orderId) public returns (bool) { clientExistsGuard(msg.sender); + _requireOwnOrder(orderId); return _ORDER_BOOK.revokeSellOrder(orderId); } @@ -136,4 +140,15 @@ contract Broker is ClientList { clientExistsGuard(msg.sender); return _ORDER_BOOK.getBuyOrders(getSecuritiesWallet(msg.sender)); } + + /** + * @dev Reverts unless an existing order was placed for the caller's securities wallet. + * An unknown id is left to the OrderBook, which reverts with OrderNotFound. + */ + function _requireOwnOrder(bytes32 orderId) internal view { + IOrderBook.Order memory order = _ORDER_BOOK.getOrder(orderId); + if (order.id != bytes32(0) && order.investorSecAddr != getSecuritiesWallet(msg.sender)) { + revert Errors.OrderNotOwnedByClient(orderId); + } + } } diff --git a/contracts/src/common/Errors.sol b/contracts/src/common/Errors.sol index 873cf61..dee8fe6 100644 --- a/contracts/src/common/Errors.sol +++ b/contracts/src/common/Errors.sol @@ -26,8 +26,6 @@ library Errors { error InsufficientPartitionBalance(); error NotMultipleOfGranularity(); error UnauthorizedOperator(); - error TbdAddressZero(); - error PartitionZero(); // --- Registry (GlobalRegistry) --- error ContractNotFound(string contractAddress); @@ -44,6 +42,9 @@ library Errors { error MissingRole(bytes32 role, address account); error NotInAllowlist(string list, address addr); + // --- Broker --- + error OrderNotOwnedByClient(bytes32 orderId); + // --- Wnok --- error BankAddressZero(); error CallbackFailed(bytes4 received); @@ -54,10 +55,9 @@ library Errors { error CctFailed(); error CctFromNotCaller(address from, address caller); - // --- BondOrderBookFactory --- - error BondTokenAddressZero(); - // --- BondManager --- + error BondTokenAddressZero(); + error BondAuctionAddressZero(); error DurationScalarZero(); error BondUnitNominalZero(); error BondDoesNotExist(string isin); diff --git a/contracts/src/csd/OrderBook.sol b/contracts/src/csd/OrderBook.sol index a37cd3a..35e5b18 100644 --- a/contracts/src/csd/OrderBook.sol +++ b/contracts/src/csd/OrderBook.sol @@ -494,6 +494,13 @@ contract OrderBook is IOrderBook, AccessControl, ReentrancyGuard { return _fetchOrders(true, address(0), address(0)); } + /** + * @inheritdoc IOrderBook + */ + function getOrder(bytes32 orderId) external view override returns (Order memory) { + return orders[orderId]; + } + function getAllSellOrders() external view override returns (Order[] memory) { return _fetchOrders(false, address(0), address(0)); } diff --git a/contracts/src/interfaces/IOrderBook.sol b/contracts/src/interfaces/IOrderBook.sol index 42c90e6..7aef769 100644 --- a/contracts/src/interfaces/IOrderBook.sol +++ b/contracts/src/interfaces/IOrderBook.sol @@ -105,6 +105,11 @@ interface IOrderBook { function getAllBuyOrders() external view returns (Order[] memory); function getAllSellOrders() external view returns (Order[] memory); + /** + * @notice Returns one order by id; every field is zero when the order does not exist. + */ + function getOrder(bytes32 orderId) external view returns (Order memory); + function revokeBuyOrder(bytes32 orderId) external returns (bool); function revokeSellOrder(bytes32 orderId) external returns (bool); diff --git a/contracts/src/norges-bank/BondManager.sol b/contracts/src/norges-bank/BondManager.sol index 6828050..df76d99 100644 --- a/contracts/src/norges-bank/BondManager.sol +++ b/contracts/src/norges-bank/BondManager.sol @@ -99,6 +99,13 @@ contract BondManager is IBondManager, AccessControl { address _govReserve, uint256 _durationScalar ) { + if (_wNok == address(0)) revert Errors.WnokAddressZero(); + if (_controller == address(0)) revert Errors.ControllerAddressZero(); + if (_bondAuction == address(0)) revert Errors.BondAuctionAddressZero(); + if (_bondToken == address(0)) revert Errors.BondTokenAddressZero(); + if (_bondDvp == address(0)) revert Errors.DvpAddressZero(); + if (_govReserve == address(0)) revert Errors.GovReserveAddressZero(); + _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); _grantRole(Roles.BOND_MANAGER_ROLE, _controller); @@ -108,7 +115,6 @@ contract BondManager is IBondManager, AccessControl { BOND_AUCTION = IBondAuction(_bondAuction); BOND_TOKEN = IBondToken(_bondToken); BOND_DVP = IBondDvP(_bondDvp); - if (_govReserve == address(0)) revert Errors.GovReserveAddressZero(); GOV_RESERVE = _govReserve; if (_durationScalar == 0) revert Errors.DurationScalarZero(); diff --git a/contracts/src/norges-bank/BondOrderBook.sol b/contracts/src/norges-bank/BondOrderBook.sol deleted file mode 100644 index e979970..0000000 --- a/contracts/src/norges-bank/BondOrderBook.sol +++ /dev/null @@ -1,701 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -pragma solidity ^0.8.29; - -import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; -import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; - -import {IBondToken} from "@norges-bank/interfaces/IBondToken.sol"; -import {Tbd} from "@private-bank/Tbd.sol"; - -import {Errors} from "@common/Errors.sol"; -import {Roles} from "@common/Roles.sol"; - -/** - * @title BondOrderBook - * @notice Limit order book for ERC1410 bond partitions vs ERC20 cash (wNOK). - * @dev Simplified matching: maker price, immediate matching with linked price levels. - */ -contract BondOrderBook is AccessControl, ReentrancyGuard { - using SafeERC20 for IERC20; - - /** - * @dev Kept structurally consistent with IOrderBook.Order for ease of tooling. - * - investorSecAddr: bond holder (receives on buy, sends on sell) - * - secContrAddr: bond token address (immutable BOND_TOKEN) - * - investorTbdAddr: cash address (pays on buy, receives on sell) - * - tbdContrAddr: cash token address (immutable _cash) - */ - struct Order { - bytes32 id; - address broker; - address investorSecAddr; - address secContrAddr; - uint256 amount; - uint256 price; - address investorTbdAddr; - address tbdContrAddr; - bool isBuySide; - bytes32 next; - bytes32 prev; - } - - /** - * @dev Aligned with IOrderBook.PriceLevel field ordering for consistency. - */ - struct PriceLevel { - uint256 price; - bytes32 head; - bytes32 tail; - uint256 prev; - uint256 next; - uint256 volume; - bool exists; - } - - enum FailureReason { - Buyer, - Seller, - Unknown - } - - mapping(bytes4 => bool) internal _supportedInterfaces; - - uint256 public bestBidPrice; - uint256 public bestAskPrice; - mapping(bytes32 => Order) public orders; - mapping(uint256 => PriceLevel) internal buyLevels; - mapping(uint256 => PriceLevel) internal sellLevels; - uint256 private _orderIdNonce; - - Tbd private immutable _TBD; - IBondToken private immutable _BOND; - bytes32 public immutable PARTITION; - address public immutable BOND_TOKEN; - - event OrderSubmitted( - bytes32 indexed orderId, bool indexed isBuy, uint256 amount, uint256 price, address bondHolder, address cashAddr - ); - event OrderMatched(bytes32 indexed orderId); - event OrderRevoked(bytes32 indexed orderId); - event DVPSuccess(bytes32 indexed orderId); - event DVPFailed(bytes32 indexed orderId, FailureReason reason); - - constructor(address admin, address tbdToken, address bondToken, bytes32 partition) { - if (admin == address(0)) revert Errors.AdminAddressZero(); - if (tbdToken == address(0)) revert Errors.TbdAddressZero(); - if (bondToken == address(0)) revert Errors.SecurityAddressZero(); - if (partition == bytes32(0)) revert Errors.PartitionZero(); - - _grantRole(DEFAULT_ADMIN_ROLE, admin); - _grantRole(Roles.ORDER_ADMIN_ROLE, admin); - _grantRole(Roles.SUBMIT_ORDER_ROLE, admin); - - _TBD = Tbd(tbdToken); - _BOND = IBondToken(bondToken); - PARTITION = partition; - BOND_TOKEN = bondToken; - } - - /** - * @dev submit buy: caller must have SUBMIT_ORDER_ROLE - */ - // slither-disable-next-line reentrancy-no-eth,reentrancy-benign - function buy(address secContrAddr, uint256 amount, uint256 price, address bondReceiver, address cashPayer) - external - nonReentrant - onlyRole(Roles.SUBMIT_ORDER_ROLE) - returns (bytes32) - { - if (secContrAddr != BOND_TOKEN) revert Errors.SecurityMismatch(); - if (amount == 0) revert Errors.InvalidAmount(); - if (price == 0) revert Errors.InvalidPrice(); - if (bondReceiver == address(0)) revert Errors.InvalidRecipient(); - if (cashPayer == address(0)) revert Errors.InvalidHolder(cashPayer); - - bytes32 orderId = _generateOrderId(); - Order memory buyOrder = Order({ - id: orderId, - broker: msg.sender, - investorSecAddr: bondReceiver, - secContrAddr: BOND_TOKEN, - amount: amount, - price: price, - investorTbdAddr: cashPayer, - tbdContrAddr: address(_TBD), - isBuySide: true, - next: bytes32(0), - prev: bytes32(0) - }); - - emit OrderSubmitted(orderId, true, amount, price, bondReceiver, cashPayer); - - (bool settled, uint256 remaining) = _settleBuy(buyOrder); - - if (!settled) { - buyOrder.amount = remaining; - orders[orderId] = buyOrder; - _appendToBuyLevel(orderId, price); - } - - return orderId; - } - - /** - * @notice Revoke a buy order - */ - function revokeBuyOrder(bytes32 orderId) external nonReentrant returns (bool) { - Order storage order = orders[orderId]; - if (order.id == bytes32(0) || !order.isBuySide) revert Errors.OrderNotFound(); - if (order.broker != msg.sender) revert Errors.UnauthorizedBroker(); - _removeOrderFromLevel(orderId); - delete orders[orderId]; - emit OrderRevoked(orderId); - return true; - } - - /** - * @notice Revoke a sell order - */ - function revokeSellOrder(bytes32 orderId) external nonReentrant returns (bool) { - Order storage order = orders[orderId]; - if (order.id == bytes32(0) || order.isBuySide) revert Errors.OrderNotFound(); - if (order.broker != msg.sender) revert Errors.UnauthorizedBroker(); - _removeOrderFromLevel(orderId); - delete orders[orderId]; - emit OrderRevoked(orderId); - return true; - } - - /** - * @notice Initialize sell orders (1-unit each) for issuance bootstrap - */ - function initializeSellOrders( - uint256 numIssuance, - uint256 price, - address secContrAddr, - address tbdContrAddr, - address investorSecAddr, - address investorTbdAddr - ) external nonReentrant onlyRole(Roles.ORDER_ADMIN_ROLE) returns (bool) { - if (price == 0) revert Errors.InvalidPrice(); - if (secContrAddr != BOND_TOKEN) revert Errors.SecurityMismatch(); - if (tbdContrAddr != address(_TBD)) revert Errors.SecurityMismatch(); - for (uint256 i = 0; i < numIssuance; i++) { - bytes32 orderId = _generateOrderId(); - Order memory sellOrder = Order({ - id: orderId, - broker: address(0x0), - investorSecAddr: investorSecAddr, - secContrAddr: BOND_TOKEN, - amount: 1, - price: price, - investorTbdAddr: investorTbdAddr, - tbdContrAddr: address(_TBD), - isBuySide: false, - next: bytes32(0), - prev: bytes32(0) - }); - orders[orderId] = sellOrder; - _appendToSellLevel(orderId, price); - } - return true; - } - - /** - * @notice Get buy orders for caller broker - */ - function getBuyOrders() external view returns (Order[] memory) { - return _fetchOrders(true, msg.sender, address(0)); - } - - /** - * @notice Get sell orders for caller broker - */ - function getSellOrders() external view returns (Order[] memory) { - return _fetchOrders(false, msg.sender, address(0)); - } - - /** - * @notice Get buy orders filtered by investor - */ - function getBuyOrders(address investorSecAddr) external view returns (Order[] memory) { - return _fetchOrders(true, msg.sender, investorSecAddr); - } - - /** - * @notice Get sell orders filtered by investor - */ - function getSellOrders(address investorSecAddr) external view returns (Order[] memory) { - return _fetchOrders(false, msg.sender, investorSecAddr); - } - - /** - * @notice Get all buy orders (no broker/investor filter) - */ - function getAllBuyOrders() external view returns (Order[] memory) { - return _fetchOrders(true, address(0), address(0)); - } - - /** - * @notice Get all sell orders (no broker/investor filter) - */ - function getAllSellOrders() external view returns (Order[] memory) { - return _fetchOrders(false, address(0), address(0)); - } - - /** - * @dev submit sell: caller must have SUBMIT_ORDER_ROLE - */ - // slither-disable-next-line reentrancy-no-eth,reentrancy-benign - function sell(address secContrAddr, uint256 amount, uint256 price, address bondSeller, address cashReceiver) - external - nonReentrant - onlyRole(Roles.SUBMIT_ORDER_ROLE) - returns (bytes32) - { - if (secContrAddr != BOND_TOKEN) revert Errors.SecurityMismatch(); - if (amount == 0) revert Errors.InvalidAmount(); - if (price == 0) revert Errors.InvalidPrice(); - if (bondSeller == address(0)) revert Errors.InvalidHolder(bondSeller); - if (cashReceiver == address(0)) revert Errors.InvalidRecipient(); - - bytes32 orderId = _generateOrderId(); - Order memory sellOrder = Order({ - id: orderId, - broker: msg.sender, - investorSecAddr: bondSeller, - secContrAddr: BOND_TOKEN, - amount: amount, - price: price, - investorTbdAddr: cashReceiver, - tbdContrAddr: address(_TBD), - isBuySide: false, - next: bytes32(0), - prev: bytes32(0) - }); - - emit OrderSubmitted(orderId, false, amount, price, bondSeller, cashReceiver); - - (bool settled, uint256 remaining) = _settleSell(sellOrder); - - if (!settled) { - sellOrder.amount = remaining; - orders[orderId] = sellOrder; - _appendToSellLevel(orderId, price); - } - - return orderId; - } - - function revoke(bytes32 orderId) external nonReentrant returns (bool) { - Order storage order = orders[orderId]; - if (order.id == bytes32(0)) revert Errors.OrderNotFound(); - if (order.broker != msg.sender) revert Errors.UnauthorizedBroker(); - _removeOrderFromLevel(orderId); - delete orders[orderId]; - emit OrderRevoked(orderId); - return true; - } - - // -------- Matching ---------- - // slither-disable-next-line reentrancy-no-eth,reentrancy-benign - function _settleBuy(Order memory buyOrder) internal returns (bool settled, uint256 remaining) { - remaining = buyOrder.amount; - bytes32 matchedOrderId = bytes32(0); - - while (remaining > 0) { - uint256 currentPrice = bestAskPrice; - if (currentPrice == 0 || currentPrice > buyOrder.price) break; - - bytes32 orderId = sellLevels[currentPrice].head; - while (orderId != bytes32(0) && remaining > 0) { - Order storage sellOrder = orders[orderId]; - bytes32 nextOrder = sellOrder.next; - - uint256 tradeAmount = remaining < sellOrder.amount ? remaining : sellOrder.amount; - uint256 settlementValue = tradeAmount * sellOrder.price; // maker price - - emit OrderMatched(orderId); - - (bool success, FailureReason reason) = _dvpsettle( - sellOrder.investorSecAddr, - buyOrder.investorSecAddr, - tradeAmount, - buyOrder.investorTbdAddr, - sellOrder.investorTbdAddr, - settlementValue - ); - - if (success) { - remaining -= tradeAmount; - buyOrder.amount = remaining; - sellOrder.amount -= tradeAmount; - sellLevels[sellOrder.price].volume -= tradeAmount; - matchedOrderId = orderId; - if (sellOrder.amount == 0) { - _removeOrderFromLevel(orderId); - delete orders[orderId]; - } - } else { - emit DVPFailed(orderId, reason); - if (reason == FailureReason.Buyer) { - return (false, remaining); - } - if (reason == FailureReason.Seller) { - _removeOrderFromLevel(orderId); - delete orders[orderId]; - } - } - orderId = nextOrder; - } - // The whole level has been walked. If it still sits at the best - // price, every order left on it failed settlement for a reason that - // neither fills nor evicts it; walking it again cannot make progress, - // so stop here and let the remainder rest in the book. - if (bestAskPrice == currentPrice) break; - } - settled = remaining == 0; - if (settled) emit DVPSuccess(matchedOrderId); - } - - // slither-disable-next-line reentrancy-no-eth,reentrancy-benign - function _settleSell(Order memory sellOrder) internal returns (bool settled, uint256 remaining) { - remaining = sellOrder.amount; - bytes32 matchedOrderId = bytes32(0); - - while (remaining > 0) { - uint256 currentPrice = bestBidPrice; - if (currentPrice == 0 || currentPrice < sellOrder.price) break; - - bytes32 orderId = buyLevels[currentPrice].head; - while (orderId != bytes32(0) && remaining > 0) { - Order storage buyOrder = orders[orderId]; - bytes32 nextOrder = buyOrder.next; - - uint256 tradeAmount = remaining < buyOrder.amount ? remaining : buyOrder.amount; - uint256 settlementValue = tradeAmount * buyOrder.price; // maker price - - emit OrderMatched(orderId); - - // slither-disable-next-line reentrancy-vulnerabilities - (bool success, FailureReason reason) = _dvpsettle( - sellOrder.investorSecAddr, - buyOrder.investorSecAddr, - tradeAmount, - buyOrder.investorTbdAddr, - sellOrder.investorTbdAddr, - settlementValue - ); - - if (success) { - remaining -= tradeAmount; - sellOrder.amount = remaining; - buyOrder.amount -= tradeAmount; - buyLevels[buyOrder.price].volume -= tradeAmount; - matchedOrderId = orderId; - if (buyOrder.amount == 0) { - _removeOrderFromLevel(orderId); - delete orders[orderId]; - } - } else { - emit DVPFailed(orderId, reason); - if (reason == FailureReason.Seller) { - return (false, remaining); - } - if (reason == FailureReason.Buyer) { - _removeOrderFromLevel(orderId); - delete orders[orderId]; - } - } - orderId = nextOrder; - } - // The whole level has been walked. If it still sits at the best - // price, every order left on it failed settlement for a reason that - // neither fills nor evicts it; walking it again cannot make progress, - // so stop here and let the remainder rest in the book. - if (bestBidPrice == currentPrice) break; - } - settled = remaining == 0; - if (settled) emit DVPSuccess(matchedOrderId); - } - - /** - * @dev Executes both legs atomically; reverts on unexpected errors. - */ - function _dvpsettle( - address sellerBondHolder, - address buyerBondHolder, - uint256 units, - address cashPayer, - address cashPayee, - uint256 cashAmount - ) internal returns (bool, FailureReason) { - // Security leg - // slither-disable-next-line reentrancy-vulnerabilities - try _BOND.operatorTransferByPartition(PARTITION, sellerBondHolder, buyerBondHolder, units, "", "") returns ( - bytes32 partitionReturned - ) { - if (partitionReturned != PARTITION) { - return (false, FailureReason.Seller); - } - } catch { - return (false, FailureReason.Seller); - } - - // Cash leg - // slither-disable-next-line reentrancy-vulnerabilities - try this._safeTransferFrom(cashPayer, cashPayee, cashAmount) { - return (true, FailureReason.Unknown); - } catch { - return (false, FailureReason.Buyer); - } - } - - /** - * @dev helper to use try/catch with SafeERC20 - */ - function _safeTransferFrom(address from, address to, uint256 amount) external { - IERC20(address(_TBD)).safeTransferFrom(from, to, amount); - } - - // -------- Order book list management (mirrors csd OrderBook logic) ---------- - - function _generateOrderId() internal returns (bytes32) { - _orderIdNonce++; - // forge-lint: disable-next-line(asm-keccak256) - return keccak256(abi.encodePacked(block.number, _orderIdNonce, address(this))); - } - - function _appendToBuyLevel(bytes32 _orderId, uint256 _price) internal { - PriceLevel storage level = _ensureBuyLevel(_price); - - if (level.head == bytes32(0)) { - level.head = _orderId; - level.tail = _orderId; - } else { - bytes32 tailId = level.tail; - orders[tailId].next = _orderId; - orders[_orderId].prev = tailId; - level.tail = _orderId; - } - - level.volume += orders[_orderId].amount; - } - - function _appendToSellLevel(bytes32 _orderId, uint256 _price) internal { - PriceLevel storage level = _ensureSellLevel(_price); - - if (level.head == bytes32(0)) { - level.head = _orderId; - level.tail = _orderId; - } else { - bytes32 tailId = level.tail; - orders[tailId].next = _orderId; - orders[_orderId].prev = tailId; - level.tail = _orderId; - } - - level.volume += orders[_orderId].amount; - } - - function _ensureBuyLevel(uint256 _price) internal returns (PriceLevel storage) { - PriceLevel storage level = buyLevels[_price]; - if (!level.exists) { - level.exists = true; - level.price = _price; - _insertBuyLevel(_price); - } - return level; - } - - function _ensureSellLevel(uint256 _price) internal returns (PriceLevel storage) { - PriceLevel storage level = sellLevels[_price]; - if (!level.exists) { - level.exists = true; - level.price = _price; - _insertSellLevel(_price); - } - return level; - } - - function _insertBuyLevel(uint256 _price) internal { - if (bestBidPrice == 0) { - bestBidPrice = _price; - return; - } - - if (_price > bestBidPrice) { - PriceLevel storage currentBest = buyLevels[bestBidPrice]; - currentBest.prev = _price; - PriceLevel storage newBest = buyLevels[_price]; - newBest.next = bestBidPrice; - bestBidPrice = _price; - return; - } - - uint256 cursor = bestBidPrice; - while (true) { - PriceLevel storage cursorLevel = buyLevels[cursor]; - uint256 nextPrice = cursorLevel.next; - - if (nextPrice == 0 || nextPrice < _price) { - cursorLevel.next = _price; - PriceLevel storage newLevel = buyLevels[_price]; - newLevel.prev = cursor; - newLevel.next = nextPrice; - if (nextPrice != 0) { - buyLevels[nextPrice].prev = _price; - } - break; - } - cursor = nextPrice; - } - } - - function _insertSellLevel(uint256 _price) internal { - if (bestAskPrice == 0) { - bestAskPrice = _price; - return; - } - - if (_price < bestAskPrice) { - PriceLevel storage currentBest = sellLevels[bestAskPrice]; - currentBest.prev = _price; - PriceLevel storage newBest = sellLevels[_price]; - newBest.next = bestAskPrice; - bestAskPrice = _price; - return; - } - - uint256 cursor = bestAskPrice; - while (true) { - PriceLevel storage cursorLevel = sellLevels[cursor]; - uint256 nextPrice = cursorLevel.next; - - if (nextPrice == 0 || nextPrice > _price) { - cursorLevel.next = _price; - PriceLevel storage newLevel = sellLevels[_price]; - newLevel.prev = cursor; - newLevel.next = nextPrice; - if (nextPrice != 0) { - sellLevels[nextPrice].prev = _price; - } - break; - } - cursor = nextPrice; - } - } - - function _removeOrderFromLevel(bytes32 _orderId) internal { - Order storage order = orders[_orderId]; - PriceLevel storage level = order.isBuySide ? buyLevels[order.price] : sellLevels[order.price]; - - if (order.amount > 0 && level.volume >= order.amount) { - level.volume -= order.amount; - } - - bytes32 prevId = order.prev; - bytes32 nextId = order.next; - - if (prevId == bytes32(0)) { - level.head = nextId; - } else { - orders[prevId].next = nextId; - } - - if (nextId == bytes32(0)) { - level.tail = prevId; - } else { - orders[nextId].prev = prevId; - } - - order.next = bytes32(0); - order.prev = bytes32(0); - - if (level.head == bytes32(0)) { - if (order.isBuySide) { - _removeBuyLevel(order.price); - } else { - _removeSellLevel(order.price); - } - } - } - - function _removeBuyLevel(uint256 _price) internal { - PriceLevel storage level = buyLevels[_price]; - uint256 prevPrice = level.prev; - uint256 nextPrice = level.next; - - if (prevPrice == 0) { - bestBidPrice = nextPrice; - } else { - buyLevels[prevPrice].next = nextPrice; - } - if (nextPrice != 0) { - buyLevels[nextPrice].prev = prevPrice; - } - delete buyLevels[_price]; - } - - function _removeSellLevel(uint256 _price) internal { - PriceLevel storage level = sellLevels[_price]; - uint256 prevPrice = level.prev; - uint256 nextPrice = level.next; - - if (prevPrice == 0) { - bestAskPrice = nextPrice; - } else { - sellLevels[prevPrice].next = nextPrice; - } - if (nextPrice != 0) { - sellLevels[nextPrice].prev = prevPrice; - } - delete sellLevels[_price]; - } - - function _fetchOrders(bool isBuySide, address broker, address investor) internal view returns (Order[] memory) { - uint256 count = 0; - uint256 currentPrice = isBuySide ? bestBidPrice : bestAskPrice; - uint256 priceCursor = currentPrice; - while (priceCursor != 0) { - PriceLevel storage level = isBuySide ? buyLevels[priceCursor] : sellLevels[priceCursor]; - bytes32 orderId = level.head; - while (orderId != bytes32(0)) { - Order storage order = orders[orderId]; - if ( - (broker == address(0) || order.broker == broker) - && (investor == address(0) || order.investorSecAddr == investor) - ) { - count++; - } - orderId = order.next; - } - priceCursor = level.next; - } - - Order[] memory result = new Order[](count); - if (count == 0) return result; - - uint256 i = 0; - priceCursor = currentPrice; - while (priceCursor != 0) { - PriceLevel storage level = isBuySide ? buyLevels[priceCursor] : sellLevels[priceCursor]; - bytes32 orderId = level.head; - while (orderId != bytes32(0)) { - Order storage order = orders[orderId]; - if ( - (broker == address(0) || order.broker == broker) - && (investor == address(0) || order.investorSecAddr == investor) - ) { - result[i] = order; - i++; - } - orderId = order.next; - } - priceCursor = level.next; - } - return result; - } -} diff --git a/contracts/src/norges-bank/BondOrderBookFactory.sol b/contracts/src/norges-bank/BondOrderBookFactory.sol deleted file mode 100644 index 8f69810..0000000 --- a/contracts/src/norges-bank/BondOrderBookFactory.sol +++ /dev/null @@ -1,91 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -pragma solidity ^0.8.29; - -import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; - -import {BondOrderBook} from "@norges-bank/BondOrderBook.sol"; - -import {Errors} from "@common/Errors.sol"; -import {Roles} from "@common/Roles.sol"; - -/** - * @title BondOrderBookFactory - * @notice Deploys one BondOrderBook per (bondToken, partition) pair using CREATE2 for determinism. - */ -contract BondOrderBookFactory is AccessControl { - /** - * @notice Address of the Tbd contract (cash leg) - */ - address public immutable TBD; - /** - * @notice Admin for deployed order books - */ - address public immutable ADMIN; - - /** - * @dev key = keccak256(abi.encode(bondToken, partition)) - */ - mapping(bytes32 => address) public getOrderBook; - address[] public allOrderBooks; - - event BondOrderBookCreated(address indexed bondToken, bytes32 indexed partition, address indexed orderBook); - - constructor(address _admin, address _tbd) { - if (_admin == address(0)) revert Errors.AdminAddressZero(); - if (_tbd == address(0)) revert Errors.TbdAddressZero(); - ADMIN = _admin; - TBD = _tbd; - _grantRole(DEFAULT_ADMIN_ROLE, _admin); - _grantRole(Roles.ORDER_ADMIN_ROLE, _admin); - } - - function allOrderBooksLength() external view returns (uint256) { - return allOrderBooks.length; - } - - /** - * @notice Deploy a bond order book for a specific bond partition. - * @param bondToken Address of the BondToken (ERC1410) contract. - * @param partition Partition (ISIN) identifier. - * @return orderBook deployed address. - */ - function createBondOrderBook(address bondToken, bytes32 partition) - external - onlyRole(Roles.ORDER_ADMIN_ROLE) - returns (address orderBook) - { - if (bondToken == address(0)) revert Errors.BondTokenAddressZero(); - if (partition == bytes32(0)) revert Errors.PartitionZero(); - - // forge-lint: disable-next-line(asm-keccak256) - bytes32 salt = keccak256(abi.encode(bondToken, partition)); - orderBook = getOrderBook[salt]; - if (orderBook != address(0)) revert Errors.DuplicateOrderBook(bondToken); - - bytes memory bytecode = - abi.encodePacked(type(BondOrderBook).creationCode, abi.encode(ADMIN, TBD, bondToken, partition)); - - assembly { - orderBook := create2(0, add(bytecode, 32), mload(bytecode), salt) - } - if (orderBook == address(0)) revert Errors.OrderBookAddressZero(); - - getOrderBook[salt] = orderBook; - allOrderBooks.push(orderBook); - - emit BondOrderBookCreated(bondToken, partition, orderBook); - } - - /** - * @notice Precompute the order book address for (bondToken, partition). - */ - function computeBondOrderBookAddress(address bondToken, bytes32 partition) external view returns (address) { - // forge-lint: disable-next-line(asm-keccak256) - bytes32 key = keccak256(abi.encode(bondToken, partition)); - bytes memory bytecode = - abi.encodePacked(type(BondOrderBook).creationCode, abi.encode(ADMIN, TBD, bondToken, partition)); - // forge-lint: disable-next-line(asm-keccak256) - bytes32 hash = keccak256(abi.encodePacked(bytes1(0xff), address(this), key, keccak256(bytecode))); - return address(uint160(uint256(hash))); - } -} diff --git a/contracts/src/norges-bank/BondToken.sol b/contracts/src/norges-bank/BondToken.sol index cc4dce6..770c09d 100644 --- a/contracts/src/norges-bank/BondToken.sol +++ b/contracts/src/norges-bank/BondToken.sol @@ -88,13 +88,14 @@ contract BondToken is IBondToken, ERC1410, AccessControl { } /** - * @notice Add new controller - * @dev To add dedicated manager role - * @param _controller of new controller + * @notice Add an ERC-1410 controller + * @dev A controller is an operator for every holder, so it can move any holder's units + * through `operatorTransferByPartition`. This grants no lifecycle rights; those come + * from BOND_CONTROLLER_ROLE, granted separately with `grantRole`. + * @param _controller Address of the new controller */ function addController(address _controller) external onlyRole(Roles.BOND_ADMIN_ROLE) { if (_controller == address(0)) revert Errors.ControllerAddressZero(); - _grantRole(Roles.BOND_CONTROLLER_ROLE, _controller); if (_isController[_controller]) { return; @@ -109,6 +110,28 @@ contract BondToken is IBondToken, ERC1410, AccessControl { _setControllers(newControllers); } + /** + * @notice Remove an ERC-1410 controller + * @dev Leaves BOND_CONTROLLER_ROLE untouched; a no-op when the address is not a controller. + * @param _controller Address of the controller to remove + */ + function removeController(address _controller) external onlyRole(Roles.BOND_ADMIN_ROLE) { + if (!_isController[_controller]) { + return; + } + + address[] memory newControllers = new address[](_controllers.length - 1); + uint256 j = 0; + for (uint256 i = 0; i < _controllers.length; i++) { + if (_controllers[i] != _controller) { + newControllers[j] = _controllers[i]; + j++; + } + } + + _setControllers(newControllers); + } + function supportsInterface(bytes4 interfaceId) public view diff --git a/contracts/src/norges-bank/interfaces/IBondToken.sol b/contracts/src/norges-bank/interfaces/IBondToken.sol index b6ced22..591af59 100644 --- a/contracts/src/norges-bank/interfaces/IBondToken.sol +++ b/contracts/src/norges-bank/interfaces/IBondToken.sol @@ -39,6 +39,8 @@ interface IBondToken is IERC1410 { function addController(address _controller) external; + function removeController(address _controller) external; + function isinToPartition(string memory _isin) external pure returns (bytes32 partition); function partitionToIsin(bytes32 partition) external view returns (string memory); diff --git a/contracts/test/broker/Broker.t.sol b/contracts/test/broker/Broker.t.sol index 7e905b1..4d7badb 100644 --- a/contracts/test/broker/Broker.t.sol +++ b/contracts/test/broker/Broker.t.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.29; import {Broker} from "@broker/Broker.sol"; +import {Errors} from "@common/Errors.sol"; import {SettlementInfo} from "@common/SettlementInfo.sol"; import {OrderBook} from "@csd/OrderBook.sol"; import {Test} from "forge-std/Test.sol"; @@ -21,6 +22,7 @@ contract BrokerTest is Test { address secToken = address(0x3); address tbdWallet = address(0x4); address secWallet = address(0x5); + address otherSecWallet = address(0x6); address unregistered = address(0x8); address orderBookAddr = address(0x9); @@ -123,9 +125,10 @@ contract BrokerTest is Test { } /** - * @notice Registered client can revoke a buy order, forwarded to OrderBook. + * @notice Registered client can revoke its own buy order, forwarded to OrderBook. */ function test_revokeBuyOrder_forwardedCorrectly() public { + _mockOrderOwnedBy(secWallet, true); vm.prank(client); vm.mockCall(orderBookAddr, abi.encodeWithSelector(OrderBook.revokeBuyOrder.selector, orderId), abi.encode(true)); bool success = broker.revokeBuyOrder(orderId); @@ -133,9 +136,10 @@ contract BrokerTest is Test { } /** - * @notice Registered client can revoke a sell order, forwarded to OrderBook. + * @notice Registered client can revoke its own sell order, forwarded to OrderBook. */ function test_revokeSellOrder_forwardedCorrectly() public { + _mockOrderOwnedBy(secWallet, false); vm.prank(client); vm.mockCall( orderBookAddr, abi.encodeWithSelector(OrderBook.revokeSellOrder.selector, orderId), abi.encode(true) @@ -144,6 +148,43 @@ contract BrokerTest is Test { assertTrue(success); } + /** + * @notice A client cannot revoke a buy order placed for another client's securities wallet. + */ + function test_RevokeBuyOrder_RevertIf_OrderBelongsToAnotherClient() public { + _mockOrderOwnedBy(otherSecWallet, true); + vm.expectRevert(abi.encodeWithSelector(Errors.OrderNotOwnedByClient.selector, orderId)); + vm.prank(client); + broker.revokeBuyOrder(orderId); + } + + /** + * @notice A client cannot revoke a sell order placed for another client's securities wallet. + */ + function test_RevokeSellOrder_RevertIf_OrderBelongsToAnotherClient() public { + _mockOrderOwnedBy(otherSecWallet, false); + vm.expectRevert(abi.encodeWithSelector(Errors.OrderNotOwnedByClient.selector, orderId)); + vm.prank(client); + broker.revokeSellOrder(orderId); + } + + function _mockOrderOwnedBy(address investorSecAddr, bool isBuySide) internal { + IOrderBook.Order memory order = IOrderBook.Order({ + id: orderId, + broker: address(broker), + investorSecAddr: investorSecAddr, + secContrAddr: secToken, + amount: 1, + price: 1, + investorTbdAddr: tbdWallet, + tbdContrAddr: brokerBank, + isBuySide: isBuySide, + next: bytes32(0), + prev: bytes32(0) + }); + vm.mockCall(orderBookAddr, abi.encodeWithSelector(OrderBook.getOrder.selector, orderId), abi.encode(order)); + } + /// @notice Unregistered client calling buy should revert function test_revertIf_buy_unregisteredClient() public { vm.expectRevert(); diff --git a/contracts/test/integration/BondLifecycle.t.sol b/contracts/test/integration/BondLifecycle.t.sol index b3628aa..11d7287 100644 --- a/contracts/test/integration/BondLifecycle.t.sol +++ b/contracts/test/integration/BondLifecycle.t.sol @@ -83,7 +83,6 @@ contract BondLifecycleIntegrationTest is Test, AuctionHelper { bondToken.grantRole(Roles.BOND_CONTROLLER_ROLE, address(bondManager)); bondToken.grantRole(Roles.BOND_CONTROLLER_ROLE, address(bondDvp)); bondToken.grantRole(Roles.DEFAULT_ADMIN_ROLE, deployer); - bondToken.addController(address(bondManager)); bondToken.addController(address(bondDvp)); bondManager.grantRole(Roles.BOND_MANAGER_ROLE, bondAdmin); diff --git a/contracts/test/norges-bank/BondManager.t.sol b/contracts/test/norges-bank/BondManager.t.sol index 78a0dbe..327a1f1 100644 --- a/contracts/test/norges-bank/BondManager.t.sol +++ b/contracts/test/norges-bank/BondManager.t.sol @@ -94,7 +94,6 @@ contract BondManagerTest is Test, AuctionHelper { bondToken.grantRole(Roles.BOND_CONTROLLER_ROLE, address(bondManager)); bondToken.grantRole(Roles.BOND_CONTROLLER_ROLE, address(bondDvp)); bondToken.grantRole(Roles.DEFAULT_ADMIN_ROLE, deployer); - bondToken.addController(address(bondManager)); bondToken.addController(address(bondDvp)); bytes32 bondManagerRole = Roles.BOND_MANAGER_ROLE; diff --git a/contracts/test/norges-bank/BondManagerConstructor.t.sol b/contracts/test/norges-bank/BondManagerConstructor.t.sol new file mode 100644 index 0000000..83708e9 --- /dev/null +++ b/contracts/test/norges-bank/BondManagerConstructor.t.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.29; + +import {Test} from "forge-std/Test.sol"; +import {BondManager} from "@norges-bank/BondManager.sol"; +import {Errors} from "@common/Errors.sol"; + +/** + * Constructor address checks for BondManager, kept apart from BondManager.t.sol, which sits at + * the via-IR size limit. Every check runs before the constructor calls into BondToken, so plain + * placeholder addresses are enough. + */ +contract BondManagerConstructorTest is Test { + struct Args { + address wNok; + address controller; + address bondAuction; + address bondToken; + address bondDvp; + address govReserve; + } + + uint256 constant DURATION_SCALAR = 1; + + function _validArgs() internal pure returns (Args memory) { + return Args({ + wNok: address(0x1), + controller: address(0x2), + bondAuction: address(0x3), + bondToken: address(0x4), + bondDvp: address(0x5), + govReserve: address(0x6) + }); + } + + function _deploy(Args memory a) internal returns (BondManager) { + return new BondManager( + "Bond Manager", a.wNok, a.controller, a.bondAuction, a.bondToken, a.bondDvp, a.govReserve, DURATION_SCALAR + ); + } + + function test_Constructor_RevertIf_WnokAddressZero() public { + Args memory a = _validArgs(); + a.wNok = address(0); + vm.expectRevert(Errors.WnokAddressZero.selector); + _deploy(a); + } + + function test_Constructor_RevertIf_ControllerAddressZero() public { + Args memory a = _validArgs(); + a.controller = address(0); + vm.expectRevert(Errors.ControllerAddressZero.selector); + _deploy(a); + } + + function test_Constructor_RevertIf_BondAuctionAddressZero() public { + Args memory a = _validArgs(); + a.bondAuction = address(0); + vm.expectRevert(Errors.BondAuctionAddressZero.selector); + _deploy(a); + } + + function test_Constructor_RevertIf_BondTokenAddressZero() public { + Args memory a = _validArgs(); + a.bondToken = address(0); + vm.expectRevert(Errors.BondTokenAddressZero.selector); + _deploy(a); + } + + function test_Constructor_RevertIf_BondDvpAddressZero() public { + Args memory a = _validArgs(); + a.bondDvp = address(0); + vm.expectRevert(Errors.DvpAddressZero.selector); + _deploy(a); + } +} diff --git a/contracts/test/norges-bank/BondOrderBook.t.sol b/contracts/test/norges-bank/BondOrderBook.t.sol deleted file mode 100644 index bbf7b15..0000000 --- a/contracts/test/norges-bank/BondOrderBook.t.sol +++ /dev/null @@ -1,382 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -pragma solidity ^0.8.29; - -import {Test} from "forge-std/Test.sol"; -import {BondOrderBook} from "@norges-bank/BondOrderBook.sol"; -import {BondToken} from "@norges-bank/BondToken.sol"; -import {Wnok} from "@norges-bank/Wnok.sol"; -import {Tbd} from "@private-bank/Tbd.sol"; -import {Roles} from "@common/Roles.sol"; -import {Errors} from "@common/Errors.sol"; - -contract BondOrderBookHarness is BondOrderBook { - constructor(address admin, address tbd, address bondToken, bytes32 partition) - BondOrderBook(admin, tbd, bondToken, partition) - {} - - function getBuyLevelVolume(uint256 price) external view returns (uint256) { - return buyLevels[price].volume; - } - - function getSellLevelVolume(uint256 price) external view returns (uint256) { - return sellLevels[price].volume; - } -} - -contract BondOrderBookTest is Test { - BondOrderBookHarness orderBook; - BondToken bondToken; - Wnok wnok; - Tbd tbd; - - address admin = address(this); - address buyer = address(0x1); - address seller = address(0x2); - address payer = address(0x3); - address payee = address(0x4); - address otherBroker = address(0x5); - address otherBuyer = address(0x6); - - bytes32 constant PARTITION = keccak256("ISIN1"); - string constant ISIN = "ISIN1"; - uint256 constant ASK_PRICE = 10; - uint256 constant BID_PRICE = 10; - uint256 constant UNITS = 3; - uint256 constant CASH_PER_UNIT = 1000; - - function setUp() public { - wnok = new Wnok(admin, "Wholesale NOK", "WNOK"); - tbd = new Tbd(admin, address(0xAA), address(wnok), address(0xBB), "TBD", "TBD"); - bondToken = new BondToken("Bond Token", "BOND"); - - // Roles and partition - bondToken.grantRole(Roles.BOND_CONTROLLER_ROLE, admin); - bondToken.createPartition(ISIN, 1_000_000, 365 days); - bondToken.mintByIsin(ISIN, seller, UNITS); - - // Order book - orderBook = new BondOrderBookHarness(admin, address(tbd), address(bondToken), PARTITION); - orderBook.grantRole(Roles.SUBMIT_ORDER_ROLE, admin); - - // Allowlist Tbd participants - tbd.add(admin); - tbd.add(buyer); - tbd.add(seller); - tbd.add(payer); - tbd.add(payee); - tbd.add(address(orderBook)); - tbd.add(otherBroker); - tbd.add(otherBuyer); - - // Authorize operator for bond transfers - vm.prank(seller); - bondToken.authorizeOperator(address(orderBook)); - - // Fund and approve cash leg - tbd.mint(payer, UNITS * CASH_PER_UNIT); - vm.prank(payer); - tbd.approve(address(orderBook), type(uint256).max); - - // Seller also can receive cash - vm.prank(payee); - tbd.approve(address(orderBook), type(uint256).max); - } - - function test_constructor_adminHasDefaultAdmin() public view { - assertTrue(orderBook.hasRole(Roles.DEFAULT_ADMIN_ROLE, admin)); - } - - function test_buy_unmatched_addsOrder() public { - bytes32 orderId = orderBook.buy(address(bondToken), 1, BID_PRICE, buyer, payer); - assertTrue(orderId != bytes32(0)); - BondOrderBook.Order[] memory buyOrders = orderBook.getBuyOrders(buyer); - assertEq(buyOrders.length, 1); - assertEq(buyOrders[0].amount, 1); - } - - function test_sell_unmatched_addsOrder() public { - bytes32 orderId = orderBook.sell(address(bondToken), 1, ASK_PRICE, seller, payee); - assertTrue(orderId != bytes32(0)); - BondOrderBook.Order[] memory sellOrders = orderBook.getSellOrders(seller); - assertEq(sellOrders.length, 1); - assertEq(sellOrders[0].amount, 1); - } - - function test_buy_matchesSell_andRemovesBoth() public { - // list a sell - orderBook.sell(address(bondToken), 1, ASK_PRICE, seller, payee); - // buyer needs bonds to stay unchanged; buyer receives bonds - // place buy at or above ask - bytes32 buyId = orderBook.buy(address(bondToken), 1, BID_PRICE, buyer, payer); - assertTrue(buyId != bytes32(0)); - assertEq(orderBook.getBuyOrders(buyer).length, 0); - assertEq(orderBook.getSellOrders(seller).length, 0); - assertEq(bondToken.balanceOfByPartition(PARTITION, buyer), 1); - assertEq(tbd.balanceOf(payer), UNITS * CASH_PER_UNIT - ASK_PRICE); - assertEq(tbd.balanceOf(payee), ASK_PRICE); - } - - function test_sell_matchesBuy_andRemovesBoth() public { - // fund buyer - tbd.mint(payer, CASH_PER_UNIT); - vm.prank(payer); - tbd.approve(address(orderBook), type(uint256).max); - - // create buy - orderBook.buy(address(bondToken), 1, BID_PRICE, buyer, payer); - // seller sells - bytes32 sellId = orderBook.sell(address(bondToken), 1, ASK_PRICE, seller, payee); - assertTrue(sellId != bytes32(0)); - assertEq(orderBook.getBuyOrders(buyer).length, 0); - assertEq(orderBook.getSellOrders(seller).length, 0); - assertEq(bondToken.balanceOfByPartition(PARTITION, buyer), 1); - assertEq(tbd.balanceOf(payee), ASK_PRICE); - } - - function test_partialFill_buyLeavesRemainder() public { - // seller lists 1 unit - orderBook.sell(address(bondToken), 1, ASK_PRICE, seller, payee); - // buy 3 units: matches 1, leaves 2 in book - orderBook.buy(address(bondToken), 3, BID_PRICE, buyer, payer); - BondOrderBook.Order[] memory buys = orderBook.getBuyOrders(buyer); - assertEq(buys.length, 1); - assertEq(buys[0].amount, 2); - assertEq(orderBook.getSellOrders(seller).length, 0); - } - - function test_revokeBuyOrder() public { - bytes32 orderId = orderBook.buy(address(bondToken), 1, BID_PRICE, buyer, payer); - assertEq(orderBook.getBuyOrders(buyer).length, 1); - orderBook.revokeBuyOrder(orderId); - assertEq(orderBook.getBuyOrders(buyer).length, 0); - } - - function test_revokeSellOrder() public { - bytes32 orderId = orderBook.sell(address(bondToken), 1, ASK_PRICE, seller, payee); - assertEq(orderBook.getSellOrders(seller).length, 1); - orderBook.revokeSellOrder(orderId); - assertEq(orderBook.getSellOrders(seller).length, 0); - } - - function test_revokeBuyOrder_revertIf_idNotFound() public { - vm.expectRevert(Errors.OrderNotFound.selector); - // forge-lint: disable-next-line(unsafe-typecast) - orderBook.revokeBuyOrder(bytes32("fake")); - } - - function test_revokeSellOrder_revertIf_idNotFound() public { - vm.expectRevert(Errors.OrderNotFound.selector); - // forge-lint: disable-next-line(unsafe-typecast) - orderBook.revokeSellOrder(bytes32("fake")); - } - - function test_revokeBuyOrder_revertIf_otherBroker() public { - bytes32 orderId = orderBook.buy(address(bondToken), 1, BID_PRICE, buyer, payer); - orderBook.grantRole(Roles.SUBMIT_ORDER_ROLE, otherBroker); - vm.prank(otherBroker); - vm.expectRevert(Errors.UnauthorizedBroker.selector); - orderBook.revokeBuyOrder(orderId); - } - - function test_revokeSellOrder_revertIf_otherBroker() public { - bytes32 orderId = orderBook.sell(address(bondToken), 1, ASK_PRICE, seller, payee); - orderBook.grantRole(Roles.SUBMIT_ORDER_ROLE, otherBroker); - vm.prank(otherBroker); - vm.expectRevert(Errors.UnauthorizedBroker.selector); - orderBook.revokeSellOrder(orderId); - } - - function test_revoke_generic() public { - bytes32 orderId = orderBook.buy(address(bondToken), 1, BID_PRICE, buyer, payer); - assertEq(orderBook.getBuyOrders(buyer).length, 1); - orderBook.revoke(orderId); - assertEq(orderBook.getBuyOrders(buyer).length, 0); - } - - function test_getBuyOrders_filtersByBroker() public { - // broker admin places two orders - orderBook.buy(address(bondToken), 1, BID_PRICE, buyer, payer); - orderBook.buy(address(bondToken), 1, BID_PRICE, buyer, payer); - // other broker places one - orderBook.grantRole(Roles.SUBMIT_ORDER_ROLE, otherBroker); - vm.startPrank(otherBroker); - orderBook.buy(address(bondToken), 1, BID_PRICE, otherBuyer, payer); - BondOrderBook.Order[] memory otherOrders = orderBook.getBuyOrders(); - vm.stopPrank(); - assertEq(otherOrders.length, 1); - BondOrderBook.Order[] memory adminOrders = orderBook.getBuyOrders(); - assertEq(adminOrders.length, 2); - } - - function test_initializeSellOrders_revertIf_nonAdmin() public { - orderBook.grantRole(Roles.SUBMIT_ORDER_ROLE, otherBroker); - vm.prank(otherBroker); - vm.expectRevert(); - orderBook.initializeSellOrders(1, ASK_PRICE, address(bondToken), address(tbd), seller, payee); - } - - function test_cashLegFails_storesOrder() public { - // break cash leg by removing payer from allowlist - tbd.remove(payer); - orderBook.sell(address(bondToken), 1, ASK_PRICE, seller, payee); - bytes32 orderId = orderBook.buy(address(bondToken), 1, BID_PRICE, buyer, payer); - BondOrderBook.Order[] memory buys = orderBook.getBuyOrders(buyer); - assertEq(buys.length, 1); - assertEq(buys[0].id, orderId); - assertEq(buys[0].amount, 1); - } - - function test_securityLegFails_storesOrder() public { - // seller only has 3 units; sell 5 to trigger insufficient balance - orderBook.buy(address(bondToken), 5, BID_PRICE, buyer, payer); - bytes32 orderId = orderBook.sell(address(bondToken), 5, ASK_PRICE, seller, payee); - BondOrderBook.Order[] memory sells = orderBook.getSellOrders(seller); - assertEq(sells.length, 1); - assertEq(sells[0].id, orderId); - assertEq(sells[0].amount, 5); - } - - function test_partialFill_sellLeavesRemainder() public { - // give seller enough supply - bondToken.mintByIsin(ISIN, seller, 5); - // two buys of 2 units each - orderBook.buy(address(bondToken), 2, BID_PRICE, buyer, payer); - orderBook.buy(address(bondToken), 2, BID_PRICE, otherBuyer, payer); - // seller sells 5 units -> matches 4, leaves 1 sell in book - orderBook.sell(address(bondToken), 5, ASK_PRICE, seller, payee); - BondOrderBook.Order[] memory sells = orderBook.getSellOrders(seller); - assertEq(sells.length, 1); - assertEq(sells[0].amount, 1); - // volume check - assertEq(orderBook.getSellLevelVolume(ASK_PRICE), 1); - assertEq(orderBook.getBuyOrders(buyer).length + orderBook.getBuyOrders(otherBuyer).length, 0); - } - - function test_volumeDecrementsOnRevoke() public { - bytes32 id1 = orderBook.buy(address(bondToken), 1, BID_PRICE, buyer, payer); - orderBook.buy(address(bondToken), 1, BID_PRICE, buyer, payer); - assertEq(orderBook.getBuyLevelVolume(BID_PRICE), 2); - orderBook.revokeBuyOrder(id1); - assertEq(orderBook.getBuyLevelVolume(BID_PRICE), 1); - } - - function test_cashAmountEqualsPriceTimesUnits() public { - uint256 units = 3; - orderBook.sell(address(bondToken), units, ASK_PRICE, seller, payee); - uint256 payerBefore = tbd.balanceOf(payer); - uint256 payeeBefore = tbd.balanceOf(payee); - orderBook.buy(address(bondToken), units, BID_PRICE, buyer, payer); - assertEq(tbd.balanceOf(payee) - payeeBefore, ASK_PRICE * units); - assertEq(payerBefore - tbd.balanceOf(payer), ASK_PRICE * units); - } - - function test_initializeSellOrders() public { - uint256 numOrders = 5; - orderBook.initializeSellOrders(numOrders, ASK_PRICE, address(bondToken), address(tbd), seller, payee); - BondOrderBook.Order[] memory sells = orderBook.getAllSellOrders(); - assertEq(sells.length, numOrders); - } - - function test_getAllBuyOrders_returnsAll() public { - orderBook.buy(address(bondToken), 1, BID_PRICE, buyer, payer); - orderBook.buy(address(bondToken), 1, BID_PRICE, otherBuyer, payer); - BondOrderBook.Order[] memory allBuys = orderBook.getAllBuyOrders(); - assertEq(allBuys.length, 2); - } - - function test_getAllSellOrders_returnsAll() public { - orderBook.sell(address(bondToken), 1, ASK_PRICE, seller, payee); - orderBook.sell(address(bondToken), 1, ASK_PRICE, otherBuyer, payee); - BondOrderBook.Order[] memory allSells = orderBook.getAllSellOrders(); - assertEq(allSells.length, 2); - } - - function test_sellVolumeDecrementsOnRevoke() public { - bytes32 id1 = orderBook.sell(address(bondToken), 1, ASK_PRICE, seller, payee); - orderBook.sell(address(bondToken), 1, ASK_PRICE, seller, payee); - assertEq(orderBook.getSellLevelVolume(ASK_PRICE), 2); - orderBook.revokeSellOrder(id1); - assertEq(orderBook.getSellLevelVolume(ASK_PRICE), 1); - } - - function test_revertIf_SecurityMismatch() public { - vm.expectRevert(Errors.SecurityMismatch.selector); - orderBook.buy(address(0xdead), 1, BID_PRICE, buyer, payer); - vm.expectRevert(Errors.SecurityMismatch.selector); - orderBook.sell(address(0xdead), 1, ASK_PRICE, seller, payee); - } - - function test_revertIf_InvalidAmountOrPrice() public { - vm.expectRevert(Errors.InvalidAmount.selector); - orderBook.buy(address(bondToken), 0, BID_PRICE, buyer, payer); - vm.expectRevert(Errors.InvalidPrice.selector); - orderBook.buy(address(bondToken), 1, 0, buyer, payer); - vm.expectRevert(Errors.InvalidAmount.selector); - orderBook.sell(address(bondToken), 0, ASK_PRICE, seller, payee); - vm.expectRevert(Errors.InvalidPrice.selector); - orderBook.sell(address(bondToken), 1, 0, seller, payee); - } - - function test_revertIf_ZeroAddresses() public { - vm.expectRevert(abi.encodeWithSelector(Errors.InvalidRecipient.selector)); - orderBook.buy(address(bondToken), 1, BID_PRICE, address(0), payer); - vm.expectRevert(abi.encodeWithSelector(Errors.InvalidHolder.selector, address(0))); - orderBook.buy(address(bondToken), 1, BID_PRICE, buyer, address(0)); - vm.expectRevert(abi.encodeWithSelector(Errors.InvalidHolder.selector, address(0))); - orderBook.sell(address(bondToken), 1, ASK_PRICE, address(0), payee); - vm.expectRevert(abi.encodeWithSelector(Errors.InvalidRecipient.selector)); - orderBook.sell(address(bondToken), 1, ASK_PRICE, seller, address(0)); - } - - function test_cashOnly_zeroUnits_noBondMovement() public { - vm.expectRevert(Errors.InvalidAmount.selector); - orderBook.sell(address(bondToken), 0, ASK_PRICE, seller, payee); - vm.expectRevert(Errors.InvalidAmount.selector); - orderBook.buy(address(bondToken), 0, BID_PRICE, buyer, payer); - } - - function test_buy_failureBuyerDropsAndStops() public { - // seller lists - orderBook.sell(address(bondToken), 1, ASK_PRICE, seller, payee); - // break buyer cash path - tbd.remove(payer); - // call should store failed buy and leave sell - orderBook.buy(address(bondToken), 1, BID_PRICE, buyer, payer); - assertEq(orderBook.getBuyOrders(buyer).length, 1); // stored due to failure - assertEq(orderBook.getSellOrders(seller).length, 1); // sell remains - } - - function test_sell_failureSellerStopsKeepsSell() public { - // revoke operator to make security leg fail - vm.prank(seller); - bondToken.revokeOperator(address(orderBook)); - // create buy to match - orderBook.buy(address(bondToken), 1, BID_PRICE, buyer, payer); - orderBook.sell(address(bondToken), 1, ASK_PRICE, seller, payee); - // sell should remain since seller failure stops - assertEq(orderBook.getSellOrders(seller).length, 1); - } - - function test_buy_failureSellerDropsMaker() public { - // two sells at same price - orderBook.sell(address(bondToken), 1, ASK_PRICE, seller, payee); - orderBook.sell(address(bondToken), 1, ASK_PRICE, seller, payee); - // break seller by zero bonds - vm.prank(seller); - bondToken.revokeOperator(address(orderBook)); - // buyer attempt should drop seller maker on failure - orderBook.buy(address(bondToken), 1, BID_PRICE, buyer, payer); - assertEq(orderBook.getSellOrders(seller).length, 0); - } - - function test_sell_failureBuyerDropsMaker() public { - // two buys - orderBook.buy(address(bondToken), 1, BID_PRICE, buyer, payer); - orderBook.buy(address(bondToken), 1, BID_PRICE, otherBuyer, payer); - // break payer allowlist (affects both) - tbd.remove(payer); - orderBook.sell(address(bondToken), 1, ASK_PRICE, seller, payee); - // both buys dropped - assertEq(orderBook.getBuyOrders(buyer).length + orderBook.getBuyOrders(otherBuyer).length, 0); - } -} diff --git a/contracts/test/norges-bank/BondOrderBookFactory.t.sol b/contracts/test/norges-bank/BondOrderBookFactory.t.sol deleted file mode 100644 index c83a902..0000000 --- a/contracts/test/norges-bank/BondOrderBookFactory.t.sol +++ /dev/null @@ -1,133 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -pragma solidity ^0.8.29; - -import {Test} from "forge-std/Test.sol"; -import {BondOrderBookFactory} from "@norges-bank/BondOrderBookFactory.sol"; -import {BondOrderBook} from "@norges-bank/BondOrderBook.sol"; -import {Errors} from "@common/Errors.sol"; - -contract BondOrderBookFactoryTest is Test { - BondOrderBookFactory factory; - - address admin = address(this); - address tbd = address(0x10); - address bondToken1 = address(0x20); - address bondToken2 = address(0x21); - // forge-lint: disable-next-line(unsafe-typecast) - bytes32 partition1 = bytes32("ISIN1"); - // forge-lint: disable-next-line(unsafe-typecast) - bytes32 partition2 = bytes32("ISIN2"); - address zero = address(0); - - function setUp() public { - factory = new BondOrderBookFactory(admin, tbd); - } - - // --- constructor --- - function test_constructor_SetsValues() public view { - assertEq(factory.ADMIN(), admin); - assertEq(factory.TBD(), tbd); - } - - function test_constructor_RevertIf_AdminZero() public { - vm.expectRevert(Errors.AdminAddressZero.selector); - new BondOrderBookFactory(zero, tbd); - } - - function test_constructor_RevertIf_TbdZero() public { - vm.expectRevert(Errors.TbdAddressZero.selector); - new BondOrderBookFactory(admin, zero); - } - - // --- create --- - function test_createBondOrderBook_Deploys() public { - address ob = factory.createBondOrderBook(bondToken1, partition1); - assertTrue(ob != address(0)); - assertEq(factory.getOrderBook(keccak256(abi.encode(bondToken1, partition1))), ob); - } - - function test_createBondOrderBook_SetsAdminRole() public { - address obAddr = factory.createBondOrderBook(bondToken1, partition1); - BondOrderBook ob = BondOrderBook(obAddr); - assertTrue(ob.hasRole(0x00, admin)); // DEFAULT_ADMIN_ROLE - } - - function test_createBondOrderBook_EmitsEvent() public { - address expected = factory.computeBondOrderBookAddress(bondToken1, partition1); - vm.expectEmit(true, true, false, false); - emit BondOrderBookFactory.BondOrderBookCreated(bondToken1, partition1, expected); - factory.createBondOrderBook(bondToken1, partition1); - } - - function test_createBondOrderBook_RevertIf_BondTokenZero() public { - vm.expectRevert(Errors.BondTokenAddressZero.selector); - factory.createBondOrderBook(zero, partition1); - } - - function test_createBondOrderBook_RevertIf_PartitionZero() public { - vm.expectRevert(Errors.PartitionZero.selector); - factory.createBondOrderBook(bondToken1, bytes32(0)); - } - - function test_createBondOrderBook_RevertIf_Duplicate() public { - factory.createBondOrderBook(bondToken1, partition1); - vm.expectRevert(abi.encodeWithSelector(Errors.DuplicateOrderBook.selector, bondToken1)); - factory.createBondOrderBook(bondToken1, partition1); - } - - function test_createBondOrderBook_MultiplePartitions() public { - address ob1 = factory.createBondOrderBook(bondToken1, partition1); - address ob2 = factory.createBondOrderBook(bondToken1, partition2); - address ob3 = factory.createBondOrderBook(bondToken2, partition1); - - assertTrue(ob1 != address(0)); - assertTrue(ob2 != address(0)); - assertTrue(ob3 != address(0)); - assertTrue(ob1 != ob2); - assertTrue(ob1 != ob3); - assertTrue(ob2 != ob3); - } - - // --- CREATE2 determinism --- - function test_computeBondOrderBookAddress_MatchesDeployed() public { - address computed = factory.computeBondOrderBookAddress(bondToken1, partition1); - address deployed = factory.createBondOrderBook(bondToken1, partition1); - assertEq(computed, deployed); - } - - function test_computeBondOrderBookAddress_DifferentKeysDifferentAddresses() public view { - address addr1 = factory.computeBondOrderBookAddress(bondToken1, partition1); - address addr2 = factory.computeBondOrderBookAddress(bondToken1, partition2); - address addr3 = factory.computeBondOrderBookAddress(bondToken2, partition1); - assertTrue(addr1 != addr2); - assertTrue(addr1 != addr3); - assertTrue(addr2 != addr3); - } - - function test_computeBondOrderBookAddress_DifferentFactoriesDiffer() public { - BondOrderBookFactory f2 = new BondOrderBookFactory(admin, tbd); - address addr1 = factory.computeBondOrderBookAddress(bondToken1, partition1); - address addr2 = f2.computeBondOrderBookAddress(bondToken1, partition1); - assertTrue(addr1 != addr2); - } - - // --- enumeration --- - function test_allOrderBooksLength_TracksDeployments() public { - assertEq(factory.allOrderBooksLength(), 0); - factory.createBondOrderBook(bondToken1, partition1); - assertEq(factory.allOrderBooksLength(), 1); - factory.createBondOrderBook(bondToken1, partition2); - assertEq(factory.allOrderBooksLength(), 2); - } - - function test_getOrderBook_ReturnsZeroIfMissing() public view { - bytes32 key = keccak256(abi.encode(bondToken1, partition1)); - assertEq(factory.getOrderBook(key), address(0)); - } - - function test_createBondOrderBook_RevertIfNotAdmin() public { - vm.prank(address(0x999)); - vm.expectRevert(); - factory.createBondOrderBook(bondToken1, partition1); - } -} diff --git a/contracts/test/norges-bank/BondToken.t.sol b/contracts/test/norges-bank/BondToken.t.sol index 241396a..1c72f20 100644 --- a/contracts/test/norges-bank/BondToken.t.sol +++ b/contracts/test/norges-bank/BondToken.t.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.29; import {Test, Vm} from "forge-std/Test.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; import {BondToken} from "@norges-bank/BondToken.sol"; import {Wnok} from "@norges-bank/Wnok.sol"; import {Errors} from "@common/Errors.sol"; @@ -249,17 +250,76 @@ contract BondTokenTest is Test { // ============ addController Tests ============ - function test_AddController_AddsRoleAndControllerList() public { + function test_AddController_AddsControllerWithoutLifecycleRole() public { address newController = address(0x3); bondToken.addController(newController); - assertTrue(bondToken.hasRole(Roles.BOND_CONTROLLER_ROLE, newController)); + assertTrue(bondToken.isController(newController)); + assertFalse(bondToken.hasRole(Roles.BOND_CONTROLLER_ROLE, newController)); address[] memory controllers = bondToken.controllers(); assertEq(controllers.length, 1); assertEq(controllers[0], newController); } + function test_RemoveController_RemovesOnlyThatController() public { + address first = address(0x3); + address second = address(0x4); + bondToken.addController(first); + bondToken.addController(second); + bondToken.grantRole(Roles.BOND_CONTROLLER_ROLE, first); + + bondToken.removeController(first); + + assertFalse(bondToken.isController(first)); + assertTrue(bondToken.isController(second)); + address[] memory controllers = bondToken.controllers(); + assertEq(controllers.length, 1); + assertEq(controllers[0], second); + // Lifecycle rights are managed separately and stay as they were. + assertTrue(bondToken.hasRole(Roles.BOND_CONTROLLER_ROLE, first)); + } + + function test_RemoveController_NoopWhenNotController() public { + address first = address(0x3); + bondToken.addController(first); + + bondToken.removeController(address(0x4)); + + address[] memory controllers = bondToken.controllers(); + assertEq(controllers.length, 1); + assertEq(controllers[0], first); + } + + function test_RemoveController_RevertIf_NotBondAdmin() public { + address first = address(0x3); + bondToken.addController(first); + + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, holder1, Roles.BOND_ADMIN_ROLE + ) + ); + vm.prank(holder1); + bondToken.removeController(first); + } + + function test_RemovedController_CannotMoveHolderUnits() public { + address operator = address(0x3); + vm.startPrank(controller); + bondToken.createPartition(ISIN, OFFERING, MATURITY_DURATION); + bondToken.mintByIsin(ISIN, holder1, 100); + vm.stopPrank(); + bytes32 partition = bondToken.isinToPartition(ISIN); + + bondToken.addController(operator); + bondToken.removeController(operator); + + vm.expectRevert(Errors.UnauthorizedOperator.selector); + vm.prank(operator); + bondToken.operatorTransferByPartition(partition, holder1, holder2, 10, "", ""); + } + function test_AddController_DeduplicatesExisting() public { address newController = address(0x3); bondToken.addController(newController); diff --git a/docs/diagrams/architecture/contract-topology.md b/docs/diagrams/architecture/contract-topology.md index 0397001..973c373 100644 --- a/docs/diagrams/architecture/contract-topology.md +++ b/docs/diagrams/architecture/contract-topology.md @@ -55,7 +55,7 @@ flowchart TB The bond and stock token models shown here are current. ADR 0002 adopts a future migration to canonical ERC-3643 for securities, but that implementation -has not landed. `OrderBookFactory`, `BondOrderBook`, and -`BondOrderBookFactory` exist in source but are not instantiated by the default -deployment scripts; the deployed secondary-market path uses a directly created -`OrderBook` for the stock token. +has not landed. `OrderBookFactory` exists in source but is not instantiated by +the default deployment scripts; the deployed secondary-market path uses a +directly created `OrderBook` for the stock token. There is no on-chain bond +secondary market. diff --git a/docs/plans/closed-loop-settlement-and-omnibus-custody-plan.md b/docs/plans/closed-loop-settlement-and-omnibus-custody-plan.md index 5e58e90..1e385c6 100644 --- a/docs/plans/closed-loop-settlement-and-omnibus-custody-plan.md +++ b/docs/plans/closed-loop-settlement-and-omnibus-custody-plan.md @@ -33,7 +33,7 @@ The closed-loop model: **wNOK is central-bank money; only allowlisted entities h - **The bond token already has the controller/operator/redeem surface** custody needs: `addController`/`isController`/`controllers`, `authorizeOperatorByPartition`/`operatorTransferByPartition`, `redeemFor`/`buybackRedeemFor` (`contracts/src/norges-bank/BondToken.sol`). The bond leg in `BondDvP._settleSecurityLeg` already moves partitions via `operatorTransferByPartition` — no change needed for Phase 1. - **Existing role wiring:** `contracts/script/norges-bank/11_BondSetup.s.sol:54` grants `SETTLE_ROLE` on BondDvP to BondManager; line **64** grants `TRANSFER_FROM_ROLE` on wNOK to BondDvP. That line 64 is the single wiring line Phase 1 changes. - **A two-tier money/custody model already exists for the customer side:** `Tbd` (tokenized bank deposit, `contracts/src/private-bank/Tbd.sol`) with `IERC1363Receiver.onTransferReceived`, and `ClientList` (`contracts/src/broker/ClientList.sol`) mapping clients → `tbdContrAddr`/`securitiesWallet`. The auction path does **not** use them — it treats the dealer as a bare wNOK wallet. Phases 2–6 reconcile this. -- **The secondary market presumes on-chain per-holder balances:** `BondOrderBook` (`contracts/src/norges-bank/BondOrderBook.sol`) is a holder-level on-chain limit book vs. wNOK — *incompatible with omnibus custody* (see Phase 5). +- **The secondary market presumed on-chain per-holder balances:** the removed holder-level `BondOrderBook` was an on-chain limit book vs. wNOK — *incompatible with omnibus custody*; no bond secondary venue exists now (see Phase 5). --- @@ -45,7 +45,7 @@ The closed-loop model: **wNOK is central-bank money; only allowlisted entities h | **2** | `PrimaryDealerRegistry` — vetted once, one source of truth | new registry; auction gate; wNOK allowlist wiring | ✅ | | **3** | Omnibus custody — `IBrokerage`/`BondBrokerage`/factory + off-chain sub-ledger service | new custody layer; issuance `bondTo` = broker | ✅ (MVP completes) | | **4** | Integrity — on-chain Merkle commitments of the broker sub-ledger | brokerage `commitSubLedger` + verification | later | -| **5** | Secondary venue refactor to broker-level (replaces holder-level `BondOrderBook`) | new venue; EIP-712 signed orders (open-loop cash) | later | +| **5** | Secondary venue refactor to broker-level (replaces the removed holder-level `BondOrderBook`) | new venue; EIP-712 signed orders (open-loop cash) | later | | **6** | Coupon & redemption — two-tier via gov bank → brokers; maturity reverse-DvP | coupon engine; `redeemFor`/`buybackRedeemFor` wiring | later | **Sequencing:** Phase 1 ships alone and fixes production. 2 + 3 give a working confidential closed-loop primary auction (the MVP). 4/5/6 depend on 3 and can reorder — recommend **6 before 5** (coupon is core lifecycle; secondary trading is later). @@ -204,7 +204,7 @@ PrimaryDealerRegistry (Phase 2), any custody/broker change (Phase 3+), coupon/re **Phase 4 — Integrity (Model B).** `commitSubLedger(merkleRoot, asOfBlock)` on the brokerage; periodic root commitment of `{client → balance}` ⇒ provable balances, detectable fraud, customer-portable proofs, without revealing balances to peers. -**Phase 5 — Secondary venue refactor.** Replace the holder-level `BondOrderBook` (which presumes segregated on-chain balances, incompatible with omnibus) with a **broker-level venue**: same-broker fills = off-chain book entries; cross-broker fills = on-chain DvP with **EIP-712 signed orders + open-loop cash authorization** (the legitimately-open-loop leg that *does* use signatures/allowances). +**Phase 5 — Secondary venue refactor.** In place of the removed holder-level `BondOrderBook` (which presumed segregated on-chain balances, incompatible with omnibus), build a **broker-level venue**: same-broker fills = off-chain book entries; cross-broker fills = on-chain DvP with **EIP-712 signed orders + open-loop cash authorization** (the legitimately-open-loop leg that *does* use signatures/allowances). **Phase 6 — Coupon & redemption.** _Note (2026-09-08): coupon, buyback, and redemption already settle in wNOK from the government reserve account (ADR 0004, `docs/plans/archive/bond-cash-leg-wnok/`), so this phase starts from a single token; the TBD leg described below is superseded. (2026-09-09) Maturity now closes the bond in one `payCoupon` (final coupon plus principal, all units burned, `BondMatured` emitted; ADR 0005), which is the "maturity reverse-DvP" below._ Coupon: CB debits the gov reserve (authority) → the gov's designated bank mints **backed** TBD (`Tbd._mintFromGovReserve` already backs it) → distributes **per broker** (fan-out scales) → brokers credit clients off-chain. Redemption at maturity: reverse DvP via `redeemFor`/`buybackRedeemFor` (burn bond ↔ return principal in wNOK by authority). diff --git a/services/nb-bond-api/DEVELOPMENT.md b/services/nb-bond-api/DEVELOPMENT.md index 49656fc..d34c400 100644 --- a/services/nb-bond-api/DEVELOPMENT.md +++ b/services/nb-bond-api/DEVELOPMENT.md @@ -21,7 +21,7 @@ The API is defined by the OpenAPI 3.1 document in `services/nb-bond-api/openapi. ### 1.2 What it does not do - It does not provide endpoints for dealers/investors to place bids. Bids are submitted on-chain to `BondAuction` using the CLIs under `scripts/` (see §5). -- It does not provide endpoints for secondary-market order placement. Scenario 3 trading is implemented on-chain via bond order book contracts, but is not driven through this OpenAPI surface. +- It does not provide endpoints for secondary-market order placement. Scenario 3 (secondary bond trading) has no on-chain venue in this repository. - It implements two configurable auth modes — `none` (sandbox default, header accepted but ignored) and `entra` (JWT validated against a Microsoft Entra ID tenant). See §7.7. The OpenAPI document always declares `bearerAuth`; the `none` mode is a runtime override for the sandbox. ## 2. Quickstart (run the service) @@ -301,7 +301,7 @@ Maturity: the final coupon payment (`coupon.payments.remaining == 1` before the ### 6.5 Scenario 3, secondary trading (note) -Scenario 3 trading is implemented on-chain (bond order book and settlement logic), but is not exposed through this OpenAPI service. If you need an HTTP interface for secondary trading, it should be designed as a separate work package (for example, an order placement API that wraps the on-chain `BondOrderBook` and enforces the correct authorisations and cash-token semantics). +Scenario 3 (secondary bond trading) has no on-chain venue in this repository and is not exposed through this OpenAPI service. A bond secondary market, and any HTTP interface for it, should be designed as a separate work package that enforces the correct authorisations and cash-token semantics. ## 7. Operational notes and troubleshooting diff --git a/services/nb-bond-api/src/abi/BondManager.json b/services/nb-bond-api/src/abi/BondManager.json index cbb8a5b..284b487 100644 --- a/services/nb-bond-api/src/abi/BondManager.json +++ b/services/nb-bond-api/src/abi/BondManager.json @@ -1 +1 @@ -{"abi":[{"type":"constructor","inputs":[{"name":"_name","type":"string","internalType":"string"},{"name":"_wNok","type":"address","internalType":"address"},{"name":"_controller","type":"address","internalType":"address"},{"name":"_bondAuction","type":"address","internalType":"address"},{"name":"_bondToken","type":"address","internalType":"address"},{"name":"_bondDvp","type":"address","internalType":"address"},{"name":"_govReserve","type":"address","internalType":"address"},{"name":"_durationScalar","type":"uint256","internalType":"uint256"}],"stateMutability":"nonpayable"},{"type":"function","name":"BOND_AUCTION","inputs":[],"outputs":[{"name":"","type":"address","internalType":"contract IBondAuction"}],"stateMutability":"view"},{"type":"function","name":"BOND_DVP","inputs":[],"outputs":[{"name":"","type":"address","internalType":"contract IBondDvP"}],"stateMutability":"view"},{"type":"function","name":"BOND_TOKEN","inputs":[],"outputs":[{"name":"","type":"address","internalType":"contract IBondToken"}],"stateMutability":"view"},{"type":"function","name":"DEFAULT_ADMIN_ROLE","inputs":[],"outputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"stateMutability":"view"},{"type":"function","name":"DURATION_SCALAR","inputs":[],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"GOV_RESERVE","inputs":[],"outputs":[{"name":"","type":"address","internalType":"address"}],"stateMutability":"view"},{"type":"function","name":"WNOK","inputs":[],"outputs":[{"name":"","type":"address","internalType":"address"}],"stateMutability":"view"},{"type":"function","name":"bondActive","inputs":[{"name":"","type":"string","internalType":"string"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"view"},{"type":"function","name":"buybackWithAuction","inputs":[{"name":"_isin","type":"string","internalType":"string"},{"name":"_end","type":"uint64","internalType":"uint64"},{"name":"_pubKey","type":"bytes","internalType":"bytes"},{"name":"_buybackSize","type":"uint256","internalType":"uint256"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"cancelAuction","inputs":[{"name":"_isin","type":"string","internalType":"string"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"closeAuction","inputs":[{"name":"_isin","type":"string","internalType":"string"}],"outputs":[{"name":"","type":"tuple[]","internalType":"struct IBondAuction.Bid[]","components":[{"name":"bidder","type":"address","internalType":"address"},{"name":"ciphertext","type":"bytes","internalType":"bytes"},{"name":"plaintextHash","type":"bytes32","internalType":"bytes32"}]}],"stateMutability":"nonpayable"},{"type":"function","name":"deployAuctionForBond","inputs":[{"name":"_isin","type":"string","internalType":"string"},{"name":"_end","type":"uint64","internalType":"uint64"},{"name":"_pubKey","type":"bytes","internalType":"bytes"},{"name":"_offering","type":"uint256","internalType":"uint256"},{"name":"_auctionType","type":"uint8","internalType":"enum IBondAuction.AuctionType"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"deployBond","inputs":[{"name":"_isin","type":"string","internalType":"string"},{"name":"_maturityDuration","type":"uint256","internalType":"uint256"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"deployBondWithAuction","inputs":[{"name":"_isin","type":"string","internalType":"string"},{"name":"_end","type":"uint64","internalType":"uint64"},{"name":"_pubKey","type":"bytes","internalType":"bytes"},{"name":"_offering","type":"uint256","internalType":"uint256"},{"name":"_maturityDuration","type":"uint256","internalType":"uint256"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"disableBond","inputs":[{"name":"_isin","type":"string","internalType":"string"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"extendBondWithAuction","inputs":[{"name":"_isin","type":"string","internalType":"string"},{"name":"_end","type":"uint64","internalType":"uint64"},{"name":"_pubKey","type":"bytes","internalType":"bytes"},{"name":"_additionalOffering","type":"uint256","internalType":"uint256"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"finaliseAuction","inputs":[{"name":"_isin","type":"string","internalType":"string"},{"name":"_alloc","type":"tuple[]","internalType":"struct IBondAuction.Allocation[]","components":[{"name":"isin","type":"string","internalType":"string"},{"name":"bidder","type":"address","internalType":"address"},{"name":"units","type":"uint256","internalType":"uint256"},{"name":"rate","type":"uint256","internalType":"uint256"},{"name":"auctionType","type":"uint8","internalType":"enum IBondAuction.AuctionType"}]},{"name":"_proofs","type":"tuple[]","internalType":"struct IBondAuction.BidVerification[]","components":[{"name":"bidIndex","type":"uint256","internalType":"uint256"},{"name":"bidderNonce","type":"uint256","internalType":"uint256"},{"name":"bidderSig","type":"bytes","internalType":"bytes"}]}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"getRoleAdmin","inputs":[{"name":"role","type":"bytes32","internalType":"bytes32"}],"outputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"stateMutability":"view"},{"type":"function","name":"getSealedBids","inputs":[{"name":"_isin","type":"string","internalType":"string"}],"outputs":[{"name":"","type":"tuple[]","internalType":"struct IBondAuction.Bid[]","components":[{"name":"bidder","type":"address","internalType":"address"},{"name":"ciphertext","type":"bytes","internalType":"bytes"},{"name":"plaintextHash","type":"bytes32","internalType":"bytes32"}]}],"stateMutability":"view"},{"type":"function","name":"grantRole","inputs":[{"name":"role","type":"bytes32","internalType":"bytes32"},{"name":"account","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"hasRole","inputs":[{"name":"role","type":"bytes32","internalType":"bytes32"},{"name":"account","type":"address","internalType":"address"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"view"},{"type":"function","name":"name","inputs":[],"outputs":[{"name":"","type":"string","internalType":"string"}],"stateMutability":"view"},{"type":"function","name":"payCoupon","inputs":[{"name":"_isin","type":"string","internalType":"string"},{"name":"_holders","type":"address[]","internalType":"address[]"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"renounceRole","inputs":[{"name":"role","type":"bytes32","internalType":"bytes32"},{"name":"callerConfirmation","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"revokeRole","inputs":[{"name":"role","type":"bytes32","internalType":"bytes32"},{"name":"account","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"supportsInterface","inputs":[{"name":"interfaceId","type":"bytes4","internalType":"bytes4"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"view"},{"type":"event","name":"BondAllocationFailed","inputs":[{"name":"id","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"isin","type":"string","indexed":false,"internalType":"string"},{"name":"bidder","type":"address","indexed":true,"internalType":"address"},{"name":"reason","type":"string","indexed":false,"internalType":"string"}],"anonymous":false},{"type":"event","name":"BondAuctionCancelled","inputs":[{"name":"id","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"isin","type":"string","indexed":false,"internalType":"string"},{"name":"offeringReduced","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"BondAuctionClosed","inputs":[{"name":"id","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"isin","type":"string","indexed":false,"internalType":"string"}],"anonymous":false},{"type":"event","name":"BondAuctionFinalised","inputs":[{"name":"id","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"isin","type":"string","indexed":false,"internalType":"string"},{"name":"dvpSuccess","type":"bool","indexed":false,"internalType":"bool"}],"anonymous":false},{"type":"event","name":"BondAuctionInitialised","inputs":[{"name":"id","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"isin","type":"string","indexed":false,"internalType":"string"},{"name":"bondAddress","type":"address","indexed":false,"internalType":"address"},{"name":"offering","type":"uint256","indexed":false,"internalType":"uint256"},{"name":"maturityDurationSeconds","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"BondBuybackAuctionInitialised","inputs":[{"name":"id","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"isin","type":"string","indexed":false,"internalType":"string"},{"name":"bondAddress","type":"address","indexed":false,"internalType":"address"},{"name":"buybackSize","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"BondBuybackComplete","inputs":[{"name":"id","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"isin","type":"string","indexed":false,"internalType":"string"},{"name":"total","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"BondCreated","inputs":[{"name":"isin","type":"string","indexed":false,"internalType":"string"},{"name":"bondAddress","type":"address","indexed":false,"internalType":"address"},{"name":"maturityDurationSeconds","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"BondDisabled","inputs":[{"name":"isin","type":"string","indexed":false,"internalType":"string"}],"anonymous":false},{"type":"event","name":"BondExtensionAuctionInitialised","inputs":[{"name":"id","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"isin","type":"string","indexed":false,"internalType":"string"},{"name":"bondAddress","type":"address","indexed":false,"internalType":"address"},{"name":"additionalOffering","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"BondIssuanceComplete","inputs":[{"name":"id","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"isin","type":"string","indexed":false,"internalType":"string"},{"name":"total","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"BondMatured","inputs":[{"name":"isin","type":"string","indexed":true,"internalType":"string"},{"name":"paymentCount","type":"uint256","indexed":false,"internalType":"uint256"},{"name":"principalPaid","type":"uint256","indexed":false,"internalType":"uint256"},{"name":"couponPaid","type":"uint256","indexed":false,"internalType":"uint256"},{"name":"unsoldBurned","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"BondRedeemed","inputs":[{"name":"isin","type":"string","indexed":true,"internalType":"string"},{"name":"holder","type":"address","indexed":true,"internalType":"address"},{"name":"value","type":"uint256","indexed":false,"internalType":"uint256"},{"name":"wnokAmount","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"CouponPaid","inputs":[{"name":"isin","type":"string","indexed":true,"internalType":"string"},{"name":"holder","type":"address","indexed":true,"internalType":"address"},{"name":"paymentAmount","type":"uint256","indexed":false,"internalType":"uint256"},{"name":"paymentNumber","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"CouponPeriodPaid","inputs":[{"name":"isin","type":"string","indexed":true,"internalType":"string"},{"name":"paymentNumber","type":"uint256","indexed":false,"internalType":"uint256"},{"name":"holdersPaid","type":"uint256","indexed":false,"internalType":"uint256"},{"name":"couponPaid","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"RoleAdminChanged","inputs":[{"name":"role","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"previousAdminRole","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"newAdminRole","type":"bytes32","indexed":true,"internalType":"bytes32"}],"anonymous":false},{"type":"event","name":"RoleGranted","inputs":[{"name":"role","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"account","type":"address","indexed":true,"internalType":"address"},{"name":"sender","type":"address","indexed":true,"internalType":"address"}],"anonymous":false},{"type":"event","name":"RoleRevoked","inputs":[{"name":"role","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"account","type":"address","indexed":true,"internalType":"address"},{"name":"sender","type":"address","indexed":true,"internalType":"address"}],"anonymous":false},{"type":"error","name":"AccessControlBadConfirmation","inputs":[]},{"type":"error","name":"AccessControlUnauthorizedAccount","inputs":[{"name":"account","type":"address","internalType":"address"},{"name":"neededRole","type":"bytes32","internalType":"bytes32"}]},{"type":"error","name":"AdditionalOfferingZero","inputs":[]},{"type":"error","name":"AllCouponsPaid","inputs":[{"name":"isin","type":"string","internalType":"string"}]},{"type":"error","name":"BondDoesNotExist","inputs":[{"name":"isin","type":"string","internalType":"string"}]},{"type":"error","name":"BondHasFinalisedAuction","inputs":[{"name":"isin","type":"string","internalType":"string"},{"name":"auctionId","type":"bytes32","internalType":"bytes32"}]},{"type":"error","name":"BondUnitNominalZero","inputs":[]},{"type":"error","name":"BuybackExceedsSupply","inputs":[{"name":"isin","type":"string","internalType":"string"},{"name":"buybackSize","type":"uint256","internalType":"uint256"},{"name":"currentSupply","type":"uint256","internalType":"uint256"}]},{"type":"error","name":"BuybackOfferingZero","inputs":[{"name":"isin","type":"string","internalType":"string"}]},{"type":"error","name":"CouponNotReady","inputs":[{"name":"isin","type":"string","internalType":"string"},{"name":"nextPaymentTime","type":"uint256","internalType":"uint256"},{"name":"currentTime","type":"uint256","internalType":"uint256"}]},{"type":"error","name":"CouponPaymentBalanceMismatch","inputs":[{"name":"isin","type":"string","internalType":"string"},{"name":"processedBalance","type":"uint256","internalType":"uint256"},{"name":"totalSupply","type":"uint256","internalType":"uint256"}]},{"type":"error","name":"DuplicateHolder","inputs":[{"name":"isin","type":"string","internalType":"string"},{"name":"holder","type":"address","internalType":"address"}]},{"type":"error","name":"DurationScalarZero","inputs":[]},{"type":"error","name":"GovReserveAddressZero","inputs":[]},{"type":"error","name":"IncorrectBondState","inputs":[{"name":"isin","type":"string","internalType":"string"},{"name":"expected","type":"bool","internalType":"bool"}]},{"type":"error","name":"MaturityDurationZero","inputs":[]},{"type":"error","name":"NoAllocations","inputs":[]},{"type":"error","name":"OfferingZero","inputs":[]},{"type":"error","name":"RedemptionIncomplete","inputs":[{"name":"_isin","type":"string","internalType":"string"},{"name":"remaining","type":"uint256","internalType":"uint256"}]},{"type":"error","name":"SettlementFailure","inputs":[{"name":"reason","type":"uint8","internalType":"uint8"},{"name":"lowLevelData","type":"bytes","internalType":"bytes"}]}]} \ No newline at end of file +{"abi":[{"type":"constructor","inputs":[{"name":"_name","type":"string","internalType":"string"},{"name":"_wNok","type":"address","internalType":"address"},{"name":"_controller","type":"address","internalType":"address"},{"name":"_bondAuction","type":"address","internalType":"address"},{"name":"_bondToken","type":"address","internalType":"address"},{"name":"_bondDvp","type":"address","internalType":"address"},{"name":"_govReserve","type":"address","internalType":"address"},{"name":"_durationScalar","type":"uint256","internalType":"uint256"}],"stateMutability":"nonpayable"},{"type":"function","name":"BOND_AUCTION","inputs":[],"outputs":[{"name":"","type":"address","internalType":"contract IBondAuction"}],"stateMutability":"view"},{"type":"function","name":"BOND_DVP","inputs":[],"outputs":[{"name":"","type":"address","internalType":"contract IBondDvP"}],"stateMutability":"view"},{"type":"function","name":"BOND_TOKEN","inputs":[],"outputs":[{"name":"","type":"address","internalType":"contract IBondToken"}],"stateMutability":"view"},{"type":"function","name":"DEFAULT_ADMIN_ROLE","inputs":[],"outputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"stateMutability":"view"},{"type":"function","name":"DURATION_SCALAR","inputs":[],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"GOV_RESERVE","inputs":[],"outputs":[{"name":"","type":"address","internalType":"address"}],"stateMutability":"view"},{"type":"function","name":"WNOK","inputs":[],"outputs":[{"name":"","type":"address","internalType":"address"}],"stateMutability":"view"},{"type":"function","name":"bondActive","inputs":[{"name":"","type":"string","internalType":"string"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"view"},{"type":"function","name":"buybackWithAuction","inputs":[{"name":"_isin","type":"string","internalType":"string"},{"name":"_end","type":"uint64","internalType":"uint64"},{"name":"_pubKey","type":"bytes","internalType":"bytes"},{"name":"_buybackSize","type":"uint256","internalType":"uint256"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"cancelAuction","inputs":[{"name":"_isin","type":"string","internalType":"string"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"closeAuction","inputs":[{"name":"_isin","type":"string","internalType":"string"}],"outputs":[{"name":"","type":"tuple[]","internalType":"struct IBondAuction.Bid[]","components":[{"name":"bidder","type":"address","internalType":"address"},{"name":"ciphertext","type":"bytes","internalType":"bytes"},{"name":"plaintextHash","type":"bytes32","internalType":"bytes32"}]}],"stateMutability":"nonpayable"},{"type":"function","name":"deployAuctionForBond","inputs":[{"name":"_isin","type":"string","internalType":"string"},{"name":"_end","type":"uint64","internalType":"uint64"},{"name":"_pubKey","type":"bytes","internalType":"bytes"},{"name":"_offering","type":"uint256","internalType":"uint256"},{"name":"_auctionType","type":"uint8","internalType":"enum IBondAuction.AuctionType"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"deployBond","inputs":[{"name":"_isin","type":"string","internalType":"string"},{"name":"_maturityDuration","type":"uint256","internalType":"uint256"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"deployBondWithAuction","inputs":[{"name":"_isin","type":"string","internalType":"string"},{"name":"_end","type":"uint64","internalType":"uint64"},{"name":"_pubKey","type":"bytes","internalType":"bytes"},{"name":"_offering","type":"uint256","internalType":"uint256"},{"name":"_maturityDuration","type":"uint256","internalType":"uint256"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"disableBond","inputs":[{"name":"_isin","type":"string","internalType":"string"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"extendBondWithAuction","inputs":[{"name":"_isin","type":"string","internalType":"string"},{"name":"_end","type":"uint64","internalType":"uint64"},{"name":"_pubKey","type":"bytes","internalType":"bytes"},{"name":"_additionalOffering","type":"uint256","internalType":"uint256"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"finaliseAuction","inputs":[{"name":"_isin","type":"string","internalType":"string"},{"name":"_alloc","type":"tuple[]","internalType":"struct IBondAuction.Allocation[]","components":[{"name":"isin","type":"string","internalType":"string"},{"name":"bidder","type":"address","internalType":"address"},{"name":"units","type":"uint256","internalType":"uint256"},{"name":"rate","type":"uint256","internalType":"uint256"},{"name":"auctionType","type":"uint8","internalType":"enum IBondAuction.AuctionType"}]},{"name":"_proofs","type":"tuple[]","internalType":"struct IBondAuction.BidVerification[]","components":[{"name":"bidIndex","type":"uint256","internalType":"uint256"},{"name":"bidderNonce","type":"uint256","internalType":"uint256"},{"name":"bidderSig","type":"bytes","internalType":"bytes"}]}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"getRoleAdmin","inputs":[{"name":"role","type":"bytes32","internalType":"bytes32"}],"outputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"stateMutability":"view"},{"type":"function","name":"getSealedBids","inputs":[{"name":"_isin","type":"string","internalType":"string"}],"outputs":[{"name":"","type":"tuple[]","internalType":"struct IBondAuction.Bid[]","components":[{"name":"bidder","type":"address","internalType":"address"},{"name":"ciphertext","type":"bytes","internalType":"bytes"},{"name":"plaintextHash","type":"bytes32","internalType":"bytes32"}]}],"stateMutability":"view"},{"type":"function","name":"grantRole","inputs":[{"name":"role","type":"bytes32","internalType":"bytes32"},{"name":"account","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"hasRole","inputs":[{"name":"role","type":"bytes32","internalType":"bytes32"},{"name":"account","type":"address","internalType":"address"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"view"},{"type":"function","name":"name","inputs":[],"outputs":[{"name":"","type":"string","internalType":"string"}],"stateMutability":"view"},{"type":"function","name":"payCoupon","inputs":[{"name":"_isin","type":"string","internalType":"string"},{"name":"_holders","type":"address[]","internalType":"address[]"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"renounceRole","inputs":[{"name":"role","type":"bytes32","internalType":"bytes32"},{"name":"callerConfirmation","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"revokeRole","inputs":[{"name":"role","type":"bytes32","internalType":"bytes32"},{"name":"account","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"supportsInterface","inputs":[{"name":"interfaceId","type":"bytes4","internalType":"bytes4"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"view"},{"type":"event","name":"BondAllocationFailed","inputs":[{"name":"id","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"isin","type":"string","indexed":false,"internalType":"string"},{"name":"bidder","type":"address","indexed":true,"internalType":"address"},{"name":"reason","type":"string","indexed":false,"internalType":"string"}],"anonymous":false},{"type":"event","name":"BondAuctionCancelled","inputs":[{"name":"id","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"isin","type":"string","indexed":false,"internalType":"string"},{"name":"offeringReduced","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"BondAuctionClosed","inputs":[{"name":"id","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"isin","type":"string","indexed":false,"internalType":"string"}],"anonymous":false},{"type":"event","name":"BondAuctionFinalised","inputs":[{"name":"id","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"isin","type":"string","indexed":false,"internalType":"string"},{"name":"dvpSuccess","type":"bool","indexed":false,"internalType":"bool"}],"anonymous":false},{"type":"event","name":"BondAuctionInitialised","inputs":[{"name":"id","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"isin","type":"string","indexed":false,"internalType":"string"},{"name":"bondAddress","type":"address","indexed":false,"internalType":"address"},{"name":"offering","type":"uint256","indexed":false,"internalType":"uint256"},{"name":"maturityDurationSeconds","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"BondBuybackAuctionInitialised","inputs":[{"name":"id","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"isin","type":"string","indexed":false,"internalType":"string"},{"name":"bondAddress","type":"address","indexed":false,"internalType":"address"},{"name":"buybackSize","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"BondBuybackComplete","inputs":[{"name":"id","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"isin","type":"string","indexed":false,"internalType":"string"},{"name":"total","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"BondCreated","inputs":[{"name":"isin","type":"string","indexed":false,"internalType":"string"},{"name":"bondAddress","type":"address","indexed":false,"internalType":"address"},{"name":"maturityDurationSeconds","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"BondDisabled","inputs":[{"name":"isin","type":"string","indexed":false,"internalType":"string"}],"anonymous":false},{"type":"event","name":"BondExtensionAuctionInitialised","inputs":[{"name":"id","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"isin","type":"string","indexed":false,"internalType":"string"},{"name":"bondAddress","type":"address","indexed":false,"internalType":"address"},{"name":"additionalOffering","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"BondIssuanceComplete","inputs":[{"name":"id","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"isin","type":"string","indexed":false,"internalType":"string"},{"name":"total","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"BondMatured","inputs":[{"name":"isin","type":"string","indexed":true,"internalType":"string"},{"name":"paymentCount","type":"uint256","indexed":false,"internalType":"uint256"},{"name":"principalPaid","type":"uint256","indexed":false,"internalType":"uint256"},{"name":"couponPaid","type":"uint256","indexed":false,"internalType":"uint256"},{"name":"unsoldBurned","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"BondRedeemed","inputs":[{"name":"isin","type":"string","indexed":true,"internalType":"string"},{"name":"holder","type":"address","indexed":true,"internalType":"address"},{"name":"value","type":"uint256","indexed":false,"internalType":"uint256"},{"name":"wnokAmount","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"CouponPaid","inputs":[{"name":"isin","type":"string","indexed":true,"internalType":"string"},{"name":"holder","type":"address","indexed":true,"internalType":"address"},{"name":"paymentAmount","type":"uint256","indexed":false,"internalType":"uint256"},{"name":"paymentNumber","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"CouponPeriodPaid","inputs":[{"name":"isin","type":"string","indexed":true,"internalType":"string"},{"name":"paymentNumber","type":"uint256","indexed":false,"internalType":"uint256"},{"name":"holdersPaid","type":"uint256","indexed":false,"internalType":"uint256"},{"name":"couponPaid","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"RoleAdminChanged","inputs":[{"name":"role","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"previousAdminRole","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"newAdminRole","type":"bytes32","indexed":true,"internalType":"bytes32"}],"anonymous":false},{"type":"event","name":"RoleGranted","inputs":[{"name":"role","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"account","type":"address","indexed":true,"internalType":"address"},{"name":"sender","type":"address","indexed":true,"internalType":"address"}],"anonymous":false},{"type":"event","name":"RoleRevoked","inputs":[{"name":"role","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"account","type":"address","indexed":true,"internalType":"address"},{"name":"sender","type":"address","indexed":true,"internalType":"address"}],"anonymous":false},{"type":"error","name":"AccessControlBadConfirmation","inputs":[]},{"type":"error","name":"AccessControlUnauthorizedAccount","inputs":[{"name":"account","type":"address","internalType":"address"},{"name":"neededRole","type":"bytes32","internalType":"bytes32"}]},{"type":"error","name":"AdditionalOfferingZero","inputs":[]},{"type":"error","name":"AllCouponsPaid","inputs":[{"name":"isin","type":"string","internalType":"string"}]},{"type":"error","name":"BondAuctionAddressZero","inputs":[]},{"type":"error","name":"BondDoesNotExist","inputs":[{"name":"isin","type":"string","internalType":"string"}]},{"type":"error","name":"BondHasFinalisedAuction","inputs":[{"name":"isin","type":"string","internalType":"string"},{"name":"auctionId","type":"bytes32","internalType":"bytes32"}]},{"type":"error","name":"BondTokenAddressZero","inputs":[]},{"type":"error","name":"BondUnitNominalZero","inputs":[]},{"type":"error","name":"BuybackExceedsSupply","inputs":[{"name":"isin","type":"string","internalType":"string"},{"name":"buybackSize","type":"uint256","internalType":"uint256"},{"name":"currentSupply","type":"uint256","internalType":"uint256"}]},{"type":"error","name":"BuybackOfferingZero","inputs":[{"name":"isin","type":"string","internalType":"string"}]},{"type":"error","name":"ControllerAddressZero","inputs":[]},{"type":"error","name":"CouponNotReady","inputs":[{"name":"isin","type":"string","internalType":"string"},{"name":"nextPaymentTime","type":"uint256","internalType":"uint256"},{"name":"currentTime","type":"uint256","internalType":"uint256"}]},{"type":"error","name":"CouponPaymentBalanceMismatch","inputs":[{"name":"isin","type":"string","internalType":"string"},{"name":"processedBalance","type":"uint256","internalType":"uint256"},{"name":"totalSupply","type":"uint256","internalType":"uint256"}]},{"type":"error","name":"DuplicateHolder","inputs":[{"name":"isin","type":"string","internalType":"string"},{"name":"holder","type":"address","internalType":"address"}]},{"type":"error","name":"DurationScalarZero","inputs":[]},{"type":"error","name":"DvpAddressZero","inputs":[]},{"type":"error","name":"GovReserveAddressZero","inputs":[]},{"type":"error","name":"IncorrectBondState","inputs":[{"name":"isin","type":"string","internalType":"string"},{"name":"expected","type":"bool","internalType":"bool"}]},{"type":"error","name":"MaturityDurationZero","inputs":[]},{"type":"error","name":"NoAllocations","inputs":[]},{"type":"error","name":"OfferingZero","inputs":[]},{"type":"error","name":"RedemptionIncomplete","inputs":[{"name":"_isin","type":"string","internalType":"string"},{"name":"remaining","type":"uint256","internalType":"uint256"}]},{"type":"error","name":"SettlementFailure","inputs":[{"name":"reason","type":"uint8","internalType":"uint8"},{"name":"lowLevelData","type":"bytes","internalType":"bytes"}]},{"type":"error","name":"WnokAddressZero","inputs":[]}]} \ No newline at end of file diff --git a/services/nb-bond-api/src/abi/BondToken.json b/services/nb-bond-api/src/abi/BondToken.json index 3662baa..113efb6 100644 --- a/services/nb-bond-api/src/abi/BondToken.json +++ b/services/nb-bond-api/src/abi/BondToken.json @@ -1 +1 @@ -{"abi":[{"type":"constructor","inputs":[{"name":"_name","type":"string","internalType":"string"},{"name":"_symbol","type":"string","internalType":"string"}],"stateMutability":"nonpayable"},{"type":"function","name":"DECIMALS","inputs":[],"outputs":[{"name":"","type":"uint8","internalType":"uint8"}],"stateMutability":"view"},{"type":"function","name":"DEFAULT_ADMIN_ROLE","inputs":[],"outputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"stateMutability":"view"},{"type":"function","name":"DEFAULT_PARTITION","inputs":[],"outputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"stateMutability":"view"},{"type":"function","name":"UNIT_NOMINAL","inputs":[],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"activePartitions","inputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"view"},{"type":"function","name":"addController","inputs":[{"name":"_controller","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"authorizeOperator","inputs":[{"name":"operator","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"authorizeOperatorByPartition","inputs":[{"name":"partition","type":"bytes32","internalType":"bytes32"},{"name":"operator","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"balanceOf","inputs":[{"name":"tokenHolder","type":"address","internalType":"address"}],"outputs":[{"name":"balance","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"balanceOfByPartition","inputs":[{"name":"partition","type":"bytes32","internalType":"bytes32"},{"name":"tokenHolder","type":"address","internalType":"address"}],"outputs":[{"name":"balance","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"buybackRedeemFor","inputs":[{"name":"_holder","type":"address","internalType":"address"},{"name":"_isin","type":"string","internalType":"string"},{"name":"_value","type":"uint256","internalType":"uint256"},{"name":"_operator","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"controllers","inputs":[],"outputs":[{"name":"controllerList","type":"address[]","internalType":"address[]"}],"stateMutability":"view"},{"type":"function","name":"couponDuration","inputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"couponPaymentCount","inputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"couponYield","inputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"createPartition","inputs":[{"name":"_isin","type":"string","internalType":"string"},{"name":"_offering","type":"uint256","internalType":"uint256"},{"name":"_maturityDuration","type":"uint256","internalType":"uint256"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"decimals","inputs":[],"outputs":[{"name":"decimalsValue","type":"uint8","internalType":"uint8"}],"stateMutability":"pure"},{"type":"function","name":"disablePartition","inputs":[{"name":"_isin","type":"string","internalType":"string"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"enableByIsin","inputs":[{"name":"_isin","type":"string","internalType":"string"},{"name":"_couponDuration","type":"uint256","internalType":"uint256"},{"name":"_couponYield","type":"uint256","internalType":"uint256"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"extendPartitionOffering","inputs":[{"name":"_isin","type":"string","internalType":"string"},{"name":"_additionalOffering","type":"uint256","internalType":"uint256"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"getCouponDetails","inputs":[{"name":"_isin","type":"string","internalType":"string"}],"outputs":[{"name":"_couponDuration","type":"uint256","internalType":"uint256"},{"name":"_couponYield","type":"uint256","internalType":"uint256"},{"name":"_maturityDuration","type":"uint256","internalType":"uint256"},{"name":"_lastCouponPayment","type":"uint256","internalType":"uint256"},{"name":"_couponPaymentCount","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"getRoleAdmin","inputs":[{"name":"role","type":"bytes32","internalType":"bytes32"}],"outputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"stateMutability":"view"},{"type":"function","name":"grantRole","inputs":[{"name":"role","type":"bytes32","internalType":"bytes32"},{"name":"account","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"granularity","inputs":[],"outputs":[{"name":"granularityValue","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"hasRole","inputs":[{"name":"role","type":"bytes32","internalType":"bytes32"},{"name":"account","type":"address","internalType":"address"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"view"},{"type":"function","name":"isController","inputs":[{"name":"operator","type":"address","internalType":"address"}],"outputs":[{"name":"isCtrl","type":"bool","internalType":"bool"}],"stateMutability":"view"},{"type":"function","name":"isMatured","inputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"view"},{"type":"function","name":"isOperator","inputs":[{"name":"operator","type":"address","internalType":"address"},{"name":"tokenHolder","type":"address","internalType":"address"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"view"},{"type":"function","name":"isOperatorForPartition","inputs":[{"name":"partition","type":"bytes32","internalType":"bytes32"},{"name":"operator","type":"address","internalType":"address"},{"name":"tokenHolder","type":"address","internalType":"address"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"view"},{"type":"function","name":"isinToPartition","inputs":[{"name":"_isin","type":"string","internalType":"string"}],"outputs":[{"name":"partition","type":"bytes32","internalType":"bytes32"}],"stateMutability":"pure"},{"type":"function","name":"lastCouponPayment","inputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"maturityDate","inputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"maturityDuration","inputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"mintByIsin","inputs":[{"name":"_isin","type":"string","internalType":"string"},{"name":"account","type":"address","internalType":"address"},{"name":"value","type":"uint256","internalType":"uint256"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"name","inputs":[],"outputs":[{"name":"","type":"string","internalType":"string"}],"stateMutability":"view"},{"type":"function","name":"operatorTransferByPartition","inputs":[{"name":"partition","type":"bytes32","internalType":"bytes32"},{"name":"from","type":"address","internalType":"address"},{"name":"to","type":"address","internalType":"address"},{"name":"value","type":"uint256","internalType":"uint256"},{"name":"data","type":"bytes","internalType":"bytes"},{"name":"operatorData","type":"bytes","internalType":"bytes"}],"outputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"stateMutability":"nonpayable"},{"type":"function","name":"partitionOffering","inputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"partitionToIsin","inputs":[{"name":"partition","type":"bytes32","internalType":"bytes32"}],"outputs":[{"name":"","type":"string","internalType":"string"}],"stateMutability":"view"},{"type":"function","name":"partitionsOf","inputs":[{"name":"tokenHolder","type":"address","internalType":"address"}],"outputs":[{"name":"partitions","type":"bytes32[]","internalType":"bytes32[]"}],"stateMutability":"view"},{"type":"function","name":"redeemFor","inputs":[{"name":"_holder","type":"address","internalType":"address"},{"name":"_isin","type":"string","internalType":"string"},{"name":"_value","type":"uint256","internalType":"uint256"},{"name":"_operator","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"reducePartitionOffering","inputs":[{"name":"_isin","type":"string","internalType":"string"},{"name":"_reductionAmount","type":"uint256","internalType":"uint256"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"renounceRole","inputs":[{"name":"role","type":"bytes32","internalType":"bytes32"},{"name":"callerConfirmation","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"revokeOperator","inputs":[{"name":"operator","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"revokeOperatorByPartition","inputs":[{"name":"partition","type":"bytes32","internalType":"bytes32"},{"name":"operator","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"revokeRole","inputs":[{"name":"role","type":"bytes32","internalType":"bytes32"},{"name":"account","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"setMatured","inputs":[{"name":"_isin","type":"string","internalType":"string"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"supportsInterface","inputs":[{"name":"interfaceId","type":"bytes4","internalType":"bytes4"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"view"},{"type":"function","name":"symbol","inputs":[],"outputs":[{"name":"","type":"string","internalType":"string"}],"stateMutability":"view"},{"type":"function","name":"totalPartitions","inputs":[],"outputs":[{"name":"partitions","type":"bytes32[]","internalType":"bytes32[]"}],"stateMutability":"view"},{"type":"function","name":"totalSupply","inputs":[],"outputs":[{"name":"supply","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"totalSupplyByPartition","inputs":[{"name":"partition","type":"bytes32","internalType":"bytes32"}],"outputs":[{"name":"supply","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"transferByPartition","inputs":[{"name":"partition","type":"bytes32","internalType":"bytes32"},{"name":"to","type":"address","internalType":"address"},{"name":"value","type":"uint256","internalType":"uint256"},{"name":"data","type":"bytes","internalType":"bytes"}],"outputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"stateMutability":"nonpayable"},{"type":"function","name":"updateCouponPayment","inputs":[{"name":"_isin","type":"string","internalType":"string"},{"name":"_timestamp","type":"uint256","internalType":"uint256"},{"name":"_paymentCount","type":"uint256","internalType":"uint256"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"event","name":"AuthorizedOperator","inputs":[{"name":"operator","type":"address","indexed":true,"internalType":"address"},{"name":"tokenHolder","type":"address","indexed":true,"internalType":"address"}],"anonymous":false},{"type":"event","name":"AuthorizedOperatorByPartition","inputs":[{"name":"partition","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"operator","type":"address","indexed":true,"internalType":"address"},{"name":"tokenHolder","type":"address","indexed":true,"internalType":"address"}],"anonymous":false},{"type":"event","name":"ChangedPartition","inputs":[{"name":"fromPartition","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"toPartition","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"value","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"IsinDisabled","inputs":[{"name":"isin","type":"string","indexed":false,"internalType":"string"}],"anonymous":false},{"type":"event","name":"IsinEnabled","inputs":[{"name":"isin","type":"string","indexed":false,"internalType":"string"},{"name":"couponDuration","type":"uint256","indexed":false,"internalType":"uint256"},{"name":"couponYield","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"IsinExtended","inputs":[{"name":"isin","type":"string","indexed":false,"internalType":"string"},{"name":"delta","type":"uint256","indexed":false,"internalType":"uint256"},{"name":"newOffering","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"IsinIssued","inputs":[{"name":"isin","type":"string","indexed":false,"internalType":"string"},{"name":"offering","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"IsinMinted","inputs":[{"name":"isin","type":"string","indexed":false,"internalType":"string"},{"name":"dst","type":"address","indexed":false,"internalType":"address"},{"name":"value","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"IsinRedeemed","inputs":[{"name":"isin","type":"string","indexed":false,"internalType":"string"},{"name":"holder","type":"address","indexed":true,"internalType":"address"},{"name":"value","type":"uint256","indexed":false,"internalType":"uint256"},{"name":"operator","type":"address","indexed":false,"internalType":"address"}],"anonymous":false},{"type":"event","name":"IsinReduced","inputs":[{"name":"isin","type":"string","indexed":false,"internalType":"string"},{"name":"delta","type":"uint256","indexed":false,"internalType":"uint256"},{"name":"newOffering","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"RevokedOperator","inputs":[{"name":"operator","type":"address","indexed":true,"internalType":"address"},{"name":"tokenHolder","type":"address","indexed":true,"internalType":"address"}],"anonymous":false},{"type":"event","name":"RevokedOperatorByPartition","inputs":[{"name":"partition","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"operator","type":"address","indexed":true,"internalType":"address"},{"name":"tokenHolder","type":"address","indexed":true,"internalType":"address"}],"anonymous":false},{"type":"event","name":"RoleAdminChanged","inputs":[{"name":"role","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"previousAdminRole","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"newAdminRole","type":"bytes32","indexed":true,"internalType":"bytes32"}],"anonymous":false},{"type":"event","name":"RoleGranted","inputs":[{"name":"role","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"account","type":"address","indexed":true,"internalType":"address"},{"name":"sender","type":"address","indexed":true,"internalType":"address"}],"anonymous":false},{"type":"event","name":"RoleRevoked","inputs":[{"name":"role","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"account","type":"address","indexed":true,"internalType":"address"},{"name":"sender","type":"address","indexed":true,"internalType":"address"}],"anonymous":false},{"type":"event","name":"TransferByPartition","inputs":[{"name":"fromPartition","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"operator","type":"address","indexed":false,"internalType":"address"},{"name":"from","type":"address","indexed":false,"internalType":"address"},{"name":"to","type":"address","indexed":false,"internalType":"address"},{"name":"value","type":"uint256","indexed":false,"internalType":"uint256"},{"name":"data","type":"bytes","indexed":false,"internalType":"bytes"},{"name":"operatorData","type":"bytes","indexed":false,"internalType":"bytes"}],"anonymous":false},{"type":"error","name":"AccessControlBadConfirmation","inputs":[]},{"type":"error","name":"AccessControlUnauthorizedAccount","inputs":[{"name":"account","type":"address","internalType":"address"},{"name":"neededRole","type":"bytes32","internalType":"bytes32"}]},{"type":"error","name":"AdditionalOfferingZero","inputs":[]},{"type":"error","name":"BondAlreadyDisabled","inputs":[{"name":"isin","type":"string","internalType":"string"}]},{"type":"error","name":"BondNotEmpty","inputs":[{"name":"isin","type":"string","internalType":"string"},{"name":"supply","type":"uint256","internalType":"uint256"}]},{"type":"error","name":"ControllerAddressZero","inputs":[]},{"type":"error","name":"ControllerZeroAddress","inputs":[]},{"type":"error","name":"CouponDurationZero","inputs":[]},{"type":"error","name":"CouponYieldZero","inputs":[]},{"type":"error","name":"DuplicatePartition","inputs":[{"name":"isin","type":"string","internalType":"string"}]},{"type":"error","name":"ExceedsOffering","inputs":[{"name":"isin","type":"string","internalType":"string"},{"name":"currentSupply","type":"uint256","internalType":"uint256"},{"name":"mintAmount","type":"uint256","internalType":"uint256"},{"name":"offering","type":"uint256","internalType":"uint256"}]},{"type":"error","name":"InsufficientBalance","inputs":[]},{"type":"error","name":"InsufficientPartitionBalance","inputs":[]},{"type":"error","name":"InvalidAmount","inputs":[]},{"type":"error","name":"InvalidGranularity","inputs":[]},{"type":"error","name":"InvalidHolder","inputs":[{"name":"holder","type":"address","internalType":"address"}]},{"type":"error","name":"InvalidRecipient","inputs":[]},{"type":"error","name":"MaturityDateZero","inputs":[]},{"type":"error","name":"MaturityDurationZero","inputs":[]},{"type":"error","name":"NotMatured","inputs":[{"name":"isin","type":"string","internalType":"string"},{"name":"maturityDate","type":"uint256","internalType":"uint256"},{"name":"currentTime","type":"uint256","internalType":"uint256"}]},{"type":"error","name":"NotMultipleOfGranularity","inputs":[]},{"type":"error","name":"PartitionNotActive","inputs":[{"name":"isin","type":"string","internalType":"string"}]},{"type":"error","name":"ReductionAmountZero","inputs":[]},{"type":"error","name":"ReductionBelowSupply","inputs":[{"name":"currentSupply","type":"uint256","internalType":"uint256"},{"name":"offeringAfterReduction","type":"uint256","internalType":"uint256"}]},{"type":"error","name":"ReductionExceedsOffering","inputs":[{"name":"offering","type":"uint256","internalType":"uint256"},{"name":"reduction","type":"uint256","internalType":"uint256"}]},{"type":"error","name":"UnauthorizedOperator","inputs":[]}]} \ No newline at end of file +{"abi":[{"type":"constructor","inputs":[{"name":"_name","type":"string","internalType":"string"},{"name":"_symbol","type":"string","internalType":"string"}],"stateMutability":"nonpayable"},{"type":"function","name":"DECIMALS","inputs":[],"outputs":[{"name":"","type":"uint8","internalType":"uint8"}],"stateMutability":"view"},{"type":"function","name":"DEFAULT_ADMIN_ROLE","inputs":[],"outputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"stateMutability":"view"},{"type":"function","name":"DEFAULT_PARTITION","inputs":[],"outputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"stateMutability":"view"},{"type":"function","name":"UNIT_NOMINAL","inputs":[],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"activePartitions","inputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"view"},{"type":"function","name":"addController","inputs":[{"name":"_controller","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"authorizeOperator","inputs":[{"name":"operator","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"authorizeOperatorByPartition","inputs":[{"name":"partition","type":"bytes32","internalType":"bytes32"},{"name":"operator","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"balanceOf","inputs":[{"name":"tokenHolder","type":"address","internalType":"address"}],"outputs":[{"name":"balance","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"balanceOfByPartition","inputs":[{"name":"partition","type":"bytes32","internalType":"bytes32"},{"name":"tokenHolder","type":"address","internalType":"address"}],"outputs":[{"name":"balance","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"buybackRedeemFor","inputs":[{"name":"_holder","type":"address","internalType":"address"},{"name":"_isin","type":"string","internalType":"string"},{"name":"_value","type":"uint256","internalType":"uint256"},{"name":"_operator","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"controllers","inputs":[],"outputs":[{"name":"controllerList","type":"address[]","internalType":"address[]"}],"stateMutability":"view"},{"type":"function","name":"couponDuration","inputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"couponPaymentCount","inputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"couponYield","inputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"createPartition","inputs":[{"name":"_isin","type":"string","internalType":"string"},{"name":"_offering","type":"uint256","internalType":"uint256"},{"name":"_maturityDuration","type":"uint256","internalType":"uint256"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"decimals","inputs":[],"outputs":[{"name":"decimalsValue","type":"uint8","internalType":"uint8"}],"stateMutability":"pure"},{"type":"function","name":"disablePartition","inputs":[{"name":"_isin","type":"string","internalType":"string"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"enableByIsin","inputs":[{"name":"_isin","type":"string","internalType":"string"},{"name":"_couponDuration","type":"uint256","internalType":"uint256"},{"name":"_couponYield","type":"uint256","internalType":"uint256"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"extendPartitionOffering","inputs":[{"name":"_isin","type":"string","internalType":"string"},{"name":"_additionalOffering","type":"uint256","internalType":"uint256"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"getCouponDetails","inputs":[{"name":"_isin","type":"string","internalType":"string"}],"outputs":[{"name":"_couponDuration","type":"uint256","internalType":"uint256"},{"name":"_couponYield","type":"uint256","internalType":"uint256"},{"name":"_maturityDuration","type":"uint256","internalType":"uint256"},{"name":"_lastCouponPayment","type":"uint256","internalType":"uint256"},{"name":"_couponPaymentCount","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"getRoleAdmin","inputs":[{"name":"role","type":"bytes32","internalType":"bytes32"}],"outputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"stateMutability":"view"},{"type":"function","name":"grantRole","inputs":[{"name":"role","type":"bytes32","internalType":"bytes32"},{"name":"account","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"granularity","inputs":[],"outputs":[{"name":"granularityValue","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"hasRole","inputs":[{"name":"role","type":"bytes32","internalType":"bytes32"},{"name":"account","type":"address","internalType":"address"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"view"},{"type":"function","name":"isController","inputs":[{"name":"operator","type":"address","internalType":"address"}],"outputs":[{"name":"isCtrl","type":"bool","internalType":"bool"}],"stateMutability":"view"},{"type":"function","name":"isMatured","inputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"view"},{"type":"function","name":"isOperator","inputs":[{"name":"operator","type":"address","internalType":"address"},{"name":"tokenHolder","type":"address","internalType":"address"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"view"},{"type":"function","name":"isOperatorForPartition","inputs":[{"name":"partition","type":"bytes32","internalType":"bytes32"},{"name":"operator","type":"address","internalType":"address"},{"name":"tokenHolder","type":"address","internalType":"address"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"view"},{"type":"function","name":"isinToPartition","inputs":[{"name":"_isin","type":"string","internalType":"string"}],"outputs":[{"name":"partition","type":"bytes32","internalType":"bytes32"}],"stateMutability":"pure"},{"type":"function","name":"lastCouponPayment","inputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"maturityDate","inputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"maturityDuration","inputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"mintByIsin","inputs":[{"name":"_isin","type":"string","internalType":"string"},{"name":"account","type":"address","internalType":"address"},{"name":"value","type":"uint256","internalType":"uint256"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"name","inputs":[],"outputs":[{"name":"","type":"string","internalType":"string"}],"stateMutability":"view"},{"type":"function","name":"operatorTransferByPartition","inputs":[{"name":"partition","type":"bytes32","internalType":"bytes32"},{"name":"from","type":"address","internalType":"address"},{"name":"to","type":"address","internalType":"address"},{"name":"value","type":"uint256","internalType":"uint256"},{"name":"data","type":"bytes","internalType":"bytes"},{"name":"operatorData","type":"bytes","internalType":"bytes"}],"outputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"stateMutability":"nonpayable"},{"type":"function","name":"partitionOffering","inputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"partitionToIsin","inputs":[{"name":"partition","type":"bytes32","internalType":"bytes32"}],"outputs":[{"name":"","type":"string","internalType":"string"}],"stateMutability":"view"},{"type":"function","name":"partitionsOf","inputs":[{"name":"tokenHolder","type":"address","internalType":"address"}],"outputs":[{"name":"partitions","type":"bytes32[]","internalType":"bytes32[]"}],"stateMutability":"view"},{"type":"function","name":"redeemFor","inputs":[{"name":"_holder","type":"address","internalType":"address"},{"name":"_isin","type":"string","internalType":"string"},{"name":"_value","type":"uint256","internalType":"uint256"},{"name":"_operator","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"reducePartitionOffering","inputs":[{"name":"_isin","type":"string","internalType":"string"},{"name":"_reductionAmount","type":"uint256","internalType":"uint256"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"removeController","inputs":[{"name":"_controller","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"renounceRole","inputs":[{"name":"role","type":"bytes32","internalType":"bytes32"},{"name":"callerConfirmation","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"revokeOperator","inputs":[{"name":"operator","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"revokeOperatorByPartition","inputs":[{"name":"partition","type":"bytes32","internalType":"bytes32"},{"name":"operator","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"revokeRole","inputs":[{"name":"role","type":"bytes32","internalType":"bytes32"},{"name":"account","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"setMatured","inputs":[{"name":"_isin","type":"string","internalType":"string"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"supportsInterface","inputs":[{"name":"interfaceId","type":"bytes4","internalType":"bytes4"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"view"},{"type":"function","name":"symbol","inputs":[],"outputs":[{"name":"","type":"string","internalType":"string"}],"stateMutability":"view"},{"type":"function","name":"totalPartitions","inputs":[],"outputs":[{"name":"partitions","type":"bytes32[]","internalType":"bytes32[]"}],"stateMutability":"view"},{"type":"function","name":"totalSupply","inputs":[],"outputs":[{"name":"supply","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"totalSupplyByPartition","inputs":[{"name":"partition","type":"bytes32","internalType":"bytes32"}],"outputs":[{"name":"supply","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"transferByPartition","inputs":[{"name":"partition","type":"bytes32","internalType":"bytes32"},{"name":"to","type":"address","internalType":"address"},{"name":"value","type":"uint256","internalType":"uint256"},{"name":"data","type":"bytes","internalType":"bytes"}],"outputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"stateMutability":"nonpayable"},{"type":"function","name":"updateCouponPayment","inputs":[{"name":"_isin","type":"string","internalType":"string"},{"name":"_timestamp","type":"uint256","internalType":"uint256"},{"name":"_paymentCount","type":"uint256","internalType":"uint256"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"event","name":"AuthorizedOperator","inputs":[{"name":"operator","type":"address","indexed":true,"internalType":"address"},{"name":"tokenHolder","type":"address","indexed":true,"internalType":"address"}],"anonymous":false},{"type":"event","name":"AuthorizedOperatorByPartition","inputs":[{"name":"partition","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"operator","type":"address","indexed":true,"internalType":"address"},{"name":"tokenHolder","type":"address","indexed":true,"internalType":"address"}],"anonymous":false},{"type":"event","name":"ChangedPartition","inputs":[{"name":"fromPartition","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"toPartition","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"value","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"IsinDisabled","inputs":[{"name":"isin","type":"string","indexed":false,"internalType":"string"}],"anonymous":false},{"type":"event","name":"IsinEnabled","inputs":[{"name":"isin","type":"string","indexed":false,"internalType":"string"},{"name":"couponDuration","type":"uint256","indexed":false,"internalType":"uint256"},{"name":"couponYield","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"IsinExtended","inputs":[{"name":"isin","type":"string","indexed":false,"internalType":"string"},{"name":"delta","type":"uint256","indexed":false,"internalType":"uint256"},{"name":"newOffering","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"IsinIssued","inputs":[{"name":"isin","type":"string","indexed":false,"internalType":"string"},{"name":"offering","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"IsinMinted","inputs":[{"name":"isin","type":"string","indexed":false,"internalType":"string"},{"name":"dst","type":"address","indexed":false,"internalType":"address"},{"name":"value","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"IsinRedeemed","inputs":[{"name":"isin","type":"string","indexed":false,"internalType":"string"},{"name":"holder","type":"address","indexed":true,"internalType":"address"},{"name":"value","type":"uint256","indexed":false,"internalType":"uint256"},{"name":"operator","type":"address","indexed":false,"internalType":"address"}],"anonymous":false},{"type":"event","name":"IsinReduced","inputs":[{"name":"isin","type":"string","indexed":false,"internalType":"string"},{"name":"delta","type":"uint256","indexed":false,"internalType":"uint256"},{"name":"newOffering","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"RevokedOperator","inputs":[{"name":"operator","type":"address","indexed":true,"internalType":"address"},{"name":"tokenHolder","type":"address","indexed":true,"internalType":"address"}],"anonymous":false},{"type":"event","name":"RevokedOperatorByPartition","inputs":[{"name":"partition","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"operator","type":"address","indexed":true,"internalType":"address"},{"name":"tokenHolder","type":"address","indexed":true,"internalType":"address"}],"anonymous":false},{"type":"event","name":"RoleAdminChanged","inputs":[{"name":"role","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"previousAdminRole","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"newAdminRole","type":"bytes32","indexed":true,"internalType":"bytes32"}],"anonymous":false},{"type":"event","name":"RoleGranted","inputs":[{"name":"role","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"account","type":"address","indexed":true,"internalType":"address"},{"name":"sender","type":"address","indexed":true,"internalType":"address"}],"anonymous":false},{"type":"event","name":"RoleRevoked","inputs":[{"name":"role","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"account","type":"address","indexed":true,"internalType":"address"},{"name":"sender","type":"address","indexed":true,"internalType":"address"}],"anonymous":false},{"type":"event","name":"TransferByPartition","inputs":[{"name":"fromPartition","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"operator","type":"address","indexed":false,"internalType":"address"},{"name":"from","type":"address","indexed":false,"internalType":"address"},{"name":"to","type":"address","indexed":false,"internalType":"address"},{"name":"value","type":"uint256","indexed":false,"internalType":"uint256"},{"name":"data","type":"bytes","indexed":false,"internalType":"bytes"},{"name":"operatorData","type":"bytes","indexed":false,"internalType":"bytes"}],"anonymous":false},{"type":"error","name":"AccessControlBadConfirmation","inputs":[]},{"type":"error","name":"AccessControlUnauthorizedAccount","inputs":[{"name":"account","type":"address","internalType":"address"},{"name":"neededRole","type":"bytes32","internalType":"bytes32"}]},{"type":"error","name":"AdditionalOfferingZero","inputs":[]},{"type":"error","name":"BondAlreadyDisabled","inputs":[{"name":"isin","type":"string","internalType":"string"}]},{"type":"error","name":"BondNotEmpty","inputs":[{"name":"isin","type":"string","internalType":"string"},{"name":"supply","type":"uint256","internalType":"uint256"}]},{"type":"error","name":"ControllerAddressZero","inputs":[]},{"type":"error","name":"ControllerZeroAddress","inputs":[]},{"type":"error","name":"CouponDurationZero","inputs":[]},{"type":"error","name":"CouponYieldZero","inputs":[]},{"type":"error","name":"DuplicatePartition","inputs":[{"name":"isin","type":"string","internalType":"string"}]},{"type":"error","name":"ExceedsOffering","inputs":[{"name":"isin","type":"string","internalType":"string"},{"name":"currentSupply","type":"uint256","internalType":"uint256"},{"name":"mintAmount","type":"uint256","internalType":"uint256"},{"name":"offering","type":"uint256","internalType":"uint256"}]},{"type":"error","name":"InsufficientBalance","inputs":[]},{"type":"error","name":"InsufficientPartitionBalance","inputs":[]},{"type":"error","name":"InvalidAmount","inputs":[]},{"type":"error","name":"InvalidGranularity","inputs":[]},{"type":"error","name":"InvalidHolder","inputs":[{"name":"holder","type":"address","internalType":"address"}]},{"type":"error","name":"InvalidRecipient","inputs":[]},{"type":"error","name":"MaturityDateZero","inputs":[]},{"type":"error","name":"MaturityDurationZero","inputs":[]},{"type":"error","name":"NotMatured","inputs":[{"name":"isin","type":"string","internalType":"string"},{"name":"maturityDate","type":"uint256","internalType":"uint256"},{"name":"currentTime","type":"uint256","internalType":"uint256"}]},{"type":"error","name":"NotMultipleOfGranularity","inputs":[]},{"type":"error","name":"PartitionNotActive","inputs":[{"name":"isin","type":"string","internalType":"string"}]},{"type":"error","name":"ReductionAmountZero","inputs":[]},{"type":"error","name":"ReductionBelowSupply","inputs":[{"name":"currentSupply","type":"uint256","internalType":"uint256"},{"name":"offeringAfterReduction","type":"uint256","internalType":"uint256"}]},{"type":"error","name":"ReductionExceedsOffering","inputs":[{"name":"offering","type":"uint256","internalType":"uint256"},{"name":"reduction","type":"uint256","internalType":"uint256"}]},{"type":"error","name":"UnauthorizedOperator","inputs":[]}]} \ No newline at end of file From 938d159c3bc047c9f573d5b865384b06e2e0f5bb Mon Sep 17 00:00:00 2001 From: Deleo <152405392+Greaker@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:00:06 +0200 Subject: [PATCH 08/18] Operator-only issuer actions with route-level authorization tests (#305) In entra mode, creating and disabling bonds and creating, closing, cancelling, and finalising auctions now require an operator role, in line with coupon payments, admin, and the Central Bank surface. The gate runs ahead of request validation, so other recognised roles get 403 before any input is inspected. Testers keep reads, bidding, and Banking; none mode stays fully open. Each gated operation documents the rule in the OpenAPI contract. A new route-authorization suite drives every mutating route through the real auth middleware as an operator and as a tester, with invalid input so a cleared gate answers without reaching the chain. Removing any gate fails a row. The UI mirrors the API: issuer controls (new bond, new auction, disable bond, and the close, finalise, and cancel lifecycle actions) and the Network Health Reconnect and Resync actions are shown only to accounts that can operate. An unrecognised AUTH_MODE now fails closed: the UI grants no capability and shows only a configuration error naming the value. Docs carry the route matrix and the UI role behaviour. Tests: jest 291 (39 suites), vitest 139 (27 files), all green. --- docs/ARCHITECTURE.md | 8 +- services/nb-bond-api/DEVELOPMENT.md | 22 ++++- services/nb-bond-api/README.md | 10 +- services/nb-bond-api/openapi.json | 6 ++ services/nb-bond-api/src/app.ts | 93 ++++++++++-------- .../nb-bond-api/src/contracts/auctions.ts | 10 +- services/nb-bond-api/src/contracts/bonds.ts | 10 +- .../src/openapi/shared-responses.ts | 9 ++ .../tests/route-authorization.test.ts | 97 ++++++++++++++++++- services/nb-ui/DEVELOPMENT.md | 20 +++- services/nb-ui/README.md | 6 +- services/nb-ui/src/App.jsx | 9 +- services/nb-ui/src/auth/capabilities.js | 12 ++- services/nb-ui/src/auth/index.js | 21 +++- .../nb-ui/src/components/ConfigErrorPage.jsx | 24 +++++ .../nb-ui/src/pages/AuctionLifecyclePanel.jsx | 7 +- services/nb-ui/src/pages/AuctionsPage.jsx | 13 ++- services/nb-ui/src/pages/BondDetailPage.jsx | 4 +- services/nb-ui/src/pages/BondsPage.jsx | 13 ++- .../nb-ui/src/pages/NetworkHealthModal.jsx | 26 +++-- .../tests/AuctionLifecyclePanel.test.jsx | 50 ++++++++++ services/nb-ui/tests/BondDetailPage.test.jsx | 25 +++++ services/nb-ui/tests/BondsPage.test.jsx | 24 +++++ .../nb-ui/tests/NetworkHealthModal.test.jsx | 39 ++++++-- services/nb-ui/tests/auth.test.js | 13 ++- services/nb-ui/tests/authGate.test.jsx | 34 ++++++- services/nb-ui/tests/capabilities.test.js | 12 +++ 27 files changed, 519 insertions(+), 98 deletions(-) create mode 100644 services/nb-ui/src/components/ConfigErrorPage.jsx create mode 100644 services/nb-ui/tests/AuctionLifecyclePanel.test.jsx diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1e35b8d..02b488a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -391,8 +391,12 @@ defaults to no-auth locally and can be switched to OIDC (Entra / MSAL) per deployment via runtime config without rebuilding the bundle. Tenant- and client-specific values are intentionally **not** committed in this repo. In `entra` mode both tiers additionally enforce role-based access from Entra App -Roles (operator vs tester; the Central Bank surface is operator-only), with the -NB Bond API as the authoritative boundary. +Roles, with the NB Bond API as the authoritative boundary. Operators hold the +issuer actions (creating and disabling bonds; creating, closing, cancelling, and +finalising auctions; coupon payments), the Central Bank surface, and the admin +recovery actions; testers keep reads, bidding, and Banking. The UI hides +operator-only controls from testers to mirror the API, and refuses to start on +an unrecognised `AUTH_MODE`. ### Live updates diff --git a/services/nb-bond-api/DEVELOPMENT.md b/services/nb-bond-api/DEVELOPMENT.md index d34c400..2d8b4bc 100644 --- a/services/nb-bond-api/DEVELOPMENT.md +++ b/services/nb-bond-api/DEVELOPMENT.md @@ -56,7 +56,7 @@ Important optional settings: - `CORS_ALLOWED_ORIGINS`: comma-separated list of origins the CORS middleware accepts. Defaults to `http://web.cbdc-sandbox.local` (the local sandbox frontend at `services/nb-ui/`). Override (with multiple comma-separated origins if needed) for a non-local deployment. - `NB_BOND_API_AUTH_MODE`: `none` (default) or `entra`. See §7.7. - `NB_BOND_API_AUTH_ENTRA_TENANT_ID` / `NB_BOND_API_AUTH_ENTRA_AUDIENCE`: required when `NB_BOND_API_AUTH_MODE=entra`. -- `NB_BOND_API_AUTH_ENTRA_OPERATOR_ROLES` / `NB_BOND_API_AUTH_ENTRA_TESTER_ROLES`: comma-separated Entra App Role values for role-based access (operator gates `/v1/central-bank/*`; recognised = operator ∪ tester). Operator roles required in `entra` mode. See §7.7. +- `NB_BOND_API_AUTH_ENTRA_OPERATOR_ROLES` / `NB_BOND_API_AUTH_ENTRA_TESTER_ROLES`: comma-separated Entra App Role values for role-based access (operator gates issuer actions, coupon payments, `/v1/admin/*`, and `/v1/central-bank/*`; recognised = operator ∪ tester). Operator roles required in `entra` mode. See §7.7. ### 2.3 Start commands @@ -495,10 +495,22 @@ token, `entra` mode authorizes it against the `roles` claim (Entra App Roles): granting baseline access without Central Bank. Optional. - Every authenticated endpoint requires at least one _recognised_ role (operator ∪ tester); a valid token carrying none is rejected with `403`. - `/v1/central-bank/*` additionally requires an operator role (`403` - otherwise). `none` mode performs no role checks. The values must match the - nb-ui `AUTH_OPERATOR_ROLES` / `AUTH_TESTER_ROLES` and the App Role values - defined in Entra. + `none` mode performs no role checks. The values must match the nb-ui + `AUTH_OPERATOR_ROLES` / `AUTH_TESTER_ROLES` and the App Role values defined + in Entra. +- Route matrix in `entra` mode (operator-only routes answer `403` to any + other recognised role, before request validation): + + | Routes | Roles | + | ------------------------------------------------------------------------------------------------------------ | ---------- | + | All reads, `/v1/events`, `POST` / `DELETE /v1/bidders`, `POST /v1/bidders/{address}/bids`, `/v1/banking/*` | recognised | + | `POST /v1/bonds`, `DELETE /v1/bonds/{isin}`, `POST /v1/bonds/{isin}/auctions` | operator | + | `PATCH /v1/auctions/{id}` (close), `DELETE /v1/auctions/{id}` (cancel), `PUT /v1/auctions/{id}/finalisation` | operator | + | `POST /v1/bonds/{isin}/coupon-payments`, `/v1/admin/*`, `/v1/central-bank/*` | operator | + + `tests/route-authorization.test.ts` exercises every mutating route as an + operator and as a tester, so a missing or misplaced gate fails CI. + - The `testMode` query flag (unseal open bids on bond / auction reads, skip the close end-time pre-check) is honoured only for operator callers (`src/test-mode.ts`, using `isOperatorRequest` in `src/auth.ts`). A tester's diff --git a/services/nb-bond-api/README.md b/services/nb-bond-api/README.md index ff6a458..b3a1234 100644 --- a/services/nb-bond-api/README.md +++ b/services/nb-bond-api/README.md @@ -48,7 +48,7 @@ Two sandbox-only resource families sit alongside the bond tree: `PUT/DELETE /v1/banking/tbd/{address}/allowlist/{holder}` and `POST /v1/banking/tbd/{address}/{mint,burn,transfer}` mutate it, signed by the token's owning bank. Open to both operator and tester roles (Central Bank - is the only operator-locked surface). + is operator-only). - **`operations`** — the operator audit trail. Every operator-initiated on-chain operation attempted through this API (bond lifecycle, auctions, bids, WNOK and TBD operations) is recorded in the preserved @@ -150,9 +150,11 @@ start. sync with the nb-ui frontend AUTH_MODE; mismatches fail fast at startup. - `NB_BOND_API_AUTH_ENTRA_OPERATOR_ROLES` / `NB_BOND_API_AUTH_ENTRA_TESTER_ROLES` – comma-separated Entra App Role values for role-based access in `entra` mode. - Operator roles gate the Central Bank endpoints (`/v1/central-bank/*`) and are - required; any recognised role (operator or tester) is needed for other - authenticated endpoints. Must match the nb-ui `AUTH_OPERATOR_ROLES` / + Operator roles gate the issuer actions (bond create / disable, auction + create / close / cancel / finalise, coupon payments), `/v1/admin/*`, and the + Central Bank endpoints (`/v1/central-bank/*`) and are required; any + recognised role (operator or tester) is needed for other authenticated + endpoints (see DEVELOPMENT.md §7.7 for the route matrix). Must match the nb-ui `AUTH_OPERATOR_ROLES` / `AUTH_TESTER_ROLES`. ## Live updates diff --git a/services/nb-bond-api/openapi.json b/services/nb-bond-api/openapi.json index ebc6f68..23a8d7d 100644 --- a/services/nb-bond-api/openapi.json +++ b/services/nb-bond-api/openapi.json @@ -297,6 +297,7 @@ ], "operationId": "createBond", "summary": "Create a bond partition without scheduling an auction. The first auction is scheduled separately via POST /v1/bonds/{isin}/auctions.", + "description": "Operator-only issuer action. In `entra` auth mode the bearer token must carry an operator App Role; any other recognised role gets 403 Forbidden before the request is validated. Open in `none` mode.", "requestBody": { "required": true, "content": { @@ -458,6 +459,7 @@ ], "operationId": "disableBond", "summary": "Soft-delete a bond. Requires no minted supply, no in-flight auction, and no FINALISED auction in history. Idempotent — returns 204 even if the bond is already disabled.", + "description": "Operator-only issuer action. In `entra` auth mode the bearer token must carry an operator App Role; any other recognised role gets 403 Forbidden before the request is validated. Open in `none` mode.", "parameters": [ { "in": "path", @@ -683,6 +685,7 @@ ], "operationId": "createAuction", "summary": "Create an auction under a bond. Response is the updated parent Bond.", + "description": "Operator-only issuer action. In `entra` auth mode the bearer token must carry an operator App Role; any other recognised role gets 403 Forbidden before the request is validated. Open in `none` mode.", "parameters": [ { "in": "path", @@ -926,6 +929,7 @@ ], "operationId": "closeAuction", "summary": "Transition auction to a new status. Only \"closed\" accepted today.", + "description": "Operator-only issuer action. In `entra` auth mode the bearer token must carry an operator App Role; any other recognised role gets 403 Forbidden before the request is validated. Open in `none` mode.", "parameters": [ { "in": "path", @@ -1030,6 +1034,7 @@ ], "operationId": "cancelAuction", "summary": "Cancel auction (soft-delete; stays on-chain with status=\"cancelled\")", + "description": "Operator-only issuer action. In `entra` auth mode the bearer token must carry an operator App Role; any other recognised role gets 403 Forbidden before the request is validated. Open in `none` mode.", "parameters": [ { "in": "path", @@ -1116,6 +1121,7 @@ ], "operationId": "finaliseAuction", "summary": "Approve and submit the selected allocation", + "description": "Operator-only issuer action. In `entra` auth mode the bearer token must carry an operator App Role; any other recognised role gets 403 Forbidden before the request is validated. Open in `none` mode.", "parameters": [ { "in": "path", diff --git a/services/nb-bond-api/src/app.ts b/services/nb-bond-api/src/app.ts index 0233c2f..283c7d4 100644 --- a/services/nb-bond-api/src/app.ts +++ b/services/nb-bond-api/src/app.ts @@ -425,52 +425,63 @@ export function createApp(dependencies: AppDependencies = {}): express.Express { } }); + // Issuer actions — creating and disabling bonds, and creating, closing, + // cancelling, and finalising auctions — are operator-only. Each route mounts + // requireAnyRole(operatorRoles) ahead of validation, so a tester gets 403 + // before any input is inspected. No-op in `none` mode. + // Pre-stage a bond without scheduling an auction. The first auction is // scheduled separately via POST /v1/bonds/{isin}/auctions. - app.post('/v1/bonds', validateRequest(createBondBodySchema), async (req, res, next) => { - try { - const body = req.body as CreateBondBody; - - let maturitySeconds: bigint; + app.post( + '/v1/bonds', + requireAnyRole(operatorRoles), + validateRequest(createBondBodySchema), + async (req, res, next) => { try { - maturitySeconds = parseBigInt(body.maturityDuration, 'maturityDuration'); - } catch (err) { - throw badRequest((err as Error).message); - } - if (maturitySeconds <= 0n) throw badRequest('maturityDuration must be positive'); + const body = req.body as CreateBondBody; - const bondManager = await getBondManager(); - const sent = await withOperationRecording( - { - db: biddersDb, - opType: 'BOND_CREATE', - target: body.isin, - detail: { maturityDuration: body.maturityDuration }, - interfaces: [bondManager.interface], - txHashOf: (sent) => sent.tx.hash, - }, - async () => { - await bondManager.deployBond.staticCall(body.isin, maturitySeconds); - return sendWithManagedNonce(async (nonce) => - bondManager.deployBond(body.isin, maturitySeconds, { nonce }), - ); - }, - ); - await awaitMutationProjection(sent, { type: 'bond', id: body.isin }); + let maturitySeconds: bigint; + try { + maturitySeconds = parseBigInt(body.maturityDuration, 'maturityDuration'); + } catch (err) { + throw badRequest((err as Error).message); + } + if (maturitySeconds <= 0n) throw badRequest('maturityDuration must be positive'); - const bond = await composeBond(historyDb, body.isin); - if (!bond) throw notFound(`bond ${body.isin} not found after creation`); - successResponse(req, res, bond, { status: 201 }); - } catch (err) { - next(err); - } - }); + const bondManager = await getBondManager(); + const sent = await withOperationRecording( + { + db: biddersDb, + opType: 'BOND_CREATE', + target: body.isin, + detail: { maturityDuration: body.maturityDuration }, + interfaces: [bondManager.interface], + txHashOf: (sent) => sent.tx.hash, + }, + async () => { + await bondManager.deployBond.staticCall(body.isin, maturitySeconds); + return sendWithManagedNonce(async (nonce) => + bondManager.deployBond(body.isin, maturitySeconds, { nonce }), + ); + }, + ); + await awaitMutationProjection(sent, { type: 'bond', id: body.isin }); + + const bond = await composeBond(historyDb, body.isin); + if (!bond) throw notFound(`bond ${body.isin} not found after creation`); + successResponse(req, res, bond, { status: 201 }); + } catch (err) { + next(err); + } + }, + ); // Soft-delete a bond. Requires no minted supply, no in-flight auction, // and no FINALISED auction in history. Idempotent: 204 even if already // disabled (the contract's BondAlreadyDisabled is the no-op signal). app.delete( '/v1/bonds/:isin', + requireAnyRole(operatorRoles), validateRequest(isinParamSchema, 'params'), async (req, res, next) => { try { @@ -560,8 +571,8 @@ export function createApp(dependencies: AppDependencies = {}): express.Express { app.post( '/v1/bonds/:isin/coupon-payments', // Operator-only — pays the coupon cash leg from the government - // reserve. The rest of /v1/bonds stays tester-accessible; no-op in - // `none` mode, 403 for non-operator roles in entra mode. + // reserve. Bond reads stay tester-accessible; no-op in `none` mode, + // 403 for non-operator roles in entra mode. requireAnyRole(operatorRoles), validateRequest(isinParamSchema, 'params'), validateRequest(holdersBodySchema), @@ -631,6 +642,7 @@ export function createApp(dependencies: AppDependencies = {}): express.Express { app.post( '/v1/bonds/:isin/auctions', + requireAnyRole(operatorRoles), validateRequest(isinParamSchema, 'params'), validateRequest(createAuctionBodySchema), async (req, res, next) => { @@ -679,6 +691,7 @@ export function createApp(dependencies: AppDependencies = {}): express.Express { app.patch( '/v1/auctions/:auctionId', + requireAnyRole(operatorRoles), validateRequest(auctionIdParamSchema, 'params'), validateRequest(closeAuctionBodySchema), async (req, res, next) => { @@ -696,6 +709,7 @@ export function createApp(dependencies: AppDependencies = {}): express.Express { app.delete( '/v1/auctions/:auctionId', + requireAnyRole(operatorRoles), validateRequest(auctionIdParamSchema, 'params'), async (req, res, next) => { try { @@ -710,6 +724,7 @@ export function createApp(dependencies: AppDependencies = {}): express.Express { app.put( '/v1/auctions/:auctionId/finalisation', + requireAnyRole(operatorRoles), validateRequest(auctionIdParamSchema, 'params'), validateRequest(finaliseBodySchema), async (req, res, next) => { @@ -1194,8 +1209,8 @@ export function createApp(dependencies: AppDependencies = {}): express.Express { // #region Banking (TBD) ────────────────────────────────────────────── // Banking (TBD) is open to both operator and tester roles so testers can - // exercise bank-money flows; Central Bank stays the only operator-locked - // surface. No-op in `none` mode; 403 for unrecognised tokens in `entra`. + // exercise bank-money flows; Central Bank stays operator-only. No-op in + // `none` mode; 403 for unrecognised tokens in `entra`. app.use('/v1/banking', requireAnyRole(recognizedRoles)); app.get('/v1/banking/tbd', async (req, res, next) => { diff --git a/services/nb-bond-api/src/contracts/auctions.ts b/services/nb-bond-api/src/contracts/auctions.ts index f4d0de6..4940823 100644 --- a/services/nb-bond-api/src/contracts/auctions.ts +++ b/services/nb-bond-api/src/contracts/auctions.ts @@ -1,7 +1,12 @@ import { z } from 'zod'; import type { ZodOpenApiPathsObject } from 'zod-openapi'; -import { errorRefs, mutationAcceptedJson, successJson } from '../openapi/shared-responses'; +import { + errorRefs, + mutationAcceptedJson, + operatorOnlyDescription, + successJson, +} from '../openapi/shared-responses'; import { addressSchema, @@ -242,6 +247,7 @@ export const auctionPaths: ZodOpenApiPathsObject = { tags: ['auctions'], operationId: 'closeAuction', summary: 'Transition auction to a new status. Only "closed" accepted today.', + description: operatorOnlyDescription, parameters: [auctionIdPathParam, testModeQueryParam], requestBody: { required: true, @@ -257,6 +263,7 @@ export const auctionPaths: ZodOpenApiPathsObject = { tags: ['auctions'], operationId: 'cancelAuction', summary: 'Cancel auction (soft-delete; stays on-chain with status="cancelled")', + description: operatorOnlyDescription, parameters: [auctionIdPathParam], responses: { 200: successJson('Cancelled auction (status="cancelled")', auctionSchema, true), @@ -270,6 +277,7 @@ export const auctionPaths: ZodOpenApiPathsObject = { tags: ['auctions'], operationId: 'finaliseAuction', summary: 'Approve and submit the selected allocation', + description: operatorOnlyDescription, parameters: [auctionIdPathParam], requestBody: { required: true, diff --git a/services/nb-bond-api/src/contracts/bonds.ts b/services/nb-bond-api/src/contracts/bonds.ts index 48e9f66..18b707a 100644 --- a/services/nb-bond-api/src/contracts/bonds.ts +++ b/services/nb-bond-api/src/contracts/bonds.ts @@ -2,7 +2,12 @@ import { z } from 'zod'; import type { ZodOpenApiPathsObject } from 'zod-openapi'; import { auctionSchema, createAuctionBodySchema } from './auctions'; -import { errorRefs, mutationAcceptedJson, successJson } from '../openapi/shared-responses'; +import { + errorRefs, + mutationAcceptedJson, + operatorOnlyDescription, + successJson, +} from '../openapi/shared-responses'; import { addressSchema, auctionIdSchema, @@ -202,6 +207,7 @@ export const bondPaths: ZodOpenApiPathsObject = { summary: 'Create a bond partition without scheduling an auction. The first auction is scheduled ' + 'separately via POST /v1/bonds/{isin}/auctions.', + description: operatorOnlyDescription, requestBody: { required: true, content: { 'application/json': { schema: createBondBodySchema } }, @@ -230,6 +236,7 @@ export const bondPaths: ZodOpenApiPathsObject = { summary: 'Soft-delete a bond. Requires no minted supply, no in-flight auction, and no FINALISED ' + 'auction in history. Idempotent — returns 204 even if the bond is already disabled.', + description: operatorOnlyDescription, parameters: [isinPathParam], responses: { 204: { description: 'Bond disabled (or was already disabled)' }, @@ -289,6 +296,7 @@ export const bondPaths: ZodOpenApiPathsObject = { tags: ['auctions'], operationId: 'createAuction', summary: 'Create an auction under a bond. Response is the updated parent Bond.', + description: operatorOnlyDescription, parameters: [isinPathParam], requestBody: { required: true, diff --git a/services/nb-bond-api/src/openapi/shared-responses.ts b/services/nb-bond-api/src/openapi/shared-responses.ts index 433a023..9a05783 100644 --- a/services/nb-bond-api/src/openapi/shared-responses.ts +++ b/services/nb-bond-api/src/openapi/shared-responses.ts @@ -82,6 +82,15 @@ export const errorRefs = { }, }; +/** + * Operation description for issuer actions gated to operators. The 403 itself + * is the shared Forbidden response already listed in `errorRefs.mutate`. + */ +export const operatorOnlyDescription = + 'Operator-only issuer action. In `entra` auth mode the bearer token must carry an operator ' + + 'App Role; any other recognised role gets 403 Forbidden before the request is validated. ' + + 'Open in `none` mode.'; + export const noContent204 = { description: 'No Content — operation succeeded; response body intentionally empty.', }; diff --git a/services/nb-bond-api/tests/route-authorization.test.ts b/services/nb-bond-api/tests/route-authorization.test.ts index 59c8d45..fe2812d 100644 --- a/services/nb-bond-api/tests/route-authorization.test.ts +++ b/services/nb-bond-api/tests/route-authorization.test.ts @@ -1,6 +1,7 @@ /** * Route-level authorization in `entra` mode, through the real auth middleware - * and the real route wiring in createApp. + * and the real route wiring in createApp. The ROUTES table covers every + * mutating route, so removing or misplacing any gate fails a row. * * `jose` is mocked so a bearer token's text becomes its `roles` claim * (`Bearer operator` → the operator role, `Bearer tester` → the tester role). @@ -54,6 +55,85 @@ const { createBidder } = require('../src/bidders') as typeof import('../src/bidd type Caller = 'operator' | 'tester'; +const BAD_ID = 'not-a-bytes32'; +const BAD_ADDRESS = 'not-an-address'; +const ISIN = 'NO0000000001'; + +/** + * Every mutating route with an input that fails validation right after the + * gate, so a passing gate answers without reaching the chain. `pass` is the + * status a caller who clears the gate gets. `DELETE /v1/bonds/{isin}` has no + * invalid path value, so it reaches the handler and fails on the offline + * chain (500); `/v1/admin` is covered through its prefix guard. + */ +const ROUTES: Array<{ + gate: 'operator' | 'recognised'; + method: string; + path: string; + body?: unknown; + pass: number; +}> = [ + // Issuer actions (operator-only). + { gate: 'operator', method: 'POST', path: '/v1/bonds', body: {}, pass: 400 }, + { gate: 'operator', method: 'DELETE', path: `/v1/bonds/${ISIN}`, pass: 500 }, + { gate: 'operator', method: 'POST', path: `/v1/bonds/${ISIN}/auctions`, body: {}, pass: 400 }, + { + gate: 'operator', + method: 'PATCH', + path: `/v1/auctions/${BAD_ID}`, + body: { status: 'closed' }, + pass: 400, + }, + { gate: 'operator', method: 'DELETE', path: `/v1/auctions/${BAD_ID}`, pass: 400 }, + { + gate: 'operator', + method: 'PUT', + path: `/v1/auctions/${BAD_ID}/finalisation`, + body: { approve: true }, + pass: 400, + }, + { + gate: 'operator', + method: 'POST', + path: `/v1/bonds/${ISIN}/coupon-payments`, + body: { holders: [BAD_ADDRESS] }, + pass: 400, + }, + // Admin and Central Bank prefix guards (operator-only). + { gate: 'operator', method: 'POST', path: '/v1/admin/no-such-route', pass: 404 }, + { + gate: 'operator', + method: 'PUT', + path: `/v1/central-bank/allowlist/${BAD_ADDRESS}`, + pass: 400, + }, + { gate: 'operator', method: 'POST', path: '/v1/central-bank/wnok/mint', body: {}, pass: 400 }, + // Bidding and Banking (any recognised role). + { gate: 'recognised', method: 'POST', path: '/v1/bidders', body: {}, pass: 400 }, + { gate: 'recognised', method: 'DELETE', path: `/v1/bidders/${BAD_ADDRESS}`, pass: 400 }, + { + gate: 'recognised', + method: 'POST', + path: `/v1/bidders/${BAD_ADDRESS}/bids`, + body: {}, + pass: 400, + }, + { gate: 'recognised', method: 'POST', path: '/v1/banking/banks', body: {}, pass: 400 }, + { + gate: 'recognised', + method: 'PUT', + path: `/v1/banking/tbd/${BAD_ADDRESS}/allowlist/${BAD_ADDRESS}`, + pass: 400, + }, + { + gate: 'recognised', + method: 'POST', + path: `/v1/banking/tbd/${BAD_ADDRESS}/mint`, + body: {}, + pass: 400, + }, +]; + describe('route authorization (entra mode)', () => { const db = openDatabase({ dbPath: ':memory:', readonly: false }) as IngestionDatabase & { close: () => void; @@ -88,6 +168,21 @@ describe('route authorization (entra mode)', () => { }); } + it.each(ROUTES)('$method $path: operators clear the gate', async (route) => { + const response = await call('operator', route.method, route.path, route.body); + expect(response.status).toBe(route.pass); + }); + + it.each(ROUTES)('$method $path: testers get the $gate gate', async (route) => { + const response = await call('tester', route.method, route.path, route.body); + expect(response.status).toBe(route.gate === 'operator' ? 403 : route.pass); + }); + + it('rejects a request without a bearer token', async () => { + const response = await fetch(`${baseUrl}/v1/bonds`, { method: 'POST' }); + expect(response.status).toBe(401); + }); + it('returns no bidder private key outside none mode, for testers and operators alike', async () => { createBidder(db, { name: 'Generated' }); for (const caller of ['tester', 'operator'] as const) { diff --git a/services/nb-ui/DEVELOPMENT.md b/services/nb-ui/DEVELOPMENT.md index 043b3f6..78f67d7 100644 --- a/services/nb-ui/DEVELOPMENT.md +++ b/services/nb-ui/DEVELOPMENT.md @@ -99,7 +99,10 @@ touching `services/nb-ui/**` via `.github/workflows/nb-ui.yml`. ## Pluggable auth -The auth seam is `src/auth/index.js`. It resolves one of: +The auth seam is `src/auth/index.js`. It resolves one of the providers +below. Any other `AUTH_MODE` value is a configuration error: the UI renders +only a page naming the bad value (`src/components/ConfigErrorPage.jsx`) and +`capabilities.js` grants nothing, so no page mounts and nothing calls the API. - `noneAuth` — no `Authorization` header, no sign-in UI. Default; the local sandbox runs this way because the NB Bond API has no auth (per @@ -126,10 +129,17 @@ claims first, access-token decode as a fallback) and mapped to capabilities by - The Central Bank nav item is hidden for non-operators and the `#/central-bank` route is guarded, so the page never mounts for them. - Operator-only controls read `canOperate` from `CapabilitiesContext` - (`src/auth/capabilitiesContext.js`, provided by `App.jsx`). The top-bar - test-mode toggle is hidden for testers, and `getTestMode()` - (`src/utils/debugSettings.js`) ignores a stored flag for any account that - cannot operate, so a tester never sends `?testMode=true`. + (`src/auth/capabilitiesContext.js`, provided by `App.jsx`) and are hidden, + not merely disabled, for testers: + - issuer actions: "+ New bond" (Bonds), "+ New auction" (Auctions), + "Disable bond…" (bond detail), and Close / Select winners & finalise / + Cancel in the auction lifecycle panel; + - Reconnect and Resync in the Network Health modal (Refresh stays); + - the top-bar test-mode toggle. `getTestMode()` + (`src/utils/debugSettings.js`) also ignores a stored flag for any account + that cannot operate, so a tester never sends `?testMode=true`. +- Testers keep reads, bidding (the Bidders page and "Place bid"), and the + Banking surface. - The Bidders page shows "Held by the server" when the API returns `privateKey: null` (always in `entra` mode, and for keys supplied by the API's `PK_*` overrides). diff --git a/services/nb-ui/README.md b/services/nb-ui/README.md index 52a488b..a75b327 100644 --- a/services/nb-ui/README.md +++ b/services/nb-ui/README.md @@ -69,8 +69,10 @@ Banking). `#/bidders`, `#/central-bank`, and `#/tbd` carry a visible these pages must never be enabled outside the local sandbox. In Entra mode, Banking is available to authenticated users with a recognised -operator or tester App Role. Central Bank and administrative operations remain -operator-only. Local `none` mode keeps all sandbox surfaces open. +operator or tester App Role. Central Bank, administrative operations, and the +issuer actions (creating and disabling bonds; creating, closing, cancelling, +and finalising auctions) remain operator-only, and their controls are hidden +from testers. Local `none` mode keeps all sandbox surfaces open. See `docs/plans/archive/nb-ui-frontend-plan.md` for the original frontend plan and `docs/plans/archive/bidders-and-central-bank-plan.md` for the bidders + diff --git a/services/nb-ui/src/App.jsx b/services/nb-ui/src/App.jsx index 052af07..df618fe 100644 --- a/services/nb-ui/src/App.jsx +++ b/services/nb-ui/src/App.jsx @@ -4,7 +4,7 @@ * has no usable session. */ import { useEffect, useState } from 'react'; -import { auth, authMode } from './auth/index.js'; +import { auth, authMode, isKnownAuthMode } from './auth/index.js'; import { capabilitiesForAccount } from './auth/capabilities.js'; import { CapabilitiesContext } from './auth/capabilitiesContext.js'; import { useRoute } from './hooks/useRoute.js'; @@ -12,6 +12,7 @@ import { ToastProvider, EmptyState } from './components/ui.jsx'; import { Layout } from './components/Layout.jsx'; import { LoginPage } from './components/LoginPage.jsx'; import { AccessDeniedPage } from './components/AccessDeniedPage.jsx'; +import { ConfigErrorPage } from './components/ConfigErrorPage.jsx'; import { BondsPage } from './pages/BondsPage.jsx'; import { BondDetailPage } from './pages/BondDetailPage.jsx'; import { AuctionsPage } from './pages/AuctionsPage.jsx'; @@ -69,6 +70,12 @@ export function App() { return unsubscribe; }, []); + // An unrecognised AUTH_MODE is a deployment error: refuse to run rather than + // guess at a mode. The API fails fast on the same mistake. + if (!isKnownAuthMode) { + return ; + } + if (authMode === 'entra') { // Hold rendering until handleRedirectPromise has settled, so returning // from the Entra redirect shows a splash instead of a login-page flash. diff --git a/services/nb-ui/src/auth/capabilities.js b/services/nb-ui/src/auth/capabilities.js index e4cf495..1855fc0 100644 --- a/services/nb-ui/src/auth/capabilities.js +++ b/services/nb-ui/src/auth/capabilities.js @@ -5,7 +5,8 @@ * * In `none` mode (the local sandbox default) the UI is fully open: there are * no roles and no gating, matching the unauthenticated backend. Gating only - * applies in `entra` mode. + * applies in `entra` mode. Any other mode is a misconfiguration and gets no + * access at all. * * Role-value strings come from runtime config (AUTH_OPERATOR_ROLES / * AUTH_TESTER_ROLES) so they are never baked into the bundle — see @@ -48,16 +49,19 @@ const NO_ACCESS = { * }} */ export function capabilitiesForAccount(account) { + const mode = AppConfig.AUTH_MODE || 'none'; // Non-interactive auth (local sandbox) is fully open. - if ((AppConfig.AUTH_MODE || 'none') !== 'entra') return FULL_ACCESS; + if (mode === 'none') return FULL_ACCESS; + // An unrecognised mode is a configuration error: grant nothing. + if (mode !== 'entra') return NO_ACCESS; const roles = account?.roles ?? []; if (roles.length === 0) return NO_ACCESS; const isOperator = roles.some((r) => operatorRoles.includes(r)); const isTester = roles.some((r) => testerRoles.includes(r)); return { canUseApp: isOperator || isTester, - // Central Bank is the only operator-locked surface; Banking (TBD) is - // open to testers so they can exercise bank-money flows end-to-end. + // Central Bank is an operator-only surface; Banking (TBD) is open to + // testers so they can exercise bank-money flows end-to-end. canAccessCentralBank: isOperator, canAccessBanking: isOperator || isTester, // Operator-only controls (test mode today). Mirrors the API, which diff --git a/services/nb-ui/src/auth/index.js b/services/nb-ui/src/auth/index.js index 004d0f7..fe86485 100644 --- a/services/nb-ui/src/auth/index.js +++ b/services/nb-ui/src/auth/index.js @@ -10,6 +10,17 @@ import { AppConfig } from '../config.js'; import { createNoneAuth } from './noneAuth.js'; import { createEntraAuth } from './entraAuth.js'; +/** + * Provider for an unrecognised AUTH_MODE. Same interface as noneAuth, but it + * never has an account and sends no Authorization header. App.jsx renders the + * configuration-error page instead of the app for such a mode, and + * capabilities.js grants it nothing, so no page mounts and nothing calls the + * API. + */ +function createMisconfiguredAuth() { + return createNoneAuth(); +} + function resolve() { switch (AppConfig.AUTH_MODE) { case 'entra': @@ -19,12 +30,14 @@ function resolve() { case undefined: return createNoneAuth(); default: - // Unknown mode: fail closed to the no-auth implementation and let the - // visible "auth mode unknown" warning in DEVELOPMENT.md guide the fix. - console.warn(`Unknown AUTH_MODE "${AppConfig.AUTH_MODE}" — falling back to no-auth.`); - return createNoneAuth(); + // Unknown mode: fail closed. The UI shows a configuration error naming + // the value instead of opening any surface. + console.error(`Unknown AUTH_MODE "${AppConfig.AUTH_MODE}" — the UI will not start.`); + return createMisconfiguredAuth(); } } export const auth = resolve(); export const authMode = AppConfig.AUTH_MODE || 'none'; +/** False when AUTH_MODE is neither `none` (or unset) nor `entra`. */ +export const isKnownAuthMode = authMode === 'none' || authMode === 'entra'; diff --git a/services/nb-ui/src/components/ConfigErrorPage.jsx b/services/nb-ui/src/components/ConfigErrorPage.jsx new file mode 100644 index 0000000..d3c11be --- /dev/null +++ b/services/nb-ui/src/components/ConfigErrorPage.jsx @@ -0,0 +1,24 @@ +/** + * ConfigErrorPage — full-page surface shown when the runtime config carries + * an AUTH_MODE the UI does not recognise. Like LoginPage, it is the only thing + * rendered: no nav, no pages, no API calls. It names the bad value so the + * deployment can be fixed; the valid values are `none` and `entra`. + */ +import { NorgesBankLogo } from './NorgesBankLogo.jsx'; + +export function ConfigErrorPage({ authMode }) { + return ( +
+
+ +

Bond Auction Service

+

+ Configuration error: unknown AUTH_MODE "{authMode}". +

+

+ Set AUTH_MODE to "none" or "entra" in the runtime config and reload. +

+
+
+ ); +} diff --git a/services/nb-ui/src/pages/AuctionLifecyclePanel.jsx b/services/nb-ui/src/pages/AuctionLifecyclePanel.jsx index 92506a3..b930680 100644 --- a/services/nb-ui/src/pages/AuctionLifecyclePanel.jsx +++ b/services/nb-ui/src/pages/AuctionLifecyclePanel.jsx @@ -8,9 +8,13 @@ * - Close auction (open → closed, PATCH) * - Approve allocation (closed → finalised, PUT) * - Cancel auction (open|closed → cancelled, DELETE) + * + * The actions are issuer actions: shown only to accounts that can operate + * (always in `none` mode). Everyone else sees the read-only stepper. */ import { useState } from 'react'; import { Fmt } from '../utils/format.js'; +import { useCapabilities } from '../auth/capabilitiesContext.js'; import { Button, Field, Input, Modal, StatusBadge } from '../components/ui.jsx'; import { FinaliseAuctionModal } from './FinaliseAuctionModal.jsx'; @@ -202,6 +206,7 @@ function CancelModal({ auction, onClose, onConfirm, busy }) { } export function AuctionLifecyclePanel({ auction, onClose, onFinalise, onCancel, busy }) { + const { canOperate } = useCapabilities(); const [modal, setModal] = useState(null); const s = auction.status; @@ -238,7 +243,7 @@ export function AuctionLifecyclePanel({ auction, onClose, onFinalise, onCancel, {summary &&

{summary}

} - {!terminal && ( + {!terminal && canOperate && (
diff --git a/services/nb-ui/src/pages/AuctionsPage.jsx b/services/nb-ui/src/pages/AuctionsPage.jsx index c8af455..5b07b4a 100644 --- a/services/nb-ui/src/pages/AuctionsPage.jsx +++ b/services/nb-ui/src/pages/AuctionsPage.jsx @@ -5,6 +5,7 @@ import { useState, useMemo } from 'react'; import { AuctionsApi } from '../api/auctionsApi.js'; import { LiveResource, useLiveQuery } from '../sync/LiveUpdatesProvider.jsx'; import { Fmt } from '../utils/format.js'; +import { useCapabilities } from '../auth/capabilitiesContext.js'; import { Button, EmptyState, @@ -39,6 +40,7 @@ function writeHideCancelled(value) { } export function AuctionsPage({ navigate }) { + const { canOperate } = useCapabilities(); const { data, loading, error, refreshing, refreshError, reload } = useLiveQuery( [LiveResource.AUCTIONS], () => AuctionsApi.listAuctions(), @@ -110,9 +112,11 @@ export function AuctionsPage({ navigate }) { - + {canOperate && ( + + )}
@@ -205,7 +209,8 @@ export function AuctionsPage({ navigate }) { auctions.length === 0 ? 'Run an auction to get started.' : 'Adjust the filters above.' } action={ - auctions.length === 0 && ( + auctions.length === 0 && + canOperate && ( diff --git a/services/nb-ui/src/pages/BondDetailPage.jsx b/services/nb-ui/src/pages/BondDetailPage.jsx index aca2f1d..a5c2f9b 100644 --- a/services/nb-ui/src/pages/BondDetailPage.jsx +++ b/services/nb-ui/src/pages/BondDetailPage.jsx @@ -10,6 +10,7 @@ import { BondsApi } from '../api/bondsApi.js'; import { useMutation } from '../hooks/useApi.js'; import { LiveResource, useLiveQuery } from '../sync/LiveUpdatesProvider.jsx'; import { Fmt } from '../utils/format.js'; +import { useCapabilities } from '../auth/capabilitiesContext.js'; import { Button, EmptyState, @@ -22,6 +23,7 @@ import { import { ConfirmDisableBondModal } from './ConfirmDisableBondModal.jsx'; export function BondDetailPage({ isin, navigate }) { + const { canOperate } = useCapabilities(); const bondQ = useLiveQuery( [LiveResource.BONDS, LiveResource.AUCTIONS], () => BondsApi.getBond(isin), @@ -111,7 +113,7 @@ export function BondDetailPage({ isin, navigate }) { - {canDisable && ( + {canOperate && canDisable && ( - + {canOperate && ( + + )}
@@ -190,7 +194,8 @@ export function BondsPage({ navigate }) { : 'Adjust the filters above.' } action={ - bonds.length === 0 && ( + bonds.length === 0 && + canOperate && ( diff --git a/services/nb-ui/src/pages/NetworkHealthModal.jsx b/services/nb-ui/src/pages/NetworkHealthModal.jsx index ee3c9b5..52d0e59 100644 --- a/services/nb-ui/src/pages/NetworkHealthModal.jsx +++ b/services/nb-ui/src/pages/NetworkHealthModal.jsx @@ -7,6 +7,10 @@ * - **Resync from block 0** drops the projection and rebuilds from * chain. Gated behind ConfirmResyncModal (type-to-confirm). * + * Both recovery actions call the operator-only /v1/admin surface, so they + * are shown only to accounts that can operate (always in `none` mode); + * everyone else gets the read-only view with Refresh. + * * The modal shares the same poll as the HealthBadge via the * `useHealthPoll` hook — the parent passes its `health` snapshot and * `onReload` callback so the modal can refresh in place after a @@ -17,6 +21,7 @@ import { Button, Modal, useToast } from '../components/ui.jsx'; import { summarise } from '../components/HealthBadge.jsx'; import { HealthApi } from '../api/healthApi.js'; import { ConfirmResyncModal } from './ConfirmResyncModal.jsx'; +import { useCapabilities } from '../auth/capabilitiesContext.js'; function formatRelative(ts) { if (!ts) return '—'; @@ -74,6 +79,7 @@ const STATUS_COLOR = { }; export function NetworkHealthModal({ health, onReload, onClose }) { + const { canOperate } = useCapabilities(); const toast = useToast(); const [confirmOpen, setConfirmOpen] = useState(false); const [reconnecting, setReconnecting] = useState(false); @@ -130,12 +136,20 @@ export function NetworkHealthModal({ health, onReload, onClose }) { - - + {canOperate && ( + <> + + + + )} } > diff --git a/services/nb-ui/tests/AuctionLifecyclePanel.test.jsx b/services/nb-ui/tests/AuctionLifecyclePanel.test.jsx new file mode 100644 index 0000000..aa7dece --- /dev/null +++ b/services/nb-ui/tests/AuctionLifecyclePanel.test.jsx @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { AuctionLifecyclePanel } from '../src/pages/AuctionLifecyclePanel.jsx'; +import { CapabilitiesContext } from '../src/auth/capabilitiesContext.js'; + +// Feature: close, finalise, and cancel are issuer actions. The lifecycle panel +// offers them only to accounts that can operate; everyone else sees the +// read-only stepper. + +const OPEN_AUCTION = { id: '0xauction1', status: 'open', bids: [], allocation: null }; +const CLOSED_AUCTION = { ...OPEN_AUCTION, status: 'closed' }; + +const OPERATOR = { + canUseApp: true, + canAccessCentralBank: true, + canAccessBanking: true, + canOperate: true, +}; +const TESTER = { ...OPERATOR, canAccessCentralBank: false, canOperate: false }; + +function renderPanel(auction, capabilities) { + return render( + + + , + ); +} + +describe('AuctionLifecyclePanel', () => { + it('offers close and cancel on an open auction to an operator', () => { + renderPanel(OPEN_AUCTION, OPERATOR); + expect(screen.getByRole('button', { name: /Close auction/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Cancel auction/ })).toBeInTheDocument(); + }); + + it('offers no lifecycle action to an account that cannot operate', () => { + renderPanel(OPEN_AUCTION, TESTER); + expect(screen.queryByRole('button', { name: /Close auction/ })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Cancel auction/ })).not.toBeInTheDocument(); + + renderPanel(CLOSED_AUCTION, TESTER); + expect(screen.queryByRole('button', { name: /finalise/i })).not.toBeInTheDocument(); + }); +}); diff --git a/services/nb-ui/tests/BondDetailPage.test.jsx b/services/nb-ui/tests/BondDetailPage.test.jsx index 929714d..b76c195 100644 --- a/services/nb-ui/tests/BondDetailPage.test.jsx +++ b/services/nb-ui/tests/BondDetailPage.test.jsx @@ -86,6 +86,31 @@ describe('BondDetailPage disable affordance', () => { expect(await screen.findByRole('button', { name: /Disable bond/i })).toBeInTheDocument(); }); + it('hides the button for an account that cannot operate', async () => { + getBondMock.mockResolvedValue(BOND_DISABLE_ABLE); + const { BondDetailPage } = await import('../src/pages/BondDetailPage.jsx'); + const { ToastProvider } = await import('../src/components/ui.jsx'); + const { CapabilitiesContext } = await import('../src/auth/capabilitiesContext.js'); + + render( + + + {}} /> + + , + ); + + await screen.findByRole('button', { name: 'Refresh' }); + expect(screen.queryByRole('button', { name: /Disable bond/i })).toBeNull(); + }); + it('hides the button when the bond has minted supply', async () => { getBondMock.mockResolvedValue(BOND_HAS_SUPPLY); const { BondDetailPage } = await import('../src/pages/BondDetailPage.jsx'); diff --git a/services/nb-ui/tests/BondsPage.test.jsx b/services/nb-ui/tests/BondsPage.test.jsx index 69a0dc2..4b129fc 100644 --- a/services/nb-ui/tests/BondsPage.test.jsx +++ b/services/nb-ui/tests/BondsPage.test.jsx @@ -117,6 +117,30 @@ describe('BondsPage', () => { expect(screen.getByText('NO0011223344')).toBeInTheDocument(); }); + it('hides "+ New bond" for an account that cannot operate', async () => { + const { BondsPage } = await import('../src/pages/BondsPage.jsx'); + const { ToastProvider } = await import('../src/components/ui.jsx'); + const { CapabilitiesContext } = await import('../src/auth/capabilitiesContext.js'); + + render( + + + {}} /> + + , + ); + + await waitFor(() => screen.getByText('NO0012345678')); + expect(screen.queryByRole('button', { name: /New bond/ })).not.toBeInTheDocument(); + }); + it('filters bonds by ISIN substring', async () => { const { BondsPage } = await import('../src/pages/BondsPage.jsx'); const { ToastProvider } = await import('../src/components/ui.jsx'); diff --git a/services/nb-ui/tests/NetworkHealthModal.test.jsx b/services/nb-ui/tests/NetworkHealthModal.test.jsx index 77ffa11..90452c1 100644 --- a/services/nb-ui/tests/NetworkHealthModal.test.jsx +++ b/services/nb-ui/tests/NetworkHealthModal.test.jsx @@ -3,8 +3,9 @@ import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; // Feature: NetworkHealthModal pretty-prints /v1/health, surfaces recent -// errors, and exposes Reconnect + Resync-from-block-0 actions. The -// destructive Resync is gated behind a type-to-confirm phrase. +// errors, and exposes Reconnect + Resync-from-block-0 actions to accounts +// that can operate. The destructive Resync is gated behind a type-to-confirm +// phrase. const okHealth = (overrides = {}) => ({ status: 'ok', @@ -33,22 +34,31 @@ async function loadModal({ restartIngestion } = {}) { restartIngestion: restart, }, })); - const [{ NetworkHealthModal }, { ToastProvider }] = await Promise.all([ + const [{ NetworkHealthModal }, { ToastProvider }, { CapabilitiesContext }] = await Promise.all([ import('../src/pages/NetworkHealthModal.jsx'), import('../src/components/ui.jsx'), + import('../src/auth/capabilitiesContext.js'), ]); - return { NetworkHealthModal, ToastProvider, restart }; + return { NetworkHealthModal, ToastProvider, CapabilitiesContext, restart }; } function renderModal(NetworkHealthModal, ToastProvider, health, overrides = {}) { - return render( + const modal = ( - , + + ); + const { CapabilitiesContext, capabilities } = overrides; + return render( + CapabilitiesContext ? ( + {modal} + ) : ( + modal + ), ); } @@ -93,6 +103,23 @@ describe('NetworkHealthModal', () => { expect(screen.getByText('getaddrinfo EAI_AGAIN')).toBeInTheDocument(); }); + it('hides Reconnect and Resync for an account that cannot operate', async () => { + const { NetworkHealthModal, ToastProvider, CapabilitiesContext } = await loadModal(); + renderModal(NetworkHealthModal, ToastProvider, okHealth(), { + CapabilitiesContext, + capabilities: { + canUseApp: true, + canAccessCentralBank: false, + canAccessBanking: true, + canOperate: false, + }, + }); + + expect(screen.getByRole('button', { name: 'Refresh' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Reconnect/ })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Resync from block 0/ })).not.toBeInTheDocument(); + }); + it('Reconnect button calls HealthApi.restartIngestion() and reloads', async () => { const restart = vi.fn().mockResolvedValue({ restarted: true }); const onReload = vi.fn().mockResolvedValue(undefined); diff --git a/services/nb-ui/tests/auth.test.js b/services/nb-ui/tests/auth.test.js index b41c766..5b82831 100644 --- a/services/nb-ui/tests/auth.test.js +++ b/services/nb-ui/tests/auth.test.js @@ -24,13 +24,16 @@ describe('auth plugin resolver', () => { expect(await auth.getAuthHeader()).toBeNull(); }); - it('falls back to no-auth on an unknown AUTH_MODE (with a warning)', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + it('fails closed on an unknown AUTH_MODE (flagged, no account, no header)', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); window.__APP_CONFIG__.AUTH_MODE = 'pigeon-courier'; - const { auth } = await import('../src/auth/index.js'); + const { auth, authMode, isKnownAuthMode } = await import('../src/auth/index.js'); + expect(authMode).toBe('pigeon-courier'); + expect(isKnownAuthMode).toBe(false); + expect(auth.getAccount()).toBeNull(); expect(await auth.getAuthHeader()).toBeNull(); - expect(warn).toHaveBeenCalled(); - warn.mockRestore(); + expect(error).toHaveBeenCalled(); + error.mockRestore(); }); it('selects the Entra provider when AUTH_MODE is "entra"', async () => { diff --git a/services/nb-ui/tests/authGate.test.jsx b/services/nb-ui/tests/authGate.test.jsx index 1751a60..fb10eec 100644 --- a/services/nb-ui/tests/authGate.test.jsx +++ b/services/nb-ui/tests/authGate.test.jsx @@ -145,8 +145,7 @@ describe('auth gate (entra mode)', () => { expect(await screen.findByRole('button', { name: /Securities/ })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: /Central Bank/ })).not.toBeInTheDocument(); - // Banking is tester-accessible — Central Bank is the only - // operator-locked surface. + // Banking is tester-accessible — Central Bank is operator-only. expect(screen.getByRole('button', { name: /Banking/ })).toBeInTheDocument(); // The test-mode toggle is operator-only. expect(screen.queryByRole('button', { name: /Test:/ })).not.toBeInTheDocument(); @@ -199,6 +198,37 @@ describe('auth gate (entra mode)', () => { }); }); +describe('auth gate (issuer actions)', () => { + it('shows "+ New auction" to an operator but not to a tester', async () => { + const operator = await loadApp({ accounts: [operatorAccount] }); + window.location.hash = '#/auctions'; + const { unmount } = render(); + expect(await screen.findByRole('button', { name: /New auction/ })).toBeInTheDocument(); + unmount(); + + const tester = await loadApp({ accounts: [testerAccount] }); + window.location.hash = '#/auctions'; + render(); + expect(await screen.findByRole('heading', { name: 'Auctions' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /New auction/ })).not.toBeInTheDocument(); + }); +}); + +describe('auth gate (unknown mode)', () => { + it('renders only the configuration error naming the bad AUTH_MODE', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { App } = await loadApp({ authMode: 'pigeon-courier' }); + render(); + + expect( + await screen.findByText('Configuration error: unknown AUTH_MODE "pigeon-courier".'), + ).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Securities/ })).not.toBeInTheDocument(); + expect(fetchMock).not.toHaveBeenCalled(); + error.mockRestore(); + }); +}); + describe('auth gate (none mode)', () => { it('never gates the local sandbox — the app renders with no login page', async () => { const { App } = await loadApp({ authMode: 'none' }); diff --git a/services/nb-ui/tests/capabilities.test.js b/services/nb-ui/tests/capabilities.test.js index cbe5999..450bcb1 100644 --- a/services/nb-ui/tests/capabilities.test.js +++ b/services/nb-ui/tests/capabilities.test.js @@ -77,6 +77,18 @@ describe('capabilitiesForAccount', () => { }); }); + it('grants nothing on an unrecognised auth mode', async () => { + const cap = await loadCapabilities({ ...entra, AUTH_MODE: 'pigeon-courier' }); + const none = { + canUseApp: false, + canAccessCentralBank: false, + canAccessBanking: false, + canOperate: false, + }; + expect(cap(null)).toEqual(none); + expect(cap({ roles: ['Sandbox.Operator'] })).toEqual(none); + }); + it('lets operator capabilities win when an account holds both roles', async () => { const cap = await loadCapabilities(entra); expect(cap({ roles: ['Sandbox.Tester', 'Sandbox.Operator'] })).toEqual({ From 00920ea20533203aa87f9c170bc1c8611772eac1 Mon Sep 17 00:00:00 2001 From: Deleo <152405392+Greaker@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:53:23 +0200 Subject: [PATCH 09/18] Add implementation plans from the September repository review (#306) Six plan folders under docs/plans/, each with intent, design, plan, and progress files, grounded in a review of the current development branch: - bond-coupon-maturity-correctness: per-holding coupon rounding, RATE auction gating after a cancel, matured partitions closed to new issuance, the BUYBACK cancel revert, and bond transfer eligibility. - ingestion-projection-correctness: abortable ingestion loop that closes its connection, chain-ordered log application, idempotent bond state, newest-first history, and finalisation that tolerates invalid bids. - nb-bond-api-hardening: per-signer nonces, one RFC 7807 error handler, liveness probe and chart defaults, scoped runtime image, cleanup. - nb-ui-correctness-and-hardening: router and error boundaries, one 401 path, matured KPI, live coupon preview, bidder soft delete, history card, nginx headers, dead-code removal. - sandbox-scripts-and-ci-hardening: flag precedence, failing readiness timeouts, command validation, bid-tool install, loopback ports, image pins, pin-drift check, CI gaps. - documentation-refresh: neutral deployment wording, plan-tree housekeeping, removed behaviour still documented, known issues, NatSpec source links, licence inventory. All plans are Proposed; the documentation index lists them. Four progress files note what changed on development after planning (#300 to #305) and which plan steps that makes moot. --- docs/DOCUMENTATION_INDEX.md | 6 + .../design.md | 305 ++++++++ .../intent.md | 115 +++ .../bond-coupon-maturity-correctness/plan.md | 514 +++++++++++++ .../progress.md | 113 +++ docs/plans/documentation-refresh/design.md | 240 ++++++ docs/plans/documentation-refresh/intent.md | 97 +++ docs/plans/documentation-refresh/plan.md | 323 ++++++++ docs/plans/documentation-refresh/progress.md | 106 +++ .../design.md | 308 ++++++++ .../intent.md | 110 +++ .../ingestion-projection-correctness/plan.md | 516 +++++++++++++ .../progress.md | 100 +++ docs/plans/nb-bond-api-hardening/design.md | 362 +++++++++ docs/plans/nb-bond-api-hardening/intent.md | 124 ++++ docs/plans/nb-bond-api-hardening/plan.md | 609 +++++++++++++++ docs/plans/nb-bond-api-hardening/progress.md | 135 ++++ .../nb-ui-correctness-and-hardening/design.md | 322 ++++++++ .../nb-ui-correctness-and-hardening/intent.md | 127 ++++ .../nb-ui-correctness-and-hardening/plan.md | 699 ++++++++++++++++++ .../progress.md | 136 ++++ .../design.md | 421 +++++++++++ .../intent.md | 131 ++++ .../sandbox-scripts-and-ci-hardening/plan.md | 656 ++++++++++++++++ .../progress.md | 108 +++ 25 files changed, 6683 insertions(+) create mode 100644 docs/plans/bond-coupon-maturity-correctness/design.md create mode 100644 docs/plans/bond-coupon-maturity-correctness/intent.md create mode 100644 docs/plans/bond-coupon-maturity-correctness/plan.md create mode 100644 docs/plans/bond-coupon-maturity-correctness/progress.md create mode 100644 docs/plans/documentation-refresh/design.md create mode 100644 docs/plans/documentation-refresh/intent.md create mode 100644 docs/plans/documentation-refresh/plan.md create mode 100644 docs/plans/documentation-refresh/progress.md create mode 100644 docs/plans/ingestion-projection-correctness/design.md create mode 100644 docs/plans/ingestion-projection-correctness/intent.md create mode 100644 docs/plans/ingestion-projection-correctness/plan.md create mode 100644 docs/plans/ingestion-projection-correctness/progress.md create mode 100644 docs/plans/nb-bond-api-hardening/design.md create mode 100644 docs/plans/nb-bond-api-hardening/intent.md create mode 100644 docs/plans/nb-bond-api-hardening/plan.md create mode 100644 docs/plans/nb-bond-api-hardening/progress.md create mode 100644 docs/plans/nb-ui-correctness-and-hardening/design.md create mode 100644 docs/plans/nb-ui-correctness-and-hardening/intent.md create mode 100644 docs/plans/nb-ui-correctness-and-hardening/plan.md create mode 100644 docs/plans/nb-ui-correctness-and-hardening/progress.md create mode 100644 docs/plans/sandbox-scripts-and-ci-hardening/design.md create mode 100644 docs/plans/sandbox-scripts-and-ci-hardening/intent.md create mode 100644 docs/plans/sandbox-scripts-and-ci-hardening/plan.md create mode 100644 docs/plans/sandbox-scripts-and-ci-hardening/progress.md diff --git a/docs/DOCUMENTATION_INDEX.md b/docs/DOCUMENTATION_INDEX.md index 2947861..d284845 100644 --- a/docs/DOCUMENTATION_INDEX.md +++ b/docs/DOCUMENTATION_INDEX.md @@ -22,6 +22,12 @@ This file lists the documentation that is most likely to need updates when behav - `docs/decisions/0004-settle-all-bond-cash-legs-in-wnok.md`: Why every bond cash leg (issuance, buyback, coupon, redemption) settles in `Wnok` from a government reserve account, removing the government-nominated `Tbd` from the bond stack while keeping the allowance-based authorization for the closed-loop plan to replace. Accepted. - `docs/decisions/0005-redeem-principal-with-the-final-coupon.md`: Why the final coupon payment also pays principal, burns every unit (unsold manager-held units without cash), and emits one `BondMatured` closure event, removing the separate redemption step and the treasury-held-units deadlock. Accepted. - `docs/plans/README.md`: How implementation plans are laid out — new plans are folders with `intent.md`, `design.md`, `plan.md`, and a committed `progress.md`; existing single-file plans stay as they are. +- `docs/plans/bond-coupon-maturity-correctness/`: Plan folder (proposed) for bond lifecycle correctness in the contracts: coupons computed per holding instead of truncated per unit, RATE-auction gating that survives a cancelled first auction and ISIN reuse, matured partitions closed to new auctions, mints, and offering extensions, the BUYBACK cancel that can revert, and bond-transfer eligibility so one holder cannot block a coupon. +- `docs/plans/ingestion-projection-correctness/`: Plan folder (proposed) that makes the NB Bond API projection safe to replay: an abortable ingestion loop that closes its SQLite connection, logs applied in chain order across contracts, position-idempotent bond-state events with a one-time rebuild, newest-first bond history with validated paging, and finalisation that tolerates an undecryptable bid. +- `docs/plans/nb-bond-api-hardening/`: Plan folder (proposed) for NB Bond API robustness: per-signer nonce management with a bounded confirmation wait, one RFC 7807 error handler (400/413/404/503 and sanitised 500s), a process-local liveness probe and safer chart defaults, a scoped runtime image, cleanup, and optional drift guards and route-module extraction. +- `docs/plans/nb-ui-correctness-and-hardening/`: Plan folder (proposed) for NB UI fixes: a non-throwing router with error boundaries, one 401 path, the matured KPI tile, a coupon modal driven by live data, bidder soft delete (API and UI), a bond history card, nginx security and cache headers, and dead-code removal. +- `docs/plans/sandbox-scripts-and-ci-hardening/`: Plan folder (proposed) for the sandbox scripts and CI: flag precedence with an untracked `.env.sandbox`, readiness timeouts that fail the run, command validation before side effects, a working bid-tool install, loopback-only host ports, registry and image-pin fixes, a delegating Makefile, a pin-drift check, and CI and verification gaps. +- `docs/plans/documentation-refresh/`: Plan folder (proposed) for pre-existing documentation drift: environment-neutral deployment wording, plan-tree housekeeping, removed behaviour still documented, known-issue pruning and additions, NatSpec source links, and the licence inventory and contributor entry points. - `docs/plans/closed-loop-settlement-and-omnibus-custody-plan.md`: Plan to settle the primary auction cash leg by central-bank authority instead of a bidder ERC-20 allowance (fixes the `ERC20InsufficientAllowance` / `FailureReason.Cash` finalisation failure), then a phased path to omnibus broker custody, a unified `PrimaryDealerRegistry`, and two-tier coupon/redemption. Phase 1 is diff-level (Roles + Wnok `settle()` + BondDvP cash-leg swap + deploy wiring); Phases 2–6 are roadmap. - `docs/plans/sandbox-fmi-wallet-plan.md`: Sandbox-only implementation plan for a multi-participant institutional wallet with encrypted local keys, typed transaction intents, simulated maker/checker approval, directory-backed recipient selection/revalidation, durable audit, and removal of bidder impersonation from the NB Bond API. - `docs/plans/sandbox-entity-directory-and-name-service-plan.md`: Standalone sandbox entity-directory/name-service plan with its own persistent database, backend API, search/admin UI, synthetic profile/contact enrichment, optional provenance-aware on-chain registry readers, and a BENS-compatible Blockscout adapter backed by the same directory data. diff --git a/docs/plans/bond-coupon-maturity-correctness/design.md b/docs/plans/bond-coupon-maturity-correctness/design.md new file mode 100644 index 0000000..6c3d344 --- /dev/null +++ b/docs/plans/bond-coupon-maturity-correctness/design.md @@ -0,0 +1,305 @@ +# Bond coupon and maturity correctness — Design + +**Status:** Draft +**Created:** 2026-09-28 +**Intent:** [`intent.md`](intent.md) +**Builds on:** [ADR 0004](../../decisions/0004-settle-all-bond-cash-legs-in-wnok.md) (every bond +cash leg in wNOK from the government reserve), [ADR 0005](../../decisions/0005-redeem-principal-with-the-final-coupon.md) +(the final coupon pays principal and closes the bond), and +[`coupon-closure-review-fixes`](../archive/coupon-closure-review-fixes/plan.md) (#278: +`DuplicateHolder`, `CouponPeriodPaid`, empty-holder closure, preview aligned to the contract). + +## Decision Summary + +Fix each defect in the contract that owns the invariant, keep every event signature unchanged, +and make the API and UI mirror the new rules rather than the old ones. + +1. **Coupon per holding, rounded down once.** `payCoupon` computes each holder's coupon as + `balance × UNIT_NOMINAL × couponYield / 10000`, rounded down to whole wNOK. The rounding + remainder (under 1 wNOK per holder per period) stays with the reserve and is not tracked. + Period and closure totals remain sums of the per-holder amounts, so they agree with the + per-holder events by construction. +2. **"RATE first" means "not yet enabled".** A bond is *enabled* when `BondToken` holds coupon + parameters for it (`couponDuration(partition) != 0`), which only a cleared RATE auction sets + and only `disablePartition` clears. `BondManager` refuses PRICE and BUYBACK on a bond that is + not enabled and RATE on one that is. `BondAuction` keeps only the rules that are true without + lifecycle knowledge: the first auction ever created for an ISIN is RATE, and a previous + auction must be terminal. `payCoupon` reverts with `BondNotEnabled` before dividing by the + coupon duration. +3. **Matured is terminal for supply.** `BondManager` refuses to schedule any auction on a + matured bond; `BondToken` refuses `mintByIsin` and `extendPartitionOffering` on a matured + partition; the final coupon reverts with `AuctionInFlight` while `bondActive[isin]` is set. + Because an in-flight buyback must always be clearable before maturity, cancelling a BUYBACK + auction stops reducing the partition offering (it never raised it). +4. **Bond holders must be able to receive wNOK.** A virtual transfer hook in `ERC1410Minimal` + lets `BondToken` reject any transfer whose recipient is not on the wNOK allowlist, reusing + `AllowlistViolation` so `BondDvP` classifies it as a security-leg failure. `payCoupon` + requires a strictly ascending holder list, replacing the quadratic duplicate scan with one + comparison per holder. Pending operator decision D6. +5. **Previews stay mirrors.** The API has no coupon arithmetic (verified); the UI keeps its + mirror of the contract math, moved into one domain helper with the new formula. + +```mermaid +stateDiagram-v2 + [*] --> Staged: deployBond + Staged --> Staged: RATE auction cancelled (RATE may be scheduled again) + Staged --> Enabled: RATE auction finalised (enableByIsin) + Staged --> [*]: disableBond (no finalised auction) + Enabled --> Enabled: PRICE / BUYBACK auctions, interim coupons + Enabled --> Matured: final coupon (no auction in flight) + Matured --> [*] + note right of Matured: no auction, no mint, no extension +``` + +## Current-State Evidence + +### Repository Evidence + +| Finding | Evidence | Consequence | +|---|---|---| +| **Verified.** Coupon per unit is computed once and truncated: `paymentPerBond = (UNIT_NOMINAL * couponYield) / PERCENTAGE_PRECISION`; each holder gets `_balance * _p.paymentPerBond` | `contracts/src/norges-bank/BondManager.sol` `payCoupon` (line 526), `_payHolder` (line 604) | Per unit `floor(yield / 10)` wNOK: 42 instead of 42.5 at 425 bps; loss up to 0.9 wNOK per unit per period | +| **Verified.** wNOK has 0 decimals; 1 wNOK = 1 NOK | `contracts/src/norges-bank/Wnok.sol` `decimals()`; `services/nb-ui/src/utils/format.js` `formatWnok` | Whole-wNOK rounding is unavoidable; the question is only where it happens | +| **Verified.** Totals already accumulate per-holder amounts: `totals.coupon += couponAmount` feeds `CouponPeriodPaid.couponPaid` and `BondMatured.couponPaid` | `BondManager._payHolders`, `payCoupon` | Changing the per-holder formula keeps totals consistent without extra work | +| **Verified.** Tests pin the truncated value: `assertEq(paymentPerBond, 42)`, fuzz and period-event expectations use `balance × 42` | `contracts/test/norges-bank/BondManager.t.sol` (`test_PayCoupon`, `test_PayCoupon_EmitsCouponPeriodPaid`, `testFuzz_PayCoupon_*`); `contracts/test/integration/BondLifecycle.t.sol` `_paymentPerBond` | These expectations change with the fix | +| **Verified.** The UI preview mirrors the truncation (`couponPerUnit = UNIT_NOMINAL × rateBps / 10000`, then `balance × perUnit`); its test asserts 25.20 K NOK for 600 units while the comment beside it states 25 500 NOK | `services/nb-ui/src/pages/PayCouponModal.jsx` lines 22–36; `services/nb-ui/tests/CouponPayoutPage.test.jsx` lines 212–215, 266 | Preview must change with the contract | +| **Verified.** The NB Bond API performs no coupon arithmetic: it exposes `coupon.rateBps` and holders, and stores event amounts as emitted | `services/nb-bond-api/src/projection/compose-projection.ts`; `ingestion.ts` `CouponPaid` / `BondMatured` branches | No API preview to align; the reviewer's "API preview math" item does not exist | +| **Verified.** `BondAuction.createAuction` decides the type by count: `previousCount == 0` requires RATE, otherwise the previous auction must be `>= FINALISED` and the type PRICE or BUYBACK. `CANCELLED` orders after `FINALISED` | `contracts/src/norges-bank/BondAuction.sol` lines 108–118; `IBondAuction.AuctionStatus` | After a cancelled RATE auction only PRICE/BUYBACK pass | +| **Verified.** `disablePartition` clears every per-partition field, but `isinToAuctionCount` lives in `BondAuction` and is never reset; `disableBond` iterates the whole count | `BondToken.disablePartition`; `BondManager.disableBond` | A re-used ISIN can never take a RATE auction; `test_DisableBond_AllowsIsinReuse` never schedules one | +| **Verified.** A PRICE finalisation mints via `mintByIsin` but calls `enableByIsin` only for RATE | `BondManager._settleIssuance` | Units in a bond with `couponDuration == 0` | +| **Verified.** `payCoupon` divides `maturityDuration / couponDuration` with no zero check | `BondManager.payCoupon` line 512 | `Panic(0x12)` on a never-enabled bond | +| **Verified.** `disableBond` reverts when any auction for the ISIN is `FINALISED` | `BondManager.disableBond` | Stranded units cannot be cleaned up by disabling | +| **Verified.** `payCoupon` has no `bondActive` check; `_deployAuctionForBond`, `mintByIsin`, and `extendPartitionOffering` check only `activePartitions`, never `isMatured` | `BondManager.sol`, `BondToken.sol` lines 235–242, 355–376 | A PRICE auction open across, or scheduled after, the final coupon mints into a matured partition; `payCoupon` then reverts `AllCouponsPaid` forever | +| **Verified.** `cancelAuction` always calls `reducePartitionOffering(isin, offering)`, but a BUYBACK auction never raised the offering | `BondManager.cancelAuction`, `_deployAuctionForBond` | On a fully issued bond (offering == supply) cancelling a buyback reverts `ReductionBelowSupply`; with no acceptable bids `finaliseAuction` reverts `NoAllocations`; the auction and `bondActive` stay stuck. No test cancels a BUYBACK auction | +| **Verified.** Bond transfers have no eligibility check: `transferByPartition` / `operatorTransferByPartition` → `_transferByPartition` → `_move` | `contracts/src/norges-bank/ERC1410/ERC1410.sol` lines 178–295; ADR 0002 context | A holder at a non-allowlisted address makes every coupon revert (`docs/KNOWN_ISSUES.md`, "Every bond holder must be on the WNOK allowlist…") | +| **Verified.** Duplicate detection is a nested loop over `_holders`, and each holder costs one `BondDvP.settle` call | `BondManager._payHolders` | Quadratic comparisons; the per-holder settle bounds holders per transaction | +| **Verified.** `BondDvP.settle` returns `true` or reverts; `_settleHolderPayout`'s `!ok` branch reverts with `SettlementFailure(Unknown, "coupon settle returned false")` | `BondDvP.settle`; `BondManager._settleHolderPayout` | Unreachable today; a string in revert data | +| **Verified.** `BondToken.redeemFor` NatSpec and body mention a "mock DVP … REDEEM_EOA" that no longer exists; `createPartition` repeats the duplicate and zero-maturity checks that `_createPartition` performs | `BondToken.sol` lines 405–408, 427, 151–181 | Misleading comments; redundant code | +| **Verified.** `ERC1410Minimal.DECIMALS = 18` while units are indivisible (`granularity 1`); no API or UI code reads `BondToken.decimals()`; `BondToken` emits no ERC-20 `Transfer` event, so explorers do not index it as a token (ADR 0002 context) | `ERC1410.sol`; grep of `services/`; ADR 0002 | Cosmetic but wrong; safe to change | +| **Verified.** The API pre-checks auction types with `isinToAuctionCount` ("first auction for ISIN must be RATE", "subsequent auctions cannot be RATE"); the UI picker uses `bond.auctions.length` | `services/nb-bond-api/src/features/auctions/service.ts` lines 74–81; `services/nb-ui/src/pages/CreateAuctionModal.jsx` lines 34–55 | Both replicate the defective rule and must follow the new one | +| **Verified.** The projection resets bond state on `BondCreated`, so a re-used ISIN starts with `couponYield = null`; `coupon` is `null` in the API resource until `IsinEnabled` | `services/nb-bond-api/src/projection/bond-state.ts` `created`; `compose-projection.ts` | The UI can derive "enabled" from `bond.coupon?.rateBps` | +| **Verified.** Ingestion applies a batch's manager events before its token events; reducers must be order-tolerant | `services/nb-bond-api/src/ingestion.ts`; `coupon-closure-review-fixes` progress | This plan adds no events, so replay order is unaffected | +| **Verified.** `BondOrderBook` (contract-level only; not deployed by `contracts/script/`) moves bond units with `operatorTransferByPartition` against TBD cash and maps any security-leg revert to `FailureReason.Seller` | `contracts/src/norges-bank/BondOrderBook.sol` `_dvpsettle` | Under D6 an ineligible buyer surfaces as a seller failure; acceptable for an undeployed contract, noted | + +### Runtime Evidence + +- **Verified (2026-09-28):** `forge test` on `development` at `db92409`: 24 suites, 390 tests + passed. `payCoupon` gas in `BondManagerTest` (1–2 holders): median 180 157, max 211 884. +- **Verified (earlier plan, 2026-09-09):** the sandbox paid `CouponPaid` 2 520 wNOK for 60 units + at 425 bps (see the archived `redeem-with-final-coupon` progress), i.e. the truncation is live. +- **Needs verification (Phase 0):** per-holder gas of an interim and a final coupon, to size the + holders-per-transaction limit against the local 60 000 000 block gas limit + (`infra/besu/config/genesis.json`). +- **Needs verification (Phase 0):** the stranded-ISIN, matured-mint, and buyback-cancel paths + reproduced by scratch Foundry tests before the fixes. + +### Existing Test Coverage and Gaps + +- Covered: interim and final coupon amounts at the truncated rate; duplicate holders; empty-holder + closure; unsold units; reserve underfunding; non-allowlisted holder revert; disable and reuse + without a new auction; first-auction-not-RATE on `BondAuction` directly. +- Gaps: coupon for a yield not a multiple of 10 bps; RATE after a cancelled RATE; RATE after + reuse; PRICE before enablement; `payCoupon` on a never-enabled bond; any auction or mint on a + matured bond; final coupon with an auction in flight; BUYBACK cancel; bond transfer + eligibility; holder ordering. + +## Significant Findings + +| Priority | Finding | Why it matters | Plan response | +|---|---|---|---| +| Blocking | Per-unit truncation underpays every coupon | Wrong cash on every payment | D1, Phase 1 | +| Blocking | Count-based RATE rule strands ISINs | Unpayable units, undisableable bond | D2, D3, Phase 2 | +| Blocking | Mint into a matured bond | Unpayable units | D4, Phase 3 | +| Important | Cancelling a BUYBACK reduces the offering and can revert | Stuck auction; with D4 it would also block maturity | D5, Phase 3 (prerequisite of D4's final-coupon rule) | +| Important | One non-allowlisted holder blocks the coupon for all | Operational deadlock, known issue | D6, D7, Phase 4 | +| Follow-up | Coupon timestamp is set to `block.timestamp` at payment, so a late payment shifts every later period | Schedule drift | Out of scope; recorded in `progress.md` | +| Follow-up | `BondAllocationFailed` carries a free-text reason; event `isin` indexing is inconsistent across `BondManager` events | ABI hygiene | Out of scope (event signature changes; no consumer need) | + +## Invariants + +- A holder's coupon for a period is `floor(balance × UNIT_NOMINAL × couponYield / 10000)` + wNOK; on the final period the payout adds `balance × UNIT_NOMINAL`. +- `CouponPeriodPaid.couponPaid` and `BondMatured.couponPaid` equal the sum of that period's + `CouponPaid.paymentAmount` values; `BondMatured.principalPaid` equals the sum of the + `BondRedeemed.wnokAmount` values. +- The reserve never pays more than the unrounded coupon; wNOK total supply is unchanged by any + coupon or closure. +- Units exist in a partition only if the bond is enabled and not matured, or are being burned in + the closure transaction. +- RATE is schedulable exactly while the bond is staged (exists, not enabled); PRICE and BUYBACK + exactly while it is enabled and not matured. +- Auction IDs stay unique per ISIN across lifecycles (`isinToAuctionCount` is never reset). +- The final coupon never runs while `bondActive[isin]` is set. +- Every holder of a bond unit was on the wNOK allowlist when it received the unit (under D6). +- No event signature changes; the projection's replay order assumptions are untouched. + +## Field and Source Classification + +| Field or concept | Current source | Target source | Class/owner | Freshness or fallback rule | +|---|---|---|---|---| +| Coupon paid per holder | `balance × floor(1000 × yield / 10000)` in `BondManager` | `floor(balance × 1000 × yield / 10000)` in `BondManager` | authoritative (chain) | Emitted in `CouponPaid` | +| Period and closure coupon totals | Sum of per-holder amounts | Unchanged rule, new per-holder values | authoritative (chain) | `CouponPeriodPaid`, `BondMatured` | +| Payout preview | `PayCouponModal.jsx` local math | `services/nb-ui/src/domain/coupon.js` helper, same formula as the contract | derived, presentation-only | From `holders` and `coupon.rateBps`; server re-checks on submit | +| Bond "enabled" | Implicit (auction count) | `BondToken.couponDuration(partition) != 0`; projected as `coupon.rateBps != null` | authoritative (chain) / projected | Projection resets on `BondCreated` | +| Allowed auction types | `BondAuction` count rule; API count pre-check; UI `auctions.length` | `BondManager` enabled/matured rule; API reads the same chain state; UI derives from `coupon` and `status` | authoritative (chain); API and UI mirror | Contract is final arbiter; API uses `staticCall` before sending | +| Holder eligibility | None on transfer; wNOK allowlist at payment | wNOK allowlist on every bond transfer (D6) | authoritative (chain) | Removal from the allowlist after receipt still blocks payment | + +## Target Architecture + +### Contract Changes + +**`BondManager`** + +- `Period` carries `couponYield` instead of `paymentPerBond`. `_payHolder` computes + `couponAmount = (_balance * UNIT_NOMINAL * _p.couponYield) / PERCENTAGE_PRECISION`. Update the + comment at the old line 525 with a worked example (60 units at 425 bps → 2 550). +- `payCoupon`: after reading coupon details, `if (couponDuration == 0) revert + Errors.BondNotEnabled(_isin);` before the division; after computing `finalPeriod`, + `if (finalPeriod && bondActive[_isin]) revert Errors.AuctionInFlight(_isin);`. If the added + locals push `payCoupon` past solc's stack limit (it sits close to it under via-IR), move the + coupon-detail read and checks into a `_loadPeriod(_isin) returns (Period memory, uint256 + supplyBefore)` helper, following the existing `Period` / `PayoutTotals` pattern. +- `_payHolders`: replace the nested duplicate loop with + `if (i > 0 && _holders[i] <= _holders[i - 1])` → `DuplicateHolder` when equal, + `HoldersNotSorted(isin, previous, current)` when lower (D7). +- `_deployAuctionForBond`: after the existence check, read `couponDuration(partition)` and + `isMatured(partition)`; revert `BondAlreadyMatured(isin)` if matured; for RATE revert + `AuctionTypeMustBePrice()` if enabled; for PRICE and BUYBACK revert + `FirstAuctionMustBeRate()` if not enabled. Reusing the two existing error names keeps the + API's revert descriptions meaningful. Update the `deployAuctionForBond` NatSpec that says the + rule is "enforced by BondAuction". +- `cancelAuction`: read the auction type (`BOND_AUCTION.getAuction(id).auctionType`) and call + `reducePartitionOffering` only for RATE and PRICE. +- `_settleHolderPayout`: replace the string revert with `Errors.SettlementNotConfirmed(holder)`. + +**`BondAuction`** + +- `createAuction` keeps `FirstAuctionMustBeRate` for `previousCount == 0` (still true: no bond + can be enabled without a RATE auction) and `PreviousAuctionActive`; it drops the + `AuctionTypeMustBePrice` branch for later auctions. Its NatSpec states that type sequencing + across a bond's lifecycle is the admin's (`BondManager`'s) responsibility. + +**`BondToken` and `ERC1410Minimal`** + +- `mintByIsin` and `extendPartitionOffering` (via `_updatePartitionOffering` when increasing) + revert `PartitionMatured(isin)` when `isMatured[partition]`. `reducePartitionOffering` stays + allowed (a cancel after maturity cannot happen once D4 holds, but reducing is harmless). +- `createPartition` drops the checks `_createPartition` already performs. +- `redeemFor`: NatSpec and inline comment describe the real flow (`BondManager` final coupon via + `BondDvP` `Redeem`, or a direct controller burn of unsold units). +- `ERC1410Minimal.DECIMALS = 0` (D9). +- Under D6: `ERC1410Minimal._transferByPartition` calls a new + `_beforeTransferByPartition(bytes32 fromPartition, bytes32 toPartition, address from, address to, uint256 value)` + internal virtual hook (empty by default) before `_move`. `BondToken` gains an immutable + `HOLDER_ALLOWLIST` (the wNOK contract, constructor argument, non-zero) and overrides the hook: + `if (!Allowlist(HOLDER_ALLOWLIST).allowlistQuery(to)) revert Errors.AllowlistViolation("BondToken", to, "recipient not on WNOK allowlist")`. + Mint and burn do not pass through the hook, so manager-held unsold units are unaffected. + +**`Errors`**: add `BondNotEnabled(string isin)`, `AuctionInFlight(string isin)`, +`BondAlreadyMatured(string isin)`, `PartitionMatured(string isin)`, +`HoldersNotSorted(string isin, address previous, address current)`, +`SettlementNotConfirmed(address holder)`. `AuctionTypeMustBePrice` and +`FirstAuctionMustBeRate` move from BondAuction-only use to BondManager use. + +### API and Zod Contract + +- No schema or OpenAPI change: no new fields, no new routes, no event changes. +- `features/auctions/service.ts`: replace the count-based pre-checks with chain reads of + `BondToken.couponDuration(partition)` and `isMatured(partition)` (the service already reads + `activePartitions` the same way). Messages: "RATE auctions are only valid before the bond is + enabled", "PRICE and BUYBACK auctions require a cleared RATE auction", "bond has matured". +- Coupon route (`app.ts`): sort `requested` ascending by address value (lower-case hex string + order equals numeric order for fixed-length addresses) before calling `payCoupon`. The + `HoldersBody` duplicate refine stays. +- ABI artifacts `src/abi/BondManager.json`, `BondToken.json`, `BondAuction.json` refreshed. + Errors raised inside `BondToken` (`PartitionMatured`, `AllowlistViolation`) reach the API + wrapped or raw; the manager-level `BondAlreadyMatured` check exists so the common path decodes + through the `BondManager` interface. Confirm decoding in Phase 3 and add the `BondToken` + interface to the relevant `describeRevert` calls if a raw selector surfaces. +- Health probe: extend the existing `bondManagerCompatible` idea only if cheap — probing + `BondToken.HOLDER_ALLOWLIST()` (under D6) flags an API running against pre-change contracts. + +### UI + +- New `services/nb-ui/src/domain/coupon.js`: `UNIT_NOMINAL`, `couponForHolding(balance, + rateBps)`, `principalForHolding(balance)`, all BigInt. `PayCouponModal.jsx` uses it; + `utils/format.js` `formatNok` imports `UNIT_NOMINAL` instead of the literal 1000. +- `PayCouponModal`: on the final period, when the bond has an auction in `open` or `closed` + state, show why the payment will be refused and disable Confirm. +- `CreateAuctionModal.jsx`: `rateAvailable = selectedBond && !enabled && !matured`, + `priceAvailable = buybackAvailable = enabled && !matured`, with + `enabled = selectedBond.coupon?.rateBps != null`; hints reworded accordingly. + +### Consistency and Failure Semantics + +All changes are all-or-nothing reverts in existing transactions. A refused final coupon or +auction schedule leaves no state change; the API maps reverts to 409/400 through the existing +paths. No projection or live-event change. + +### Security and Deployment Boundary + +No role changes. The eligibility hook adds a cross-contract read from `BondToken` to `Wnok` +(view only). `BondToken`'s constructor gains one argument (D6), so `contracts/script/norges-bank/10_Bond.s.sol` +passes the wNOK address it already resolves. A non-local deployment receives the behaviour by +redeploying the bond contracts; no configuration is added. + +## Alternatives Considered + +- **S3: compute the period total on total supply and distribute remainders (largest + remainder).** Pays the exact total but needs a second pass and a tie-break order; rounding per + holding is the conventional rule for paying coupons in the smallest currency unit and keeps the + single-pass loop. +- **S3: keep a per-unit coupon but in a higher-precision unit.** wNOK has 0 decimals; any + per-unit value still has to be rounded per holding at payment. +- **S3: move wNOK to øre (2 decimals).** Correct long-term but touches every cash leg, the + allowlisted balances, the API, and the UI; out of scope. +- **S4: a `rateFinalised[isin]` flag in `BondAuction`.** Needs a reset hook on `disableBond`, + coupling `BondAuction` to the bond lifecycle; the coupon parameters in `BondToken` already + carry the same fact and are already cleared on disable. +- **S4: reset `isinToAuctionCount` on disable.** Auction IDs are `keccak256(isin, index)`, so a + reset would re-issue IDs of the previous lifecycle's auctions and collide with their stored + status, bids, and allocations. +- **S5: forbid finalising an auction after maturity instead of forbidding the final coupon while + one is open.** Leaves an auction with bids that can only be cancelled, and still needs the + matured-mint guard; blocking the final coupon keeps one ordering rule the operator can see. +- **S5: forbid every coupon while an auction is in flight.** Interim coupons are unaffected by + concurrent PRICE or BUYBACK auctions (units are minted or burned atomically at finalisation), + so the broader rule adds operator friction for no safety gain. +- **S6: skip failing holders and let them claim later.** Needs a claims ledger and breaks the + all-or-nothing final burn; too large for the sandbox. +- **S6: forbid holder-initiated transfers entirely.** Breaks ERC-1410 semantics, the + distribution tests, and the order book. +- **S6: wait for ERC-3643 (ADR 0002).** Correct target, but the migration is not scheduled; the + interim check is small, reuses an existing allowlist, and is removed with `BondToken`. +- **S6: transient-storage or mapping dedupe.** Works but needs inline assembly (no transient + mappings in Solidity 0.8.36) or storage writes; a sorted list is the common, cheapest + pattern and the API controls the order. + +## Decisions + +| Decision | Recommendation | Rationale | Operator action required | +|---|---|---|---| +| D1 Coupon rounding | Per holding, round down to whole wNOK; remainder stays with the reserve, untracked; totals are sums of per-holder amounts | Conventional, single pass, totals consistent by construction; loss < 1 wNOK per holder per period | Confirm; decide whether to record it as an ADR (recommended: it changes a numeric convention that `contracts/docs/contracts-versioning.md` lists as breaking) | +| D2 Auction-type rule | Enabled state in `BondToken` decides; enforced in `BondManager`; `BondAuction` keeps only lifecycle-free rules | Single source already cleared on disable; no ID collisions | None | +| D3 `payCoupon` on a non-enabled bond | `BondNotEnabled(isin)` before the division | Named error instead of a panic | None | +| D4 Matured bonds | Manager refuses auctions (`BondAlreadyMatured`); token refuses mint and extension (`PartitionMatured`); final coupon refuses while an auction is in flight (`AuctionInFlight`) | Defence in depth at both layers; one visible ordering rule | None | +| D5 BUYBACK cancel | Do not reduce the offering when cancelling a BUYBACK auction | Pre-existing bug that would otherwise let a stuck buyback block maturity under D4 | None (in scope because D4 depends on it) | +| D6 Holder eligibility | Bond transfers require the recipient on the wNOK allowlist, via an `ERC1410Minimal` hook and a `BondToken` constructor argument | Removes the common cause of the coupon deadlock; interim until ERC-3643 | **Explicit approval**: new transfer rule and constructor change; may merit a short ADR | +| D7 Holder list | Strictly ascending; API sorts | Linear check; API already owns the list | None (lands with D6's slice) | +| D8 Preview alignment | UI mirror in one domain helper; no API field | API has no math today; composer-computed previews stay a follow-up | None | +| D9 `DECIMALS` | 0 | Matches indivisible units; no consumer reads it | None | +| D10 Events | No signature changes; `isin` indexing unchanged | Keeps ingestion and replay untouched | None | +| D11 Tests | New `contracts/test/norges-bank/BondManagerLifecycle.t.sol` with its own fixture for the new cases; existing files only get amount updates | `BondManagerTest` is near the via-IR assembly tag limit; `BondLifecycle.t.sol` sets the precedent of a separate fixture | None | +| D12 Local rollout | Fresh sandbox (`./sandbox.sh delete` then `start`) after the last contract slice | New constructor, new behaviour, new addresses; projection rebuilt | **Go-ahead** before deleting local chain state | + +## Residual Risks + +- Rounding per holding means the reserve pays up to `holdersPaid − 1` wNOK less than the + unrounded period total; documented, not reconciled. +- A holder removed from the wNOK allowlist after receiving units still blocks the coupon for + everyone; the known issue stays, narrowed to that case. +- Holders per transaction remain bounded by gas (one `settle` per holder); pagination is a + follow-up sized by the Phase 0 measurement. +- `BondOrderBook` would report an ineligible buyer as a seller failure; the contract is not + deployed by the sandbox scripts. +- A separate hardening change also edits contract code; whichever lands second + rebases onto the other. diff --git a/docs/plans/bond-coupon-maturity-correctness/intent.md b/docs/plans/bond-coupon-maturity-correctness/intent.md new file mode 100644 index 0000000..5b923e9 --- /dev/null +++ b/docs/plans/bond-coupon-maturity-correctness/intent.md @@ -0,0 +1,115 @@ +# Bond coupon and maturity correctness — Intent + +**Status:** Draft +**Created:** 2026-09-28 +**Requested by:** sandbox operator + +## Outcome + +A bond's cash flows and lifecycle transitions are correct at the contract level. Every holder +receives the coupon their holding is actually owed at the auction's clearing rate, not a +per-unit value truncated to whole wNOK. A bond cannot end up with units that no coupon or +maturity payment can ever reach: a cancelled first auction no longer locks the ISIN out of a +rate auction, a bond that never cleared a RATE auction cannot be minted into or have its coupon +called, and a matured bond cannot be minted into. One holder can no longer make the coupon +payment revert for every other holder by receiving units at an address that cannot receive +wNOK. The operator's payout preview and auction-type choices in the UI match what the chain +will do. + +## Why This Change + +A contract review on 2026-09-28 found four lifecycle defects in the bond stack +(`BondManager`, `BondAuction`, `BondToken`), each reachable through normal operator actions: + +- **Coupon underpayment.** `BondManager.payCoupon` computes one per-unit coupon, + `(UNIT_NOMINAL × couponYield) / 10000`, rounds it down to whole wNOK (wNOK has 0 decimals), + and multiplies that by each holding. At 425 bps a unit is owed 42.5 wNOK and receives 42, so + every holder is underpaid by about 1.2 % every period; any yield that is not a multiple of + 10 bps loses money. The UI preview was aligned to this truncation in #278, so both show the + wrong number consistently. +- **Stranded ISIN after a cancelled RATE auction.** "The first auction must be RATE" is + enforced by counting auctions per ISIN, not by whether a RATE auction ever cleared. After a + cancelled RATE auction only PRICE or BUYBACK can be scheduled, and the count survives + `disableBond`, so a re-used ISIN is locked the same way. A PRICE auction then mints units into + a bond that has no coupon parameters, `payCoupon` panics with a division by zero, and + `disableBond` is blocked because a finalised auction now exists. +- **Units minted into a matured bond.** Nothing stops scheduling a PRICE auction on a matured + bond, or finalising one that was open across the final coupon. The minted units can never be + paid: every further `payCoupon` reverts with `AllCouponsPaid`. +- **One holder blocks everyone's coupon.** Bond units move freely between any addresses, but + coupon and principal are paid in wNOK, which requires both ends to be on the wNOK allowlist. + One holder at a non-allowlisted address makes the atomic payment revert for all holders; the + existing known issue describes the workaround. The on-chain duplicate-holder check is also + quadratic in the number of holders. + +These sit in the same code as the maturity closure shipped by ADR 0005 and should be fixed +before further lifecycle work builds on them. + +## Scope + +### In Scope + +- **Coupon amount (S3).** Per-holding coupon `floor(balance × UNIT_NOMINAL × yield / 10000)` + in `BondManager`; totals in `CouponPeriodPaid` and `BondMatured` stay the sum of the + per-holder amounts; the UI payout preview uses the same rule from one helper. +- **Auction-type rule (S4).** "RATE first" is decided by whether the bond has coupon parameters + (a RATE auction cleared in this lifecycle), not by the auction count; `payCoupon` on a bond + without coupon parameters reverts with a named error instead of a panic; the API pre-check + and the UI auction-type picker follow the same rule. +- **Matured bonds stay closed (S5).** No auction can be scheduled on, and no unit minted into, + a matured partition; the final coupon cannot be paid while an auction is in flight; cancelling + a BUYBACK auction no longer reduces the partition offering (required so an in-flight buyback + can always be cleared before maturity). +- **Coupon robustness (S6).** Bond units can only be transferred to an address on the wNOK + allowlist; the holder list passed to `payCoupon` must be strictly ascending (linear duplicate + check); the API sorts it. Subject to the operator decision in `design.md`. +- **Cleanups in touched code.** Stale "mock DVP / REDEEM_EOA" comments in `BondToken`; + duplicated checks in `BondToken.createPartition`; a custom error instead of the + `"coupon settle returned false"` string; `ERC1410Minimal.DECIMALS` from 18 to 0. +- Contract, API, and UI tests; contract reference, security, and walkthrough docs; hand-patched + NatSpec pages; API ABI artifacts; `docs/KNOWN_ISSUES.md`; a fresh local sandbox run. + +### Out of Scope + +- Items owned by a separate hardening change. +- Coupon payment pagination (several transactions per period); recorded as a follow-up. +- Closing a fully bought-back bond without paying its remaining empty periods one by one + (`test_PayCoupon_EmptyHolders_ClosesZeroSupplyBond` documents the current behaviour). +- Changing event signatures, including making `isin` indexed on every `BondManager` event. +- Replacing the `string reason` in `BondAllocationFailed` (not in a touched path; an event + signature change). +- Moving wNOK to a sub-unit denomination (decimals > 0). +- The ERC-3643 migration (ADR 0002); the transfer eligibility check here is an interim rule it + will replace. +- A coupon holder removed from the wNOK allowlist after acquiring units still blocks the + payment; the known-issue workaround stays for that case. + +## Acceptance Criteria + +| Criterion | Risk addressed | Verification evidence | +|---|---|---| +| At 425 bps a holding of 60 units receives 2 550 wNOK per period (not 2 520); a holding of 1 unit receives 42; the period total in `CouponPeriodPaid` and `BondMatured.couponPaid` equals the sum of the per-holder `CouponPaid` amounts | Underpayment; totals drifting from per-holder events | Foundry amount and event assertions; fuzz over balance and yield against the reference formula | +| The pay-coupon preview shows the same per-holder and total amounts the contract pays, including for a yield that is not a multiple of 10 bps (7 units at 333 bps → 233) | Preview and chain disagree | nb-ui test at 425 bps and 333 bps | +| After a cancelled RATE auction, a new RATE auction can be scheduled and cleared on the same bond; PRICE and BUYBACK are refused until a RATE auction has cleared; the same holds after `disableBond` and ISIN re-use | Stranded ISIN | Foundry tests for cancel-then-RATE, reuse-then-RATE, PRICE refused before enablement | +| `payCoupon` on a bond with no coupon parameters reverts with a named error, not `Panic(0x12)` | Opaque failure | Foundry `expectRevert` with the error selector | +| Scheduling any auction on a matured bond reverts; `BondToken.mintByIsin` and `extendPartitionOffering` revert on a matured partition; the final coupon reverts while an auction is in flight and succeeds after it is finalised or cancelled | Unpayable units in a matured bond | Foundry tests for each path | +| Cancelling a BUYBACK auction on a fully issued bond succeeds and leaves the partition offering unchanged | In-flight buyback that can never be cleared, which would now also block maturity | Foundry test | +| A bond unit transfer to an address not on the wNOK allowlist reverts; issuance, coupon, maturity, and buyback flows still pass for allowlisted holders | One holder blocking the whole coupon | Foundry tests; existing suites green | +| `payCoupon` rejects a holder list that is not strictly ascending; the API always sends a sorted list and the coupon route works with the default holder set | Quadratic duplicate check; API/contract mismatch | Foundry tests; API route test | +| No event signature changes; the projection replays a fresh chain to the same bond states and history as before for the flows exercised | Ingestion breakage | ABI diff shows only errors, views, and the constructor change; fresh-sandbox run | +| A fresh local sandbox runs a bond through RATE auction, interim coupon, PRICE extension, final coupon, and closure with the corrected amounts | End-to-end regression | Balance trail in `progress.md` | + +## Constraints + +- sandbox-sized; local-first; nothing here needs a non-local deployment change beyond + redeploying the bond contracts +- public repo: no secrets, private identifiers, or home-directory paths +- no new dependencies +- the bond contracts are redeployed (new addresses): the local sandbox is recreated with the + operator's go-ahead + +## Open Questions + +- Whether to adopt the transfer eligibility rule (S6) now or leave it to the ERC-3643 + migration — owner: sandbox operator; `design.md` decision D6 recommends adopting it now. +- Whether the rounding rule gets its own ADR — owner: sandbox operator; `design.md` decision D1. diff --git a/docs/plans/bond-coupon-maturity-correctness/plan.md b/docs/plans/bond-coupon-maturity-correctness/plan.md new file mode 100644 index 0000000..d45fe89 --- /dev/null +++ b/docs/plans/bond-coupon-maturity-correctness/plan.md @@ -0,0 +1,514 @@ +# Bond coupon and maturity correctness — Implementation Plan + +**Status:** Proposed +**Created:** 2026-09-28 +**Scope:** `contracts/src/norges-bank/BondManager.sol`, `BondAuction.sol`, `BondToken.sol`, +`ERC1410/ERC1410.sol`, `contracts/src/common/Errors.sol`, `contracts/script/norges-bank/10_Bond.s.sol`, +contract tests and docs (reference, security, walkthrough, hand-patched NatSpec pages); +`services/nb-bond-api` (auction pre-check, coupon route ordering, ABI artifacts, tests, `DEVELOPMENT.md`); +`services/nb-ui` (`domain/coupon.js`, `PayCouponModal.jsx`, `CreateAuctionModal.jsx`, `utils/format.js`, tests); +`docs/KNOWN_ISSUES.md`, `docs/diagrams/processes/auction-lifecycle.md` +**Intent:** [`intent.md`](intent.md) · **Design:** [`design.md`](design.md) · **Progress:** [`progress.md`](progress.md) + +## Plan Order + +```text +Phase 0 Baseline, scratch reproductions, per-holder gas -> evidence only +Phase 1 Coupon per holding (S3) + UI preview helper -> PR 1 +Phase 2 Auction-type rule from enabled state; BondNotEnabled (S4) -> PR 2 +Phase 3 Matured guards, final coupon vs in-flight auction, BUYBACK cancel (S5) -> PR 2 +Phase 4 Holder eligibility on transfer + sorted holders (S6, needs D6) -> PR 3 +Phase 5 Cleanups in touched code (comments, createPartition, error, DECIMALS) -> PR 4 (or folded, see slices) +Phase 6 Fresh local sandbox run -> evidence in the last PR +Phase 7 Docs, known issues, index, archive -> each PR + final +``` + +Correctness of amounts (Phase 1) goes first because it is independent and every later test +that pays a coupon asserts amounts. Phases 2 and 3 share one PR: both change which auctions may +be scheduled, both change the API pre-check and the UI picker, and Phase 3's final-coupon rule +depends on Phase 3's BUYBACK-cancel fix. Phase 4 waits for decision D6. + +Every contract PR changes behaviour without changing event signatures, so the API and projection +keep working against either contract version; still, do not redeploy the local sandbox from +`development` between PRs unless the PR's own API and UI changes are in (each slice below +carries them). + +### Working rules for every contract phase + +- `BondManagerTest` is close to the via-IR assembly tag limit: new cases go into + `contracts/test/norges-bank/BondManagerLifecycle.t.sol` (`BondManagerLifecycleTest is Test, + AuctionHelper`) with its own `setUp`, modelled on `BondManagerTest.setUp` and + `BondLifecycle.t.sol`. Only amount expectations change inside `BondManager.t.sol`. +- `payCoupon` is close to solc's stack limit: if a change triggers stack-too-deep, move logic + into a helper that takes and returns memory structs (`Period`, `PayoutTotals` pattern) rather + than reshuffling locals. +- Do not run `forge doc` to refresh `contracts/docs/natspec/`: it regenerates every page. + Hand-patch only the pages of the contracts whose NatSpec changed. +- Slither runs in CI (Contracts CI); read its report on the PR, and record any accepted finding in + `contracts/docs/contracts-security.md`. +- After every contract change: `forge fmt`, `forge build`, `forge test`, and + `./contracts/check-verify-latest-mapping.sh`; copy the changed ABIs from `contracts/out/` into + `services/nb-bond-api/src/abi/` in the same PR. + +## Phase 0: Baseline and Scratch Reproductions + +### Goal + +Prove each finding on the current code and measure per-holder gas, without committing anything. + +### Scope + +- Scratch Foundry tests kept outside the committed tree (for example a local, uncommitted file + under `contracts/test/` deleted afterwards, or a gitignored scratch folder). + +### Steps + +1. Record the baseline: `forge test` (24 suites, 390 tests on 2026-09-28), `npm run` package + gates for `services/nb-bond-api` and `services/nb-ui`. +2. Scratch reproductions, each asserting today's (wrong) outcome: + - 60 units at 425 bps → `CouponPaid` 2 520; + - RATE cancelled → a second RATE reverts `AuctionTypeMustBePrice`; a PRICE auction then + finalises; `payCoupon` reverts `Panic(0x12)`; `disableBond` reverts `BondHasFinalisedAuction`; + - deploy → cancel RATE → `disableBond` → `deployBond` → RATE reverts `AuctionTypeMustBePrice`; + - PRICE auction scheduled before the final coupon, final coupon paid, PRICE finalised → units in + a matured partition, next `payCoupon` reverts `AllCouponsPaid`; + - fully issued bond, BUYBACK scheduled, `cancelAuction` reverts `ReductionBelowSupply`. +3. Gas: pay one interim and one final coupon to 1, 50, and 200 holders; record gas per holder and + the quadratic share of the duplicate check. Derive the holder count that fits in the local + 60 000 000 block gas limit with a safety margin. +4. Write results into `progress.md` "Verified So Far"; delete the scratch files. + +### Verification Stop + +- Each reproduction behaves as `design.md` describes; if one does not, stop and correct the + design before Phase 1. + +### Exit Criteria + +- [ ] Five reproductions recorded with their revert data. +- [ ] Per-holder gas and a holder-count estimate recorded. +- [ ] No committed change. + +## Phase 1: Coupon per Holding (S3) + +### Goal + +Every holder receives `floor(balance × UNIT_NOMINAL × yield / 10000)`; the preview agrees. + +### Scope + +- `contracts/src/norges-bank/BondManager.sol` (`Period`, `payCoupon`, `_payHolder`) +- `contracts/test/norges-bank/BondManager.t.sol`, `contracts/test/integration/BondLifecycle.t.sol`, + new `contracts/test/norges-bank/BondManagerLifecycle.t.sol` +- `services/nb-ui/src/domain/coupon.js` (new), `src/pages/PayCouponModal.jsx`, + `src/utils/format.js`, `tests/CouponPayoutPage.test.jsx` +- `contracts/docs/contracts-reference.md` (coupon amount rule), BondManager NatSpec page + +### Steps + +1. `Period.paymentPerBond` → `Period.couponYield`; `payCoupon` passes `couponYield`; `_payHolder` + computes `(_balance * UNIT_NOMINAL * _p.couponYield) / PERCENTAGE_PRECISION`. Rewrite the + comment above it with the worked example (60 units at 425 bps: 60 × 1000 × 425 / 10000 = + 2 550). +2. Update expectations in `BondManager.t.sol`: `test_PayCoupon` (1 000 units → 42 500, was 42 000), + `test_PayCoupon_EmitsCouponPeriodPaid`, `test_PayCoupon_MultipleHolders`, both fuzz tests, the + final-period tests; replace the `paymentPerBond` helpers with a per-holding helper + `_coupon(balance)` mirroring the contract formula. Same in `BondLifecycle.t.sol` + (`_paymentPerBond`). +3. New `BondManagerLifecycleTest` (fixture: WNOK, BondAuction, BondToken, BondDvP, BondManager, + two bidders, reserve) with: + - `test_PayCoupon_PaysPerHoldingNotTruncatedPerUnit`: 60 and 40 units at 425 bps → 2 550 and + 1 700; `CouponPeriodPaid(…, 2, 4 250)`; + - `test_PayCoupon_RoundsDownPerHolding`: yield 333 bps, holdings 4 and 7 → 133 and 233 + (total 366, unrounded 366.3; the old per-unit rule paid 132 and 231); + - `testFuzz_PayCoupon_MatchesReferenceFormula(uint16 units, uint16 yieldBps)`: bounded + inputs, a RATE auction at `yieldBps`, one interim coupon; assert the paid amount equals the + formula and the reserve delta equals it. + A clearing rate other than 425 needs its own RATE finalisation in the fixture; keep that in a + helper `_issue(units, rateBps)`. +4. UI: create `src/domain/coupon.js` exporting `UNIT_NOMINAL = 1000n`, `BPS = 10000n`, + `couponForHolding(balance, rateBps)`, `principalForHolding(balance)`; `PayCouponModal.jsx` + uses them (header comment updated to the per-holding rule); `format.js` `formatNok` uses + `UNIT_NOMINAL`. Update `CouponPayoutPage.test.jsx`: 600 units → "25.50 K NOK", 400 → + "17.00 K NOK", total "42.50 K NOK"; the manager-held case to match; add one case at + 333 bps (7 units preview 233 NOK; the old rule showed 231). +5. Contract docs: coupon amount rule in `contracts/docs/contracts-reference.md`; hand-patch the + `BondManager` NatSpec page. +6. Gates: `forge fmt && forge build && forge test`; nb-ui format, lint, test, build. No ABI + change in this phase (only an internal struct); confirm with a diff of + `contracts/out/BondManager.sol/BondManager.json` ABI against `services/nb-bond-api/src/abi/BondManager.json`. + +### Verification Stop + +- New and updated tests green; the UI test shows 25.50 K NOK for 600 units. + +### Failure Diagnosis / Fix Forward / Rollback + +- Stack-too-deep after renaming: the field swap should not add locals; if it appears, move the + coupon-detail read into `_loadPeriod` (design.md). +- A fuzz failure at extreme inputs: bound `units` by the offering and `yieldBps` to 1–10 000; + zero-coupon holdings (tiny holding, tiny yield) are valid and pay 0. +- Rollback: revert the PR; no chain state changes until Phase 6. + +### Exit Criteria + +- [ ] Contract pays per holding; totals equal sums of per-holder amounts. +- [ ] UI preview uses `domain/coupon.js` and matches the contract for 425 and 333 bps. +- [ ] ABI unchanged. + +## Phase 2: Auction-Type Rule From Enabled State (S4) + +### Goal + +RATE is schedulable exactly while a bond is staged; PRICE and BUYBACK exactly while it is +enabled; `payCoupon` on a non-enabled bond fails with a named error. + +### Scope + +- `BondManager._deployAuctionForBond`, `payCoupon`; `BondAuction.createAuction`; `Errors.sol` +- `contracts/test/norges-bank/BondAuction.t.sol`, `BondManagerLifecycle.t.sol` +- `services/nb-bond-api/src/features/auctions/service.ts`, `tests/auction-service.test.ts` +- `services/nb-ui/src/pages/CreateAuctionModal.jsx` and its tests +- ABI artifacts `BondManager.json`, `BondAuction.json` + +### Steps + +1. `Errors.sol`: add `BondNotEnabled(string isin)`. +2. `BondManager._deployAuctionForBond`: after the existence check, + `bool enabled = BOND_TOKEN.couponDuration(partition) != 0;` then RATE on an enabled bond → + `AuctionTypeMustBePrice()`; PRICE or BUYBACK on a non-enabled bond → + `FirstAuctionMustBeRate()`. Keep the checks before `extendPartitionOffering` and + `createAuction`. Update the `deployAuctionForBond` NatSpec. +3. `BondManager.payCoupon`: `if (couponDuration == 0) revert Errors.BondNotEnabled(_isin);` + before `maturityDuration / couponDuration`. +4. `BondAuction.createAuction`: keep the `previousCount == 0` RATE check and + `PreviousAuctionActive`; remove the `AuctionTypeMustBePrice` branch; NatSpec says lifecycle + type sequencing belongs to the admin. Adjust `BondAuction.t.sol` if a test relied on the + removed branch (none found on 2026-09-28; `test_CreateAuction_RevertIf_FirstAuctionNotRate` + stays valid). +5. Tests in `BondManagerLifecycleTest`: + - `test_RateAuction_AfterCancelledRate_Succeeds` (schedule, cancel, schedule RATE, finalise, + coupon parameters set); + - `test_RateAuction_AfterDisableAndReuse_Succeeds` (the path `test_DisableBond_AllowsIsinReuse` + never exercised); + - `test_PriceAuction_RevertIf_NotEnabled` (after a cancelled RATE) and the BUYBACK equivalent; + - `test_RateAuction_RevertIf_AlreadyEnabled`; + - `test_PayCoupon_RevertIf_NotEnabled` (staged bond; `BondNotEnabled`, not a panic). +6. API: in `features/auctions/service.ts` replace the two `isinToAuctionCount` checks with reads + of `BondToken.couponDuration(partition)` (and `isMatured`, used in Phase 3), mirroring the + existing `activePartitions` read and its `DependencyUnavailableError`; update the messages and + `tests/auction-service.test.ts` (count-based fixtures become coupon-state fixtures). +7. UI: `CreateAuctionModal.jsx` derives `enabled` from `selectedBond.coupon?.rateBps != null`; + `pickIsin` picks RATE for a non-enabled bond and PRICE otherwise; hints reworded; update its + tests. +8. Docs: `contracts/docs/contracts-reference.md` auction rule (line 178); + `services/nb-bond-api/DEVELOPMENT.md` lines 188, 276, 326; `docs/diagrams/processes/auction-lifecycle.md` + type-constraint flowchart ("Bond enabled?" instead of "First auction for ISIN?"); NatSpec pages + for `BondManager`, `BondAuction`, `Errors`. +9. Refresh ABI artifacts; gates for all three packages. + +### Verification Stop + +- The cancel-then-RATE and reuse-then-RATE reproductions from Phase 0 now succeed; PRICE before + enablement reverts with `FirstAuctionMustBeRate`. + +### Failure Diagnosis / Fix Forward / Rollback + +- A RATE auction scheduled via `deployBondWithAuction` reverts: the enabled read must use the + partition created in the same call; `_deployBond` runs first, so `couponDuration` is 0 — check + the call order. +- API test drift: the service mocks `isinToAuctionCount`; replace the mock rather than keeping + both reads. +- Rollback: revert the PR before Phase 6. + +### Exit Criteria + +- [ ] Count no longer decides the auction type anywhere (contract, API, UI). +- [ ] `payCoupon` on a staged bond reverts `BondNotEnabled`. +- [ ] Auction IDs unchanged in shape (`isinToAuctionCount` untouched). + +## Phase 3: Matured Bonds Stay Closed (S5) + +### Goal + +No unit can enter a matured partition, and the final coupon cannot race an auction. + +### Scope + +- `BondManager._deployAuctionForBond`, `payCoupon`, `cancelAuction`; `BondToken.mintByIsin`, + `_updatePartitionOffering`; `Errors.sol` +- `BondManagerLifecycle.t.sol`, `contracts/test/norges-bank/BondToken.t.sol`, + `contracts/test/invariant/BondToken.invariant.t.sol` +- `services/nb-bond-api/src/features/auctions/service.ts`; `services/nb-ui/src/pages/PayCouponModal.jsx`, + `CreateAuctionModal.jsx`, tests +- ABI artifacts `BondManager.json`, `BondToken.json` + +### Steps + +1. `Errors.sol`: add `AuctionInFlight(string isin)`, `BondAlreadyMatured(string isin)`, + `PartitionMatured(string isin)`. +2. `BondManager.cancelAuction`: read `BOND_AUCTION.getAuction(auctionId).auctionType`; call + `reducePartitionOffering` only for RATE and PRICE; emit `BondAuctionCancelled` with + `offeringReduced = 0` for BUYBACK (event unchanged; the value becomes truthful). +3. `BondManager._deployAuctionForBond`: `if (BOND_TOKEN.isMatured(partition)) revert + Errors.BondAlreadyMatured(_isin);` before the type checks. +4. `BondManager.payCoupon`: after `finalPeriod` is known, + `if (finalPeriod && bondActive[_isin]) revert Errors.AuctionInFlight(_isin);`. +5. `BondToken.mintByIsin` and `_updatePartitionOffering` (increase path only): revert + `PartitionMatured(isin)` when `isMatured[partition]`. +6. Tests in `BondManagerLifecycleTest`: + - `test_CancelBuyback_OnFullyIssuedBond_LeavesOfferingUnchanged`; + - `test_PayCoupon_FinalPeriod_RevertIf_AuctionInFlight` then succeeds after the PRICE auction + is finalised (units included in the closure) and, separately, after it is cancelled; + - `test_PayCoupon_InterimPeriod_AllowedWithAuctionInFlight`; + - `test_DeployAuction_RevertIf_Matured` for PRICE and BUYBACK. + In `BondToken.t.sol`: `test_MintByIsin_RevertIf_Matured`, `test_ExtendOffering_RevertIf_Matured`. + Invariant handler: `mintToHolder` and `extendOffering` return early when matured, and add + `invariant_MaturedPartitionSupplyNeverGrows` (track supply at the moment `markMatured` runs). +7. API: the auction pre-check adds "bond has matured" from `isMatured(partition)`; confirm the + 409 detail for `AuctionInFlight` decodes through the `BondManager` interface on the coupon + route; if `PartitionMatured` ever surfaces raw, add the `BondToken` interface to that + `describeRevert` call. +8. UI: `PayCouponModal` on the final period with an `open` or `closed` auction in `bond.auctions` + shows "Finalise or cancel the open auction before the final payment" and disables Confirm; + `CreateAuctionModal` offers no type for a `matured` bond. Tests for both. +9. Docs: `contracts/docs/contracts-security.md` coupon-and-maturity section (ordering rule, + matured guard); `contracts/docs/bond-lifecycle-walkthrough.md` step 5 (no auction in flight + at the final coupon); NatSpec pages for `BondManager`, `BondToken`, `Errors`. +10. Refresh ABI artifacts; gates for all three packages. + +### Verification Stop + +- The matured-mint and buyback-cancel reproductions from Phase 0 now revert or succeed as + designed. + +### Failure Diagnosis / Fix Forward / Rollback + +- `payCoupon` stack-too-deep: move the checks into `_loadPeriod` (design.md). +- Existing tests that finalise a PRICE auction after all coupons: none expected; if one exists, + it encoded the bug — rewrite it to schedule before maturity. +- Rollback: revert the PR before Phase 6. + +### Exit Criteria + +- [ ] No path mints into or schedules an auction on a matured bond. +- [ ] The final coupon refuses while an auction is in flight; interim coupons do not. +- [ ] Cancelling a BUYBACK leaves the offering unchanged. + +## Phase 4: Holder Eligibility and Sorted Holders (S6) + +Requires decision D6. If the operator declines D6, implement only steps 5–7 (sorted holders) +and keep the known issue unchanged. + +### Goal + +Bond units can only reach addresses that can receive wNOK; the holder check is linear. + +### Scope + +- `ERC1410/ERC1410.sol` (hook), `BondToken.sol` (constructor, override, view), `IBondToken.sol` + (`HOLDER_ALLOWLIST()`), `BondManager._payHolders`, `Errors.sol` +- `contracts/script/norges-bank/10_Bond.s.sol` +- Every test that constructs `BondToken` (seven existing files: `BondToken.t.sol`, + `BondManager.t.sol`, `BondAuction.t.sol`, `BondDVP.t.sol`, `BondOrderBook.t.sol`, + `BondLifecycle.t.sol`, the invariant test; plus the new `BondManagerLifecycle.t.sol`) +- `services/nb-bond-api/src/app.ts` coupon route, tests; ABI artifacts `BondToken.json`, + `BondManager.json` +- `docs/KNOWN_ISSUES.md` + +### Steps + +1. `ERC1410Minimal._transferByPartition`: call + `_beforeTransferByPartition(fromPartition, toPartition, from, to, value)` (internal virtual, + empty) after the zero-recipient check and before `_move`. +2. `BondToken`: constructor `(string _name, string _symbol, address _holderAllowlist)`; revert on + zero; store `address public immutable HOLDER_ALLOWLIST`; override the hook to require + `Allowlist(HOLDER_ALLOWLIST).allowlistQuery(to)`, else + `AllowlistViolation("BondToken", to, "recipient not on WNOK allowlist")`. Add + `HOLDER_ALLOWLIST()` to `IBondToken`. +3. `10_Bond.s.sol`: pass the wNOK address the script already resolves (line 38). Check that + every sandbox holder (dealers, seeded bidders) is on the wNOK allowlist in + `07_WnokSetup.s.sol`; it must already be true for issuance cash legs. +4. Tests: update every constructor call site (pass a `Wnok` where one exists, otherwise a + small allowlist deployed in `setUp`); `BondToken.t.sol` gains + `test_TransferByPartition_RevertIf_RecipientNotAllowlisted` and the operator variant; + `BondManagerLifecycleTest` gains `test_Issuance_ToNonAllowlistedBidder_FailsAtSecurityLeg` + (allocation failure, units stay on the manager, reason `Security`); `BondOrderBook.t.sol` + allowlists its buyers. +5. `Errors.sol`: add `HoldersNotSorted(string isin, address previous, address current)`. + `_payHolders`: replace the nested loop with the ascending check (`DuplicateHolder` when equal, + `HoldersNotSorted` when lower). +6. Existing duplicate-holder tests keep their `DuplicateHolder` expectation (sort their inputs so + the duplicate is adjacent); add `test_PayCoupon_RevertIf_HoldersNotSorted`; tests that pass + two holders pass them in ascending order. +7. API coupon route: sort `requested` ascending (lower-case string compare) before + `payCoupon`; test that an unsorted body and the default holder set both reach the contract + sorted. Update the `HoldersBody` description to say order is irrelevant. +8. Health (optional, if one line): probe `BondToken.HOLDER_ALLOWLIST()` alongside `GOV_RESERVE()`. +9. `docs/KNOWN_ISSUES.md`: narrow "Every bond holder must be on the WNOK allowlist…" to holders + removed from the allowlist after receiving units; the transfer path is closed. + `contracts/docs/contracts-security.md`: transfer eligibility and its interim status under + ADR 0002. `contracts/docs/contracts-versioning.md` is unchanged (constructor changes are + deployment-internal). +10. Refresh ABI artifacts; gates for all three packages. + +### Verification Stop + +- `forge test` green with the new cases; the Phase 0 gas measurement repeated for 200 holders + shows the duplicate-check share gone. + +### Failure Diagnosis / Fix Forward / Rollback + +- Issuance allocation failures reported as `Security` instead of `Cash` for non-allowlisted + bidders: expected (the security leg runs first); update any test asserting `Cash` for that + case. +- Order-book tests fail with seller failures: their buyers are not allowlisted; allowlist them. +- Rollback: revert the PR before Phase 6; if already redeployed, redeploy from the prior + `development` commit. + +### Exit Criteria + +- [ ] Transfers to non-allowlisted recipients revert; all lifecycle suites pass. +- [ ] Holder list check is linear; the API always sends a sorted list. +- [ ] Known issue narrowed. + +## Phase 5: Cleanups in Touched Code + +### Goal + +Leave the touched files accurate and consistent. + +### Steps + +1. `BondToken.redeemFor`: replace the "mock DVP … REDEEM_EOA" NatSpec line and the inline comment + with the actual flow. +2. `BondToken.createPartition`: remove the `activePartitions` and zero-maturity checks that + `_createPartition` repeats; keep `_partitionIsin` assignment before `_createPartition` so its + `DuplicatePartition` names the ISIN. Existing revert tests must stay green unchanged. +3. `Errors.sol`: add `SettlementNotConfirmed(address holder)`; `_settleHolderPayout` uses it + instead of `SettlementFailure(Unknown, "coupon settle returned false")`. +4. `ERC1410Minimal.DECIMALS = 0`; update the NatSpec of `decimals()`. +5. Hand-patch NatSpec pages for `BondToken`, `BondManager`, `Errors`, `ERC1410Minimal`. +6. Gates; refresh ABI artifacts if `Errors` usage changed the `BondManager` ABI. + +### Verification Stop + +- `forge test` green; `grep -n "REDEEM_EOA\|mock DVP" contracts/src` empty. + +### Exit Criteria + +- [ ] No stale comment, duplicated check, or string revert in the touched paths. + +## Phase 6: Fresh Local Sandbox Run + +### Steps + +1. With the operator's go-ahead (D12): `./infra/infra.sh registry-start` if needed, + `./sandbox.sh delete`, then `./sandbox.sh start` on the branch of the last contract PR. +2. `/v1/health` reports contracts reachable (and `HOLDER_ALLOWLIST` compatible under D6). +3. Bond A (two dealers, 60 and 40 units, 425 bps, two periods): interim coupon pays 2 550 and + 1 700 wNOK; the reserve drops by 4 250; schedule a PRICE auction, attempt the final coupon + (409 `AuctionInFlight`), finalise the PRICE auction, pay the final coupon; `BondMatured` + totals equal the sums; bond `matured`, supply 0; scheduling another auction returns 400/409. +4. Bond B: schedule and cancel a RATE auction, schedule a RATE auction again, clear it, pay a + coupon. Disable a staged bond C after a cancelled RATE auction, re-create the ISIN, schedule + RATE successfully. +5. Bond D (under D6): try to transfer units from a dealer to a non-allowlisted address with + `cast send` → reverts `AllowlistViolation`. +6. Mine a fresh block before each time-gated call (gas estimation reads the latest block time). +7. Record the balance trails in `progress.md`. + +### Exit Criteria + +- [ ] Every step above observed; trails recorded; no private environment data in the record. + +## Phase 7: Documentation and Archive + +Each PR carries the docs for its phase (listed above). After the last PR: + +1. `docs/DOCUMENTATION_INDEX.md`: add this folder when the plan is first committed; update the + entry when archived. +2. `docs/ARCHITECTURE.md` bond lifecycle bullets: auction-type rule and matured guard, if they + describe either. +3. Move the folder to `docs/plans/archive/` with `Status: Implemented` after the last PR merges. +4. Run the public-repo checks below. + +## Test Matrix + +| Layer | Risk or behavior | Test/evidence | +|---|---|---| +| Contract unit | Coupon per holding at 425 and 333 bps; totals = sums | `BondManagerLifecycleTest` S3 cases; updated `BondManager.t.sol` expectations | +| Contract fuzz | Paid amount equals reference formula for any units/yield | `testFuzz_PayCoupon_MatchesReferenceFormula` | +| Contract unit | RATE after cancelled RATE and after reuse; PRICE/BUYBACK refused before enablement; RATE refused after | S4 cases | +| Contract unit | `payCoupon` on staged bond → `BondNotEnabled` | S4 case | +| Contract unit | Auction on matured bond refused; mint/extend on matured partition refused | S5 cases, `BondToken.t.sol` | +| Contract unit | Final coupon refused with auction in flight; interim allowed | S5 cases | +| Contract unit | BUYBACK cancel on fully issued bond succeeds, offering unchanged | S5 case | +| Contract invariant | Matured partition supply never grows | `BondToken.invariant.t.sol` | +| Contract unit | Transfer to non-allowlisted recipient reverts (holder and operator paths); issuance to such a bidder fails at the security leg | S6 cases | +| Contract unit | Holder list must be strictly ascending; duplicates still named | S6 cases | +| Contract integration | Full lifecycle with corrected amounts | `BondLifecycle.t.sol` | +| API | Auction pre-check follows enabled/matured state; coupon route sorts holders | `auction-service.test.ts`, coupon route test | +| API | ABI artifacts decode new errors | route tests with mocked reverts | +| UI | Preview amounts (425 and 333 bps), final-coupon block with open auction, auction-type picker | `CouponPayoutPage.test.jsx`, `CreateAuctionModal` tests | +| Local runtime | End-to-end trails | Phase 6 | + +## Recommended PR Slices + +| Slice | Architectural outcome | Main proof | Temporary compatibility/cleanup | +|---|---|---|---| +| 1 `feature/coupon-per-holding` | Correct coupon amounts on-chain and in the preview | Foundry S3 cases; nb-ui test | No ABI change; safe to deploy alone | +| 2 `feature/bond-lifecycle-guards` | Auction-type rule from enabled state; matured guards; final coupon vs auction; BUYBACK cancel; API pre-check and UI picker | Foundry S4/S5 cases; API and UI tests | Carries its own API/UI changes; safe to deploy alone | +| 3 `feature/bond-holder-eligibility` | Transfer eligibility; sorted holders; API sorting; known issue narrowed | Foundry S6 cases; API route test | Needs D6; constructor change, so redeploy only with the API ABI refresh in the same PR | +| 4 `feature/bond-contract-cleanups` | Comments, `createPartition`, custom error, `DECIMALS` | `forge test` | May be folded into slice 2 or 3 if the reviewer prefers fewer PRs; Phase 6 evidence goes into the last slice | + +Each slice is a `feature/` branch with a PR against `development`; branch naming, +commit conventions, and the CI gates follow the repository PR workflow. + +## Migration, Rebuild, and Rollout + +- Data/schema version effect: none in the API database; projection unchanged; no OpenAPI + change. +- Local rebuild/restart path: fresh sandbox after the last contract slice (D12). Earlier slices + can be exercised with `forge test` only. +- Compatibility window: none needed locally; do not redeploy from `development` with a contract + slice merged but its API/UI changes missing (each slice carries them). +- Non-local deployment note: redeploy the bond contracts (`BondToken`, `BondAuction`, + `BondManager`) together; any consumer of `BondToken`'s constructor or of the holder order in + `payCoupon` must follow; event consumers are unaffected. +- Rollback or fix-forward boundary: revert PRs before a redeploy; after, redeploy from the prior + `development` commit. + +## Documentation and Public-Repo Hygiene + +- Docs and indexes to update: `contracts/docs/contracts-reference.md`, + `contracts/docs/contracts-security.md`, `contracts/docs/bond-lifecycle-walkthrough.md`, + hand-patched NatSpec pages (`BondManager`, `BondAuction`, `BondToken`, `ERC1410Minimal`, + `IBondToken`, `Errors`), `services/nb-bond-api/DEVELOPMENT.md`, + `docs/diagrams/processes/auction-lifecycle.md`, `docs/DOCUMENTATION_INDEX.md`. +- Architecture/known-issue updates: `docs/KNOWN_ISSUES.md` allowlist entry narrowed (Phase 4); + new follow-ups from `progress.md` added only when the operator accepts them. +- Third-party/license inventory impact: none (no dependency or image change). +- ADR: if the operator accepts the recommendation in D1 (and optionally D6), author the ADR in + the first slice that implements it and link it from `design.md`. + +Verification: + +```bash +python3 scripts/verification/check-public-repo-hygiene.py +python3 scripts/verification/check-markdown-links.py +``` + +## Out of Scope + +- The separate hardening change. +- Coupon pagination, empty-period collapse for bought-back bonds, schedule drift on late + payments, `BondAllocationFailed` reason type, event `isin` indexing, wNOK sub-units, ERC-3643. + +## Done Criteria + +- [ ] Every acceptance criterion in `intent.md` has evidence in `progress.md`. +- [ ] No temporary compatibility path remains. +- [ ] `forge test`, both package gates, and the public-repo checks pass. +- [ ] Documentation matches the implemented behaviour. +- [ ] PR evidence contains no private environment information. diff --git a/docs/plans/bond-coupon-maturity-correctness/progress.md b/docs/plans/bond-coupon-maturity-correctness/progress.md new file mode 100644 index 0000000..7b7e000 --- /dev/null +++ b/docs/plans/bond-coupon-maturity-correctness/progress.md @@ -0,0 +1,113 @@ +# Bond coupon and maturity correctness — Progress + +**Plan:** [`plan.md`](plan.md) +**Last updated:** 2026-09-28 — plan folder written from the 2026-09-28 contract review; findings re-verified against `development` at `db92409` +**Current phase:** Not started +**Next action:** Operator reviews `intent.md` and answers D1 (rounding rule, ADR or not), D6 (transfer eligibility now or at the ERC-3643 migration), and D12 (fresh-sandbox go-ahead at the end); then start Phase 0 by writing the five scratch reproductions listed in `plan.md` against `development`. + +## Phase Log + +| Phase | Status | Evidence | PR | +|---|---|---|---| +| 0 — Baseline, scratch reproductions, per-holder gas | Not started | | | +| 1 — Coupon per holding (S3) | Not started | | | +| 2 — Auction-type rule from enabled state (S4) | Not started | | | +| 3 — Matured bonds stay closed (S5) | Not started | | | +| 4 — Holder eligibility and sorted holders (S6) | Not started (needs D6) | | | +| 5 — Cleanups in touched code | Not started | | | +| 6 — Fresh local sandbox run | Not started (needs D12) | | | +| 7 — Documentation and archive | Not started | | | + +## Deviations From the Plan + +Corrections to the review findings, made while writing the plan: + +- **"API coupon preview math" does not exist.** The NB Bond API performs no coupon arithmetic; + it exposes `coupon.rateBps` and the holders and stores event amounts as emitted. Only the UI + preview (`PayCouponModal.jsx`) and `format.js` `formatNok` hard-code the unit nominal. The + alignment phase is therefore UI-only. +- **`_handleAllocationFailure` is not a revert path.** It emits `BondAllocationFailed` with a + string reason; replacing the string changes an event signature and the function is not touched + by any fix here. Moved to follow-ups. +- **Event `isin` indexing left alone.** No fix requires an event change, and ingestion resolves + indexed and non-indexed `isin` differently; changing it is recorded as a follow-up. +- **Added: BUYBACK cancel reduces the offering.** Not in the review, but the S5 rule "no final + coupon while an auction is in flight" cannot be safe while a BUYBACK auction can become + impossible to cancel. It is in scope under the repository's scope rule because the requested + behaviour depends on it. +- **R5 (`DECIMALS = 18`) included** as a cleanup: no API or UI code reads `BondToken.decimals()`, + and `BondToken` emits no ERC-20 `Transfer` event, so explorers do not treat it as a token. +- **R2 (bought-back bond needs empty periods) left out of scope** as a known behaviour. + +## Verified So Far + +- `BondManager.payCoupon` truncates the per-unit coupon before multiplying (`BondManager.sol` + lines 525–526, 604); wNOK `decimals()` is 0 (`Wnok.sol`) — verified by reading on 2026-09-28. +- `BondAuction.createAuction` enforces the type by `isinToAuctionCount`, `CANCELLED` orders after + `FINALISED`, and the count is never reset by `disableBond` — verified by reading on 2026-09-28. +- `BondManager._settleIssuance` calls `enableByIsin` only for RATE; `payCoupon` divides by + `couponDuration` without a zero check; `disableBond` refuses when any auction is `FINALISED` — + verified by reading on 2026-09-28. +- `payCoupon` has no `bondActive` check; `_deployAuctionForBond`, `BondToken.mintByIsin`, and + `extendPartitionOffering` never read `isMatured` — verified by reading on 2026-09-28. +- `BondManager.cancelAuction` reduces the offering for every auction type while a BUYBACK auction + never raised it; no test cancels a BUYBACK — verified by reading and grep on 2026-09-28 + (runtime reproduction is Phase 0). +- ERC-1410 transfers (`transferByPartition`, `operatorTransferByPartition` → `_move`) have no + eligibility check; the duplicate-holder scan in `_payHolders` is a nested loop — verified by + reading on 2026-09-28. +- The API auction pre-check (`features/auctions/service.ts`) and the UI picker + (`CreateAuctionModal.jsx`) replicate the count-based rule — verified by reading on 2026-09-28. +- The projection resets bond state on `BondCreated`, so `coupon` is `null` for a re-used ISIN + until `IsinEnabled` — verified in `projection/bond-state.ts` on 2026-09-28. +- Baseline `forge test`: 24 suites, 390 tests passed; `payCoupon` median gas 180 157 with one or + two holders — verified by running on 2026-09-28. +- `BondOrderBook` is not deployed by `contracts/script/` — verified by grep on 2026-09-28. + +## Blocked / Waiting On + +- D6 (transfer eligibility) — sandbox operator; blocks Phase 4 steps 1–4 and 8–9. +- D12 (fresh sandbox) — sandbox operator; blocks Phase 6. +- D1 ADR choice — sandbox operator; does not block Phase 1 code. + +## Follow-ups Found Along the Way + +- Coupon payment pagination (several transactions per period) — `BondManager.payCoupon` — + size from the Phase 0 gas measurement; propose only if the holder limit is within reach of a + demo. +- A fully bought-back bond needs one empty `payCoupon` call per remaining period before it can + mature — `BondManager.payCoupon`, `test_PayCoupon_EmptyHolders_ClosesZeroSupplyBond` — known + behaviour; a single "close empty bond" path could collapse them. +- `updateCouponPayment` records `block.timestamp`, so a late payment shifts every later coupon + date — `BondManager.payCoupon` line 550 — consider advancing by `couponDuration` instead. +- `BondAllocationFailed` carries a free-text `reason` decoded with inline assembly — + `BondManager._handleAllocationFailure` — replace with an enum when the event is next changed. +- `isin` is indexed on coupon and maturity events but not on auction and lifecycle events — + `IBondManager.sol` — align when the ABI next breaks, together with ingestion's `resolveIsin`. +- The UI mirrors contract math for the payout preview — `services/nb-ui/src/domain/coupon.js` + (after Phase 1) — a composer-computed preview in the API would remove the mirror. +- A shared Foundry fixture for the bond stack would remove the `setUp` duplicated between + `BondManager.t.sol`, `BondManagerLifecycle.t.sol`, and `BondLifecycle.t.sol`. + +## Changes Since Planning (2026-09-28) + +Changes merged after this plan was written (#300, #302, which also carry +#301 and #303). Effects on this plan: + +- `BondOrderBook`, `BondOrderBookFactory`, and their tests were removed (#302). The design rows + and plan steps that mention them, including `BondOrderBook.t.sol` in the Phase 4 test list, + no longer apply. +- `ERC1410._transferByPartition` and `_move` now take a single partition, and operator transfers + never change partition (#300). Phase 4 (transfer eligibility) builds on that signature. +- `BondToken.addController` no longer grants `BOND_CONTROLLER_ROLE`; `BondManager` holds the role + but is not an ERC-1410 controller, and `BondDvP` is both (#302). Test fixtures that wire roles + follow that shape. +- Re-run the Phase 0 reproductions on current `development` before starting; line references in + `design.md` predate these changes. + +## Session Handoff + +- Nothing implemented; no branch exists. The plan was written against `development` at + `db92409` with a clean tree. +- A separate hardening change also edits contract code; check whether it has landed + before starting Phase 4 and rebase accordingly. diff --git a/docs/plans/documentation-refresh/design.md b/docs/plans/documentation-refresh/design.md new file mode 100644 index 0000000..515dc01 --- /dev/null +++ b/docs/plans/documentation-refresh/design.md @@ -0,0 +1,240 @@ +# Documentation refresh — Design + +**Status:** Draft +**Created:** 2026-09-28 +**Intent:** [`intent.md`](intent.md) +**Builds on:** ADR 0003, [ADR 0004](../../decisions/0004-settle-all-bond-cash-legs-in-wnok.md), +[ADR 0005](../../decisions/0005-redeem-principal-with-the-final-coupon.md), #225, #259, #276, +#278, #291, #292, #293, #294. + +## Decision Summary + +Fix each drifted document at the place that owns the fact, in five small documentation PRs +ordered by risk: + +1. **Cloud wording and tooling names first.** They break publication rules; everything else is + accuracy. The two `testMode` descriptions live in Zod contract modules, so the generated + `openapi.json` is regenerated in the same PR. +2. **Plan housekeeping second**, after slice 1 has removed tooling names from the ERC-3643 plan, + because archiving freezes it. +3. **Removed behaviour and known issues third.** Pure doc corrections, verified against code. +4. **NatSpec source links fourth**, fixed in the committed post-processing script rather than by + hand, so every future regeneration produces resolvable links. +5. **License inventory and entrypoints last.** + +The closed-loop settlement plan is **parked**, not refreshed: its Phase 1 design needs real design +work (all four cash legs now use the allowance path) that belongs to whoever schedules it. The +plan gets a dated drift note and keeps `Proposed` status. + +## Current-State Evidence + +Line numbers are from `development` at `db92409` (2026-09-28). All findings below are +**Verified** against the named file unless marked otherwise. + +### D1 — Cloud wording outside the accepted surfaces + +The self-check was run over all tracked files (the pattern is kept with the operator's local +tooling, not in the repository). Hits were classified as follows. + +| Class | Files | Handling | +|---|---|---| +| Accepted: `entra` auth mode | `services/nb-bond-api/src/{app,auth,env-vars}.ts` (mode checks), `src/openapi/document.ts`, `src/contracts/live-events.ts`, nb-bond-api `README.md`/`DEVELOPMENT.md` auth sections, both Helm values files, `services/nb-ui/src/**` auth code and tests, nb-ui `README.md`/`AGENTS.md`/`DEVELOPMENT.md` auth sections, `services/nb-ui/package.json` and `THIRD_PARTY_LICENSES.md:108` (the MSAL package identifier) | Keep | +| Accepted by interpretation: docs describing the `entra` auth mode outside the two service folders | `docs/ARCHITECTURE.md:193,280-288,385-397`, `docs/diagrams/README.md:24`, `docs/diagrams/architecture/{system-context,nb-bond-api-components}.md`, `docs/diagrams/security/trust-boundaries.md`, `services/README.md:57`, `docs/plans/sandbox-fmi-wallet-plan.md:49`, `docs/DOCUMENTATION_INDEX.md:45,46,57` | Keep; listed for the operator to confirm | +| Accepted: existing `docs/AZURE_BOUNDARY.md` text and links to that path | `docs/AZURE_BOUNDARY.md`; links from nb-bond-api and nb-ui `DEVELOPMENT.md` | Keep the file and the link targets; the title is an operator question | +| Frozen records | 10 archived plans under `docs/plans/archive/`; no ADR or post-mortem hits | Report only | +| **To fix** | table below | Slice 1 | + +| Location | What it says (described, not quoted) | Replacement direction | +|---|---|---| +| `docs/plans/closed-loop-settlement-and-omnibus-custody-plan.md:18` | Non-goal names the cloud platform and its managed Kubernetes runtime | "No infrastructure change: the plan runs on the existing sandbox runtime; deployment is the existing Foundry scripts against the Besu RPC." | +| same file `:227` | Boundary names the cloud platform, the IaC tool, and the managed Kubernetes runtime | "It requires no infrastructure change; the existing Besu runtime hosts it." | +| `services/nb-bond-api/DEVELOPMENT.md:507` | Auth-mode sync attributed to the GitOps controller | "A non-local deployment must keep `NB_BOND_API_AUTH_MODE` and the nb-ui `AUTH_MODE` in sync." | +| same file `:521-524` | SSE proxy requirements framed for the cloud platform | "Behind a non-local gateway, response buffering must be disabled, …"; keep the link to `docs/AZURE_BOUNDARY.md` | +| `services/nb-bond-api/README.md:147` | Env-var sync attributed to the GitOps controller | "The deployment must keep these in sync with the nb-ui `AUTH_MODE`; …" | +| `services/nb-bond-api/helm/values.local.example.yaml:75` | Same, as a values comment | "Non-local deployments MUST keep this in sync with nb-ui AUTH_MODE." | +| `services/nb-bond-api/src/env-vars.ts:71` (comment) and `:120` (startup error text) | Same | Comment: "A non-local deployment must keep this in sync …"; error: "Mode and config must be kept in sync by the deployment." No test asserts that text (`tests/env-vars.test.ts` matches the missing variable names only). | +| `services/nb-bond-api/src/auth.ts:10` | Same | "The deployment keeps the two in sync." | +| `services/nb-bond-api/src/contracts/bonds.ts:162`, `src/contracts/auctions.ts:206` | `testMode` parameter description says GitOps-managed deployments should ignore the flag | "Non-local deployments should ignore it." Flows into `openapi.json:229,403,791,871,946` → regenerate | +| `services/nb-ui/src/utils/debugSettings.js:18` | Same, as a JSDoc line | "Sandbox-only. Non-local deployments should ignore this flag." | +| `services/nb-ui/DEVELOPMENT.md:247-250` | SSE gateway requirements framed for the cloud platform's gateway | "A non-local gateway must forward Authorization, …" | +| `services/nb-ui/DEVELOPMENT.md:107`, `services/nb-ui/src/auth/entraAuth.js:2` | The identity provider's former product name in parentheses after "Microsoft Entra" | Drop the parenthetical; "Microsoft Entra ID" stays (auth-mode surface) | +| `docs/DOCUMENTATION_INDEX.md:16` | `AZURE_BOUNDARY.md` entry names the GitOps tool and platform in parentheses | "… reused from a non-local deployment." | +| `docs/DOCUMENTATION_INDEX.md:62` | SSE plan entry names cloud proxy constraints and says it was verified on a cloud-hosted deployment | "… and explicit non-local proxy constraints. Shipped via #224." (drop the verification clause) | +| `docs/KNOWN_ISSUES.md:97` | Entra renewal follow-up "owned by the cloud deployment work" | "until a non-local deployment running `entra` mode exercises the timed renewal." (**new**, not in the original findings) | + +### D2 — Local tooling names in active docs + +| Location | Handling | +|---|---| +| `docs/KNOWN_ISSUES.md:212` | "Warrants its own implementation plan under `docs/plans/` before implementation." | +| `docs/plans/erc-3643-incremental-adoption-plan.md:7,380,411` | Replace tooling references with the committed conventions they stand for: `docs/plans/README.md` for plan layout and archiving, `CONTRIBUTING.md` for PR workflow. Must happen **before** the file is archived in slice 2, since archived plans are frozen. | +| 13 archived plans | Frozen; report only | + +### D3 — Plan tree + +- **Verified.** `docs/plans/erc-3643-incremental-adoption-plan.md` status is "Superseded in + direction by ADR 0002" but the file sits in the active `docs/plans/`. `docs/plans/README.md` + says superseded plans move to `archive/`. References: `docs/DOCUMENTATION_INDEX.md:28` (active + list), ADR 0002 lines 116 and 164 (plain text, frozen), the plan's own line 362. +- **Verified.** ADR 0004 and ADR 0005 carry `Plan:` lines naming `docs/plans/bond-cash-leg-wnok/` + and `docs/plans/redeem-with-final-coupon/`; both folders are now under `archive/`. The pointers + are plain text, so the link checker does not flag them. ADRs are frozen. +- Target: one convention line in `docs/plans/README.md`: "ADR plan pointers name the plan's + original path; archived plans are found under `archive/` with the same name." This resolves + all three ADR pointers without touching an ADR. + +### D4 — Closed-loop settlement plan + +| Finding | Evidence | +|---|---| +| Motivating tx evidence is from the chain reset by ADR 0003 (the transaction and block no longer exist; the chain id is still 2018, which makes it look current) | plan `:30`; `infra/besu/config/genesis.json` | +| Phase 1 changes only the issuance/buyback cash legs, but every bond cash leg now uses `BondDvP._settleCashLeg` → `transferFrom`: issuance (bidder pays), buyback, interim coupons, and the final coupon with principal (all paid from `GOV_RESERVE`) | `BondManager.sol:350-352,397-398,631-632`; `BondDvP.sol:97-98` | +| Decision D2 in the plan ("drop `TRANSFER_FROM_ROLE` on BondDvP") would break coupon and maturity settlement unless those legs also move to the authority path | same | +| Code references drifted: role grants are now `11_BondSetup.s.sol:51` and `:61`, not 54 and 64 | `contracts/script/norges-bank/11_BondSetup.s.sol` | +| Phase 6 is largely delivered: coupons and principal already settle in wNOK from the reserve, and maturity already burns through `BondDvP` → `redeemFor` (reverse DvP). What remains is the two-tier broker fan-out, which depends on Phase 3 | `BondDvP.sol:81,88`; `BondManager.sol:555,618-640` | +| Goal sentence and index entry still promise "two-tier coupon/redemption" as new work | plan `:9`; `docs/DOCUMENTATION_INDEX.md:25` | + +Refreshing Phase 1 properly means redesigning the authority path for four legs and two payers, +which is design work with operator decisions attached. Nothing is scheduled. **Recommendation: +park.** Keep `Status: Proposed`, prefix it with "parked , not scheduled", add a short +dated "Drift since authoring" block listing the rows above, fix the cloud wording (slice 1), and +update the index entry. Do not convert the single-file plan to a folder. + +### D5 — 2026-07-15 plans + +- **Verified.** `docs/plans/sandbox-fmi-wallet-plan.md:5` and + `docs/plans/sandbox-entity-directory-and-name-service-plan.md:5` suggest `imp/…` branches; the + repository convention is `feature/` against `development`. Both are `Proposed` with no + implementation since 2026-07-15. +- Fix the two branch lines. Keep or park is an operator decision; if parked, use the same status + phrasing as D4. + +### D6 — Removed behaviour still documented + +| Location | Drift | Evidence | Correction | +|---|---|---|---| +| `docs/KNOWN_ISSUES.md:100-114` | Reopen-auction entry says the UI exposes "Reopen…" and the API client throws 501 | #225 removed it; `services/nb-ui/tests/auctionsApi.test.js:6` asserts `AuctionsApi.reopenAuction` is undefined | Replace with a short entry "Closed and finalised auctions cannot be reopened", keeping the existing bullet that a finalised auction is terminal and a bad outcome needs a fresh auction | +| `docs/ARCHITECTURE.md:157-160` | Failed-issuance units stay in `BondManager` "for explicit withdrawal" | `withdrawFailedIssuance` removed in #278; `BondManager.sol:555` burns unsold units at maturity | "…may leave unsold units on `BondManager`; they earn no coupon and the final `payCoupon` burns them without payment (ADR 0005)." | +| `contracts/docs/contracts-security.md:44` | `BOND_MANAGER_ROLE` can "recover failed issuance … and redeem bonds" | `BondManager.sol` role-gated functions: deploy, deploy-with-auction, extend, buyback, finalise, disable, close, cancel, `payCoupon` | "Can deploy bonds; open issuance, extension, and buyback auctions; close, cancel, and finalise them; disable unissued bonds; and pay coupons, where the final payment also pays principal and closes the bond." | +| `docs/diagrams/architecture/nb-bond-api-components.md:19` | Lifecycle handlers "create / disable / coupon / redeem" | Redeem route removed in #276; no redeem route in `app.ts` | "create / disable / coupon and closure" | +| `docs/diagrams/architecture/runtime-deployment.md:86-88` | NodePort and Kind mappings "reserve ports 443 and 8546" | #259; `infra/cluster/cluster-config.yaml` maps only 80 and 8545 | Delete that sentence; keep the WebSocket sentence | +| `services/nb-bond-api/DEVELOPMENT.md:362` | Lists `GET /v1/bonds/{isin}/holders` | No such route in `src/app.ts` | Replace with the projection-backed reads that exist (`GET /v1/bonds`, `/v1/bonds/{isin}`, `/v1/bonds/{isin}/history`, `/v1/auctions`) | +| `services/AGENTS.md:15`, `services/DEVELOPMENT.md:80-88` | `./bens-microservice.sh start` | No such script ever existed (`git grep` finds only these two lines); `services/blockscout/bens-microservice/README.md:12-17` says `blockscout.sh start` builds and deploys BENS | "BENS is built and deployed by `./blockscout.sh start`; there is no separate start command." | +| `services/AGENTS.md` Structure | Omits `services/nb-ui/` | directory exists with its own `AGENTS.md` | Add one line pointing at `services/nb-ui/AGENTS.md` | +| `docs/diagrams/operations/sandbox-startup-flow.md` | No "resume stopped node" step; contract deploy shown as unconditional | `sandbox.sh:175-178` (`startClusterNodes` when the cluster exists, #293); `contracts/contracts.sh:166` skips deploy when already deployed | Add a node after the registry check: "Cluster exists? start stopped node containers"; label contracts node "deploy unless already deployed on this chain" | +| `docs/DOCUMENTATION_INDEX.md:101` | "`docs/diagrams/operations/*.md`: Sandbox lifecycle and deployment flowcharts" | only `sandbox-startup-flow.md` exists | "Sandbox startup flowchart (`./sandbox.sh start`, including resume after `stop`)." | +| `services/nb-bond-api/README.md:13`, `services/nb-bond-api/DEVELOPMENT.md:140` | Link label `docs/openapi-v2-plan.md`; target is correct | target `docs/plans/archive/openapi-v2-plan.md` | Label = target path | +| `docs/diagrams/processes/bond-lifecycle.md:12,41,50-52` | Classifier shows "`everIssued AND totalSupply > 0`"; prose says a full pre-maturity buyback falls back to `staged` | #278 changed `bondStatus` to `everIssued && (supply > 0 || remaining coupon periods > 0)` (`compose-projection.ts:188-212`); the diagram was last touched in #276 | Update the decision node, the `AUCTIONING --> OUTSTANDING` label, and the prose: a fully bought-back bond stays `outstanding` while coupon periods remain; only a bond with no remaining periods and zero supply falls back to `staged` | + +Handled by another plan (not here): `services/nb-ui/DEVELOPMENT.md:33` link label and the +`reopenAuction` references at `services/nb-ui/AGENTS.md:85` and `services/nb-ui/DEVELOPMENT.md:254` +belong to `nb-ui-correctness-and-hardening`. + +### D7 — Known issues + +| Item | Evidence | Handling | +|---|---|---| +| Resolved ingestion self-heal entry (`:138-149`) | Struck-through heading "— resolved" | Remove. The next entry (`:152`) opens with "Same root trigger as the ingestion bug above"; reword to "Triggered by a briefly unreachable Besu RPC." | +| Sample error text `version=6.16.0` (`:158`) | `ethers` pinned at 6.17.0 in `services/nb-bond-api/package.json:24` and both CLIs | Drop the version from the sample (`version=…`) so it cannot drift again | +| Blockscout returns `ens_domain_name: null` for names BENS resolves | Recorded only in `docs/plans/archive/blockscout-catchup-indexer/progress.md:35-39` (**Inferred** pre-existing there). 2026-09-28: `GET /api/v2/transactions` on the running sandbox returned `ens_domain_name: null` for every party (**Verified** null; BENS-side resolution not re-checked) | New entry; link the archived progress file | +| BENS returns `500` for the first seconds on a fresh `blockscout` namespace (`mapping` table not yet created) | same file `:40-41`; Phase 2 evidence there shows two `500`s | New entry, same link | +| Bond units transfer without any eligibility check | `ERC1410.transferByPartition` → `_transferByPartition` → `_move` checks only granularity and balances (`contracts/src/norges-bank/ERC1410/ERC1410.sol:178-296`); the coupon allowlist entry (`:331-343`) already names "an address that received units by transfer" | Add only if the operator defers the transfer eligibility rule proposed in `docs/plans/bond-coupon-maturity-correctness/`; otherwise that plan's own docs update covers it. Link the archived ERC-3643 incremental plan, which already analyses the gap | +| ~~Full pre-maturity buyback shows as `staged`~~ | **Dropped as a known issue**: since #278 such a bond stays `outstanding` while periods remain. The stale text is in the diagram and is fixed under D6 | — | + +### D8 — NatSpec "Git Source" links + +- **Verified.** All 32 contract pages under `contracts/docs/natspec/src/src/` link to + `…/blob/e1ad13913c07…/src/.sol`. Two faults: the path omits the `contracts/` prefix + (`forge doc` runs with `contracts/` as project root), and `e1ad139` is a local pre-squash commit + that is on no branch (`git branch -a --contains` is empty; it was squash-merged as `a35d5af`, + #231), so GitHub cannot serve it. +- **Verified.** `check-markdown-links.py` ignores `http(s)` links (`IGNORED_PREFIXES`), which is + why the 404s went unnoticed. +- **Verified (scratch run, 2026-09-28).** `forge doc` with a `[doc] path` value in `foundry.toml` + still emitted `blob//src/…`; Foundry has no setting that adds the subdirectory prefix. +- **Verified (scratch run).** Regenerating from current source and running `fix-links.py` + differs from the committed tree only in the Git Source lines plus three pages: + `src/README.md` (homepage copy of `contracts/README.md`, still saying "redemption flow"), + `BondManager.md` (internal helpers and structs missing), and `IBondManager.md` (whitespace). + The homepage's relative links are hand-fixed in the committed copy; `fix-links.py` does not + rewrite them. +- Target: extend `contracts/docs/natspec/fix-links.py` to rewrite Git Source URLs into + repository-relative links to `contracts/src/…` (checked by the link checker, correct at every + commit, no branch assumption), and to rewrite the homepage's relative links. Then regenerate + once and update the natspec `README.md` note that says links embed the commit hash. + +### D9 — License inventory and entrypoints + +| Location | Drift | Evidence | Correction | +|---|---|---|---| +| `THIRD_PARTY_LICENSES.md:29` | Snapshot "as of September 4, 2026" | Rows changed in #283, #291, #292 (last 2026-09-25) | Date of the slice's merge | +| `THIRD_PARTY_LICENSES.md:165` | Blockscout application "Pulled at deploy time" | `docs/KNOWN_ISSUES.md:243-266`; `./sandbox.sh build-images` source-builds backend and frontend | "Built locally from pinned upstream source tags (`./sandbox.sh build-images`); upstream no longer publishes release images" | +| Deployment-Time table | No `nginxinc/nginx-unprivileged` row | `common/images.yaml:55`; `services/nb-ui/Dockerfile:19`; listed in `docs/THIRD_PARTY_NOTES.md:47` | Add row: BSD-2-Clause, NB UI runtime stage. Also widen the Node.js row to "NB Bond API image and NB UI build stage" | +| `README.md:48-52` "AI Ready" list | Omits `services/nb-ui/AGENTS.md` | file exists | Add it | +| `CONTRIBUTING.md` | No NB UI checks; no repository checks | CI `nb-ui.yml` runs root `npm ci` then `format:check`, `lint`, `test`, `build` with `-w nb-ui`; `publication-hygiene.yml`, `license-inventory.yml` | Add an NB UI block and a "Repository checks" block mirroring CI. The existing NB Bond API block runs `npm ci` inside the package while CI installs once at the workspace root; aligning it with CI is recommended (**Needs verification** whether the in-package form still works) | +| `docs/DOCUMENTATION_INDEX.md:19` | ADR 0001 entry does not say superseded | ADR 0001 status line; `docs/decisions/README.md:33` | Append "Superseded by ADR 0003." | + +`check-third-party-licenses.py` passes today because it checks manifests, not image pins; the +missing image row is manual drift. + +### Frozen-record status lines (report only) + +- `docs/plans/archive/blockscout-catchup-indexer/progress.md:5-6,15` and + `docs/plans/archive/sandbox-stop-keeps-state/progress.md:5,18` still say the final PR phase is + "In progress" while their `plan.md` status says Implemented. Recommendation: with operator + approval, change only the `Current phase` and `Next action` header lines to + "Complete — #294 merged" / "Complete — #293 merged" and "None", matching + `archive/coupon-closure-review-fixes/progress.md`; leave the phase-log rows. +- 10 archived plans contain cloud wording and 13 name local tooling. They are frozen; the + operator decides whether that history is accepted as is. + +## Invariants + +- Only documentation, comments, one generated OpenAPI file, generated NatSpec pages, and the + NatSpec post-processing script change. No runtime behaviour, route, schema shape, or contract + changes. +- ADR bodies, archived plan bodies, and post-mortems are not edited. +- Accepted `entra` surfaces keep their wording. +- Every new or edited relative link resolves; no new links into ADRs. + +## Alternatives Considered + +- **Link NatSpec sources to `blob/main/contracts/src/…` or `blob/development/…`** — resolves on + GitHub but lags or leads the page it sits on, and the link checker ignores it. Rejected for + relative links. +- **Hand-patch the 32 Git Source lines once** — fixes today, breaks on the next `forge doc`. + Rejected. +- **Refresh the closed-loop plan now** — needs design decisions on four legs and two payers with + no schedule behind them. Rejected for park-with-drift-note. +- **Edit ADR `Plan:` lines** — ADRs are frozen. Rejected for the plans README convention. +- **One large docs PR** — mixes a hard-rule fix with routine accuracy work and invites merge + conflicts with the other plans. Rejected for five slices. + +## Decisions + +| Decision | Recommendation | Rationale | Operator action required | +|---|---|---|---| +| Closed-loop plan | Park with drift note, keep Proposed | Refresh is design work nobody scheduled | Confirm | +| Wallet and entity-directory plans | Fix branch lines; keep or park | No implementation since 2026-07-15 | Decide keep / park | +| Transfer eligibility known issue | Add only if the bond coupon and maturity plan defers its eligibility rule | Avoid an entry that one PR later becomes wrong | Decide with that plan | +| NatSpec links | Relative links rewritten by `fix-links.py`, one regeneration PR | Durable, checked by CI | None | +| Archived progress headers | Update the two header lines | Status-line-only change allowed | Explicit approval (frozen records) | +| `docs/AZURE_BOUNDARY.md` title | Leave as existing text | Rule accepts existing text | Operator may decide otherwise | +| Entra mentions in `docs/` outside the service folders | Accept as auth-mode descriptions | They describe the public `entra` surface | Confirm the interpretation | +| Frozen records with cloud wording or tooling names | Leave | Frozen-record rule | Confirm | + +## Cross-Plan Boundaries + +| Other plan (folder under `docs/plans/`) | Overlap | Rule | +|---|---|---| +| `nb-bond-api-hardening` | May edit `src/env-vars.ts`, `src/auth.ts`, `src/contracts/*.ts` and regenerate `openapi.json` | Whichever lands second rebases and reruns `regen:openapi`; slice 1 is small and should land first | +| `nb-ui-correctness-and-hardening` | Owns nb-ui `AGENTS.md`/`DEVELOPMENT.md`/`config.js` doc fixes | Slice 1 touches only the cloud-wording lines in nb-ui (`DEVELOPMENT.md:107,247-250`, `debugSettings.js:18`, `entraAuth.js:2`); everything else in nb-ui docs is theirs | +| `bond-coupon-maturity-correctness` | Contract changes regenerate NatSpec pages; may add a transfer eligibility rule; edits `docs/KNOWN_ISSUES.md` and contract docs | Land slice 4's `fix-links.py` change before or with that plan's NatSpec regeneration; transfer-eligibility entry decided jointly | +| `ingestion-projection-correctness` | May change `bondStatus` | If it lands first, the bond-lifecycle diagram fix follows its classifier, not the one quoted here | +| `sandbox-scripts-and-ci-hardening` | Owns README, Makefile, and script-comment fixes tied to its script changes; may touch `sandbox.sh start` | If it changes the start order, it updates `sandbox-startup-flow.md` itself; slice 3 adds only the resume step that exists today | + +## Residual Risks + +- The cloud-term self-check is manual; a future edit can reintroduce wording. A committed check + would be a new CI surface and needs operator approval (listed as a follow-up). +- Deployment-time image rows remain hand-maintained for the same reason. diff --git a/docs/plans/documentation-refresh/intent.md b/docs/plans/documentation-refresh/intent.md new file mode 100644 index 0000000..61507ae --- /dev/null +++ b/docs/plans/documentation-refresh/intent.md @@ -0,0 +1,97 @@ +# Documentation refresh — Intent + +**Status:** Draft +**Created:** 2026-09-28 +**Requested by:** sandbox operator + +## Outcome + +A reader of the public repository finds documentation that matches what the code does today and +that follows the repository's own publication rules: no cloud-deployment specifics outside the +accepted surfaces, no names of local-only agent tooling, active plans that are actually active, +no behaviour described that was removed months ago, a known-issues list without resolved items, +contract reference pages whose source links resolve, and a license inventory that lists every +image the sandbox builds on. + +## Why This Change + +A read-only review on 2026-09-28 found documentation drift that predates every plan written that +day. Each item is small, but together they mislead: operators are told to run a script that never +existed, reviewers see an API route and a contract function that were removed, the generated +contract reference links to a commit that is not on GitHub, and several committed files name the +tooling or the cloud platform the operator deliberately keeps out of the public tree. The +cloud-wording items break a hard publication rule, so they come first. + +## Scope + +### In Scope + +Pre-existing drift only, where a document describes behaviour that has already changed: + +1. **Cloud wording (D1).** Committed docs, Helm example values, source comments, and the + generated `services/nb-bond-api/openapi.json` that name the cloud platform, its managed + Kubernetes service, the GitOps controller, or the infrastructure-as-code tool. Replace them + with environment-neutral wording ("non-local deployment", "the deployment"). Accepted + surfaces stay: the `entra` auth mode in `services/nb-ui` and `services/nb-bond-api` (including + its npm package identifier), and existing text in `docs/AZURE_BOUNDARY.md`. +2. **Local tooling names (D2).** Active committed docs that name local agent tooling. +3. **Plan housekeeping (D3–D5).** Archive the superseded ERC-3643 incremental plan; add one + convention line to `docs/plans/README.md` about ADR plan pointers; park the closed-loop + settlement plan with a drift note; fix the branch-suggestion lines in the two 2026-07-15 + plans. +4. **Removed behaviour still documented (D6).** The reopen-auction known issue, failed-issuance + withdrawal, the `BOND_MANAGER_ROLE` row, the redeem route in a component diagram, removed + gateway ports, a holders endpoint that does not exist, a BENS script that never existed, the + services structure list, the startup flowchart's resume step, the diagram index wording, stale + link labels, and the bond status classifier diagram. +5. **Known issues (D7).** Remove the resolved ingestion entry; correct the sample ethers version; + add the two BENS follow-ups recorded only in an archived plan; add the bond transfer + eligibility gap unless the bond coupon and maturity correctness plan adopts its fix. +6. **Generated NatSpec links (D8).** Every "Git Source" link in `contracts/docs/natspec/` 404s. +7. **License inventory and entrypoints (D9).** Snapshot date, the Blockscout application row, + the missing NB UI runtime image row, the README agent-guidance list, the CONTRIBUTING check + list, and the ADR 0001 index entry. + +### Out of Scope + +- Frozen records: numbered ADRs, archived plans, post-mortems. Drift in them is reported in + `design.md`; only status lines may change, and only with operator approval. +- Documentation that other plans written on 2026-09-28 update as they land: + `bond-coupon-maturity-correctness`, `ingestion-projection-correctness`, + `nb-bond-api-hardening`, `nb-ui-correctness-and-hardening` (owns the nb-ui `AGENTS.md`, + `DEVELOPMENT.md`, and `config.js` doc fixes other than the cloud wording in D1), and + `sandbox-scripts-and-ci-hardening` (owns README, Makefile, and script-comment fixes tied to its + script changes). `design.md` lists the exact boundaries. +- Index entries for the plan folders written on 2026-09-28; the operator adds them when + committing those folders. +- Any behaviour change, new CI check, or new dependency. + +## Acceptance Criteria + +| Criterion | Risk addressed | Verification evidence | +|---|---|---| +| The operator's cloud-term self-check over tracked files, excluding frozen records, returns only accepted surfaces | Hard publication rule broken | Self-check output recorded in `progress.md` (counts only) | +| `openapi.json` differs from `development` only in the five `testMode` description strings | Unintended API contract change | `npm run regen:openapi -w nb-bond-api`; `git diff --stat` | +| No active committed doc names local agent tooling | Committed text rule | grep for tooling names outside frozen records returns nothing | +| `docs/plans/` holds only plans that are Proposed, Approved, or In progress; the superseded plan is under `archive/` and indexed there | Plan tree misleads | `ls docs/plans`; index entry | +| No active doc describes `reopenAuction`, failed-issuance withdrawal, the redeem route, the holders endpoint, ports 443/8546, or the BENS start script | Operators follow removed paths | grep in the verification stop of Phase 3 | +| Bond status diagram matches `bondStatus` in `compose-projection.ts` | Diagram contradicts code | Side-by-side review | +| `docs/KNOWN_ISSUES.md` has no resolved entries and records the BENS follow-ups | Stale and missing issues | Review of the diff | +| Every "Git Source" link in the NatSpec pages resolves and is checked by the link checker | 404 links nobody notices | `check-markdown-links.py` passes with relative links | +| `THIRD_PARTY_LICENSES.md` lists every pinned runtime image, and its date matches the last row change | Inventory incomplete | Row review against `common/images.yaml`; license check passes | +| Hygiene and markdown-link checks pass on every slice | Broken links, leaked identifiers | CI `validate-publication-hygiene` | + +## Constraints + +- Sandbox-sized; documentation and comment changes only, plus one generated file and one + post-processing script. +- Public repo: no secrets, private identifiers, home-directory paths, tooling names, or + cloud-deployment specifics in any committed text, including this plan. +- Frozen records change only on their status line, with operator approval. + +## Open Questions + +- Whether the title of `docs/AZURE_BOUNDARY.md`, which names the cloud platform and the GitOps + tool, is within its established generic scope. It is existing text and stays as is unless the + operator decides otherwise. Owner: operator. +- Keep or park the two 2026-07-15 plans (wallet, entity directory). Owner: operator. diff --git a/docs/plans/documentation-refresh/plan.md b/docs/plans/documentation-refresh/plan.md new file mode 100644 index 0000000..669e2d7 --- /dev/null +++ b/docs/plans/documentation-refresh/plan.md @@ -0,0 +1,323 @@ +# Documentation refresh — Implementation Plan + +**Status:** Proposed +**Created:** 2026-09-28 +**Scope:** `docs/` (index, known issues, architecture, diagrams, plans tree), `services/AGENTS.md`, `services/DEVELOPMENT.md`, `services/nb-bond-api/{README,DEVELOPMENT}.md`, `services/nb-bond-api/helm/values.local.example.yaml`, `services/nb-bond-api/src/{env-vars,auth}.ts`, `services/nb-bond-api/src/contracts/{bonds,auctions}.ts`, `services/nb-bond-api/openapi.json` (generated), cloud-wording lines in `services/nb-ui/`, `contracts/docs/contracts-security.md`, `contracts/docs/natspec/` (script and generated pages), `THIRD_PARTY_LICENSES.md`, `README.md`, `CONTRIBUTING.md` +**Intent:** [`intent.md`](intent.md) · **Design:** [`design.md`](design.md) · **Progress:** [`progress.md`](progress.md) + +## Plan Order + +```text +Phase 0 Baseline: rerun the searches, record counts -> no PR +Phase 1 Cloud wording + local tooling names (+ openapi.json regen) -> PR 1 +Phase 2 Plan housekeeping: archive, README convention, park, branches -> PR 2 +Phase 3 Removed behaviour, diagrams, known issues -> PR 3 +Phase 4 NatSpec source links: script fix + one regeneration -> PR 4 +Phase 5 License inventory, README, CONTRIBUTING, ADR 0001 index entry -> PR 5 +``` + +Phase 1 goes first because it fixes a hard publication rule. Phase 2 depends on Phase 1 (the +ERC-3643 plan must lose its tooling names before archiving freezes it). Phases 3–5 are +independent of each other; each touches `docs/DOCUMENTATION_INDEX.md` on different lines, so +merge them one at a time and rebase the next on `development`. + +## Phase 0: Baseline + +### Goal + +Confirm every finding in `design.md` still holds on the current `development` before editing. + +### Steps + +1. `git switch development && git pull --ff-only`. +2. Run the operator's cloud-term self-check over tracked files; classify every hit with the + table in `design.md` §D1. Record counts per class in `progress.md`, never quoted matches. +3. `git grep -n` for the local tooling names outside `docs/plans/archive/` and `docs/decisions/` + (the names themselves stay out of committed text). +4. `git grep -n -E "reopenAuction|withdrawFailedIssuance|bens-microservice\.sh|/holders|443 and 8546|openapi-v2-plan\.md\]"` + outside frozen records. +5. `grep -c "Git Source" -r contracts/docs/natspec/src` and one sample link. +6. `python3 scripts/verification/check-third-party-licenses.py`, + `python3 scripts/verification/check-public-repo-hygiene.py`, + `python3 scripts/verification/check-markdown-links.py` (all should pass today). +7. Check which of the other 2026-09-28 plans have merged (see `design.md` "Cross-Plan + Boundaries") and drop or adjust any item they already fixed. + +### Verification Stop + +- Every design row is either confirmed or marked as already fixed in `progress.md`. + +### Exit Criteria + +- [ ] Baseline counts recorded in `progress.md`. + +## Phase 1: Cloud wording and local tooling names (PR 1) + +### Goal + +No active committed file names the cloud platform, its managed Kubernetes service, the GitOps +controller, the IaC tool, or local agent tooling, outside the accepted surfaces. + +### Scope + +Every "To fix" row of `design.md` §D1 and every active row of §D2. + +### Steps + +1. Docs: `docs/plans/closed-loop-settlement-and-omnibus-custody-plan.md:18,227`, + `services/nb-bond-api/DEVELOPMENT.md:507,521-524`, `services/nb-bond-api/README.md:147`, + `services/nb-ui/DEVELOPMENT.md:107,247-250`, `docs/DOCUMENTATION_INDEX.md:16,62`, + `docs/KNOWN_ISSUES.md:97` — apply the replacement direction from the design table. +2. Values example: `services/nb-bond-api/helm/values.local.example.yaml:75`. + `scripts/generate-local-sandbox-fixtures.mjs` reads the example as its template, so an + existing local `values.local.yaml` keeps the old comment until it is regenerated. That file + is gitignored and the comment has no runtime effect, so no regeneration is required. +3. Source comments and strings: `services/nb-bond-api/src/env-vars.ts:71,120`, + `src/auth.ts:10`, `src/contracts/bonds.ts:162`, `src/contracts/auctions.ts:206`, + `services/nb-ui/src/utils/debugSettings.js:18`, `services/nb-ui/src/auth/entraAuth.js:2`. + Keep the two `testMode` descriptions identical to each other. +4. Regenerate the OpenAPI snapshot from the repository root: + `npm run regen:openapi -w nb-bond-api`. +5. Tooling names: `docs/KNOWN_ISSUES.md:212`; + `docs/plans/erc-3643-incremental-adoption-plan.md:7,380,411` (point at `docs/plans/README.md` + and `CONTRIBUTING.md` instead). +6. Package gates, mirroring CI from the repository root: `npm ci`; + `npm run lint -w nb-bond-api`, `npm run format:check -w nb-bond-api`, `npm test -w nb-bond-api`; + `npm run format:check -w nb-ui`, `npm run lint -w nb-ui`, `npm test -w nb-ui`, + `npm run build -w nb-ui`. +7. Hygiene and link checks. + +### Verification Stop + +- The self-check returns only accepted classes; counts recorded in `progress.md`. +- `git diff --stat services/nb-bond-api/openapi.json` shows only the five `testMode` + description lines changed. +- No test asserts the changed error text (`tests/env-vars.test.ts` matches variable names only). +- All gates green. + +### Failure Diagnosis / Fix Forward / Rollback + +- `openapi.json` shows unrelated changes: the snapshot was stale before this PR (another PR + changed a schema without regenerating). Stop, report it, and land the regeneration separately + or with the operator's agreement; do not hide it in this PR. +- Prettier fails on a rewrapped comment: run the package `format` script, re-run the gate. +- Revert is a plain `git revert`; no runtime effect. + +### Exit Criteria + +- [ ] Every D1 "To fix" row and D2 active row resolved. +- [ ] `openapi.json` regenerated with only the description change. +- [ ] Package gates, hygiene, and link checks pass. + +## Phase 2: Plan housekeeping (PR 2) + +### Goal + +The active plan tree contains only live plans with correct metadata, and ADR plan pointers +resolve by convention. + +### Steps + +1. `git mv docs/plans/erc-3643-incremental-adoption-plan.md docs/plans/archive/`; append + "archived " to its `Status:` line. Move its index entry from the active list + (`docs/DOCUMENTATION_INDEX.md:28`) into the archive list with the new path. Fix any other + reference found by `git grep erc-3643-incremental-adoption-plan` outside ADRs. +2. `docs/plans/README.md`: add under "Conventions": "ADR plan pointers name the plan's original + path; archived plans are found under `archive/` with the same name." +3. Closed-loop plan (edit in place, do not convert): status prefix "Proposed — parked + , not scheduled"; add a dated "Drift since authoring" block under the status + lines with the five rows of `design.md` §D4 (evidence from the pre-reset chain; all four cash + legs now use `transferFrom`, so Phase 1 and decision D2 need redesign; line references moved to + `11_BondSetup.s.sol:51,61`; Phase 6 largely delivered by ADR 0004/0005; refresh before + implementing). Leave the phase bodies unchanged. Update the index entry (`:25`) to say parked + and predating ADR 0003–0005. +4. `docs/plans/sandbox-fmi-wallet-plan.md:5` → `feature/sandbox-fmi-wallet`; + `docs/plans/sandbox-entity-directory-and-name-service-plan.md:5` → + `feature/entity-directory-service`. If the operator chooses to park them, apply the D4 status + phrasing and index note. +5. Only with explicit operator approval: update the `Current phase` / `Next action` header lines + of `docs/plans/archive/blockscout-catchup-indexer/progress.md` and + `docs/plans/archive/sandbox-stop-keeps-state/progress.md` (design §"Frozen-record status + lines"). Nothing else in those files. + +### Verification Stop + +- `ls docs/plans` lists only `README.md`, `archive/`, and plans with status Proposed / Approved / + In progress (plus the 2026-09-28 folders once committed). +- Link check passes (the archived plan's internal relative links still resolve from its new + depth; fix any that do not before archiving, since the file freezes after the move). + +### Failure Diagnosis / Fix Forward / Rollback + +- A relative link inside the moved plan breaks: fix it in the same commit as the move (the edit + is part of archiving, not a later change to a frozen record). + +### Exit Criteria + +- [ ] Superseded plan archived and indexed as archived. +- [ ] Convention line present. +- [ ] Closed-loop plan parked with drift note; branch lines fixed. + +## Phase 3: Removed behaviour, diagrams, and known issues (PR 3) + +### Goal + +No active doc describes removed behaviour; the known-issues list has no resolved entries and +contains the follow-ups that were only recorded in an archived plan. + +### Steps + +1. Apply every row of `design.md` §D6 except the ones owned by the nb-ui plan. +2. `docs/diagrams/operations/sandbox-startup-flow.md`: add the resume node between + `registry` and `infraQ`, and relabel the contracts node. Keep Mermaid valid (render the file in + a Mermaid previewer or on the PR's rich diff). +3. `docs/diagrams/processes/bond-lifecycle.md`: match `bondStatus` in + `services/nb-bond-api/src/projection/compose-projection.ts` at the time of the PR (re-read it; + the ingestion plan may have changed it). +4. `docs/KNOWN_ISSUES.md`: apply §D7 — remove the resolved entry and reword the next entry's + opening; generalise the sample version; replace the reopen entry with the short terminal-auction + entry; add "Blockscout does not show BENS names" and "BENS returns 500 briefly on a fresh + Blockscout namespace", each linking + [`docs/plans/archive/blockscout-catchup-indexer/progress.md`](../archive/blockscout-catchup-indexer/progress.md) + with the observed evidence and "Planned follow-up: investigate" wording; add the transfer + eligibility entry only per the operator's decision. +5. Hygiene and link checks. + +### Verification Stop + +- The Phase 0 step 4 grep returns only frozen records and nb-ui files owned by the nb-ui plan. +- Mermaid blocks in the two edited diagrams render. + +### Failure Diagnosis / Fix Forward / Rollback + +- A diagram fails to render: fix the syntax in the same PR; do not merge a broken diagram. + +### Exit Criteria + +- [ ] All D6 and D7 rows resolved or explicitly deferred with a reason in `progress.md`. + +## Phase 4: NatSpec source links (PR 4) + +### Goal + +Every "Git Source" link in the committed NatSpec pages resolves at the commit it is viewed at, +and stays that way after future regenerations. + +### Steps + +1. Extend `contracts/docs/natspec/fix-links.py`: + - rewrite any link whose label is `Git Source` and whose target matches + `https://github.com///blob//src/` to a path relative to the page, + pointing at `contracts/src/`; + - rewrite the relative links in the generated homepage (`src/README.md`, a copy of + `contracts/README.md`) so they resolve from `contracts/docs/natspec/src/`; + - keep the script idempotent and standard-library only. +2. From `contracts/`: `forge doc --out docs/natspec` then `python3 docs/natspec/fix-links.py`. +3. Review the diff: expected are the 32 Git Source lines, the homepage refresh, and the + `BondManager` / `IBondManager` pages. Anything else is Foundry-version churn — keep it only if + it is content, otherwise note it in `progress.md`. +4. Update `contracts/docs/natspec/README.md`: remove the note that links embed the commit hash; + say `fix-links.py` makes source links repository-relative. +5. Link check (it now validates the 32 source links), hygiene check. + +### Verification Stop + +- `check-markdown-links.py` passes and the count of checked links rose by about 32. +- `grep -r "blob/" contracts/docs/natspec/src` returns nothing. +- Running `fix-links.py` a second time reports zero changed files. + +### Failure Diagnosis / Fix Forward / Rollback + +- `forge doc` output differs widely because of a Foundry version change: stop, record the + version in `progress.md`, and ask the operator whether to accept the churn or hand-apply the + link rewrite to the existing pages (the script change is useful either way). +- If a contract-changing plan is in flight, land this PR first and let that plan regenerate with + the fixed script, or rebase and regenerate after it merges. + +### Exit Criteria + +- [ ] Script fixed; pages regenerated; links resolve; README note updated. + +## Phase 5: License inventory and entrypoints (PR 5) + +### Steps + +1. `THIRD_PARTY_LICENSES.md`: add the `nginxinc/nginx-unprivileged` row (BSD-2-Clause, NB UI + runtime stage, pinned in `common/images.yaml` under `nb_ui.nginx`); widen the Node.js row; + reword the Blockscout application row; set the snapshot date to the merge date. +2. `README.md` "AI Ready": add `services/nb-ui/AGENTS.md: NB UI operator frontend guidance`. +3. `CONTRIBUTING.md`: add an "NB UI" block and a "Repository checks" block + (`check-public-repo-hygiene.py`, `check-markdown-links.py`, `check-third-party-licenses.py`) + mirroring CI; align the NB Bond API block with the root-workspace install that CI uses. +4. `docs/DOCUMENTATION_INDEX.md:19`: append "Superseded by ADR 0003." +5. All three verification scripts. + +### Verification Stop + +- Each image in `common/images.yaml` and `common/node-version.env` maps to a Deployment-Time row + or a `docs/THIRD_PARTY_NOTES.md` entry. +- License, hygiene, and link checks pass. + +### Exit Criteria + +- [ ] Inventory complete for pinned images; entrypoints list every area guide and CI check. + +## Test Matrix + +| Layer | Risk or behavior | Test/evidence | +|---|---|---| +| API/Zod contract | Description-only change leaks into schema shape | `openapi.json` diff limited to five strings; `npm test -w nb-bond-api` | +| API startup | Changed error text breaks env validation | `tests/env-vars.test.ts` (asserts variable names) | +| UI | Comment-only change | nb-ui format, lint, test, build | +| Docs | Broken relative links, including new NatSpec source links | `check-markdown-links.py` | +| Docs | Leaked identifiers or key-shaped strings | `check-public-repo-hygiene.py`; manual cloud-term self-check | +| Docs | Inventory rows vs manifests | `check-third-party-licenses.py` | +| Diagrams | Mermaid syntax after edits | Rendered preview of the two edited diagrams | +| Script | `fix-links.py` idempotent and complete | Second run changes zero files; no `blob/` left | + +## Recommended PR Slices + +| Slice | Architectural outcome | Main proof | Temporary compatibility/cleanup | +|---|---|---|---| +| 1 `feature/docs-neutral-deployment-wording` | Publication rules hold in active files | Self-check counts; five-string `openapi.json` diff; package gates | Frozen records left as reported | +| 2 `feature/docs-plan-tree-housekeeping` | Plan tree reflects reality | `ls docs/plans`; link check | Archived progress headers only with approval | +| 3 `feature/docs-removed-behaviour-and-known-issues` | Docs match shipped behaviour | Grep of removed names; rendered diagrams | Transfer-eligibility entry pending decision | +| 4 `feature/natspec-relative-source-links` | Durable, checked source links | Link check count; idempotent script | None | +| 5 `feature/docs-license-inventory-and-entrypoints` | Inventory and entrypoints complete | License check; row review | None | + +Each slice is a `feature/` PR against `development`; branch, commit, and PR conventions +follow `CONTRIBUTING.md`. + +## Migration, Rebuild, and Rollout + +- No data, schema, chain, or image effect. The only generated artifacts are `openapi.json` + (slice 1) and the NatSpec pages (slice 4). No sandbox restart is needed. + +## Documentation and Public-Repo Hygiene + +- Index updates: slice 1 (`:16`, `:62`), slice 2 (`:25`, `:28`), slice 3 (`:101`), slice 5 + (`:19`). Entries for the plan folders written on 2026-09-28 are added by the operator's commit + of those folders, not by this plan. +- Committed text in every slice: no vendor, tooling, or cloud-deployment names; the self-check + runs before each push. + +```bash +python3 scripts/verification/check-public-repo-hygiene.py +python3 scripts/verification/check-markdown-links.py +python3 scripts/verification/check-third-party-licenses.py +``` + +## Out of Scope + +- Frozen-record bodies; the `docs/AZURE_BOUNDARY.md` title (operator question). +- nb-ui doc fixes other than cloud wording; README and script-comment fixes tied to script + changes; any contract, API, or UI behaviour change. +- A committed cloud-term or image-inventory check (new CI surface; follow-up). + +## Done Criteria + +- [ ] Every acceptance criterion in `intent.md` has evidence in `progress.md`. +- [ ] Deferred items (transfer eligibility entry, archived headers, plan parking) are decided and + recorded. +- [ ] Hygiene, link, and license checks pass on `development` after the last slice. +- [ ] PR bodies contain no private environment information. diff --git a/docs/plans/documentation-refresh/progress.md b/docs/plans/documentation-refresh/progress.md new file mode 100644 index 0000000..299224d --- /dev/null +++ b/docs/plans/documentation-refresh/progress.md @@ -0,0 +1,106 @@ +# Documentation refresh — Progress + +**Plan:** [`plan.md`](plan.md) +**Last updated:** 2026-09-28 — plan folder written; findings re-verified against `development` at `db92409` +**Current phase:** Not started +**Next action:** Operator reviews `intent.md` and answers the decisions in `design.md` (closed-loop park, wallet and entity-directory keep or park, transfer-eligibility entry, archived progress headers); then run Phase 0 on a fresh `development` and open slice 1 as `feature/docs-neutral-deployment-wording`. + +## Phase Log + +| Phase | Status | Evidence | PR | +|---|---|---|---| +| 0 — Baseline | Not started | | | +| 1 — Cloud wording and tooling names | Not started | | | +| 2 — Plan housekeeping | Not started | | | +| 3 — Removed behaviour and known issues | Not started | | | +| 4 — NatSpec source links | Not started | | | +| 5 — License inventory and entrypoints | Not started | | | + +## Deviations From the Plan + +Corrections made to the review findings while writing the plan: + +- **Full pre-maturity buyback shown as `staged` — dropped as a known issue.** Since #278, + `bondStatus` keeps an issued bond `outstanding` while coupon periods remain + (`services/nb-bond-api/src/projection/compose-projection.ts:188-212`). The stale statement is + in `docs/diagrams/processes/bond-lifecycle.md`, last edited in #276; it is fixed there instead + (Phase 3). +- **Startup flowchart path.** The flowchart is `docs/diagrams/operations/sandbox-startup-flow.md`; + there is no `docs/operations/` folder. +- **NatSpec regeneration is not a full-tree churn today.** A scratch regeneration from current + source differs only in the Git Source lines and three pages, so one regeneration PR is small. +- **`forge doc` cannot fix the path prefix.** A `[doc]` path setting did not change the + generated `blob//src/…` form, so the fix belongs in `fix-links.py`. +- **Line references moved** in the closed-loop plan evidence: role grants are at + `11_BondSetup.s.sol:51,61`, not 54 and 64. +- **Added:** one more cloud-wording line (`docs/KNOWN_ISSUES.md:97`) and the parenthetical former + product name in `services/nb-ui/DEVELOPMENT.md:107` and `services/nb-ui/src/auth/entraAuth.js:2`. +- **Added:** `docs/DOCUMENTATION_INDEX.md:62` also states where the SSE plan was verified; that + clause goes too. + +## Verified So Far + +- Cloud-term self-check over tracked files classified into accepted `entra` surfaces, accepted + `docs/AZURE_BOUNDARY.md` text, frozen records (10 archived plans; no ADR or post-mortem hits), + and 15 to-fix rows (18 lines) listed in `design.md` — verified by `git grep` on 2026-09-28. +- Local tooling names: 1 in `docs/KNOWN_ISSUES.md`, 3 in the ERC-3643 incremental plan, 13 + archived plans — verified by `git grep` on 2026-09-28. +- `reopenAuction` removed (`services/nb-ui/tests/auctionsApi.test.js:6`); no + `withdrawFailedIssuance` in `contracts/src`; no redeem or holders route in + `services/nb-bond-api/src/app.ts`; Kind maps only ports 80 and 8545; no `bens-microservice.sh` + in the tree — verified by reading source on 2026-09-28. +- `sandbox.sh:175-178` resumes stopped nodes; `contracts/contracts.sh:166` skips a repeat deploy + — verified on 2026-09-28. +- Bond units move through `ERC1410._move` with no eligibility check — verified by reading + `contracts/src/norges-bank/ERC1410/ERC1410.sol` on 2026-09-28. +- `ethers` is 6.17.0 in all three manifests and the lockfile — verified on 2026-09-28. +- Blockscout's `/api/v2/transactions` returned `ens_domain_name: null` for every party on the + running local sandbox — verified 2026-09-28 (BENS-side resolution not re-checked). +- All 32 NatSpec Git Source links point at `e1ad139`, which is on no branch, and omit the + `contracts/` prefix; the link checker skips `http(s)` links — verified on 2026-09-28. +- `check-third-party-licenses.py` passes; it does not inspect image pins — verified on + 2026-09-28. + +## Blocked / Waiting On + +- Transfer eligibility known-issue entry — waits on the operator's decision on the eligibility + rule in `docs/plans/bond-coupon-maturity-correctness/`. +- Archived progress header lines — wait on explicit operator approval (frozen records). +- Keep or park the wallet and entity-directory plans — operator. + +## Follow-ups Found Along the Way + +- `ERC1410.transferByPartition` documents that 32-byte `data` selects the destination partition, + but passes the source partition as destination — + `contracts/src/norges-bank/ERC1410/ERC1410.sol:174-184` — contract behaviour, outside this plan; + hand to the bond coupon and maturity correctness plan or a new one. +- The nb-bond-api "opaque 500s on RPC outage" known issue is still open (no mapping of + `RpcUnavailableError` to `503` in the error middleware) — `docs/KNOWN_ISSUES.md:151-170` — + candidate for the nb-bond-api hardening plan. +- No committed check catches cloud wording, tooling names, or missing image-inventory rows; + adding one is a new CI surface and needs operator approval. +- `contracts/docs/contracts-security.md:47-48` describe "redemption flows"; accurate in substance + (principal at maturity) but could say "maturity principal" for consistency with ADR 0005. + +## Changes Since Planning (2026-09-28) + +Changes merged after this plan was written (#300, #302, #304, #305). Effects +on this plan: + +- Phase 1: the `testMode` descriptions in `services/nb-bond-api/src/contracts/{bonds,auctions}.ts` + (and so `openapi.json`) and the `services/nb-ui/src/utils/debugSettings.js` header no longer + name deployment tooling (#304). The remaining source hits are `src/env-vars.ts` and + `src/auth.ts`; the `openapi.json` acceptance criterion no longer expects a diff. +- `BondOrderBook` documentation was removed with the contract (#302); the contract reference, + topology note, and NatSpec pages changed, so re-baseline Phase 4 link counts. +- `contracts/docs/contracts-security.md` still lists "recover failed issuance" and "redeem bonds" + for `BOND_MANAGER_ROLE`; Phase 3 still applies. +- Re-run the Phase 0 searches on current `development` before starting. + +## Session Handoff + +- Plan written read-only on 2026-09-28; no tracked file outside this folder was changed. +- The scratch NatSpec regeneration used a copy of `contracts/` outside the repository; nothing + under `contracts/docs/` was modified. +- The other plan folders written on 2026-09-28 were not yet committed when this plan was + written; re-check their final scope in Phase 0 step 7. diff --git a/docs/plans/ingestion-projection-correctness/design.md b/docs/plans/ingestion-projection-correctness/design.md new file mode 100644 index 0000000..8c27d0f --- /dev/null +++ b/docs/plans/ingestion-projection-correctness/design.md @@ -0,0 +1,308 @@ +# Ingestion and projection correctness — Design + +**Status:** Draft +**Created:** 2026-09-28 +**Intent:** [`intent.md`](intent.md) +**Builds on:** projection schema v6 and the replay fixes recorded in +[`coupon-closure-review-fixes/progress.md`](../archive/coupon-closure-review-fixes/progress.md); +graceful shutdown from [`sandbox-stop-keeps-state`](../archive/sandbox-stop-keeps-state/design.md). + +## Decision Summary + +- **Chain stays authoritative; the projection becomes replay-safe.** Every log of a batch is + applied in `(blockNumber, logIndex)` order across `BondManager`, `BondToken`, and `BondAuction`, + and a `bond_state` reducer event is applied only if its chain position is after the bond's last + applied position (`updated_block`, `updated_log_index`). Chain order is what makes the position + check valid, so ordering lands first. +- **Stopping a loop means it can no longer commit.** Each loop run carries a `stopped` flag that + the range processor checks immediately before its synchronous SQLite transaction. Because the + transaction is synchronous, nothing can commit after `stopIngestionLoop()` returns. Starts carry + a generation number, so an older start that finishes late closes its connection and installs + nothing. The loop's connection is closed after the shared ingestion queue settles. +- **The minimum refactor that makes this testable:** split `processBlockRange` into + `fetchRangeInputs` (all RPC reads) and `applyRangeInputs` (one synchronous transaction), share + one log decoder, and pass the chain reader into the loop. No other restructuring. +- **History pages come from SQL**, newest first by `(block, log_index)`, with the boundary query + validated by the existing (fixed) Zod schema. +- **An undecryptable bid is data, not an error.** It becomes an `invalid` bid with a reason code, + is excluded from allocation, and cannot be selected at finalisation. +- **One schema bump (v7)** after the ordering and idempotency fixes, so existing projections are + rebuilt with corrected rules, historical allocation failures are backfilled, and two unused + columns disappear. + +```mermaid +flowchart LR + A["Besu logs (3 contracts)"] --> B["fetchRangeInputs: getLogs, decode, partitions, allocations, timestamps"] + B --> C{"run stopped?"} + C -- yes --> X["return, commit nothing"] + C -- no --> D["applyRangeInputs: one transaction, logs in (block, logIndex) order"] + D --> E["bond_state (position-guarded), balances (deduped), events, checkpoint"] + E --> F["composer / history query"] + F --> G["API (Zod) -> nb-ui"] +``` + +## Current-State Evidence + +All paths are under `services/nb-bond-api/` unless stated. Line numbers are as of `db92409`. + +### Repository Evidence + +| Finding | Evidence | Consequence | +|---|---|---| +| **Verified.** `BondCreated` always applies the `created` transition; `reduceBondState` starts from `emptyBondState` on `created` (supply `0`, offering `0`, flags cleared) | `src/ingestion.ts:944-971`; `src/projection/bond-state.ts:70-79` | Re-applying `BondCreated` wipes all later facts for the ISIN | +| **Verified.** `supply-delta` is applied only when `applyBalanceDelta` inserted a new `balance_events` row; on replay the row exists and the delta is skipped | `src/ingestion.ts:1103-1121`, `:376-428` | After a replay through `BondCreated`, projected supply stays `0` while `balances` keeps the holders: wrong status (`staged`), `payable`, holders vs supply | +| **Verified.** Other replayed facts recover because later events are replayed in order (offering, coupon count, `isMatured`, `everIssued`); only supply depends on deduplicated rows | reducer cases, `bond-state.ts:80-118` | The visible damage of A1 is supply-driven; the fix must still make every transition idempotent | +| **Verified.** A window's rows and its checkpoint commit in one better-sqlite3 transaction; a failed tick commits nothing | `src/ingestion.ts:713-1127`, `:1227-1266` | Process crash or RPC error does **not** cause re-processing | +| **Verified.** `stopIngestionLoop()` only clears the interval, nulls the advancer, and sets `loopRunning = false` | `src/ingestion.ts:105-112` | In-flight and already-queued `processTo` calls keep running with the old closure | +| **Verified.** `processTo` loops windows until the target block with no stop check; the new loop reads its checkpoint (`loadCheckpoint`) after six awaited RPC calls, before its first queued tick | `src/ingestion.ts:1219-1258`, `:1188-1202` | **Plain restart re-processes:** if the old loop commits a window after the new loop read the checkpoint, the new loop replays that window (triggers A1). Near-certain during a backfill, possible in steady state | +| **Verified.** `resetProjectionAndRestart()` drops tables right after `stop()` without waiting; `startIngestionLoop()` recreates them in `openDatabase()` | `src/admin.ts:100-117`; `src/ingestion-db.ts:448-487` | **Reset race:** the old loop can write a later window plus its checkpoint into the fresh tables; the new loop then skips blocks `0..N`, or replays partial data | +| **Verified.** `startIngestionLoopWithRetry()` has no single-flight guard; `restartIngestionLoop()` calls `stop()` then `start()` fire-and-forget | `src/ingestion.ts:1377-1403`; `src/admin.ts:76-82` | Restart during a boot retry, or two restarts before the first start finishes, installs two loops; the first interval handle is overwritten and never cleared, and each loop re-processes the other's windows | +| **Verified.** The loop's connection lives only in the tick closure and is never closed; it is opened before four more RPC reads that can throw | `src/ingestion.ts:1193-1202` | One leaked connection per restart and per failed boot attempt; shutdown exits with it open | +| **Verified.** The live API pod holds three `ingestion.sqlite` and three `-wal` descriptors (history, bidders, loop) | `/proc/1/fd` in the pod, 2026-09-28 | Baseline for the leak check | +| **Verified.** Per batch the apply order is: all manager logs, then all auction logs, then token events, then transfers | `src/ingestion.ts:728`, `:1005`, `:1051`, `:1094` | Cross-contract order inside a transaction is lost | +| **Verified.** `disableBond` emits `IsinDisabled` (token) then `BondDisabled`; `_deployBond` emits `IsinIssued` (token) then `BondCreated` | `contracts/src/norges-bank/BondManager.sol:214,443`; `BondToken.sol:165,344` | Disable plus re-create in one batch: manager pass leaves `disabled = false`, then the token pass applies the old `IsinDisabled` → `bond_state.disabled = 1` | +| **Verified.** The listing filters on `partitions.disabled` (manager events only); the DTO reads `bond_state.disabled` | `src/ingestion-db.ts:809-826`; `src/projection/compose-projection.ts:260` | Re-created bond is listed but reports `disabled: true` | +| **Verified.** The timestamp prefetch is a hand-kept name list; a missing entry projects `0` silently | `src/ingestion.ts:666-678`, `blockTimestamps.get(block) ?? 0n` | `CouponPeriodPaid` was once missing (recorded in the coupon-closure progress); `AuctionFinalized` is listed but its handler never reads a timestamp | +| **Verified.** `getAuctionEventsByIsin` and `getBondEventsByIsin` order `ASC` with `LIMIT limit*4`; `composeBondHistory` filters `before`, sorts descending, slices | `src/ingestion-db.ts:489-503`, `:547-562`; `src/compose.ts:117-149` | The newest events of a bond with more than `4 × limit` rows per table are never returned; `before` can only page backwards from rows already read | +| **Verified.** The route converts `?limit` with `Number()`; `NaN` reaches SQLite as a `LIMIT` bind ("datatype mismatch" → 500); a negative value becomes an unbounded `LIMIT` | `src/app.ts:567-569`; probe against better-sqlite3 13.0.3 | 500 on bad input; negative limit reads everything | +| **Verified.** `historyQuerySchema` is defined and unused; as written (`.nullable()`, not optional) it would reject a request with no query parameters | `src/contracts/bonds.ts:134-139` | Must be fixed before wiring | +| **Verified.** The OpenAPI history parameters are hand-written (`before` ≥ 0; `limit` 1–500, default 100) and the read responses already include 400 | `src/contracts/bonds.ts:258-283`; `src/openapi/shared-responses.ts:65-73` | Runtime validation can match the published contract without a spec change | +| **Verified.** `BondSnapshot.events` (up to 1000 bond events per bond) is loaded for every bond read and never used | `src/projection/snapshots.ts:86,109`; only `AuctionSnapshot.events` is read (`compose-projection.ts:48`) | Dead read; its function is the one A4 changes | +| **Verified.** `finalise` unseals every on-chain sealed bid before looking at the selection; `unsealBid` throws on decrypt, schema, hash, ISIN, bidder, or signature failure | `src/features/auctions/service.ts:267-272`; `src/bid.ts:16-49` | One bad bid → unhandled `Error` → 500 for every finalisation attempt | +| **Verified.** `BondAuction.submitBid` accepts any caller during bidding and only checks non-empty ciphertext | `contracts/src/norges-bank/BondAuction.sol:271-295` | Any address can plant an undecryptable bid | +| **Verified.** The composer unseals all bids in one `try`; any failure shows every bid as `sealed` and the allocation preview as `null` | `src/projection/compose-projection.ts:74-88,155-165` | Operator cannot see or select the valid bids | +| **Verified.** nb-ui assumes a homogeneous bid list (`bids[0].state`) and parses `rate` on every unsealed row; `FinaliseAuctionModal` selects only `unsealed` bids | `services/nb-ui/src/pages/AuctionDetailPage.jsx:57-58,319`; `FinaliseAuctionModal.jsx:14` | A new bid state needs a small UI change in the same slice | +| **Verified.** `BondAllocationFailed(id, isin, bidder, reason)` and `BondBuybackComplete(id, isin, total)` are in the ABI artifact and emitted by `BondManager`; ingestion has no branch for them; `BondAuctionFinalised` history payload drops `dvpSuccess` | `src/abi/BondManager.json`; `BondManager.sol:416,667`; `src/ingestion.ts:790-809` | DvP failures leave no trace in the API | +| **Verified.** `BondAuction` never emits `BidCancelled` (declared in the interface only); ingestion handles it and every bid read filters `cancelled = 0` | `contracts/src/norges-bank/BondAuction.sol` emits; `src/ingestion.ts:323-333,1044-1045`; `compose-projection.ts:76,165` | Dead handler and filter | +| **Verified.** `bond_state.redemption_complete` is created but never written or read by code | `src/ingestion-db.ts:133,238` | Dead column (reducer field already removed in #278) | +| **Verified.** Schema changes that alter reducer semantics are shipped as a `SCHEMA_VERSION` bump that drops and rebuilds all projection tables in one transaction, preserving system-of-record tables | `src/ingestion-db.ts:6-50,434-487`; `DEVELOPMENT.md` §7.4 | v7 follows the established path | + +### Runtime Evidence + +- **Verified (2026-09-28):** local sandbox up; `/v1/health` `ok`, head 219, ingestion lag 0, no + bonds projected; API pod holds 3 + 3 `ingestion.sqlite` descriptors. +- **Verified (2026-09-28):** `npm test` in `services/nb-bond-api`: 37 suites, 252 tests pass; Jest + prints "A worker process has failed to exit gracefully". `--detectOpenHandles` attributes it to + the 60 s timer created by the third test in `tests/shutdown.test.ts`, not to a database handle. +- **Needs verification (Phase 7):** each defect reproduced or disproved live — disable and + re-create followed by `fromBlock=0`, restart during backfill against on-chain supply, a + malformed bid, an unfunded bidder's failed allocation. + +### Existing Test Coverage and Gaps + +- Covered: reducer transitions (`tests/bond-state-reducer.test.ts`), event-table and balance + idempotency helpers (`tests/ingestion-idempotency.test.ts`), window arithmetic and projection + wait helpers (`tests/ingestion.test.ts`), admin reset through injected fakes + (`tests/admin.test.ts`), auction persistence helpers (`tests/auction-projection.test.ts`). +- Not covered: `processBlockRange` dispatch and ordering, replay through the real apply path, + disable and re-create in one batch, stop during an in-flight window, overlapping starts, + connection close, history ordering and paging, finalisation with an invalid bid. + +## Significant Findings + +| Priority | Finding | Why it matters | Plan response | +|---|---|---|---| +| Blocking | A2 restart/reset race and overlapping starts | The only live trigger for A1 today; can also skip blocks | Phase 2 | +| Blocking | A1 non-idempotent `created` transition | Wrong supply/status/payability after any replay | Phase 4 (after ordering) | +| Important | A3 manager-before-token order | Wrong `disabled`; blocks a position-based idempotency rule | Phase 3 | +| Important | A4 history returns oldest rows; 500 on bad `limit` | Newest lifecycle events unreachable | Phase 5 | +| Important | A5 one bad bid blocks finalisation | Permissionless griefing; also hit when the sealing key is not pinned across restarts (`DEVELOPMENT.md` §7.1) | Phase 6 | +| Important | Silent `0` timestamp when the prefetch list is incomplete | Recurring replay bug class | Phase 3 (handler-declared timestamps, strict getter) | +| Follow-up-sized, included | A15 allocation failure events not ingested | Invisible DvP failures | Phase 3 | +| Cleanup, included | Dead `BidCancelled` path, `redemption_complete`, `AuctionFinalized` prefetch, unused bond event read | Misleading code next to the code being fixed | Phases 3–4 | +| Corrected | "Jest leaked worker is likely the DB handle" | It is the shutdown test's timer | Follow-up in `progress.md` | + +## Invariants + +- The chain is authoritative; every projection table stays rebuildable by a `fromBlock=0` resync. +- A window's rows and its checkpoint commit together or not at all. +- Once `stopIngestionLoop()` returns, the stopped loop commits nothing. +- At most one loop run is installed at a time; only the newest start may install one. +- Within a batch, logs are applied in `(blockNumber, logIndex)` order. +- Each chain log drives at most one `bond_state` transition, and a transition at or before the + bond's `(updated_block, updated_log_index)` is a no-op once the bond row exists. +- Reducer transitions remain order-tolerant (defence in depth; the `enabled` guard stays). +- System-of-record tables (`bidders`, `banks`, `operation_attempts`, `chain_identity`) are never + dropped or written by ingestion. +- A bid that cannot be unsealed never enters an allocation or an on-chain finalisation. + +## Field and Source Classification + +| Field or concept | Current source | Target source | Class/owner | Freshness or fallback rule | +|---|---|---|---|---| +| `Bond.totalSupply` | `bond_state.total_supply` via `supply-delta` gated on new `balance_events` rows | Same, plus position guard | projected | Rebuilt on v7 bump | +| `Bond.disabled` | `bond_state.disabled` (order-dependent) | Same, chain-ordered | projected | Must agree with `partitions.disabled` | +| `Bond.status`, `coupon.payable` | Derived from `bond_state` and checkpoint time | Unchanged | derived | Correct once supply is correct | +| History rows | Oldest-first reads trimmed in JS | SQL page, newest first by `(block, log_index)` | projected | Checkpoint-consistent read | +| `ALLOCATION_FAILED`, `BUYBACK_COMPLETE` rows | Not ingested | `BondAllocationFailed`, `BondBuybackComplete` | projected | Backfilled by the v7 rebuild | +| `Bid.state = invalid`, `Bid.reason` | Not represented | Unsealed per bid in the composer | derived (sealing key + projected ciphertext) | Depends on the running sealing key | +| Block timestamps | Name allowlist, `0` fallback | Declared per handler, strict | projected input | Missing timestamp fails the tick | + +## Target Architecture + +### Ownership and Dependency Direction + +`src/ingestion.ts` keeps the loop, the queue, and the handlers; the new seams are internal. The +composer (`src/compose.ts`, `src/projection/compose-projection.ts`) owns history paging and bid +presentation; `src/bid.ts` owns unsealing and its failure taxonomy; `src/contracts/*.ts` own the +Zod contracts; nb-ui only renders the states the API names. + +### Ingestion seams (Phase 1) + +- `decodeLogs(logs, iface, source)` replaces the identical `decodeManagerEvents` and + `decodeTokenEvents`, tagging each entry with its source contract. +- `type ChainReader = Pick` and a narrow + contracts type (`interface`, `target`, and the two reads `partitionToIsin`, `getAllocations`). +- `fetchRangeInputs(chain, contracts, db, from, to)`: the three `getLogs`, decoding, partition + resolution, final allocations, and block timestamps. Returns a plain `RangeInputs` object. +- `applyRangeInputs(db, inputs, nextCheckpoint)`: today's transaction body, unchanged in Phase 1. +- `processTo(run, targetBlock)` at module level over an `IngestionRun { db, chain, contracts, + nextBlock, stopped }`; `startIngestionLoop(deps = defaultDeps)` builds the run. `deps` carries the + chain reader, contract factory, `openDatabase`, and `getBondManagerAddress`, following the + existing seam style (`AdminDeps`, `RetryOptions`). + +### Loop lifecycle (Phase 2) + +- `stopIngestionLoop()`: marks the current run `stopped`, bumps `loopGeneration`, clears the timer + and advancer, sets `loopRunning = false`, detaches the run's connection and closes it after + `ingestionQueue` settles (`closeQuietly`, logging instead of throwing). Stays synchronous. +- `processTo`: checks `run.stopped` at the top of each window and again after + `fetchRangeInputs` resolves, immediately before `applyRangeInputs`. A stopped run returns + `false` without counting a failure or recording an error. +- `startIngestionLoopWithRetry()` takes `const generation = ++loopGeneration` and stops retrying once + the generation moves on; `startIngestionLoop()` checks the generation after each await and, if + superseded, closes the connection it opened and returns without installing anything. Any + failure after `openDatabase()` closes that connection before rethrowing. +- `waitForIngestionIdle()` resolves after the queue and any pending close. +- `resetProjectionAndRestart()`: `stop()`, then a bounded `waitIdle()` (default 5 s; on timeout it + logs and proceeds, which is safe because the stopped run cannot commit), then drop, then start. + `AdminDeps` gains `waitIdle`. +- `IngestionDatabase` declares `close(): void`; the cast in `src/admin.ts` goes. + +This keeps the earlier design point that closing must wait for the queue (a run between two +awaits must never find its connection closed) and adds the abort, which is what actually +prevents the race: waiting alone could not stop a multi-window backfill from committing. + +### Chain-ordered application (Phase 3) + +- `applyRangeInputs` merges the decoded manager, auction, and token entries into one list sorted by + `(blockNumber, index)` and dispatches each to `applyManagerLog`, `applyAuctionLog`, or + `applyTokenLog` (the existing branch bodies, moved). `TransferByPartition` is applied at its own + position by `applyTokenLog`. Partition mappings are still upserted first. +- Handlers are declared in one table per source: `{ name, needsTimestamp, apply }`. The prefetch + set is built from `needsTimestamp`; the apply context exposes `timestampOf(block)`, which throws + `missing prefetched timestamp for block N (EventName)` instead of returning `0`. +- New manager handlers: `BondAllocationFailed` → `auction_events` row `ALLOCATION_FAILED` + `{ bidder, reason }`; `BondBuybackComplete` → `bond_events` row `BUYBACK_COMPLETE` `{ total }` + (mirrors `ISSUANCE_COMPLETE`). Neither needs a timestamp or a reducer transition. + `BondAuctionFinalised` history payload gains `{ dvpSuccess }`. +- Removed: `BidCancelled` handler and `cancelAuctionBid`, `cancelled` filtering in the composer, + `AuctionFinalized` in the timestamp set, `BondSnapshot.events`. + +### Idempotent bond state (Phase 4) + +- `applyBondStateEvent` loads the row; if it exists and `(block, logIndex) <= (updated_block, + updated_log_index)`, it returns without writing. A pure helper `isAfterPosition(state, position)` + in `bond-state.ts` carries the comparison so the reducer tests cover it. +- `SCHEMA_VERSION = 7`, with history note "chain-ordered, position-guarded `bond_state`; allocation + failure rows; `auction_bids.cancelled`, `idx_auction_bids_active`, and + `bond_state.redemption_complete` removed". `BondStateRow` and `AuctionBidRow` lose the fields. + +### History (Phase 5) + +- `listBondHistoryPage(db, isin, { before, limit })` in `src/ingestion-db.ts`: `UNION ALL` of + `auction_events` and `bond_events` for the ISIN, `block < before` when given, `ORDER BY block + DESC, log_index DESC LIMIT limit`. If the page is full, a second query adds the remaining rows + of the oldest block on the page, so `before = ` never skips events. A page can + therefore exceed `limit` by the rest of one block. +- `composeBondHistory` maps rows only. `getAuctionEventsByIsin` loses its last caller and is + removed with its test lines; `getBondEventsByIsin` stays for tests only if still used, otherwise + removed. +- `historyQuerySchema`: `before: z.coerce.number().int().nonnegative().optional()`, `limit: + z.coerce.number().int().min(1).max(500).default(100)`; the route adds + `validateRequest(historyQuerySchema, 'query')` and reads the parsed values. The hand-written + OpenAPI parameters already state these bounds; the `limit` description gains the block-completion + note, then `openapi.json` is regenerated. + +### Invalid bids (Phase 6) + +- `src/bid.ts`: `unsealBid` throws `BidUnsealError` with `code` in `undecryptable`, + `malformed-plaintext`, `hash-mismatch`, `isin-mismatch`, `bidder-mismatch`, `unsigned`; new + `unsealBids(isin, bids)` returns `{ valid: UnsealedBid[]; invalid: { bidIndex, bidder, reason }[] }`. +- Composer: uses `unsealBids`; bids are listed in `bidIndex` order as `unsealed` or `invalid` + (`{ bidder, state: 'invalid', bidIndex, reason, plaintextHash, md5 }`); the allocation preview uses + valid bids only. Open auctions without test mode stay `sealed`. +- Service `finalise`: uses `unsealBids`; a selected index that is invalid → 400 `bidIndex N is + invalid (reason)`; unknown index keeps its 400. Allocation and proofs use valid bids only. +- Contract: `invalidBidSchema`, `bidStateSchema` gains `invalid`, `bidReasonSchema` enum; + `openapi.json` regenerated. +- nb-ui: `BidsCard` renders unsealed rows and a separate invalid list (bidder, index, reason); + totals use unsealed bids only; the detail KPI shows the invalid count; `FinaliseAuctionModal` + shows "N invalid bids excluded" when N > 0. + +### Consistency and Failure Semantics + +- A mutation whose projection wait is cut short by a restart gets `false` from the aborted + `processTo` and is reported as accepted/pending, the existing honest path. +- A tick that fails because of a missing timestamp commits nothing and appears in + `recentErrors`; it is a code defect, not a data state. +- The v7 bump rebuilds the projection on first boot after upgrade; reads during the rebuild show a + partial projection, as today after `fromBlock=0`. + +### Security and Deployment Boundary + +- No auth, exposure, or secret change. Invalid-bid reasons are fixed codes; no plaintext or key + material is echoed. +- Non-local deployment note: a persistent projection volume is rebuilt once by the v7 bump; the + existing backup guidance in `DEVELOPMENT.md` §7.4 applies. No other portability impact. + +## Alternatives Considered + +- **Apply a reducer event only when its event row is newly inserted** (the balance pattern). Token + lifecycle events (`IsinIssued`, `IsinEnabled`, `IsinExtended`, `IsinReduced`, `IsinDisabled`) + have no event rows, so this needs a new `applied_logs(tx_hash, log_index)` table and schema bump. + Order-independent, but adds a table that duplicates what the position already says; A3 needs + chain order anyway. Rejected in favour of the position guard. +- **Position guard without merged ordering.** Would skip real events: in a finalisation the mint + `TransferByPartition` and `IsinEnabled` have lower log indices than `BondIssuanceComplete`, so a + manager-first pass would advance the position past them. Rejected. +- **Position-aware reducer that ignores events before the lifecycle's `created` position** + (targeted A3 fix). Fixes the one symptom but keeps cross-contract order wrong for every other + transition. Rejected. +- **Only wait for idle before dropping (no abort).** A backfill can run many windows; waiting + lets them all commit into the old tables and the new loop still reads a stale checkpoint unless + it also waits. Abort is simpler and closes every path. Rejected as the sole mechanism; the wait + stays for the reset path and the connection close. +- **Close the connection immediately in `stopIngestionLoop()`.** A run paused on an RPC call would + then fail on its next statement and record a false error. Rejected. +- **Composite history cursor** (`before=:`). Exact page sizes but changes the + public query contract. Rejected for block completion; revisit if a block can hold more events + than a sandbox page. +- **Skip undecryptable bids silently.** Hides griefing and a mis-pinned sealing key from the + operator. Rejected for the explicit `invalid` state. +- **Full ingestion rewrite into per-event modules.** Beyond the request; the fetch/apply split and + per-source handler tables are enough to test and fix every finding. + +## Decisions + +| Decision | Recommendation | Rationale | Operator action required | +|---|---|---|---| +| Replay idempotency mechanism | Position guard on `bond_state`, after chain-ordered apply | No new table; uses existing columns; order fix is needed anyway | Acknowledge | +| Projection schema v7 bump | Yes, in Phase 4 | Reducer semantics change; rebuilds any projection damaged by A1–A3, backfills allocation failures, drops two unused columns | Acknowledge the one-time rebuild on upgrade | +| New public bid state `invalid` | Yes, additive union member, nb-ui updated in the same PR | Honest representation; the only client is in this repo | Acknowledge the API contract change | +| History page size | Complete the oldest block; a page may exceed `limit` by the rest of one block | Keeps the `before` contract; no lost events | Acknowledge | +| Remove the `BidCancelled` handler | Yes | Contract never emits it; the known issue on `cancelBid` already describes adding ingestion with the contract change | None | +| Timestamp prefetch | Declared per handler; strict getter | Removes a recurring silent-`0` bug class | None | +| Live verification | Phase 7 on the local sandbox: bonds created, a malformed bid submitted, allocation failure provoked, restarts and a `fromBlock=0` resync; no `./sandbox.sh delete` | Unit tests alone missed earlier replay bugs | Explicit go-ahead before the chain and projection mutations | +| ADR | Not recommended; document the invariants in `DEVELOPMENT.md` §7.4 | Internal projection rule, reversible by a schema bump | Operator may ask for one | + +## Residual Risks + +- A hung RPC call keeps a stopped run's connection open until it returns; shutdown and reset bound + their own waits. +- The position guard assumes one `bond_state` transition per log; a future handler that emits two + from one log must combine them into one transition. Recorded as an invariant and a code comment. +- Changes to events from the contracts plan must add a handler-table entry; the strict timestamp + getter and dispatch tests surface omissions only for timestamp-dependent handlers. +- Invalid-bid classification depends on the running sealing key; a key change makes all earlier + bids `invalid (undecryptable)`, which is now visible rather than a 500. diff --git a/docs/plans/ingestion-projection-correctness/intent.md b/docs/plans/ingestion-projection-correctness/intent.md new file mode 100644 index 0000000..85c5795 --- /dev/null +++ b/docs/plans/ingestion-projection-correctness/intent.md @@ -0,0 +1,110 @@ +# Ingestion and projection correctness — Intent + +**Status:** Draft +**Created:** 2026-09-28 +**Requested by:** sandbox operator + +## Outcome + +The NB Bond API projection shows the same bond, auction, and history facts as the chain, +however the ingestion loop is restarted, reset, or replayed. An operator who restarts or resets +ingestion from the health modal, re-creates a disabled ISIN, looks at a busy bond's history, or +finalises an auction that received a malformed bid gets the correct supply, status, holders, +disabled flag, newest events, and a working finalisation. Every open SQLite connection the loop +opens is closed when the loop stops. + +## Why This Change + +A review of `services/nb-bond-api` ingestion, projection, and history code on 2026-09-28 found +defects that corrupt or hide projected state through normal operator actions: + +- **Replay zeroes supply.** Re-processing a block window that contains `BondCreated` resets the + bond's projected state, and the deduplicated transfer rows never add the supply back. The bond + then shows zero supply, the wrong status, and is no longer payable. +- **Restart and reset race in-flight ingestion.** Stopping the loop only clears its timer. The + window being processed keeps running, commits rows and a checkpoint after the stop, and so + makes the next loop replay blocks (triggering the defect above) or, after a reset, skip them. + Overlapping starts can leave two loops running for good. The loop's SQLite connection is never + closed. +- **A re-created bond can show `disabled: true`.** Within one batch, manager events are applied + before token events, so the old lifecycle's `IsinDisabled` lands after the new `BondCreated`. +- **History returns the oldest events.** The history query reads the oldest rows first, then keeps + the newest of those; the newest events of a busy bond (for example `MATURED`) are unreachable. + A non-numeric `limit` returns 500 and a negative one removes the limit. +- **One malformed bid blocks finalisation.** `submitBid` is permissionless; finalisation unseals + every bid up front and fails with 500 on the first one it cannot decrypt, and the auction view + then shows every bid as sealed. +- **Allocation failures are invisible.** `BondAllocationFailed` and `BondBuybackComplete` are not + ingested. + +## Scope + +### In Scope + +1. A minimal split of the ingestion range processor into an async fetch step and a synchronous + apply step, with one shared log decoder, so ordering, replay, and stop behaviour can be tested + without a chain. +2. Loop lifecycle: stop aborts in-flight work before it commits, only the newest start can install + a loop, the admin reset waits for ingestion to go idle before dropping tables, and the loop's + SQLite connection is closed after stop, on a failed start, and on shutdown. +3. Chain-ordered application: manager, token, and auction logs of a batch are applied in + `(block, logIndex)` order; the block-timestamp prefetch list comes from the event handlers + themselves and a missing timestamp fails the tick instead of projecting `0`. +4. Idempotent bond-state reduction: a reducer event at or before the bond's last applied chain + position is skipped, plus a projection schema bump so existing databases rebuild with the + corrected rules. +5. Bond history: newest first, `before` and `limit` applied in SQL, query validated at the + boundary (400 on bad input), pages that never split a block. +6. Malformed bids: per-bid unsealing, an explicit `invalid` bid state with a reason code in the + API, invalid bids excluded from the allocation preview and from finalisation, and the operator + UI showing them. +7. `BondAllocationFailed` and `BondBuybackComplete` recorded in bond history, and the + `dvpSuccess` flag of `BondAuctionFinalised` kept in its history payload. +8. Dead projection code in the files above: the `BidCancelled` handler and `cancelled` bid + filtering (the contract never emits `BidCancelled`), the unused `bond_state.redemption_complete` + column, the unused `AuctionFinalized` timestamp prefetch, and the unused bond event read in + bond snapshots. +9. Tests for dispatch order, replay idempotency, disable and re-create in one batch, loop + stop/abort/close, history ordering and validation, and finalisation with an invalid bid. +10. Documentation of the new ingestion invariants, history semantics, restart semantics, and the + invalid bid state; a live check on the local sandbox including a `fromBlock=0` resync. + +### Out of Scope + +- Error middleware, nonce handling, the chart, and route extraction from `app.ts` (covered by the + separate `nb-bond-api-hardening` plan). +- Contract, ABI, or event changes (covered by the separate `bond-coupon-maturity-correctness` + plan); this plan consumes the current ABI. +- A larger restructuring of ingestion beyond the fetch/apply split and per-source handlers. +- Exposing allocation failures on the `Auction` DTO; history rows only. +- The Jest "worker failed to exit" warning: it comes from a pending timer in + `tests/shutdown.test.ts`, not from ingestion (recorded as a follow-up). + +## Acceptance Criteria + +| Criterion | Risk addressed | Verification evidence | +|---|---|---| +| Applying the same batch twice, or a batch that contains `BondCreated` after later transfers were applied, leaves `bond_state` byte-identical | Replay zeroes supply and status | Apply-seam test replays a full issuance lifecycle batch | +| After `stopIngestionLoop()`, no projection row or checkpoint is written by the stopped loop, and its connection is closed once queued work settles | Restart race, skipped or replayed blocks, handle leak | Lifecycle tests with a controllable fake chain | +| A start superseded by a later stop or start installs no timer and closes its connection | Two concurrent loops | Lifecycle test with a start held mid-setup | +| `resetProjectionAndRestart()` calls stop, then waits for idle (bounded), then drops, then starts | Old loop writing into fresh tables | `tests/admin.test.ts` ordering assertion | +| A batch containing disable of an ISIN and its re-creation projects `disabled: false`, matching the listing | Re-created bond shows disabled | Apply-seam test, plus a live `fromBlock=0` resync of that scenario | +| Logs of one batch are applied in `(block, logIndex)` order across the three contracts | Cross-contract order bugs | Apply-seam dispatch-order test | +| A handler that needs a block timestamp gets one, or the tick fails with a named error | Silent `0` timestamps | Test with a missing timestamp | +| `GET /v1/bonds/{isin}/history` returns the newest events first, reaches the oldest through `before`, never splits a block across pages, and answers 400 for `limit=abc`, `limit=0`, `limit=501`, `before=-1` | Hidden newest events; 500 on bad input | Composer and route tests with more rows than one page | +| Finalising with one undecryptable bid in the auction succeeds when the selection excludes it; selecting it returns 400; the auction view shows it as `invalid` with a reason | One bid blocks the auction | Service and composer tests; nb-ui tests; live malformed-bid run | +| History shows `ALLOCATION_FAILED` for each failed DvP leg and `BUYBACK_COMPLETE` for buybacks | Invisible allocation failures | Apply-seam test; live finalisation with an unfunded bidder | +| Repeated admin restarts leave the API pod with three `ingestion.sqlite` descriptors | Connection leak | Descriptor count in the pod before and after | +| nb-bond-api and nb-ui package gates, `openapi.json` regeneration, hygiene and link checks pass | Regression | CI and local gates | + +## Constraints + +- Sandbox-sized; local-first; no new dependencies. +- The projection stays rebuildable from chain; system-of-record tables are never dropped. +- Public repo: no secrets, private identifiers, or home-directory paths. +- Reducer transitions stay order-tolerant even after chain-ordered application. + +## Open Questions + +- None blocking. The decisions that need operator acknowledgement are listed in + [`design.md`](design.md#decisions). diff --git a/docs/plans/ingestion-projection-correctness/plan.md b/docs/plans/ingestion-projection-correctness/plan.md new file mode 100644 index 0000000..1db1526 --- /dev/null +++ b/docs/plans/ingestion-projection-correctness/plan.md @@ -0,0 +1,516 @@ +# Ingestion and projection correctness — Implementation Plan + +**Status:** Proposed +**Created:** 2026-09-28 +**Scope:** `services/nb-bond-api` (`src/ingestion.ts`, `src/ingestion-db.ts`, `src/admin.ts`, +`src/index.ts`, `src/projection/bond-state.ts`, `src/projection/compose-projection.ts`, +`src/projection/snapshots.ts`, `src/compose.ts`, `src/bid.ts`, `src/features/auctions/service.ts`, +`src/contracts/bonds.ts`, `src/contracts/auctions.ts`, the history route in `src/app.ts`, +`openapi.json`, tests, `DEVELOPMENT.md`); `services/nb-ui` (`AuctionDetailPage.jsx`, +`FinaliseAuctionModal.jsx`, tests) +**Intent:** [`intent.md`](intent.md) · **Design:** [`design.md`](design.md) · **Progress:** [`progress.md`](progress.md) + +## Plan Order + +```text +Phase 0 Baseline and characterization (no PR) +Phase 1 Fetch/apply seam, shared decoder, injected chain -> PR 1 +Phase 2 Loop lifecycle: abort, single start, idle, close -> PR 2 +Phase 3 Chain-ordered apply, declared timestamps, + allocation-failure events, dead projection code -> PR 3 +Phase 4 Position-guarded bond_state, schema v7 -> PR 4 +Phase 5 Bond history newest first, validated query -> PR 5 (independent) +Phase 6 Invalid bids in API, finalise, and UI -> PR 6 (independent) +Phase 7 Live verification on the local sandbox, archive -> evidence in the last PR +``` + +Phases 1 → 2 → 3 → 4 are strictly ordered: the lifecycle and ordering tests need the seam, and +the position guard is only valid once logs are applied in chain order. Phase 2 comes before the +ordering work because it removes the only live trigger for replays. Phases 5 and 6 touch the +read and auction paths only and can land in parallel with 1–4. Each PR updates the documentation +its change affects; Phase 7 records the live evidence and archives the folder. + +## Phase 0: Baseline and Characterization + +### Goal + +Record the starting point so every later phase can show its delta. + +### Scope + +- `services/nb-bond-api`, `services/nb-ui` package gates; the live sandbox (read-only). + +### Steps + +1. `cd services/nb-bond-api && npm run lint && npm run format:check && npm test && npm run build`; + record suites and tests (37 and 252 on 2026-09-28) in `progress.md`. +2. `cd services/nb-ui && npm run format:check && npm run lint && npm test && npm run build`; record + the vitest count. +3. Read-only live baseline: `/v1/health` (head, lag), `/v1/bonds?includeDisabled=true`, and the + descriptor count + `kubectl -n nb-bond-api exec deploy/nb-bond-api -- sh -c 'ls -l /proc/1/fd | grep -c "ingestion.sqlite$"'` + (3 on 2026-09-28). + +### Verification Stop + +- Gates green; counts recorded. + +### Failure Diagnosis / Fix Forward / Rollback + +- A red gate on `development` is a pre-existing failure: record it, do not fix it here. + +### Exit Criteria + +- [ ] Baseline counts and live observations are in `progress.md`. + +## Phase 1: Fetch/Apply Seam + +### Goal + +Make range processing testable without a chain, with no behaviour change. + +### Scope + +- `src/ingestion.ts`; new `tests/ingestion-apply.test.ts`; a small log-builder helper under + `tests/` (for example `tests/helpers/chain-logs.ts`). + +### Steps + +1. Replace `decodeManagerEvents` and `decodeTokenEvents` with `decodeLogs(logs, iface, source)`; + the entry type gains `source: 'manager' | 'token' | 'auction'`. +2. Introduce `ChainReader` and a narrow `IngestionContracts` type (see `design.md`). Move the three + `getLogs` calls, decoding, partition resolution, the `AuctionFinalized` allocation reads, and the + timestamp prefetch into `fetchRangeInputs(...)`, returning `RangeInputs { parsedManager, + parsedToken, parsedAuction, tokenActions, tokenPartitionMappings, resolvedPartitions, + blockTimestamps, finalAllocations }`. +3. Move the transaction body verbatim into `applyRangeInputs(db, inputs, nextCheckpoint)`, returning + `{ latestTxHash, changedResources }`. Keep the loop order exactly as today. +4. Lift `processTo` to module level over `IngestionRun { db, chain, contracts, nextBlock }` and give + `startIngestionLoop(deps = defaultIngestionDeps)` a deps object (`chain`, `contractsFor(address)`, + `openDatabase`, `getBondManagerAddress`). Defaults are the current module-level provider and + functions. Export `applyRangeInputs` and `decodeLogs` with the same "exported for tests" comment + the file already uses. +5. Test helper: build `Log`-shaped objects from the real ABIs in `src/abi` with + `Interface.encodeEventLog`, assigning `blockNumber`, `index`, and `transactionHash`, then run them + through `decodeLogs`. +6. `tests/ingestion-apply.test.ts` (characterization, in-memory `openDatabase({ dbPath: ':memory:' })`): + - a RATE issuance lifecycle batch (`IsinIssued`, `BondCreated`, `AuctionCreated`, + `BondAuctionInitialised`, `BidSubmitted`, `BondAuctionClosed`, mint `TransferByPartition`, + `IsinEnabled`, two DvP transfers, `BondIssuanceComplete`, `AuctionFinalized` with prefetched + allocations, `BondAuctionFinalised`) → `bond_state` supply, offering, `ever_issued`, coupon + fields; `balances`; auction status `finalised`; checkpoint saved; + - a coupon period and a maturity batch → `coupon_payment_count`, `is_matured`, supply `0`. + +### Verification Stop + +- Package gate green; test count = baseline + new tests; no existing test changed. + +### Failure Diagnosis / Fix Forward / Rollback + +- A characterization assertion that fails against the moved code means the move changed behaviour: + diff the moved block against `development` and restore it. Do not adjust the assertion. + +### Exit Criteria + +- [ ] `processBlockRange` is gone; `fetchRangeInputs` + `applyRangeInputs` replace it. +- [ ] One decoder; the loop runs against an injected chain reader. +- [ ] Characterization tests pass on the moved code. + +## Phase 2: Loop Lifecycle + +### Goal + +A stopped loop commits nothing, only the newest start installs a loop, the reset waits for idle, +and every loop connection is closed. + +### Scope + +- `src/ingestion.ts`, `src/ingestion-db.ts` (`close()` on `IngestionDatabase`), `src/admin.ts`, + `src/index.ts` (no call change expected), new `tests/ingestion-loop-lifecycle.test.ts`, + `tests/admin.test.ts`, `DEVELOPMENT.md` §7.9. + +### Steps + +1. `IngestionRun` gains `stopped: boolean`; module state gains `currentRun`, `loopGeneration`, and + `pendingClose: Promise`. +2. `stopIngestionLoop()`: set `currentRun.stopped = true`, `loopGeneration++`, clear timer and + advancer, `loopRunning = false`, detach the connection and chain + `pendingClose = ingestionQueue.then(() => closeQuietly(db))`. +3. `processTo(run, target)`: return `false` (no `pushError`, no failure count) if `run.stopped` at + the start of each window or after `fetchRangeInputs` resolves; the second check sits on the line + directly before `applyRangeInputs`. +4. `startIngestionLoopWithRetry()`: `const generation = ++loopGeneration`; after each attempt and + each sleep, return if `loopGeneration !== generation`. Pass `generation` to + `startIngestionLoop`, which checks it after every await; when superseded it closes the connection + it opened and returns. Wrap everything after `openDatabase()` so a throw closes the connection + before rethrowing. +5. `waitForIngestionIdle()` resolves after `ingestionQueue` and `pendingClose`. +6. `IngestionDatabase` declares `close(): void`; remove the cast in `src/admin.ts`. +7. `resetProjectionAndRestart()`: `stop()`, `await` a bounded `waitIdle()` (new `AdminDeps.waitIdle`, + default `waitForIngestionIdle` raced against 5 s; on timeout log a warning and continue), then + drop, then start. `restartIngestionLoop()` keeps its shape (the abort makes waiting unnecessary). +8. `__resetIngestionStateForTests()` also clears `currentRun`, `pendingClose`, and the generation. +9. Tests (`tests/ingestion-loop-lifecycle.test.ts`, with a fake chain reader whose `getLogs` returns + a promise the test resolves): + - stop while a window is fetching → resolve → no rows, checkpoint unchanged, `processTo` + resolves `false`, `consecutiveFailures` 0; + - stop during a multi-window backfill → only windows committed before the stop exist; + - connection closed only after the queued work settles (a held queue item delays `close`); + - start that throws after `openDatabase()` closes the connection; + - start held mid-setup, then `stop()` → release → no timer installed, `loopRunning` false, + connection closed; + - two overlapping `startIngestionLoopWithRetry()` calls → exactly one run installed. +10. `tests/admin.test.ts`: reset calls `stop`, `waitIdle`, `dropProjection`, `start` in that order; + a `waitIdle` that never settles still drops after the bound. +11. `DEVELOPMENT.md` §7.9: plain restart and reset semantics (stop aborts in-flight work; reset + waits up to the bound for idle; connections closed). + +### Verification Stop + +- Package gate green. +- Local: deploy the branch image (`./services/nb-bond-api/nb-bond-api.sh start`), call + `POST /v1/admin/restart-ingestion` five times, then `?fromBlock=0` once; descriptor count stays 3; + `/v1/health` shows lag 0 and no new `recentErrors`. (Operator go-ahead for the redeploy.) + +### Failure Diagnosis / Fix Forward / Rollback + +- "database connection is not open" in `recentErrors` → a close ran before its run settled; the + close must chain on the queue captured at stop time. +- Descriptor count grows → a start path returns without closing; check the superseded and throw + paths. +- Revert is a single-PR revert; no data migration is involved. + +### Exit Criteria + +- [ ] Every lifecycle test listed passes. +- [ ] Live descriptor count unchanged across restarts; health clean. + +## Phase 3: Chain-Ordered Apply + +### Goal + +Apply every batch in chain order through declared handlers, fail loudly on a missing timestamp, +record allocation failures, and remove dead projection paths. + +### Scope + +- `src/ingestion.ts`, `src/ingestion-db.ts` (`getAuctionBids`, `AuctionBidRow` reads), + `src/projection/compose-projection.ts`, `src/projection/snapshots.ts`, + `tests/ingestion-apply.test.ts`, `tests/auction-projection.test.ts`, `DEVELOPMENT.md` §7.4. + +### Steps + +1. Move the branch bodies into `applyManagerLog`, `applyAuctionLog`, and `applyTokenLog` (the latter + also applies `TransferByPartition` through the existing balance and `supply-delta` code). Each + source has a handler table `{ [eventName]: { needsTimestamp: boolean; apply(ctx, entry) } }`. +2. `applyRangeInputs`: after the partition upserts, merge the three decoded lists, sort by + `(blockNumber, index)`, and dispatch. `latestTxHash` becomes the last entry's hash. +3. `fetchRangeInputs` builds the timestamp block set from `needsTimestamp` (today: `CouponPaid`, + `CouponPeriodPaid`, `IsinEnabled`, `BondAuctionClosed`, `BondAuctionFinalised`). The apply context + exposes `timestampOf(block, eventName)`, which throws when absent. Replace every + `blockTimestamps.get(block) ?? 0n`. +4. New handlers: `BondAllocationFailed` → `upsertAuctionEvent` type `ALLOCATION_FAILED`, payload + `{ bidder, reason }`; `BondBuybackComplete` → `insertBondEvent` type `BUYBACK_COMPLETE`, payload + `{ total }`. `BondAuctionFinalised` payload gains `dvpSuccess`. Both add `bonds` and `auctions` to + `changedResources`. +5. Remove the `BidCancelled` handler and `cancelAuctionBid`; stop reading `cancelled` in + `getAuctionBids` and drop the `row.cancelled === 0` filters in `compose-projection.ts`; stop + writing it in `upsertAuctionBid` (the column keeps its default until Phase 4). Remove + `BondSnapshot.events` and its two reads. Update the cancellation test in + `tests/auction-projection.test.ts` to cover resubmission idempotency only. +6. Tests in `tests/ingestion-apply.test.ts`: + - dispatch order: interleaved manager/token/auction logs in one block are applied in `logIndex` + order (assert through a disable-then-re-create batch: `IsinIssued`, `BondCreated`, + `IsinDisabled`, `BondDisabled`, `IsinIssued`, `BondCreated` → `bond_state.disabled = 0` and + `partitions.disabled = 0`, `GET`-level `disabled: false` via `composeProjectedBond`); + - the Phase 1 characterization batches still pass unchanged; + - missing timestamp → `applyRangeInputs` throws and the checkpoint is unchanged; + - `ALLOCATION_FAILED` and `BUYBACK_COMPLETE` rows written once and deduplicated on replay. +7. `DEVELOPMENT.md` §7.4: chain-ordered application, handler-declared timestamps, new history + types. + +### Verification Stop + +- Package gate green; every Phase 1 characterization test unchanged and green. + +### Failure Diagnosis / Fix Forward / Rollback + +- A characterization failure after the merge points at a handler that relied on manager-first + order; fix the handler to be order-tolerant rather than reordering. +- A live tick failing with "missing prefetched timestamp" names the handler whose flag is wrong. + +### Exit Criteria + +- [ ] One ordered dispatch loop; no hand-kept timestamp list; no `?? 0n` timestamp fallback. +- [ ] Disable and re-create in one batch projects `disabled: false`. +- [ ] Allocation failure and buyback completion rows present; dead paths removed. + +## Phase 4: Position-Guarded Bond State and Schema v7 + +### Goal + +Make every `bond_state` transition idempotent under replay and rebuild existing projections with +the corrected rules. + +### Scope + +- `src/projection/bond-state.ts`, `src/ingestion.ts` (`applyBondStateEvent`), + `src/ingestion-db.ts` (schema, row types), `tests/bond-state-reducer.test.ts`, + `tests/ingestion-apply.test.ts`, `tests/ingestion-idempotency.test.ts` (version assertions), + `DEVELOPMENT.md` §7.4. + +### Steps + +1. `bond-state.ts`: export `isAfterPosition(state, { block, logIndex })` and document the invariant + "one transition per log". +2. `applyBondStateEvent`: if the row exists and the event is not after its + `(updated_block, updated_log_index)`, return without writing. +3. `ingestion-db.ts`: `SCHEMA_VERSION = 7` with its history line; remove + `bond_state.redemption_complete`, `auction_bids.cancelled`, and `idx_auction_bids_active` from the + DDL and row types. +4. Tests: + - reducer: `isAfterPosition` boundaries (same block lower/equal/higher index; lower block); + - apply: the issuance lifecycle batch applied twice → identical `bond_state` rows; + - apply: a window containing `BondCreated` re-applied after a later window (transfers, + coupon) → supply, offering, coupon count, `ever_issued` unchanged; + - apply: re-create after disable across two windows still resets (a later `created` is applied); + - migration: a v6 file opens as v7, projection rebuilt, system-of-record rows preserved (extend + the existing migration test rather than adding a file). +5. `DEVELOPMENT.md` §7.4: position guard, v7 note, current version `7`. + +### Verification Stop + +- Package gate green. +- Local: redeploy the branch image; logs show `ingestion DB schema migrated from v6 to v7` once; + projection catches up to the head; `/v1/bonds` matches the pre-upgrade listing. + +### Failure Diagnosis / Fix Forward / Rollback + +- A bond missing a transition after the upgrade means an event was applied out of chain order + (Phase 3) or a log produced two transitions; find it by replaying that bond's blocks in a test. +- Rolling back to a v6 binary needs a database backup from before the upgrade (existing guidance); + locally, `fromBlock=0` on the older image rebuilds it. + +### Exit Criteria + +- [ ] Replay tests show byte-identical `bond_state`. +- [ ] v7 migration verified locally; no unused columns remain. + +## Phase 5: Bond History + +### Goal + +History returns the newest events first, pages backwards without gaps, and rejects bad input. + +### Scope + +- `src/ingestion-db.ts`, `src/compose.ts`, `src/contracts/bonds.ts`, the history route in + `src/app.ts`, `openapi.json`, `tests/ingestion-db.test.ts`, a new `tests/bond-history.test.ts`, + `DEVELOPMENT.md` §4.2. + +### Steps + +1. `listBondHistoryPage(db, isin, { before, limit })` as in `design.md`, including the oldest-block + completion query. Remove `getAuctionEventsByIsin` and `getBondEventsByIsin` once they have no + callers (after Phase 3 removes the snapshot read; if Phase 5 lands first, keep + `getBondEventsByIsin` for the snapshot until Phase 3) and update `tests/ingestion-db.test.ts`. +2. `composeBondHistory(db, isin, { before, limit })` maps rows to `HistoryEvent`; no sorting or + slicing in JS. +3. `historyQuerySchema`: `before` optional non-negative int, `limit` int 1–500 default 100. Route: + `validateRequest(historyQuerySchema, 'query')` after the params check; read `req.query` values. +4. `bonds.ts` path fragment: `limit` description "A page may exceed `limit` to include every event + of its oldest block." Run `npm run regen:openapi`; commit `openapi.json`. +5. Tests (`tests/bond-history.test.ts`, in-memory DB): + - 600 bond and auction rows for one ISIN: first page holds the newest 100 in + `(block, log_index)` descending order, including the newest `MATURED` row; + - walking `before` to the end returns every row exactly once; + - a block with events straddling the page boundary is returned whole; + - route: `limit=abc`, `limit=0`, `limit=501`, `before=-1` → 400 problem documents; no params → + 100 rows. +6. `DEVELOPMENT.md` §4.2: ordering, cursor, page-completion note, 400 behaviour. + +### Verification Stop + +- Package gate green; `openapi-contract` test passes on the regenerated document. + +### Failure Diagnosis / Fix Forward / Rollback + +- Duplicate rows across pages mean the cursor is inclusive; `before` is exclusive by block. + +### Exit Criteria + +- [ ] Newest-first paging proven past 4 × `limit` rows; bad input returns 400. + +## Phase 6: Invalid Bids + +### Goal + +A bid that cannot be unsealed is shown as `invalid`, excluded from the allocation, and cannot +block or join a finalisation. + +### Scope + +- `src/bid.ts`, `src/projection/compose-projection.ts`, `src/features/auctions/service.ts`, + `src/contracts/auctions.ts`, `openapi.json`, `tests/bid.test.ts`, + `tests/auction-service.test.ts`, `tests/compose-auction-failure.test.ts`; + `services/nb-ui/src/pages/AuctionDetailPage.jsx`, `FinaliseAuctionModal.jsx` and their tests; + `DEVELOPMENT.md` §4.3 and §7.2. + +### Steps + +1. `bid.ts`: `BidUnsealError` with a `code`; `unsealBid` throws it for each existing check + (decryption failure wrapped as `undecryptable`); add `unsealBids`. +2. Contract: `bidReasonSchema` enum, `invalidBidSchema`, `bidStateSchema` with `invalid`, + `bidSchema` union of three. `npm run regen:openapi`. +3. Composer: per-bid unsealing via `unsealBids`; bids in `bidIndex` order; allocation preview from + valid bids; the blanket `try/catch` goes. +4. Service `finalise`: `unsealBids` over the on-chain sealed bids; invalid selected index → 400 + with the reason; allocation and proofs from valid bids only. +5. nb-ui: `BidsCard` splits unsealed and invalid rows (the unsealed table and totals ignore + invalid bids; invalid rows show bidder, index, reason); detail KPI shows the invalid count; + `FinaliseAuctionModal` notes excluded invalid bids. +6. Tests: + - `bid.test.ts`: each reason code from a crafted bid (garbage ciphertext, wrong ISIN, hash + mismatch, unsigned); + - `auction-service.test.ts`: finalise with one garbage bid not selected → sends the transaction; + selecting it → 400; all-valid path unchanged; + - `compose-auction-failure.test.ts` (or a sibling): closed auction with one garbage bid → + one `invalid` bid, others `unsealed`, allocation computed from valid bids; + - nb-ui: mixed bid list renders both sections and correct totals; modal hint with N > 0. +7. Docs: `DEVELOPMENT.md` §4.3 (finalise with invalid bids) and §7.1/§7.2 (sealing key change shows + bids as `invalid (undecryptable)`). + +### Verification Stop + +- Both package gates green; regenerated `openapi.json` committed. + +### Failure Diagnosis / Fix Forward / Rollback + +- An allocation hash mismatch between preview and finalise means the two paths used different + valid sets; both must call `unsealBids`. + +### Exit Criteria + +- [ ] One undecryptable bid no longer blocks finalisation; it is visible with its reason. + +## Phase 7: Live Verification and Close-Out + +### Goal + +Prove the fixes on the local sandbox, including a `fromBlock=0` resync, then archive the plan. + +### Scope + +- Local sandbox (operator go-ahead required: creates bonds and auctions, submits a malformed bid, + restarts and resets ingestion). No `./sandbox.sh delete`. + +### Steps + +1. Deploy the final branch image. Record `/v1/health` and the descriptor count. +2. Disable and re-create: create a bond, disable it, create the same ISIN again; confirm + `disabled: false` and listed; `POST /v1/admin/restart-ingestion?fromBlock=0`; after catch-up, + confirm the same. +3. Supply under replay: run a RATE auction to finalisation; compare projected `totalSupply` with + `cast call "totalSupplyByPartition(bytes32)(uint256)" `; trigger + `fromBlock=0`, then a plain restart during the backfill; compare again. +4. Malformed bid: submit a bid with arbitrary ciphertext bytes to an open auction from a dealer + account (address and key from the generated local fixture files, referenced by path); close; + the auction shows one `invalid` bid; finalise with the valid selection succeeds. +5. Allocation failure: finalise with a winner lacking wNOK balance or allowlisting; history shows + `ALLOCATION_FAILED` and `FINALISED` with `dvpSuccess: false`. +6. History: `GET /v1/bonds/{isin}/history?limit=5` shows the newest rows; `limit=abc` → 400. +7. Record evidence in `progress.md`; move the folder to `docs/plans/archive/`, set statuses, update + `docs/DOCUMENTATION_INDEX.md` in the same PR as the last slice. + +### Verification Stop + +- Every acceptance criterion in `intent.md` has recorded evidence. + +### Failure Diagnosis / Fix Forward / Rollback + +- A live mismatch that unit tests did not show: capture the block range, reproduce it as an + apply-seam test from the real logs, fix forward in the owning phase's code. + +### Exit Criteria + +- [ ] Live evidence recorded; folder archived; index updated. + +## Test Matrix + +| Layer | Risk or behavior | Test/evidence | +|---|---|---| +| Reducer/domain | Position comparison boundaries; order tolerance kept | `tests/bond-state-reducer.test.ts` | +| Persistence/ingestion | Characterization of a full lifecycle through the real apply path | `tests/ingestion-apply.test.ts` (Phase 1) | +| Persistence/ingestion | Cross-contract `logIndex` order; disable and re-create in one batch | `tests/ingestion-apply.test.ts` (Phase 3) | +| Persistence/ingestion | Replay of a window with `BondCreated`; duplicate batch | `tests/ingestion-apply.test.ts` (Phase 4) | +| Persistence/ingestion | Missing timestamp fails the tick without committing | `tests/ingestion-apply.test.ts` (Phase 3) | +| Persistence/ingestion | Allocation failure and buyback rows, deduplicated | `tests/ingestion-apply.test.ts` (Phase 3) | +| Loop lifecycle | Stop mid-window, mid-backfill; superseded and failed starts; deferred close | `tests/ingestion-loop-lifecycle.test.ts` | +| Admin | Reset order stop → idle → drop → start; bounded wait | `tests/admin.test.ts` | +| Schema | v6 → v7 rebuild keeps system-of-record tables | `tests/ingestion-idempotency.test.ts` | +| Composer/service | History newest first, cursor walk, block completion | `tests/bond-history.test.ts` | +| Composer/service | Invalid bid excluded from preview and finalise; selection rejected | `tests/compose-auction-failure.test.ts`, `tests/auction-service.test.ts` | +| API/Zod contract | History query 400s; `invalid` bid in the union; regenerated spec | route tests, `tests/openapi-contract.test.ts` | +| UI/client | Mixed bid states render; totals ignore invalid bids | nb-ui vitest | +| Local runtime | Descriptor count; `fromBlock=0` resync of re-create; supply vs chain; malformed bid; failed allocation | Phase 7 evidence | + +## Recommended PR Slices + +| Slice | Branch | Architectural outcome | Main proof | Temporary compatibility/cleanup | +|---|---|---|---|---| +| 1 | `feature/ingestion-apply-seam` | Testable fetch/apply split, one decoder, injected chain | Characterization tests | None | +| 2 | `feature/ingestion-loop-lifecycle` | Stop aborts; single start; idle before drop; connections closed | Lifecycle and admin tests; live descriptor count | None | +| 3 | `feature/ingestion-chain-order` | Chain-ordered dispatch; declared timestamps; allocation failure history; dead paths removed | Order, re-create, timestamp tests | `cancelled` and `redemption_complete` columns remain until slice 4 | +| 4 | `feature/ingestion-idempotent-bond-state` | Position guard; schema v7 rebuild | Replay tests; local migration log | None | +| 5 | `feature/bond-history-newest-first` | SQL paging, validated query | History tests; regenerated spec | Keeps `getBondEventsByIsin` if slice 3 has not merged | +| 6 | `feature/auction-invalid-bids` | `invalid` bid state across API, finalise, UI | Service, composer, UI tests | None | + +Phase 7 evidence and the archive move ride on whichever slice merges last. Branching, commits, +and CI gates follow the repository PR workflow. + +## Migration, Rebuild, and Rollout + +- Data/schema version effect: v6 → v7 in slice 4; one automatic projection rebuild on first boot; + system-of-record tables untouched. +- Local rebuild/restart path: `./services/nb-bond-api/nb-bond-api.sh start` from the branch; the + migration runs at open. `POST /v1/admin/restart-ingestion?fromBlock=0` remains the manual path. +- Compatibility window: slice 3 leaves two unused columns on existing databases (harmless defaults); + slice 4 removes them. Slices 5 and 6 are independent of the schema. +- Deployed note: a non-local deployment with a persistent projection volume rebuilds once on the + v7 image; take the backup described in `DEVELOPMENT.md` §7.4 first. +- Rollback or fix-forward boundary: slices 1–3 and 5–6 revert cleanly; after slice 4 a rollback + needs the pre-upgrade database or a `fromBlock=0` resync on the older image. + +## Documentation and Public-Repo Hygiene + +- `services/nb-bond-api/DEVELOPMENT.md`: §4.2 history, §4.3 finalise, §7.1–§7.2 invalid bids, §7.4 + ingestion order, timestamps, position guard, schema v7, §7.9 restart semantics. +- `docs/ARCHITECTURE.md`: check the projection paragraph (around the atomic-commit sentence) and + `docs/diagrams/processes/mutation-projection-sequence.md` for ordering or restart claims. +- `docs/KNOWN_ISSUES.md`: no new entry expected; the `cancelBid` entry stays accurate. +- `docs/DOCUMENTATION_INDEX.md`: add this folder when the plan is committed; move it to the archive + list when it ships. +- Third-party/license inventory impact: none. + +Verification: + +```bash +python3 scripts/verification/check-public-repo-hygiene.py +python3 scripts/verification/check-markdown-links.py +``` + +## Out of Scope + +- Error middleware, nonce handling, the chart, and `app.ts` route extraction (separate hardening + plan); contract and ABI changes (separate contracts plan). +- Allocation failures on the `Auction` DTO; history rows only. +- The `tests/shutdown.test.ts` timer leak (follow-up). + +## Done Criteria + +- [ ] Every acceptance criterion in `intent.md` has evidence in `progress.md`. +- [ ] No temporary compatibility path remains (unused columns removed by v7). +- [ ] Both package gates, the regenerated `openapi.json`, hygiene and link checks pass. +- [ ] `DEVELOPMENT.md` describes the implemented ordering, idempotency, history, restart, and + invalid-bid behaviour. +- [ ] PR evidence contains no private environment information. diff --git a/docs/plans/ingestion-projection-correctness/progress.md b/docs/plans/ingestion-projection-correctness/progress.md new file mode 100644 index 0000000..038da32 --- /dev/null +++ b/docs/plans/ingestion-projection-correctness/progress.md @@ -0,0 +1,100 @@ +# Ingestion and projection correctness — Progress + +**Plan:** [`plan.md`](plan.md) +**Last updated:** 2026-09-28 — plan written from a code review of ingestion, projection, and history; awaiting operator approval +**Current phase:** Not started +**Next action:** Operator approves the plan and acknowledges the decisions in `design.md`; then run Phase 0 (package gates for `services/nb-bond-api` and `services/nb-ui`, live descriptor count) and open `feature/ingestion-apply-seam` for Phase 1. + +## Phase Log + +| Phase | Status | Evidence | PR | +|---|---|---|---| +| 0 — Baseline and characterization | Not started | | | +| 1 — Fetch/apply seam | Not started | | | +| 2 — Loop lifecycle | Not started | | | +| 3 — Chain-ordered apply | Not started | | | +| 4 — Position-guarded bond state, schema v7 | Not started | | | +| 5 — Bond history | Not started | | | +| 6 — Invalid bids | Not started | | | +| 7 — Live verification and close-out | Not started | | | + +## Deviations From the Plan + +None yet. + +## Verified So Far + +All against `development` at `db92409` on 2026-09-28, by reading the code unless stated. + +- **A1** `BondCreated` always applies `created`, which resets `bond_state`; `supply-delta` only + applies with a new `balance_events` row. Re-processing a window with `BondCreated` leaves supply + at `0`. Other facts recover because later events replay in order. +- **When a window is re-processed today:** (1) plain restart while the old loop is mid-backfill + or mid-window, because the new loop reads its checkpoint before the old loop's last commit; + (2) two concurrent loops after a restart during a boot retry or two restarts in quick + succession, since starts are not single-flight and the first interval handle is overwritten; + (3) the reset race, where the old loop writes a later window and checkpoint into the fresh + tables (skip) or partial data the new loop replays. **Not a path:** process crash or RPC error, + because a window's rows and checkpoint are one SQLite transaction; schema migration, which + replays into empty tables. +- **A2** `stopIngestionLoop()` only clears the timer; `processTo` never checks for a stop; + `resetProjectionAndRestart()` drops without waiting; the loop connection is never closed. The + live pod holds 3 + 3 `ingestion.sqlite` descriptors (checked with `kubectl exec`). +- **A3** Apply order per batch is manager, auction, token, transfers; `disableBond` and + `_deployBond` emit the token event before the manager event, so a disable and re-create in one + batch ends with `bond_state.disabled = 1` while `partitions.disabled = 0`. +- **A4** Both history reads are `ORDER BY block, id` with `LIMIT limit*4`; the composer sorts and + slices afterwards. `Number('abc')` reaches SQLite as a `LIMIT` bind and fails with "datatype + mismatch" (probed against better-sqlite3 13.0.3); a negative `LIMIT` returns every row. + `historyQuerySchema` is unused and would reject a request with no parameters as written. +- **A5** `finalise` unseals every sealed bid before reading the selection; `submitBid` is + permissionless; the composer falls back to all-sealed on any failure; nb-ui assumes one bid + state per list. +- **A15** `BondAllocationFailed` and `BondBuybackComplete` are in the ABI artifact and emitted, and + have no ingestion branch; `dvpSuccess` is discarded. +- **Dead code** The contract never emits `BidCancelled`; `redemption_complete` is never written or + read; the `AuctionFinalized` handler never reads a timestamp; `BondSnapshot.events` is never + read. +- **Tests** 37 suites, 252 tests pass (`npm test`). The Jest "worker failed to exit" warning comes + from the 60 s timer in the third `tests/shutdown.test.ts` case (`--detectOpenHandles`), **not** + from a database handle. The finding that it was likely the DB handle is corrected. +- **Refactor sizing** `processBlockRange` spans `src/ingestion.ts:588-1162`; the two decoders are + identical. The plan limits the refactor to a fetch/apply split, one decoder, an injected chain + reader, and per-source handler tables. +- Live sandbox on 2026-09-28: health `ok`, head 219, lag 0, no bonds projected, so no defect was + reproduced live yet (Phase 7). + +## Blocked / Waiting On + +- Operator approval of the plan and acknowledgement of the decisions (schema v7 rebuild, `invalid` + bid state, history page completion, live-verification mutations). + +## Follow-ups Found Along the Way + +- `tests/shutdown.test.ts` third case leaves a 60 s timer pending (`void shutdown('SIGTERM')` + never settles), which causes Jest's "worker failed to exit" warning. Proposed: use fake timers in + that case, or advance and await the first call. Outside this plan's scope. +- The `Auction` DTO could expose allocation failures directly (`allocation.failures[]`) instead of + only history rows. Proposed: consider once the contracts plan settles the DvP failure events. +- `src/types.ts` `AuctionCache` (used by the in-memory map in `src/state.ts`) still carries + `cancelled?: boolean` and other pre-projection fields; check whether that cache is still needed + now that reads come from the projection. Proposed: separate cleanup. +- Error mapping for unexpected errors (a plain `Error` becomes 500) belongs to the separate + `nb-bond-api-hardening` plan; this plan removes the two causes it found (history `NaN`, bid + unsealing). + +## Coordination With Other Plans + +- `docs/plans/bond-coupon-maturity-correctness/` (contracts) may change events or the ABI. Any new + or changed event needs an entry in the Phase 3 handler tables (with `needsTimestamp` set + correctly) and must drive at most one `bond_state` transition per log. Landing Phase 3 first makes + that a one-line addition. +- `nb-bond-api-hardening` touches `src/app.ts` (route extraction) and the error middleware. Phase 5 + changes only the history route's middleware line and handler body; Phase 6 changes + `src/features/auctions/service.ts`. Whichever lands second rebases. + +## Session Handoff + +- Nothing implemented; no branch exists. The live sandbox was only read (health, bond list, + descriptor count). +- `npm test` was run once in `services/nb-bond-api`; it compiles into the ignored `.tmp/` folder. diff --git a/docs/plans/nb-bond-api-hardening/design.md b/docs/plans/nb-bond-api-hardening/design.md new file mode 100644 index 0000000..50a23a6 --- /dev/null +++ b/docs/plans/nb-bond-api-hardening/design.md @@ -0,0 +1,362 @@ +# nb-bond-api operational hardening — Design + +**Status:** Draft +**Created:** 2026-09-28 +**Intent:** [`intent.md`](intent.md) +**Builds on:** the operator audit trail (#213), the committed-mutation `202` semantics +(`MutationAcceptedError`), the health indicator and self-healing plan +([`docs/plans/archive/health-indicator-and-self-healing-plan.md`](../archive/health-indicator-and-self-healing-plan.md)), +graceful shutdown (`src/shutdown.ts`), and the auctions service extraction +(`src/features/auctions/service.ts`). + +## Decision Summary + +- **One transaction sender per signer address.** A new `src/tx-sender.ts` owns nonce + assignment for every key the API signs with. Its state is a map keyed by checksummed + address, holding a cached nonce and a serialising queue. `sendWithManagedNonce` stays as + the `BOND_ADMIN` convenience wrapper, so the auctions service does not change shape. The + Central Bank, bank TBD, bidder, and created-bank paths move onto it. +- **The lock is held through confirmation, but bounded.** `tx.wait(1, NB_BOND_API_TX_WAIT_TIMEOUT_MS)` + replaces the unbounded wait. On timeout the sender resets that signer's nonce cache, + releases the queue, and throws `TransactionUnconfirmedError { hash }`. The review suggested + releasing the lock right after broadcast. That was not adopted: with a 1-second QBFT block + period it gains about a second per queued transaction, and it breaks the invariant that + each preflight (`staticCall` or `eth_estimateGas`) sees the effects of the previous + transaction from the same signer (see Alternatives). +- **Failure classification lives in one error middleware.** `http.ts` becomes the only place + that maps errors to HTTP. Client errors (body-parser, validation) keep their 4xx. A chain + that cannot be reached, or a registry entry that cannot be resolved, gives `503`. A + broadcast-but-unconfirmed transaction gives `202` `MutationAccepted` where a bond or auction + resource is known, and `504` ProblemDetails with the hash otherwise. Anything else gives a + `500` with a sanitised message plus a reference id that is also logged. Only that last + group is logged at `error` as "unhandled". +- **Liveness is process-local, and health is time-bounded.** `GET /livez` touches neither + the provider nor SQLite. `/v1/health` races its chain reads against a short bound, so it + always answers inside the probe timeout. It never hangs behind the ethers + network-detection retry loop. +- **The chart enforces one pod per volume.** It uses `strategy: Recreate`, and the template + fails to render when `replicaCount > 1`. Probe, security, strategy, and resource defaults + move from `values.local.example.yaml` into the chart's `values.yaml`, because the example is + copied once into a gitignored file and would never reach an existing sandbox. +- **Correctness before cleanup.** The stale `TbdBytecode.json` is refreshed in the cleanup + slice, because it is an artifact defect and not a style issue. Drift guards and the + route-module refactor come last and are optional. + +```mermaid +flowchart LR + R["Route handler"] --> S["tx-sender (per address queue)"] + S -->|"nonce n, estimate, broadcast"| C["Besu RPC"] + S -->|"wait(1, timeout)"| C + S -->|"receipt"| P["awaitMutationProjection"] + S -->|"TransactionUnconfirmedError"| M["http.ts classifier"] + R -->|"any throw"| M + M --> O["4xx / 202 / 503 / 504 / 500 + ref"] +``` + +## Current-State Evidence + +### Repository Evidence + +| Finding | Evidence | Consequence | +|---|---|---| +| **Verified.** Only `BOND_ADMIN` sends go through `sendWithManagedNonce`. It has a single global `cachedNonce` and mutex | `src/chain.ts:51-92`; callers in `src/app.ts` (bond create, disable, coupon) and `src/features/auctions/service.ts` | Other signers have no coordination | +| **Verified.** Central Bank writes call `wnok.add/remove/mint/burn/transfer` without a nonce, then `tx.wait()` | `src/central-bank.ts:166-216` | Concurrent CB mutations read the same pending nonce | +| **Verified.** Bank creation signs `GlobalRegistry.setContract` and the WNOK allowlist add with the same CB wallet. The TBD deploy uses the bank key, and its comment says fresh keys need no coordination | `src/banks.ts:197-240` | A bank create that overlaps a CB mutation collides. An operator-supplied bank key is not necessarily fresh | +| **Verified.** Bank TBD writes build `new Wallet(match.privateKey)` per call and `tx.wait()` | `src/banking-tbd.ts:214-238` | Concurrent TBD mutations for one bank collide | +| **Verified.** Bidder bids build `new Wallet(input.bidder.privateKey)` and `tx.wait()` | `src/bidder-bid.ts:217-226` | Concurrent bids by one bidder collide | +| **Verified.** Nordea's configured bank key and the seeded Nordea bidder key are the same key: both resolve through `fixtureRoleKey('PK_NORDEA')` | `src/banking-tbd.ts:45-62`, `src/bidders.ts:51-121` | A bid and a TBD Nordea mutation can collide across features. Keying by instance or by module would not catch this; keying by address does | +| **Verified.** `sendWithManagedNonce` awaits `tx.wait()` inside the lock, with no timeout | `src/chain.ts:80-91` | One unconfirmed transaction blocks every later `BOND_ADMIN` mutation indefinitely | +| **Verified.** `express.json()` errors (`status` 400 or 413, `type: 'entity.parse.failed'`/`'entity.too.large'`) reach the app-level wrapper. It only special-cases `HttpError`, logs everything else as "unhandled", and `problemErrorMiddleware` answers 500 with `err.message` | `src/app.ts:143`, `src/app.ts:1509-1520`, `src/http.ts:186-187` | Client mistakes look like server faults. Live: `POST /v1/bidders` with `{bad` returned 500 on 2026-09-28 | +| **Verified.** No catch-all route before the error middleware | `src/app.ts` (last route is `/v1/banking/tbd/:address/transfer`) | Live: `GET /v1/nope` returned `404 text/html` on 2026-09-28 | +| **Verified.** `MutationAcceptedError` and `DependencyUnavailableError` also pass through the "unhandled" `logger.error` before becoming 202 or 503 | `src/app.ts:1511-1519` | Successful 202s produce error-level stack traces | +| **Verified.** `RpcUnavailableError` and `RegistryResolutionError` are thrown on request paths (`assertProviderReady`, `getBondManagerAddress`) but are not mapped | `src/chain.ts:19-43`, `166-233`; `src/http.ts:152-188` | Chain outage returns 500; the open known issue | +| **Verified.** The `500` detail is `err.message`, and an existing test pins that behaviour. The OpenAPI `InternalError` description says the detail carries the thrown message | `tests/http.test.ts` "exposes the thrown message for unknown sandbox errors"; `openapi.json` `components.responses.InternalError` | Changing it is a deliberate contract-text change, not a bug fix in passing | +| **Verified.** `/v1/health` (unauthenticated) and `POST /v1/admin/restart-ingestion` return `recentErrors[].message` verbatim from `pushError` | `src/ingestion.ts:73-82`, `src/app.ts:278-290`, `src/admin.ts:85,120` | ethers `SERVER_ERROR`/`NETWORK_ERROR` messages embed request payloads and `requestUrl` | +| **Verified.** `entra` 401 detail is `Invalid token: ${err.message}` | `src/auth.ts:108-113` | Tells a caller which claim check failed | +| **Verified.** `If-None-Match` is compared with `===` against the strong tag | `src/http.ts:76-81` | Weak tags, lists, and `*` never give 304 | +| **Verified.** The gateway does not weaken the ETag today (no `Content-Encoding`, strong `ETag` through `bond-api.cbdc-sandbox.local`) | `curl -D -` on 2026-09-28 | The ETag fix is spec correctness, not a live outage | +| **Verified.** Liveness and readiness both probe `/v1/health` with the default `timeoutSeconds: 1` | `helm/values.local.example.yaml`; live `livenessProbe` JSON | See Runtime Evidence | +| **Verified.** `/v1/health` does up to five sequential chain reads: block number, network, `BOND_AUCTION()`, `BOND_TOKEN()`, `GOV_RESERVE()`, plus registry lookups until cached | `src/app.ts:222-261` | Slow RPC makes the probe exceed one second | +| **Verified.** ethers 6.17 `JsonRpcProvider._start()` retries network detection every second while the RPC is down, and queued requests wait instead of rejecting. Without `staticNetwork`, account-level calls re-detect the network | `node_modules/ethers/lib.commonjs/providers/provider-jsonrpc.js` `_start` and `_detectNetwork` | When Besu is down at boot, every chain read, including health, hangs | +| **Verified.** Two providers exist: request path `src/chain.ts:14` and ingestion `src/ingestion.ts:41`. Neither sets a request timeout; the `FetchRequest` default is 300 s | source | A stalled RPC can hold a request for five minutes | +| **Verified.** Deployment has no `strategy`, `resources`, or checksum annotations, and `replicas` comes from `.Values.replicaCount` | `helm/templates/deployment.yaml` | See Runtime Evidence | +| **Verified.** Probes, `securityContext`, and `podSecurityContext` live only in `values.local.example.yaml`. The fixture generator copies it once into the gitignored `values.local.yaml` (no `--force`) | `scripts/generate-local-sandbox-fixtures.mjs:24-33`; `common/helpers.sh` `requireNBBondApiHelmValues` | Editing the example never reaches an existing sandbox. The live `values.local.yaml` already differs from the example | +| **Verified.** Runtime stage copies the whole pruned workspace `node_modules` | `Dockerfile:44-47` | Live image has `react`, `react-dom`, and nb-ui's browser-auth package; `node_modules` is 96 MB | +| **Verified.** `CMD` touches `data/ingestion.sqlite` because `createApp()` opens the read-only handle before the writable one | `Dockerfile:51`; `src/app.ts:173-183`; `src/ingestion-db.ts:448-486` | Opening the writable handle first creates the file and schema, so the `touch` becomes unnecessary | +| **Verified.** `check-node-version-consistency.py` requires `NB_BOND_API_RUNTIME_BASEIMAGE=$SANDBOX_NODE_IMAGE` and rejects hard-coded `node:` in the Dockerfiles and `common/helpers.sh` | `scripts/verification/check-node-version-consistency.py:105-135` | A slim runtime needs a new pin variable and a matching check change | +| **Verified.** `TbdBytecode.json` was last written in #200 (2026-07-03). #232 (2026-07-16) moved the compiler to 0.8.36, the EVM target to `osaka`, and OpenZeppelin to 5.6.1. A current `contracts/out` build differs at most byte positions. All six ABIs match `contracts/out` | `git log`; local compare on 2026-09-28 | Created banks deploy stale TBD code. The ABI "in sync" claim holds; the bytecode claim does not | +| **Verified.** `openapi.json` equals a fresh generation from `src/schemas.ts` | scratch regeneration and `diff` on 2026-09-28 | A snapshot guard would pass today | +| **Verified.** `REDEMPTION` stays in the API enum on purpose: the comment and #276 keep it so stored `operation_attempts` rows (a preserved system-of-record) still validate. Only the write-side `OPERATION_TYPES` list lacks that comment | `src/contracts/operations.ts:16-17`; `src/operations.ts:25-46`; commit 49dd2ed | The finding "leftover to remove" is corrected: removing it would break stored rows. Plan clarifies instead | +| **Verified.** The global `requireAnyRole(recognizedRoles)` already covers `/v1/banking`, so the second gate at `app.use('/v1/banking', ...)` is a no-op | `src/app.ts:325`, `src/app.ts:1219` | Dead code | +| **Verified.** The comment "Central Bank is operator-only…" sits above `/v1/registry` and `/v1/operations`; the guard it describes is further down | `src/app.ts:941-966` | Misleads readers about what `/v1/registry` requires | +| **Verified.** The `NB_BOND_API_CLOSE_GAS_LIMIT` comment says "during the migration"; the QBFT migration shipped in #232 | `src/env-vars.ts:57-63` | Stale note | +| **Verified.** `.env.example` lacks `PK_NORDEA`/`PK_DNB`/`PK_ALICE_TBD`, the `NB_BOND_API_AUTH_*` variables, the `TBD_*`/`DVP_CONTRACT_NAME` names, `CORS_ALLOWED_ORIGINS`, `NB_BOND_API_SSE_HEARTBEAT_MS`, and `NB_BOND_API_CLOSE_GAS_LIMIT` | `services/nb-bond-api/.env.example` vs `src/env-vars.ts` | Direct `npm run dev` users miss settings | +| **Verified.** The amount parse block (`BigInt(body.amount)` plus the positive check) appears 6 times, and the address regex 8 times in `app.ts`. `bigIntStringSchema` already rejects non-digits, so the `try/catch` never fires, but nothing bounds the value to `uint256` | `src/app.ts` central-bank and banking routes; `src/contracts/common.ts:21-28` | Duplication; an amount above 2^256−1 reaches ethers and returns 500 | +| **Verified.** `createBidder` only checks hex shape. A zero or out-of-range scalar makes `secp256k1.getPublicKey` throw a plain `Error`, which becomes 500. `env-vars.ts` and `banks.ts` already range-check | `src/bidders.ts:141-150,265-289`; `src/env-vars.ts:125-147`; `src/banks.ts:262-267` | Inconsistent validation | +| **Verified.** `reconcileFixtureBidderOverrides` deletes and inserts without a transaction | `src/bidders.ts:235-236` | A crash between the two statements loses the fixture row | + +### Runtime Evidence + +- **Verified (2026-09-28, read-only `kubectl`):** pod `nb-bond-api` restarted 44 minutes + earlier after `SandboxChanged` (host restart). Events show `Readiness probe failed` and + `Liveness probe failed` on `/v1/health` with "context deadline exceeded". The Deployment + strategy is `RollingUpdate` with `maxSurge` and `maxUnavailable` at 25%. For one replica + that means one surge pod and zero unavailable, so an upgrade runs two pods side by side. + `resources` is `{}`. The PVC is `ReadWriteOnce` on `standard`. +- **Verified:** container memory is about 182 MiB current and 203 MiB peak (cgroup + `memory.current` and `memory.peak`). +- **Verified:** the live image is about 2.0 GB uncompressed, built on the full `node:26.5.0` + base. +- **Needs verification (Phase 0):** time for `/v1/health` and `/livez` with `RPC_URL` + pointing at a closed port, before and after the change. Whether `npm ci --omit=dev + --workspace nb-bond-api` (or an equivalent prune) yields a working runtime tree with the + `better-sqlite3` native binding. That `node:26.5.0-slim` shares the builder's glibc line so + the compiled binding loads. That `--force` regeneration of `values.local.yaml` reproduces + the same deterministic fixture keys. + +### Existing Test Coverage and Gaps + +- `tests/http.test.ts` covers md5, ETag exact match, ProblemDetails, 202 and 503 mapping, and + pins the raw 500 detail (this plan updates that test deliberately). +- `tests/app.test.ts` boots `createApp` against in-memory SQLite. It is the right place for + 404, body-parser, and `/livez` checks. +- `tests/auction-service.test.ts` mocks `sendWithManagedNonce`; keeping that export stable + avoids churn. +- Gaps: no test for nonce assignment or concurrency, wait timeouts, chain-unavailable + mapping, weak or listed `If-None-Match`, `createBidder` key range, or health sanitisation. + +## Significant Findings + +| Priority | Finding | Why it matters | Plan response | +|---|---|---|---| +| Important | Nonce collisions on every non-admin signer, including across features on the shared Nordea key | Operator actions fail intermittently under normal UI concurrency | Phase 2 per-address sender | +| Important | Unbounded `tx.wait()` under the admin lock | One stuck transaction blocks every bond and auction mutation until the pod restarts | Phase 2 wait timeout | +| Important | Liveness depends on the chain; health hangs while ethers retries network detection | Restart loop during Besu outages; observed probe failures on 2026-09-28 | Phase 3 `/livez` and bounded health | +| Important | `RollingUpdate` on one RWO SQLite volume | Two ingestion loops and two nonce caches on `BOND_ADMIN` during every upgrade | Phase 3 `Recreate` and replica guard | +| Important | Stale `TbdBytecode.json` | Created banks run older compiled code than fixture banks; nothing detects it | Phase 5 refresh; Phase 6 optional guard | +| Important | Chart defaults live only in the copied example | Probe and security fixes would silently never apply to existing sandboxes | Phase 3 moves defaults into `values.yaml`, plus a one-time regeneration | +| Follow-up | Error responses use `application/json`, not `application/problem+json` | Convention gap, not a defect; changing it touches the UI client's content negotiation | Recorded in `progress.md` | + +## Invariants + +- A signer never has two unconfirmed transactions from this process at once. Every + preflight sees the chain state produced by that signer's previous transaction. +- A response never claims failure after a durable mutation: once a hash exists, the client + gets `202` or `504` with the hash, never `503` "retry". +- `/v1/health` keeps its documented shape and never answers 5xx for chain reasons. +- `/livez` never touches the provider, SQLite, or the ingestion module. +- One API process per SQLite volume at any time. +- The system-of-record tables (`bidders`, `banks`, `operation_attempts`) are untouched by any + schema change. No projection schema version bump is needed. +- `openapi.json` is regenerated from `src/schemas.ts`, never edited by hand. +- Stored `REDEMPTION` audit rows keep validating. + +## Target Architecture + +### Ownership and Dependency Direction + +- `src/tx-sender.ts` (new) owns nonce state and confirmation bounds. It depends on ethers + and `env-vars` only. `chain.ts` re-exports `sendWithManagedNonce` as + `(send) => sendSigned(adminWallet, send)`. +- `src/http.ts` owns error classification. A new `src/error-text.ts` (or a section of + `http.ts`) owns sanitising. The app-level wrapper in `app.ts` is deleted, and the + middleware takes the logger by parameter. +- `src/app.ts` keeps route wiring. Parsing helpers move to `src/parsing.ts`, and address + params become Zod param schemas next to `bidderAddressParamSchema`. +- The chart owns the pod lifecycle. `values.yaml` carries defaults, and + `values.local.yaml` carries only secrets, env, and per-workstation overrides. + +### Transaction Sender + +```ts +// src/tx-sender.ts (shape, not final code) +export class TransactionUnconfirmedError extends Error { hash: string; signer: string; timeoutMs: number } +export async function sendSigned( + wallet: Wallet, + send: (nonce: number) => Promise, +): Promise<{ tx: T; receipt: TransactionReceipt | null }>; +``` + +- The state is `Map }>`. The queue is + the existing promise-chain mutex, lifted per address. +- Inside the queue: resolve the nonce (cached, or `getTransactionCount(address, 'pending')`), + `send(nonce)`, then `tx.wait(1, NB_BOND_API_TX_WAIT_TIMEOUT_MS)`, then `nonce + 1`. +- On any error, reset that address's nonce to `null`. This is always correct because the + lock is held through confirmation, so no in-flight transaction from this process can be + miscounted. An ethers `TIMEOUT` from `wait` becomes `TransactionUnconfirmedError` with the + hash. +- Send sites move over like this: `central-bank.ts` writes use `sendSigned(getCbWallet(), (nonce) => fn(addr, amount, { nonce }))`; + `banks.ts` `registerContract` uses the CB wallet, and `deployTbd` uses + `factory.deploy(..., { nonce })` then `deploymentTransaction()`; `banking-tbd.ts` + `sendTbdTx` passes `{ nonce }` into `invoke`; `bidder-bid.ts` passes `{ nonce }` to + `submitBid`. +- `withOperationRecording` records `TransactionUnconfirmedError` with its hash. `classifyFailure` + reads `err.hash` alongside `receipt.hash`, and the error text says "unconfirmed after N ms; + may still mine". The status stays `FAILED`, so the `OperationStatus` enum does not change. + This is a residual honesty gap (see Residual Risks). + +### Provider + +- `chain.ts` builds its provider from a `FetchRequest` with `timeout = + NB_BOND_API_RPC_TIMEOUT_MS` (default 10 000) and `{ staticNetwork: true }`. +- The `FetchRequest` timeout does not cover requests that are still queued behind the ethers + boot-time network detection loop, because those are never dispatched. `assertProviderReady()` + therefore races `getBlockNumber()` against the same timeout and throws + `RpcUnavailableError` when it expires. That function is the entry point for the registry + lookups (`getBondManagerAddress`, `getWnokAddress`, `resolveRegisteredAddress`, + `listRegistryEventNames`) that every request makes before its contract handles are cached. + So when Besu is down at boot, requests get a 503 instead of hanging. +- The ingestion provider is not changed here. Backfill `getLogs` calls can legitimately run + longer than a request-path timeout, and `src/ingestion.ts` belongs to the ingestion + correctness plan. Sharing, or at least `staticNetwork: true` there, is handed to that plan. + +### API and Zod Contract + +- Classifier order in `problemErrorMiddleware`: + 1. `MutationAcceptedError` gives 202 (unchanged). + 2. `TransactionUnconfirmedError` with a known resource: the route converts it to + `MutationAcceptedError` (bond or auction routes) and gets 202. Otherwise it becomes + `504 Gateway Timeout` ProblemDetails with detail "transaction was broadcast but + not confirmed within N s; do not resend; check the Operations page or the explorer". + 3. `HttpError` keeps its status. + 4. body-parser errors (`type` starting `entity.` or `charset.`/`encoding.`, numeric + `status` 4xx) keep their status with a fixed title and safe detail: "Malformed JSON + body", "Request body exceeds 100kb", "Unsupported encoding". + 5. `DependencyUnavailableError`, `RpcUnavailableError`, `RegistryResolutionError`, and + ethers errors with `code` in `NETWORK_ERROR`, `TIMEOUT`, or `SERVER_ERROR` (or a + `cause.code` in `ECONNREFUSED`, `ECONNRESET`, `EAI_AGAIN`, `ENOTFOUND`) give 503 with + "Chain RPC is unavailable; wait for the Besu pod to become ready, then retry." These + are raised before broadcast by construction, because after broadcast the sender throws + `TransactionUnconfirmedError`, so the retry advice is honest. They are logged at `warn`. + 6. Everything else gives 500 with `detail = sanitise(err) + " (ref )"`, logged at + `error` with the full stack and the same ref. +- Sanitiser: prefer ethers `shortMessage`; otherwise `message` cut at the first ethers + key-dump suffix (` (action=`, `(code=`, `transaction=`, `info=`, `request=`, `data=`). Any + `http(s)://` URL is reduced to `protocol//host` (the same rule as `sanitiseRpcUrl`), and + the text is capped at 300 characters. The same function maps `recentErrors[].message` at + the health and restart-ingestion boundary, not inside `ingestion.ts`. +- JSON 404: a final `app.use((req, res) => problem(req, res, 404, 'Not Found', { detail: 'No route for ' }))` + after all routes and before the error middleware. It sits behind `authMiddleware`, so in + `entra` mode an anonymous caller gets 401 first and learns nothing about route existence. +- `If-None-Match`: parse per RFC 9110. `*` matches. Otherwise split on commas, trim, strip a + `W/` prefix, and use weak comparison on the opaque tag. This applies to GET and HEAD only, + as today. +- `entra` 401: detail "Invalid or expired bearer token"; the library message is logged at + `debug`. +- OpenAPI changes, all in `src/openapi/`: the `InternalError` description (sanitised + message plus ref); a `PayloadTooLarge` response added to `errorRefs.mutate`; a + `GatewayTimeout` (504) added to `errorRefs.mutate`; the `ServiceUnavailable` description + names chain unavailability. Regenerate `openapi.json`. No schema field changes, so nb-ui + needs no change, because `httpClient.js` already renders `detail`. +- `/livez` is **not** added to the OpenAPI document. It follows the `/docs` precedent of an + unversioned operational endpoint outside the contract. It is documented in the README. + +### Probes and Chart + +- `GET /livez` is mounted first, before helmet, CORS, rate limit, and body parsing. It + returns `200 {"status":"alive"}` with `Cache-Control: no-store`. +- `/v1/health`: the chain-probe block and the contract-address block run concurrently under + one `withTimeout(..., 2000)` budget, so the handler's worst case is about 2 s, not the sum + of both blocks. When the budget expires, the unfinished block records + `headReachable: false` or keeps the zero addresses, as it already does on errors. +- `values.yaml` (chart defaults): + - `strategy: { type: Recreate }`. + - `livenessProbe`: `/livez`, `periodSeconds: 20`, `timeoutSeconds: 2`, + `failureThreshold: 3`, `initialDelaySeconds: 10`. + - `readinessProbe`: `/v1/health`, `periodSeconds: 5`, `timeoutSeconds: 4`, + `failureThreshold: 6`. + - `resources`: requests `cpu: 50m`, `memory: 256Mi`; limit `memory: 512Mi`; no CPU limit, + because unseal and finalise are bursty and a CPU limit only throttles. + - `securityContext`: the current block plus `readOnlyRootFilesystem: true`, and a `/tmp` + `emptyDir` mount. + - `podSecurityContext`: `fsGroup: 1000`. +- `deployment.yaml`: `checksum/config` and `checksum/secret` pod annotations + (`include ... | sha256sum`); `strategy` from values; `{{ if gt (int .Values.replicaCount) 1 }}{{ fail ... }}`. +- `values.local.example.yaml` loses the probe and security blocks and keeps `namespace`, + `imagePullPolicy`, `env`, and `secret`. Helm deep-merges maps, so a stale + `values.local.yaml` that still carries `livenessProbe.httpGet.path: /v1/health` would + override the new default. Rollout therefore includes a one-time + `node scripts/generate-local-sandbox-fixtures.mjs --force`, after Phase 0 confirms it + reproduces the same deterministic keys. + +### Consistency and Failure Semantics + +| Situation | Today | Target | +|---|---|---| +| Two same-signer mutations overlap | One fails with a nonce error as a 500 | Serialised; both succeed | +| Transaction never confirms | Admin lock held forever; request hangs | 202 or 504 with hash after the timeout; signer released; nonce cache re-read | +| Chain down, before broadcast | 500 with a raw ethers message | 503 with a stable detail | +| Chain down at boot, health probe | Hangs; probes fail; liveness can restart the pod | Health answers `down` within about 2 s; `/livez` 200; no restart | +| Malformed body | 500, logged as unhandled | 400 or 413 | +| Upgrade with a new image | Two pods on one volume for a while | Old pod stops (graceful shutdown) before the new one starts; brief downtime | + +### Security and Deployment Boundary + +- Fewer disclosures: no raw ethers payloads, no URL paths, no JWT validation hints. +- The new env vars (`NB_BOND_API_TX_WAIT_TIMEOUT_MS`, `NB_BOND_API_RPC_TIMEOUT_MS`) have + defaults and are validated in `env-vars.ts`. A non-local deployment can tune them without + code changes. +- `Recreate`, the replica guard, resources, and `readOnlyRootFilesystem` carry over to a + non-local deployment unchanged. That deployment must also keep one replica, which is + already required by the in-process SSE broadcaster. + +## Alternatives Considered + +- **Release the per-signer lock right after broadcast** (the review's suggestion). This was + rejected for the sandbox. With `blockperiodseconds: 1` the lock is held about 1–2 s per + transaction, so pipelining gains little. It would also let a second same-signer + transaction be simulated against `latest` state that excludes the first one. For + dependent operations that means false reverts at estimation, or, on the close-auction + retry path that skips estimation, mined reverts. The operator can revisit this if + sandbox throughput ever matters. +- **ethers `NonceManager`.** Rejected. Its `sendTransaction` increments the nonce before + `populateTransaction` runs `eth_estimateGas`. A revert at estimation therefore burns a + local nonce and leaves a gap until `reset()`. The repo's preflight-and-reset pattern is + the safer base. +- **One global lock for every signer.** Rejected. It serialises unrelated keys, for example + a bidder bid behind a coupon payment, for no correctness gain. +- **Generic "Internal error" 500 detail.** Rejected in favour of a sanitised message plus a + ref. The sandbox is used for technology testing, and the decoded revert or short message + is the useful part. +- **A readiness endpoint that fails while the chain is down.** Considered (see Decisions). + With one replica there is no other pod to route to. An unready pod makes the gateway + answer with a bare 503, and the UI loses the API's own `down` explanation. +- **Share one provider between request path and ingestion.** Deferred to the ingestion plan, + because the timeout needs differ. +- **Keep the `touch` in `CMD`.** Rejected. It hides an ordering dependency that a two-line + reorder in `createApp()` removes. + +No decision here is load-bearing enough for an ADR. They are local operational policies and +can be reversed cheaply. + +## Decisions + +| Decision | Recommendation | Rationale | Operator action required | +|---|---|---|---| +| Signer lock scope | Per address, held through confirmation, bounded by `NB_BOND_API_TX_WAIT_TIMEOUT_MS` (default 30 000) | See Alternatives | Confirm the deviation from the review | +| `500` detail policy | Sanitised short message plus logged ref id; update the `InternalError` description and the pinning test | Keeps sandbox observability without payload dumps | Confirm | +| Unconfirmed-transaction status | 202 `MutationAccepted` on bond and auction routes; 504 ProblemDetails with hash elsewhere; audit row `FAILED` with hash and "unconfirmed" text | Honest, no enum change | Confirm | +| Readiness semantics | Readiness stays on bounded `/v1/health`, so the pod is Ready while the chain is down and reports `down` | Single replica; better UI signal | **Decide before Phase 3** | +| `/livez` in OpenAPI | No, like `/docs` | Kubelet-facing, not an API resource | Confirm | +| Chart defaults location | Move to `values.yaml`; strip the example; one-time `--force` regeneration | Otherwise the change never reaches existing sandboxes | Confirm the regeneration step | +| Slim runtime base `node:26.5.0-slim` | Adopt as Phase 4b behind approval: new `SANDBOX_NODE_RUNTIME_SLIM_IMAGE` in `common/node-version.env`, and a `check-node-version-consistency.py` rule update | New image tag and a verification-script change | **Explicit approval** | +| OpenAPI snapshot test | Add to `tests/openapi-contract.test.ts` (runs in the existing `format-lint-test` job) | Cheap; no new workflow | **Explicit approval** (makes an existing CI check stricter) | +| ABI and bytecode sync check | New `scripts/verification/check-nb-bond-api-abi-sync.py` plus a step in Contracts CI after `forge build`, with sparse checkout extended to `services/nb-bond-api/src/abi` | Only that job has `forge build` output | **Explicit approval** (new script and CI step) | +| Route-module extraction | Optional Phase 7, one PR per feature, after the separate hardening change lands | Large refactor | **Explicit go-ahead** | +| Live outage test on the cluster | Prefer a local `npm run dev` against a dead `RPC_URL`; scale Besu to zero only with a go-ahead | Avoids mutating shared sandbox state | Go-ahead if the cluster test is wanted | + +## Residual Risks + +- A transaction that times out may still mine later. The audit row says `FAILED` with an + "unconfirmed" note. An `UNCONFIRMED` status would be more honest but changes the + `OperationStatus` enum, so it is recorded as a follow-up. +- If a transaction is stuck (not dropped) in the Besu pool, the next same-signer send + re-reads `pending`, queues behind it, and also times out. That is visible and bounded, but + not self-healing. With zero base fee and one validator it is only expected when the + validator itself is down. +- `Recreate` adds a few seconds of downtime per upgrade: graceful shutdown (at most 10 s) + plus startup. +- The body limit stays at the Express default of 100 kB. Very large finalise bodies would now + get an honest 413 instead of a 500, but the limit itself is not revisited here. +- `readOnlyRootFilesystem` could expose an unexpected write path. Phase 3 verifies this + against the live pod before merging. diff --git a/docs/plans/nb-bond-api-hardening/intent.md b/docs/plans/nb-bond-api-hardening/intent.md new file mode 100644 index 0000000..1aff29e --- /dev/null +++ b/docs/plans/nb-bond-api-hardening/intent.md @@ -0,0 +1,124 @@ +# nb-bond-api operational hardening — Intent + +**Status:** Draft +**Created:** 2026-09-28 +**Requested by:** sandbox operator + +## Outcome + +`services/nb-bond-api` stays correct and diagnosable when requests overlap, when the chain is +slow or down, and when the pod is restarted or upgraded. Concurrent operator actions that sign +with the same key no longer fail on nonce collisions. One stuck transaction no longer blocks +every later mutation. Clients get an honest status for every failure: a client mistake gets a +4xx, an unreachable chain gets a 503, an unknown route gets a JSON 404, and a transaction that +was broadcast but not confirmed says so instead of inviting a blind retry. The pod is no longer +restarted because Besu is down, is never run twice against the same SQLite volume, and picks +up configuration changes on `./nb-bond-api.sh start`. The runtime image stops shipping +another workspace's dependencies. The small leftovers from earlier reviews are cleaned up. + +## Why This Change + +A repository review on 2026-09-28 found these gaps, and this plan re-verified them against the +code and the running sandbox: + +- Only the `BOND_ADMIN` key has a managed nonce. The Central Bank key, the bank TBD keys, the + bidder keys, and created-bank keys rely on the ethers pending-nonce lookup. Two overlapping + requests for the same key read the same nonce and one of them fails. The Nordea fixture key + signs both bids and TBD Nordea mutations, so the collision can even cross features. +- The `BOND_ADMIN` nonce lock is held across `tx.wait()` with no timeout. One transaction that + never confirms blocks every later admin mutation for as long as the process lives. +- Malformed JSON returns `500` and is logged as an unhandled error. An unknown route returns + the Express HTML page. `RpcUnavailableError` is not mapped, so a chain outage shows up as a + `500` (the open `docs/KNOWN_ISSUES.md` entry "nb-bond-api request-path chain reads bubble up + as opaque 500s"). `500` responses and `/v1/health` `recentErrors` carry raw ethers messages + that embed request and transaction payloads. In `entra` mode a `401` echoes the JWT + library's validation text. +- On 2026-09-28 the live pod's readiness and liveness probes both timed out against + `/v1/health` after a host restart. Liveness calls a chain-dependent endpoint, so a Besu + outage can put the pod into a restart loop that cannot fix anything. +- The chart has the default `RollingUpdate` strategy on a `ReadWriteOnce` SQLite volume. An + image upgrade briefly runs two pods, which means two ingestion loops and two nonce caches on + the same key. It has no resource requests or limits, and no config checksum, so a + ConfigMap-only change is never picked up. +- The runtime image copies the whole workspace `node_modules`, including the React and + browser-auth packages that only nb-ui uses. +- `src/abi/TbdBytecode.json` was compiled before the QBFT and Osaka baseline, so banks created + through the API deploy TBD code built by an older compiler, for an older EVM target, against + older OpenZeppelin code than the fixture TBDs. + +## Scope + +### In Scope + +1. A per-signer transaction sender, keyed by address, used by every signing path. It bounds + confirmation with a timeout and returns an explicit "broadcast but unconfirmed" outcome. + Bounded RPC request timeouts on the request-path provider. +2. Error envelope: body-parser errors keep their 4xx status. A JSON ProblemDetails 404 + fallback. Chain-unavailable and registry-resolution errors become `503`. `500` detail and + health `recentErrors` are sanitised. A generic `401` detail in `entra` mode. Handled + outcomes are no longer logged as "unhandled". `If-None-Match` handles weak tags, lists, + and `*`. The related OpenAPI text changes and `openapi.json` is regenerated. +3. Probes and chart: a process-local `GET /livez` for liveness, a time-bounded `/v1/health`, + `strategy: Recreate`, a guard against `replicaCount > 1`, resource requests and a memory + limit, config and secret checksum annotations, `readOnlyRootFilesystem`, and chart defaults + that actually reach existing sandboxes. +4. Dockerfile: runtime `node_modules` limited to nb-bond-api's production dependencies. The + `touch` workaround removed by opening the writable database handle first. Stale comments + fixed. An optional slim runtime base, which needs operator approval. +5. Cleanup: refresh the stale `TbdBytecode.json`, shared amount and address parsing, + `createBidder` key-range validation, a transaction around the fixture-override migration, + `.env.example` completeness, the stale gas-limit note, the misplaced RBAC comment, the + redundant `/v1/banking` role gate, and a clarified legacy `REDEMPTION` operation type. +6. Optional drift guards: an OpenAPI snapshot test, and an ABI and bytecode sync check. Each + needs operator approval. +7. Optional, later: extract route modules per feature from `src/app.ts`, following the + auctions service precedent. This is a larger refactor and needs its own go-ahead. + +### Out of Scope + +- Items owned by a separate hardening change, including role gating on routes. This plan only moves one comment and removes one no-op + duplicate gate. +- Ingestion, projection, history, and finalise-bid defects, and how ingestion opens its own + database handle. These belong to the ingestion and projection correctness plan. This plan + does not edit `src/ingestion.ts`. +- Contract source changes and redeployment. +- Content type `application/problem+json` on error responses. This is recorded as a + follow-up. +- Running more than one API replica. This plan makes one replica an enforced rule rather than + designing for more. + +## Acceptance Criteria + +| Criterion | Risk addressed | Verification evidence | +|---|---|---| +| Two concurrent mutations signed by the same key (Central Bank, bank TBD, bidder, or `BOND_ADMIN`) get consecutive nonces and both succeed | Nonce collision (A9) | `tx-sender` unit test with a fake signer; live: two parallel `curl` WNOK mints both return 200 | +| A transaction that does not confirm within `NB_BOND_API_TX_WAIT_TIMEOUT_MS` releases the signer and returns an "unconfirmed" outcome carrying the hash (202 for bond and auction mutations, 504 ProblemDetails elsewhere). The next mutation proceeds | Global hang (A9) | Unit test with a never-confirming fake tx; error-middleware test | +| Malformed JSON gives 400, an oversized body gives 413, and neither logs "unhandled" | Wrong status (A10) | `app.test.ts` requests; log assertion | +| An unknown path returns a JSON ProblemDetails 404 | HTML 404 (A10) | `app.test.ts` | +| Chain unreachable on a request path gives 503 with a stable detail; the known-issue entry is closed | Opaque 500 (A10) | Middleware tests for `RpcUnavailableError`, `RegistryResolutionError`, ethers `NETWORK_ERROR`/`TIMEOUT`; `docs/KNOWN_ISSUES.md` diff | +| `500` detail and `recentErrors` contain no `transaction=`/`info=`/`request=` payload and no URL path or credentials | Payload leakage (A10) | Sanitiser unit tests with recorded ethers error shapes | +| An `entra` 401 for a bad token returns a generic detail | Validation-hint leak (A10) | `auth.test.ts` | +| `If-None-Match: W/""`, `"a", ""`, and `*` each yield 304 on GET | Missed revalidation (A10) | `http.test.ts` | +| `/livez` answers 200 in under 50 ms with `RPC_URL` pointing at a closed port; `/v1/health` answers within its bound with `status: down` | Probe restart loop (A11) | Local `npm run dev` run against a dead RPC; chart renders liveness on `/livez` | +| Rendered chart has `strategy.type: Recreate`, resources, checksum annotations, and `readOnlyRootFilesystem: true`; `replicaCount: 2` fails to render | Double pod on one SQLite volume, silent config drift (A11) | `helm template` output; live `kubectl get deploy -o jsonpath` | +| Runtime `node_modules` has no `react`, `react-dom`, or nb-ui browser-auth package; the container starts without the `touch` in `CMD` | Image bloat, workaround (A14) | `docker run --rm ls node_modules`; live start | +| `src/abi/TbdBytecode.json` equals `contracts/out/Tbd.sol/Tbd.json` `bytecode.object` from a current `forge build` | Created banks deploy outdated code | Local compare command in `plan.md` | +| `createBidder` with a zero or out-of-range key returns 400 | 500 on bad input | `bidders.test.ts` | +| Package gate, `forge build` (for the artifact), hygiene and link checks pass | Regression | CI plus local gate | + +## Constraints + +- Sandbox-sized and local-first. New settings are environment variables with safe defaults, + so a non-local deployment can tune them without code changes. +- Public repo: no secrets, private identifiers, or home-directory paths. Fixture keys are + referenced by file path only. +- No new npm packages. ethers already provides `FetchRequest`, `staticNetwork`, and + `tx.wait(confirms, timeout)`. +- A new image tag, a new CI check, or a new verification script needs explicit operator + approval (root `AGENTS.md`). + +## Open Questions + +- Whether a pod should report Ready while the chain is unreachable (see the `design.md` + decision "Readiness semantics"). The recommendation is yes. The operator decides before + Phase 3. diff --git a/docs/plans/nb-bond-api-hardening/plan.md b/docs/plans/nb-bond-api-hardening/plan.md new file mode 100644 index 0000000..0027936 --- /dev/null +++ b/docs/plans/nb-bond-api-hardening/plan.md @@ -0,0 +1,609 @@ +# nb-bond-api operational hardening — Implementation Plan + +**Status:** Proposed +**Created:** 2026-09-28 +**Scope:** `services/nb-bond-api` (`src/chain.ts`, new `src/tx-sender.ts`, `src/central-bank.ts`, `src/banks.ts`, `src/banking-tbd.ts`, `src/bidder-bid.ts`, `src/features/auctions/service.ts` send wiring only, `src/http.ts`, `src/app.ts`, `src/auth.ts`, `src/env-vars.ts`, `src/parsing.ts`, `src/bidders.ts`, `src/operations.ts`, `src/openapi/shared-responses.ts`, `src/contracts/common.ts`, `src/abi/TbdBytecode.json`, `openapi.json`, `helm/`, `Dockerfile`, `.env.example`, `README.md`, `DEVELOPMENT.md`, tests); `docs/KNOWN_ISSUES.md`; `docs/DOCUMENTATION_INDEX.md`; approval-gated: `common/node-version.env`, `common/helpers.sh`, `scripts/verification/check-node-version-consistency.py`, `docs/THIRD_PARTY_NOTES.md`, `THIRD_PARTY_LICENSES.md`, `.github/workflows/test-contracts.yml`, a new `scripts/verification/check-nb-bond-api-abi-sync.py` +**Intent:** [`intent.md`](intent.md) · **Design:** [`design.md`](design.md) · **Progress:** [`progress.md`](progress.md) + +## Plan Order + +```text +Phase 0 Baseline and characterisation (no behaviour change) +Phase 1 Error envelope and contract text (A10) -> slice 1 +Phase 2 Per-signer transaction sender (A9) -> slice 2 +Phase 3 Liveness, bounded health, chart hardening (A11) -> slice 3 +Phase 4 Runtime image (A14); 4b slim base behind approval -> slices 4a, 4b +Phase 5 Cleanup and TBD bytecode refresh -> slice 5 +Phase 6 Drift guards (optional, approval) -> slice 6 +Phase 7 Route-module extraction (optional, go-ahead) -> slices 7.x +Phase 8 Close-out: index, known issues, archive +``` + +Why this order: + +- Phase 1 comes before Phase 2 because Phase 2 adds one error class + (`TransactionUnconfirmedError`) and needs the consolidated classifier and its tests to plug + into. +- Phase 3 depends on Phase 1 for the 503 mapping. Once health is bounded and the pod is Ready + while the chain is down, requests must fail fast and honestly. +- Phase 5 comes after Phases 1 and 2 so the shared parsing helpers land in the consolidated + error model. +- Phase 7 comes last so it moves settled code, and only after the separate hardening change lands. + +Each phase is one PR against `development` unless noted. Every phase updates `progress.md` in +the same commit. Branch naming, commit style, and the per-package gate follow the repository +PR workflow. + +## Phase 0: Baseline and Characterisation + +### Goal + +Pin the current behaviour that later phases change, and resolve the design's "Needs +verification" items before any code depends on them. + +### Scope + +Scratch work only, plus characterisation tests that ship with Phase 1 and Phase 2. No +production code changes. + +### Steps + +1. Record the gate baseline: `npm run lint -w nb-bond-api && npm run format:check -w nb-bond-api && npm test -w nb-bond-api`. + Note the test count in `progress.md`. +2. Local dead-RPC timing. Run `RPC_URL=http://127.0.0.1:1 GLOBAL_REGISTRY_ADDRESS= BOND_ADMIN_PK= DB_PATH=/i.sqlite npm run dev -w nb-bond-api`, + using values from the gitignored fixture files by path. Time `curl -m 30 -w '%{time_total}' http://127.0.0.1:8080/v1/health`. + Expect it to hang past 1 s, which is the ethers detection loop. +3. Nonce collision reproduction against the live sandbox (read-mostly; needs operator OK + because it sends two WNOK mints). Fire two `POST /v1/central-bank/wnok/mint` for 1 unit + to the CB address in parallel and record the second response. If the operator declines, + rely on the Phase 2 unit test. +4. Image facts. Record `docker image inspect` size and `du -sh node_modules` for the current + tag. In a scratch copy of the Dockerfile, try + `npm ci --omit=dev --workspace nb-bond-api --include-workspace-root=false` in a separate + `deps` stage. Check that `node -e "require('better-sqlite3')"` succeeds in the runtime + stage. Repeat with `node:26.5.0-slim` as the runtime base, without pushing anything to + the shared registry. +5. Fixture regeneration check. Copy `services/nb-bond-api/helm/values.local.yaml` to a + scratch directory, run `node scripts/generate-local-sandbox-fixtures.mjs --force` with + the repo's generated outputs backed up, and diff the `secret` and `env` blocks. The keys + must be identical. Restore the backup whatever the result. The operator may prefer to run + this step themselves. +6. `forge build` in `contracts/` (writes only the gitignored `out/`). Confirm + `jq -r .bytecode.object contracts/out/Tbd.sol/Tbd.json` differs from + `jq -r .bytecode services/nb-bond-api/src/abi/TbdBytecode.json`, and that all six ABIs + match. + +### Verification Stop + +`progress.md` "Verified So Far" records: the dead-RPC health timing, collision evidence (or +the decision to skip), the working deps-stage command, whether slim loads the native binding, +the regeneration diff, and the bytecode diff. + +### Failure Diagnosis / Fix Forward / Rollback + +- If `npm ci --workspace` does not give a runnable tree, fall back to a builder step + `npm prune --omit=dev --workspace nb-bond-api`, or copy only the modules that + `npm ls --omit=dev -w nb-bond-api --parseable` lists. Record the chosen command. +- If `--force` changes any key, do not use it in rollout. Instead document a manual deletion + of the probe and security blocks from `values.local.yaml`. + +### Exit Criteria + +- [ ] Every "Needs verification" item in `design.md` is resolved or re-scoped in + `progress.md`. + +## Phase 1: Error Envelope and Contract Text (A10) + +### Goal + +Every failure gets an honest status and a safe, useful detail, classified in one place. + +### Scope + +`src/http.ts`, a new `src/error-text.ts`, `src/app.ts` (remove the wrapper middleware, add the +404 fallback, sanitise health and restart output), `src/auth.ts`, `src/openapi/shared-responses.ts`, +`src/contracts/health.ts` (description only), `openapi.json` (regenerated), tests. + +### Steps + +1. `src/error-text.ts`: `sanitiseErrorText(input: unknown): string`. Prefer `shortMessage`, + cut at the first ethers key-dump marker, reduce URLs to their origin, and cap at 300 + characters. Export `isChainUnavailable(err)`, which is true for `RpcUnavailableError`, + `RegistryResolutionError`, ethers `code` in `NETWORK_ERROR`, `TIMEOUT`, or `SERVER_ERROR`, + or a `cause.code`/`error.code` in `ECONNREFUSED`, `ECONNRESET`, `EAI_AGAIN`, or `ENOTFOUND`. + Import the chain error classes from `chain.ts`, or move them to `application-errors.ts` + to avoid an http-to-chain import. Moving them is preferred, with `chain.ts` re-exporting + them for compatibility. +2. `src/http.ts`: + - `problemErrorMiddleware` becomes `createProblemErrorMiddleware(log)`, with the + classification order in `design.md`. It handles body-parser errors (`type` and `status`), + chain-unavailable errors (503 and `warn`), and unknown errors (500 with sanitised text + plus `ref`, logged at `error` with the stack and ref via `crypto.randomUUID()`). + - `ifNoneMatchMatches(header, etag)` per RFC 9110 weak comparison. `successResponse` uses + it. +3. `src/app.ts`: + - Delete the wrapper at the end of the file. Mount `createProblemErrorMiddleware(logger)`. + - Add the JSON 404 fallback just before it. + - Map `ingestion.recentErrors` through `sanitiseErrorText` in `/v1/health`. For + `/v1/admin/restart-ingestion`, sanitise `outcome.status.recentErrors` before + `successResponse`. `src/ingestion.ts` stays untouched. +4. `src/auth.ts`: fixed detail "Invalid or expired bearer token"; log the jose message at + `debug`. +5. OpenAPI (`src/openapi/`, and wherever `components.responses` is assembled; locate with + `rg -n "InternalError" src`): + - Reword the `InternalError` description to "sanitised message plus a reference id that + matches the server log". + - Reword the `ServiceUnavailable` description to include chain RPC unavailability. + - Add a `PayloadTooLarge` (413) response and add it to `errorRefs.mutate`. + - Run `npm run regen:openapi`. +6. Tests, sized like the neighbouring files: + - `tests/http.test.ts`: replace "exposes the thrown message…" with sanitisation cases + built from recorded ethers error shapes: a `CALL_EXCEPTION` with `transaction=`, a + `SERVER_ERROR` with `info.requestUrl` containing a path and credentials, and a non-Error + throw. Add `RpcUnavailableError`, `RegistryResolutionError`, and + `{ code: 'NETWORK_ERROR' }` giving 503; a body-parser 400 and 413 shape; and the weak, + list, and `*` `If-None-Match` cases, including a stale tag in a list. + - `tests/app.test.ts`: `POST /v1/bidders` with `{bad` gives 400 ProblemDetails; + `GET /v1/nope` gives 404 `application/json` ProblemDetails. + - `tests/auth.test.ts`: an invalid token's detail does not contain the jose text. + - A health sanitisation unit test on the mapping helper. + +### Verification Stop + +- The package gate is green. `git diff openapi.json` shows only the response-description and + 413 changes. +- Against the redeployed local build: `POST` with bad JSON gives 400, and `/v1/nope` gives a + JSON 404. Only unit tests prove the chain-unavailable 503 in this slice. The live + dead-RPC proof comes in Phase 2, once `assertProviderReady` is bounded, because before that + a boot-time outage makes requests hang instead of throwing. + +### Failure Diagnosis / Fix Forward / Rollback + +- A handler that intentionally rethrows raw errors for its own mapping (the coupon route's + `describeRevert`, `mapCentralBankError`) must still see them first. The middleware only + sees what escapes, so if a mapped status changes unexpectedly, look for a handler that + swallowed its own mapping. +- The slice is self-contained, so rollback is a revert. + +### Exit Criteria + +- [ ] The acceptance rows for body-parser, 404, 503, sanitisation, 401, and ETag are met. +- [ ] No `logger.error("unhandled…")` for 202, 4xx, or 503 outcomes. A test spies on the + logger. +- [ ] `docs/KNOWN_ISSUES.md` "request-path chain reads bubble up as opaque 500s" is marked + resolved, pointing at this plan and noting the Phase 2 follow-through for + post-broadcast failures. + +## Phase 2: Per-Signer Transaction Sender (A9) + +### Goal + +No nonce collisions for any signer; no unbounded waits; honest outcomes for unconfirmed +transactions. + +### Scope + +New `src/tx-sender.ts`; `src/chain.ts`; `src/central-bank.ts`; `src/banks.ts`; +`src/banking-tbd.ts`; `src/bidder-bid.ts`; send wiring in `src/app.ts` and +`src/features/auctions/service.ts`; `src/operations.ts` (`classifyFailure`); `src/env-vars.ts`; +`src/http.ts` (504 mapping); `src/openapi/shared-responses.ts` (504 in `errorRefs.mutate`); +tests. + +### Steps + +1. `env-vars.ts`: add `NB_BOND_API_TX_WAIT_TIMEOUT_MS` (default 30 000) and + `NB_BOND_API_RPC_TIMEOUT_MS` (default 10 000), each with a comment in the existing style. +2. `src/tx-sender.ts`: + - `sendSigned(wallet, send)` with a per-checksummed-address `{ nonce, queue }` map. + - The nonce is resolved inside the queue. The wait is `tx.wait(1, timeout)`. + - Any error resets that address's nonce to `null`. + - An ethers `TIMEOUT` from `wait` becomes `TransactionUnconfirmedError { hash, signer, timeoutMs }`. + - Export `__resetTxSenderForTests()`. +3. `src/chain.ts`: + - The provider becomes `new JsonRpcProvider(fetchRequestWithTimeout(RPC_URL), undefined, { staticNetwork: true })`. + - `assertProviderReady()` races `getBlockNumber()` against `NB_BOND_API_RPC_TIMEOUT_MS` + and throws `RpcUnavailableError` on expiry. Requests queued behind the boot-time + network detection loop are never dispatched, so the fetch timeout alone would not end + them. + - `sendWithManagedNonce = (send) => sendSigned(wallet, send)`. + - Delete the old mutex and cache. +4. Migrate the send sites: + - `central-bank.ts`: each write becomes `sendSigned(getCbWallet(), (nonce) => fn(..., { nonce }))`; + `toTxRef` reads the returned receipt. + - `banks.ts`: `registerContract` uses the CB wallet through `sendSigned`. `deployTbd` + uses `sendSigned(bankWallet, async (nonce) => (await factory.deploy(..., { nonce })).deploymentTransaction()!)`, + and the address comes from `receipt.contractAddress`. Replace the "fresh keys start at + nonce 0" comment, because operator-supplied keys are not necessarily fresh. + `addBankToWnokAllowlist` already calls `addToAllowlist`. + - `banking-tbd.ts`: `sendTbdTx` calls `sendSigned(wallet, (nonce) => invoke(tbd, { nonce }))`. + The `invoke` callbacks forward overrides. + - `bidder-bid.ts`: `submitFn(..., { nonce })` inside `sendSigned(bidderWallet, ...)`. +5. Unconfirmed outcomes: + - `http.ts` maps `TransactionUnconfirmedError` to 504 ProblemDetails with the hash in + detail. Add a `GatewayTimeout` response to `errorRefs.mutate`, then regenerate + `openapi.json`. + - Bond and auction mutations (`POST /v1/bonds`, coupon payments in `app.ts`; create, + close, cancel, and finalise in the auctions service) catch `TransactionUnconfirmedError` + after `withOperationRecording` and throw `MutationAcceptedError({ transactionHash: + err.hash, blockNumber: null, resource })`. This gives the documented 202. Put it in a + small shared helper, for example `acceptIfUnconfirmed(resource, fn)` in + `application-errors.ts`, so the auctions service change is a wrapper, not a rewrite. + - `operations.ts` `classifyFailure`: take `txHash` from `err.hash` for + `TransactionUnconfirmedError`; the error text is "unconfirmed after N ms; may still mine". +6. Tests: + - A new `tests/tx-sender.test.ts` (about 150 lines) with a fake wallet whose provider + returns a scripted pending count and fake tx objects with controllable `wait`. Cover: + 1. Two concurrent sends for one address get `n` and `n+1`, and the second waits for the + first to confirm. + 2. Two addresses run in parallel. + 3. A never-resolving `wait` gives `TransactionUnconfirmedError` after a short + test-configured timeout, and the next send re-reads the nonce and proceeds. + 4. A pre-broadcast failure resets the cache and re-reads it. + 5. Keying is by checksummed address: two `Wallet` instances for one key share a queue. + - `tests/http.test.ts`: 504 mapping. + - `tests/operations.test.ts`: the unconfirmed hash is recorded. + - Existing `central-bank`, `banks`, `banking-tbd`, and `bidder-bid` tests are updated for + the override argument. + +### Verification Stop + +- The package gate is green. +- Live, after `./nb-bond-api.sh start`: two parallel `POST /v1/central-bank/wnok/mint` + requests both return 200 with consecutive-nonce transactions (check with + `cast tx nonce --rpc-url http://besu.cbdc-sandbox.local:8545`). Needs the operator's + OK to mint 2 units. +- A bid and a TBD Nordea mint fired in parallel both succeed. +- Local `npm run dev` with `RPC_URL=http://127.0.0.1:1`: `GET /v1/registry` and + `POST /v1/bonds` return 503 ProblemDetails. `/v1/registry` can take up to two timeouts, + because its event scan swallows the first failure. This is the live proof deferred from + Phase 1. + +### Failure Diagnosis / Fix Forward / Rollback + +- A `nonce too low` after the migration means some send path still bypasses `sendSigned`. + Find it with `rg -n "\.wait\(|deploy\(" src`; outside `tx-sender.ts` the list should be + empty. +- If `staticNetwork: true` masks a chain reset, the ingestion chain-identity check still + fails fast and the process restarts. Record it if it is observed. +- Rollback is a revert. There is no persisted state change. + +### Exit Criteria + +- [ ] `rg -n "\.wait\(" services/nb-bond-api/src` matches only `src/tx-sender.ts`. +- [ ] The A9 acceptance rows are met. + +## Phase 3: Liveness, Bounded Health, Chart Hardening (A11) + +### Goal + +Probes measure what they claim. Upgrades never run two pods on one volume. Configuration +changes roll the pod. Defaults reach existing sandboxes. + +### Scope + +`src/app.ts` (`/livez`, the health timeout helper), `helm/values.yaml`, +`helm/values.local.example.yaml`, `helm/templates/deployment.yaml`, `nb-bond-api.sh` +(comment only), `README.md`, `DEVELOPMENT.md` §7.8 and §7.10, tests. + +### Steps + +1. Get the readiness-semantics decision from the operator (`design.md` Decisions). +2. `app.ts`: + - Mount `app.get('/livez', ...)` before `express.json()` and the other middleware. + - Run the two chain blocks in `/v1/health` concurrently under one `withTimeout(..., 2000)` + budget. The readiness `timeoutSeconds: 4` leaves headroom above it. When the budget + expires, record `headReachable: false` or keep the zero addresses, as on errors. +3. `values.yaml`: add `strategy`, `livenessProbe` (`/livez`), `readinessProbe` (`/v1/health`, + `timeoutSeconds: 4`), `resources`, `securityContext` (plus `readOnlyRootFilesystem: + true`), and `podSecurityContext`, with the values from `design.md`. Add + `replicaCount: 1`, with a comment that it is the only supported value. +4. `deployment.yaml`: + - `strategy: {{ toYaml .Values.strategy | nindent 4 }}`. + - The replica guard (`fail` when `replicaCount > 1`). + - `checksum/config` and `checksum/secret` annotations on the pod template. + - A `tmp` `emptyDir` mounted at `/tmp`. +5. `values.local.example.yaml`: remove the probe and security blocks (they are now chart + defaults). Leave a pointer comment to `values.yaml`. Fix the "emptyDir" wording. +6. Fix the `nb-bond-api.sh` start-comment "emptyDir" wording. +7. Tests: + - `tests/app.test.ts`: `/livez` answers 200 without touching the provider (mock + `../src/chain` to throw on access). + - Health returns within the bound when a mocked provider never resolves. Use fake timers, + or a 2 s real wait if fake timers fight the promise race. +8. Chart proof, in a scratch directory: + - `helm template services/nb-bond-api/helm --set image=x --values services/nb-bond-api/helm/values.local.example.yaml`. + Check that `strategy.type` is `Recreate`, the annotations are present, `/livez` is the + liveness path, and `readOnlyRootFilesystem` is `true`. + - `--set replicaCount=2` fails. + +### Verification Stop + +- Rollout needs operator go-ahead for the regeneration: back up `values.local.yaml`, run + `node scripts/generate-local-sandbox-fixtures.mjs --force` (safe per Phase 0), then + `./services/nb-bond-api/nb-bond-api.sh start`. +- `kubectl -n nb-bond-api get deploy nb-bond-api -o jsonpath='{.spec.strategy.type}'` gives + `Recreate`. The pod has resources, the pod annotations have the checksums, and + `kubectl get events` shows no probe failures after 5 minutes. +- `kubectl exec` writes to `/app/data` and `/tmp` succeed, and a write to `/app` fails. +- The UI health badge is green. +- Change only `env.NB_BOND_API_SSE_HEARTBEAT_MS` in `values.local.yaml`, rerun `start`, and + check that the pod is recreated. Revert the value afterwards. +- Optional, with go-ahead: scale Besu to zero, confirm there are no liveness restarts for + 3 minutes and that `/v1/health` reports `down`, then start Besu. + +### Failure Diagnosis / Fix Forward / Rollback + +- `EROFS` in the logs means a process writes outside `/app/data` and `/tmp`. Add the missing + path as an `emptyDir` or record it. Do not drop `readOnlyRootFilesystem` silently. +- The pod is `OOMKilled` means the memory limit is too tight. Raise it from the measured peak + plus headroom, and record the value. +- Rollback: `helm rollback nb-bond-api -n nb-bond-api` and restore the backed-up + `values.local.yaml`. The PVC is untouched either way. + +### Exit Criteria + +- [ ] The A11 acceptance rows are met with live evidence in `progress.md`. +- [ ] README "Local Deployment Model" and DEVELOPMENT §7.8/§7.10 describe `/livez`, + `Recreate`, the single-replica rule, and the new env vars. + +## Phase 4: Runtime Image (A14) + +### Goal + +The runtime ships only nb-bond-api's production tree and starts without shell workarounds. +Optionally, it runs on a smaller base. + +### Scope + +4a: `Dockerfile`, `src/app.ts` (handle order), `README.md`. 4b (approval-gated): +`common/node-version.env`, `common/helpers.sh`, +`scripts/verification/check-node-version-consistency.py`, `docs/THIRD_PARTY_NOTES.md`, +`THIRD_PARTY_LICENSES.md`. + +### Steps (4a) + +1. Add a `deps` stage (or builder step) that produces an nb-bond-api-only production + `node_modules` using the Phase 0 command. The runtime copies from it instead of the full + pruned root. +2. In `createApp()`, open `biddersDb` (writable, which creates the file, the schema, and WAL) + before `historyDb` (read-only). Change `CMD` to `["node", "/app/services/nb-bond-api/dist/index.js"]`. + Keep `RUN mkdir -p /app/data`. Coordinate with the ingestion correctness plan: if it + already restructures handle opening, take its version and only drop the `touch`. +3. Fix the Dockerfile comments that say the chart mounts an `emptyDir`. +4. Test: `tests/app.test.ts` gets one case where `createApp()` with a non-existent file + `DB_PATH` starts cleanly. It uses a scratch temp directory, not `:memory:`. + +### Steps (4b, only after explicit approval) + +1. Add `SANDBOX_NODE_RUNTIME_SLIM_IMAGE=node:26.5.0-slim` to `common/node-version.env`. +2. In `common/helpers.sh`, set `NB_BOND_API_RUNTIME_BASEIMAGE=$SANDBOX_NODE_RUNTIME_SLIM_IMAGE`. +3. In `check-node-version-consistency.py`, require the runtime to derive from the new + variable, and require the slim tag to equal `node:${SANDBOX_NODE_VERSION}-slim`. +4. `./infra/infra.sh registry-sync` (operator-run) so the offline build works. +5. Update the `node` bullet in `docs/THIRD_PARTY_NOTES.md` and the Node.js row notes in + `THIRD_PARTY_LICENSES.md`, then run the licence check. + +### Verification Stop + +- `docker run --rm --entrypoint ls /app/node_modules` shows no `react`, `react-dom`, + or nb-ui auth package. Record the before and after image size and `node_modules` size. +- The container starts on an empty volume and `/v1/health` answers. +- `python3 scripts/verification/check-node-version-consistency.py` and + `bash scripts/verification/check-image-hash-inputs.sh` pass. For 4b, also run + `python3 scripts/verification/check-third-party-licenses.py`. + +### Failure Diagnosis / Fix Forward / Rollback + +- `Cannot find module` at start means the workspace-scoped install missed a hoisted + dependency. Switch to the `npm ls --parseable` copy approach from Phase 0. +- On slim, a `better-sqlite3` load error means a glibc mismatch. Stay on the full image and + record why. 4a stands alone. + +### Exit Criteria + +- [ ] The A14 acceptance row is met. +- [ ] 4b is either merged with approval or recorded as declined in `progress.md`. + +## Phase 5: Cleanup and TBD Bytecode Refresh + +### Goal + +Remove the verified leftovers, and fix the one artifact defect. + +### Scope + +`src/abi/TbdBytecode.json`, `src/parsing.ts`, `src/contracts/common.ts` (param schemas), +`src/app.ts`, `src/bidders.ts`, `src/operations.ts` (comment), `src/env-vars.ts` (comment), +`.env.example`, `README.md` (Env), tests. + +### Steps + +1. `TbdBytecode.json`: after `forge soldeer install && forge build` in `contracts/`, write + `bytecode.object` from `contracts/out/Tbd.sol/Tbd.json` into the `bytecode` field, and + keep `_comment`. Confirm that `Tbd.json`'s ABI is unchanged. +2. `parsing.ts`: `parsePositiveUint256(value, field)` throws `badRequest` for zero or for + anything above `2^256 − 1`. Replace the six amount blocks in `app.ts`. +3. `contracts/common.ts`: `addressParamSchema` (`{ address }`) and `tbdHolderParamSchema` + (`{ address, holder }`) built from `addressSchema`. Replace the eight regex checks with + `validateRequest(..., 'params')`. The 400 body changes from "Bad Request / address must + be…" to "Validation failed" with `errors[]`. That is contract-compatible, and nb-ui + renders both. +4. `bidders.ts`: in `normalizePrivateKey`, reject scalar `0` and `>= SECP256K1_ORDER` with + `BidderValidationError`. Wrap the delete and insert in + `reconcileFixtureBidderOverrides` in `db.transaction(...)()`. +5. `operations.ts`: add the legacy comment above `'REDEMPTION'`, matching + `src/contracts/operations.ts`. The value stays in both lists (see `design.md`). +6. `app.ts`: + - Move the "Central Bank is operator-only" comment to the `app.use('/v1/central-bank', ...)` + line. + - Delete the redundant `app.use('/v1/banking', requireAnyRole(recognizedRoles))`, keeping + the explanatory comment next to the global gate. Skip this item if the separate hardening change is open, because it owns role gates. +7. `env-vars.ts`: reword the `NB_BOND_API_CLOSE_GAS_LIMIT` comment. It is a fallback for a + stale-head `eth_estimateGas` false revert, kept because empty blocks can still lag wall + clock by up to `emptyblockperiodseconds`. Drop "during the migration". +8. `.env.example`: add every variable in `env-vars.ts`, grouped and commented, with the + secrets empty. Add `README.md` Env entries for `NB_BOND_API_CLOSE_GAS_LIMIT`, + `NB_BOND_API_TX_WAIT_TIMEOUT_MS`, and `NB_BOND_API_RPC_TIMEOUT_MS`, plus any other + variables missing there. +9. Tests: + - `parsing.test.ts`: uint256 bounds. + - `bidders.test.ts`: a zero key and the order value give `BidderValidationError`, and + the reconcile is atomic (a stubbed insert that throws leaves the original row). + - `app.test.ts`: a bad address param gives 400 `Validation failed`. + +### Verification Stop + +- The package gate is green. The hygiene check passes: `.env.example` is on its allowlist, + so it contains no key-shaped values. +- The live create-bank flow deploys a TBD, and `cast code ` matches the runtime part of + the new artifact. This needs an operator go-ahead because it creates a bank. + +### Failure Diagnosis / Fix Forward / Rollback + +- A byte mismatch after refresh means `contracts/out` came from a different + `foundry.toml`. Rebuild with the committed config. +- Rollback is a revert. Banks already created keep their deployed code. + +### Exit Criteria + +- [ ] Each cleanup bullet is done or explicitly skipped with a reason in `progress.md`. + +## Phase 6: Drift Guards (Optional, Operator Approval) + +### Goal + +Make the two generated artifacts fail CI when they drift. + +### Steps + +1. OpenAPI: add a `tests/openapi-contract.test.ts` case that compares + `JSON.parse(readFileSync('openapi.json'))` with + `JSON.parse(JSON.stringify(openApiDocument))` and fails with "run npm run regen:openapi". + It runs inside the existing `format-lint-test` job. +2. ABI and bytecode: add `scripts/verification/check-nb-bond-api-abi-sync.py`, which compares + `abi` for the six contracts, and `bytecode.object` for `Tbd`, between + `contracts/out/.sol/.json` and `services/nb-bond-api/src/abi/`. In + `.github/workflows/test-contracts.yml`, add `services/nb-bond-api/src/abi` to + `sparse-checkout`, a step after "Run Forge build", and `services/nb-bond-api/src/abi/**` + to `paths`. Document the script in `scripts/verification/README.md`. + +### Verification Stop + +Both checks pass on the branch, and fail when one ABI entry is hand-edited in a scratch +commit that is never pushed. + +### Exit Criteria + +- [ ] Approved guards are merged, or declined with a reason recorded. + +## Phase 7: Route-Module Extraction (Optional, Separate Go-Ahead) + +### Goal + +Shrink `src/app.ts` (1526 lines, every route inline) into per-feature route modules, with no +behaviour change. + +### Approach + +- Follow the auctions precedent and extend it: `src/features//routes.ts` exports + `createRouter(deps)` (an Express `Router`). Where there is logic, + `service.ts` holds it. +- `createApp()` keeps middleware order, public routes, the auth gates, the 404 fallback, and + the error middleware, and mounts the routers. +- One PR per feature, in order: `central-bank`, `banking`, `bidders`, `system` (registry, + operations, admin, events), `bonds`, then moving the auction routes next to the existing + auction service. +- Start only after the separate hardening change and Phases 1–5 have merged, so the + refactor moves settled code. +- Proof per slice: the full jest suite unchanged; `openapi.json` unchanged; `tests/app.test.ts` + still green; a live smoke of that feature's pages in nb-ui. + +## Phase 8: Close-Out + +1. `docs/DOCUMENTATION_INDEX.md`: add this plan folder in the same PR as Phase 1, or as soon + as this plan is committed. +2. `docs/KNOWN_ISSUES.md`: resolved entry (Phase 1). Add new entries only for accepted + follow-ups. +3. When Phases 1–5 are merged, and 6 and 7 are merged or declined, move the folder to + `docs/plans/archive/`, set `Status: Implemented`, and update the index. + +## Test Matrix + +| Layer | Risk or behaviour | Test/evidence | +|---|---|---| +| Unit: tx sender | Same-address serialisation, cross-address parallelism, wait timeout, reset on failure, address keying | `tests/tx-sender.test.ts` | +| Unit: error text | ethers payload stripping, URL redaction, length cap | `tests/http.test.ts` sanitiser cases | +| Unit: classifier | 400/413 body-parser, 503 chain-unavailable, 504 unconfirmed, 500 with ref, 202 unchanged, logging level | `tests/http.test.ts` | +| Unit: ETag | Weak, list, `*`, stale-in-list | `tests/http.test.ts` | +| Unit: validation | uint256 bounds, address params, bidder key range, atomic reconcile | `parsing`, `bidders`, `app` tests | +| API: app | JSON 404, bad JSON 400, `/livez` without chain, bounded health | `tests/app.test.ts` | +| API: auth | Generic `entra` 401 | `tests/auth.test.ts` | +| API: contract | Response text, 413 and 504 refs; optional snapshot | `openapi.json` diff; Phase 6 test | +| Audit trail | Unconfirmed hash recorded | `tests/operations.test.ts` | +| Chart | Recreate, guard, checksums, probes, read-only root | `helm template` in a scratch directory | +| Image | No nb-ui deps; starts without `touch`; slim loads native binding | `docker run` checks | +| Local runtime | Parallel same-key mutations; config change rolls pod; no probe failures; optional Besu-down | Phase 2 and Phase 3 verification stops | +| Artifact | `TbdBytecode.json` equals `forge build` output | Phase 5 compare; Phase 6 script | + +## Recommended PR Slices + +| Slice | Architectural outcome | Main proof | Temporary compatibility/cleanup | +|---|---|---|---| +| 1 `feature/nb-bond-api-error-envelope` | One classifier; honest 4xx, 503, 500; JSON 404; sanitised health; weak ETags | http, app, and auth tests; openapi diff | Closes the known-issue entry. 504 lands in slice 2 | +| 2 `feature/nb-bond-api-signer-queue` | Per-address sender; bounded waits; 202/504 for unconfirmed | `tx-sender` tests; live parallel mints | `sendWithManagedNonce` kept as an alias | +| 3 `feature/nb-bond-api-probes-chart` | `/livez`; bounded health; Recreate; guard; resources; checksums; read-only root | `helm template`; live events | One-time `values.local.yaml` regeneration | +| 4a `feature/nb-bond-api-runtime-deps` | Scoped runtime deps; no `touch` | `docker run` checks | None | +| 4b `feature/nb-bond-api-slim-runtime` | Slim base (approval) | Node consistency and licence checks | None | +| 5 `feature/nb-bond-api-cleanup` | Parsing helpers; key range; atomic reconcile; comments; `.env.example`; TBD bytecode | Unit tests; bytecode compare | `/v1/banking` gate removal may defer to the separate hardening change | +| 6 `feature/nb-bond-api-drift-guards` | Snapshot and ABI guards (approval) | Guard passes, and fails on a scratch edit | None | +| 7.x `feature/nb-bond-api-routes-` | Route modules (go-ahead) | Unchanged suite and spec | None | + +## Migration, Rebuild, and Rollout + +- Data/schema version effect: none. No SQLite schema change. System-of-record tables are + untouched. +- Local rebuild/restart path: `./services/nb-bond-api/nb-bond-api.sh start` per slice. Slice 3 + needs the one-time `values.local.yaml` regeneration first. Slice 4b needs + `./infra/infra.sh registry-sync` first. +- Compatibility window: none needed. nb-ui already renders `detail` and handles 202. +- Non-local deployment note: the new env vars have defaults. That deployment must keep one + replica, and must carry the chart defaults (strategy, probes, read-only root, resources) if + it overrides values. +- Rollback or fix-forward boundary: every slice reverts cleanly. The TBD bytecode refresh + affects only banks created afterwards. + +## Documentation and Public-Repo Hygiene + +- Docs to update, in the slice that changes the behaviour: `services/nb-bond-api/README.md` + (Env, Local Deployment Model, `/livez`), `services/nb-bond-api/DEVELOPMENT.md` (§7.8 + health, §7.10 chart table, and the error-semantics description where 500/503 are + described), `docs/KNOWN_ISSUES.md`, `docs/DOCUMENTATION_INDEX.md`, and + `scripts/verification/README.md` (Phase 6). +- Third-party/licence inventory impact: only slice 4b. +- Examples use placeholders only, and fixture keys are referenced by file path. + +Verification: + +```bash +python3 scripts/verification/check-public-repo-hygiene.py +python3 scripts/verification/check-markdown-links.py +# Slice 4b only: +python3 scripts/verification/check-third-party-licenses.py +python3 scripts/verification/check-node-version-consistency.py +``` + +## Out of Scope + +- Items owned by a separate hardening change, including role gating on routes. +- The ingestion and projection correctness plan's items, including `src/ingestion.ts` + provider settings and its database handle. +- Contract changes, `application/problem+json` content type, an `UNCONFIRMED` audit status, + and multi-replica support. + +## Done Criteria + +- [ ] Every acceptance criterion in `intent.md` has evidence in `progress.md`. +- [ ] The `sendWithManagedNonce` alias is either kept deliberately or removed in slice 7. +- [ ] Package gate, hygiene, and link checks pass. The licence and node checks pass for 4b. +- [ ] README, DEVELOPMENT, and KNOWN_ISSUES match the implemented behaviour. +- [ ] PR bodies contain no private environment information. diff --git a/docs/plans/nb-bond-api-hardening/progress.md b/docs/plans/nb-bond-api-hardening/progress.md new file mode 100644 index 0000000..12e3084 --- /dev/null +++ b/docs/plans/nb-bond-api-hardening/progress.md @@ -0,0 +1,135 @@ +# nb-bond-api operational hardening — Progress + +**Plan:** [`plan.md`](plan.md) +**Last updated:** 2026-09-28 — plan drafted; findings re-verified against code and the live sandbox +**Current phase:** Not started +**Next action:** Operator reviews `intent.md` and the `design.md` Decisions table, in +particular the readiness semantics, the signer-lock scope, and the 500 detail policy. Then run +Phase 0 step 1 (the package gate baseline) and step 2 (the local dead-RPC health timing) on a +`feature/nb-bond-api-error-envelope` branch. + +## Phase Log + +| Phase | Status | Evidence | PR | +|---|---|---|---| +| 0 — Baseline and characterisation | Not started | | | +| 1 — Error envelope and contract text | Not started | | | +| 2 — Per-signer transaction sender | Not started | | | +| 3 — Liveness, bounded health, chart | Not started | | | +| 4a — Runtime dependencies and `touch` removal | Not started | | | +| 4b — Slim runtime base (approval) | Not started | | | +| 5 — Cleanup and TBD bytecode refresh | Not started | | | +| 6 — Drift guards (approval) | Not started | | | +| 7 — Route-module extraction (go-ahead) | Not started | | | +| 8 — Close-out | Not started | | | + +## Deviations From the Plan + +Deviations from the review findings, recorded while the plan was written: + +- **Lock release after broadcast** was not adopted. The per-signer lock is held through + confirmation with a timeout instead. The reason is in `design.md` Alternatives: a 1-second + block period gives little pipelining gain, and releasing early would let preflights run + against stale same-signer state. +- **`REDEMPTION` removal** is corrected to a clarifying comment. The value is deliberately + kept (#276) so stored `operation_attempts` rows, a preserved system-of-record, still + validate. Removing it would change the contract and break those rows. +- **"ABI in sync today"** holds for the six ABIs, but `TbdBytecode.json` is stale: it was + written in #200, before #232 changed the compiler, EVM target, and OpenZeppelin version. + Its refresh is added to Phase 5. +- **Sharing the ingestion provider** is handed to the ingestion correctness plan. Backfill + `getLogs` needs a different timeout than request paths, and `src/ingestion.ts` is that + plan's file. +- **`/livez` and OpenAPI:** kept outside the contract, following the `/docs` precedent. +- **Chart defaults** move from `values.local.example.yaml` to `values.yaml`. This was not in + the review. It was found while checking how the example reaches a sandbox: it is copied + once, so edits never apply to an existing `values.local.yaml`. + +## Verified So Far + +- Only `BOND_ADMIN` sends use a managed nonce. The CB, bank TBD, bidder, and created-bank + paths call ethers without a nonce and `tx.wait()` without a timeout. Verified by reading + `src/chain.ts`, `src/central-bank.ts`, `src/banks.ts`, `src/banking-tbd.ts`, and + `src/bidder-bid.ts` on 2026-09-28. +- The Nordea bank signing key and the Nordea bidder key are the same fixture role key. + Verified through `configuredBankSigningKey` and `FIXTURE_ROSTER` on 2026-09-28. +- Malformed JSON to `POST /v1/bidders` returns 500 with the parser message, and + `GET /v1/nope` returns `404 text/html`. Verified live against + `bond-api.cbdc-sandbox.local` on 2026-09-28. +- The gateway passes a strong `ETag` without `Content-Encoding`. Verified with `curl -D -` + on 2026-09-28. +- The live pod restarted after `SandboxChanged`. Readiness and liveness probes on + `/v1/health` failed with "context deadline exceeded" (`timeoutSeconds: 1`). The strategy is + `RollingUpdate` 25%/25% and `resources` is `{}`. Verified with read-only `kubectl` on + 2026-09-28. +- Container memory is about 182 MiB current and 203 MiB peak. The runtime `node_modules` is + 96 MB and contains `react`, `react-dom`, and nb-ui's browser-auth package. The image is + about 2.0 GB. Verified with `kubectl exec` (read-only) and `docker image inspect` on + 2026-09-28. +- In ethers 6.17, the `JsonRpcProvider` boot loop retries network detection every second and + holds queued requests. Verified by reading `node_modules/ethers/lib.commonjs/providers/provider-jsonrpc.js` + on 2026-09-28. +- `openapi.json` equals a fresh generation from `src/schemas.ts`. Verified by generating to a + scratch file and running `diff` on 2026-09-28. +- The six ABIs match `contracts/out`, and `TbdBytecode.json` does not match (at 13 815 + differing hex positions). The `contracts/out` build from 2026-09-25 postdates the last + contracts change (2026-09-14). Verified locally on 2026-09-28. +- `check-node-version-consistency.py` pins the nb-bond-api runtime base to + `SANDBOX_NODE_IMAGE`. Verified by reading the script on 2026-09-28. +- The generator copies `values.local.example.yaml` to `values.local.yaml` only when missing, + unless `--force`. The live file already differs from the example. Verified by reading the + generator and diffing on 2026-09-28. + +## Blocked / Waiting On + +- Readiness-semantics decision, needed before Phase 3. Owner: sandbox operator. +- Approval for slim runtime image (4b), drift guards (6), and route extraction (7). Owner: + sandbox operator. +- Go-ahead for the live mutations used as proof (parallel WNOK mints, parallel bid plus TBD + mint, bank creation, optional Besu scale-down), and for the one-time `values.local.yaml` + regeneration. Owner: sandbox operator. +- Sequencing with a separate hardening change (route role gates, `auth.ts`) and the + ingestion correctness plan (`docs/plans/ingestion-projection-correctness/`, which covers + `src/ingestion.ts` and database handle opening). Both edit + `src/app.ts`, so land PRs one at a time and rebase. + +## Follow-ups Found Along the Way + +- Error responses are sent as `application/json` rather than `application/problem+json`, + which differs from the documented RFC 7807 convention. Proposed handling: a separate small + change coordinated with the nb-ui client. +- An `UNCONFIRMED` operation status would describe timed-out transactions more honestly than + `FAILED`. It needs an `OperationStatus` enum change. Proposed handling: follow-up if + timeouts are ever observed. +- `helm/templates/pvc.yaml` cites a `docs/KNOWN_ISSUES.md` entry ("bidders disappear on helm + upgrade") that no longer exists. Proposed handling: fix the comment in the chart slice if + the reviewer agrees, otherwise a docs sweep. +- The close-auction fallback retries with an explicit gas limit and so skips simulation. It + was already recorded in `docs/plans/archive/backend-design-improvements-backlog.md`, and is + not addressed here. +- Some pre-existing committed comments in `src/env-vars.ts`, `helm/values.yaml`, + `README.md`, and `DEVELOPMENT.md` name the non-local deployment tooling. Proposed handling: + a public-repo wording sweep, outside this plan. +- The ingestion provider (`src/ingestion.ts:41`) has no `staticNetwork` and no request + timeout. It is handed to the ingestion correctness plan. + +## Changes Since Planning (2026-09-28) + +Changes merged after this plan was written (#300, #302, #304, #305). +Effects on this plan: + +- `src/abi/TbdBytecode.json` was regenerated from a current `forge build` in #300, so the Phase 5 + bytecode refresh is done; keep only the optional Phase 6 drift guard. +- Route role gates for bond and auction mutations and the route-authorization suite landed in + #305, so the Phase 7 prerequisite ("after the security change lands") is met. The redundant + `app.use('/v1/banking', ...)` gate is still present and stays in Phase 5. +- The `testMode` descriptions in `src/contracts/{bonds,auctions}.ts` no longer name deployment + tooling (#304); the names remain in `src/env-vars.ts` and `src/auth.ts` comments. +- `app.ts` line references in `design.md` predate these changes; re-check them in Phase 0. + +## Session Handoff + +- No code has changed. The plan was written against `development` at `db92409`. +- Scratch artifacts from verification (the regenerated OpenAPI) live outside the repo and are + not needed to resume. +- A local `contracts/out` build exists (2026-09-25). Phase 0 step 6 rebuilds it anyway. diff --git a/docs/plans/nb-ui-correctness-and-hardening/design.md b/docs/plans/nb-ui-correctness-and-hardening/design.md new file mode 100644 index 0000000..112f87c --- /dev/null +++ b/docs/plans/nb-ui-correctness-and-hardening/design.md @@ -0,0 +1,322 @@ +# NB UI correctness and hardening — Design + +**Status:** Draft +**Created:** 2026-09-28 +**Intent:** [`intent.md`](intent.md) +**Builds on:** [`archive/redeem-with-final-coupon/`](../archive/redeem-with-final-coupon/) +(ADR 0005 lifecycle), [`archive/coupon-closure-review-fixes/`](../archive/coupon-closure-review-fixes/) +(work-queue Coupon payout page), the bond soft-delete pattern (`DELETE /v1/bonds/{isin}` + +`includeDisabled`). + +## Decision Summary + +Fix each finding in the layer that owns it, correctness first, then serving, then cleanup. + +- **Live data is the source of truth for a dialog.** Modals hold an identifier, never a row + snapshot, and re-select from the live query on every render (U2). +- **The HTTP client owns the reaction to 401.** `httpClient.js` already owns the + `Authorization` header; it also becomes the single place that calls + `auth.handleUnauthorized()`, for REST and for the stream (U7). +- **Failures stay local.** The router never throws; a page-level error boundary keyed by route + keeps navigation alive, and a root boundary catches the rest (U4). +- **Bidder removal is a soft delete in the system-of-record table.** An additive + `disabled_at` column, no schema-version bump, no projection rebuild; the API mirrors the bond + pattern (`DELETE` disables idempotently, `includeDisabled` lists); the UI warns about + holdings computed from data it already loads (U8). +- **History is shown where the lifecycle lives.** A card on the bond detail page, reading the + existing history endpoint once its ordering is fixed elsewhere (U3). +- **Serving policy lives in chart values.** One ConfigMap renders both `config.js` and the + nginx server config, so CSP origins derive from the same `runtimeConfig` values and the + existing checksum annotation rolls the pod on any change (U9). + +```mermaid +flowchart LR + V["helm values: runtimeConfig + web"] --> CM["ConfigMap: config.js + default.conf"] + CM --> NG["nginx: headers, cache policy"] + API["NB Bond API: bonds, history, bidders"] --> HC["httpClient: ETag, auth header, 401"] + SSE["/v1/events"] --> HC + HC --> LQ["useLiveQuery"] + LQ --> P["Pages and modals (select by id)"] + EB["ErrorBoundary per route"] --- P +``` + +## Current-State Evidence + +All `Verified` items were read in the repository on 2026-09-28 at `db92409`. Line numbers +refer to that commit. + +### Repository Evidence + +| Finding | Evidence | Consequence | +|---|---|---| +| **Verified.** Tile counts start as `{ total, staged, auctioning, outstanding }` and only increment known keys; there is no Matured tile, although the filter offers `matured` | `src/pages/BondsPage.jsx:66-72`, `:114-131`, `:149`; API enum `staged, auctioning, outstanding, matured` in `services/nb-bond-api/src/contracts/bonds.ts:17` | Every matured bond counts in Total but in no tile | +| **Verified.** The row object is stored (`setPayTarget(b)`) and passed as `bond`; the modal derives `holders`, `isFinal`, `rateBps`, and totals from that prop | `src/pages/CouponPayoutPage.jsx:227`, `:241`; `src/pages/PayCouponModal.jsx:47-67` | Live refreshes replace `data` but not `payTarget`; the preview can show interim amounts for what is now the final period, or old holders, while the server pays the current holder set (`holders: null`) | +| **Verified.** Hint and empty state promise coupon history "on the Bonds page"; Bonds and bond detail show only `made/total`; `BondsApi.listBondHistory` has no caller | `CouponPayoutPage.jsx:110-115`, `:150`; `src/api/bondsApi.js:41-46`; `grep listBondHistory src` | The promised history is unreachable in the UI | +| **Verified.** History endpoint fetches `limit × 4` rows per table `ORDER BY block, id` (oldest first), then sorts descending and slices; `before` is applied after the fetch | `services/nb-bond-api/src/compose.ts:117-148`; `ingestion-db.ts:489-503`, `:547-562` | For a bond with more than 400 rows in one table the newest rows (the closure) are missing, and paging by `before` can return nothing. Owned by `ingestion-projection-correctness` | +| **Verified.** `parseHash` calls `decodeURIComponent` unguarded; `useRoute` calls it in the `useState` initialiser and in the `hashchange` listener; no error boundary exists anywhere | `src/hooks/useRoute.js:24`, `:28`, `:55`, `:58`; `grep -r ErrorBoundary src` empty; `src/main.jsx` | On load with `#/bonds/%E0` the throw happens during render and React unmounts the root (blank page). On a later `hashchange` the throw happens in the listener, so the route silently does not change until a reload, which then blanks the page | +| **Verified.** `request()` throws `HttpError` on any non-2xx and never calls the auth provider; only the stream loop calls `auth.handleUnauthorized()` on 401 and returns silently on 403 | `src/api/httpClient.js:143`; `src/sync/LiveUpdatesProvider.jsx:71-75` | With `LIVE_UPDATES=false`, or when a REST call is the first to see the expired token, the operator sees "HTTP 401" toasts and error cards instead of the login page. A 403 on the stream ends live updates with no indication | +| **Verified.** `entraAuth.handleUnauthorized()` is idempotent (`markSessionExpired` guards and notifies once); `noneAuth.handleUnauthorized()` is a no-op | `src/auth/entraAuth.js:130-135`, `:219-221`; `src/auth/noneAuth.js:23` | Calling it from the client on every 401 is safe in both modes | +| **Verified.** UI delete is `window.confirm` then `BiddersApi.deleteBidder`; API route checks only unrevealed sealed bids on open auctions, then `DELETE FROM bidders` | `src/pages/BiddersPage.jsx:65-80`; `services/nb-bond-api/src/app.ts:806-833`, `:896-924`; `ingestion-db.ts:704-707` | The signing key is gone while the address may still hold bond units (and keep receiving coupons) or wNOK. The page header says delete is "hard-blocked when on-chain bids reference them" (`BiddersPage.jsx:7`), which overstates the guard | +| **Verified.** `bidders` is a system-of-record table created with `CREATE TABLE IF NOT EXISTS`, excluded from projection drops; `SCHEMA_VERSION` bumps drop and rebuild projection tables only; there is no `ALTER TABLE` precedent | `ingestion-db.ts:38-50`, `:284-293`, `:407-446` | A new column needs an idempotent additive step, not a version bump | +| **Verified.** Seeding inserts the fixture roster only when `COUNT(*)` is 0; the fixture-override reconcile replaces a row by delete plus insert | `services/nb-bond-api/src/bidders.ts:181-200`, `:214-243` | Today, hard-deleting every bidder reseeds the fixtures on the next boot. With soft delete the count stays non-zero; the reconcile must carry `disabled_at` into the replacement row | +| **Verified.** No route-level bidder tests exist; `tests/bidders.test.ts` covers the service functions against an in-memory DB | `grep -rn v1/bidders services/nb-bond-api/tests` empty | U8 adds service tests plus the route cases that need no chain | +| **Verified.** Coupon payout page reads `{ data, loading, error, reload }` only; `refreshError` is dropped | `CouponPayoutPage.jsx:43-47`; `useApi` returns `refreshError` (`src/hooks/useApi.js:40-44`) | A failed background refresh leaves stale rows with no signal | +| **Verified.** No custom nginx config; image is `nginxinc/nginx-unprivileged:1.30.3-alpine` by tag | `Dockerfile:19`, `:41-45`; `common/images.yaml:54-55` | No CSP, `X-Frame-Options`, `nosniff`, `Referrer-Policy`; `Server` header shows the version; `index.html` and `config.js` get heuristic browser caching | +| **Verified.** `vite build` emits source maps; the current `dist/` has two `.map` files of about 1.2 MB each; `public/*` is copied into `dist/`, so `config.template.js`, `config.js`, and the logo ship in the image | `vite.config.js:10`; `ls services/nb-ui/dist` | Image carries maps and dead files; the Dockerfile comment "we don't ship a default config.js" is wrong (the mount masks the shipped copy) | +| **Verified.** `LIVE_UPDATES` is emitted unquoted; other values use `quote` | `helm/templates/configmap.yaml:21` | `--set-string runtimeConfig.liveUpdates='x; …'` becomes executable JavaScript in `config.js` | +| **Verified.** Deployment has no `resources` and no `automountServiceAccountToken` | `helm/templates/deployment.yaml` | Unbounded pod; a service-account token mounted into a static file server that never calls the Kubernetes API | +| **Verified.** Fonts load at runtime from a third-party CDN | `index.html:7-12`; stacks fall back to system fonts (`src/styles.css:47-49`) | A CSP must allow those origins, and offline or privacy-restricted browsers fall back silently | +| **Verified.** Unused code: selectors `selectBond`, `selectAuction`, `selectAuctionInList`, `selectHolders`, `selectBids`, `selectAllocation`, `selectLatestAuctionTx` (and `selectAllAuctions` is used only inside `selectors.js`); `Fmt.durationToYears`; CSS `.divider`, `.lc-decision`, `.row-between`, `.section-title`, `.stack-2/3/4`; `public/norges-bank-logo.svg` referenced only by a comment | `grep` over `src` and `tests` | Dead surface; `AGENTS.md` still documents the selectors | +| **Verified.** Finalisation sends `testMode`; the API route never reads it and the OpenAPI operation does not declare it | `src/api/auctionsApi.js:59-64`; `services/nb-bond-api/src/contracts/auctions.ts:266-279`; `app.ts:729` | Undeclared parameter; misleads readers into thinking test mode affects finalisation | +| **Verified.** Central Bank tile reads "pays coupon, buyback, redemption"; Banking page shows a "Government-nominated" KPI reading `token.government.nominated` | `src/pages/CentralBankPage.jsx:190`; `src/pages/BankingPage.jsx:293-311`; fixtures in `tests/BankingPage.test.jsx:32`, `:43` | Pre-ADR-0005 wording; the tile throws a `TypeError` if the API drops `government` first | +| **Verified.** Stale docs: `AGENTS.md` (selectors list, `config.template.js` for envsubst, init container in the checklist, `reopenAuction` pattern), `public/config.js:2-4`, `DEVELOPMENT.md` (envsubst paragraph, `nginx-unprivileged:1.27`, "CSP from defaults is permissive enough", `reopenAuction` follow-up, a mislabelled link at line 33), `Dockerfile:43-45` | files as listed | Docs describe a deployment shape and a feature state that no longer exist. The `docs/KNOWN_ISSUES.md` reopen entry and the nb-ui cloud-wording lines are owned by the `documentation-refresh` plan | +| **Verified.** Duplication: "Not authorised" block three times; `isMutationAccepted` toast branch in five handlers; `formatNok` and `formatWnok` differ only by the ×1000 and the `''` case; `UNIT_NOMINAL` hard-coded as `1000n` and `* 1000`; `window.confirm` three times; the Show-disabled storage key literal duplicated | `App.jsx:101-136`; `BondsPage.jsx:76`, `AuctionsPage.jsx:80`, `CouponPayoutPage.jsx:81`, `AuctionDetailPage.jsx:64,81,102`; `src/utils/format.js:30-49`; `PayCouponModal.jsx:26`; `BiddersPage.jsx:66`, `BankingPage.jsx:117`, `CentralBankPage.jsx:53`; `BondDetailPage.jsx:72` vs `BondsPage.jsx:20` | Low-risk drift; optional phase | + +### Runtime Evidence + +- **Verified:** `npx vitest run` in `services/nb-ui`: 25 files, 125 tests pass (2026-09-28). +- **Needs verification:** the nginx-unprivileged image's `/etc/nginx/conf.d/default.conf` + shape (listen 8080, root path) before replacing it; resource usage of the `web-server` + container for request and limit values; whether the entra-mode token flow needs `frame-src` + in addition to `connect-src` (read `entraAuth.js` and confirm in a deployment that uses + entra; locally only `none` runs). + +### Existing Test Coverage and Gaps + +- Covered: Bonds list, filter, disabled toggle; bond detail disable gate and ISIN-typed + confirm; coupon page queue, payable gating, preview amounts, reserve warning; bidder roster + render and modal opening; `httpClient` headers, errors, stream parsing; stream 401 and 403 + in `LiveUpdatesProvider`; auth gate and entra plugin. +- Not covered: `parseHash`, any error boundary, REST 401, tile sums, modal behaviour under + live change, bidder delete, history, `AuctionDetailPage` close/cancel/finalise, + `FinaliseAuctionModal`, `PlaceBidModal`, `CreateAuctionModal` RATE/PRICE/BUYBACK gating, + `CreateBondModal` submit, the wNOK and TBD modals, and chart rendering. + +## Significant Findings + +| Priority | Finding | Why it matters | Plan response | +|---|---|---|---| +| Important | U2 stale coupon preview | Money-moving confirmation can show different amounts, or interim instead of final wording, from what executes | Phase 2 | +| Important | U8 hard delete of a key-bearing roster row | Irrecoverable loss of the only key for an address that may hold assets | Phase 3 | +| Important | U4 blank app on bad link or render error | Any single fault takes down the whole operator surface | Phase 1 | +| Important | U7 401 handled only on the stream | Expired sessions surface as raw errors with live updates off | Phase 1 | +| Important | U9 unquoted `LIVE_UPDATES` | Chart value becomes executable script | Phase 5 | +| Follow-up | U1 tile sums, coupon page refresh errors, U9 headers and caching, cleanup, docs | Wrong or misleading display; hygiene | Phases 2, 5, 6 | +| Dependency | History endpoint returns oldest rows and filters `before` after the fetch | U3 card would miss the closure rows on large bonds | Phase 4 waits for `ingestion-projection-correctness` | + +## Invariants + +- `httpClient.js` stays the only module that calls `fetch`, attaches `Authorization`, and now + reacts to 401. Pages never call the auth provider for this. +- Coupon eligibility comes only from the server's `coupon.payable`; the UI never derives it + from wall-clock time. +- The Coupon payout page stays a work queue: issued, not disabled, not matured, with supply or + remaining periods. History lives on the bond detail page. +- System-of-record tables are never dropped or rebuilt; the bidder column change is additive + and idempotent and needs no `SCHEMA_VERSION` bump. +- A disabled bidder's address is still a chain holder: coupons and closure payouts continue to + resolve it on-chain; disabling changes only the roster and impersonated bid entry. +- One source for runtime configuration: chart `runtimeConfig` values feed both `config.js` and + the CSP origins. +- No new npm package, image, or copied third-party file without approval. + +## Field and Source Classification + +| Field or concept | Current source | Target source | Class/owner | Freshness or fallback rule | +|---|---|---|---|---| +| Bond status counts | `listBonds` statuses, three of four counted | Same, all four counted | derived / UI | Recomputed on every live refresh | +| Coupon preview (holders, final period, amounts) | Row snapshot at click time | Live `listBonds` entry selected by ISIN | projected / API; derived / UI | Re-derived each render; confirm disabled when the bond is gone, matured, or `payable` is false and no submit is in flight | +| Bond history rows | Not shown | `GET /v1/bonds/{isin}/history` | projected / API | Live query on `BONDS`; limit 500, newest first once the endpoint is fixed | +| Session validity after 401 | Stream only | Any API response | auth provider state | `handleUnauthorized` idempotent | +| Live-updates availability | Not exposed | Provider `status` | presentation-only | `forbidden` after a stream 403; cleared on auth change | +| Bidder active flag | Row existence | `bidders.disabled_at` (NULL = active) | configured, system of record / API | Written by `DELETE` (disable) and restore | +| Bidder holdings warning | Not shown | `wnokBalance` from the bidder DTO; bond units from `listBonds` `holders` | projected / API; derived / UI | Both queries already live on the page | +| CSP `connect-src` origins | None | `runtimeConfig.apiBaseUrl` origin, auth authority origin in `entra` mode, `web.csp.extraConnectSrc` | configured / chart | Rendered at install; pod rolls on change via checksum | +| `UNIT_NOMINAL` | Two literals | One exported UI constant mirroring `BondToken.UNIT_NOMINAL` | configured (contract constant) / UI | Changes only with a contract redeploy; API exposure is a follow-up | + +## Target Architecture + +### Ownership and Dependency Direction + +- `src/hooks/useRoute.js`: `parseHash` uses a local `safeDecode` that returns the raw segment + when decoding fails. A raw `%E0` then reaches the API, which rejects it (invalid ISIN or + unknown auction), and the page's existing `ErrorState` shows the reason. No new route type. +- `src/components/ErrorBoundary.jsx`: the one class component in the package (React offers no + hook equivalent); `getDerivedStateFromError` plus `componentDidCatch` logging to the console. + Props: `fallback` render function and `resetKey`. Used twice: in `main.jsx` around `` + with a self-contained fallback (no Layout, no toast context) and in `App.jsx` around the + routed page with `resetKey` set to the route, so navigating away clears the error while the + top bar keeps working. `AGENTS.md` gains the exception to "no class components". +- `src/api/httpClient.js`: a small `failedResponse(res, body)` helper builds the `HttpError` + and calls `auth.handleUnauthorized()` when `status === 401`. Both `request()` and + `streamLiveEvents()` use it. `LiveUpdatesProvider` keeps its "stop on 401" branch but no + longer calls the provider itself. REST 403 stays a plain `HttpError` (an authorisation + answer, not a session problem). +- `src/sync/LiveUpdatesProvider.jsx`: the context value gains `status` + (`disabled | connecting | open | reconnecting | forbidden`) and a `useLiveUpdatesStatus()` + hook; `Layout` shows a one-line notice when the status is `forbidden` ("Live updates are not + permitted for this account; use Refresh"). An auth-state change restarts the loop and clears + `forbidden`. +- `CouponPayoutPage.jsx`: holds `payIsin`; `payTarget` is `(data ?? []).find(b => b.isin === + payIsin)` from the unfiltered live list. The modal receives the live bond (or `null`) and a + `stale` reason when the bond is missing, matured, disabled, or not payable. Adds + `RefreshState`. +- `PayCouponModal.jsx`: when `stale` is set and `mutation.loading` is false, confirm is + disabled and a notice explains why. While a submit is in flight the modal ignores the stale + signal, because the operator's own payment makes the bond non-payable and the live update can + arrive before the POST response. +- `BondDetailPage.jsx`: new `BondHistoryCard` (same file or `src/pages/BondHistoryCard.jsx`) + with its own `useLiveQuery([BONDS], () => BondsApi.listBondHistory(isin, { limit: 500 }))`. + Bond-level events only (`auctionId === null`; auction events already appear in the Auctions + card). Rows newest first: `BOND_CREATED`, `ISSUANCE_COMPLETE`, `COUPON_PERIOD_PAID` (period + number, holders paid, coupon total) with its per-holder `COUPON_PAID` rows grouped beneath + by `paymentNumber`, `REDEEMED`, `MATURED` (payment count, principal, coupon, unsold burned), + `BOND_DISABLED`. Block number and a transaction link built like `OperationsPage`'s `TxCell` + (extract it to `src/components/TxLink.jsx` so both pages share it). A note appears when 500 + rows came back. History events carry blocks, not timestamps; the card shows blocks. +- Bidders (API): `services/nb-bond-api/src/ingestion-db.ts`, `bidders.ts`, `app.ts`, + `contracts/bidders.ts`, regenerated `openapi.json`. UI: `src/api/biddersApi.js`, + `BiddersPage.jsx`, a new `ConfirmModal` primitive in `src/components/ui.jsx`. +- Chart: `helm/templates/configmap.yaml` renders `config.js` and `default.conf`; + `deployment.yaml` mounts `default.conf` over `/etc/nginx/conf.d/default.conf` by `subPath`; + `values.yaml` gains a `web` block (CSP extras, report-only switch) and `resources`. + +### Data or State Model + +`bidders` gains `disabled_at INTEGER NULL` (unix milliseconds). Applied in `createTables` for +fresh databases and by an idempotent `ensureBidderDisabledColumn(db)` (checks +`PRAGMA table_info(bidders)`, then `ALTER TABLE bidders ADD COLUMN disabled_at INTEGER`) inside +the existing write-handle initialisation transaction. `SCHEMA_VERSION` is unchanged. + +Rules: + +- `listBidderRows(db, { includeDisabled })` filters `disabled_at IS NULL` by default. +- `getBidderRowByAddress` and `getBidderRowByName` return disabled rows too (callers decide). +- `disableBidder(db, address, now)` sets `disabled_at` when NULL; returns the row; idempotent. +- `restoreBidder(db, address)` clears it (only if the restore decision is accepted). +- `createBidder` conflict messages say "exists (disabled)" when the clashing row is disabled. +- `reconcileFixtureBidderOverrides` copies `disabled_at` into the replacement row. +- `deleteBidderRow` remains only for that reconcile path; no route calls it. +- Seeding still counts all rows, so disabling every bidder no longer reseeds on restart. + +### API and Zod Contract + +Follow the API's existing conventions (Zod schemas generate `openapi.json`, RFC 7807 errors, +`md5` on resources, `includeDisabled` as on bonds): + +- `Bidder` schema: add `disabled: boolean` and `disabledAt: unixMillis | null`. +- `GET /v1/bidders`: `includeDisabled` boolean query parameter, default false. +- `DELETE /v1/bidders/{address}`: operationId `disableBidder` (was `deleteBidder`), summary + "Disable a bidder (soft delete; key and history retained)". Keeps the unrevealed-bid 409 + guard. 204 when disabled or already disabled; 404 when unknown. +- `PATCH /v1/bidders/{address}` body `{ "disabled": false }` returns the `Bidder` (restore; + only if accepted). A body with `disabled: true` is rejected with 400 so there is one way to + disable. +- `POST /v1/bidders/{address}/bids`: 409 `bidder is disabled`. +- `publishLiveChange(['bidders'])` on disable and restore. +- Regenerate `openapi.json`; the API contract test and the UI live-event contract test must + pass. + +UI client: `listBidders({ includeDisabled })`, `disableBidder`, `restoreBidder`; the old +`deleteBidder` name is removed in the same PR (single consumer). + +### Consistency and Failure Semantics + +- Coupon modal: preview equals the latest projection the UI has seen. The server still resolves + holders on-chain at submit time, so a projection lag can still differ from chain state; the + server-side eligibility re-check and the contract's full-supply coverage check make that fail + loudly rather than pay a partial set (residual risk below). +- History card: the endpoint is read-only and idempotent; the card reloads on bond live + events. Until the dependency lands, a bond with more than 400 rows in one history table would + miss its newest rows, which is why the phase is sequenced after the fix. +- 401: the client marks the session expired and still rejects, so callers keep their error + handling; in `entra` mode the gate flips to the login page with the "expired" notice. +- Bidder disable is a single-row update: no partial state. Restoring a bidder has no chain + effect. +- Error boundary: a caught error is logged to the console with the component stack; the + fallback offers "Try again" (resets the boundary) and a link to Bonds. + +### Security and Deployment Boundary + +nginx server config rendered by the chart (values in `web`): + +```text +server_tokens off; +location /assets/ -> Cache-Control "public, max-age=31536000, immutable" + security headers +location = /config.js, location = /index.html, location / -> Cache-Control "no-cache" + security headers +security headers (one snippet included in every location, because add_header in a location +drops server-level add_header): + Content-Security-Policy[-Report-Only]: + default-src 'self'; script-src 'self'; style-src 'self' ; + font-src 'self' ; img-src 'self' data:; connect-src 'self' + ; frame-ancestors 'none'; + base-uri 'self'; form-action 'self'; object-src 'none' + X-Frame-Options: DENY + X-Content-Type-Options: nosniff + Referrer-Policy: strict-origin-when-cross-origin +``` + +- Origins come from `runtimeConfig.apiBaseUrl` (parsed with `urlParse`) and, in `entra` mode, + `runtimeConfig.authAuthority` or the default authority origin that `src/auth/entraAuth.js` + falls back to. The font origins appear only while the operator keeps the font CDN. +- `style-src` without `'unsafe-inline'`: React `style` props are applied through the CSSOM, + which CSP does not block, and the bundle ships no inline `