Skip to content

chore(release): v3.18.0 - #845

Merged
fraseragain merged 44 commits into
mainfrom
develop
Oct 14, 2026
Merged

fraseragain merged 44 commits into
mainfrom
develop

Conversation

@fraseragain

Copy link
Copy Markdown
Contributor

DRAFT. Do not merge until the checklist below is done. Release of develop to main: expected version 3.18.0 (minor, from the feat commits).

What's included

Faucet quota stack plus two follow-ups (44 commits on develop since v3.17.0):

Deploy notes

  • Three new migrations (a new faucetRequest table with its index and FK, then status/completedAt columns and a createdAt default on that same table; nothing touches existing tables): 1791500000000, …001, …002. The production "Database migrations" workflow runs npm run migrate on push to main and applies them. Old code ignores them, so a rollback of the image is safe.
  • Behaviour changes that need production env vars checked before merge:
    • TESTNET_FAUCET_UPPER_CAP_CHEQ is no longer read. The initial top-up now comes from TESTNET_INITIAL_TOPUP_CHEQ (default 10,000) and the per-address cap from FAUCET_ADDRESS_CAP_CHEQ (default 100,000; it was 10,000).
    • TESTNET_MINIMUM_BALANCE default in the Dockerfile changes from 1000 to 10000 (applies only if production does not set it).
  • Intended production values: TESTNET_INITIAL_TOPUP_CHEQ=10000, FAUCET_ADDRESS_CAP_CHEQ=100000. Remove TESTNET_FAUCET_UPPER_CAP_CHEQ.
  • No other new variables are required; all new FAUCET_* settings have defaults (see example.env).

Before merging (checklist)

  • Production DigitalOcean app: env vars set as above; check whether it still has a PRE_DEPLOY database-migrations job (staging's hung on every deploy and was removed; see below)
  • Manual staging pass: Balances page (via the portal), a faucet request with a session and with an API key, a brand-new testnet account gets 10,000 CHEQ
  • An approving review (main requires 1)

How to merge

  • Use a merge commit, not squash. Semantic Release derives the version from the conventional commits since the last tag; a squash would collapse them into one chore(release) commit and release a patch (3.17.1) instead of 3.18.0, and main would diverge from develop again.
  • On merge: Semantic Release (version, changelog, tag), the production Docker image is built and pushed, and production migrations run. There is no manual approval gate on the production environment.
  • Release the portal after this one: its Balances page reads the new faucet block from Studio.
  • Afterwards open a main into develop back-merge PR (merge commit), because Semantic Release commits chore(release): X.Y.Z [skip ci] straight to main.

Verification done on staging (develop at e748f7c)

  • Deployed and serving the new code; live Swagger matches develop (59 paths).
  • Handler-level integration tests: 37/37 pass against Postgres. Unit tests: 209 pass.
  • Unauthenticated sweep of 24 GET endpoints: no unexpected 5xx. Bad credentials return 500 on every authenticated endpoint on both staging and production (pre-existing, not part of this release).
  • Not yet verified: authenticated flows and the portal UI (needs a login).

🤖 Generated with Claude Code

dubiouspip and others added 30 commits October 8, 2026 14:28
Refs #818

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Per-customer quota (default 100,000 CHEQ per UTC calendar month), reserved transactionally under a per-customer advisory lock. Refs #818

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Removes the API-key/customer-id rejection and enforces FAUCET_MONTHLY_LIMIT_CHEQ, returning 429 with Retry-After and quota details. Quota is released if the upstream faucet call fails. Closes #818

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…t amounts

An explicit amount is no longer blocked by the per-account balance cap; it is bounded by the remaining monthly quota. Omitting the amount still tops up to the balance cap. Refs #818

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Reverts 6979f42 so the balance cap applies to explicit amounts again, and sources that cap from FAUCET_MONTHLY_LIMIT_CHEQ (default 100,000). The same figure is now the most an address can hold and the most a customer can request per calendar month. TESTNET_FAUCET_UPPER_CAP_CHEQ keeps governing only the automatic top-up at account creation. Refs #818

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…UP_CHEQ

The setting only drives the automatic top-up at account bootstrap, so name it for that. The old variable is still read as a fallback so existing deployments keep their value. Refs #818

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Both account-bootstrap top-up paths now reserve their amount in the customer's monthly quota and release it if the faucet fails. If the quota is exhausted the top-up is skipped and account creation still succeeds. Refs #818

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ances

Lets clients such as the portal show the cap and quota without duplicating the configured value. Refs #818

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ted now

- Keep the quota reservation when the faucet call throws (outcome unknown, tokens may have been sent); release it only when the faucet reports a failure
- Share one reserve/credit/settle function between POST /account/faucet and the bootstrap top-up
- maxRequestable now honours the monthly quota; a request with no amount tops up by that amount instead of failing when the quota is nearly used
- Add FAUCET_ADDRESS_CAP_CHEQ (defaults to FAUCET_MONTHLY_LIMIT_CHEQ) so the address cap and the monthly limit are separately named
- Remove an unreachable invalid-config branch, de-duplicate the usage query and move the quota summary builder into the helper module
Refs #818

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Unit tests for the quota helpers and credit orchestration (including keeping the reservation when the faucet throws), plus a Postgres-backed integration test for the ledger (skipped unless FAUCET_TEST_DATABASE_URL is set). Refs #818

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…when quota is used up

POST /account/faucet no longer calls Stripe on every request: the active/trialing subscription status and plan are cached for FAUCET_SUBSCRIPTION_CACHE_SECONDS (default 60, 0 disables). The stored subscription status is still checked on every request, so cancellations apply immediately. When the monthly quota is already used up the endpoint answers 429 before querying the on-chain balance. LogTo role/scope lookups are left uncached because they are shared by every API-key endpoint and a stale cache would delay role removals. Refs #820

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
POST /account/faucet now rejects a request made within FAUCET_MIN_INTERVAL_SECONDS (default 10, 0 disables) of the customer's previous one with 429 and Retry-After. The check runs first as a cheap read, so a tight loop does not reach Stripe, the RPC node or the faucet, and again under the per-customer lock so simultaneous requests cannot slip through. Failed faucet attempts are released and so do not block a retry. The bootstrap top-up is not subject to it. Refs #821

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Reservations are now pending, completed, unknown or abandoned. A pending reservation counts towards the quota only for FAUCET_PENDING_TIMEOUT_SECONDS (default 600), so a crash between reserving and calling the faucet no longer leaves quota used; stale ones are marked abandoned the next time the customer reserves. A reservation whose faucet call threw is marked unknown and always keeps counting, so tokens that may have been sent are never refunded. A late confirmation re-counts an abandoned reservation. Adds a migration for the status and completedAt columns. Refs #822

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… createdAt

The entity now exposes amountNcheq as a bigint (via a column transformer) instead of a string, which removes the constructor guard, and createdAt is set by the database (DEFAULT now()) so all instances agree on when a request was made. Adds a migration for the column default; the ledger test checks the schema still matches the entity. Refs #827

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The on-chain balance is read before the reservation and does not include transfers still on their way, so simultaneous requests could each be allowed the full room under FAUCET_ADDRESS_CAP_CHEQ. The cap check now also runs under the per-customer lock, subtracting requests made for the same address in the last FAUCET_BALANCE_SETTLE_SECONDS (default 20) from the room, so only as many requests as fit are accepted. The bootstrap top-up is not subject to it. Refs #823

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
faucetRequest rows older than FAUCET_REQUEST_RETENTION_MONTHS (default 13 calendar months, 0 keeps everything) are deleted. Studio has no scheduler, so the cleanup runs in the background from normal reservations, at most once an hour per process, and never delays or fails the request that triggered it. The README documents a manual query as well. Refs #824

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Every reservation, completion, release, unknown outcome and rejection (quota, minimum interval, address cap, plus the early checks before Stripe), the sweep of abandoned reservations and the retention cleanup now write one JSON log line with an event name and the customer, address, amount and relevant figures, so a blocked or refunded request can be traced. The README lists the events and a support query. The event tracker is not used because its operations feed usage and billing reports. Refs #825

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ee auth methods

Session, API key and machine-to-machine credentials all end in the same scope check, so a unit test pins that the route requires request:faucet:testnet and is not open to unauthenticated callers. The Swagger description and README now state the supported credentials and that an M2M application needs the scope granted in LogTo. No behaviour change: all three methods remain allowed. Refs #826

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
21 tests call the real requestFaucetTokens and getBalances against a real Postgres with Stripe, the faucet, the account lookup and the on-chain balance faked. They cover session, API-key and machine-to-machine requests, the quota, minimum interval, address cap, failure handling, reservation status and the balances response. They use Node's test runner (the controller's dependencies do not load under jest) and a small loader hook for the balance fake, live outside tests/unit, and skip unless FAUCET_TEST_DATABASE_URL is set, so CI is unchanged. The README says how to run them. Refs #828

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- The faucet HTTP call had no timeout, so a hung faucet held the caller (and a quota reservation) for Node's default of several minutes. It now aborts after FAUCET_REQUEST_TIMEOUT_SECONDS (default 30), covering the response body too. POST /account/faucet maps the timeout to 504 with a message that the request may or may not have been processed; the quota stays used because tokens may have been sent
- parseNumberEnv now warns, naming the variable and the default used, when a set value is invalid, so a typo in a limit (for example FAUCET_MONTHLY_LIMIT_CHEQ=1,000) is no longer silently replaced by the default
Refs #818

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…est.status

- Merges the faucet timeout and invalid-config warning from feat/faucet-api-key-cooldown, keeping this branch's settings alongside them
- parseNonNegativeIntEnv now warns, naming the variable, when a set value is invalid, like parseNumberEnv
- faucetRequest.status is constrained to the four known values with a CHECK constraint (added to the unreleased status migration, with a matching @check on the entity), so the database cannot hold a status the code does not know about
Refs #829

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
createdAt is set by the database (DEFAULT now()), but month windows, the minimum interval, the pending timeout, the address-cap settle window and retention were compared against the application clock, so instances with different clocks could disagree about how old a request is. They now all use the database clock: reserve reads clock_timestamp() after taking the lock, and the read paths read it once per call. getQuotaStatus returns the usage together with the window, so the 429 Retry-After and the balances response use the same clock, and completedAt is set by the database. Tests fake the application clock hours ahead and fail against the previous behaviour. Refs #827

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Accept any lowercase bech32 cheqd address in POST /account/faucet, not
only the customer's own payment accounts. Malformed addresses return 400.
Quota, interval and address-cap limits still apply to the calling customer.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Drop the ENABLE_ACCOUNT_TOPUP bootstrap top-up from account creation and
bootstrap, along with TESTNET_INITIAL_TOPUP_CHEQ, TESTNET_MINIMUM_BALANCE
and the testnetMinimumBalance response field. Users now request testnet
CHEQ themselves via POST /account/faucet.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Stop honouring TESTNET_FAUCET_UPPER_CAP_CHEQ as a fallback for
TESTNET_INITIAL_TOPUP_CHEQ. It was the faucet cap (often 100,000), so a
deployment that only set the old name got a 100,000 CHEQ initial top-up.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The initial top-up now runs only when the call created the customer's
testnet account, not on every account or bootstrap call. Bootstrap no
longer reports an error when the monthly quota is exhausted; it skips the
top-up, as account creation already did.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Add handler tests for the one-off initial top-up on POST /account and in
bootstrap: new vs existing testnet account, top-up to (not by) 10,000,
quota exhaustion, faucet failure and ENABLE_ACCOUNT_TOPUP off. Export
topupTestnet for them. Set the Dockerfile TESTNET_MINIMUM_BALANCE default
to 10000 so it no longer overrides the code default.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…e a 20-byte account

Move address validation into faucetValidator so a malformed address is
rejected with 400 before any subscription lookup, and into helpers/faucet.ts
as a pure function with unit tests. Require a 20-byte account address so
the API agrees with the portal's form validation. resolveFaucetAccount
becomes resolveFaucetTarget and no longer validates.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
dubiouspip and others added 14 commits October 13, 2026 09:14
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
## What changed
Merges `main` into `develop` to bring in the v3.17.0 release commits
(CHANGELOG.md and package.json version only; no code changes).

## Why
Keeps `develop` ahead of `main` so the stacked faucet PRs and the next
release PR do not conflict.

## How to test
Diff against `develop` is limited to `CHANGELOG.md` and `package.json`.

## Risks
None. **Use a merge commit, not squash**, so `main` stays an ancestor of
`develop`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
…#819)

## What changed
- `POST /account/faucet` now works with an API key (the `x-api-key` /
`customer-id` rejection is removed). The existing
`request:faucet:testnet` scope check applies.
- New per-customer **monthly faucet quota**: `FAUCET_MONTHLY_LIMIT_CHEQ`
(default 100,000 CHEQ, calendar month UTC), shared across all of a
customer's addresses. Exceeding it returns `429` with `Retry-After` and
quota details.
- New **address cap**: `FAUCET_ADDRESS_CAP_CHEQ` is the most a single
testnet address can hold before the faucet stops topping it up. It
defaults to `FAUCET_MONTHLY_LIMIT_CHEQ`, so out of the box there is one
100,000 CHEQ figure (previously the per-address cap was 10,000).
- A request with no `amount` tops the address up by the most that can be
requested now: the lower of the room under the address cap and the
monthly quota left. `balance.maxRequestable` in the response reports the
same figure.
- The initial top-up at account bootstrap is debited from the quota. If
the quota is exhausted the top-up is skipped and account creation still
succeeds.
- `TESTNET_FAUCET_UPPER_CAP_CHEQ` renamed to
`TESTNET_INITIAL_TOPUP_CHEQ` (it now only drives the bootstrap top-up).
The old name is still read as a fallback.
- `GET /account/balances` returns a new `faucet` block (cap + quota
used/remaining/reset) so clients such as the portal do not need to
duplicate the cap.
- New `faucetRequest` table and migration; Swagger, README and
`example.env` updated.

## Why
Closes #818. Studio API-key users could not use the faucet, and the
faucet backend (`cheqd/cosmjs`) exempts Studio requests from its own
cooldown and email verification, so Studio has to be the layer that
limits abuse. Backend hardening is tracked separately in
cheqd/cosmjs#12.

## How quota is enforced
Quota is reserved in a transaction under a per-customer Postgres
advisory lock before the faucet call, so concurrent requests cannot
overshoot the limit. The reservation is settled by one shared function
(`creditWithinQuota`) used by both the endpoint and the bootstrap
top-up:
- faucet answers with an error status: it did not credit the account, so
the reservation is **released**;
- faucet call **throws** (for example a timeout): the outcome is unknown
and tokens may have been sent, so the reservation is **kept**.
Over-counting is safer than refunding quota for tokens that were
credited.

## Edge cases
- `cap_reached` responses and faucet failures do not use quota.
- Quota exhausted during bootstrap: top-up skipped, no error.
- An explicit `amount` above the room under the address cap returns
`400`; one that does not fit the quota returns `429`.
- The balance read is not serialised with the quota lock. With the cap
and quota both at 100,000 this only matters if an address already holds
at least 50,000 and two requests run at once; the monthly quota still
bounds the total.
- A crash between reserving and calling the faucet leaves that amount
counted for the month (see follow-up issues).

## How to test
1. `npm run test:unit` (new pure tests in
`tests/unit/account/faucet-quota.test.ts`, including the release/keep
rules for `creditWithinQuota`).
2. Ledger integration test against Postgres (skipped unless
`FAUCET_TEST_DATABASE_URL` is set), covering reserve, release, month
boundary, customer isolation, the concurrency guarantee and
migration/entity drift:
`docker run -d --rm --name faucet-test-db -e POSTGRES_PASSWORD=pw -e
POSTGRES_DB=studio -p 55432:5432 postgres:16`
`FAUCET_TEST_DATABASE_URL=postgres://postgres:pw@localhost:55432/studio
npm run test:unit -- faucet-request.service`
3. I also ran the real `requestFaucetTokens` and `getBalances` handlers
against Postgres with Stripe, the faucet and the on-chain balance mocked
(20 scenarios, all passing): API-key access, 429 with `Retry-After`,
release on faucet failure, quota kept when the faucet throws, address
cap and `maxRequestable`, no-amount clamping, 6 parallel requests
funding exactly 3, and invalid input. That harness is not committed.
4. Manually: `POST /account/faucet` with an API key; repeat until `429`;
check `quota` in the response and in `GET /account/balances`.

Not tested end to end: Stripe, the real faucet and LogTo, and the
bootstrap top-up path (it reuses the tested ledger and orchestration).

## Release note
- The default per-address faucet cap rises from 10,000 to 100,000 CHEQ
(`FAUCET_ADDRESS_CAP_CHEQ`), bounded by the 100,000 CHEQ monthly quota
per customer. Set `FAUCET_ADDRESS_CAP_CHEQ` to keep a lower per-address
cap.
- `TESTNET_FAUCET_UPPER_CAP_CHEQ` is renamed
`TESTNET_INITIAL_TOPUP_CHEQ`; the old name keeps working as a fallback.

## Risks and rollout
- The migration only creates a new table, so deploy order with the app
is safe (the migrations workflow runs on push to `develop`).
- Deploy this before the portal change (cheqd/studio-portal#258), which
reads the new `faucet` field.
- The full controller graph only loads on Node 20 (Node 22+ fails on
Veramo's JSON import syntax), which is what CI uses.

## Architecture and best-practice review fixes
- **Faucet call timeout.** The faucet HTTP call had no timeout, so a
hung faucet held the caller (and a quota reservation) for Node's default
of several minutes. It now aborts after `FAUCET_REQUEST_TIMEOUT_SECONDS`
(default 30), covering the response body too. `POST /account/faucet`
maps a timeout to `504` with a message that the request may or may not
have been processed; the quota stays used because tokens may have been
sent. Unit tests stub `fetch`; verified through the real handler.
- **Config typos are no longer silent.** `parseNumberEnv` now warns,
naming the variable and the default used, when a set value is invalid,
so for example `FAUCET_MONTHLY_LIMIT_CHEQ=1,000` is no longer silently
replaced by 100,000.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
## [3.18.0-develop.1](3.17.0...3.18.0-develop.1) (2026-10-13)

### Features

* add monthly faucet quota service and helpers ([7eef4f7](7eef4f7)), closes [#818](#818)
* allow API keys on POST /account/faucet with monthly quota ([6ed02c8](6ed02c8)), closes [#818](#818)
* allow API keys on the faucet endpoint with a monthly CHEQ quota ([#819](#819)) ([17dcb04](17dcb04)), closes [cheqd/studio-portal#258](https://github.com/cheqd/studio-portal/issues/258)
* apply monthly quota as the per-request limit for explicit faucet amounts ([6979f42](6979f42)), closes [#818](#818)
* **db:** add faucetRequest table for monthly faucet quota ([ef43cb1](ef43cb1)), closes [#818](#818)
* debit the initial testnet top-up from the monthly faucet quota ([15c8d8c](15c8d8c)), closes [#818](#818)
* return faucet cap and monthly quota usage from GET /account/balances ([3ba590d](3ba590d)), closes [#818](#818)
* use a single 100k CHEQ cap for faucet balance and monthly quota ([4612b1f](4612b1f)), closes [#818](#818)

### Bug Fixes

* time out the faucet call and warn on invalid faucet config ([9b14df1](9b14df1)), closes [#818](#818)
#819) (#829)

Stacked on #819 (base is `feat/faucet-api-key-cooldown`); retarget to
`develop` once #819 merges.

Closes #820, closes #821, closes #822.

## What changed
- **#820 Cost per request.** `POST /account/faucet` no longer calls
Stripe on every request: the active/trialing subscription status and
plan are cached for `FAUCET_SUBSCRIPTION_CACHE_SECONDS` (default 60, `0`
disables). The stored subscription status is still checked in the
database on every request, so a cancellation takes effect immediately;
only a plan change can lag by up to the TTL. When the monthly quota is
already used up, the endpoint answers `429` before querying the on-chain
balance.
- **#821 Burst limiting.** A request made within
`FAUCET_MIN_INTERVAL_SECONDS` (default 10, `0` disables) of the
customer's previous one gets `429` with `Retry-After`. It is checked
first as a cheap read, so a tight loop never reaches Stripe, the RPC
node or the faucet, and again under the per-customer lock so
simultaneous requests cannot slip through. A failed faucet attempt is
released and does not block a retry. The bootstrap top-up is not subject
to it.
- **#822 Reservation status.** Reservations are now `pending`,
`completed`, `unknown` or `abandoned`. A pending reservation counts
towards the quota for `FAUCET_PENDING_TIMEOUT_SECONDS` (default 600) and
then stops counting, so a crash between reserving and calling the faucet
no longer leaves quota used (stale ones are marked `abandoned` the next
time the customer reserves). A reservation whose faucet call **threw**
is marked `unknown` and always keeps counting, so tokens that may have
been sent are never refunded. A late confirmation re-counts an abandoned
reservation. New migration `1791500000001` adds `status` and
`completedAt` (existing rows become `completed`).

## Decisions and non-goals
- LogTo role/scope lookups are **not** cached: they are shared by every
API-key endpoint, and a stale cache would delay role removals. Stripe is
the larger and faucet-specific cost.
- Pending reservations expire rather than being reconciled against the
faucet: the faucet has no lookup that links a Studio request to a
transfer. The remaining gap is a crash *after* the faucet credited but
*before* the row was marked completed, which would refund that amount
after the timeout. That window is a single DB write.
- No scheduler was added; the sweep runs inside the reservation, under
the lock.

## How to test
1. `npm run test:unit` (new tests for the interval helper,
`too_frequent`, and the complete / unknown / release rules).
2. Ledger integration test against Postgres (skipped unless
`FAUCET_TEST_DATABASE_URL` is set; Node 20): now also covers the minimum
interval, simultaneous requests, and every status transition including
expiry and late confirmation. See the header of
`tests/unit/account/faucet-request.service.test.ts` for the docker
command.
3. I also ran the real handlers against Postgres with Stripe, the faucet
and the balance read faked: the 20 earlier scenarios (regression), plus:
three requests make one Stripe call; an exhausted quota returns `429`
with no balance read and no faucet call; an immediate second request
returns `429` with no Stripe, RPC or faucet call; five simultaneous
requests fund exactly one; a failed attempt does not block a retry; and
the status transitions above through the handler. That harness is not
committed (see #828).

## Risks and rollout
- New default limit: customers calling the faucet more than once every
10 seconds now get `429`. Set `FAUCET_MIN_INTERVAL_SECONDS=0` to
disable.
- The migration is additive (two columns on the new table), so deploy
order is safe.
- `FAUCET_PENDING_TIMEOUT_SECONDS` should stay longer than the longest
faucet call (Node's default fetch timeout is about 5 minutes).

## Architecture and best-practice review fixes
- Merges the faucet timeout and config-warning fixes from #819, and
extends the warning to `parseNonNegativeIntEnv`, which backs this PR's
settings.
- **`faucetRequest.status` is constrained** to the four known values
with a `CHECK` constraint (added to the still-unreleased status
migration, with a matching `@Check` on the entity), so the database
cannot hold a status the code does not know about. The ledger test
checks a bad value is rejected and that the schema still matches the
entity.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
## [3.18.0-develop.2](3.18.0-develop.1...3.18.0-develop.2) (2026-10-13)

### Features

* add a minimum interval between faucet requests per customer ([2d3ebf5](2d3ebf5)), closes [#821](#821)
* faucet cost, burst limiting and reservation status (follow-ups to [#819](#819)) ([#829](#829)) ([e7d7b67](e7d7b67)), closes [#820](#820) [#821](#821) [#820](#820) [#821](#821) [#822](#822) [#828](#828)
* track whether each faucet quota reservation completed ([a371bec](a371bec)), closes [#822](#822)

### Performance Improvements

* cache the Stripe subscription lookup and skip the balance read when quota is used up ([dd408e2](dd408e2)), closes [#820](#820)
…idy-ups (follow-ups to #829) (#830)

Stacked on #829, which is stacked on #819. The base is
`feat/faucet-cost-and-reservations`; retarget as the earlier PRs merge.

Closes #823, closes #824, closes #825, closes #826, closes #827, closes
#828.

## What changed
- **#823 Address cap under the lock.** The on-chain balance is read
before the reservation and does not include transfers still on their
way, so simultaneous requests could each be allowed the full room under
`FAUCET_ADDRESS_CAP_CHEQ`. The cap check now also runs under the
per-customer lock, subtracting requests made for the same address in the
last `FAUCET_BALANCE_SETTLE_SECONDS` (default 20) from the room, so only
as many requests as fit are accepted. The bootstrap top-up is not
subject to it. Trade-off: for those 20 seconds a request that already
reached the balance is counted twice, which can briefly refuse a
legitimate follow-up only when the address is near its cap.
- **#824 Retention.** `faucetRequest` rows older than
`FAUCET_REQUEST_RETENTION_MONTHS` (default 13 calendar months, `0` keeps
everything) are deleted. Studio has no scheduler, so the cleanup runs in
the background from normal reservations, at most once an hour per
process, and never delays or fails the request that triggered it. A
manual query is in the README.
- **#825 Logging.** Every reservation, completion, release, unknown
outcome and rejection (quota, minimum interval, address cap, plus the
early checks before Stripe), the sweep of abandoned reservations and the
retention cleanup write one JSON log line with an `event` name and the
customer, address, amount and relevant figures. The README lists the
events and a support query. The event tracker is deliberately not used:
its operations feed usage and billing reports.
- **#826 Auth methods.** Session, API key and machine-to-machine
credentials all remain allowed (as decided). A unit test pins that they
all end in the same `request:faucet:testnet` scope check and that the
route is not open to unauthenticated callers; the Swagger description
and README state the supported credentials, including that an M2M
application needs the scope granted in LogTo. No behaviour change.
- **#827 Entity tidy-ups.** `amountNcheq` is a `bigint` in the entity
(via a column transformer), and `createdAt` is set by the database
(`DEFAULT now()`) so all instances agree on when a request was made. New
migration `1791500000002`; the ledger test checks the schema still
matches the entity.
- **#828 Handler tests.** 21 handler-level tests call the real
`requestFaucetTokens` and `getBalances` against a real Postgres with
Stripe, the faucet, the account lookup and the on-chain balance faked.
They cover session, API-key and M2M requests, the quota, minimum
interval, address cap, failure handling, reservation status and the
balances response. As agreed, **CI is unchanged**: they skip unless
`FAUCET_TEST_DATABASE_URL` is set. They use Node's test runner (a
dependency of the controller does not import under jest) and a small
loader hook for the balance fake, and live in `tests/integration`,
outside jest's `tests/unit` pattern.

## How to test
1. `npm run test:unit` (new pure tests: retention cutoff, address room,
logging events, auth rule).
2. With a disposable Postgres, the ledger and handler suites; the README
has the exact commands. Ledger suite (jest): reserve/release, interval,
statuses, address cap including simultaneous requests, retention, entity
round trip and the schema-drift check. Handler suite (Node 20): the 21
end-to-end scenarios.
3. I ran both against Postgres 16 on Node 20: 212 of 212 pass, including
the 21 handler tests; local Node 26 runs 186 and skips the 26
database-backed ones. I also confirmed end to end that the retention
cleanup is triggered by a normal request, deletes only rows past
retention and runs at most once an hour, and that four simultaneous
requests for an address with room for one are now refused three times.

## Risks and rollout
- Migration `1791500000002` is additive (a column default) and safe in
any deploy order.
- `FAUCET_BALANCE_SETTLE_SECONDS` can over-refuse for a few seconds near
the cap; set it to `0` to turn the in-flight accounting off.
- The first request after a deploy per process may delete old rows in
the background; with the default 13 months and a table that only just
appeared, nothing is deleted for over a year.

## Architecture and best-practice review fixes
- Merges the fixes from #819 and #829.
- **One clock for every decision.** `createdAt` is set by the database,
but month windows, the minimum interval, the pending timeout, the settle
window and retention were compared against the application clock, so
instances with different clocks could disagree about how old a request
is. They now all use the database clock (`clock_timestamp()`, read after
the lock is taken), the 429 `Retry-After` and balances response use the
same window, and `completedAt` is set by the database. Tests fake the
application clock hours ahead; I confirmed they fail against the
previous behaviour (a fresh pending reservation was treated as expired,
the month was wrong, and `completedAt` was 3 hours off).

Merged state verified on Node 20 against Postgres 16: 234 of 234 tests
pass, including the 21 handler tests.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
…is documented

The POST /account/faucet doc comment had unquoted colons followed by spaces in its description, which is invalid YAML. swagger-jsdoc logged the error and dropped the whole /account/faucet path from the generated spec. Reword the two sentences and regenerate swagger-api.json.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
## [3.18.0-develop.3](3.18.0-develop.2...3.18.0-develop.3) (2026-10-13)

### Features

* delete faucet request history past a retention period ([038a079](038a079)), closes [#824](#824)
* faucet address-cap race, retention, logging, tests and entity tidy-ups (follow-ups to [#829](#829)) ([#830](#830)) ([766b1cd](766b1cd)), closes [#823](#823) [#824](#824) [#825](#825) [#826](#826) [#827](#827) [#823](#823) [#824](#824) [#825](#825) [#826](#826) [#827](#827) [#828](#828) [#819](#819)
* log faucet quota decisions as structured events ([b21176b](b21176b)), closes [#825](#825)

### Bug Fixes

* enforce the faucet address cap under the quota lock ([eed180b](eed180b)), closes [#823](#823)
* use the database clock for every faucet quota decision ([cb6c856](cb6c856)), closes [#827](#827)
…e (follow-up to #830) (#839)

## What changed
Stacked on #830 (base is `feat/faucet-hardening-low`, so the diff is
only this change; merge after #819, #829 and #830).

- **Any address:** `POST /account/faucet` now accepts any valid
lowercase `cheqd` bech32 address, owned or not. Previously an address
the customer did not own returned `403`. The address is validated in
`faucetValidator` (before any subscription lookup): it must be a
lowercase `cheqd` bech32 account address (20 bytes, valid checksum),
otherwise `400`. With no address it still funds the customer's own
testnet account.
- **Initial top-up, once:** with `ENABLE_ACCOUNT_TOPUP=true`, the top-up
to `TESTNET_INITIAL_TOPUP_CHEQ` now runs only when the call creates the
customer's testnet account (`POST /account` and bootstrap), not on every
call.
- **Default stays 10,000 CHEQ:** `TESTNET_FAUCET_UPPER_CAP_CHEQ` is no
longer a fallback for `TESTNET_INITIAL_TOPUP_CHEQ`. It was the faucet
cap (often 100,000), so a deployment that only set the old name got a
100,000 top-up.
- **Bootstrap quota:** bootstrap now skips the top-up when the monthly
quota is exhausted (logs `faucet.bootstrap_topup_skipped`) instead of
returning `500`, matching `POST /account`.

## Why
Users should choose how much to top up and where, within a 100,000 CHEQ
monthly quota, in one or several requests.

## Behaviour unchanged
- Monthly quota (`FAUCET_MONTHLY_LIMIT_CHEQ`, default 100,000) is per
customer across all addresses; the 10k initial top-up counts against it.
- Per-address cap (`FAUCET_ADDRESS_CAP_CHEQ`, default 100,000) is
checked against the address's on-chain balance, so a funded address can
be topped up only to the cap.
- Minimum interval, reservations and plan/scope checks are as in #830.

## Edge cases
- A mainnet address has the same `cheqd1…` prefix, so it is accepted; it
receives testnet tokens only.
- If the one-off initial top-up fails, nothing retries it; the user uses
`POST /account/faucet`.
- For existing accounts, the bootstrap `testnetMinimumBalance` flag
stays `false` because no check runs.

## How to test
- Integration (Node 20, disposable Postgres):
`FAUCET_TEST_DATABASE_URL=... npx -y -p node@20 node --import tsx
--import ./tests/integration/hooks/register.mjs --test
tests/integration/faucet-handler.test.ts` — 35/35 pass, including cases
for a non-owned address, malformed/wrong-prefix/uppercase addresses, and
the initial top-up (new vs existing account, top up to 10,000, quota
exhausted, faucet failure, `ENABLE_ACCOUNT_TOPUP` off) on both `POST
/account` and bootstrap (`topupTestnet` is exported for these tests).
Removing the new-account gating or the quota skip makes 3 of them fail.
- Unit: `npx jest tests/unit/account` — 67 pass, 30 skipped (includes
`isValidFaucetAddress`).
- `tsc --noEmit` and Prettier clean.

## Risks
- Anyone with a faucet-enabled plan can fund any address, up to their
own monthly quota. Nothing caps total CHEQ received by one address
across customers beyond the 100,000 balance cap. A cross-customer
per-address limit could be added.
- `docker/Dockerfile` `TESTNET_MINIMUM_BALANCE` default is corrected
from 1000 to 10000 so it no longer overrides the code default. An
environment that sets 100000 explicitly still wins.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
## [3.18.0-develop.4](3.18.0-develop.3...3.18.0-develop.4) (2026-10-13)

### Features

* **faucet:** allow funding any valid cheqd testnet address ([7a95c5d](7a95c5d))
* **faucet:** fund any cheqd address; top up new testnet accounts once (follow-up to [#830](#830)) ([#839](#839)) ([1558ccf](1558ccf)), closes [#819](#819) [#829](#829)

### Bug Fixes

* **faucet:** keep the initial top-up at 10,000 CHEQ by default ([dc2c0c6](dc2c0c6))
* **faucet:** top up only newly created testnet accounts; skip on quota ([56d3f25](56d3f25))
* **swagger:** make the faucet description valid YAML so the endpoint is documented ([7f2f6ac](7f2f6ac))
POST /account/faucet without an amount now sends FAUCET_DEFAULT_REQUEST_CHEQ
(default 10,000), lowered to the room under the address cap or the quota left,
instead of taking the whole monthly quota. An explicit amount is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Closes #841.

## What changed
`POST /account/faucet` without `amount` now sends
`FAUCET_DEFAULT_REQUEST_CHEQ` (default 10,000 CHEQ) instead of the most
that can be requested now. The amount is lowered to the room under the
address cap or the quota left if either is smaller. An explicit `amount`
behaves exactly as before, including the address cap and `429`
responses.

With defaults, one call that omits `amount` leaves 90% of the monthly
quota unused (previously it took all of it).

## Details
- New config `FAUCET_DEFAULT_REQUEST_CHEQ` (invalid values fall back
loudly, like the other faucet settings).
- New pure helper `defaultRequestNcheq` in
`src/helpers/faucet-quota.ts`.
- OpenAPI text (controller and tracked `swagger-api.json`) and README
"Faucet settings" updated.
- The account-bootstrap top-up is a separate path and is unchanged.

## Tests
- Unit: 4 new cases for `defaultRequestNcheq` (default below cap and
quota; above quota left; above room under cap; nothing to send).
- Integration (Postgres, Node 20): 2 new cases (fresh request sends
10,000 and leaves 90,000; nearly full address gets only the room left).
37/37 pass.
- The existing "tops up to the cap" test now passes an explicit `amount:
40000`, as it relied on the old omit-amount behaviour.
- `tsc --noEmit` and Prettier clean.

## Risks
Callers relying on the old omit-amount behaviour to top up to the cap
now get 10,000 per call; they can pass `amount` to ask for more.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
## [3.18.0-develop.5](3.18.0-develop.4...3.18.0-develop.5) (2026-10-14)

### Features

* **faucet:** send a default amount when none is requested ([#841](#841)) ([7864611](7864611))
* **faucet:** send a default amount when none is requested ([#841](#841)) ([#842](#842)) ([18223ce](18223ce))
@fraseragain
fraseragain marked this pull request as ready for review October 14, 2026 15:53
@fraseragain
fraseragain merged commit 6020d4a into main Oct 14, 2026
3 checks passed
fraseragain pushed a commit that referenced this pull request Oct 14, 2026
The merge of #845 did not start any workflows on main, so no release, image or production migrations ran. The cause was wording in the PR description that GitHub reads as an instruction not to run workflows.

This commit is empty. It only gives main a new push so the release workflows run.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
fraseragain added a commit that referenced this pull request Oct 14, 2026
Empty commit that gives `main` a new push, so the release workflows run
for v3.18.0.

## Why
#845 was merged into `main` (merge commit `6020d4a`), but no workflows
started. GitHub builds the merge commit message from the PR description,
and the description contained wording that GitHub reads as an
instruction not to run workflows on push. As a result there was no
Semantic Release run, no production image and no production migrations.
Production is unchanged (still v3.17.0).

## What this does
- No file changes. The only change is one empty commit.
- Merging it triggers the normal pipeline on `main`: Semantic Release
(expected version 3.18.0, because the `feat` commits from #845 are
already in `main`'s history since the last tag), the production Docker
image, and the production database migrations (three new ones, all
additive to the new `faucetRequest` table).

## Before merging
- Production env vars are set (`TESTNET_INITIAL_TOPUP_CHEQ`,
`FAUCET_ADDRESS_CAP_CHEQ`) and the production `database-migrations` job
has been removed from the app spec.
- Keep the merge commit message plain: it must not contain any wording
that tells GitHub to bypass workflows.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
@cheqd-bot

cheqd-bot Bot commented Oct 14, 2026

Copy link
Copy Markdown

🎉 This PR is included in version 3.18.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

@cheqd-bot cheqd-bot Bot added the released label Oct 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Development

Successfully merging this pull request may close these issues.

3 participants