From 3935f46b6b8659128778736eda8a03d7da63c262 Mon Sep 17 00:00:00 2001 From: Ant Somers Date: Sat, 11 Jul 2026 17:03:07 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(decdn=5Fnode):=20add=20Arbitrum=20conf?= =?UTF-8?q?ig=20parity=20=E2=80=94=20origin=20directory,=20blacklist,=20ca?= =?UTF-8?q?che=20origin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the decdn_node role to parity with the upstream Arbitrum Sepolia node config example (examples/configs/arbitrum-sepolia.toml), whose rendered node.toml was missing several [blockchain] fields and had no [cache.origin] backend. - Expose the ADR 022 origin-directory pair (origin_assignment_address + publisher_registry_address, origin_directory_from_block), the ADR 011/031 content_blacklist_address (+ from_block), and a [cache.origin] pull-through backend (http/fs/s3) — each optional and omitted from node.toml when unset, following the existing ADR-028 slash-appeal knob pattern. - Add fail-loud asserts: origin-directory both-or-neither, blacklist address when set, integer-shape *_from_block, and cache-origin kind + required subfields (incl. decompress enum + path_style boolean shape). - Ship a ready-to-use Arbitrum Sepolia host_vars example with the real chain 421614 genesis addresses, cited to contracts/deployments/421614.json. Field names verified against decdn/crates/common/src/config/types.rs (BlockchainConfig; OriginConfig is a tagged enum kind=http|fs|s3). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../host_vars/decdn-node-1.yml.example | 44 +++++++--- ansible/roles/decdn_node/README.md | 24 +++++- ansible/roles/decdn_node/defaults/main.yml | 28 +++++++ ansible/roles/decdn_node/tasks/main.yml | 82 +++++++++++++++++++ .../roles/decdn_node/templates/node.toml.j2 | 38 +++++++++ 5 files changed, 200 insertions(+), 16 deletions(-) diff --git a/ansible/inventory/host_vars/decdn-node-1.yml.example b/ansible/inventory/host_vars/decdn-node-1.yml.example index 85daf93..0cccf14 100644 --- a/ansible/inventory/host_vars/decdn-node-1.yml.example +++ b/ansible/inventory/host_vars/decdn-node-1.yml.example @@ -2,29 +2,49 @@ # Per-node deployment values. Copy to inventory/host_vars/.yml # (git-ignored) and fill in. The node refuses to start until these are set. # -# Protocol facts (chain id, contract addresses) are NOT invented here — source -# them from the canonical deployment / an ADR in decdn/adr/ and cite it. +# The contract addresses below are the canonical Arbitrum Sepolia (chain 421614) +# v0.1.0 genesis deploy, cited to decdn/contracts/deployments/421614.json +# (deployBlock 11249862) — public on-chain facts, not invented here. If you target +# a different deployment, replace them (and chain_id) with that deployment's values. +# MUST-EDIT lines are yours to fill; the rest match the genesis deploy. # Pinned release to install (a v GitHub Release must exist). decdn_node_version: "0.1.0" # Recommended: pin the tarball's sha256 (no checksums file is published upstream). decdn_node_sha256: "" -# --- Chain (required; sourced from the deployment / ADR — do not guess) ------- -decdn_rpc_url: "https://YOUR-ARB-SEPOLIA-RPC/with-key" # SENSITIVE (may embed an API key) +# --- Chain (Arbitrum Sepolia, chain 421614) ----------------------------------- +# MUST-EDIT — SENSITIVE (may embed an API key). The public endpoint below works +# for light use; run your own or use a provider for production reliability. +decdn_rpc_url: "https://sepolia-rollup.arbitrum.io/rpc" decdn_chain_id: 421614 # Arbitrum Sepolia -# Non-hex sentinels so a half-filled copy fails the deploy-time asserts loudly -# (the zero address would otherwise slip through). Replace with real addresses. -decdn_payment_channel_address: "0xREPLACE_PaymentChannel_FROM_DEPLOYMENT" -decdn_capacity_bond_address: "0xREPLACE_CapacityBond_FROM_DEPLOYMENT" -decdn_slash_judge_address: "0xREPLACE_SlashJudge_FROM_DEPLOYMENT" +# Required contracts (deployments/421614.json). +decdn_payment_channel_address: "0xb4bcA0AbF679212708164dCAb98eFa621Fa0F4d3" # PaymentChannel +decdn_capacity_bond_address: "0x2aF490628579c08DC6D0B013090cDC11D4d60Dd4" # CapacityBond +decdn_slash_judge_address: "0x20abcCC80F595a586f4Bd906a1b00c4196416EB7" # SlashJudge # --- Slash appeals (optional; ADR 028) ---------------------------------------- -# decdn_slash_appeal_address: "0xREPLACE_SlashAppeal_FROM_DEPLOYMENT" # for `decdn appeal slash` -# decdn_slash_judge_from_block: 0 # SlashJudge deploy block — set to bound per-restart RPC rescan +decdn_slash_appeal_address: "0x0D60c0AbffBb5D612B66DB6bDd0d741BB36072cF" # SlashAppeal +decdn_slash_judge_from_block: 11249862 # SlashJudge deploy block — bounds per-restart rescan + +# --- Origin directory (optional; ADR 022 — set both or neither) --------------- +decdn_origin_assignment_address: "0x06b394f497481c33FB5ae164d5FbB0307299546c" # OriginAssignment +decdn_publisher_registry_address: "0x32c811eA20326B911B518c2Cf38f9f172aC02e6c" # PublisherRegistry +decdn_origin_directory_from_block: 11249862 # PublisherRegistry deploy block + +# --- Content blacklist compliance (optional; ADR 011/031) --------------------- +decdn_content_blacklist_address: "0xd4b2b7CC768c14004dC3400743E2EDB0724355f3" # ContentBlacklist +decdn_content_blacklist_from_block: 11249862 # ContentBlacklist deploy block + +# --- Cache pull-through origin (what the node fetches on a cache miss) --------- +# MUST-EDIT — operator-specific backing origin (NOT a chain fact). A serving node +# needs one, else cache misses fail NoOrigin. http shown; see defaults/main.yml for +# fs / s3 fields. +decdn_cache_origin_kind: "http" +decdn_cache_origin_url: "https://your-origin.example/" # --- Node identity / locale --------------------------------------------------- -decdn_region: "US" # ISO 3166-1 alpha-2 of the node's physical location +decdn_region: "US" # MUST-EDIT — ISO 3166-1 alpha-2 of the node's physical location # decdn_relay_url: "" # optional iroh relay for NAT traversal # --- Economics ---------------------------------------------------------------- diff --git a/ansible/roles/decdn_node/README.md b/ansible/roles/decdn_node/README.md index 6b86bac..e111e00 100644 --- a/ansible/roles/decdn_node/README.md +++ b/ansible/roles/decdn_node/README.md @@ -52,10 +52,26 @@ Per `decdn/adr/019-node-onboarding.md`, a node only serves paid traffic after `decdn_region` (ISO 3166-1 alpha-2). Contract addresses/chain-id are protocol facts — source them from the deployment / an ADR, never guess. -Optional (omitted from `node.toml` unless set): `decdn_slash_appeal_address` -(SlashAppeal contract, source from the deployment / ADR 028 — only needed to file -appeals with `decdn appeal slash`) and `decdn_slash_judge_from_block` (SlashJudge -deploy block; bounds the slash-detection watcher's per-restart chain rescan). See +Optional (omitted from `node.toml` unless set): + +- `decdn_slash_appeal_address` — SlashAppeal contract (ADR 028); only needed to file + appeals with `decdn appeal slash`, and `decdn_slash_judge_from_block` — SlashJudge + deploy block; bounds the slash-detection watcher's per-restart chain rescan. +- `decdn_origin_assignment_address` + `decdn_publisher_registry_address` — the ADR 022 + chain-backed origin directory that gates DHT prefetch. **Set both or neither** (either + alone fails the deploy-time assert); `decdn_origin_directory_from_block` bounds its + per-restart log replay. +- `decdn_content_blacklist_address` — the ADR 011/031 compliance watcher (evicts + blacklisted blobs in your region scope); `decdn_content_blacklist_from_block` bounds + its per-restart log replay. Unset ⇒ no watcher (serving a blacklisted hash past its + compliance window is then slashable with no local protection). +- `decdn_cache_origin_kind` (`http`|`fs`|`s3`) + that kind's fields — the pull-through + origin the node fetches on a cache miss. **A serving node needs one:** unset ⇒ no + `[cache.origin]` and cache misses fail `NoOrigin` (the node can only serve blobs it + already holds). + +Source contract addresses / chain-id from the deployment +(`contracts/deployments/.json`) or an ADR — never guess. See `roles/decdn_node/defaults/main.yml` for the full knob list and defaults. ## Network diff --git a/ansible/roles/decdn_node/defaults/main.yml b/ansible/roles/decdn_node/defaults/main.yml index 26f7377..bfa06fb 100644 --- a/ansible/roles/decdn_node/defaults/main.yml +++ b/ansible/roles/decdn_node/defaults/main.yml @@ -25,6 +25,22 @@ decdn_slash_appeal_address: "" # here on EVERY daemon start, so 0 (the upstream default) re-scans the full chain # each restart — RPC-heavy on an established L2. Set to bound restart cost. decdn_slash_judge_from_block: 0 +# Optional (ADR 022). Chain-backed origin directory that gates DHT prefetch. Set +# BOTH or NEITHER — either alone fails the deploy-time assert. Empty => both keys +# omitted and the directory is deny-all (prefetch finds no authorized origins). +decdn_origin_assignment_address: "" +decdn_publisher_registry_address: "" +# Optional. PublisherRegistry deploy block: bounds the origin-directory log replay +# on restart (absent/0 => scans the whole chain — RPC-heavy on an established L2). +# Only emitted when the pair above is set AND this is > 0. +decdn_origin_directory_from_block: 0 +# Optional (ADR 011/031). ContentBlacklist watcher — evicts blacklisted blobs in +# your region scope. Empty => no watcher (serving a blacklisted hash past its +# compliance window is then slashable with no local protection). +decdn_content_blacklist_address: "" +# Optional. ContentBlacklist deploy block; bounds the blacklist log replay on +# restart. Only emitted when decdn_content_blacklist_address is set AND this is > 0. +decdn_content_blacklist_from_block: 0 decdn_region: "" # ISO 3166-1 alpha-2 decdn_chain_id: 421614 # Arbitrum Sepolia (matches decdn-node's --chain-id default) @@ -38,6 +54,18 @@ decdn_rate_per_mb: 10 # USDC base units (6 decimals) # --- Cache -------------------------------------------------------------------- decdn_cache_size_mb: 10240 # 10 GB decdn_max_blob_size_mb: 1024 # 1 GB +# Pull-through origin (#437): what the node fetches on a cache miss. Empty kind => +# the [cache.origin] table is omitted and misses fail NoOrigin — a serving node +# needs an origin. Pick ONE kind and set that kind's fields; the others are ignored. +decdn_cache_origin_kind: "" # "" (omit) | http | fs | s3 +decdn_cache_origin_url: "" # http: base URL (blobs served at {url}/{blake3_hex}) +decdn_cache_origin_decompress: "" # http, optional: "auto" (default) | "strict" +decdn_cache_origin_path: "" # fs: filesystem root +decdn_cache_origin_s3_bucket: "" # s3: bucket name +decdn_cache_origin_s3_region: "" # s3: AWS region (used for SigV4 even with a custom endpoint) +decdn_cache_origin_s3_endpoint_url: "" # s3, optional: custom endpoint for R2/B2/MinIO (omit for AWS) +decdn_cache_origin_s3_path_style: false # s3, optional: path-style addressing (MinIO & many self-hosted) +decdn_cache_origin_s3_prefix: "" # s3, optional: key prefix prepended to every object # --- Observability (loopback only) -------------------------------------------- decdn_log_level: info diff --git a/ansible/roles/decdn_node/tasks/main.yml b/ansible/roles/decdn_node/tasks/main.yml index a8dd300..c76640c 100644 --- a/ansible/roles/decdn_node/tasks/main.yml +++ b/ansible/roles/decdn_node/tasks/main.yml @@ -64,6 +64,88 @@ deploy block). Got "{{ decdn_slash_judge_from_block }}". Leave it 0 to scan from genesis, or set the deploy block to bound per-restart rescan cost. +# The ADR 022 origin directory needs BOTH OriginAssignment + PublisherRegistry (it +# gates prefetch on the pair); the template emits them together, so reject a +# one-sided config here. Runs when EITHER is set — an empty counterpart then fails +# the address regex, turning "both or neither" into a loud, specific failure. +- name: Validate the optional origin-directory contract pair (ADR 022) + ansible.builtin.assert: + that: + - decdn_origin_assignment_address is match('^0x[0-9a-fA-F]{40}$') + - decdn_publisher_registry_address is match('^0x[0-9a-fA-F]{40}$') + - decdn_origin_assignment_address != decdn_zero_address + - decdn_publisher_registry_address != decdn_zero_address + fail_msg: >- + The origin directory (ADR 022) needs BOTH decdn_origin_assignment_address and + decdn_publisher_registry_address set to valid contract addresses (0x + 40 hex, + not the zero address) — or BOTH empty to omit it. Source them from the + deployment (contracts/deployments/.json) / ADR 022. + vars: + decdn_zero_address: "0x0000000000000000000000000000000000000000" + when: >- + (decdn_origin_assignment_address | length > 0) + or (decdn_publisher_registry_address | length > 0) + +# content_blacklist_address is optional (ADR 011/031); a half-filled/zero value +# would silently render a bad node.toml, so validate it fail-loud when present — +# same posture as the SlashAppeal address. +- name: Validate the optional ContentBlacklist address when set + ansible.builtin.assert: + that: + - decdn_content_blacklist_address is match('^0x[0-9a-fA-F]{40}$') + - decdn_content_blacklist_address != decdn_zero_address + fail_msg: >- + decdn_content_blacklist_address is set but is not a valid contract address + (0x + 40 hex, not the zero address). Leave it empty to omit it, or set the + deployed ContentBlacklist address (source it from the deployment / ADR 011). + vars: + decdn_zero_address: "0x0000000000000000000000000000000000000000" + when: decdn_content_blacklist_address | length > 0 + +# The *_from_block knobs feed `| int > 0` gates in node.toml.j2, and Jinja's int +# filter silently coerces an unparseable value to 0 — dropping the key and +# reverting to a full-chain rescan with no trace. Assert integer shape (like +# slash_judge_from_block) so a typo fails loud. Unconditional: the default 0 passes. +- name: Validate the origin-directory and blacklist scan-floor blocks are integers + ansible.builtin.assert: + that: + - decdn_origin_directory_from_block | string is match('^[0-9]+$') + - decdn_content_blacklist_from_block | string is match('^[0-9]+$') + fail_msg: >- + decdn_origin_directory_from_block and decdn_content_blacklist_from_block must + be non-negative integers (the respective contract deploy blocks). Got + "{{ decdn_origin_directory_from_block }}" / "{{ decdn_content_blacklist_from_block }}". + Leave them 0 to scan from genesis, or set the deploy block to bound rescan cost. + +# The cache pull-through origin is a tagged [cache.origin] table: node.toml.j2 +# emits only the chosen kind's fields, and the daemon denies unknown fields. Reject +# an unknown kind or a kind missing its required field(s) so a serving node never +# starts with an unusable (or NoOrigin) cache backend. +- name: Validate the cache pull-through origin when set + ansible.builtin.assert: + that: + - decdn_cache_origin_kind in ["http", "fs", "s3"] + - (decdn_cache_origin_kind != "http") or (decdn_cache_origin_url | length > 0) + - (decdn_cache_origin_kind != "fs") or (decdn_cache_origin_path | length > 0) + - >- + (decdn_cache_origin_kind != "s3") + or (decdn_cache_origin_s3_bucket | length > 0 + and decdn_cache_origin_s3_region | length > 0) + # Optional http knob — the daemon's DecompressMode enum accepts only these + # two; an unchecked typo renders TOML that fails config load as a crash-loop. + - decdn_cache_origin_decompress in ["", "auto", "strict"] + # path_style feeds a `| bool` gate in node.toml.j2. Assert a real boolean so a + # quoted "false" fails here rather than silently coercing to path_style = true. + - decdn_cache_origin_s3_path_style | string | lower in ["true", "false"] + fail_msg: >- + decdn_cache_origin_kind must be one of http|fs|s3 and carry that kind's + required fields — http: decdn_cache_origin_url; fs: decdn_cache_origin_path; + s3: decdn_cache_origin_s3_bucket + decdn_cache_origin_s3_region. + decdn_cache_origin_decompress, if set, must be auto|strict, and + decdn_cache_origin_s3_path_style must be a boolean (true/false). Leave the + kind empty to omit [cache.origin] (cache misses then fail NoOrigin). + when: decdn_cache_origin_kind | length > 0 + # --- User & directories ------------------------------------------------------- - name: Create decdn system group ansible.builtin.group: diff --git a/ansible/roles/decdn_node/templates/node.toml.j2 b/ansible/roles/decdn_node/templates/node.toml.j2 index a69b5f3..df20361 100644 --- a/ansible/roles/decdn_node/templates/node.toml.j2 +++ b/ansible/roles/decdn_node/templates/node.toml.j2 @@ -26,6 +26,19 @@ slash_appeal_address = "{{ decdn_slash_appeal_address }}" {% if decdn_slash_judge_from_block | int > 0 %} slash_judge_from_block = {{ decdn_slash_judge_from_block | int }} {% endif %} +{% if decdn_origin_assignment_address | length > 0 %} +origin_assignment_address = "{{ decdn_origin_assignment_address }}" +publisher_registry_address = "{{ decdn_publisher_registry_address }}" +{% if decdn_origin_directory_from_block | int > 0 %} +origin_directory_from_block = {{ decdn_origin_directory_from_block | int }} +{% endif %} +{% endif %} +{% if decdn_content_blacklist_address | length > 0 %} +content_blacklist_address = "{{ decdn_content_blacklist_address }}" +{% if decdn_content_blacklist_from_block | int > 0 %} +content_blacklist_from_block = {{ decdn_content_blacklist_from_block | int }} +{% endif %} +{% endif %} [payment] rate_per_mb = {{ decdn_rate_per_mb }} @@ -34,6 +47,31 @@ rate_per_mb = {{ decdn_rate_per_mb }} cache_dir = "{{ decdn_cache_dir }}" cache_size_mb = {{ decdn_cache_size_mb }} max_blob_size_mb = {{ decdn_max_blob_size_mb }} +{% if decdn_cache_origin_kind | length > 0 %} + +[cache.origin] +kind = "{{ decdn_cache_origin_kind }}" +{% if decdn_cache_origin_kind == "http" %} +url = "{{ decdn_cache_origin_url }}" +{% if decdn_cache_origin_decompress | length > 0 %} +decompress = "{{ decdn_cache_origin_decompress }}" +{% endif %} +{% elif decdn_cache_origin_kind == "fs" %} +path = "{{ decdn_cache_origin_path }}" +{% elif decdn_cache_origin_kind == "s3" %} +bucket = "{{ decdn_cache_origin_s3_bucket }}" +region = "{{ decdn_cache_origin_s3_region }}" +{% if decdn_cache_origin_s3_endpoint_url | length > 0 %} +endpoint_url = "{{ decdn_cache_origin_s3_endpoint_url }}" +{% endif %} +{% if decdn_cache_origin_s3_path_style | bool %} +path_style = true +{% endif %} +{% if decdn_cache_origin_s3_prefix | length > 0 %} +prefix = "{{ decdn_cache_origin_s3_prefix }}" +{% endif %} +{% endif %} +{% endif %} [observability] log_level = "{{ decdn_log_level }}" From 64522d05f70c841c48543b7a109dbaddaf8fe730 Mon Sep 17 00:00:00 2001 From: Ant Somers Date: Sat, 11 Jul 2026 17:10:22 +0300 Subject: [PATCH 2/2] fix(decdn_node): scope cache-origin value-shape asserts to their kind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review (Copilot + gemini): the decompress and path_style asserts fired regardless of decdn_cache_origin_kind, so a leftover value for a non-selected kind — which the template never renders — could fail an unrelated deploy, contradicting the 'other kinds' fields are ignored' contract. Gate the decompress check to kind==http and path_style to kind==s3. Also switch path_style to `is boolean` so a quoted "false" is rejected loudly rather than accepted by the prior string check (which contradicted its own comment). Co-Authored-By: Claude Opus 4.8 (1M context) --- ansible/roles/decdn_node/tasks/main.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/ansible/roles/decdn_node/tasks/main.yml b/ansible/roles/decdn_node/tasks/main.yml index c76640c..327c05d 100644 --- a/ansible/roles/decdn_node/tasks/main.yml +++ b/ansible/roles/decdn_node/tasks/main.yml @@ -131,12 +131,14 @@ (decdn_cache_origin_kind != "s3") or (decdn_cache_origin_s3_bucket | length > 0 and decdn_cache_origin_s3_region | length > 0) - # Optional http knob — the daemon's DecompressMode enum accepts only these - # two; an unchecked typo renders TOML that fails config load as a crash-loop. - - decdn_cache_origin_decompress in ["", "auto", "strict"] - # path_style feeds a `| bool` gate in node.toml.j2. Assert a real boolean so a - # quoted "false" fails here rather than silently coercing to path_style = true. - - decdn_cache_origin_s3_path_style | string | lower in ["true", "false"] + # Validate each kind's optional value-shape ONLY for the selected kind — a + # leftover value for a non-selected kind is never rendered, so it must not + # fail an unrelated deploy. http: DecompressMode accepts only auto|strict (a + # typo would render TOML the daemon rejects at load — an opaque crash-loop). + - (decdn_cache_origin_kind != "http") or (decdn_cache_origin_decompress in ["", "auto", "strict"]) + # s3: path_style feeds a `| bool` gate in node.toml.j2; require a REAL boolean + # so a quoted "false" is rejected here, not silently coerced to path_style = true. + - (decdn_cache_origin_kind != "s3") or (decdn_cache_origin_s3_path_style is boolean) fail_msg: >- decdn_cache_origin_kind must be one of http|fs|s3 and carry that kind's required fields — http: decdn_cache_origin_url; fs: decdn_cache_origin_path;