All notable, externally-visible changes go here. The single source of truth
for the version number is collateral_provider/api/__init__.py
(__version__); the OpenAPI schema and /healthz both read from it.
The format follows Keep a Changelog, and we aim to follow Semantic Versioning for the public HTTP contract:
- MAJOR when a request/response shape changes incompatibly (e.g. a required field is renamed without a deprecation window).
- MINOR when we add an endpoint, an optional request field, or a response field that existing clients can ignore.
- PATCH for bug fixes that don't change the contract.
- Optional
additional_utxosrequest field. Forwarded to Ogmios asadditionalUtxoon theevaluateTransactioncall, so callers can splice in UTxOs that don't yet exist on chain (chained-tx / future-input scenarios). Missing or empty is skipped silently. Each entry may be either a[txin, txout]pair of objects (the prose-docs shape) or a flat Ogmios v6Utxoobject (the JSON-RPC schema shape — what callers learn from Koios directly); both are accepted in the same request and normalized internally to the flat shape Ogmios actually expects on the wire. The JSON-encoded field is capped at 32 KiB and at most 400 entries — malformed or oversized input fails locally instead of burning a Koios round-trip. tx_hashandduration_mson the success log line. JSON formatter surfaces them as top-level fields; text formatter embeds them in the message. Operators can now grep "did we sign tx X" by hash.- Container-platform deploy shape.
Dockerfile,docker-entrypoint.sh, and.do/app.yamlbundle a one-command DigitalOcean App Platform deploy: push tomain→ DO rebuilds the image and rolls it out behind their TLS-terminating router. Signing keys enter the runtime viaSKEY_CONTENTS/VKEY_CONTENTSSECRET env vars (or a mounted volume); the entrypoint materializes them to/run/keys/(tmpfs) before exec'ing gunicorn so the existingApiConfig.ready()signing-key check is satisfied. Operator runbook indocs/DEPLOY.md. whitenoisedependency for in-process static-file serving so the deployed container needs no separate web server. Picked the non-manifest variant — the manifest one breaks template rendering whenevercollectstatichasn't run yet, which includes the test suite.- CIDR support in
TRUSTED_PROXY_IPS. Each entry is parsed viaipaddress.ip_networkso single hosts (127.0.0.1) and blocks (10.0.0.0/8) both work. Container platforms that don't pin a single LB egress IP need this; without it the per-IP throttle would collapse into a global one. - CI Docker smoke test. A second job in the workflow builds the
image, boots the container with synthetic env, and curls
/healthz/. Catches Dockerfile / entrypoint / collectstatic / static- serving regressions the unit tests can't.
- Dependabot config for
pipandgithub-actionsecosystems. Weekly schedule, security updates grouped.
- JSON-only request bodies on
/<env>/collateral/. DRF's default also accepted form-encoded and multipart, which was undocumented surface area. Non-JSON content types now return 415. - Body-size cap dropped from Django's 2.5 MiB default to a value
derived from
MAX_TX_SIZEandADDITIONAL_UTXOS_MAX_BYTES(≈ 64 KiB by default). The protocol caps a tx at 16 KiB binary (32 KiB hex); the derived cap leaves room for the JSON envelope andadditional_utxoswhile making oversized junk cheap to reject. MAX_TX_SIZEandADDITIONAL_UTXOS_MAX_BYTESare now env-overridable from settings, so a future hard-fork that bumps the protocol parameter doesn't require a code redeploy.- Validation orchestration moved out of the serializer into
api.services.collateral.issue_witness. The serializer is now shape-only; the service runs the validator chain, calls Koios, and returns(witness_hex, tx_hash_hex). No external behavior change. - gunicorn switched from sync workers to
gthread(2 workers × 8 threads). The whole product is IO-bound on Koios; the previous 2-sync-workers shape blocked one request per worker for the full upstream RTT. - Trailing slash now optional on
/api/docs,/api/schema, and/api/redoc. Both/api/docsand/api/docs/resolve directly. /healthzand/known_hosts/now setCache-Control: no-storeso an upstream proxy can't serve a stale "ok" after the keys go missing or a stale registry after an operator edit./healthz503 body no longer leaks signing-key paths. Publicproblemslist is label-only ("skey unreadable"); the path stays in the WARNING-level log line for operator debug.- Landing page and
/known_hosts/are now GET-only. A POST to either previously returned 200; now returns 405. settings.pyno longer hard-fails when.envis missing. Container-platform deploys inject configuration viaos.environ; there is no file.django-environreads from the process env when no file is present. The required-vars check below still fails loudly if anything actually needed is unset.- CI
pip-auditnow also auditsrequirements-dev.txt.
- Legacy
tx_bodyrequest field. The transition release that accepted bothtxandtx_bodyis over. Clients must sendtx. Sendingtx_bodynow returns a 400 (txfield required).
A second polish pass focused on observability, 12-factor configuration, and operator self-service. Fully backward-compatible with 1.1: every existing client request and response shape is still honored.
- Single
__version__constant incollateral_provider/api/__init__.py. Both the OpenAPI schema (SPECTACULAR_SETTINGS['VERSION']) and the/healthzresponse read from it. One bump location, no drift. - JSON logging (
LOG_FORMAT=json). Stdlib-only formatter atapi.log_format.JsonFormatteremits one JSON object per record withlevel,time,logger,module,message,request_id, plus anyextra={...}kwargs as top-level keys. Default staystextso existing log scraping is unaffected. - Prometheus
/metricsendpoint. Off by default (METRICS_ENABLED=False→ 404); when on, restricted toMETRICS_ALLOW_IPS(default127.0.0.1,::1). Metrics are aggregated and low-cardinality (no per-IP / per-PKH / per-tx labels) in line with the privacy goal:collateral_http_requests_total{environment, status}collateral_http_request_duration_seconds{environment}collateral_koios_requests_total{environment, outcome}— outcomes:success,tx_invalid,timeout,request_error,http_error,invalid_jsoncollateral_koios_request_duration_seconds{environment}
- Hot-reloadable JSON data files for ban list and known-hosts
registry.
bans.json(path:BANS_PATH) andknown.hosts.json(path:KNOWN_HOSTS_PATH) are re-read on the next request after the file's mtime advances — operators can update them without bouncing the service. Atomic write-tmp-then-rename is the expected edit workflow.bans.json.examplechecked in with the canonical shape; the real file is gitignored. txrequest field as the canonical name on the collateral endpoint. The legacy field nametx_bodywas misleading (the value is the whole transaction CBOR, not just the body) and is now a deprecated alias that still works for one transition release. Sending bothtxandtx_bodyis rejected.TRUSTED_PROXY_IPSallowlist for X-Forwarded-For. The service only honors XFF when the immediate peer is in the list (default127.0.0.1,::1). A misconfigured deploy where gunicorn is reachable directly can no longer be tricked into accepting a spoofed source IP via XFF. Empty list disables XFF entirely.
_load_known_hostsno longer useslru_cache(maxsize=1). The cache was effectively forever; reloading required a process restart. Now uses the same mtime-aware loader as the ban list.- The DRF default throttle classes setting was removed (had been a
global default that any new endpoint would inherit). Endpoints now
state their throttle explicitly.
/metricsand/healthzopt out via@throttle_classes([]). landing_pageandknown_hosts_viewno longer have FileNotFoundError branches; the loader returns{}for a missing file, which is the same shape as an empty registry.
_client_ipnow refuses to honor X-Forwarded-For from untrusted peers. Documented inSECURITY.mdunder the operator hardening checklist./metricsis not exposed unless explicitly enabled, and even then only to allow-listed IPs — implementation-detail observability data should not be world-readable.
- Stale
captcha/directory (only contained__pycache__from a long-removed feature). Added__pycache__/and*.pycto the root.gitignoreso the same accident can't recur. lru_cacheimport fromviews.py(no longer needed after the known-hosts refactor).
This release is the result of a substantial cleanup pass on top of the 1.0 production codebase. The HTTP contract is fully backward-compatible with 1.0; everything below is either additive or an internal change.
GET /healthz— liveness/readiness probe. Returns200 {"status": "ok", "version": "..."}when signing keys andknown.hosts.jsonare readable;503 {"status": "error", "problems": [...]}otherwise. Not rate-limited.X-Request-IDresponse header on every request. The id is also stamped on every log line emitted during the request, for log-correlation. Honors a client-suppliedX-Request-ID(capped at 64 chars) if sent.- OpenAPI 3 schema at
/api/schema/, Swagger UI at/api/docs/, ReDoc at/api/redoc/. - Configuration via
.envfor nearly every operational knob:SKEY_PATH,VKEY_PATH,COLLATERAL_THROTTLE_RATE,LOG_LEVEL,LOG_FILE,CACHE_DIR,PREPROD_KOIOS_URL,MAINNET_KOIOS_URL. Seecollateral_provider/sample.env. - CI on GitHub Actions: ruff, tests, coverage, OpenAPI schema validation,
pip-audit. See
.github/workflows/ci.yml. SECURITY.mdwith vulnerability reporting and operator hardening checklist.
- Every 4xx/5xx response now uses the same shape:
{"detail": "<message>"}. Previously the API returned{"tx_body": ["..."]}for validator errors,{"error": "..."}for invalid environment, and{"detail": "..."}for everything else. Internal serializer field names no longer leak. - Koios calls now have a
(3s connect, 5s read)timeout. Network failures surface as503 Validation Service Unavailablerather than masquerading as400 Transaction Fails Validation(which previously misled users about whether their transaction was bad). - The
/<env>/collateral/rate limit is configurable via theCOLLATERAL_THROTTLE_RATEenv var (default60/min). - The throttle now uses a file-based cache backend (
.cache/by default, overridable viaCACHE_DIR) so multi-worker gunicorn shares per-IP counters. - Log levels normalized: 4xx events are
WARNING, notERROR. The error log is now actionable instead of full of routine user-input rejections.
pycardanodependency (was being used for one symbol,OrderedSet, which dragged in 30+ transitive deps includingblockfrost-python,cardano-tools,ogmios,cose,mnemonic). Replaced withcbor2.CBORTag(258, sorted(...))— byte-equivalent encoding, no transitives.HandleDisallowedHostMiddleware— was duplicating Django's built-inALLOWED_HOSTScheck and silently rejecting Django's test client (which made several of the test cases pass for the wrong reason).DEFAULT_THROTTLE_CLASSESglobal — was a footgun where any new endpoint would inherit a1/minrate. Endpoints now opt in explicitly to the throttle they want.
- 14 tests in
test_cbor.pyhadassertIn(...)calls placed insidewith self.assertRaises(...)blocks, after the line that raised. They were never executing, so the asserted error messages were never checked. Moved out of thewithblock; updated the messages to match the actual Title-Case strings the validator emits. - Patched a class of pre-existing test bugs where the host middleware
was rejecting Django's default
Host: testserverand tests that expected400from the validators were getting400 Invalid Host Headerfrom the middleware. View tests now use@override_settings(ALLOWED_HOSTS=["testserver"])so they exercise the actual validation pipeline.
- 32 known dependabot CVEs at the start of this release → 0 at the end, via dropping unused deps and bumping the rest to current patches.
- HTTPS posture: removed dead
SECURE_*settings that were setting things to their Django defaults (and one was strictly worse — the explicitSECURE_HSTS_SECONDS = 0in the production block disabled HSTS where Django's default behavior would have respected the proxy). AddedSECURE_PROXY_SSL_HEADERsorequest.is_secure()works behind a TLS-terminating reverse proxy. - Signing keys are read once at startup via
api.apps.ApiConfig.ready(). A misconfigured deploy (missing or unreadablepayment.skey/payment.vkey) now fails to start rather than 500'ing on the first POST.
Initial production release. See git history before commit 1e19a15
for the full pre-1.1 history.