From 310e11e8a653db1c28872cee25b5a4f14732acd2 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 3 Sep 2026 06:43:04 -0700 Subject: [PATCH 01/52] docs(e2e-test): count SLASH among the covered actions now that the capability slash suite exists --- components/e2e-test/README.md | 2 +- components/e2e-test/architecture.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/components/e2e-test/README.md b/components/e2e-test/README.md index ff2e090..e2d4cb1 100644 --- a/components/e2e-test/README.md +++ b/components/e2e-test/README.md @@ -42,7 +42,7 @@ sequenceDiagram ## Features -- **30 ACTION test suites**: ADDRESS, AIRDROP, BATCH, BROADCAST, CALLBACK, COINPAY, COLLECT, DELEGATE, DEPLOY, DEPOSIT, DESTROY, DISPENSER, DIVIDEND, EXECUTE, FILE, ISSUE, LINK, LIST, MESSAGE, MINT, ORDER, PRICE, ROLLCALL, SEND, SLEEP, STAKE, SWAP, SWEEP, UNSTAKE, WITHDRAW +- **31 ACTION test suites**: ADDRESS, AIRDROP, BATCH, BROADCAST, CALLBACK, COINPAY, COLLECT, DELEGATE, DEPLOY, DEPOSIT, DESTROY, DISPENSER, DIVIDEND, EXECUTE, FILE, ISSUE, LINK, LIST, MESSAGE, MINT, ORDER, PRICE, ROLLCALL, SEND, SLASH, SLEEP, STAKE, SWAP, SWEEP, UNSTAKE, WITHDRAW **How that number is counted:** one suite per ACTION name, with every version of an action folded into a single entry, so ISSUE V0 through V5 counts once and SEND V0 through V3 counts once. An ACTION name is counted when a suite under `test/actions/` builds a payload for it, whether directly or through a helper it loads, and the name is recognised by the decoder's `VALID_ACTION_NAMES`. The figure is not a file count: 69 files collapse onto these 29 names because reorg, negative, and variant suites re-test actions already listed. Regenerate it with `node scripts/count-action-suites.js` (add `--json` for the per-suite breakdown); `test/unit/scripts/actionSuiteCount.test.js` fails if this list and the tree disagree. Actions exercised only by other tiers, such as BET and VOTE in `test/sdk/` or ATTEST and NODEPROOF in `test/federation/`, are outside this count. - **9 service connectors**: BlockchainConnector (axios, Basic Auth), XChainUtxoTrackerConnector, XChainEncoderConnector, XChainDecoderConnector, XChainIndexerConnector, XChainExplorerConnector, XChainHubConnector (multi-endpoint failover), RegtestMinerConnector, and Database (MariaDB connection pool) diff --git a/components/e2e-test/architecture.md b/components/e2e-test/architecture.md index f9fc354..23e8592 100644 --- a/components/e2e-test/architecture.md +++ b/components/e2e-test/architecture.md @@ -178,7 +178,7 @@ xchain-e2e-test/ │ ├── initialCheck.test.js # Mocha root hooks (beforeAll/afterAll) │ ├── cryptoHelper.js # BIP39/BIP32 wallet management │ ├── transactionHelper.js # PSBT construction, signing, broadcast -│ ├── actions/ # 76 action test files (live, ordered), covering 30 ACTION names +│ ├── actions/ # 77 action test files (live, ordered), covering 31 ACTION names │ ├── helpers/ # 50 modules (action helpers + federation/fee/utility helpers) │ ├── unit/ # 350+ unit tests (stubbed, no services) │ ├── integration/ # 150+ integration tests (stubbed I/O) From 18a220bb8b39d7d18167a759118a7b3711011783 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 3 Sep 2026 06:43:06 -0700 Subject: [PATCH 02/52] docs(utxo-tracker): document the widened per-chain undo window and the rollback budget it bounds --- components/utxo-tracker/architecture.md | 2 +- components/utxo-tracker/configuration.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/components/utxo-tracker/architecture.md b/components/utxo-tracker/architecture.md index 1a95cd9..0be9a18 100644 --- a/components/utxo-tracker/architecture.md +++ b/components/utxo-tracker/architecture.md @@ -91,7 +91,7 @@ Two string keys are also used as checkpoints: **H key (output hint)**: Maps an outpoint (txHash8 + index) back to its scriptHash. When processing an input that spends an output, the tracker reads the H hint to find the scriptHash, then deletes the corresponding O record. Without H, the tracker would need to scan all O records to find the one being spent. -**K/M keys (deleted archives)**: When a UTXO is spent, the O and H records are deleted, but copies are saved as K and M records keyed by blockHash. If a reorg rolls back that block, the K/M records are restored to O/H. After `DEFAULT_UNDO_BLOCKS` (BTC: 12, LTC: 48, DOGE: 120) subsequent blocks, the K/M records are purged. +**K/M keys (deleted archives)**: When a UTXO is spent, the O and H records are deleted, but copies are saved as K and M records keyed by blockHash. If a reorg rolls back that block, the K/M records are restored to O/H. After `DEFAULT_UNDO_BLOCKS` (BTC: 12, LTC: 120, DOGE: 120) subsequent blocks, the K/M records are purged. **txHash8 truncation**: Transaction hashes are truncated to 8 bytes in index keys (T, I, O, H, J, K, M, W). The full 32-byte hash is stored in O values for API responses. 8-byte truncation provides sufficient uniqueness for index lookups while halving key sizes. diff --git a/components/utxo-tracker/configuration.md b/components/utxo-tracker/configuration.md index 126bf78..bd3dcdc 100644 --- a/components/utxo-tracker/configuration.md +++ b/components/utxo-tracker/configuration.md @@ -125,7 +125,7 @@ These values are defined in `src/XChainUtxoTracker.js` and are not configurable | `DB_TRANSACTION_BLOCKS_QUANTITY` | `200` | Number of blocks per LevelDB batch commit | | `PREFETCH_SIZE` | `10` | Number of blocks pre-fetched concurrently | | `ETA_WINDOW_BLOCKS` | `1000` | Rolling window size for sync ETA calculation | -| `DEFAULT_UNDO_BLOCKS` | BTC: `12` / LTC: `48` / DOGE: `120` | Per-chain K/M archive retention window; override per coin via `XCHAIN_UNDO_BLOCKS_BTC`, `XCHAIN_UNDO_BLOCKS_LTC`, `XCHAIN_UNDO_BLOCKS_DOGE` | +| `DEFAULT_UNDO_BLOCKS` | BTC: `12` / LTC: `120` / DOGE: `120` | Per-chain K/M archive retention window; override per coin via `XCHAIN_UNDO_BLOCKS_BTC`, `XCHAIN_UNDO_BLOCKS_LTC`, `XCHAIN_UNDO_BLOCKS_DOGE` | ### Storage From 7d8e3ee35fa8174cc0d0a7c79b164424905350e0 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 3 Sep 2026 06:43:08 -0700 Subject: [PATCH 03/52] protocol(attest): document the per-block admission caps and the EXECUTE an over-cap request reverts --- components/e2e-test/architecture.md | 2 +- protocol/actions/attest.md | 1 + protocol/constants.js | 8 ++++++-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/components/e2e-test/architecture.md b/components/e2e-test/architecture.md index 23e8592..1423d88 100644 --- a/components/e2e-test/architecture.md +++ b/components/e2e-test/architecture.md @@ -178,7 +178,7 @@ xchain-e2e-test/ │ ├── initialCheck.test.js # Mocha root hooks (beforeAll/afterAll) │ ├── cryptoHelper.js # BIP39/BIP32 wallet management │ ├── transactionHelper.js # PSBT construction, signing, broadcast -│ ├── actions/ # 77 action test files (live, ordered), covering 31 ACTION names +│ ├── actions/ # 78 action test files (live, ordered), covering 31 ACTION names │ ├── helpers/ # 50 modules (action helpers + federation/fee/utility helpers) │ ├── unit/ # 350+ unit tests (stubbed, no services) │ ├── integration/ # 150+ integration tests (stubbed I/O) diff --git a/protocol/actions/attest.md b/protocol/actions/attest.md index 4b40d4d..050377f 100644 --- a/protocol/actions/attest.md +++ b/protocol/actions/attest.md @@ -83,6 +83,7 @@ System-synthesized expiry for request abc...def - `CONTRACT_INDEX` (carried via `EMITTER`) must reference an existing contract. - `REQUEST_ID` is verified by re-deriving from `tx_hash:root_action_index:emitter_path:contract_index:emitter_position` (colon-delimited; defends against compromised VM). - Admission flag-day (`ATTEST_ADMISSION_ACTIVATION` in `protocol/constants.js`; mainnet 961000, testnet/regtest genesis): at/above the height, a request whose responsible set at its own block is smaller than `REDUNDANCY` (e.g. after the stake-weighted-quorum source-dedupe) is rejected at admission, since the v1 path can never collect `REDUNDANCY` signatures from a smaller set. Below the height the request is accepted and expires at `DEADLINE_BLOCK` unchanged (replay bit-identical). +- Per-block admission caps (`ATTEST_REQUEST_CAP_ACTIVATION` and `ATTEST_REQUEST_CAPS` in `protocol/constants.js`; testnet/regtest genesis, mainnet unset): a block admits at most `perContract` (2) requests from any one contract and `perBlock` (10) in total, counted over the admitted v0s earlier in the same block (`action_index` order, which is total, so every node counts the same prefix). An admitted request obliges `REDUNDANCY` validators to make a provider call the requester does not pay for, so the ceiling is what bounds validator spend; the per-contract share stops one contract taking the whole ceiling. Over-cap produces `invalid: ATTEST cap (…)`, which, being an emission validation failure, fails the whole enclosing EXECUTE: the over-cap request, the under-cap requests the same EXECUTE already emitted, and its state writes all roll back together. A contract that needs more than two attestations spaces them across blocks. Where the activation height is unset the rule is inert and admission is uncapped. #### Responsible-set selection diff --git a/protocol/constants.js b/protocol/constants.js index d8e69a4..fa315b3 100644 --- a/protocol/constants.js +++ b/protocol/constants.js @@ -651,10 +651,14 @@ const ATTEST_ADMISSION_ACTIVATION = { // same for a free HTTP GET as for a paid model. On a fee-bearing network economics bound the // shape; on testnet neither the coin nor XCHAIN is scarce, so the bound must be consensus. // -// REJECTION, not deferral: unlike the three sibling per-block caps (XCALL_MAX_CALLS_PER_BLOCK, +// REFUSAL, not deferral: unlike the three sibling per-block caps (XCALL_MAX_CALLS_PER_BLOCK, // ATTEST_MAX_EXPIRIES_PER_BLOCK, CROSS_SETTLE_MAX_PER_BLOCK) this caps admission of an action // already in the block rather than a pass the indexer schedules, so there is no next block to -// carry overflow into. Over-cap requests go 'rejected' (terminal at creation, fee never escrowed). +// carry overflow into. An ATTEST v0 exists only as a VM emission and a failed emission validation +// fails its enclosing EXECUTE, so the refusal lands as a REVERTED EXECUTE: the over-cap request, +// the under-cap requests the same EXECUTE already emitted, and its state writes all roll back +// together, and no ATTEST v0 row is ever stored 'rejected'. The only durable trace is the cap's +// message on contract_executions.error_message. Driven on BTC regtest 2026-09-02. // // Counted deterministically from the attests table at (block_index = this block, action_index < // this action, request_status <> 'rejected'): a total order every node replays identically. From 08a64cc43c65209d7708b7fef887b9c7c23e2686 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 3 Sep 2026 06:43:10 -0700 Subject: [PATCH 04/52] docs(hub): document the oracle takeover ambiguous-send cooldown knob --- components/hub/configuration.md | 1 + 1 file changed, 1 insertion(+) diff --git a/components/hub/configuration.md b/components/hub/configuration.md index 2169698..c1ecfe3 100644 --- a/components/hub/configuration.md +++ b/components/hub/configuration.md @@ -263,6 +263,7 @@ Controls `OraclePublisher`, which broadcasts finalized price rounds on-chain as | `ORACLE_PUBLISH_ENABLED` | No | `true` | Set to `false` to stop this hub publishing oracle rounds on-chain. Consensus participation is unaffected. | | `ORACLE_PUBLISH_FAILOVER_WINDOW_BLOCKS` | No | `0` | How many blocks a hub waits before re-assembling a publishing window its leader left dark. A hub publishes only the windows it leads, so without this a window whose leader never broadcasts stays unpublished forever; followers take over in rank order, one at a time rather than all at once. `0` disables takeover, which is the default because taking over spends DOGE on a window a peer may already have paid for. Arm it per deployment once the observation feed is known to be working. | | `ORACLE_PUBLISH_BLOCK_MS` | No | `600000` | Approximate block time in milliseconds, used only to convert `ORACLE_PUBLISH_FAILOVER_WINDOW_BLOCKS` into a takeover timer. The default is the Bitcoin ten-minute target; set it lower on a faster chain, or the takeover wait is longer than intended. Values that are not a positive number fall back to the default. | +| `ORACLE_TAKEOVER_AMBIGUOUS_COOLDOWN_MS` | No | `ORACLE_PUBLISH_FAILOVER_WINDOW_BLOCKS × ORACLE_PUBLISH_BLOCK_MS` | How long a follower holds off taking over a window when something says a batch for it may already be on the wire, but unmined. A hub can only see batches that have been mined, so a leader whose transaction is stuck in the mempool looks the same as a leader that never sent, and stepping in pays the DOGE fee twice. The wait starts either from the co-signature this hub gave that window's leader or from an ambiguous broadcast of its own; once it passes with the window still absent from the chain, the earlier transaction never landed and the takeover goes ahead. `0` removes the wait. Only meaningful when takeover is armed. | | `ORACLE_PUBLISH_CONFIRM_CHECK_MS` | No | `300000` | How often the publisher checks that the transactions it broadcast actually confirmed. Recording a transaction id proves only that it was sent, so without this a batch that never mines leaves the hub reporting a healthy last publication forever. Results appear in `getoraclepublisherstatus`. `0` disables the check. | | `ORACLE_PUBLISH_CONFIRM_STALE_MS` | No | `1800000` | How long a published transaction may stay unconfirmed before it is logged as stale. Detection only: nothing is re-broadcast and no fee is bumped, because a transaction still sitting in a mempool may yet confirm and re-sending would pay twice for the same rounds. | | `ORACLE_PUBLISH_ALLOW_UNCONFIRMED_INPUTS` | No | `false` | Whether a published batch may be funded from this hub's own unconfirmed change. Off by default: miners judge a transaction by its whole ancestor package, so one cheap early transaction holds down every batch chained behind it, however much the newest one pays. Leaving it off means a hub with no confirmed output defers the window instead, which is recoverable. Turn it on only for a venue that mines on demand, such as regtest, where waiting for a confirmation would stall the harness. | From aaf2da69e518926a104e09a90d09e8563da2f494 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 3 Sep 2026 06:43:12 -0700 Subject: [PATCH 05/52] docs(hub): mark the consensus-uniform oracle and XCHAIN price knobs regtest only --- components/hub/configuration.md | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/components/hub/configuration.md b/components/hub/configuration.md index c1ecfe3..7879738 100644 --- a/components/hub/configuration.md +++ b/components/hub/configuration.md @@ -251,8 +251,8 @@ The hub reads the BTC chain tip to anchor consensus rounds. These gates stop a s | `ORACLE_FINALIZED_MAX` | No | `10000` | Cap on retained finalized-round records held in memory. | | `ORACLE_SUBMISSIONS_RETENTION_ROUNDS` | No | _(unset)_ | Number of past rounds of raw price submissions to retain. Unset keeps the built-in retention. | | `ORACLE_PUBLISHED_ROUNDS_RETENTION_ROUNDS` | No | `12960` | Number of recent rounds of published-round markers to keep, roughly 90 days at the default round interval. Set to `0` to disable pruning and keep every marker. Only confirmed markers are ever pruned: a marker for a round whose on-chain state is still unknown is a quarantine record an operator reconciles by hand, so those are always retained. | -| `ORACLE_ALLOW_UNVERIFIED_PAIRS` | No | `false` | Set to `true` to accept price pairs that have not been verified. Loosens a fail-closed check; intended for bring-up, not production. | -| `ORACLE_MAX_PRICE_AGE_SECONDS` | No | _(coin registry, per pair)_ | Maximum age of an oracle price before it is treated as stale. Resolution order is `p2pConfig` → this variable → the per-pair value pinned in the coin registry. The registry value is never a hardcoded literal, so a coordinated release that changes the pin cannot silently diverge the hub's advisory from the indexer's gate. Setting this per-host overrides that pin: do it deliberately, and match it across the federation. | +| `ORACLE_ALLOW_UNVERIFIED_PAIRS` | Regtest only | `false` | Set to `true` to co-sign a proposed pair this hub can verify against nothing (no live local aggregate and no finalized history). It stands down a Byzantine-leader defense, so it is honored **only on regtest**: on mainnet, testnet, and a standalone hub with no `HUB_NETWORK`, it is ignored (and logged) and unverifiable-pair co-sign stays fail-closed. A real federation always has a second fetcher, so the hatch has no legitimate use there. | +| `ORACLE_MAX_PRICE_AGE_SECONDS` | Regtest only | _(coin registry, per pair)_ | Maximum age of an oracle price before it is treated as stale. Resolution order is `p2pConfig` → this variable → the per-pair value pinned in the coin registry. The bound is consensus-pinned: it is content-hashed into `CONSENSUS_CONFIG_PIN`, and the indexer reads only the pinned bundle with no override path of its own. So the override is honored **only on regtest**; on mainnet, testnet, and standalone it is ignored (and logged) in favour of the pinned bound. Honoring it elsewhere would detach this hub's fee quotes, and the `oracleMaxPriceAgeSeconds` it reports over `getoraclesubmissions`, from the bound they claim to mirror: quoting rounds the fleet's fee gate rejects, or refusing rounds it accepts. To change the staleness gate for real, change the pinned coin bundle. | ### Oracle Publishing @@ -465,10 +465,25 @@ The XCHAIN/USD price is derived from platform-realized fills rather than an exte | `XCHAIN_PRICE_INDEXER_DB_USER` | No | None | Database user | | `XCHAIN_PRICE_INDEXER_DB_SECRET` | No | None | Database password. Deprecated name `XCHAIN_PRICE_INDEXER_DB_PASS` is still read; see Secret variable naming above. Treat as a credential: supply it from the deployment environment, never a checked-in file. | | `XCHAIN_PRICE_INDEXER_DB_COIN` | No | `BTC` | Chain whose fills the price is derived from | -| `XCHAIN_PRICE_WINDOW_BLOCKS` | No | _(built-in)_ | Rolling window, in blocks, over which fills are aggregated | -| `XCHAIN_PRICE_MIN_BTC_VOLUME` | No | _(built-in)_ | Minimum BTC-notional volume in the window before a derived price is considered valid | -| `XCHAIN_PRICE_CONFIRMATION_BUFFER` | No | _(built-in)_ | Confirmations a fill needs before it counts toward the derived price | -| `XCHAIN_PRICE_BOOTSTRAP_SATS` | No | `1000` | Bootstrap XCHAIN price in SATOSHIS, used before enough on-platform volume exists to derive one. Converted to USD at round time with the consensus BTC/USD, so it is never a USD pin. Consensus-critical: a per-operator value forks fee acceptance | + +The four derivation parameters below are **consensus-uniform**, not per-operator +tuning. Every validator has to compute the same window over the same fills, so a +hub honoring a local override would produce a different XCHAIN/BTC leg, land +outside the co-sign deviation band, and expose itself to slashing. They are +therefore **honored on regtest only**: on mainnet and testnet the hub logs a +`set but IGNORED` warning and uses the consensus-pinned value regardless of what +the environment says, and so does a standalone hub with no `HUB_NETWORK`. +Retuning them for real is a coordinated flag-day change to the pinned values, +never an operator environment variable. The per-operator +`XCHAIN_PRICE_INDEXER_DB_*` source settings above are not gated this way: those +are per-validator by design. + +| Variable | Required | Default | Description | +|---|---|---|---| +| `XCHAIN_PRICE_WINDOW_BLOCKS` | Regtest only | _(built-in)_ | Rolling window, in blocks, over which fills are aggregated. Ignored (and logged) off regtest. | +| `XCHAIN_PRICE_MIN_BTC_VOLUME` | Regtest only | _(built-in, supersession disabled)_ | Minimum BTC-notional volume in the window before a derived price supersedes the carry-forward. `0` means any realized volume supersedes, which is what an e2e drill sets to prove the derived branch at all. Ignored (and logged) off regtest. | +| `XCHAIN_PRICE_CONFIRMATION_BUFFER` | Regtest only | _(built-in)_ | Confirmations a fill needs before it counts toward the derived price. Ignored (and logged) off regtest. | +| `XCHAIN_PRICE_BOOTSTRAP_SATS` | Regtest only | `1000` | Bootstrap XCHAIN price in SATOSHIS, used before enough on-platform volume exists to derive one. Converted to USD at round time with the consensus BTC/USD, so it is never a USD pin. Ignored (and logged) off regtest: a per-operator value would fork fee acceptance. | ### LLM Attestation Provider From b20925eab8b440da3261fb0d2610a14d8a463262 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 3 Sep 2026 06:43:14 -0700 Subject: [PATCH 06/52] docs(hub): document the local rate-limit exemption and the JSON-RPC 429 --- components/hub/configuration.md | 3 ++- components/hub/operations.md | 20 +++++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/components/hub/configuration.md b/components/hub/configuration.md index 7879738..6e54901 100644 --- a/components/hub/configuration.md +++ b/components/hub/configuration.md @@ -97,7 +97,8 @@ These variables are required regardless of operating mode. | `HUB_DB_USER` | Yes | None | MariaDB username | | `HUB_DB_SECRET` | Yes | None | MariaDB password. Deprecated name `HUB_DB_PASS` is still read; see Secret variable naming above. | | `HUB_DB_KEEPALIVE_INTERVAL` | No | `30000` | Interval (ms) between no-op keepalive queries sent to the MariaDB pool to prevent idle-connection drops | -| `HUB_RATE_LIMIT_RPM` | No | `100` | Requests allowed per IP per 60-second window across the whole API. Over the limit the request returns HTTP 429. Behind a reverse proxy the limiter keys on `X-Forwarded-For`, which is what `HUB_TRUST_PROXY` below governs. | +| `HUB_RATE_LIMIT_RPM` | No | `100` | Requests allowed per IP per 60-second window across the whole API. Over the limit the request returns HTTP 429 with a JSON-RPC error body (code `-32029`) naming the limit, the window and the seconds to wait, plus `Retry-After` and `RateLimit-*` headers. Behind a reverse proxy the limiter keys on `X-Forwarded-For`, which is what `HUB_TRUST_PROXY` below governs. | +| `HUB_RATE_LIMIT_EXEMPT_LOCAL` | No | `true` | Exempts callers whose resolved client IP is loopback or private-range (RFC1918, IPv6 unique-local and link-local) from the per-IP limit above. This is what lets a node's own indexer rebuild price history from the chain at the shipped default: it replays one `pushpricebatch` per batch-bearing block, far faster than 100/min, and reaches the hub over the container bridge. The check runs on the post-`trust proxy` client IP, so a public caller arriving through a private-IP reverse proxy is still limited. Set to `false` to enforce the cap on every caller. | | `HUB_MAX_RPC_BATCH` | No | `20` | Maximum call objects in one JSON-RPC batch array. The rate limiter above charges one token per HTTP request while the dispatcher runs every element of the batch, so without this cap one request amplifies past the limit. Over the cap the hub answers `400` with JSON-RPC error `-32600`. Every hub connector sends a single call object, so the cap breaks no existing client. | | `HUB_TRUST_PROXY` | No | `loopback, uniquelocal` | Express `trust proxy` setting. A containerized hub behind a local reverse proxy works with the default. Set to `false` to disable, a hop count (e.g. `1`), or a CIDR list for other topologies. See [Express docs](https://expressjs.com/en/guide/behind-proxies.html). | | `HUB_ALLOW_UNAUTHENTICATED` | No | `false` | A hub in validator mode (`P2P_VALIDATOR_ADDR` set) with no `HUB_API_KEY` refuses to boot, because its write methods would let anyone drive consensus-affecting writes. Set to `true` to explicitly acknowledge running keyless (regtest/dev only). See OPERATIONS.md → Authentication. | diff --git a/components/hub/operations.md b/components/hub/operations.md index d098dd2..de6b7eb 100644 --- a/components/hub/operations.md +++ b/components/hub/operations.md @@ -190,7 +190,25 @@ flowchart TD ### Rate Limiting -The API is rate-limited to 100 requests per minute per IP (configurable via `HUB_RATE_LIMIT_RPM`). Exceeding the limit returns HTTP 429. Behind a reverse proxy, the limiter keys on `X-Forwarded-For` (Express `trust proxy` defaults to loopback; override with `HUB_TRUST_PROXY`). +The API is rate-limited to 100 requests per minute per IP (configurable via `HUB_RATE_LIMIT_RPM`). Behind a reverse proxy, the limiter keys on `X-Forwarded-For` (Express `trust proxy` defaults to loopback; override with `HUB_TRUST_PROXY`). + +Exceeding the limit returns HTTP 429 with a JSON-RPC error body, so a client parsing the response reads the reason rather than a parse failure: + +```json +{ + "jsonrpc": "2.0", + "id": 41, + "error": { + "code": -32029, + "message": "hub rate limit exceeded: 100 requests per 60s per IP (HUB_RATE_LIMIT_RPM); retry after 60s", + "data": { "limit": 100, "windowMs": 60000, "retryAfterSeconds": 60, "policy": "per-ip", "env": "HUB_RATE_LIMIT_RPM" } + } +} +``` + +The response also carries `Retry-After` and the `RateLimit-*` headers, so a client that cannot read the body still learns the limit and the wait. + +Callers on loopback or a private range (RFC1918, IPv6 unique-local and link-local) skip the limit by default. That exemption is what lets a node's own indexer rebuild price history from the chain without a raised limit: it pushes one price batch per batch-bearing block as fast as it reads blocks, which is far past 100/min, and it reaches the hub over the container bridge rather than the internet. The check runs on the client IP Express resolves after `trust proxy`, so a public caller arriving through a private-IP reverse proxy is still limited. Set `HUB_RATE_LIMIT_EXEMPT_LOCAL=false` to enforce the cap on every caller. ### Public Deployment Behind a Reverse Proxy From 33b150718f5e83d3162b52947ffbd1a402fc13f7 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 3 Sep 2026 06:49:53 -0700 Subject: [PATCH 07/52] docs(e2e-test): count the action test files from the committed tree The tree listing claimed a file tally taken from a working-tree scan, which runs ahead of the committed count whenever a lane holds unlanded suites. Register the committed number and drop the two count exceptions the ACTION set now covers on its own. --- components/e2e-test/architecture.md | 2 +- test/action-count-claims.test.js | 16 +++++----------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/components/e2e-test/architecture.md b/components/e2e-test/architecture.md index 1423d88..bdd4b06 100644 --- a/components/e2e-test/architecture.md +++ b/components/e2e-test/architecture.md @@ -178,7 +178,7 @@ xchain-e2e-test/ │ ├── initialCheck.test.js # Mocha root hooks (beforeAll/afterAll) │ ├── cryptoHelper.js # BIP39/BIP32 wallet management │ ├── transactionHelper.js # PSBT construction, signing, broadcast -│ ├── actions/ # 78 action test files (live, ordered), covering 31 ACTION names +│ ├── actions/ # 75 action test files (live, ordered), covering 31 ACTION names │ ├── helpers/ # 50 modules (action helpers + federation/fee/utility helpers) │ ├── unit/ # 350+ unit tests (stubbed, no services) │ ├── integration/ # 150+ integration tests (stubbed I/O) diff --git a/test/action-count-claims.test.js b/test/action-count-claims.test.js index f9f3d7e..75388a6 100644 --- a/test/action-count-claims.test.js +++ b/test/action-count-claims.test.js @@ -112,17 +112,11 @@ const WIRE_SCOPED = [ ]; const SCOPED = [ - { file: 'components/e2e-test/architecture.md', claim: '76 action', count: 1, - why: 'test files, several per action; git ls-files xchain-e2e-test/test/actions on 2026-09-02 ' - + 'after the attestation widening drive landed. Count the COMMITTED tree: an ' - + 'untracked suite from another lane inflates a filesystem scan, and the gate gates the commit' }, - { file: 'components/e2e-test/architecture.md', claim: '30 ACTION names', count: 1, - why: 'ACTION names those 75 files cover, one entry per name with versions folded in; ' - + 'regenerated by xchain-e2e-test/scripts/count-action-suites.js' }, - { file: 'components/e2e-test/README.md', claim: '30 ACTION', count: 1, - why: 'test suites counted one per ACTION name with versions folded in, not the ' - + 'ACTION set; the bullet enumerates all 30 and states the rule, and ' - + 'xchain-e2e-test/test/unit/scripts/actionSuiteCount.test.js re-derives it from the tree' }, + { file: 'components/e2e-test/architecture.md', claim: '75 action', count: 1, + why: 'test files, several per action; git ls-files xchain-e2e-test/test/actions on 2026-09-03. ' + + 'Count the COMMITTED tree: an untracked suite from another lane inflates a filesystem ' + + 'scan, and the gate gates the commit. count-action-suites.js scans the working tree, so ' + + 'its file tally runs ahead of this number whenever a lane holds unlanded suites' }, { file: 'components/indexer/architecture.md', claim: '48 action', count: 1, why: 'handler classes in xchain-indexer src/actions.js, not the ACTION set; ' + 'requires, instantiations and dispatch cases all counted 48 on 2026-08-06' }, From d1c69d6bc07427f27e99b0a4632ba65d64f4fab0 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 3 Sep 2026 08:27:11 -0700 Subject: [PATCH 08/52] docs(e2e-test): recount the action suites, files and helper modules from the tree The published figures counted two slash suites that are not in the repo, so the e2e-test repo's own recount test refused them. The tree carries 30 ACTION test suites across 76 files with 48 helper modules; SLASH leaves the enumerated list with them. --- components/e2e-test/README.md | 4 ++-- components/e2e-test/architecture.md | 4 ++-- test/action-count-claims.test.js | 9 ++++++++- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/components/e2e-test/README.md b/components/e2e-test/README.md index e2d4cb1..0fe7608 100644 --- a/components/e2e-test/README.md +++ b/components/e2e-test/README.md @@ -42,7 +42,7 @@ sequenceDiagram ## Features -- **31 ACTION test suites**: ADDRESS, AIRDROP, BATCH, BROADCAST, CALLBACK, COINPAY, COLLECT, DELEGATE, DEPLOY, DEPOSIT, DESTROY, DISPENSER, DIVIDEND, EXECUTE, FILE, ISSUE, LINK, LIST, MESSAGE, MINT, ORDER, PRICE, ROLLCALL, SEND, SLASH, SLEEP, STAKE, SWAP, SWEEP, UNSTAKE, WITHDRAW +- **30 ACTION test suites**: ADDRESS, AIRDROP, BATCH, BROADCAST, CALLBACK, COINPAY, COLLECT, DELEGATE, DEPLOY, DEPOSIT, DESTROY, DISPENSER, DIVIDEND, EXECUTE, FILE, ISSUE, LINK, LIST, MESSAGE, MINT, ORDER, PRICE, ROLLCALL, SEND, SLEEP, STAKE, SWAP, SWEEP, UNSTAKE, WITHDRAW **How that number is counted:** one suite per ACTION name, with every version of an action folded into a single entry, so ISSUE V0 through V5 counts once and SEND V0 through V3 counts once. An ACTION name is counted when a suite under `test/actions/` builds a payload for it, whether directly or through a helper it loads, and the name is recognised by the decoder's `VALID_ACTION_NAMES`. The figure is not a file count: 69 files collapse onto these 29 names because reorg, negative, and variant suites re-test actions already listed. Regenerate it with `node scripts/count-action-suites.js` (add `--json` for the per-suite breakdown); `test/unit/scripts/actionSuiteCount.test.js` fails if this list and the tree disagree. Actions exercised only by other tiers, such as BET and VOTE in `test/sdk/` or ATTEST and NODEPROOF in `test/federation/`, are outside this count. - **9 service connectors**: BlockchainConnector (axios, Basic Auth), XChainUtxoTrackerConnector, XChainEncoderConnector, XChainDecoderConnector, XChainIndexerConnector, XChainExplorerConnector, XChainHubConnector (multi-endpoint failover), RegtestMinerConnector, and Database (MariaDB connection pool) @@ -67,7 +67,7 @@ flowchart TD subgraph E2E["xchain-e2e-test"] CH["cryptoHelper
BIP39/BIP32
wallet mgmt"] TH["transactionHelper
PSBT/P2SH"] - AH["action helpers (50 modules)
message construction"] + AH["action helpers (48 modules)
message construction"] SC["Service Connectors (src/)
BlockchainConnector, XChainEncoderConnector
XChainUtxoTrackerConn, XChainDecoderConnector
XChainIndexerConnector, XChainExplorerConnector
XChainHubConnector, RegtestMinerConnector
Database (MariaDB)"] CH --> SC TH --> SC diff --git a/components/e2e-test/architecture.md b/components/e2e-test/architecture.md index bdd4b06..442eb48 100644 --- a/components/e2e-test/architecture.md +++ b/components/e2e-test/architecture.md @@ -178,8 +178,8 @@ xchain-e2e-test/ │ ├── initialCheck.test.js # Mocha root hooks (beforeAll/afterAll) │ ├── cryptoHelper.js # BIP39/BIP32 wallet management │ ├── transactionHelper.js # PSBT construction, signing, broadcast -│ ├── actions/ # 75 action test files (live, ordered), covering 31 ACTION names -│ ├── helpers/ # 50 modules (action helpers + federation/fee/utility helpers) +│ ├── actions/ # 76 action test files (live, ordered), covering 30 ACTION names +│ ├── helpers/ # 48 modules (action helpers + federation/fee/utility helpers) │ ├── unit/ # 350+ unit tests (stubbed, no services) │ ├── integration/ # 150+ integration tests (stubbed I/O) │ │ ├── fixtures/ # mockMariadb, services, dbRows, hub diff --git a/test/action-count-claims.test.js b/test/action-count-claims.test.js index 75388a6..b35fd71 100644 --- a/test/action-count-claims.test.js +++ b/test/action-count-claims.test.js @@ -112,7 +112,14 @@ const WIRE_SCOPED = [ ]; const SCOPED = [ - { file: 'components/e2e-test/architecture.md', claim: '75 action', count: 1, + { file: 'components/e2e-test/README.md', claim: '30 ACTION', count: 1, + why: 'test suites counted one per ACTION name with versions folded in, not the ACTION set; ' + + 'the bullet enumerates all 30 and states the rule, and ' + + 'xchain-e2e-test/test/unit/scripts/actionSuiteCount.test.js re-derives it from the tree' }, + { file: 'components/e2e-test/architecture.md', claim: '30 ACTION names', count: 1, + why: 'ACTION names those 76 files cover, one entry per name with versions folded in; ' + + 'regenerated by xchain-e2e-test/scripts/count-action-suites.js' }, + { file: 'components/e2e-test/architecture.md', claim: '76 action', count: 1, why: 'test files, several per action; git ls-files xchain-e2e-test/test/actions on 2026-09-03. ' + 'Count the COMMITTED tree: an untracked suite from another lane inflates a filesystem ' + 'scan, and the gate gates the commit. count-action-suites.js scans the working tree, so ' From 6490b41b5ef699bc0d3f108c9b6fadb7cd88cfb7 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 3 Sep 2026 08:33:08 -0700 Subject: [PATCH 09/52] docs: document 15 previously-undocumented env vars across five components Covers the encoder/node maintenance-window sentinel pair, three hub oracle consensus/publishing knobs plus two operator drill-script RPC targets, and the four HUB_SYNC_* mirror-drain knobs on both the indexer and its explorer twin, documented from each component's own copy of hub_db_sync.js. Notes that the explorer never calls the resync path HUB_SYNC_BARRIER_HOLD_CEILING_S gates, unlike the indexer's block loop. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0133x7wrwS7q1num9bQ2PT3R --- components/encoder/README.md | 1 + components/explorer/configuration.md | 4 ++++ components/hub/configuration.md | 12 ++++++++++++ components/indexer/configuration.md | 4 ++++ components/node/configuration.md | 1 + 5 files changed, 22 insertions(+) diff --git a/components/encoder/README.md b/components/encoder/README.md index 6f272c4..3ff93b2 100644 --- a/components/encoder/README.md +++ b/components/encoder/README.md @@ -157,6 +157,7 @@ npm run api | `ENCODER_MAX_CONCURRENT_PROBES` | No | `16` | Concurrency cap for cheap probe requests (`GET /status`, `GET /openrpc.json`), which get their own gate so a monitoring flood cannot consume the budget real work needs. Over the cap a probe is refused immediately with `429` and `Retry-After: 1` rather than queued; `0` disables the cap | | `ENCODER_MAX_CONCURRENT_REQUESTS` | No | `50` | Concurrency cap for everything that is not a probe. Same immediate-`429` behaviour, and `0` likewise disables it | | `CORS_ORIGIN` | No | Disabled | CORS origin (`*` to allow all) | +| `ENCODER_MAINTENANCE_FILE` | No | `/tmp/xchain-encoder-maintenance.json` | Path, inside the encoder container, to the maintenance-window sentinel that `GET /status` reads before reporting an unreachable UTXO tracker. When xchain-node's bootstrap stops the tracker for a scheduled publish, it drops a small JSON file here declaring the outage planned; `/status` then folds that in as context alongside the unchanged readiness fields, so the public status board can show "Maintenance" instead of "Degraded" without ever making an unready encoder read ready. Must be set to the same path as xchain-node's `XCHAIN_NODE_ENCODER_MAINTENANCE_FILE`, since that variable is what writes and removes the file this one points at | ## Testing diff --git a/components/explorer/configuration.md b/components/explorer/configuration.md index 53e4795..2fd8deb 100644 --- a/components/explorer/configuration.md +++ b/components/explorer/configuration.md @@ -72,6 +72,10 @@ See [WEBSOCKET.md](websocket.md) for the full WebSocket API reference. | `HUB_RETRY_DELAY_MS` | No | `2000` | Base backoff between hub config retry attempts. Tests set `0`. | | `HUB_DB_SYNC_POLL_INTERVAL` | No | `30000` | Interval in milliseconds between hub-mirror table sync polls. | | `HUB_SYNC_WATERMARK_INTERVAL_MS` | No | `10000` | Interval in milliseconds at which the hub-mirror sync persists its progress watermark. | +| `HUB_SYNC_BARRIER_HOLD_CEILING_S` | No | `900` (15 min) | Seconds a caller may hold at a mirror-completeness barrier before the mirror client is willing to force a resync of its own accord (`requestResync`): tearing down and reconnecting its hub-DB WebSocket, or re-kicking its bootstrap directly in poll mode. `0` disables the forced resync. The explorer's own mirror manager does not currently call `requestResync` anywhere, so setting this here has no observable effect in the explorer today; the mirror client (`hub_db_sync.js`) is a vendored twin of the indexer's, where the block loop does call it against a completeness barrier the explorer has no equivalent of. | +| `HUB_SYNC_BATCH_APPLY` | No | `true` | Set to `false` to disable batched multi-row upserts when applying `price_snapshots` rows during a hub-mirror bootstrap drain, falling back to applying rows one at a time. Throughput only. | +| `HUB_SYNC_BATCH_APPLY_ROWS` | No | `500` | Number of buffered `price_snapshots` rows a hub-mirror bootstrap drain collects before flushing them as one multi-row upsert statement. Values below `2` fall back to the default. | +| `HUB_SYNC_BOOTSTRAP_PROGRESS_MS` | No | `15000` (15s) | Minimum interval between the "bootstrapping <table>: N row(s) fetched..." progress lines a hub-mirror table drain logs while its mirror is bootstrapping, so a cold start shows the drain moving rather than only its final result. `0` silences the periodic line entirely (the drain still logs when it starts and finishes). | | `MIRROR_DB_PASS` | No | None | Password for the hub-mirror schema migration tool, read only by `bin/migrate-hub-mirror.js` and never by the running explorer. Passed in the environment specifically so it stays off the command line: `MIRROR_DB_PASS=… node bin/migrate-hub-mirror.js --host … --user … --schema …`. Treat as a credential. | | `CONFIG_CACHE_FILE` | No | `/tmp/config-cache.json` | Path to the on-disk last-known-good hub config cache. The explorer writes here after each successful hub fetch and reads it on startup when the hub is unreachable, so it comes up serving the last known coin set rather than zero coins. Override to a mounted volume path to survive container recreation. | | `NO_HUB` | No | None | Set to `1` (or `true`/`yes`) to enable standalone mode: the hub is not contacted and all coin/network + database config is read from `src/config.json` (or `NODE_CONFIG`). Use on single-server deployments where the hub publishes docker-internal DB hosts that are not reachable from the explorer process. | diff --git a/components/hub/configuration.md b/components/hub/configuration.md index 6e54901..1c3ed34 100644 --- a/components/hub/configuration.md +++ b/components/hub/configuration.md @@ -254,6 +254,7 @@ The hub reads the BTC chain tip to anchor consensus rounds. These gates stop a s | `ORACLE_PUBLISHED_ROUNDS_RETENTION_ROUNDS` | No | `12960` | Number of recent rounds of published-round markers to keep, roughly 90 days at the default round interval. Set to `0` to disable pruning and keep every marker. Only confirmed markers are ever pruned: a marker for a round whose on-chain state is still unknown is a quarantine record an operator reconciles by hand, so those are always retained. | | `ORACLE_ALLOW_UNVERIFIED_PAIRS` | Regtest only | `false` | Set to `true` to co-sign a proposed pair this hub can verify against nothing (no live local aggregate and no finalized history). It stands down a Byzantine-leader defense, so it is honored **only on regtest**: on mainnet, testnet, and a standalone hub with no `HUB_NETWORK`, it is ignored (and logged) and unverifiable-pair co-sign stays fail-closed. A real federation always has a second fetcher, so the hatch has no legitimate use there. | | `ORACLE_MAX_PRICE_AGE_SECONDS` | Regtest only | _(coin registry, per pair)_ | Maximum age of an oracle price before it is treated as stale. Resolution order is `p2pConfig` → this variable → the per-pair value pinned in the coin registry. The bound is consensus-pinned: it is content-hashed into `CONSENSUS_CONFIG_PIN`, and the indexer reads only the pinned bundle with no override path of its own. So the override is honored **only on regtest**; on mainnet, testnet, and standalone it is ignored (and logged) in favour of the pinned bound. Honoring it elsewhere would detach this hub's fee quotes, and the `oracleMaxPriceAgeSeconds` it reports over `getoraclesubmissions`, from the bound they claim to mirror: quoting rounds the fleet's fee gate rejects, or refusing rounds it accepts. To change the staleness gate for real, change the pinned coin bundle. | +| `ORACLE_ROUND_ABANDON_GRACE_MS` | No | `15000` | Extra slack, on top of a round's own timer ladder (`ORACLE_LEADER_TIMEOUT_MS` + a fixed fallback grace + `ORACLE_FINALIZATION_TIMEOUT`), before this hub's round-abandonment watchdog gives up on a round it opened and never saw finalized. When the watchdog fires, the hub records a locally-skipped row for that round so its own absence of a snapshot is a stated fact rather than a silent hole, which is what lets hub-to-hub round-presence comparison (`getoracleroundpresence`) tell "the whole federation lost this round" apart from "only this hub never saw it". Raising it gives a round already running late more time before it is written off; it never widens the ladder those other timeouts define. | ### Oracle Publishing @@ -269,6 +270,7 @@ Controls `OraclePublisher`, which broadcasts finalized price rounds on-chain as | `ORACLE_PUBLISH_CONFIRM_STALE_MS` | No | `1800000` | How long a published transaction may stay unconfirmed before it is logged as stale. Detection only: nothing is re-broadcast and no fee is bumped, because a transaction still sitting in a mempool may yet confirm and re-sending would pay twice for the same rounds. | | `ORACLE_PUBLISH_ALLOW_UNCONFIRMED_INPUTS` | No | `false` | Whether a published batch may be funded from this hub's own unconfirmed change. Off by default: miners judge a transaction by its whole ancestor package, so one cheap early transaction holds down every batch chained behind it, however much the newest one pays. Leaving it off means a hub with no confirmed output defers the window instead, which is recoverable. Turn it on only for a venue that mines on demand, such as regtest, where waiting for a confirmation would stall the harness. | | `ORACLE_BATCH_WINDOW_ROUNDS` | No | `6` | How many finalized rounds one published action carries. A round does not ride its own transaction: it is buffered, and the whole window leaves together under a single quorum signature set. Hubs configured differently elect different leaders and may publish overlapping windows, which is harmless (ingest is idempotent) but wasteful, so keep this equal across a federation. | +| `ORACLE_BATCH_LANDING_RESERVE_MS` | No | `300000` | Estimated time from a window closing to its published batch being readable on-chain: assembly, the co-signing round, broadcast, and one DOGE confirmation plus indexing. Measured at roughly 180s on public testnet; the default is that with headroom. Subtracted, together with `ORACLE_BATCH_GRACE_MS`, from the fee-price staleness bound when deriving the largest `ORACLE_BATCH_WINDOW_ROUNDS` that still keeps the freshest priced snapshot inside that bound; raising it shrinks the derived window ceiling. | | `ORACLE_BATCH_GRACE_MS` | No | `300000` | How long after a window closes the elected leader waits before assembling it, giving late-finalizing peers time to agree on its contents. Armed once per window and never extended, so a trickle of stragglers cannot postpone a window indefinitely. | | `ORACLE_BATCH_SIGN_TIMEOUT_MS` | No | `60000` | How long the leader waits for a signing quorum on an assembled window. No quorum means no publication for that window: it stays buffered and a later leader can propose it again. | | `ORACLE_BATCH_BUFFER_MAX_ROUNDS` | No | `4032` | Upper bound on buffered rounds, so a hub that never leads a window cannot grow its buffer without limit. Reached only if publication has been failing for a long time; the oldest rounds are dropped first. | @@ -278,6 +280,7 @@ Controls `OraclePublisher`, which broadcasts finalized price rounds on-chain as | `DOGE_ENCODER_URL` | No | _(from config table)_ | Encoder URL used to build DOGE publish transactions. | | `DOGE_ENCODER_API_KEY` | No | _(from config table)_ | API key presented to that encoder when it runs keyed. Treat as a credential. | | `DOGE_LOW_BALANCE_THRESHOLD` | No | `10` | DOGE balance below which the publisher warns that it is running out of funds. | +| `ORACLE_BATCH_CATCHUP_INTERVAL_MS` | No | `3600000` (1 hour) | How often a recurring sweep re-proposes closed batch windows that are still buffered and unpublished (a signing round that failed to reach quorum, for example). Deliberately slow: the refusal it recovers from is either a peer being down or content drift that the leader repairs from `price_snapshots` before re-proposing, and a faster retry would only add a signing round per window per interval across the federation without fixing either cause sooner. | ### Rewards and Slashing @@ -562,6 +565,15 @@ Regtest-only genesis overrides, ignored on mainnet and testnet, which always use | `GOV_VOTING_PERIOD` | No | `604800000` | Governance voting period in milliseconds (default: 7 days) | | `GOVERNANCE_TALLY_INTERVAL` | No | `60000` | Interval between governance tally sweeps | +### Diagnostic Scripts (`bin/`) + +Read-only operator tools; neither broadcasts nor writes anything and neither is read by the running hub process itself. + +| Variable | Required | Default | Description | +|---|---|---|---| +| `HUB_RPC_URL` | No | `http://127.0.0.1:4000` | Hub JSON-RPC base URL `bin/stake-share-drill.js` queries (`getstakeshare`) when no `--hub` flag is given. The drill reports how much more third-party stake the federation can absorb before the stake-weighted quorum commit gate stops being reachable, and what a stake of a given size would do to that margin; used to size a top-up before putting real stake on the network. | +| `HUB_RPC_URLS` | No | _(empty; `--hubs` required instead)_ | Comma-separated hub JSON-RPC URLs `bin/oracle-round-presence.js` polls (`getoracleroundpresence`) when no `--hubs` flag is given. Asks every named hub about the same round range and reports whether the federation agrees on which rounds happened, so a round that finalized on some validators and not others shows up as a named divergence instead of looking like ordinary absence. At least two URLs are required; comparing one hub to itself is refused. | + ## Database Schema The hub uses 20 MariaDB tables, auto-created on startup from `src/sql/`: diff --git a/components/indexer/configuration.md b/components/indexer/configuration.md index 18cd601..05b7f2c 100644 --- a/components/indexer/configuration.md +++ b/components/indexer/configuration.md @@ -70,6 +70,7 @@ Configuration is loaded from a `.env` file and environment variables. Copy the ` | `DOGE_INDEXER_URL` | DOGE indexer JSON-RPC URL the BTC indexer uses to re-prove that a mirrored anchor reward's DOGE anchor was actually mined (`getanchorconfirmations`), before crediting it. `DOGE_INDEXER_API_URL` takes precedence when both are set. Required on a BTC indexer once the anchor-reward derive flag-day is armed: unset, no reward can be proven and the block defers. | _(unset)_ | | `DOGE_INDEXER_API_KEY` | API key sent as `x-api-key` with that read (`getanchorconfirmations` is a federation-read method on the DOGE indexer). | _(unset)_ | | `ANCHOR_PROOF_TIMEOUT_MS` | Per-request timeout for the DOGE anchor proof read, and for the ROLLCALL signer read below. A timeout is treated as "cannot tell", which defers the block; it is never read as "not mined". | `15000` | +| `HUB_SYNC_BARRIER_HOLD_CEILING_S` | How long the block loop may sit deferring at a hub-mirror-completeness barrier before the mirror forces itself to resync: tearing down and reconnecting its hub-DB WebSocket (or, in poll mode, re-kicking the bootstrap directly). A mirror's stream watermark only advances while its bootstrap drain is flagged complete, and nothing else re-arms that flag once a drain has stalled, so a socket can sit open and heartbeating while the mirror certifies nothing, indefinitely; this bounds that wait by a re-drive instead of leaving it unbounded. Purely operational: it opens no barrier and commits no block early, a genuinely-behind mirror keeps deferring after the resync, and the forced resync is rate-limited to once per ceiling window. Seconds; `0` disables it (no forced resync). | `900` (15 min) | **The same DOGE wiring is what ROLLCALL runs on, and it becomes required a second time.** From `ROLLCALL_ACTIVATION` onward, every **BTC** indexer closes each roll-call epoch by asking its DOGE indexer for the epoch's signers (`getrollcallsigners`, a federation-read method served off the committed view). It reuses `DOGE_INDEXER_API_URL` → `DOGE_INDEXER_URL` → config, the `DOGE_INDEXER_API_KEY` header, and `ANCHOR_PROOF_TIMEOUT_MS`; there is no separate env knob for it. @@ -82,6 +83,9 @@ A BTC indexer with no DOGE wiring **defers every block** from the first epoch cl | `HUB_CONFIG_POLL_INTERVAL_MS` | Interval between hub config refresh polls | `60000` | | `HUB_DB_SYNC_POLL_INTERVAL` | Interval between hub-mirror table sync polls (used when `HUB_DB_SYNC_ENABLED=true`) | `30000` | | `HUB_SYNC_WATERMARK_INTERVAL_MS` | Interval at which the hub-mirror sync persists its progress watermark | `10000` | +| `HUB_SYNC_BATCH_APPLY` | Set to `false` to disable batched multi-row upserts when applying `price_snapshots` rows during a hub-mirror bootstrap drain, falling back to applying rows one at a time. Throughput only: no barrier, floor, or mirrored row depends on it. | `true` | +| `HUB_SYNC_BATCH_APPLY_ROWS` | Number of buffered `price_snapshots` rows a hub-mirror bootstrap drain collects before flushing them as one multi-row upsert statement. Values below `2` fall back to the default. | `500` | +| `HUB_SYNC_BOOTSTRAP_PROGRESS_MS` | Minimum interval between the "bootstrapping <table>: N row(s) fetched..." progress lines a hub-mirror table drain logs on startup, so a cold start shows the drain moving rather than only its final result. `0` silences the periodic line entirely (the drain still logs when it starts and finishes). | `15000` (15s) | | `HUB_PUSH_RETRY_INTERVAL_MS` | How often the push-queue poller wakes to drain due rows | `30000` | | `HUB_PUSH_RETRY_BASE_MS` | Base backoff for a failed push. The wait grows as `base × 2^(attempts-1)`, capped at `HUB_PUSH_RETRY_MAX_MS`. | `30000` | | `HUB_PUSH_RETRY_MAX_MS` | Backoff ceiling for push retries | `600000` (10 min) | diff --git a/components/node/configuration.md b/components/node/configuration.md index 6234208..c830988 100644 --- a/components/node/configuration.md +++ b/components/node/configuration.md @@ -181,6 +181,7 @@ signer as fatal. | `XCHAIN_NODE_FORCE_BOOTSTRAP` | Set to `1` to restore a published bootstrap even when the service's data directory is already populated. Normally a populated service is left alone, because the restore wipes that directory. Use this when a restore failed on the install that would have taken it: the service then starts syncing from scratch, which makes it look populated to every later run, so the one chance at a bootstrap is otherwise spent on the attempt that failed. The end-of-install summary names this flag whenever a restore did not happen. | | `XCHAIN_NODE_BOOTSTRAP_MAX_LAG_BLOCKS` | Maximum blocks a bootstrap source may trail the chain tip and still be accepted (default `100`). | | `XCHAIN_NODE_BOOTSTRAP_SKIP_HEALTH_GATE` | Set to `1`/`true`/`yes` to skip the bootstrap source health gate entirely. The gate exists to stop a stale or unhealthy source becoming a published archive; skip it only deliberately. | +| `XCHAIN_NODE_ENCODER_MAINTENANCE_FILE` | Path, inside the coin's encoder container, that `BootstrapService` writes a JSON sentinel into (via `docker exec`, no bind mount or container recreate needed) around the UTXO tracker stop/restart a bootstrap publish performs, and removes again once the publish ends. Default `/tmp/xchain-encoder-maintenance.json`. Declares the resulting encoder outage as planned maintenance rather than a fault, so the public status board shows "Maintenance" instead of "Degraded" for the run. Must be set to the same value as the encoder's own `ENCODER_MAINTENANCE_FILE`, since that is the path the encoder reads back. | ### Go-live gate From 8ffe4713451056db7ad3931cc15d122fdbbf5fd5 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 3 Sep 2026 08:34:41 -0700 Subject: [PATCH 10/52] docs(node): document XCHAIN_NODE_REINDEX_LEDGER_DIR, the reindex-republish ledger override Sixteenth variable added to this pass after the coordinator found it landed on origin (visible to the coverage checker's origin-pointed ref run but not the local checkout this pass started from). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0133x7wrwS7q1num9bQ2PT3R --- components/node/configuration.md | 1 + 1 file changed, 1 insertion(+) diff --git a/components/node/configuration.md b/components/node/configuration.md index c830988..052ed34 100644 --- a/components/node/configuration.md +++ b/components/node/configuration.md @@ -182,6 +182,7 @@ signer as fatal. | `XCHAIN_NODE_BOOTSTRAP_MAX_LAG_BLOCKS` | Maximum blocks a bootstrap source may trail the chain tip and still be accepted (default `100`). | | `XCHAIN_NODE_BOOTSTRAP_SKIP_HEALTH_GATE` | Set to `1`/`true`/`yes` to skip the bootstrap source health gate entirely. The gate exists to stop a stale or unhealthy source becoming a published archive; skip it only deliberately. | | `XCHAIN_NODE_ENCODER_MAINTENANCE_FILE` | Path, inside the coin's encoder container, that `BootstrapService` writes a JSON sentinel into (via `docker exec`, no bind mount or container recreate needed) around the UTXO tracker stop/restart a bootstrap publish performs, and removes again once the publish ends. Default `/tmp/xchain-encoder-maintenance.json`. Declares the resulting encoder outage as planned maintenance rather than a fault, so the public status board shows "Maintenance" instead of "Degraded" for the run. Must be set to the same value as the encoder's own `ENCODER_MAINTENANCE_FILE`, since that is the path the encoder reads back. | +| `XCHAIN_NODE_REINDEX_LEDGER_DIR` | Test/ops override for the directory holding the bootstrap-reindex ledger (`bootstrap-reindex.json`). Default matches `CredentialsService`'s per-user directory, `~/.xchain-node`. `reset` records, per `(module, coin, network)` combo, when it last wiped and rebuilt that combo's data; `bootstrap create`'s scheduler compares that timestamp against the combo's last successful publish and pulls the combo into its plan as due whenever the reindex is newer, even when the normal schedule or tracker opt-in would otherwise have skipped it. Kept outside `XCHAIN_NODE_DATA_DIR` on purpose: a marker stored under the data dir would be erased by the very reset it exists to record, and would not be on the path a publisher staging bootstraps to a different data dir would read. | ### Go-live gate From 13de7029684d49aa8f175288b2b697ad25165831 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 3 Sep 2026 11:06:00 -0700 Subject: [PATCH 11/52] docs(e2e-test): count the helper module the fixture-stake teardown adds The teardown ledger adds one module under test/helpers, so the published figure moves from 48 to 49. --- components/e2e-test/README.md | 2 +- components/e2e-test/architecture.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/components/e2e-test/README.md b/components/e2e-test/README.md index 0fe7608..de9c7eb 100644 --- a/components/e2e-test/README.md +++ b/components/e2e-test/README.md @@ -67,7 +67,7 @@ flowchart TD subgraph E2E["xchain-e2e-test"] CH["cryptoHelper
BIP39/BIP32
wallet mgmt"] TH["transactionHelper
PSBT/P2SH"] - AH["action helpers (48 modules)
message construction"] + AH["action helpers (49 modules)
message construction"] SC["Service Connectors (src/)
BlockchainConnector, XChainEncoderConnector
XChainUtxoTrackerConn, XChainDecoderConnector
XChainIndexerConnector, XChainExplorerConnector
XChainHubConnector, RegtestMinerConnector
Database (MariaDB)"] CH --> SC TH --> SC diff --git a/components/e2e-test/architecture.md b/components/e2e-test/architecture.md index 442eb48..1214fa0 100644 --- a/components/e2e-test/architecture.md +++ b/components/e2e-test/architecture.md @@ -179,7 +179,7 @@ xchain-e2e-test/ │ ├── cryptoHelper.js # BIP39/BIP32 wallet management │ ├── transactionHelper.js # PSBT construction, signing, broadcast │ ├── actions/ # 76 action test files (live, ordered), covering 30 ACTION names -│ ├── helpers/ # 48 modules (action helpers + federation/fee/utility helpers) +│ ├── helpers/ # 49 modules (action helpers + federation/fee/utility helpers) │ ├── unit/ # 350+ unit tests (stubbed, no services) │ ├── integration/ # 150+ integration tests (stubbed I/O) │ │ ├── fixtures/ # mockMariadb, services, dbRows, hub From 0244efe34b6f73e884323a4c3254986d1e1f73aa Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 3 Sep 2026 11:18:18 -0700 Subject: [PATCH 12/52] docs(e2e-test): count the action test file the capability slash coverage adds The capability slash suite adds one file under test/actions, so the published figure moves from 76 to 77. --- components/e2e-test/architecture.md | 2 +- test/action-count-claims.test.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/components/e2e-test/architecture.md b/components/e2e-test/architecture.md index 1214fa0..1f1866d 100644 --- a/components/e2e-test/architecture.md +++ b/components/e2e-test/architecture.md @@ -178,7 +178,7 @@ xchain-e2e-test/ │ ├── initialCheck.test.js # Mocha root hooks (beforeAll/afterAll) │ ├── cryptoHelper.js # BIP39/BIP32 wallet management │ ├── transactionHelper.js # PSBT construction, signing, broadcast -│ ├── actions/ # 76 action test files (live, ordered), covering 30 ACTION names +│ ├── actions/ # 77 action test files (live, ordered), covering 30 ACTION names │ ├── helpers/ # 49 modules (action helpers + federation/fee/utility helpers) │ ├── unit/ # 350+ unit tests (stubbed, no services) │ ├── integration/ # 150+ integration tests (stubbed I/O) diff --git a/test/action-count-claims.test.js b/test/action-count-claims.test.js index b35fd71..da7ca05 100644 --- a/test/action-count-claims.test.js +++ b/test/action-count-claims.test.js @@ -119,7 +119,7 @@ const SCOPED = [ { file: 'components/e2e-test/architecture.md', claim: '30 ACTION names', count: 1, why: 'ACTION names those 76 files cover, one entry per name with versions folded in; ' + 'regenerated by xchain-e2e-test/scripts/count-action-suites.js' }, - { file: 'components/e2e-test/architecture.md', claim: '76 action', count: 1, + { file: 'components/e2e-test/architecture.md', claim: '77 action', count: 1, why: 'test files, several per action; git ls-files xchain-e2e-test/test/actions on 2026-09-03. ' + 'Count the COMMITTED tree: an untracked suite from another lane inflates a filesystem ' + 'scan, and the gate gates the commit. count-action-suites.js scans the working tree, so ' From 7ee8dc20e65c285054f8ef318e03f39e7d3df94d Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 3 Sep 2026 11:29:02 -0700 Subject: [PATCH 13/52] docs(e2e-test): restore SLASH to the covered actions now its suites are on the tree The capability and contract slash suites bring SLASH back into the counted set, so the figure returns to 31 and the two count exceptions are no longer needed. --- components/e2e-test/README.md | 2 +- components/e2e-test/architecture.md | 2 +- test/action-count-claims.test.js | 7 ------- 3 files changed, 2 insertions(+), 9 deletions(-) diff --git a/components/e2e-test/README.md b/components/e2e-test/README.md index de9c7eb..c05be0c 100644 --- a/components/e2e-test/README.md +++ b/components/e2e-test/README.md @@ -42,7 +42,7 @@ sequenceDiagram ## Features -- **30 ACTION test suites**: ADDRESS, AIRDROP, BATCH, BROADCAST, CALLBACK, COINPAY, COLLECT, DELEGATE, DEPLOY, DEPOSIT, DESTROY, DISPENSER, DIVIDEND, EXECUTE, FILE, ISSUE, LINK, LIST, MESSAGE, MINT, ORDER, PRICE, ROLLCALL, SEND, SLEEP, STAKE, SWAP, SWEEP, UNSTAKE, WITHDRAW +- **31 ACTION test suites**: ADDRESS, AIRDROP, BATCH, BROADCAST, CALLBACK, COINPAY, COLLECT, DELEGATE, DEPLOY, DEPOSIT, DESTROY, DISPENSER, DIVIDEND, EXECUTE, FILE, ISSUE, LINK, LIST, MESSAGE, MINT, ORDER, PRICE, ROLLCALL, SEND, SLASH, SLEEP, STAKE, SWAP, SWEEP, UNSTAKE, WITHDRAW **How that number is counted:** one suite per ACTION name, with every version of an action folded into a single entry, so ISSUE V0 through V5 counts once and SEND V0 through V3 counts once. An ACTION name is counted when a suite under `test/actions/` builds a payload for it, whether directly or through a helper it loads, and the name is recognised by the decoder's `VALID_ACTION_NAMES`. The figure is not a file count: 69 files collapse onto these 29 names because reorg, negative, and variant suites re-test actions already listed. Regenerate it with `node scripts/count-action-suites.js` (add `--json` for the per-suite breakdown); `test/unit/scripts/actionSuiteCount.test.js` fails if this list and the tree disagree. Actions exercised only by other tiers, such as BET and VOTE in `test/sdk/` or ATTEST and NODEPROOF in `test/federation/`, are outside this count. - **9 service connectors**: BlockchainConnector (axios, Basic Auth), XChainUtxoTrackerConnector, XChainEncoderConnector, XChainDecoderConnector, XChainIndexerConnector, XChainExplorerConnector, XChainHubConnector (multi-endpoint failover), RegtestMinerConnector, and Database (MariaDB connection pool) diff --git a/components/e2e-test/architecture.md b/components/e2e-test/architecture.md index 1f1866d..d3c9943 100644 --- a/components/e2e-test/architecture.md +++ b/components/e2e-test/architecture.md @@ -178,7 +178,7 @@ xchain-e2e-test/ │ ├── initialCheck.test.js # Mocha root hooks (beforeAll/afterAll) │ ├── cryptoHelper.js # BIP39/BIP32 wallet management │ ├── transactionHelper.js # PSBT construction, signing, broadcast -│ ├── actions/ # 77 action test files (live, ordered), covering 30 ACTION names +│ ├── actions/ # 77 action test files (live, ordered), covering 31 ACTION names │ ├── helpers/ # 49 modules (action helpers + federation/fee/utility helpers) │ ├── unit/ # 350+ unit tests (stubbed, no services) │ ├── integration/ # 150+ integration tests (stubbed I/O) diff --git a/test/action-count-claims.test.js b/test/action-count-claims.test.js index da7ca05..a1f8eab 100644 --- a/test/action-count-claims.test.js +++ b/test/action-count-claims.test.js @@ -112,13 +112,6 @@ const WIRE_SCOPED = [ ]; const SCOPED = [ - { file: 'components/e2e-test/README.md', claim: '30 ACTION', count: 1, - why: 'test suites counted one per ACTION name with versions folded in, not the ACTION set; ' - + 'the bullet enumerates all 30 and states the rule, and ' - + 'xchain-e2e-test/test/unit/scripts/actionSuiteCount.test.js re-derives it from the tree' }, - { file: 'components/e2e-test/architecture.md', claim: '30 ACTION names', count: 1, - why: 'ACTION names those 76 files cover, one entry per name with versions folded in; ' - + 'regenerated by xchain-e2e-test/scripts/count-action-suites.js' }, { file: 'components/e2e-test/architecture.md', claim: '77 action', count: 1, why: 'test files, several per action; git ls-files xchain-e2e-test/test/actions on 2026-09-03. ' + 'Count the COMMITTED tree: an untracked suite from another lane inflates a filesystem ' From 5ccc644166aceabbad8e458d51e1047122cbacdb Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 3 Sep 2026 13:55:35 -0700 Subject: [PATCH 14/52] docs(hub,indexer): document the roll-call, frozen-tip and config-oracle variables The hub's RollcallRound and StateCheckpointEngine and the indexer's hub client landed reads of nine variables on origin with no configuration rows, and the cross-repo coverage gate has been refusing every platform push since. Each gets a row (name, default, meaning) on its component's configuration page, plus the three ROLLCALL_*_BLOCKS tunables that the scanner cannot see because they are read by computed name. The computed-read baselines move to match the committed trees: hub 31 to 33 (the roll-call tunable resolver and the attest response forward override), node 17 to 20 (ConfigService passes the roll-call rail env through by name). --- components/hub/configuration.md | 17 +++++++++++++++++ components/indexer/configuration.md | 2 ++ lib/env-var-doc-coverage.js | 11 ++++++++++- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/components/hub/configuration.md b/components/hub/configuration.md index 1c3ed34..6f1b8fe 100644 --- a/components/hub/configuration.md +++ b/components/hub/configuration.md @@ -426,6 +426,23 @@ Controls `StateCheckpointEngine`, which produces the quorum-signed per-block sta | `CHECKPOINT_ROUND_TIMEOUT_MS` | No | `60000` | Timeout for one checkpoint signing round. | | `CHECKPOINT_COSIGN_TOLERANCE_BLOCKS` | No | `144` | Fail-closed co-sign gate: a `SIGN_REQ` whose `snapshot_block` deviates from this hub's own BTC tip by more than this many blocks is declined. The default is roughly a day of BTC blocks. | | `CHECKPOINT_STALL_LOG_MS` | No | `3600000` (1 h) | Throttle for the "cadence stalled" log line. The eligibility poll runs far more often than the checkpoint cadence, so the reason is logged at most this often and the counter carries the true rate. | +| `CHECKPOINT_FROZEN_TIP_TICKS` | No | `60` | Consecutive not-my-slot eligibility ticks that see the same BTC tip before the tick is metered as a cadence stall naming the frozen block. A frozen tip pins the rotation slot to one constant, so every hub whose rank is not that constant returns forever with no stall counted; at the default 60 s poll this is about an hour, longer than any normal inter-block gap. Non-positive values fall back to `60`. | + +### Roll Call + +Controls `RollcallRound`, which signs the per-epoch ledger-hash roll call and elects the hub that publishes it on DOGE. Epoch cadence and the accept window are consensus constants and are not configurable; these knobs cover only this hub's own timing, participation and rails. + +| Variable | Required | Default | Description | +|---|---|---|---| +| `ROLLCALL_ENABLED` | No | `true` | Set to `false` to stop this hub signing roll calls and standing for publisher election. | +| `ROLLCALL_POLL_MS` | No | `30000` | Interval between roll-call epoch polls. | +| `DOGE_INDEXER_URL` | No | _(from config table)_ | DOGE indexer JSON-RPC URL the round reads to learn what is already on chain for the epoch. `DOGE_INDEXER_API_URL` takes precedence when both are set. | +| `DOGE_INDEXER_API_KEY` | No | _(from config table)_ | API key presented to that DOGE indexer. Treat as a credential. | +| `ROLLCALL_SPEND_LOG_PATH` | No | `./data/rollcall-publish.spend.jsonl` | JSONL spend audit for the fee-bearing publish. The intent line is written and fsynced BEFORE the DOGE moves and the broadcast is gated on it, so a crash mid-flight still leaves a trace that DOGE may have been spent. | +| `ROLLCALL_SIGN_LOG_PATH` | No | `./data/rollcall-signatures.jsonl` | Durable store of the signatures this hub has emitted. A restart inside the accept window re-emits the same signature for an epoch rather than minting a second one. | +| `ROLLCALL_PUBLISH_DELAY_BLOCKS` | No | `12` (regtest `1`) | Blocks after the accept window closes before the elected publisher broadcasts, so late gossiped signatures still make the published set. Non-numeric values fall back to the default rather than disabling the gate. | +| `ROLLCALL_ELECTION_TOLERANCE_BLOCKS` | No | `36` (regtest `3`) | Blocks the elected publisher is given before the next hub in the election ladder may take over. Separate from `ANCHOR_ELECTION_TOLERANCE_BLOCKS` on purpose: the two ladders climb against different anchors. | +| `ROLLCALL_SELF_PUBLISH_BLOCKS` | No | `100` (regtest `9`) | Blocks after which any hub still holding an unpublished epoch publishes it itself, whatever the ladder says. | ### Full-Node Challenge diff --git a/components/indexer/configuration.md b/components/indexer/configuration.md index 05b7f2c..1406462 100644 --- a/components/indexer/configuration.md +++ b/components/indexer/configuration.md @@ -33,6 +33,8 @@ Configuration is loaded from a `.env` file and environment variables. Copy the ` | `INDEXER_RATE_LIMIT_RPM` | API requests per minute per IP | `600` | | `HUB_API_URL` | Hub JSON-RPC base URL used by the indexer's hub client. Falls back to the URL passed in code when unset. | _(unset)_ | | `HUB_API_KEY` | API key sent with hub calls. Required whenever the hub runs keyed, which is always in validator mode. Treat as a credential. | _(unset)_ | +| `HUB_CONFIG_URL` | Hub API base URL for the config-oracle poll (`getallconfigs`). The hub keeps that method off its public feed port because the answer carries every service's DB credentials, so an indexer pointed at a validator's feed port pushes and mirrors correctly but fails its config poll once a minute forever, silently freezing the hub-supplied params at their startup values. Point this at a private hub API port to separate the two roles. Unset falls back to `HUB_API_URL`, so a single-hub deployment is unchanged. | _(unset)_ | +| `HUB_CONFIG_API_KEY` | API key sent with the config-oracle poll when `HUB_CONFIG_URL` is a separately keyed port. Unset falls back to `HUB_API_KEY`. Treat as a credential. | _(unset)_ | | `HUB_REORG_API_KEY` | Separate key for the hub's retraction rails (`pushpricereorg`, `pushxcallreorg`, `pushdexreorg`) when the hub gates them independently. Unset falls back to `HUB_API_KEY`, which is the legacy single-key behaviour. Treat as a credential. | _(falls back to `HUB_API_KEY`)_ | | `INDEXER_ALLOW_UNAUTHENTICATED` | Set to `true` to restore keyless pass-through on the gated methods (validator-reward writes, federation reads, gated exec). With no API key configured those methods otherwise fail closed. This is the explicit escape hatch for single-host and regtest nodes; do not set it on a node reachable beyond its own host. | _(unset, fails closed)_ | | `UTXO_TRACKER_URL` | UTXO-tracker hostname. Optional overall, but required for the DISPENSER fresh-address check. | _(unset)_ | diff --git a/lib/env-var-doc-coverage.js b/lib/env-var-doc-coverage.js index 01a109b..a5e41f3 100644 --- a/lib/env-var-doc-coverage.js +++ b/lib/env-var-doc-coverage.js @@ -1031,8 +1031,17 @@ function checkDivergentDefaults(survey) { const COMPUTED_READ_BASELINE = { // Measured 2026-08-11 against the committed trees of all 11 gated // components: 95 sites in 37 files across 10 of them. - decoder: 4, encoder: 4, explorer: 7, hub: 31, indexer: 6, node: 17, + decoder: 4, encoder: 4, explorer: 7, hub: 33, indexer: 6, node: 20, 'regtest-miner': 7, sdk: 4, sync: 9, 'utxo-tracker': 8, vm: 0, + // hub 31 -> 33 on 2026-09-03: RollcallRound._resolveTunable() reads + // process.env[name] twice for the three ROLLCALL_*_BLOCKS tunables, and + // attest_response_timing.js reads ATTEST_RESPONSE_FORWARD_S_OVERRIDE by its + // constant. All four names carry rows in components/hub/configuration.md. + // Raised to match the committed trees, which had landed on origin ahead of + // the rows; the roll-call indirection is what gives each tunable one NaN guard. + // node 17 -> 20 on 2026-09-03: ConfigService passes the roll-call rail env + // through to the indexer and hub by name (process.env[varName], read three + // times in one guard), so the operator sets each variable once on the node. // node 16 -> 17 on 2026-09-01, with v0.12.2: ValidatorService.resolveImportedWif() // reads process.env[envName] so one function serves both key imports, and the two // names it resolves (XCHAIN_NODE_STAKE_WIF, XCHAIN_NODE_DOGE_WIF) each carry a row From f6465aa0a58780d242098ec83179f61cd91415ac Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 2 Sep 2026 19:49:33 -0700 Subject: [PATCH 15/52] components(node): document the explorer serving limits the CLI passes through Four host-environment variables the node hands to the explorer: the overall request budget, the fee-quote and pre-flight budgets, and the tip-age freshness gate. Each note says what makes the public default wrong on a private venue - one tunnelled host is a single IP to the limiter, and a regtest chain that nobody is mining crosses the six-hour staleness gate while its lag is zero. --- components/node/configuration.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/components/node/configuration.md b/components/node/configuration.md index 052ed34..2aa546f 100644 --- a/components/node/configuration.md +++ b/components/node/configuration.md @@ -196,6 +196,10 @@ signer as fatal. | `EXPLORER_CHECKPOINT_SELF_SYNC` | _(unset)_ | Opt in to a self-synced checkpoint mirror for the explorer. When set, the generated explorer config gains a `checkpoint` database descriptor whose host, port, user and password are taken from the indexer's own, plus a `_HubMirror` schema the explorer provisions and keeps current from the hub. Leave unset where `database.checkpoint` is pointed at a real hub schema by hand | | `HUB_API_URL` | derived from the hub container name and port | Base REST URL the explorer's mirror writer uses to pull hub-mirrored tables. Distinct from `HUB_API_HOST`/`HUB_PORT`, which feed the ordinary config poll rather than the mirror. Emitted only when `EXPLORER_CHECKPOINT_SELF_SYNC` is set | | `EXPLORER_VM_QUERY_ENABLED` | _(unset)_ | Passed through verbatim to the explorer to enable contract read-method simulation. The reader tests for the exact string `true`, so the value is not coerced | +| `EXPLORER_RATE_LIMIT_RPM` | _(unset; explorer defaults to `500`)_ | Passed through to the explorer: requests per minute per IP across its whole API. A private venue reached through one tunnel or proxy is a SINGLE IP to this limiter, so every browser and every automated run on that host shares one budget; a browser-driven test suite alone sustains several hundred a minute. Left unset, the explorer's public-facing default applies. | +| `EXPLORER_FEE_QUOTE_RATE_LIMIT_RPM` | _(unset; explorer defaults to `120`)_ | Passed through to the explorer: the tighter limit on `/{COIN}/api/feequote`, `/oraclefeequote` and `/feeschedule`. Raise it alongside the one above on a venue whose only client is a test suite composing fee-bearing actions back to back. | +| `EXPLORER_PREFLIGHT_POST_RATE_LIMIT_RPM` | _(unset; explorer defaults to `60`)_ | Passed through to the explorer: the limit on `POST /{COIN}/api/preflight`, the one unauthenticated route that accepts a large body. | +| `EXPLORER_TIP_MAX_AGE_S` | _(unset; explorer defaults to `21600`)_ | Passed through to the explorer: the age in seconds past which a coin's newest indexed block counts as stale, after which the explorer refuses reads for that coin with `503 COIN_DATA_STALE` and drops it from `/{COIN}/api/status`'s `available` map. `0` disables the gate. **A regtest chain advances only when someone mines it**, so an idle one crosses the six-hour default while its lag is zero; set this to `0` on a venue that serves nothing but regtest coins. The explorer's own per-coin `EXPLORER_TIP_MAX_AGE_S_` form is not carried through here - set it on the explorer directly if you need to exempt one chain rather than all of them. | > **Note on `XCHAIN_NODE_EXTERNAL_DB_ROOT_PASSWORD`:** this is a credential value. Pass it via your deployment environment or secrets manager; do not store it in config files checked into version control. From af1324e99a730f7ca3d1c23bf0cab666543b3013 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 2 Sep 2026 22:19:05 -0700 Subject: [PATCH 16/52] protocol(attest): declare the response-mirror activation height An attestation costs two on-chain transactions today. The request rides inside the EXECUTE the user already paid for, but the response is a whole ATTEST v1 transaction that a validator broadcasts and pays a fee for, and the contract callback fires only when it mines. A contract executed a thousand times is a thousand validator-paid transactions, each waiting on Bitcoin block time. This is the height at which that stops. At or above it the responsible set's finalized artifact reaches indexers through the hub mirror the way PRICE rounds already do, and the full history still lands on chain in periodic batches so a node replaying the chain re-derives every callback. Evaluated on the request's own block, like the other attestation gates, so the rule for a request is fixed the moment it is admitted. Mainnet and testnet both ship unratified. --- protocol/constants.js | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/protocol/constants.js b/protocol/constants.js index fa315b3..1bab8c8 100644 --- a/protocol/constants.js +++ b/protocol/constants.js @@ -789,6 +789,38 @@ const ATTEST_RESPONSIBLE_WIDENING = { maxSlots: 2, }; +// ATTEST_RESPONSE_MIRROR_ACTIVATION (attestation response mirror): the flag-day at/above which a +// finalized attestation response stops being an on-chain ATTEST v1 transaction that a validator +// broadcasts and pays a Bitcoin fee for, and instead rides the hub mirror the way PRICE rounds do. +// Below the height an attestation costs TWO on-chain transactions (the v0 request inside the +// EXECUTE the user already paid for, plus the validator-paid v1 response) and the contract callback +// fires only when the v1 mines. At or above it the responsible set's finalized artifact is written +// to the hub's attestation_responses table, gossiped to the whole federation, streamed to every +// indexer through the hub mirror, and applied at a block that is a pure function of the SIGNED +// effective_time and the indexer's own chain state. The full history still reaches the chain in +// periodic ATTEST v5/v6 batches, so a node replaying the chain alone re-derives every callback. +// +// The gate is evaluated on the REQUEST's own BTC block, not the response's, exactly like +// ATTEST_RESPONSIBLE_WIDENING_ACTIVATION and ATTEST_ADMISSION_ACTIVATION: the rule for a given +// request is fixed the moment it is admitted, so no request can be admitted under one regime and +// answered under the other while the fleet crosses the height. The same height selects the +// CANONICAL the responsible set signs (the mirror-era canonical appends the signed effective_time), +// so the two eras never share a signature. A relayed request (a v0 admitted on LTC or DOGE) is +// served on BTC as its ATTEST v3 materialization and the v3's BTC block keys the gate. +// +// Unlike the widening ladder, deploy ORDER cannot cover a straddle here: an upgraded hub stops +// broadcasting v1 entirely, so an indexer that has not upgraded would simply never see the +// response. The height is therefore armed past a SYNCHRONIZED fleet window (hubs, indexers and +// explorer together, the HUB_SCHEMA_VERSION 4->5 flip) with no request straddling it. +// +// Kept value-identical to the local copies in xchain-{hub,indexer}/src/attest_response_mirror_activation.js +// by the activation-constants parity suite. +const ATTEST_RESPONSE_MIRROR_ACTIVATION = { + mainnet: null, // INERT: operator-owned height, unratified. The legacy on-chain response path runs byte for byte. + testnet: null, // UNARMED: operator-armed after the regtest milestone is REACHED and the synchronized schema-5 fleet window closes. + regtest: 0, // ARMED at genesis so the e2e mirror venue exercises the mirror path +}; + // ATTEST_BROADCAST_FEE_ACTIVATION (attestation Phase 3 economics, spec §11 leader broadcast-fee // reimbursement): the flag-day at/above which a FULFILLED ATTEST settle carves a broadcast-fee // reimbursement out of the v0 fee escrow and pays it to the lowest-hash member of the request's @@ -1262,6 +1294,7 @@ module.exports = { ATTEST_BROADCAST_FEE_CAP, ATTEST_RESPONSIBLE_WIDENING_ACTIVATION, ATTEST_RESPONSIBLE_WIDENING, + ATTEST_RESPONSE_MIRROR_ACTIVATION, ORACLE_FEE_OUTPUT_ACTIVATION, ORACLE_FEE_SET_CAPTURE_ACTIVATION, DISPENSER_EXPIRY_REALIGN_ACTIVATION, From 2e819da74c447cf291989968fcb4528b2e006346 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 3 Sep 2026 08:14:46 -0700 Subject: [PATCH 17/52] config: document the two attestation response-mirror overrides Both are regtest-only test seams introduced with the response mirror, and both were invisible to the coverage gate: the hub's was read through a constant holding its own name, and the indexer's is resolved by a shared helper that indexes process.env by argument. Operator-settable variables with no row in any configuration table. The hub knob shifts when a mirrored response becomes applicable, and it exists because regtest blocks are stamped at roughly now, so without it no response could bind for two real minutes. The indexer knob is the barrier's grace, which only has to cover stream lag because the real forward margin travels inside the signed row. Both refuse to take effect off regtest, because two nodes resolving either differently settle blocks differently. --- components/hub/configuration.md | 1 + components/indexer/configuration.md | 1 + 2 files changed, 2 insertions(+) diff --git a/components/hub/configuration.md b/components/hub/configuration.md index 6f1b8fe..97e9a1f 100644 --- a/components/hub/configuration.md +++ b/components/hub/configuration.md @@ -207,6 +207,7 @@ mounts it into the hub container automatically. See OPERATIONS.md → Validator | `HUB_SNAPSHOT_REORG_BUFFER` | No | `6` | Blocks of reorg buffer applied when building a capability snapshot. **Consensus-critical: it must match across the federation.** A malformed value logs an error and falls back to `6` rather than forking the federation on a typo. | | `XCHAIN_HUB_SKIP_REORG_BUFFER_ASSERT` | No | _(unset)_ | Set to `1` to bypass the assertion that `HUB_SNAPSHOT_REORG_BUFFER` equals the canonical federation value. Only for a venue where **every** hub runs the same override: each hub subtracts this buffer before resolving a snapshot, so hubs disagreeing on it lock different blocks for the same round and produce divergent validator sets and quorum N. On `mainnet` and `testnet` a mismatch otherwise refuses to start (`REORG_BUFFER_MISMATCH`); standalone and regtest warn instead. The bypass logs a warning every time it is taken. | | `XCHAIN_HUB_SKIP_MIN_STAKE_ASSERT` | No | _(unset)_ | Set to `1` to skip the minimum-stake assertion at startup. Test and bring-up seam; leaving it set on a real deployment disables a safety check. | +| `ATTEST_RESPONSE_FORWARD_S_OVERRIDE` | No | _(unset)_ | Overrides `ATTEST_RESPONSE_FORWARD_S` (120), the seconds a round leader adds to now when stamping the effective time an attestation response becomes applicable at. **Honoured on `regtest` only**; on any other network a differing value is ignored with a warning latched once per process, and standalone mode (no network) counts as not-regtest and keeps the frozen value. On `regtest` a value that is not a whole number of seconds throws at resolve time rather than defaulting, because a silent fallback to 120 leaves an acceptance run waiting two minutes per attestation with nothing in the log to explain it. The seam exists because regtest blocks are stamped at roughly now, so without it no mirrored response could bind for 120 real seconds. Also readable from the validator config table under the same key, which takes precedence over the environment. | ### Hub-DB WebSocket (`GET /hub-db/subscribe`) diff --git a/components/indexer/configuration.md b/components/indexer/configuration.md index 1406462..3f2d05f 100644 --- a/components/indexer/configuration.md +++ b/components/indexer/configuration.md @@ -69,6 +69,7 @@ Configuration is loaded from a `.env` file and environment variables. Copy the ` | `XCALL_DIRECT_PRESENCE_TIMEOUT_MS` | Call-presence barrier timeout in direct-hub-DB mode. With no HubDbSync mirror the cross-chain-call sync barrier is skipped, but reading the hub's MariaDB directly does not guarantee an in-flight relay row has landed, so the indexer waits this long for it before the cross-chain-call pass. | `10000` | | `CHAIN_TIP_PUSH_MAX_LAG` | Skip pushing the chain tip to the hub while the indexer is more than this many blocks behind the decoder tip. During a bulk re-index, pushing a tip per historical block floods the hub's rate limiter with `429`s for no value: the hub only cares about the live tip. | `100` | | `HUB_SYNC_ANCHOR_ATTEST_GRACE_S` | Grace margin on the anchor-reward attestation mirror barrier. The BTC indexer only derives an anchor/archive reward once the hub mirror is certified to hold everything produced up to the block being processed; a node that cannot certify that defers the block rather than deriving a partial reward set. Honoured on regtest only: elsewhere it is a consensus input and the frozen value wins. | `120` | +| `HUB_SYNC_ATTEST_RESPONSE_GRACE_S` | Grace margin on the attestation-response mirror barrier. Above the response-mirror activation height a finalized attestation response reaches the indexer through the hub mirror rather than as its own transaction, and a node that has not received the row would fire the contract callback at a different block from its peers, which is a fork rather than a lag. So this barrier has no chain-only escape: the block waits until the mirror's stream watermark reaches the block's protocol time plus this margin. The margin only has to cover ordinary stream lag, because the real forward margin travels inside the signed row. **Honoured on `regtest` only**; elsewhere a differing value is ignored with a startup warning and the frozen protocol value wins, because two nodes resolving it differently would settle blocks differently. | `120` | | `DOGE_INDEXER_URL` | DOGE indexer JSON-RPC URL the BTC indexer uses to re-prove that a mirrored anchor reward's DOGE anchor was actually mined (`getanchorconfirmations`), before crediting it. `DOGE_INDEXER_API_URL` takes precedence when both are set. Required on a BTC indexer once the anchor-reward derive flag-day is armed: unset, no reward can be proven and the block defers. | _(unset)_ | | `DOGE_INDEXER_API_KEY` | API key sent as `x-api-key` with that read (`getanchorconfirmations` is a federation-read method on the DOGE indexer). | _(unset)_ | | `ANCHOR_PROOF_TIMEOUT_MS` | Per-request timeout for the DOGE anchor proof read, and for the ROLLCALL signer read below. A timeout is treated as "cannot tell", which defers the block; it is never read as "not mined". | `15000` | From 541a657debd33afa05f77c2687e50cbf6f22fd17 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 3 Sep 2026 08:54:04 -0700 Subject: [PATCH 18/52] database: document the attestation_responses mirror table Two schema tables landed locally with no row naming them, on both sides of the mirror. The hub authors the table when a round reaches quorum; the indexer carries a local copy that hub_db_sync populates. The indexer's row says the two things a reader needs that the hub's does not: that the mirror is transport and never authority, since the applier re-verifies every row's signatures against the responsible set it resolves from its own local request row, and that the applied state lives in attests rather than here. --- components/hub/database.md | 1 + components/indexer/database.md | 1 + 2 files changed, 2 insertions(+) diff --git a/components/hub/database.md b/components/hub/database.md index 9b8889b..1b513f7 100644 --- a/components/hub/database.md +++ b/components/hub/database.md @@ -464,6 +464,7 @@ Records detected validator misbehavior for governance review. The hub detects vi | `anchor_published_archives` | The same at-most-once marker for the ANCHOR archive publish, held per network rather than per batch because a crashed round always rebuilds under a fresh `batch_seq` (hub-local, not mirrored) | | `capability_snapshots` | Per-block capability validator sets locked at BTC-anchored block boundaries | | `anchor_reward_attestations` | Quorum-attested ANCHOR publisher rewards; mirrored to indexers | +| `attestation_responses` | Finalized ATTEST responses, written once a round reaches quorum and mirrored to indexers, so a response no longer costs a validator an on-chain transaction. Insert-only; every hub holding the artifact writes its own row, so the id is hub-local | ### `anchor_reward_attestations` diff --git a/components/indexer/database.md b/components/indexer/database.md index 6d6779c..5794822 100644 --- a/components/indexer/database.md +++ b/components/indexer/database.md @@ -202,6 +202,7 @@ Two slashing systems produce distinct table families. | `oracle_prices` | Local mirror of the hub's `oracle_prices` table (PRICE v1 user oracle rows). Populated by `hub_db_sync`. Rolled back on reorg by `(source_chain, action_index)` | | `price_snapshots` | Local mirror of the hub's `price_snapshots` table (PRICE v0 consensus rounds). Populated by `hub_db_sync`. Rolled back on reorg by `reference_block` | | `anchor_reward_attestations` | Local mirror of the hub's table of the same name: quorum-attested ANCHOR publisher rewards. Populated by `hub_db_sync`, INSERT-IGNORE, never retracted | +| `attestation_responses` | Local mirror of the hub's table of the same name: the finalized ATTEST response, carried here instead of as a validator-paid on-chain transaction. Populated by `hub_db_sync`, INSERT-IGNORE, never retracted, and re-paged from the start on every bootstrap because the hub's row id is stripped on apply. TRANSPORT ONLY: the applier re-verifies each row's signatures against the responsible set it resolves from its OWN local request row before the response takes effect, so a row that fails verification is inert rather than a fork. Applied state lives in `attests`, not here | | `anchor_reward_reconcile_log` | Pre-image log of validator-reward rows deleted when an ANCHOR reconciles a contested publisher. Stores each deleted row's exact `amount` string and its original `reward_block_index`, so a reorg restore re-inserts it byte-identically, and only when the earn-block itself survives | | `cross_chain_call_rejections` | Refused XCALL dispatch injections, one row per `call_id`, with the refusal `reason` family, human-readable `detail`, and the attempt count and first/last block. Makes a call that never lands diagnosable instead of silently absent | | `push_generations` | Per-coin monotonic counter bumped on every rollback and never decremented. Stamped onto hub pushes so the hub can fence stale pushes from an orphaned range (its `price_ingest_watermarks` side) | From 68134b067a70b25ac452bc99f8c77240bfe1b4a7 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 3 Sep 2026 12:37:47 -0700 Subject: [PATCH 19/52] feat(rollcall): let a two-chain regtest venue opt in to roll-call activation Regtest stays inert by default, because arming a network commits every BTC indexer on it to a wired DOGE peer and a single-coin venue would defer at its first epoch close. A venue that runs both chains sets XC_ROLLCALL_REGTEST_ACTIVATION on every hub and indexer to arm at height 0, or at a height it names. --- components/hub/configuration.md | 3 ++ components/indexer/configuration.md | 6 ++++ protocol/actions/rollcall.md | 4 ++- protocol/constants.js | 43 +++++++++++++++++++++-------- 4 files changed, 44 insertions(+), 12 deletions(-) diff --git a/components/hub/configuration.md b/components/hub/configuration.md index 97e9a1f..06ea461 100644 --- a/components/hub/configuration.md +++ b/components/hub/configuration.md @@ -558,6 +558,9 @@ Regtest-only genesis overrides, ignored on mainnet and testnet, which always use | `XCHAIN_GENESIS_BLOCK` | Regtest only | per-coin | Genesis block height for a regtest chain. | | `XCHAIN_GENESIS_LEDGER_HASH` | Regtest only | per-coin | Genesis ledger-hash pin for a regtest chain. | | `XCHAIN_GENESIS_DUMP_HASH` | Regtest only | per-coin | Genesis dump-hash pin for a regtest chain. | +| `XC_ROLLCALL_REGTEST_ACTIVATION` | Regtest only | unset (inert) | Arms ROLLCALL on a private regtest venue, so this hub signs roll calls and elects publishers there. `armed` (or `genesis`/`on`/`true`/`yes`) activates at BTC height `0`; a bare non-negative integer activates at that height; `off`/`inert`/`false` and anything unrecognised leave it inert. Read once at startup, so a change needs a restart. Ignored on mainnet and testnet, whose heights are fixed in source and unreachable from the environment. | + +ROLLCALL arming is a **venue-wide** setting: set `XC_ROLLCALL_REGTEST_ACTIVATION` identically on every hub and every BTC indexer in the venue, and wire the indexers' `DOGE_INDEXER_API_URL`. Regtest ships inert because arming a network commits every BTC indexer on it to a wired DOGE peer, and a single-coin BTC venue would defer forever at its first epoch close. A venue that arms its hubs and forgets an indexer surfaces as a consensus-rules digest mismatch rather than as silent disagreement about which epochs exist. ### Fee Destination Override diff --git a/components/indexer/configuration.md b/components/indexer/configuration.md index 3f2d05f..e10b050 100644 --- a/components/indexer/configuration.md +++ b/components/indexer/configuration.md @@ -77,6 +77,12 @@ Configuration is loaded from a `.env` file and environment variables. Copy the ` **The same DOGE wiring is what ROLLCALL runs on, and it becomes required a second time.** From `ROLLCALL_ACTIVATION` onward, every **BTC** indexer closes each roll-call epoch by asking its DOGE indexer for the epoch's signers (`getrollcallsigners`, a federation-read method served off the committed view). It reuses `DOGE_INDEXER_API_URL` → `DOGE_INDEXER_URL` → config, the `DOGE_INDEXER_API_KEY` header, and `ANCHOR_PROOF_TIMEOUT_MS`; there is no separate env knob for it. +| Variable | Description | Default | +|---|---|---| +| `XC_ROLLCALL_REGTEST_ACTIVATION` | **Regtest only.** Arms ROLLCALL on this private venue. `armed` (or `genesis`/`on`/`true`/`yes`) activates at BTC height `0`; a bare non-negative integer activates at that height, for a venue whose epochs should begin above an already-indexed prefix; `off`/`inert`/`false` and anything unrecognised leave it inert, and an unrecognised value is logged. Read **once at startup**, so a change needs a restart. mainnet and testnet are fixed in source and cannot be moved from the environment. | _(unset: inert)_ | + +Regtest ships inert on purpose: arming a network commits every BTC indexer on it to a wired DOGE peer, so a hardcoded height wedged every single-coin BTC venue at its first close. Set this on **every** BTC indexer and hub in a two-chain acceptance venue, alongside `DOGE_INDEXER_API_URL`. A venue that arms its hubs and forgets its indexer shows up as a consensus-rules digest mismatch, because `ROLLCALL_ACTIVATION` is one of the shared gates that digest covers. + A BTC indexer with no DOGE wiring **defers every block** from the first epoch close onward, with `stallReason = 'rollcall_proof_unavailable'`, rather than judging absences it cannot prove. The same deferral covers an unreachable or malformed answer, a DOGE tip that has not yet buried the window cut by `ROLLCALL_DOGE_MATURITY`, and a DOGE indexer whose vendored action-manifest hash differs from this indexer's own. That last case is what turns a DOGE indexer running a decoder too old to know `ROLLCALL` from a silent evict-the-federation bug into a loud, safe stall: wire the DOGE indexers and deploy their decoder **before** `ROLLCALL_ACTIVATION` is reached. ### Hub push queue and mirror diff --git a/protocol/actions/rollcall.md b/protocol/actions/rollcall.md index 04b0b66..fc0e907 100644 --- a/protocol/actions/rollcall.md +++ b/protocol/actions/rollcall.md @@ -99,7 +99,7 @@ All eight values are **consensus** and frozen in `protocol/constants.js`, with b | Constant | mainnet | testnet | regtest | Unit | |---|---|---|---|---| -| `ROLLCALL_ACTIVATION` | `null` (inert) | 151200 | `null` (inert) | BTC height | +| `ROLLCALL_ACTIVATION` | `null` (inert) | 151200 | `null` (inert), arms at `0` on opt-in | BTC height | | `ROLLCALL_INTERVAL_BLOCKS` | 1008 | 1008 | 30 | BTC blocks | | `ROLLCALL_ACCEPT_WINDOW_BLOCKS` | 144 | 144 | 12 | BTC blocks | | `ROLLCALL_PROOF_DELAY_BLOCKS` | 36 | 36 | 2 | BTC blocks | @@ -110,6 +110,8 @@ All eight values are **consensus** and frozen in `protocol/constants.js`, with b Every gate keys on the carried BTC `EPOCH_HEIGHT`, never on either chain's local height. Mainnet ships inert: the operator pins that height with the mainnet federation. +**Regtest is the one network whose height a venue pins for itself.** Every other value here is fixed in source and unreadable from the environment, because on a shared ledger a tunable consensus input is a fork waiting to happen. A regtest chain is private, so no two venues validate the same blocks and nothing a venue pins can fork anybody. It still ships inert, because arming a network commits every BTC indexer on it to a wired DOGE peer, and a single-coin BTC venue would defer forever at its first close. A two-chain venue opts in by setting `XC_ROLLCALL_REGTEST_ACTIVATION=armed` on every BTC indexer and hub it runs, which arms the network at height `0`; the same variable also takes a bare height for a venue whose epochs should begin above an already-indexed prefix. Anything unrecognised leaves the venue inert. Because `ROLLCALL_ACTIVATION` is one of the shared gates in the consensus-rules digest, a venue that arms its hubs and forgets its indexer reports a rules mismatch rather than disagreeing silently about which epochs exist. + ## Size and broadcast `MAX_DATA_BYTES` is 8189 and chain-agnostic. At a 7-digit epoch height the header costs 152 bytes and each signer pair 194, giving **41 pairs per action**; a federation larger than 41 is rolled in several actions per epoch, which the union rule makes free. A one-signature self-publish is 344 bytes. Those figures are measured, not derived: `protocol/test-vectors/rollcall_canonical.json` carries the exact byte counts alongside real signatures. diff --git a/protocol/constants.js b/protocol/constants.js index 1bab8c8..0dcc7cb 100644 --- a/protocol/constants.js +++ b/protocol/constants.js @@ -535,26 +535,45 @@ const ANCHOR_REWARD_MIRROR_MATURITY = 144; // ~24h of BTC blocks // A source absent for K consecutive ROLLED epochs is evicted by a synthetic UNSTAKE, so its // stake deactivates and refunds after the cooldown. Nothing is burned: absence is not an offense. // -// All eight values are CONSENSUS. They decide which epochs exist, which signatures count, and at -// what BTC height an eviction and a COLLECT-spendable reward materialise, so none may be read -// from the coin registry, env, or coins.resolveConfirmations() -- the argument -// anchor_reward_activation.js makes for its own maturity and burial depths. Kept byte-identical -// to xchain-{indexer,hub}/src/rollcall_activation.js by the cross-service regression suite. +// All eight values are CONSENSUS on a SHARED-LEDGER network. They decide which epochs exist, which +// signatures count, and at what BTC height an eviction and a COLLECT-spendable reward materialise, +// so on mainnet and testnet none may be read from the coin registry, env, or +// coins.resolveConfirmations() -- the argument anchor_reward_activation.js makes for its own +// maturity and burial depths. By operator ruling 2026-09-01 that rule is SCOPED to networks with a +// shared ledger: a regtest chain is private, no two regtest venues validate the same blocks, and +// refusing a venue-pinned height only left the AT1-AT10 acceptance suite with nowhere to run. Kept +// byte-identical to xchain-{indexer,hub}/src/rollcall_activation.js by the cross-service suite. // // Keyed on the carried BTC EPOCH_HEIGHT on BOTH chains (the snapshot_block convention of // STAKE_WEIGHTED_QUORUM_ACTIVATION), never on either chain's local height, so a pre-activation // roll call is inert on DOGE and on BTC alike and no second DOGE-height flag day exists. // INERT on mainnet (null = never active) until the operator pins a height with the mainnet // federation; the null placeholder follows SNAPSHOT_BURIAL_ACTIVATION.mainnet. -// INERT on regtest too, by operator ruling 2026-08-31: arming a network commits every BTC indexer -// on it to a wired DOGE peer, because the epoch close cannot decide a non-empty responsible set -// without one and halts rather than read silence as absence. A single-coin BTC regtest venue has -// no DOGE peer and can never have one, so a hardcoded regtest height wedged every such venue at -// its first close. A venue that runs both chains opts in instead. +// REGTEST ARMS AT 0, but only when the venue OPTS IN with XC_ROLLCALL_REGTEST_ACTIVATION, and the +// 2026-08-31 finding is why the default stays inert: arming a network commits every BTC indexer on +// it to a wired DOGE peer, because the epoch close cannot decide a non-empty responsible set +// without one and defers rather than read silence as absence. A single-coin BTC regtest venue has +// no DOGE peer, so a hardcoded regtest height wedged every such venue at its first close. A venue +// that runs both chains opts in; a BTC-only venue is left alone. +const ROLLCALL_REGTEST_ARMED_HEIGHT = 0; +const ROLLCALL_REGTEST_ENV = 'XC_ROLLCALL_REGTEST_ACTIVATION'; +function resolveRegtestActivation(env){ + let raw = (env || {})[ROLLCALL_REGTEST_ENV]; + if(raw === undefined || raw === null) return null; + let s = String(raw).trim().toLowerCase(); + if(s === '' || s === 'off' || s === 'inert' || s === 'false' || s === 'no' || s === 'none') return null; + if(s === 'armed' || s === 'genesis' || s === 'on' || s === 'true' || s === 'yes') + return ROLLCALL_REGTEST_ARMED_HEIGHT; + if(/^\d+$/.test(s)){ + let h = parseInt(s, 10); + if(Number.isFinite(h) && h >= 0) return h; + } + return null; // fail CLOSED; the service copies also warn on stderr +} const ROLLCALL_ACTIVATION = { mainnet: null, // INERT placeholder: the operator owns this height testnet: 151200, // 1008 x 150 = 144 x 1050; tip was 150400 on 2026-08-30, ~5.5 days out - regtest: null, // INERT: a BTC-only regtest venue has no DOGE peer to prove a close + regtest: resolveRegtestActivation(process.env), // ARMS AT 0 when the venue sets XC_ROLLCALL_REGTEST_ACTIVATION }; // ROLLCALL_INTERVAL_BLOCKS: epoch cadence in BTC blocks. Weekly on the live networks (1008 BTC @@ -1277,6 +1296,8 @@ module.exports = { ANCHOR_REWARD_DERIVE_ACTIVATION, ANCHOR_REWARD_MIRROR_MATURITY, ROLLCALL_ACTIVATION, + ROLLCALL_REGTEST_ARMED_HEIGHT, + ROLLCALL_REGTEST_ENV, ROLLCALL_INTERVAL_BLOCKS, ROLLCALL_ACCEPT_WINDOW_BLOCKS, ROLLCALL_PROOF_DELAY_BLOCKS, From 754344b96a5a9baf654c8adb14c352ef63c5da46 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 3 Sep 2026 12:37:52 -0700 Subject: [PATCH 20/52] feat(flag-days): report gates parked on the unarmed testnet sentinel A consensus change registered after the public testnet launch cannot be genesis-active there without re-deciding history outside nodes have committed, so it parks on the sentinel until an operator names an instant. The page now says which gates those are, rather than letting the genesis-active line stand for every gate. --- bin/generate-flag-days.js | 51 ++++++++++++++++++++++++++++++++++++--- protocol/flag-days.md | 2 ++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/bin/generate-flag-days.js b/bin/generate-flag-days.js index 63eb38d..5187146 100644 --- a/bin/generate-flag-days.js +++ b/bin/generate-flag-days.js @@ -468,6 +468,36 @@ function collectTestnetArms(indexerSrc = INDEXER_SRC) { return [...found.values()].sort((a, b) => (a.time - b.time) || a.gate.localeCompare(b.gate)); } +/** + * TESTNET slots parked on an UNARMED sentinel (>= SENTINEL_FLOOR), as `{ gate, time }` + * sorted by name. + * + * These are the OTHER way the "testnet is genesis-active" invariant can be false, and + * they were impossible until the public testnet launch of 2026-09-01 made testnet a + * live ledger: a consensus change registered after it cannot arm testnet at genesis + * without re-deciding history that outside nodes have already committed, so it parks on + * the sentinel until an operator names an instant. Leaving them unmentioned would let + * the page assert that a testnet stack "has always run the post-activation behavior" for + * a rule testnet has never run at all. Read from the same two declaration shapes as the + * armed parse. + */ +function collectTestnetUnarmed(indexerSrc = INDEXER_SRC) { + const scannable = withoutComments( + fs.readFileSync(path.join(indexerSrc, 'protocol_changes.js'), 'utf8'), + ); + const found = new Map(); + const add = (gate, time) => { + if (!Number.isFinite(time) || time < SENTINEL_FLOOR) return; + if (!found.has(gate)) found.set(gate, { gate, time }); + }; + let match; + const constRe = /const\s+([A-Z][A-Z0-9_]*)_TESTNET_TIME\s*=\s*(\d+)\s*;/g; + while ((match = constRe.exec(scannable)) !== null) add(match[1], Number(match[2])); + const callRe = /addChange\(\s*'([A-Z0-9_]+)'\s*,\s*'[0-9.]+'\s*,\s*[A-Za-z0-9_]+\s*,\s*(\d+)/g; + while ((match = callRe.exec(scannable)) !== null) add(match[1], Number(match[2])); + return [...found.values()].sort((a, b) => a.gate.localeCompare(b.gate)); +} + /** * The coordinated contract-era flag day: the timestamp the most gates ride. * Derived rather than named, because naming it here would reintroduce exactly @@ -490,7 +520,7 @@ function coordinatedFlagDay(gates) { return { time: ranked[0][0], count: ranked[0][1] }; } -function render(gates, testnetArms = []) { +function render(gates, testnetArms = [], testnetUnarmed = []) { const anchor = coordinatedFlagDay(gates); const others = gates.filter((g) => g.time !== anchor.time); @@ -528,6 +558,19 @@ function render(gates, testnetArms = []) { + 'comment in \`protocol_changes.js\`. The values on this page are otherwise ' + 'mainnet values only.'; + // The other way a gate can be off the genesis-active invariant: parked on the UNARMED + // sentinel on testnet, so testnet has never run that rule and is waiting on an + // operator to name an instant. Prose for the same reason an arm is. + const unarmedNote = testnetUnarmed.length === 0 + ? '' + : `\n\n**${testnetUnarmed.length === 1 ? 'One gate is UNARMED on testnet' : `${testnetUnarmed.length} gates are UNARMED on testnet`}** ` + + `(${testnetUnarmed.map((g) => `\`${g.gate}\``).join(', ')}): testnet carries the ` + + 'sentinel rather than `0`, so a testnet stack has **never** run the ' + + 'post-activation behavior and will not until an operator arms it. A consensus ' + + 'change registered after the public testnet launch cannot be genesis-active ' + + 'there without re-deciding history that outside nodes have already committed. ' + + 'Each names its reason in its registration comment in `protocol_changes.js`.'; + return ` @@ -562,7 +605,7 @@ ${outliers} **Testnet and regtest are genesis-active** for the time-keyed gates: they carry threshold \`0\`, so a testnet or regtest stack has always run the -post-activation behavior. ${testnetNote} +post-activation behavior. ${testnetNote}${unarmedNote} ## Mainnet time-keyed gates @@ -578,7 +621,7 @@ here; they are inventoried on } function generate(indexerSrc = INDEXER_SRC) { - return render(collectGates(indexerSrc), collectTestnetArms(indexerSrc)); + return render(collectGates(indexerSrc), collectTestnetArms(indexerSrc), collectTestnetUnarmed(indexerSrc)); } if (require.main === module) { @@ -598,6 +641,6 @@ if (require.main === module) { } module.exports = { - collectGates, collectTestnetArms, coordinatedFlagDay, render, generate, utcInstant, utcDate, + collectGates, collectTestnetArms, collectTestnetUnarmed, coordinatedFlagDay, render, generate, utcInstant, utcDate, DOC_ROOT, INDEXER_SRC, REGISTRY, OUTPUT, TIMESTAMP_FLOOR, SENTINEL_FLOOR, }; diff --git a/protocol/flag-days.md b/protocol/flag-days.md index be7cf38..5b95887 100644 --- a/protocol/flag-days.md +++ b/protocol/flag-days.md @@ -34,6 +34,8 @@ simultaneously on Bitcoin, Litecoin, and Dogecoin. threshold `0`, so a testnet or regtest stack has always run the post-activation behavior. One gate is the exception: `ISSUE_INHERITED_MINT_WINDOW` arms testnet at `1787961600` (2026-08-29 00:00:00 UTC). The reason it cannot be genesis-active there is written in its registration comment in `protocol_changes.js`. The values on this page are otherwise mainnet values only. +**One gate is UNARMED on testnet** (`UNIFIED_FEES_SWEEP_CALLBACK`): testnet carries the sentinel rather than `0`, so a testnet stack has **never** run the post-activation behavior and will not until an operator arms it. A consensus change registered after the public testnet launch cannot be genesis-active there without re-deciding history that outside nodes have already committed. Each names its reason in its registration comment in `protocol_changes.js`. + ## Mainnet time-keyed gates | Gate | Block time | UTC instant | Rides | Declared in | From 82bc391c9df0db5e878efb19b344d1b2b803b3ae Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 3 Sep 2026 12:37:57 -0700 Subject: [PATCH 21/52] docs(protocol): price SWEEP and CALLBACK on the unified gas schedule Both actions charge a base plus a per-item or per-recipient amount from the unified fee gate, in place of the flat charge per database hit. The base is what keeps the smallest sweep or callback able to buy a native-coin fee output above the chain's dust threshold. --- components/hub/api.md | 4 ++++ protocol/actions/callback.md | 3 ++- protocol/actions/sweep.md | 1 + 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/components/hub/api.md b/components/hub/api.md index 678dd36..fe9f793 100644 --- a/components/hub/api.md +++ b/components/hub/api.md @@ -631,6 +631,10 @@ Calculates the native coin fee amount for a given action. The conversion uses tw | `OWNERSHIP_ESCROW` | 50,000 | Ownership escrow deposit | | `AIRDROP_PER_RECIPIENT` | 100 | Per recipient in an airdrop | | `DIVIDEND_PER_RECIPIENT` | 100 | Per recipient in a dividend distribution | +| `SWEEP_BASE` | 5,000 | Base cost for a sweep, charged whatever it moves | +| `SWEEP_PER_ITEM` | 100 | Per swept balance, closed escrow, or transferred ownership | +| `CALLBACK_BASE` | 5,000 | Base cost for a callback, charged whatever it pays out | +| `CALLBACK_PER_RECIPIENT` | 100 | Per recipient paid by a callback | | `VM_EXECUTE_BASE` | 1,000 | Base cost for a VM contract execution | | `VM_DEPLOY_BASE` | 100,000 | Base cost for a VM contract deployment | | `VM_DEPLOY_PER_BYTE` | 10 | Per byte of contract source code | diff --git a/protocol/actions/callback.md b/protocol/actions/callback.md index 55bb750..ae7c880 100644 --- a/protocol/actions/callback.md +++ b/protocol/actions/callback.md @@ -28,7 +28,8 @@ This example calls back the JDOG token to the token owner address - All `TICK` supply holders will receive `CALLBACK_AMOUNT` of `CALLBACK_TICK` per `UNIT` ## Notes -- `CALLBACK` requires a fee based on number of database hits. The fee may be paid in `XCHAIN` (deducted from the sender's balance) or in native coin via a qualified coin output in the same transaction. +- `CALLBACK` requires a fee. The fee may be paid in `XCHAIN` (deducted from the sender's balance) or in native coin via a qualified coin output in the same transaction; on Litecoin and Dogecoin the native-coin output is the only accepted form. +- The fee is priced on the unified gas schedule as `CALLBACK_BASE` plus `CALLBACK_PER_RECIPIENT` for each holder paid, from the `UNIFIED_FEES_SWEEP_CALLBACK` gate; before it, the fee was a flat charge per database hit. The base exists so the smallest callback still buys a native-coin fee output above the chain's dust threshold. See [Flag-Day Values](../flag-days.md) for where the gate stands on each network, and the hub's [gas schedule](../../components/hub/api.md) for the values. - `UNIT` - A specific unit of measure (1 or 1.0) - `CALLBACKS` respect `CALLBACK_TICK` `ALLOW_LIST` and `BLOCK_LIST` and will only distribute `CALLBACK_TICK` to authorized holders - Use `^` (caret) as prefix when passing `TICK_ID` for `TICK` field (^1234 = `TICK_ID` 1234) diff --git a/protocol/actions/sweep.md b/protocol/actions/sweep.md index ce17bc9..f4caa50 100644 --- a/protocol/actions/sweep.md +++ b/protocol/actions/sweep.md @@ -77,6 +77,7 @@ flowchart TD - Use `^` (caret) as prefix when passing an `ADDRESS_ID` for `DESTINATION` (^57 = `ADDRESS_ID` 57); see [Index ID References](../index-id-references.md) - `DISPENSERS=1` closure is delayed by the standard dispenser-close window (1 hour). Escrow routing to `DESTINATION` happens at close time, not at sweep time. - An offer's escrow is always routed by the offer-close path; `DESTINATION` becomes the new escrow recipient regardless of the `OWNERSHIPS` setting. +- `SWEEP` charges a protocol fee, payable in `XCHAIN` from `SOURCE`'s balance or as a native-coin output in the same transaction; on Litecoin and Dogecoin the native-coin output is the only accepted form. The fee is priced on the unified gas schedule as `SWEEP_BASE` plus `SWEEP_PER_ITEM` for each swept balance, closed escrow, and transferred ownership, from the `UNIFIED_FEES_SWEEP_CALLBACK` gate; before it, the fee was a flat charge per database hit. The base exists so even a sweep that moves almost nothing still buys a native-coin fee output above the chain's dust threshold. See [Flag-Day Values](../flag-days.md) for where the gate stands on each network, and the hub's [gas schedule](../../components/hub/api.md) for the values. --- From a53743e9eb574bf9e2e36e50272264f2e66f3521 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 3 Sep 2026 12:37:57 -0700 Subject: [PATCH 22/52] docs(indexer): document the priceSource field on the fee-schedule response Names the database the quoted oracle prices were read from, so a tool that seeds prices writes to the one the node reads instead of inferring it from its own environment. --- components/indexer/operations.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/components/indexer/operations.md b/components/indexer/operations.md index fc63f16..94e94a3 100644 --- a/components/indexer/operations.md +++ b/components/indexer/operations.md @@ -205,6 +205,8 @@ Full native-coin fee schedule plus current oracle prices. Called internally by t **Response:** Fee schedule map (action → `{ base_sats, per_byte_sats }`) and current coin/XCHAIN oracle prices used for USD-pegged fee calculation. +The response also carries `priceSource`: `{ hubDb, database }`, saying which database those prices were read from. `hubDb` is `true` when `HUB_DB_HOST` and `HUB_DB_NAME` are both set and the indexer therefore routes price lookups through its hub connection, `false` when it reads its own database. `database` names that database on testnet and regtest and is `null` on mainnet. Anything that writes prices for a node to read (test fixtures, a seeding tool) should follow this field rather than infer the answer from its own environment: the setting lives on the indexer alone, and writing to the other database fails every priced action with `no current oracle price` while both databases look healthy. + --- ### `getactionconfirmations` From 6ee610cac0160043bfabc6f46ac265a9da53f566 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 3 Sep 2026 12:38:00 -0700 Subject: [PATCH 23/52] docs: record the v0.14.0 release train A consensus train that moves the node, hub and indexer, and points the install example at it. There is no v0.13.0: the number was skipped deliberately. --- operations/releases.md | 57 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/operations/releases.md b/operations/releases.md index 020077d..1a45bd5 100644 --- a/operations/releases.md +++ b/operations/releases.md @@ -9,6 +9,61 @@ Each train tag is GPG-signed with the platform release key. See [Release Signing](./release-signing.md) to verify a download, and [Release Process](./release-process.md) for how a train is cut. +## v0.14.0 + +Released 2026-09-02. [Release notes and artifacts](https://github.com/XChain-Platform/xchain-node/releases/tag/v0.14.0) + +A consensus train. `xchain-node`, `xchain-hub` and `xchain-indexer` move to +0.14.0; the other ten components keep the tags they already carry. There is no +v0.13.0: that number was skipped deliberately, and nothing in the platform +resolves a train by counting upward, so a gap in the sequence is not a missing +release. + +| Component | Version | +|---|---| +| xchain-node | 0.14.0 | +| xchain-hub | 0.14.0 | +| xchain-indexer | 0.14.0 | +| xchain-explorer | 0.12.0 | +| xchain-decoder | 0.12.0 | +| xchain-encoder | 0.12.0 | +| xchain-sync | 0.12.0 | +| xchain-utxo-tracker | 0.12.0 | +| xchain-vm | 0.12.0 | +| xchain-sdk | 0.12.0 | +| xchain-contracts | 0.12.0 | +| xchain-e2e-test | 0.12.0 | +| xchain-regtest-miner | 0.12.0 | + +An attestation request drew its responsible set from on-chain stake alone, with +nothing in the calculation about whether a validator was answering. A validator +that was staked and served nothing kept its slot forever, and a set holding one +such member could never gather the signatures finalization needs. Every +attestation request on Bitcoin testnet was expiring with zero responses. A +stalled request now widens its responsible set as its own window elapses, the +hub signs from the widened set and the indexer accepts from it, and the fee +split for a fulfilled request follows the same set. The widening ladder is fixed +by consensus rather than configured per hub, because it decides who is allowed +to sign. + +Alongside it: validators gossip a digest of the consensus rules they are +applying on the heartbeat and warn when a peer, or the node itself, is on +different flag-day heights, and the indexer publishes the same digest on its +health endpoint so it can be compared against the federation it follows. +`install xchain-hub` no longer fails with HTTP 401 on a host the runbook +provisioned, because the CLI now sends the hub API key that `validator init` +generated. The checkpoint config block ships `hub_url` beside `self_sync`, so a +fresh install resolves its checkpoint peer and every installed coin gets a +checkpoint block. `xchain-node rollback` prints the recovery path and exits +instead of hanging in its precheck. + +**This train changes state derived from existing bytes.** Responsible-set +widening activates on Bitcoin testnet at block 150780, and from genesis on +regtest. Mainnet has not ratified it and the rule is inert there. Below the +height, and on an unratified network, behaviour is byte for byte unchanged, but +once a widened response lands, an indexer or hub on the old rules judges it +differently: update every indexer and hub. + ## v0.12.3 Released 2026-09-01. [Release notes and artifacts](https://github.com/XChain-Platform/xchain-node/releases/tag/v0.12.3) @@ -280,7 +335,7 @@ changelog below a marker line and are not comparable to platform versions. ## Installing a specific train ``` -xchain-node install v0.12.1 +xchain-node install v0.14.0 ``` A pinned install resolves every component to the exact commit recorded in that From dac91be3a7017567a5f960bbf3f4427110e9cf65 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 3 Sep 2026 13:25:22 -0700 Subject: [PATCH 24/52] components(e2e-test): recount the action suites and helper modules The tree carries 79 action test files and 51 helper modules. --- components/e2e-test/README.md | 2 +- components/e2e-test/architecture.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/components/e2e-test/README.md b/components/e2e-test/README.md index c05be0c..2dbc528 100644 --- a/components/e2e-test/README.md +++ b/components/e2e-test/README.md @@ -67,7 +67,7 @@ flowchart TD subgraph E2E["xchain-e2e-test"] CH["cryptoHelper
BIP39/BIP32
wallet mgmt"] TH["transactionHelper
PSBT/P2SH"] - AH["action helpers (49 modules)
message construction"] + AH["action helpers (51 modules)
message construction"] SC["Service Connectors (src/)
BlockchainConnector, XChainEncoderConnector
XChainUtxoTrackerConn, XChainDecoderConnector
XChainIndexerConnector, XChainExplorerConnector
XChainHubConnector, RegtestMinerConnector
Database (MariaDB)"] CH --> SC TH --> SC diff --git a/components/e2e-test/architecture.md b/components/e2e-test/architecture.md index d3c9943..ae59392 100644 --- a/components/e2e-test/architecture.md +++ b/components/e2e-test/architecture.md @@ -178,8 +178,8 @@ xchain-e2e-test/ │ ├── initialCheck.test.js # Mocha root hooks (beforeAll/afterAll) │ ├── cryptoHelper.js # BIP39/BIP32 wallet management │ ├── transactionHelper.js # PSBT construction, signing, broadcast -│ ├── actions/ # 77 action test files (live, ordered), covering 31 ACTION names -│ ├── helpers/ # 49 modules (action helpers + federation/fee/utility helpers) +│ ├── actions/ # 79 action test files (live, ordered), covering 31 ACTION names +│ ├── helpers/ # 51 modules (action helpers + federation/fee/utility helpers) │ ├── unit/ # 350+ unit tests (stubbed, no services) │ ├── integration/ # 150+ integration tests (stubbed I/O) │ │ ├── fixtures/ # mockMariadb, services, dbRows, hub From d56e155978d4e5b454088e427f999dbe8811b271 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 3 Sep 2026 14:45:10 -0700 Subject: [PATCH 25/52] docs: recount the explorer routes and register the action-suite claim The explorer serves 174 api patterns and 117 page routes, and the end-to-end tree carries 79 action test files. --- architecture/component-map.md | 6 +++--- test/action-count-claims.test.js | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/architecture/component-map.md b/architecture/component-map.md index 9c2d41a..c07dd18 100644 --- a/architecture/component-map.md +++ b/architecture/component-map.md @@ -85,10 +85,10 @@ See [`../components/indexer/`](../components/indexer/) for full documentation. Key technical details: -- 299 REST endpoint patterns across the `/api` and `/explorer` namespaces, covering tokens, balances, holders, orders, dispensers, transactions, events, market data, contracts, staking, attestations, cross-chain calls, betting feeds and bets, governance polls and ballots, contract emissions, vote delegations, validator capabilities and slashing, chain reorgs, anchor reward attestations, per-block commitments, and more. The breakdown, re-derived from `xchain-explorer/src/XChainExplorer.js` on 2026-08-28: - - 171 `/{COIN}/api/...` and 112 `/{COIN}/explorer/...` patterns in the dispatch table built by `setupUrls()`, matched by the catch-all handler rather than registered with Express individually. +- 302 REST endpoint patterns across the `/api` and `/explorer` namespaces, covering tokens, balances, holders, orders, dispensers, transactions, events, market data, contracts, staking, attestations, cross-chain calls, betting feeds and bets, governance polls and ballots, contract emissions, vote delegations, validator capabilities and slashing, chain reorgs, anchor reward attestations, per-block commitments, and more. The breakdown, re-derived from `xchain-explorer/src/XChainExplorer.js` on 2026-08-28: + - 174 `/{COIN}/api/...` and 112 `/{COIN}/explorer/...` patterns in the dispatch table built by `setupUrls()`, matched by the catch-all handler rather than registered with Express individually. - 16 hand-registered `/{COIN}/api/...` routes that bypass the dispatch table: raw file download, fee quote, oracle fee quote, preflight (registered twice, GET and POST, because the largest legal action does not fit a query string), fee schedule, checkpoint list, checkpoint range, checkpoint verify, hub-mirror status, the five Merkle proof endpoints (balance, locked balance, action, validator set, contract state), and the POST contract-call query endpoint. - - Outside those two namespaces the same server also registers 114 HTML page routes plus `/openapi.json`, `/icon`, `/relay`, and the static asset mounts. + - Outside those two namespaces the same server also registers 117 HTML page routes plus `/openapi.json`, `/icon`, `/relay`, and the static asset mounts. - JSON-RPC 2.0 interface compatible with Counterparty-style tooling. - Bootstrap-based web UI with Highcharts for order book and market price visualization. - Reads configuration from xchain-hub every 60 seconds (fee schedules, supported parameters, fiat pricing). diff --git a/test/action-count-claims.test.js b/test/action-count-claims.test.js index a1f8eab..df3c09a 100644 --- a/test/action-count-claims.test.js +++ b/test/action-count-claims.test.js @@ -112,7 +112,7 @@ const WIRE_SCOPED = [ ]; const SCOPED = [ - { file: 'components/e2e-test/architecture.md', claim: '77 action', count: 1, + { file: 'components/e2e-test/architecture.md', claim: '79 action', count: 1, why: 'test files, several per action; git ls-files xchain-e2e-test/test/actions on 2026-09-03. ' + 'Count the COMMITTED tree: an untracked suite from another lane inflates a filesystem ' + 'scan, and the gate gates the commit. count-action-suites.js scans the working tree, so ' From 66da60dde2b108aef6c3cb571a9588018499fb9b Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 3 Sep 2026 17:04:11 -0700 Subject: [PATCH 26/52] attest: document the hub-mirror response path and the v5/v6 batch format Above the response-mirror activation height a response reaches every indexer through the hub mirror and fires its callback at the first block whose protocol time reaches the signed effective time, with no validator transaction; the batch head and continuation formats that put the window's responses on chain, the mirror-era signing canonical, the invalid status for an on-chain v1 above the height, and the escrow split without a broadcast reimbursement are all written down beside the existing lifecycle. --- CHANGELOG.md | 1 + protocol/actions/attest.md | 67 +++++++++++++++++++++++++++++++++++--- 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 544a97b..578d642 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `XROLLCALL` joins `ENGINE_TAGS` with header vectors; it is namespacing only and deliberately not a SLASH family. - `rollcall_canonical.json` freezes the canonical and wire bytes with real Ed25519 signatures, including negative cases for a wrong ledger hash, network or epoch. - The indexer configuration page states that the existing DOGE wiring becomes required a second time from `ROLLCALL_ACTIVATION`, and that a BTC indexer without it defers every block. +- ATTEST's hub-mirror response path is documented: the mirror-era signed canonical, deterministic callback timing with no broadcast transaction, the v5/v6 batch format, and the escrow-split change once broadcasting stops. ### Changed - The white paper is bumped to version 1.4, dated 2026-08-31, for the announcement. diff --git a/protocol/actions/attest.md b/protocol/actions/attest.md index 050377f..72db803 100644 --- a/protocol/actions/attest.md +++ b/protocol/actions/attest.md @@ -2,14 +2,14 @@ # XChain Platform Action - ATTEST -This action covers the external-data attestation lifecycle in five version-discriminated phases: v0 (VM-emitted request), v1 (validator-broadcast response), v2 (system-synthesized expiry), and the two cross-chain relay legs v3 (a request materialized onto BTC) and v4 (the response relayed back to the origin chain). +This action covers the external-data attestation lifecycle. A request is v0 (VM-emitted). Below a network's response-mirror activation height, the answer is v1, a validator-broadcast transaction. At or above that height, a finalized response does not arrive as its own on-chain transaction: it is agreed by the validator network and delivered to every indexer through the hub, and only shows up on chain afterward, batched together with other responses, as v5 (batch head) and v6 (batch continuation). See Response delivery, below, for how the two paths work and which one a given request uses. Two more versions round out the lifecycle: v2 (system-synthesized expiry) and the cross-chain relay legs v3 (a request materialized onto BTC) and v4 (the response relayed back to the origin chain). All `attestation` capability stake lives on BTC, so a request emitted by an LTC or DOGE contract has no responsible set where it landed and cannot be fulfilled there. v3 materializes such a request onto BTC, giving it a real BTC `block_index`; from that point the ordinary v0/v1 machinery services it. v4 carries the outcome back so the origin chain fires the contract callback. Both legs are gated (see the Formats section) and inert until then. ## PARAMS | Name | Type | Description | | ---------------------- | ------- | ----------------------------------------------------------------------------------------------- | -| `VERSION` | Integer | Format version (0=request, 1=response, 2=expire, 3=relay request, 4=relay response) | +| `VERSION` | Integer | Format version (0=request, 1=response, 2=expire, 3=relay request, 4=relay response, 5=batch head, 6=batch continuation) | | `ORIGIN_CHAIN` | String | Chain a relayed request was emitted on (`LTC` or `DOGE`); v3 only | | `ORIGIN_ACTION_INDEX` | Integer | The origin chain's v0 `action_index`: the relay correlation key, and together with `ORIGIN_CHAIN` the exactly-once relay identity on BTC; v3 only | | `HOME_RESPONSE_ACTION_INDEX` | Integer | The BTC v1 `action_index` whose outcome is being relayed; v4 only | @@ -29,6 +29,16 @@ All `attestation` capability stake lives on BTC, so a request emitted by an LTC | `SIG_COUNT` | Integer | Number of (pubkey, sig) pairs that follow; v1 only | | `PUBKEY_n` | String | 64-hex Ed25519 pubkey, qualified for `attestation` at the request block; v1 only | | `SIG_n` | String | 128-hex Ed25519 signature over the canonical message; v1 only | +| `BATCH_KEY` | String | 64-hex SHA-256 over `ATTESTBATCH:NETWORK:WINDOW_START:WINDOW_END` (colon-delimited); the batch's own identifier, and the key a v6 wire cites to find its head; v5 and v6 | +| `WINDOW_START` | Integer | Start of the batch's time window, unix seconds; v5 only | +| `WINDOW_END` | Integer | End of the batch's time window, unix seconds; v5 only | +| `ROW_COUNT` | Integer | Number of responses carried in the batch (0 for an empty window); v5 only | +| `BTC_BLOCK_HEIGHT` | Integer | BTC block height the batch's validator signer set is checked against; v5 only | +| `BATCH_CRC32` | String | 8-hex CRC32 checksum of the batch's uncompressed contents, checked after reassembly; v5 and v6 | +| `TOTAL_CHUNKS` | Integer | Total number of wires (the head plus its continuations) the batch is split across; v5 and v6 | +| `BODY_B64` | String | Base64 of the batch's compressed contents, or the head's share of them when the batch continues into v6 wires; v5 only | +| `CHUNK_INDEX` | Integer | This continuation's position among the batch's wires (the head is position 0); v6 only | +| `BODY_B64_CHUNK` | String | Base64 continuation of the compressed contents begun in the head; v6 only | ## Formats @@ -51,6 +61,14 @@ The trailing `FEE_TICK|FEE_AMOUNT` pair is optional. A feeless request omits the Both relay versions activate at `ATTEST_RELAY_ACTIVATION` (BTC 963000 on mainnet, genesis on testnet and regtest). Below the height they are rejected as an unknown VERSION and persist nothing. Every indexer and hub must be deployed before that height. +### Version `5` - Batch head (system-published, Dogecoin only) +- `ATTEST|5|BATCH_KEY|NETWORK|WINDOW_START|WINDOW_END|ROW_COUNT|BTC_BLOCK_HEIGHT|BATCH_CRC32|TOTAL_CHUNKS|BODY_B64` + +### Version `6` - Batch continuation (system-published, Dogecoin only) +- `ATTEST|6|BATCH_KEY|CHUNK_INDEX|TOTAL_CHUNKS|BATCH_CRC32|BODY_B64_CHUNK` + +Both are published once every wall-clock hour on Dogecoin, never user-broadcast, and exist so a node with no connection to the validator network can still reconstruct the complete history of attestation responses from the chain alone (see Response delivery, below). A batch head, plus as many continuations as it needs, carries every response the validator network finalized in that hour, compressed and split so each wire fits inside a single action; an hour that produced no responses still publishes a head with `ROW_COUNT=0`, so every hour's coverage is provable even when nothing happened. A batch is capped at 256 responses and 1,048,576 bytes (1 MiB) of uncompressed content; a window that would need more than that is refused outright, loudly, rather than silently dropped or truncated. + ## Examples ``` ATTEST|0|abc...def|http_get|https://example.com/v1/score/42|handleResponse|["ctx-42"]|1|10 @@ -102,6 +120,7 @@ Below the activation neither rule applies and the legacy per-key ranking runs un - A valid `FEE_AMOUNT > 0` debits `FEE_PAYER` and writes an escrow row at the v0 `action_index`. Absent or zero value means feeless with no ledger movement. ### Version 1 (response) +- Checked before anything else: if the request is at or above its network's response-mirror activation height, an on-chain v1 is rejected outright with `invalid: ATTEST v1 after mirror activation`. Such a request is answered only through the hub mirror (see Response delivery, below); a v1 transaction reaching the chain for it is stale or hostile. - Indexer rejects if `REQUEST_ID` does not match a `pending` row from a prior v0. - `PROVIDER_ID` must equal the request's provider. - Indexer's `BLOCK_INDEX` must be no greater than the request's `DEADLINE_BLOCK`. @@ -131,6 +150,18 @@ Below the activation neither rule applies and the legacy per-key ranking runs un - The signature list must meet the same `cross_chain` quorum as v3, over the relay-response canonical. - On acceptance the request goes `fulfilled` (`ok`) or `errored` (`expired`), its v0 fee escrow settles, and the contract callback is injected with the identical argument shape a locally serviced attestation produces. +### Version 5 (batch head, Dogecoin only) +- Accepted only on Dogecoin; system-published only, never user-broadcast. +- `BATCH_KEY` must equal the SHA-256 of `ATTESTBATCH:NETWORK:WINDOW_START:WINDOW_END`; a head whose key does not match its own declared window is rejected. +- `WINDOW_START` must not exceed `WINDOW_END`, and `ROW_COUNT` must not exceed 256. +- `TOTAL_CHUNKS` is the number of wires (this head plus its continuations) the batch is split across; once every continuation arrives, the reassembled and decompressed contents must decode to exactly `ROW_COUNT` responses matching the header fields exactly, or the whole batch is invalid with no partial acceptance. +- The Dogecoin indexer checks the batch's own quorum signature, covering the whole window, against the validator capability snapshot at `BTC_BLOCK_HEIGHT`; it does not separately re-check each carried response's own signatures there. Those are checked, using the same rule a live response is checked against (see Canonical signing message, mirror era, below), once the responses reach Bitcoin, the chain that actually applies them, through the same delivery path a live response takes. + +### Version 6 (batch continuation, Dogecoin only) +- Accepted only on Dogecoin; system-published only, never user-broadcast. +- Belongs to the head sharing its `BATCH_KEY`; `CHUNK_INDEX` numbers its position among the batch's wires (the head itself is position 0), and `TOTAL_CHUNKS` must match the head's. +- A batch missing any of its declared continuations cannot be reassembled; it stays incomplete until the missing wire arrives, rather than being rejected outright. + ## Canonical signing messages (v3/v4) The relay legs sign pipe-joined field lists, with free-form payloads folded in as SHA-256 digests so the signed bytes stay bounded: @@ -150,12 +181,21 @@ request_id || provider_id || sha256(response_payload) || status || meta Where `sha256(response_payload)` is the lowercase hex digest of the raw response bytes (after base64-decoding the wire field). +### Canonical signing message (v1, mirror era) +At or above the response-mirror activation height, the signed message carries one more field, separated from the rest by a `|`: the effective time at which the response becomes bindable. + +``` +request_id || provider_id || sha256(response_payload) || status || meta | effective_time +``` + +The round's leader proposes the effective time a short margin ahead of the moment of agreement; every other signer checks the proposed time against its own clock before it signs, so a leader proposing an implausible time simply fails to collect a valid quorum. Below the activation height the signed message is the five-field concatenation above with no separator and no effective time, unchanged. Because the two messages are built differently, a signature valid for one can never be mistaken for a signature under the other, so a request cannot be admitted under one era and answered under the other. + ## Lifecycle 1. VM EXECUTE emits ATTEST v0; indexer stores a v0 row in the consolidated `attests` table (`version=0`) with `request_status='pending'`. 2. Validators staked for the `attestation` capability detect the request via the hub's `AttestationRound` polling. 3. Top-`REDUNDANCY` validators (deterministic leader sort by `SHA-256(request_id || pubkey)`) fetch via the provider and gossip `ATTEST_PROPOSE`. -4. Leader publishes ATTEST v1 on-chain with `REDUNDANCY` Ed25519 signatures. -5. On a terminal v1 the indexer flips the request to `fulfilled` (`STATUS=ok`) or `errored` (a genuinely terminal failure such as `expired`) and injects a system EXECUTE invoking the callback. A retryable v1 (`STATUS` of `no_quorum`, `timeout`, or `provider_error`) is recorded but leaves `request_status='pending'`, so the responsible set can attempt another round before the deadline; no callback fires yet. +4. What happens next depends on the request's own response-mirror activation height (see Response delivery, below). Below it, the leader publishes ATTEST v1 on-chain with `REDUNDANCY` Ed25519 signatures, paying the broadcast cost itself. At or above it, nobody broadcasts a transaction: the finalized response is written into the validator network's shared database, passed on to every hub, and delivered to every indexer that way. +5. On a terminal response, whether it arrived as an on-chain v1 or through the mirror, the indexer flips the request to `fulfilled` (`STATUS=ok`) or `errored` (a genuinely terminal failure such as `expired`) and injects a system EXECUTE invoking the callback. A retryable v1 (`STATUS` of `no_quorum`, `timeout`, or `provider_error`) is recorded but leaves `request_status='pending'`, so the responsible set can attempt another round before the deadline; no callback fires yet. 6. If `DEADLINE_BLOCK` passes while still `pending` (no terminal v1, or only retryable rounds), the indexer's per-block expiry pipeline synthesizes ATTEST v2 (flips status to `expired`, fires the callback with `status='expired'`). ```mermaid @@ -192,8 +232,21 @@ stateDiagram-v2 note right of pending: retryable v1, no_quorum, timeout, or provider_error, leaves request_status pending ``` +### Response delivery: on-chain versus the hub mirror + +Each network has its own response-mirror activation height, `ATTEST_RESPONSE_MIRROR_ACTIVATION` in `protocol/constants.js`, checked against the request's own block: mainnet is `null` (inactive; every mainnet request today uses the on-chain path above), testnet is `null` until the operator arms it, and regtest is `0` (active from genesis, so every regtest request already uses the mirror). + +Steps 1 through 3 above are unchanged either way: the request is emitted the same way, and the same top-`REDUNDANCY` validators still agree on the answer together. What changes is what happens with that agreed answer. + +Below the activation height, the leader broadcasts it on-chain as an ATTEST v1 transaction, paying the broadcast cost, and the callback fires once that transaction mines. + +At or above the activation height, nobody broadcasts a transaction for the response. Instead, the agreed answer, together with a signed effective time the leader picks a short margin ahead of the moment of agreement, is written into the validator network's shared database and passed on to every hub in the federation, so every indexer receives it regardless of which hub it is connected to. Each indexer then applies the response on its own, deterministically, at the first block whose own clock has reached the response's signed effective time, as long as that block is still at or before the request's deadline; a response that would not become due until after the deadline never applies; the deadline's own expiry (v2) closes that request instead. The applied response is recorded exactly like an on-chain one, a v1 row with the agreed body and the verifying signatures, except that it carries no transaction: it is generated by the indexer itself, the same way a request's expiry (v2) is. Because delivery already happened through the mirror, an ATTEST v1 that reaches the chain for a request at or above the height is rejected as `invalid: ATTEST v1 after mirror activation`. + +The full history of mirror-delivered responses still reaches the chain: periodically, every finalized response is republished on Dogecoin as one of the batches described under Version 5 and Version 6 above, so a node with no connection to the validator network can still reconstruct every response and replay every callback from the chain alone. + ## Effects on v1 with valid signatures -- Persists a v1 row into the `attests` table (`version=1`) with the agreed body and the verified federation sigs inlined as a JSON array in `validator_signatures` (always, including retryable rounds, for audit). A v0 request and its v1 response(s) are separate rows correlated by `request_id`. +- At or above the response-mirror activation height, only a terminal `ok` response is ever delivered this way: a retryable round stays in the validator network's own records rather than becoming a row here, and `expired` is already handled locally by v2. Such a response is generated by the indexer itself from the mirror-delivered response rather than parsed from a broadcast transaction (see Response delivery, above), but is otherwise persisted exactly as below. +- Persists a v1 row into the `attests` table (`version=1`) with the agreed body and the verified federation sigs inlined as a JSON array in `validator_signatures` (always, including retryable rounds on the on-chain path, for audit). A v0 request and its v1 response(s) are separate rows correlated by `request_id`. - Terminal statuses flip the matching v0 `attests` row: `fulfilled` (`STATUS=ok`) or `errored` (a terminal failure such as `expired`). - Retryable statuses (`no_quorum`, `timeout`, `provider_error`) leave `request_status='pending'` untouched so a later round can still reach quorum before the deadline (or the v2 expiry path takes over). No status flip and no callback for these. - On a terminal status only, synthesizes an EXECUTE injecting the callback with params `[request_id, provider_id, status, response_payload, ...original_callback_params]`. @@ -221,6 +274,8 @@ When a v0 request carries `FEE_AMOUNT > 0`, the fee is escrowed from `FEE_PAYER` | v1 retryable (`no_quorum` / `timeout` / `provider_error`) | No movement: escrow stays locked, request stays `pending`. | | v2 expiry (synthesized) | Release escrow and refund `FEE_PAYER`. | +At and above the response-mirror activation height, nobody broadcasts a transaction for the response, so there is nothing to reimburse: the full escrow above is split across the responsible-set pubkeys with nothing carved out first. Below the height, on networks where a reimbursement for the leader's on-chain broadcast cost is active, that reimbursement is paid to the responsible set's lowest-hash member before the remaining escrow is split among the whole set; where no such reimbursement is active the full escrow already splits the same way as above the height. + `validator_rewards` rows are paid out to stakers via `COLLECT` (the same path as protocol rewards, hence the XCHAIN-only constraint, since that chain has no per-row tick column). A reorg mid-fulfillment rolls back the release and reward rows generically (credits/debits/escrows by `action_index`, rewards by `block_index`) and resets `request_status` to `pending`; the earlier v0 escrow row survives. ## Notes @@ -228,6 +283,8 @@ When a v0 request carries `FEE_AMOUNT > 0`, the fee is escrowed from `FEE_PAYER` - The positional label in the indexer's internal format string for v0 is `CALLBACK_PARAMS_JSON` (so named to signal that the field carries a JSON array). The data object key used throughout the handler and the stored column name is `CALLBACK_PARAMS`. The name in this PARAMS table (`CALLBACK_PARAMS`) is the canonical user-facing name and matches the stored key; the `_JSON` suffix in the format string is an implementor hint, not a separate field. - Storage is consolidated into a single `attests` table: v0 (request) and v1 (response) rows are version-discriminated and correlated by `request_id`, mirroring how `messages` holds every MESSAGE variant in one table. v2 (expire) writes no row, it only flips the v0 row's `request_status`. Validator sigs live inline as a JSON array in the response row's `validator_signatures` column; per-validator accountability tallies live in `attest_validator_stats`. - The optional `FEE_TICK`/`FEE_AMOUNT` request fee is live. The separate `gas_escrow` (callback-gas) field remains stubbed at `'0'`; real callback-gas escrow is Phase 3 economic work, independent of the request fee. +- Publishing a v5/v6 batch pays no reward of its own; it is a durability measure, not a service the protocol compensates separately. +- Which path answers a given request (on-chain broadcast or the hub mirror) is fixed for that request the moment it is admitted, by `ATTEST_RESPONSE_MIRROR_ACTIVATION` in [`protocol/constants.js`](../constants.js) checked against the request's own block, so a request is never admitted under one rule and answered under the other. - See [`EXECUTE.md`](./execute.md) for the system-synthesized EXECUTE that delivers attestation callbacks and for the cross-contract call mechanics that share the same emission and savepoint patterns. --- From 36d137a370c00aaa2fc5400e192eb29aed2620f2 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 3 Sep 2026 18:27:48 -0700 Subject: [PATCH 27/52] components(e2e-test): recount the helper modules now that the attest mirror venue has one The tree carries 52 helper modules; both component pages published 51. --- components/e2e-test/README.md | 2 +- components/e2e-test/architecture.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/components/e2e-test/README.md b/components/e2e-test/README.md index 2dbc528..bf73875 100644 --- a/components/e2e-test/README.md +++ b/components/e2e-test/README.md @@ -67,7 +67,7 @@ flowchart TD subgraph E2E["xchain-e2e-test"] CH["cryptoHelper
BIP39/BIP32
wallet mgmt"] TH["transactionHelper
PSBT/P2SH"] - AH["action helpers (51 modules)
message construction"] + AH["action helpers (52 modules)
message construction"] SC["Service Connectors (src/)
BlockchainConnector, XChainEncoderConnector
XChainUtxoTrackerConn, XChainDecoderConnector
XChainIndexerConnector, XChainExplorerConnector
XChainHubConnector, RegtestMinerConnector
Database (MariaDB)"] CH --> SC TH --> SC diff --git a/components/e2e-test/architecture.md b/components/e2e-test/architecture.md index ae59392..15464ee 100644 --- a/components/e2e-test/architecture.md +++ b/components/e2e-test/architecture.md @@ -179,7 +179,7 @@ xchain-e2e-test/ │ ├── cryptoHelper.js # BIP39/BIP32 wallet management │ ├── transactionHelper.js # PSBT construction, signing, broadcast │ ├── actions/ # 79 action test files (live, ordered), covering 31 ACTION names -│ ├── helpers/ # 51 modules (action helpers + federation/fee/utility helpers) +│ ├── helpers/ # 52 modules (action helpers + federation/fee/utility helpers) │ ├── unit/ # 350+ unit tests (stubbed, no services) │ ├── integration/ # 150+ integration tests (stubbed I/O) │ │ ├── fixtures/ # mockMariadb, services, dbRows, hub From 042bcd39b16cbf6cc14440a11475bcce8dcb6bd9 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 3 Sep 2026 18:34:36 -0700 Subject: [PATCH 28/52] docs(hub): document the attestation batch publisher's three settings The batch publisher and its window seam read three variables that no configuration table described, so the coverage gate refused every push across the tree. The kill switch and the buffer path join the attestation publishing table. The window override goes beside the forward-margin override it shares a module and a rule set with, including the one way they differ: a batch window of zero seconds is a division by zero in the alignment arithmetic, so regtest demands a positive integer rather than merely a whole number. --- components/hub/configuration.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/components/hub/configuration.md b/components/hub/configuration.md index 06ea461..0e13052 100644 --- a/components/hub/configuration.md +++ b/components/hub/configuration.md @@ -208,6 +208,7 @@ mounts it into the hub container automatically. See OPERATIONS.md → Validator | `XCHAIN_HUB_SKIP_REORG_BUFFER_ASSERT` | No | _(unset)_ | Set to `1` to bypass the assertion that `HUB_SNAPSHOT_REORG_BUFFER` equals the canonical federation value. Only for a venue where **every** hub runs the same override: each hub subtracts this buffer before resolving a snapshot, so hubs disagreeing on it lock different blocks for the same round and produce divergent validator sets and quorum N. On `mainnet` and `testnet` a mismatch otherwise refuses to start (`REORG_BUFFER_MISMATCH`); standalone and regtest warn instead. The bypass logs a warning every time it is taken. | | `XCHAIN_HUB_SKIP_MIN_STAKE_ASSERT` | No | _(unset)_ | Set to `1` to skip the minimum-stake assertion at startup. Test and bring-up seam; leaving it set on a real deployment disables a safety check. | | `ATTEST_RESPONSE_FORWARD_S_OVERRIDE` | No | _(unset)_ | Overrides `ATTEST_RESPONSE_FORWARD_S` (120), the seconds a round leader adds to now when stamping the effective time an attestation response becomes applicable at. **Honoured on `regtest` only**; on any other network a differing value is ignored with a warning latched once per process, and standalone mode (no network) counts as not-regtest and keeps the frozen value. On `regtest` a value that is not a whole number of seconds throws at resolve time rather than defaulting, because a silent fallback to 120 leaves an acceptance run waiting two minutes per attestation with nothing in the log to explain it. The seam exists because regtest blocks are stamped at roughly now, so without it no mirrored response could bind for 120 real seconds. Also readable from the validator config table under the same key, which takes precedence over the environment. | +| `ATTEST_BATCH_WINDOW_S_OVERRIDE` | No | _(unset)_ | Overrides `ATTEST_BATCH_WINDOW_S` (3600), the length of the window an attestation batch closes on. Same seam and same rules as `ATTEST_RESPONSE_FORWARD_S_OVERRIDE` above, with one difference: on `regtest` the value must be a **positive** whole number of seconds, because a window of zero is not a faster cadence but a division by zero in the alignment arithmetic, and a bad spelling throws at resolve time rather than defaulting. **Honoured on `regtest` only**; elsewhere a differing value is ignored with a warning latched once per process, since the window bounds are part of the batch key and of the signed batch canonical, so a hub running its own cadence proposes batches no peer can co-sign. Also readable from the validator config table under the same key, which takes precedence over the environment. | ### Hub-DB WebSocket (`GET /hub-db/subscribe`) @@ -365,6 +366,8 @@ Controls `AttestationPublisher`, which writes the validator network's answers to | `ATTESTATION_AMBIGUOUS_COOLDOWN_MS` | No | `ATTESTATION_FAILOVER_WINDOW_BLOCKS × ATTESTATION_BLOCK_MS` | Cooldown after an ambiguous publish result before another hub may retry. | | `ATTEST_PUBLISHED_REQUESTS_RETENTION_MS` | No | `7776000000` | How long a confirmed publish marker is kept in `attest_published_requests` before it is swept, roughly 90 days. Set to `0` to disable pruning and keep every marker. Only confirmed markers are ever deleted: an intent-only row is the quarantine record for a request whose on-chain state is unknown, which an operator reconciles by hand, so those are kept regardless of age. The window is also floored at the longest live provider `deadline_window_blocks` and never touches a request still on the durable queue file. | | `BTC_ADDRESS` | No | _(from config table)_ | BTC address of this hub's publishing wallet. | +| `ATTEST_BATCH_PUBLISH_ENABLED` | No | `true` | Set to `false` to stop this hub publishing attestation batches on-chain, halting the outbound DOGE spend during an incident without tearing the pipeline's configuration down. Consensus participation is unaffected. A halted publisher **skips** each window rather than buffering it, so re-enabling does not flood the rail with a backlog. Read from the environment first, then the validator config table under the same key. | +| `ATTEST_BATCH_BUFFER_PATH` | No | `./data/attest-batch-buffer.jsonl` | Durable record of what each attestation batch window was built from at the moment it published, so an operator replaying a dead-lettered or quarantined window has its content instead of reconstructing it from tables that have since moved on. Point at persistent storage. The dead-letter file sits beside it, at the same path with `.deadletter.jsonl` in place of `.jsonl`. Belongs to the batch publisher alone and must not be pointed at the price publisher's buffer. Read from the environment first, then the validator config table under the same key. | ### Attestation Relay From 11c45e397a0f383ecc81ab2b0e9adc90e51527c2 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 3 Sep 2026 19:04:59 -0700 Subject: [PATCH 29/52] docs(node): document the three hub-mirror grace windows and raise the node read baseline xchain-node forwards the price, oracle and attestation-response grace windows to the indexer by name in one loop, so each is a computed read the coverage gate cannot see. All three now carry rows, and the baseline moves to match the committed tree. --- components/node/configuration.md | 3 +++ lib/env-var-doc-coverage.js | 8 +++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/components/node/configuration.md b/components/node/configuration.md index 2aa546f..498ec3f 100644 --- a/components/node/configuration.md +++ b/components/node/configuration.md @@ -119,6 +119,9 @@ These variables are read by xchain-node itself at startup. They control runtime | `XCHAIN_NODE_TELEMETRY_URL` | Override the telemetry collector endpoint (default: `https://hub.xchain.io/telemetry`). Useful for self-hosted collectors or test environments. | | `HUB_API_KEY` | API key xchain-node sends as `x-api-key` when talking to the hub, and forwards into the generated service `.env` files. Required whenever the hub runs keyed. Treat as a credential. | | `FEE_DESTINATION` | Native-coin fee destination forwarded into the generated service `.env` files. **Honoured on testnet and regtest only**, and only when the more specific `XCHAIN_FEE_DESTINATION__` is unset; on mainnet the bundled coin registry always wins, because fee acceptance is consensus and must not depend on an operator's environment. | +| `HUB_SYNC_PRICE_GRACE_S` | Seconds of grace the indexer allows on the hub mirror's `price_snapshots` watermark before its barrier holds a block. Forwarded into the indexer's generated `.env`. On regtest xchain-node defaults it to `0`, because a single-operator venue has no second writer to wait for; a value set in the host environment always wins. | +| `HUB_SYNC_ORACLE_GRACE_S` | The same grace for the `oracle_prices` stream. Same regtest default of `0` and the same precedence. | +| `HUB_SYNC_ATTEST_RESPONSE_GRACE_S` | The same grace for the `attestation_responses` stream, which gates the attestation response mirror's barrier. Same regtest default of `0` and the same precedence. | ### Telemetry collector diff --git a/lib/env-var-doc-coverage.js b/lib/env-var-doc-coverage.js index a5e41f3..1820248 100644 --- a/lib/env-var-doc-coverage.js +++ b/lib/env-var-doc-coverage.js @@ -1031,7 +1031,7 @@ function checkDivergentDefaults(survey) { const COMPUTED_READ_BASELINE = { // Measured 2026-08-11 against the committed trees of all 11 gated // components: 95 sites in 37 files across 10 of them. - decoder: 4, encoder: 4, explorer: 7, hub: 33, indexer: 6, node: 20, + decoder: 4, encoder: 4, explorer: 7, hub: 33, indexer: 6, node: 23, 'regtest-miner': 7, sdk: 4, sync: 9, 'utxo-tracker': 8, vm: 0, // hub 31 -> 33 on 2026-09-03: RollcallRound._resolveTunable() reads // process.env[name] twice for the three ROLLCALL_*_BLOCKS tunables, and @@ -1039,6 +1039,12 @@ const COMPUTED_READ_BASELINE = { // constant. All four names carry rows in components/hub/configuration.md. // Raised to match the committed trees, which had landed on origin ahead of // the rows; the roll-call indirection is what gives each tunable one NaN guard. + // node 20 -> 23 on 2026-09-04: ConfigService forwards the three hub-mirror + // grace windows to the indexer by name (process.env[varName] in one loop), so + // the operator sets each once on the node and the indexer inherits it. All + // three carry rows in components/node/configuration.md. Raised deliberately: + // the indirection is what lets regtest default the trio to 0 while a host + // value still wins, which a per-name read would have to repeat three times. // node 17 -> 20 on 2026-09-03: ConfigService passes the roll-call rail env // through to the indexer and hub by name (process.env[varName], read three // times in one guard), so the operator sets each variable once on the node. From 20f7bd501ed7794c5784ff959b2234df5ed0832e Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 3 Sep 2026 19:07:09 -0700 Subject: [PATCH 30/52] docs(hub): document the attestation round cadence knobs Both reached the p2p config only recently, so an operator setting them saw no effect and neither carried a row. --- components/hub/configuration.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/components/hub/configuration.md b/components/hub/configuration.md index 0e13052..6c63cc5 100644 --- a/components/hub/configuration.md +++ b/components/hub/configuration.md @@ -253,6 +253,8 @@ The hub reads the BTC chain tip to anchor consensus rounds. These gates stop a s | `ORACLE_LEADER_TIMEOUT_MS` | No | `30000` | How long a round waits on its leader before failover. Kept below the finalization window. | | `ORACLE_FINALIZED_MAX` | No | `10000` | Cap on retained finalized-round records held in memory. | | `ORACLE_SUBMISSIONS_RETENTION_ROUNDS` | No | _(unset)_ | Number of past rounds of raw price submissions to retain. Unset keeps the built-in retention. | +| `ATTESTATION_POLL_MS` | No | `15000` | How often a validator hub polls its indexer for new attestation requests, in milliseconds. | +| `ATTESTATION_ROUND_TIMEOUT_MS` | No | `120000` | How long an attestation round may run before it is abandoned, in milliseconds. A round that times out leaves the request for a later round rather than failing it. | | `ORACLE_PUBLISHED_ROUNDS_RETENTION_ROUNDS` | No | `12960` | Number of recent rounds of published-round markers to keep, roughly 90 days at the default round interval. Set to `0` to disable pruning and keep every marker. Only confirmed markers are ever pruned: a marker for a round whose on-chain state is still unknown is a quarantine record an operator reconciles by hand, so those are always retained. | | `ORACLE_ALLOW_UNVERIFIED_PAIRS` | Regtest only | `false` | Set to `true` to co-sign a proposed pair this hub can verify against nothing (no live local aggregate and no finalized history). It stands down a Byzantine-leader defense, so it is honored **only on regtest**: on mainnet, testnet, and a standalone hub with no `HUB_NETWORK`, it is ignored (and logged) and unverifiable-pair co-sign stays fail-closed. A real federation always has a second fetcher, so the hatch has no legitimate use there. | | `ORACLE_MAX_PRICE_AGE_SECONDS` | Regtest only | _(coin registry, per pair)_ | Maximum age of an oracle price before it is treated as stale. Resolution order is `p2pConfig` → this variable → the per-pair value pinned in the coin registry. The bound is consensus-pinned: it is content-hashed into `CONSENSUS_CONFIG_PIN`, and the indexer reads only the pinned bundle with no override path of its own. So the override is honored **only on regtest**; on mainnet, testnet, and standalone it is ignored (and logged) in favour of the pinned bound. Honoring it elsewhere would detach this hub's fee quotes, and the `oracleMaxPriceAgeSeconds` it reports over `getoraclesubmissions`, from the bound they claim to mirror: quoting rounds the fleet's fee gate rejects, or refusing rounds it accepts. To change the staleness gate for real, change the pinned coin bundle. | From e8c0a8420d7369dd6bc1c1cf2f20e662f7ad1c94 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 3 Sep 2026 19:11:32 -0700 Subject: [PATCH 31/52] docs(hub): say why the attestation round timeout is coupled to the seen window An operator retuning it cannot see from the name that both engines read the one value, or that raising it alone re-opens a pending request and pays for a duplicate provider fetch. --- components/hub/configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/hub/configuration.md b/components/hub/configuration.md index 6c63cc5..9f814f2 100644 --- a/components/hub/configuration.md +++ b/components/hub/configuration.md @@ -254,7 +254,7 @@ The hub reads the BTC chain tip to anchor consensus rounds. These gates stop a s | `ORACLE_FINALIZED_MAX` | No | `10000` | Cap on retained finalized-round records held in memory. | | `ORACLE_SUBMISSIONS_RETENTION_ROUNDS` | No | _(unset)_ | Number of past rounds of raw price submissions to retain. Unset keeps the built-in retention. | | `ATTESTATION_POLL_MS` | No | `15000` | How often a validator hub polls its indexer for new attestation requests, in milliseconds. | -| `ATTESTATION_ROUND_TIMEOUT_MS` | No | `120000` | How long an attestation round may run before it is abandoned, in milliseconds. A round that times out leaves the request for a later round rather than failing it. | +| `ATTESTATION_ROUND_TIMEOUT_MS` | No | `120000` | How long an attestation round may run before it is abandoned, in milliseconds. A round that times out leaves the request for a later round rather than failing it. **Coupled, so retune it deliberately:** the request-seen window is sized to stay outside a live round, and both the round and the consensus engine read this one value. Raising it without that in mind lets a second window re-open a request whose round is still pending, which pays a provider a second time for the same fetch. | | `ORACLE_PUBLISHED_ROUNDS_RETENTION_ROUNDS` | No | `12960` | Number of recent rounds of published-round markers to keep, roughly 90 days at the default round interval. Set to `0` to disable pruning and keep every marker. Only confirmed markers are ever pruned: a marker for a round whose on-chain state is still unknown is a quarantine record an operator reconciles by hand, so those are always retained. | | `ORACLE_ALLOW_UNVERIFIED_PAIRS` | Regtest only | `false` | Set to `true` to co-sign a proposed pair this hub can verify against nothing (no live local aggregate and no finalized history). It stands down a Byzantine-leader defense, so it is honored **only on regtest**: on mainnet, testnet, and a standalone hub with no `HUB_NETWORK`, it is ignored (and logged) and unverifiable-pair co-sign stays fail-closed. A real federation always has a second fetcher, so the hatch has no legitimate use there. | | `ORACLE_MAX_PRICE_AGE_SECONDS` | Regtest only | _(coin registry, per pair)_ | Maximum age of an oracle price before it is treated as stale. Resolution order is `p2pConfig` → this variable → the per-pair value pinned in the coin registry. The bound is consensus-pinned: it is content-hashed into `CONSENSUS_CONFIG_PIN`, and the indexer reads only the pinned bundle with no override path of its own. So the override is honored **only on regtest**; on mainnet, testnet, and standalone it is ignored (and logged) in favour of the pinned bound. Honoring it elsewhere would detach this hub's fee quotes, and the `oracleMaxPriceAgeSeconds` it reports over `getoraclesubmissions`, from the bound they claim to mirror: quoting rounds the fleet's fee gate rejects, or refusing rounds it accepts. To change the staleness gate for real, change the pinned coin bundle. | From 10906448cef81668368de4152d80e1dc689d55f5 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 3 Sep 2026 19:12:08 -0700 Subject: [PATCH 32/52] docs(hub): describe the table that marks a published response batch It is the same at-most-once guard the single-response marker beside it provides, one level up, covering a whole window rather than one response. The row records why it lives in the database and not in the publisher's buffer file: the batch spends real coin, and the buffer sits on the disk whose exhaustion is what makes a rewrite after the broadcast fail. The detail carries the four states, because only one of them is safe to act on automatically. A window left at intent is quarantined for a person, since the transaction may be waiting in a mempool this hub cannot see. --- components/hub/database.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/components/hub/database.md b/components/hub/database.md index 1b513f7..d98bded 100644 --- a/components/hub/database.md +++ b/components/hub/database.md @@ -318,6 +318,7 @@ PBFT-finalized XCALL dispatch and result records. Each XCALL produces two rows i | Table | Purpose | |---|---| | `attest_published_requests` | Durable at-most-once broadcast marker for ATTEST v1 response publishes | +| `attest_published_batches` | Durable at-most-once broadcast marker for the periodic ATTEST v5/v6 response batch, one row per window | | `attestation_fetch_cache` | Per-request cache of a validator's own provider-fetch outcome, so a retry doesn't pay for the same fetch twice | These tables back the External Attestation Framework: validators fetch data from a governance-approved provider for an ATTEST v0 request, gossip their proposal to PBFT-style quorum, and the elected leader publishes the finalized ATTEST v1 response on-chain. They are unrelated to the `attestations` table under Cross-Chain Tables, which records cross-chain *action* confirmations rather than external-data attestations. @@ -335,6 +336,16 @@ The restart-surviving half of the at-most-once guard around `AttestationPublishe **Primary key:** `(request_id)`. **Key:** `idx_sent (sent_at)` +### `attest_published_batches` + +The same at-most-once guard as `attest_published_requests`, one level up: it marks the periodic batch that carries a window of finalized responses, rather than a single response. It is a table and not the publisher's buffer file on purpose, because the batch is a money-bearing broadcast on the operator's one Dogecoin wallet, and the buffer lives on the very disk whose exhaustion makes a post-broadcast rewrite fail. A restart reads this back before publishing anything, so a crash between the send and its marker costs an operator check rather than a second fee. + +Keyed on `(network, window_start)` rather than on a batch identifier, because the window is the unit of coverage and the batch key is derived from the window bounds, so keying on one is keying on the other. The window is one hour, aligned to the unix hour, and frozen as a protocol constant: those bounds are what the batch key derives from and what the quorum signs, so two hubs on different cadences do not simply publish on two schedules, they propose batches no peer can co-sign. Every window publishes, an empty one as a `row_count` of zero, which is what lets a chain-only node prove coverage by finding a head for each window instead of trusting that a quiet hour held nothing. + +`status` carries the whole state machine. `intent` is written before the send and means the outcome is unknown; a restart that finds one quarantines that window for an operator rather than republishing, since the transaction may sit in a mempool this hub cannot see. `sent` means this hub has paid for the window. `deadletter` means the content cannot become a batch at all, over the row cap or a body the wire refuses, so the sweep stops retrying it and the content goes to the publisher's append-only dead-letter file. `landed` means a batch was parsed back off Dogecoin, and it is authoritative for the whole federation: any hub's batch covers the window, so hubs that never published one stop considering it. A window with no row is simply unpublished, which is where a window whose signing round found no quorum is deliberately left, since its rows remain in `attestation_responses` and a later attempt rebuilds byte-identical content from them. + +**Primary key:** `(network, window_start)`. **Key:** `idx_attest_batch_window (network, status, window_start)` + ### `attestation_fetch_cache` Caches the outcome of this validator's own fetch from an attestation provider for a given ATTEST v0 request. Provider fetches are billed, so without this a retry within the same request's retry window would pay for the identical fetch again; a cache hit returns the earlier recorded outcome (`ok` or `provider_error`) instead of calling the provider a second time. Rows age out on the same retry window the round manager uses and are evicted along with it. From a19fff1a1902e6fcd22bb90b2ac4994a24122721 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 3 Sep 2026 19:21:16 -0700 Subject: [PATCH 33/52] docs(hub): say why the batch window is not tied to the price window The two are different units on purpose, and an operator reading only the hour cannot see that tying them would move a chain-only node's coverage proof whenever price staleness is retuned. --- components/hub/database.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/hub/database.md b/components/hub/database.md index d98bded..340a605 100644 --- a/components/hub/database.md +++ b/components/hub/database.md @@ -340,7 +340,7 @@ The restart-surviving half of the at-most-once guard around `AttestationPublishe The same at-most-once guard as `attest_published_requests`, one level up: it marks the periodic batch that carries a window of finalized responses, rather than a single response. It is a table and not the publisher's buffer file on purpose, because the batch is a money-bearing broadcast on the operator's one Dogecoin wallet, and the buffer lives on the very disk whose exhaustion makes a post-broadcast rewrite fail. A restart reads this back before publishing anything, so a crash between the send and its marker costs an operator check rather than a second fee. -Keyed on `(network, window_start)` rather than on a batch identifier, because the window is the unit of coverage and the batch key is derived from the window bounds, so keying on one is keying on the other. The window is one hour, aligned to the unix hour, and frozen as a protocol constant: those bounds are what the batch key derives from and what the quorum signs, so two hubs on different cadences do not simply publish on two schedules, they propose batches no peer can co-sign. Every window publishes, an empty one as a `row_count` of zero, which is what lets a chain-only node prove coverage by finding a head for each window instead of trusting that a quiet hour held nothing. +Keyed on `(network, window_start)` rather than on a batch identifier, because the window is the unit of coverage and the batch key is derived from the window bounds, so keying on one is keying on the other. The window is one hour, aligned to the unix hour, and frozen as a protocol constant: those bounds are what the batch key derives from and what the quorum signs, so two hubs on different cadences do not simply publish on two schedules, they propose batches no peer can co-sign. Every window publishes, an empty one as a `row_count` of zero, which is what lets a chain-only node prove coverage by finding a head for each window instead of trusting that a quiet hour held nothing. That hour is deliberately not tied to the PRICE batch window, which is a COUNT OF ROUNDS (`ORACLE_BATCH_WINDOW_ROUNDS`, defaulted from a staleness ceiling and settable by an operator) rather than a span of time. The two are different units on purpose: an hour is something a node holding nothing but the chain can enumerate, while a round count moves whenever price staleness is retuned, so tying attestation coverage to it would make that proof shift for reasons that have nothing to do with attestation. `status` carries the whole state machine. `intent` is written before the send and means the outcome is unknown; a restart that finds one quarantines that window for an operator rather than republishing, since the transaction may sit in a mempool this hub cannot see. `sent` means this hub has paid for the window. `deadletter` means the content cannot become a batch at all, over the row cap or a body the wire refuses, so the sweep stops retrying it and the content goes to the publisher's append-only dead-letter file. `landed` means a batch was parsed back off Dogecoin, and it is authoritative for the whole federation: any hub's batch covers the window, so hubs that never published one stop considering it. A window with no row is simply unpublished, which is where a window whose signing round found no quorum is deliberately left, since its rows remain in `attestation_responses` and a later attempt rebuilds byte-identical content from them. From 06ea099f43a44c459bc43d3b8e2939baf0944646 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 3 Sep 2026 13:52:16 -0700 Subject: [PATCH 34/52] docs(wallet): the 25th-word passphrase is stored, entered once at setup The six wallet pages described a passphrase that was never stored and had to be typed at every unlock. It is now captured once at create or import and stored encrypted under the wallet password, so the pages say that, and say plainly what it does and does not protect: it covers a recovery phrase that leaks on its own, it does not cover someone holding the device and the password, and it is not a decoy mechanism. Import now warns that a phrase carries no record of whether a passphrase was used, so importing without one opens a different, empty wallet with nothing to signal it. --- bin/generate-flag-days.js | 6 +++--- components/wallet/README.md | 2 +- components/wallet/features.md | 6 +++--- components/wallet/glossary.md | 2 +- components/wallet/keys-signing.md | 14 +++++++++----- components/wallet/release/qa-checklist.md | 9 ++++++--- components/wallet/ux.md | 3 ++- 7 files changed, 25 insertions(+), 17 deletions(-) diff --git a/bin/generate-flag-days.js b/bin/generate-flag-days.js index 5187146..fb11455 100644 --- a/bin/generate-flag-days.js +++ b/bin/generate-flag-days.js @@ -324,9 +324,9 @@ function collectSiblingGates(indexerSrc, add) { * Refuses a registry that declares a gate in a style the two regexes above * cannot read. * - * WHY THIS IS LOUD RATHER THAN LENIENT. An unrecognised declaration used to be - * skipped in silence, and the generator then rewrote protocol/flag-days.md to - * agree with the loss, so the page shipped one row short with the whole suite + * WHY THIS IS LOUD RATHER THAN LENIENT. An unrecognised declaration left + * unread would be skipped in silence, and the generator would then rewrite protocol/flag-days.md to + * agree with the loss, so the page would ship one row short with the whole suite * green: test/flag-day-literals.test.js can only anchor gate names that already * exist, which is no help for the gate somebody adds tomorrow. * diff --git a/components/wallet/README.md b/components/wallet/README.md index f173ba0..c8e7ef0 100644 --- a/components/wallet/README.md +++ b/components/wallet/README.md @@ -13,7 +13,7 @@ The wallet implements every XChain feature exposed by the platform: all 31 user- - **Four shells, one codebase**: `@xchain-wallet/web` (Vite SPA, mobile-responsive), `@xchain-wallet/extension` (Chrome MV3 popup + full-screen + side panel + service worker), `@xchain-wallet/desktop` (Electron, main-process signing isolation), `@xchain-wallet/mobile` (Capacitor wrapper of the built web SPA; Android shipped, iOS later); all share `@xchain-wallet/core` for routes, components, flows, and signers - **All 31 user-encodable ACTIONs** (every action has an authoring form; `PROTOCOL_ONLY_ACTIONS` is empty): ADDRESS, AIRDROP, BATCH, BET, BROADCAST, CALLBACK, COINPAY, COLLECT, DELEGATE, DEPLOY, DEPOSIT, DESTROY, DISPENSER, DIVIDEND, EXECUTE, FILE, ISSUE, LINK, LIST, MESSAGE, MINT, ORDER, PRICE, SEND, SLEEP, STAKE, SWAP, SWEEP, UNSTAKE, VOTE, WITHDRAW. COLLECT, DEPLOY, and EXECUTE are BTC-only; the other 28 are offered on all three chains -- **Self-custodial key management**: BIP39 mnemonic + optional 25th-word passphrase, BIP32 HD derivation per chain, AES-256-GCM vault encrypted with an Argon2id-derived master key (calibrated per device), Counterwallet legacy mnemonic import +- **Self-custodial key management**: BIP39 mnemonic + optional 25th-word passphrase (captured once at setup and stored encrypted alongside the mnemonic), BIP32 HD derivation per chain, AES-256-GCM vault encrypted with an Argon2id-derived master key (calibrated per device), Counterwallet legacy mnemonic import - **Pluggable signer interface**: four concrete signers, `SoftwareSigner` (in-vault keys), `TrezorSigner` (Trezor Connect), `LedgerSigner` (WebHID), and `RemoteSigner` (cross-shell pairing). Multisig (classical n-of-m + MuSig2) is orchestrated by flows over these signers rather than by a signer of its own; a dedicated `MultisigSigner` is planned but not implemented - **Token issuance suite**: issue / mint / destroy / distribute / dividend / dispenser / broadcast / airdrop / sweep, with parsed-recipient previews and dry-run review - **DEX surface**: markets list, market view with lightweight-charts price chart, place-order panel, orderbook, recent trades, open orders, trade history diff --git a/components/wallet/features.md b/components/wallet/features.md index 53d002d..43d5a02 100644 --- a/components/wallet/features.md +++ b/components/wallet/features.md @@ -162,12 +162,12 @@ For users who keep keys on an offline device: ## Onboarding & recovery -- **Create**: fresh BIP39 12-word mnemonic by default (24 words selectable), optional 25th-word passphrase, password-derived vault encryption -- **Import**: BIP39 (12 / 15 / 18 / 21 / 24 words), Counterwallet legacy, or single WIF +- **Create**: fresh BIP39 12-word mnemonic by default (24 words selectable), optional 25th-word passphrase captured once and stored encrypted alongside the mnemonic, password-derived vault encryption +- **Import**: BIP39 (12 / 15 / 18 / 21 / 24 words) with an optional passphrase, Counterwallet legacy, or single WIF. A recovery phrase carries no record of whether a passphrase was used, so importing without the original passphrase opens a different, empty wallet with no warning - **Migrate to BIP39**: one-way migration from Counterwallet legacy mnemonic; fresh BIP39 phrase, opt-in sweep flow to move balances - **Discover used addresses**: gap-limit scan that populates already-used receive addresses on import - **Dry-run restore**: verify a mnemonic + passphrase pair against the first N derived addresses without committing to a fresh wallet -- **Backup file**: full vault export, re-wrapped under a backup-specific KDF +- **Backup file**: full vault export, including the encrypted passphrase where one is set, re-wrapped under a backup-specific KDF - **Add address**: `AddAddressModal` batch-generates 1-25 addresses (Coin + Type picker), sequentially; hardware wallets prompt the device per address - **View private key**: per-address WIF export; gated by a warning in an unlocked session (no password re-entry); requires unlock when the wallet is locked diff --git a/components/wallet/glossary.md b/components/wallet/glossary.md index c5e7a64..9ad829d 100644 --- a/components/wallet/glossary.md +++ b/components/wallet/glossary.md @@ -26,7 +26,7 @@ A reference for terms used throughout the wallet's design and user-facing surfac **imported WIF** - A single private key imported into an existing HD wallet. The key sits alongside derived keys but is not recoverable from the mnemonic; it must be backed up separately. -**BIP39 passphrase** - An optional "25th word" added to the mnemonic when deriving the seed. Different passphrases produce different wallets from the same mnemonic. Permanent: forgetting the passphrase permanently locks the wallet. +**BIP39 passphrase** - An optional "25th word" added to the mnemonic when deriving the seed. Different passphrases produce different wallets from the same mnemonic. Captured once, at wallet creation or import, and stored encrypted alongside the mnemonic under the wallet's own password, so it is not re-entered at each unlock; restoring the wallet on a different device still requires it, together with the recovery phrase. Permanent: forgetting the passphrase permanently locks the wallet. **signer** - An object that produces a signature for a transaction or message. Concrete kinds: software (mnemonic + password unlock), Trezor (over Trezor Connect), Ledger (over WebHID), and remote/multisig composites. Selected per address. diff --git a/components/wallet/keys-signing.md b/components/wallet/keys-signing.md index 034e76a..25ba251 100644 --- a/components/wallet/keys-signing.md +++ b/components/wallet/keys-signing.md @@ -60,16 +60,18 @@ flowchart TD S5 --> DONE ``` -Vault contents include the encrypted seed, derivation roots, accounts, addresses, contacts, connected-sites, multisig configs, in-flight signing sessions, queued broadcasts, registered signers, and settings. See [Architecture; Vault and state model](architecture.md) for the full collection list. +Vault contents include the encrypted seed, the encrypted passphrase (where one is set), derivation roots, accounts, addresses, contacts, connected-sites, multisig configs, in-flight signing sessions, queued broadcasts, registered signers, and settings. See [Architecture; Vault and state model](architecture.md) for the full collection list. ## Mnemonic handling Two mnemonic formats are supported on import: -- **BIP39**: 12, 15, 18, 21, or 24 words, validated against the BIP39 wordlist by `@scure/bip39`. Generation defaults to 12 words (128-bit entropy); a caller may request 24 words (256-bit) by passing a higher `strengthBits` value. Optional 25th-word passphrase is offered on the create flow and on import. +- **BIP39**: 12, 15, 18, 21, or 24 words, validated against the BIP39 wordlist by `@scure/bip39`. Generation defaults to 12 words (128-bit entropy); a caller may request 24 words (256-bit) by passing a higher `strengthBits` value. Optional 25th-word passphrase is offered on the create flow and on import, captured once: the wallet stores it encrypted under the same vault key as the seed and decrypts it alongside the seed at unlock, feeding it into derivation. After this one-time capture it is never re-entered on that device, except to restore the wallet on another one. - **Counterwallet legacy**: 12 words from a non-standard wordlist. Implemented in-house at `core/src/crypto/counterwallet.js` + `counterwallet-wordlist.js` because the wordlist isn't published in any standardized package. -Both flows derive a BIP32 seed and store the encrypted seed in the vault. The plaintext mnemonic is shown to the user during create + view-private-key flows, both gated behind explicit confirmation, and is never persisted in plaintext. +Both flows derive a BIP32 seed and store the encrypted seed in the vault. The plaintext mnemonic is shown to the user during create + view-private-key flows, both gated behind explicit confirmation, and is never persisted in plaintext. The passphrase, where one is set, follows the same rule: shown once at capture, and stored and handled only in encrypted form afterward. + +**Passphrase capture for existing wallets**: a wallet created before passphrase storage shipped has no encrypted passphrase in the vault yet. On the first unlock after upgrading, the wallet asks for the passphrase once, explains that it will be stored, and verifies it by re-deriving the wallet's own addresses and checking them against the addresses already in the vault. Once verified, the passphrase is stored encrypted the same way a newly created wallet's is, and the wallet does not ask again. Until this capture completes, the wallet is listed but cannot sign. Migration: a Counterwallet-imported wallet can be migrated to BIP39 on demand via the `MigrateToBip39` route. The wallet generates a fresh 24-word BIP39 mnemonic, derives the same chain/account roots, and offers a sweep flow to move balances from the legacy derivation to the new one. The migration is opt-in and reversible (the legacy mnemonic continues to control the legacy addresses). @@ -176,13 +178,15 @@ A dedicated `MultisigSigner` class is planned (§17.5) but not yet implemented. The wallet ships three backup paths: - **View private key**: per-address WIF export; gated by a "Before you continue" warning in an unlocked session (no password re-entry when already unlocked); a locked wallet must be unlocked first; surfaced in the `ViewPrivateKey` route -- **Backup file**: full vault export as an encrypted blob; `core/src/crypto/backup.js` re-wraps the vault with a backup-specific KDF +- **Backup file**: full vault export as an encrypted blob, including the encrypted passphrase where one is set; `core/src/crypto/backup.js` re-wraps the vault with a backup-specific KDF, re-keying both the seed and the passphrase under the restoring device's password - **Mnemonic + passphrase**: the canonical recovery path; recreating the wallet on any compatible client recovers identical addresses -The `dryRunRestore` flow (`core/src/flows/dryRunRestore.js`) lets a user verify they have the right mnemonic + passphrase combination without committing to a fresh wallet; the wallet derives the first N addresses and shows them alongside any on-chain history. If the addresses look right, the user confirms; otherwise they go back to retry the mnemonic. +The `dryRunRestore` flow (`core/src/flows/dryRunRestore.js`) lets a user verify they have the right mnemonic + passphrase combination without committing to a fresh wallet; the wallet derives the first N addresses and shows them alongside any on-chain history. If the addresses look right, the user confirms; otherwise they go back to retry the mnemonic. A recovery phrase carries no record of whether a passphrase was originally used, so an import that omits it does not fail: it silently derives a different, valid-looking, empty wallet. This preview is the only check against that. `importSingleWif` and `importWif` cover the case where a user has only a single private key (e.g., recovered from a paper wallet or another wallet) and wants the XChain wallet to manage it. These flows create a single-address, no-mnemonic wallet that supports all wallet operations except HD-derived receive-address generation. +**What the stored passphrase protects**: because the passphrase is encrypted under the same key as the seed, it protects a recovery phrase that leaks on its own (written down, photographed, or typed into the wrong site): the phrase alone does not reach the funds. It does not protect against someone who has both the device and the wallet's unlock password; that person unlocks the same wallet the owner does. It is not a decoy or plausible-deniability mechanism: there is no hidden or fake wallet behind it. + ## Label sync `core/src/crypto/labelSync.js` provides an opt-in, end-to-end-encrypted address-label sync across the user's own devices. The user's mnemonic is the only key material; labels are encrypted with a derived sub-key and stored as a regular `MESSAGE` (ECIES-to-self) action on the user's primary chain. Other shells running the same mnemonic decrypt and merge the labels into their local vault. The same wallet on multiple devices stays consistent; nobody else sees plaintext labels. diff --git a/components/wallet/release/qa-checklist.md b/components/wallet/release/qa-checklist.md index 6e1f727..ea9f244 100644 --- a/components/wallet/release/qa-checklist.md +++ b/components/wallet/release/qa-checklist.md @@ -38,7 +38,8 @@ Run on a clean profile (extension: fresh install / cleared storage; web: incogni - ⬜ Once accepted, the license gate does not reappear on subsequent launches. - ⬜ Create wallet, 12-word selection: recovery phrase displayed, copy works, verify-quiz mismatch surfaces the offending word. - ⬜ Create wallet, 24-word selection: same as above; verify-quiz scales position count. -- ⬜ BIP39 passphrase advanced toggle: matched-pair input, permanent-loss warning visible, threaded into vault. +- ⬜ BIP39 passphrase advanced toggle: matched-pair input, permanent-loss warning visible, captured once and stored encrypted in the vault alongside the mnemonic. +- ⬜ Import without the original passphrase: no error, the wallet silently opens a different, empty wallet; the pre-derivation preview is the only signal something is off. - ⬜ Import recovery phrase: typed input, drag-drop `.txt`, scan QR (where a camera is available). - ⬜ Import encrypted backup: file picker and paste both work; backup password unlocks the file. - ⬜ Try-before-commit demo mode: entry button works; demo banner mounts; "Exit demo & wipe" clears the wallet. @@ -109,6 +110,7 @@ Run on a clean profile (extension: fresh install / cleared storage; web: incogni - ⬜ Caps-lock warning appears when caps is active in the password field. - ⬜ Privacy blur engages on window blur (extension and desktop). - ⬜ Biometric unlock works on supported devices (WebAuthn PRF). +- ⬜ Legacy wallet passphrase capture: unlock a wallet created before passphrase storage shipped. It prompts for the passphrase once, states it will be stored, verifies it against the wallet's own addresses, then stores it and never prompts again. A wrong passphrase is rejected and the wallet stays unable to sign; until capture succeeds the wallet is listed but cannot sign. - ⬜ Panic mode arms: sign attempts reject and a 24-hour countdown is visible in Settings. - ⬜ Duress passphrase silently arms panic mode and shows a decoy wallet. @@ -117,8 +119,9 @@ Run on a clean profile (extension: fresh install / cleared storage; web: incogni ## Backup and recovery - ⬜ Encrypted backup export: file downloads with the wallet's backup extension; size is greater than zero. -- ⬜ Reveal seed phrase: password gate, tap-to-reveal, words match what was created. -- ⬜ Dry-run restore: paste mnemonic, preview accounts/addresses without writing. +- ⬜ Restore a backup file on a different device: the restoring device's password re-keys both the mnemonic and, where one was set, the passphrase; the restored wallet signs identically to the original. +- ⬜ Reveal recovery phrase: password gate, tap-to-reveal, words match what was created; if a passphrase was set it is shown alongside the phrase. +- ⬜ Dry-run restore: paste mnemonic and passphrase (where one was used), preview accounts/addresses without writing; confirm that omitting the passphrase does not error, it previews a different, empty wallet. - ⬜ Publish labels now (software wallets only). - ⬜ Backup-reminder card surfaces on Home for an unverified wallet; "Back up now" routes to the right place. diff --git a/components/wallet/ux.md b/components/wallet/ux.md index 24ff008..1ff916f 100644 --- a/components/wallet/ux.md +++ b/components/wallet/ux.md @@ -96,7 +96,7 @@ The first-run experience is one of: - **Restore from backup file**: re-wrap an exported vault under a fresh device password. - **Counterwallet migration**: accept the legacy 12-word format, then optionally migrate to BIP39 from Settings later. -Optional 25th-word passphrase is offered on both Create and Import. The passphrase materially changes derived addresses; the wallet shows a pre-derivation preview to let the user confirm they've entered the correct passphrase. +Optional 25th-word passphrase is offered on both Create and Import, captured once. The wallet stores it encrypted alongside the mnemonic, under the wallet's own password-derived key, exactly the way the recovery phrase itself is stored; after this one-time capture, the wallet password is the only secret the user ever types on that device. The passphrase materially changes derived addresses, so on Import the wallet shows a pre-derivation preview to let the user confirm they've entered the correct passphrase. Because a recovery phrase carries no record of whether a passphrase was originally used, importing without it does not fail, it silently opens a different, empty wallet; the pre-derivation preview is the only check against this. Restoring the wallet on any other device still requires both the recovery phrase and this passphrase: Reveal Recovery Phrase shows both, and an exported backup file carries both and re-keys them under the restoring device's password. ## Lock / unlock / auto-lock @@ -105,6 +105,7 @@ Optional 25th-word passphrase is offered on both Create and Import. The passphra - **Foreground auto-lock**: configurable timeout in Settings. Default: 5 minutes idle. Lock fires on tab/window blur for the popup; on idle-detection for the desktop and full-screen views. - **Browser-close lock**: extension session-key namespace clears automatically. Web shell drops in-memory keys when the tab unloads. - **OS keychain auto-unlock**: desktop only, opt-in. Stores the session master key in the OS keychain (Keychain on macOS, Credential Manager on Windows, Secret Service on Linux). Disabled by default; enabled via Settings → Security. +- **Legacy passphrase capture**: a wallet created before passphrase storage shipped has no stored passphrase yet. On the first unlock after upgrading, the wallet asks for it once, explains that it will be stored, and checks it against the wallet's own addresses before saving it encrypted; it never asks again after that. Until this one-time step completes, the wallet is listed but cannot sign. ## Home From 77270d675ec6eed24dc763198948d9b058bee44c Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 4 Sep 2026 08:11:11 -0700 Subject: [PATCH 35/52] docs: re-sync protocol, licensing and corpus claims against what ships One wave of the review round on the xchain-platform board. Every change was re-derived from the code rather than applied from the finding recommended option, and each carries a control that reproduces the original failure. Review findings: 6393 6394 6397 6425 6426 6442 6447 6458 6459 6460 6461 6500 6502 6532 6558 6559 6560 6561 c234140a0bd9 --- CONTRIBUTING.md | 28 +++- MAINTAINERS.md | 4 +- NOTICE.md | 8 +- README.md | 4 +- SECURITY.md | 2 +- architecture/component-map.md | 4 +- architecture/platform-map-app.html | 2 +- architecture/platform-map.json | 4 +- bin/generate-flag-days.js | 60 ++++++- components/README.md | 2 +- components/hub/configuration.md | 2 + concepts/gas.md | 7 + developer-guide/adding-a-blockchain.md | 2 +- developer-guide/regtest-development.md | 10 +- developer-guide/solidity-to-xchain.md | 12 +- protocol/action-manifest.json | 2 +- protocol/action-manifest.md | 2 +- protocol/constants.js | 30 ++-- protocol/controller-bound-tokens.md | 106 +++++++++--- protocol/flag-days.md | 2 + protocol/nft-standard.md | 13 +- protocol/protocol-activation.md | 20 ++- test/layout-doc-currency.test.js | 213 +++++++++++++++++++++++++ 23 files changed, 455 insertions(+), 84 deletions(-) create mode 100644 test/layout-doc-currency.test.js diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c36ea33..be39d05 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,9 +30,9 @@ xchain-documentation/ ├── protocol/ 37 ACTION definitions, Token Information Standard, schemas ├── operations/ deployment, Docker, monitoring, upgrades, troubleshooting ├── legal/ licensing, commercial license, trademark, contributor agreement -├── BLOCKCHAINS.md supported chains, adding new blockchains -├── OVERVIEW.md platform overview -├── WHITEPAPER.md technical whitepaper +├── blockchains.md supported chains, adding new blockchains +├── overview.md platform overview +├── whitepaper.md technical whitepaper ├── CHANGELOG.md authoritative version history └── README.md entry point and section map ``` @@ -53,13 +53,24 @@ git clone https://github.com/XChain-Platform/xchain-documentation.git cd xchain-documentation ``` -There is no `npm install` or build step needed for editorial contributions. +There is no build step. There is a check suite, and it gates your PR, so install +once before you run it: + +```bash +npm install +``` + +That pulls a single devDependency (`mathjs`), used by the arithmetic gates. --- ## Making changes -This repository contains Markdown files only. There is no source code to compile and no test suite to run. +This repository is almost entirely Markdown, with `protocol/constants.js` and a `lib/` helper alongside it. There is nothing to compile, but there is a suite of gates under `test/` that check the prose against the platform's real behavior: internal links and heading anchors, documented environment variables, ACTION counts, protocol constants, and the arithmetic of the genesis allocation table. They decide whether a PR can merge, so run them before you open one: + +```bash +npm test +``` ### Types of contribution @@ -137,9 +148,10 @@ Match the existing log style: a concise subject line, then a short body explaini Before opening a PR: -1. Confirm `git status` is clean apart from intended changes (no editor backup files, no `.env`). -2. Update `CHANGELOG.md` with a terse entry for your change. -3. Open the PR with a clear title and a description of what changed and why. For spec changes, link to the issue where maintainer sign-off was obtained. +1. Run `npm test` and confirm it is green. These are the same gates that run on the PR. +2. Confirm `git status` is clean apart from intended changes (no editor backup files, no `.env`). +3. Update `CHANGELOG.md` with a terse entry for your change. +4. Open the PR with a clear title and a description of what changed and why. For spec changes, link to the issue where maintainer sign-off was obtained. For non-security bugs or editorial issues, open an issue at . For security bugs, see [`SECURITY.md`](./SECURITY.md). diff --git a/MAINTAINERS.md b/MAINTAINERS.md index dcf3a8e..a83f094 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -27,7 +27,7 @@ Until additional maintainers join, the lead owns every area below. The table is | Area | What it covers | |---|---| | Protocol spec | `protocol/` ACTION definitions, encoding formats, DB naming conventions, Token Information Standard, error codes, URI scheme, and JSON schemas | -| Component docs | `components/` documentation for each of the 15 documented xchain-* components | +| Component docs | `components/` documentation for each of the 14 documented xchain-* components | | Developer guide | `developer-guide/` tutorials, integration examples, and query references | | Getting started | `getting-started/` platform intro, quickstarts, and glossary | | Concepts | `concepts/` metalayer model, tokens, ACTIONs, encoding, cross-chain, gas, and security overviews | @@ -35,7 +35,7 @@ Until additional maintainers join, the lead owns every area below. The table is | User guide | `user-guide/` capabilities, use cases, and FAQ for non-technical readers | | AI and agents | `ai-agents/` guides for building AI agents on the platform | | Operations | `operations/` deployment, monitoring, upgrades, and troubleshooting | -| Overview and whitepaper | `OVERVIEW.md`, `WHITEPAPER.md`, `BLOCKCHAINS.md` | +| Overview and whitepaper | `overview.md`, `whitepaper.md`, `blockchains.md` | | Legal and project files | `legal/`, `LICENSE.md`, `NOTICE.md`, `CHANGELOG.md`, `SECURITY.md`, `CODE_OF_CONDUCT.md`, `CONTRIBUTING.md`, `MAINTAINERS.md` | --- diff --git a/NOTICE.md b/NOTICE.md index c82ef4d..e64b054 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -17,6 +17,12 @@ XChain Platform is dual-licensed: - **A commercial license** from Dankest, LLC, for use without the AGPL's source-disclosure requirements. +One first-party component is carved out of that dual license: the +`xchain-contracts` template library is licensed under the **MIT License**, +because it exists to be copied into user contracts. Its terms are in that +repository's own `LICENSE` file and are not affected by the AGPL's +source-disclosure requirements. + For commercial licensing, contact **legal@dankest.llc**. ## Attribution @@ -30,7 +36,7 @@ attribution, per the AGPL and the project's trademark policy: "XChain" and the XChain logo are trademarks of Dankest, LLC. The software license does not grant any rights to these marks. See the Trademark Policy: - + ## Third-Party Components diff --git a/README.md b/README.md index bb06d46..60cd1d5 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ A blockchain-agnostic token protocol currently running on Bitcoin, Litecoin, and | [**xchain-vm**](https://github.com/XChain-Platform/xchain-vm/) | Sandboxed JavaScript virtual machine for on-chain smart contracts with gas metering, deterministic execution, and reorg-safe state | | [**xchain-contracts**](https://github.com/XChain-Platform/xchain-contracts/) | MIT-licensed template library: audited example smart contracts, reusable patterns, a no-code policy generator, and a CLI, deployed via the ordinary `DEPLOY` action | | [**xchain-sdk**](https://github.com/XChain-Platform/xchain-sdk/) | Developer SDK: builders for all 31 developer-invocable actions, 100+ explorer query methods, smart contract support, live WebSocket events, batch builder, PSBT generation | -| [**xchain-wallet**](https://github.com/XChain-Platform/xchain-wallet/) | Reference self-custodial multi-chain wallet: browser, Chrome extension, and Electron desktop from a single codebase; software + Trezor + Ledger + remote + multisig signers; full DEX, messaging, contracts, staking, and `window.xchain` dApp bridge | +| [**xchain-wallet**](https://github.com/XChain-Platform/xchain-wallet/) | Reference self-custodial multi-chain wallet: browser, Chrome extension, Electron desktop, and Capacitor mobile (Android shipped, iOS later) from a single codebase; software + Trezor + Ledger + remote + multisig signers; full DEX, messaging, contracts, staking, and `window.xchain` dApp bridge | | [**xchain-regtest-miner**](https://github.com/XChain-Platform/xchain-regtest-miner/) | Auto-mines blocks for regtest development environments | | [**xchain-e2e-test**](https://github.com/XChain-Platform/xchain-e2e-test/) | Full-stack Mocha test suite running against a live regtest deployment | ## Legal @@ -56,7 +56,7 @@ Any redistribution or modification must include the attribution notice specified **Copyright © 2025-2026 Dankest, LLC** -**Based on XChain Platform by Dankest, LLC – https://dankest.llc** +**Based on XChain Platform by Dankest, LLC - https://dankest.llc** Licensed under the **GNU Affero General Public License v3.0** (AGPL-3.0-or-later) with a commercial license available for proprietary use. diff --git a/SECURITY.md b/SECURITY.md index c934407..e506ff3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -82,7 +82,7 @@ If you are unsure, send the report anyway and we will tell you whether it falls ## Versions covered -We ship security fixes against the latest revision on `master`. The current version is recorded in `CHANGELOG.md` and the badge in `README.md`. +We ship security fixes against the latest revision on `master`. The current version is recorded in `CHANGELOG.md` and in `package.json`. --- diff --git a/architecture/component-map.md b/architecture/component-map.md index c07dd18..2c78a6a 100644 --- a/architecture/component-map.md +++ b/architecture/component-map.md @@ -326,7 +326,7 @@ See [`../components/vm/`](../components/vm/) for full documentation. | | | |---|---| -| **Purpose** | Self-custodial multi-chain reference wallet; browser SPA, Chrome MV3 extension, and Electron desktop app | +| **Purpose** | Self-custodial multi-chain reference wallet; browser SPA, Chrome MV3 extension, Electron desktop app, and Capacitor mobile app (Android shipped, iOS later) | | **Inputs** | User interaction; xchain-sdk for action construction; xchain-explorer for balance and history queries; xchain-hub for config and fee data | | **Outputs** | Signed transactions broadcast to coin nodes via the encoder; read-only views of balances, tokens, actions, and markets | | **Storage** | Client-side only (browser localStorage / extension storage / Electron local store); no server-side state | @@ -336,7 +336,7 @@ Key technical details: - Built on xchain-sdk; all action construction goes through the SDK's 31 developer-invocable ACTION methods. - Supports every chain the platform runs on, today Bitcoin, Litecoin, and Dogecoin (mainnet, testnet, regtest), from the same codebase. -- Deployed as a web SPA (served from a static docroot), a Chrome MV3 extension (packaged from the same source), and an Electron desktop application. +- Deployed as a web SPA (served from a static docroot), a Chrome MV3 extension (packaged from the same source), an Electron desktop application, and a Capacitor mobile app wrapping the same web build (Android shipped, iOS later). - Private keys never leave the client; signing happens locally before broadcast. - Targets non-technical end users; UI language is intentionally plain (e.g., "About" not "Token Spec"). diff --git a/architecture/platform-map-app.html b/architecture/platform-map-app.html index 9e3e262..5529690 100644 --- a/architecture/platform-map-app.html +++ b/architecture/platform-map-app.html @@ -252,7 +252,7 @@

Flows

{ "id": "replicas", "label": "Validator / Replica Nodes", "type": "external", "group": "serve", "description": "Downstream validator stacks fed by xchain-sync. Run their own indexer/explorer against replicated state and re-verify state commitments." }, { "id": "miner", "label": "xchain-regtest-miner", "type": "service", "group": "access", "tech": "Node.js", "description": "Regtest-only auto-miner: dual-timer mempool mining, generate_blocks, send_funds, fill_mempool stress tool, set_mock_time for deterministic expiries. Refused on mainnet." }, { "id": "sdk", "label": "xchain-sdk", "type": "library", "group": "clients", "tech": "Node.js (npm: library + JSON-RPC server)", "description": "Developer SDK generating all 31 user ACTION types with ticker compaction and a MuSig2 co-signer toolkit. Discovers endpoints via hub getallconfigs with an https downgrade guard; defaults to public .xchain.io hosts off-regtest." }, - { "id": "wallet", "label": "xchain-wallet", "type": "service", "group": "clients", "tech": "JS monorepo: web SPA, MV3 extension, Electron", "description": "Self-custodial multi-chain wallet built on the SDK. Packages: core (shell-free, enforced by CI), web, extension, desktop, plus Trezor/Ledger signer packages. Signs PSBTs locally; keys never leave the device." }, + { "id": "wallet", "label": "xchain-wallet", "type": "service", "group": "clients", "tech": "JS monorepo: web SPA, MV3 extension, Electron, Capacitor mobile", "description": "Self-custodial multi-chain wallet built on the SDK. Packages: core (shell-free, enforced by CI), web, extension, desktop, mobile (Capacitor; Android shipped, iOS later), plus Trezor/Ledger signer packages. Signs PSBTs locally; keys never leave the device." }, { "id": "node", "label": "xchain-node", "type": "service", "group": "clients", "tech": "Node.js, Docker, Commander CLI", "description": "Installer/operator CLI: provisions coin nodes (SHA-256 pinned binaries) and every platform service as Docker containers. Ed25519-signed bootstraps, go-live and skew guards, telemetry (opt-out), interactive TUI." }, { "id": "e2e", "label": "xchain-e2e-test", "type": "service", "group": "clients", "tech": "Mocha", "description": "Full-stack end-to-end suite: broadcasts real transactions on regtest through service connectors (Blockchain, Encoder, Indexer, Hub, UtxoTracker, RegtestMiner), 30+ waitFor* DB pollers, BTC/LTC/DOGE parity corpus, perf gates." }, { "id": "contracts", "label": "xchain-contracts", "type": "library", "group": "clients", "tech": "Node.js, mocha (tooling only)", "description": "Smart-contract template library (amm, escrow, vesting, crowdsale, treasury, priceBet, stableVault, urlOracle, cardDispenser) plus a policy-driven controller-guard generator. Deployed as base64 DEPLOY actions; tested against xchain-vm." }, diff --git a/architecture/platform-map.json b/architecture/platform-map.json index bb6aff7..d1d6fd1 100644 --- a/architecture/platform-map.json +++ b/architecture/platform-map.json @@ -152,8 +152,8 @@ "label": "xchain-wallet", "type": "service", "group": "clients", - "tech": "JS monorepo: web SPA, MV3 extension, Electron", - "description": "Self-custodial multi-chain wallet built on the SDK. Packages: core (shell-free, enforced by CI), web, extension, desktop, plus Trezor/Ledger signer packages. Signs PSBTs locally; keys never leave the device." + "tech": "JS monorepo: web SPA, MV3 extension, Electron, Capacitor mobile", + "description": "Self-custodial multi-chain wallet built on the SDK. Packages: core (shell-free, enforced by CI), web, extension, desktop, mobile (Capacitor; Android shipped, iOS later), plus Trezor/Ledger signer packages. Signs PSBTs locally; keys never leave the device." }, { "id": "node", diff --git a/bin/generate-flag-days.js b/bin/generate-flag-days.js index fb11455..ace9c88 100644 --- a/bin/generate-flag-days.js +++ b/bin/generate-flag-days.js @@ -332,7 +332,8 @@ function collectSiblingGates(indexerSrc, add) { * * WHY THE CALL ARM IS VALUE-GATED. Most of the registry is not on this page at * all: roughly forty gates carry mainnet_time 0, several carry block heights, - * and one parks the 9999999999 sentinel. Failing on unreadable SYNTAX alone + * and seven park the 9999999999 sentinel (see `collectMainnetUnarmed`, which + * names them on the page rather than dropping them). Failing on unreadable SYNTAX alone * therefore fires hardest on declarations that could never have contributed a * row, and the evidence that was meant to exclude that (regenerate today's * registry, see nothing throw) cannot see it: every call in today's registry is @@ -498,6 +499,38 @@ function collectTestnetUnarmed(indexerSrc = INDEXER_SRC) { return [...found.values()].sort((a, b) => a.gate.localeCompare(b.gate)); } +/** + * MAINNET slots parked on an UNARMED sentinel (>= SENTINEL_FLOOR), as `{ gate, time }` + * sorted by name. + * + * `collectGates` drops these deliberately: publishing 9999999999 as a flag day would put + * a fake commitment on a page implementers plan fleet upgrades from. Dropping them with + * no trace is the other failure, and it is the one that shipped: a gate absent from the + * table reads as a gate that does not exist, so `sweep.md` could send a reader here "for + * where the gate stands on each network" and the page would not say. Naming them without + * an instant keeps both properties. + * + * Scoped to `protocol_changes.js`, exactly like the testnet twin above. A sibling + * `*_activation.js` module can also park a mainnet sentinel, and this scan does not reach + * it; the note it feeds says so rather than claiming a completeness it does not have. + */ +function collectMainnetUnarmed(indexerSrc = INDEXER_SRC) { + const scannable = withoutComments( + fs.readFileSync(path.join(indexerSrc, 'protocol_changes.js'), 'utf8'), + ); + const found = new Map(); + const add = (gate, time) => { + if (!Number.isFinite(time) || time < SENTINEL_FLOOR) return; + if (!found.has(gate)) found.set(gate, { gate, time }); + }; + let match; + const callRe = /addChange\(\s*'([A-Z0-9_]+)'\s*,\s*'[0-9.]+'\s*,\s*(\d+)/g; + while ((match = callRe.exec(scannable)) !== null) add(match[1], Number(match[2])); + const constRe = /const\s+([A-Z][A-Z0-9_]*)_MAINNET_TIME\s*=\s*(\d+)\s*;/g; + while ((match = constRe.exec(scannable)) !== null) add(match[1], Number(match[2])); + return [...found.values()].sort((a, b) => a.gate.localeCompare(b.gate)); +} + /** * The coordinated contract-era flag day: the timestamp the most gates ride. * Derived rather than named, because naming it here would reintroduce exactly @@ -520,7 +553,7 @@ function coordinatedFlagDay(gates) { return { time: ranked[0][0], count: ranked[0][1] }; } -function render(gates, testnetArms = [], testnetUnarmed = []) { +function render(gates, testnetArms = [], testnetUnarmed = [], mainnetUnarmed = []) { const anchor = coordinatedFlagDay(gates); const others = gates.filter((g) => g.time !== anchor.time); @@ -571,6 +604,22 @@ function render(gates, testnetArms = [], testnetUnarmed = []) { + 'there without re-deciding history that outside nodes have already committed. ' + 'Each names its reason in its registration comment in `protocol_changes.js`.'; + // The symmetric mainnet note. Without it a sentinel-parked gate leaves no trace on the + // page at all, so the table below reads as the whole registry and the testnet sentence + // above reads as if mainnet were armed. Names, never instants: the sentinel is not a + // date anybody scheduled. + const mainnetUnarmedNote = mainnetUnarmed.length === 0 + ? '' + : `\n\n**${mainnetUnarmed.length === 1 ? 'One gate is UNARMED on mainnet' : `${mainnetUnarmed.length} gates are UNARMED on mainnet`}** ` + + `(${mainnetUnarmed.map((g) => `\`${g.gate}\``).join(', ')}): each parks the sentinel ` + + 'rather than an instant, so mainnet has **never** run the post-activation behavior ' + + 'and will not until an operator names a date. They carry no row in the table below, ' + + 'because publishing the sentinel as a flag day would put a commitment on this page ' + + 'that nobody made. Each names its reason in its registration comment in ' + + '`protocol_changes.js`. This note covers the registry only; a sibling ' + + '`*_activation.js` module can park a mainnet sentinel too, and those are not ' + + 'enumerated here.'; + return ` @@ -601,7 +650,7 @@ simultaneously on Bitcoin, Litecoin, and Dogecoin. | **UTC instant** | ${utcInstant(anchor.time)} | | **Gates riding it** | ${anchor.count} | -${outliers} +${outliers}${mainnetUnarmedNote} **Testnet and regtest are genesis-active** for the time-keyed gates: they carry threshold \`0\`, so a testnet or regtest stack has always run the @@ -621,7 +670,8 @@ here; they are inventoried on } function generate(indexerSrc = INDEXER_SRC) { - return render(collectGates(indexerSrc), collectTestnetArms(indexerSrc), collectTestnetUnarmed(indexerSrc)); + return render(collectGates(indexerSrc), collectTestnetArms(indexerSrc), + collectTestnetUnarmed(indexerSrc), collectMainnetUnarmed(indexerSrc)); } if (require.main === module) { @@ -641,6 +691,6 @@ if (require.main === module) { } module.exports = { - collectGates, collectTestnetArms, collectTestnetUnarmed, coordinatedFlagDay, render, generate, utcInstant, utcDate, + collectGates, collectTestnetArms, collectTestnetUnarmed, collectMainnetUnarmed, coordinatedFlagDay, render, generate, utcInstant, utcDate, DOC_ROOT, INDEXER_SRC, REGISTRY, OUTPUT, TIMESTAMP_FLOOR, SENTINEL_FLOOR, }; diff --git a/components/README.md b/components/README.md index 385334d..147dd9c 100644 --- a/components/README.md +++ b/components/README.md @@ -17,7 +17,7 @@ This section contains documentation for each of the 14 XChain Platform component | [utxo-tracker](./utxo-tracker/) | Indexes UTXOs from coin nodes and serves address and balance queries | | [sdk](./sdk/) | Developer SDK for constructing and submitting XChain actions | | [contracts](./contracts/) | MIT-licensed smart contract template library, patterns, linter CLI, and no-code policy generator | -| [wallet](./wallet/) | Reference self-custodial multi-chain wallet: browser, Chrome extension, and Electron desktop | +| [wallet](./wallet/) | Reference self-custodial multi-chain wallet: browser, Chrome extension, Electron desktop, and Capacitor mobile (Android shipped, iOS later) | | [node](./node/) | CLI tool for installing and managing all platform services as Docker containers | | [e2e-test](./e2e-test/) | End-to-end Mocha test suite that exercises the full platform stack | | [regtest-miner](./regtest-miner/) | Auto-mines mempool transactions for regtest development environments | diff --git a/components/hub/configuration.md b/components/hub/configuration.md index 9f814f2..94c33d2 100644 --- a/components/hub/configuration.md +++ b/components/hub/configuration.md @@ -203,6 +203,8 @@ mounts it into the hub container automatically. See OPERATIONS.md → Validator |---|---|---|---| | `PBFT_TIMEOUT` | No | `30000` | Consensus round timeout in milliseconds. Triggers view change on expiry. | | `MIN_VALIDATORS` | No | `1` | Minimum validators required before a consensus round may run. | +| `PBFT_SNAPSHOT_TOLERANCE_BLOCKS` | No | `144` | How far a config-PBFT `PRE_PREPARE`'s leader-stamped `btcBlockHeight` may deviate from this hub's own BTC tip before a federated follower declines to PREPARE. The stamped height selects the validator set, the leader and the stake-weighted-quorum outcome, so an unbounded one lets the proposer choose all three; a follower that cannot resolve a tip of its own declines. Single-node hubs are unaffected. | +| `ORACLE_SNAPSHOT_TOLERANCE_BLOCKS` | No | `144` | The same bound on the price-round `PROPOSE` path: how far a leader-supplied `btcBlockHeight` may deviate from this hub's own BTC tip before a federated follower drops the round. | | `HUB_CONSENSUS_INPUT_ALERT_AFTER` | No | _(built-in default)_ | Consecutive consensus-input failures before the alarm fires. A non-integer or non-positive value logs an error and falls back to the default rather than disabling the alarm, so a typo cannot silently restore fail-closed-and-silent behaviour. | | `HUB_SNAPSHOT_REORG_BUFFER` | No | `6` | Blocks of reorg buffer applied when building a capability snapshot. **Consensus-critical: it must match across the federation.** A malformed value logs an error and falls back to `6` rather than forking the federation on a typo. | | `XCHAIN_HUB_SKIP_REORG_BUFFER_ASSERT` | No | _(unset)_ | Set to `1` to bypass the assertion that `HUB_SNAPSHOT_REORG_BUFFER` equals the canonical federation value. Only for a venue where **every** hub runs the same override: each hub subtracts this buffer before resolving a snapshot, so hubs disagreeing on it lock different blocks for the same round and produce divergent validator sets and quorum N. On `mainnet` and `testnet` a mismatch otherwise refuses to start (`REORG_BUFFER_MISMATCH`); standalone and regtest warn instead. The bypass logs a warning every time it is taken. | diff --git a/concepts/gas.md b/concepts/gas.md index ebac94b..108b903 100644 --- a/concepts/gas.md +++ b/concepts/gas.md @@ -44,6 +44,12 @@ The fee destination address is the per-network `ADDRESS.FEE_DESTINATION` value f | **Ownership-escrow premium** | 50,000 | 0.5 | ORDER/SWAP/DISPENSER **create** that gives ownership; flat, charged on top of the expiration fee and not covered by the free period | | **AIRDROP** | 100/recipient | 0.001/recipient | 1,000 recipients = 1 XCHAIN | | **DIVIDEND** | 100/recipient | 0.001/recipient | Same as AIRDROP | +| **SWEEP base** (`SWEEP_BASE`) | 5,000 | 0.05 | Flat, charged once per SWEEP. Sized so the smallest sweep still buys a native-coin fee output above the chain's dust threshold. Priced this way from the `UNIFIED_FEES_SWEEP_CALLBACK` gate; see [Flag-Day Values](../protocol/flag-days.md) for where that gate stands on each network, and [SWEEP](../protocol/actions/sweep.md) for what the earlier pricing was | +| **SWEEP per item** (`SWEEP_PER_ITEM`) | 100/item | 0.001/item | One item is one swept balance, one closed order/swap/dispenser escrow, or one transferred ownership. Same gate as the base | +| **CALLBACK base** (`CALLBACK_BASE`) | 5,000 | 0.05 | Flat, charged once per CALLBACK, same dust-threshold reasoning and same gate as `SWEEP_BASE` | +| **CALLBACK per recipient** (`CALLBACK_PER_RECIPIENT`) | 100/recipient | 0.001/recipient | AIRDROP/DIVIDEND per-recipient parity. Same gate as the base | +| **BET feed creation** (`BET_FEED_PER_DAY`) | 550/day | ~0.0055/day | Duration-metered on the feed's full life, with the same 90-day free period as the expiration fee, but under its own schedule key so the two families can be repriced independently. Genesis-active on every chain and network | +| **BET place** (`BET_PER_CREDIT`) | 100/bet | 0.001/bet | Pre-funds the bet's single terminal credit at place time, which is what makes the system-injected expiry pass free. Resolve and cancel are free by design | ### VM Fees @@ -62,6 +68,7 @@ The fee destination address is the per-network `ADDRESS.FEE_DESTINATION` value f | **Cross-chain call request** | 2,000 | 0.02 | Additional fee on top of action emission for `emit.crossExecute()`; the federation relay work. The call also pre-pays its remote `gasLimit` plus the cross-chain callback ceiling, with no refund of unused remote gas | | **Cross-chain callback ceiling** | 20,000 | 0.2 | Fixed gas ceiling the result/expiry callback runs against on the source chain, pre-paid at `emit.crossExecute()` time | | **Computation** | 1/instruction | None | Metered by isolated-vm; one charge per control-flow point | +| **Controller-guard ceiling** (`VM_GUARD_GAS_CEILING`) | 200,000 | 2.0 | Fixed ceiling the controller guard runs against, reserved and billed against `SOURCE` from the `CONTROLLER_GUARD` flag day. Consensus-critical: it is committed into the ledger and contract hashes, so an indexer whose schedule omits or mistypes it throws at the guard-fee site rather than billing a phantom default | > **Indexed `for` loops are charged twice per iteration.** The gas meter injects a control-flow charge at the top of the loop body and a second charge into the update expression, so a `for` loop of N iterations costs `2 × N` computation gas. `while`, `do-while`, `for-in`, and `for-of` loops have no update expression and cost 1 per iteration. Account for the doubled cost when budgeting a gas ceiling for contracts that use indexed `for` loops. diff --git a/developer-guide/adding-a-blockchain.md b/developer-guide/adding-a-blockchain.md index 7c5ddde..882fbd5 100644 --- a/developer-guide/adding-a-blockchain.md +++ b/developer-guide/adding-a-blockchain.md @@ -165,7 +165,7 @@ Adjust the coin-level consensus params if the chain's economics differ. Chain-specific parsing quirks (for example Litecoin's HogEx flag, Dogecoin's AuxPoW header) are handled in the decoder/encoder, not in the coin file; see -`BLOCKCHAINS.md` and the decoder component docs if your chain needs +`blockchains.md` and the decoder component docs if your chain needs pre-processing before bitcoinjs-lib can parse its transactions. ### 2. Register it diff --git a/developer-guide/regtest-development.md b/developer-guide/regtest-development.md index a09ad8c..736b835 100644 --- a/developer-guide/regtest-development.md +++ b/developer-guide/regtest-development.md @@ -42,7 +42,7 @@ After `install`, all the following services are running locally: | Bitcoin node (regtest) | 18443 | Coin node | | xchain-decoder | 3002 | Polls node, writes to Decoder DB | | xchain-indexer | 3004 | Processes actions, writes to Indexer DB | -| xchain-explorer | 8080 | REST API + web UI | +| xchain-explorer | 18080 | REST API + web UI (host port `EXPLORER_PORT_HTTP`; 8080 is the container-internal port) | | xchain-encoder | 3003 | PSBT builder | | xchain-hub | 10000 | Config oracle | | xchain-utxo-tracker | 3001 | UTXO/balance queries | @@ -173,7 +173,7 @@ const sdk = new XChainSDK({ // Or hardcode each service const sdk = new XChainSDK({ encoderUrl: 'http://localhost:3003', - explorerUrl: 'http://localhost:8080', + explorerUrl: 'http://localhost:18080', }); ``` @@ -220,9 +220,9 @@ SELECT * FROM actions ORDER BY action_index DESC LIMIT 20; If the indexer has data but the explorer doesn't, the explorer may have a query bug. Hit the endpoint directly: ```bash -curl http://localhost:8080/BTC/api/token/MYTOKEN -curl http://localhost:8080/BTC/api/balances/YOUR_ADDRESS -curl http://localhost:8080/BTC/api/history/MYTOKEN/token +curl http://localhost:18080/BTC/api/token/MYTOKEN +curl http://localhost:18080/BTC/api/balances/YOUR_ADDRESS +curl http://localhost:18080/BTC/api/history/MYTOKEN/token ``` If the explorer answers `503 COIN_DATA_STALE` for every endpoint on a coin, the query is fine and the chain has simply gone quiet: see [Keeping an Idle Chain Available](#keeping-an-idle-chain-available). diff --git a/developer-guide/solidity-to-xchain.md b/developer-guide/solidity-to-xchain.md index 701425f..195845d 100644 --- a/developer-guide/solidity-to-xchain.md +++ b/developer-guide/solidity-to-xchain.md @@ -140,11 +140,13 @@ by the protocol on every transfer and cannot be bypassed by any marketplace. // guard contract: the indexer calls guard(...) before a guarded action settles module.exports = { guard: function (xchain) { - var actionType = xchain.getInputParam(0); // e.g. 'transfer' - var to = xchain.getInputParam(2); - // deny transfers to a blocked address - if (xchain.state.get('blocked:' + to) === '1') xchain.revert('recipient blocked'); - // (optional) return a royalty split via payoutLegs for 'trade' + var actionType = xchain.getInputParam(0); // the invocation point: 'SEND', 'SWEEP', ... + var to = xchain.getInputParam(2); // '' on AIRDROP/DIVIDEND and the trade creates + // deny transfers to a blocked address. `to` is populated only on SEND, SWEEP, + // SWEEP_OWNERSHIP and MINT, so branch on actionType: see the invocation-points table + // in the controller-bound-tokens spec. + if (to !== '' && xchain.state.get('blocked:' + to) === '1') xchain.revert('recipient blocked'); + // (optional) return a royalty split via payoutLegs from ORDER_CREATE / SWAP_CREATE } }; // bound with ISSUE v6: CONTROLLER = , ACTION_CLASS = 'transfer' (or 'all') diff --git a/protocol/action-manifest.json b/protocol/action-manifest.json index 93e02ab..ad21bd2 100644 --- a/protocol/action-manifest.json +++ b/protocol/action-manifest.json @@ -11,7 +11,7 @@ }, "categories": { "wire-user": "user-encodable on-chain action: decoded + indexed + SDK-encodable", - "validator": "validator-broadcast on-chain action: decoded + indexed but NOT user-encodable (ANCHOR/ATTEST/NODEPROOF/SLASH)", + "validator": "validator-broadcast on-chain action: decoded + indexed but NOT user-encodable (ANCHOR/ATTEST/NODEPROOF/ROLLCALL/SLASH)", "mirror-injected": "indexer-injected from the hub mirror, NOT chain-decoded (XCALL/XEXEC/CROSS_SETTLE)", "lifecycle": "system-generated sub-action, never a decoded wire tx (matches/expiries/dispense)", "explorer-legacy-render": "render-only in the explorer (legacy order/dispenser cancel+edit views); no decoder/indexer twin" diff --git a/protocol/action-manifest.md b/protocol/action-manifest.md index 527d898..e7820fc 100644 --- a/protocol/action-manifest.md +++ b/protocol/action-manifest.md @@ -57,7 +57,7 @@ user forces an SDK Format that can only build dead transactions, so re-read the handler before editing one. The per-repo sets legitimately differ by role: the SDK omits validator-only -actions (`ANCHOR`/`ATTEST`/`NODEPROOF`/`SLASH`); the indexer adds mirror-injected +actions (`ANCHOR`/`ATTEST`/`NODEPROOF`/`ROLLCALL`/`SLASH`); the indexer adds mirror-injected (`XCALL`/`XEXEC`/`CROSS_SETTLE`) and lifecycle (`*_MATCH`/`*_EXPIRE`/`DISPENSE`) handlers that are never decoded wire bytes; the explorer is the render superset (including legacy order/dispenser cancel+edit views). The manifest encodes these diff --git a/protocol/constants.js b/protocol/constants.js index 0dcc7cb..cf7cbbf 100644 --- a/protocol/constants.js +++ b/protocol/constants.js @@ -403,8 +403,9 @@ const STATE_COMMITMENT_ACTIVATION = { // which the quorum-signed checkpoint canonical (and the on-chain ANCHOR) COMMIT the additive // `state_root` + `block_merkle_root` (with their version bytes) that STATE_COMMITMENT_ACTIVATION made // the indexer compute in Phase 1. Post-flag-day the checkpoint canonical string gains -// `|STATE_ROOT|STATE_ROOT_VERSION|BLOCK_MERKLE_ROOT|BLOCK_MERKLE_VERSION` and a new ANCHOR v3 carries -// the roots on DOGE; pre-flag-day both keep their old shape and the roots are absent. Consensus-relevant +// `|STATE_ROOT|STATE_ROOT_VERSION|BLOCK_MERKLE_ROOT|BLOCK_MERKLE_VERSION` and each section of the ANCHOR +// checkpoint bundle (v0) carries the roots on DOGE; pre-flag-day both keep their old shape and the roots +// are absent. (The restarted wire set is v0/v1/v2, see ANCHOR_ACTIVATION below.) Consensus-relevant // for signature verification (the signed preimage changes), so it must deploy hub + ALL indexers + the // SDK/explorer verifiers atomically. // @@ -427,10 +428,12 @@ const CHECKPOINT_COMMITMENT_ACTIVATION = { // ANCHOR_REWARD_ACTIVATION (anchor-reward re-derivation): the flag-day at/above which the validator // anchor reward stops being TRUSTED from the hub's `pushvalidatorrewards` JSON-RPC and is instead // DERIVED by every indexer from the on-chain ANCHOR bytes. Post-flag-day the hub emits a publisher- -// bearing ANCHOR (v4 rootless / v5 root-bearing) carrying the elected publisher pubkey plus a 2f+1 -// `oracle_publish` attestation (XANCPUB) over the reward tuple; the indexer verifies that quorum and -// credits the publisher with ANCHOR_REWARD_AMOUNT (a frozen consensus constant, NEVER from the wire). -// Below the flag-day the old push path stands and v4/v5 anchors are rejected. Consensus-relevant (the +// bearing ANCHOR checkpoint bundle (v0 of the restarted wire set, whose sections carry the SPV roots) +// carrying the elected publisher pubkey plus a 2f+1 `oracle_publish` attestation (XANCPUB) over the +// `anchor_bundle` reward tuple; the indexer verifies that quorum and credits the publisher with +// ANCHOR_REWARD_AMOUNT (a frozen consensus constant, NEVER from the wire). Below the flag-day the old +// push path stands and the PUBLISHER tail an anchor carries earns no derived credit. +// Consensus-relevant (the // credited reward becomes a COLLECT-spendable per-block ledger row), so it must deploy hub + ALL // indexers atomically. Like CHECKPOINT_COMMITMENT_ACTIVATION / STAKE_WEIGHTED_QUORUM_ACTIVATION it gates // on the BTC-anchored `snapshot_block` carried by every ANCHOR canonical. Kept byte-identical to the @@ -449,12 +452,13 @@ const ANCHOR_REWARD_AMOUNT = '10.00000000'; // ARCHIVE_REWARD_ACTIVATION (archive-reward re-derivation): the flag-day at/above which the // anchor_archive reward stops riding the key-authenticated `pushvalidatorrewards` rail and is instead -// DERIVED by every indexer from the on-chain ANCHOR v6 bytes (the v1 archive anchor plus the same -// PUBLISHER + 2f+1 XANCPUB attestation tail as v4/v5, attested over an 'anchor_archive' canonical -// keyed on MATCH_BATCH_SEQ). This retires the last insider-with-key reward-forge surface the -// per-chain ANCHOR_REWARD flag-day left open. Below the flag-day the legacy v1 + push path stands -// and v6 anchors are rejected. Consensus-relevant, same deploy rules and snapshot_block gating as -// ANCHOR_REWARD_ACTIVATION; kept byte-identical to the local copies in +// DERIVED by every indexer from the on-chain ANCHOR archive-head bytes (v1 of the restarted wire set, +// carrying the same PUBLISHER + 2f+1 XANCPUB attestation tail the v0 bundle carries, attested over an +// 'anchor_archive' canonical keyed on MATCH_BATCH_SEQ). This retires the last insider-with-key +// reward-forge surface the per-chain ANCHOR_REWARD flag-day left open. Below the flag-day the push path +// stands and an archive head's PUBLISHER tail earns no derived credit. Consensus-relevant, same +// deploy rules and snapshot_block gating as ANCHOR_REWARD_ACTIVATION; kept byte-identical to the +// local copies in // xchain-{hub,indexer}/src/anchor_reward_activation.js by the cross-service regression suite. const ARCHIVE_REWARD_ACTIVATION = { mainnet: 963000, // ARMED 2026-07-16, RE-PINNED 2026-08-12 off 969500 onto the shared pre-freeze train boundary (tip 959,853 on 07-27 at ~144 blocks/day + 21d); deploy every consumer before this era @@ -502,7 +506,7 @@ const ARCHIVE_REWARD_AMOUNT = '10.00000000'; // block_index = snapshot_block. Consensus-relevant (COLLECT-spendable), same snapshot_block gating // and atomic-deploy rules as ANCHOR_REWARD_ACTIVATION. It CANNOT ride the 961000/963000 boundaries // (already live on testnet/regtest, so no coordinated flip window; and one gate must cover both -// the v4/v5 and v6 families). Kept byte-identical to the local copies in +// the `anchor_bundle` and `anchor_archive` reward families). Kept byte-identical to the local copies in // xchain-{hub,indexer}/src/anchor_reward_activation.js by the cross-service regression suite. // INERT on mainnet (null = never active) until the operator ratifies a coordinated BTC // snapshot_block; testnet and regtest are active from genesis. Testnet was armed at 0 by the diff --git a/protocol/controller-bound-tokens.md b/protocol/controller-bound-tokens.md index 1c56850..b7ae790 100644 --- a/protocol/controller-bound-tokens.md +++ b/protocol/controller-bound-tokens.md @@ -172,20 +172,49 @@ positional, all-string input params (read via `xchain.getInputParam(i)`): | i | Param | Notes | |---|---|---| -| 0 | `action_type` | the guard invocation point (see the [class table](#action-classes)). For `SWEEP_OWNERSHIP` there is one run per swept ownership deed, `from` = owner/SOURCE, `to` = DESTINATION. No guard runs at match or dispense: see [Proceeds split](#proceeds-split-royalty-fee-payout_legs). | -| 1 | `from` | the address giving up / sending the token (`''` if n/a) | -| 2 | `to` | the address receiving the token (`''` if n/a) | +| 0 | `action_type` | the guard invocation point (see the [class table](#action-classes) and the per-`action_type` table below). No guard runs at match or dispense: see [Proceeds split](#proceeds-split-royalty-fee-payout_legs). | +| 1 | `from` | the address the guarded action is charged to: the sender, seller, burner, minter or staker. **Not** always "the address giving up the token": on `MINT` nothing is given up and `from` is the minter | +| 2 | `to` | the counterparty address, `''` where the invocation point passes none. Its meaning is per-`action_type`; read it out of the table below rather than inferring it | | 3 | `tick` | the controlled token | | 4 | `amount` | token amount moving (or order/dispenser quantity) | -| 5 | `price` | proceeds amount for a sale (`''` for a plain `SEND`) | -| 6 | `proceeds_tick` | proceeds tick for a sale (`''` for a plain `SEND`) | +| 5 | `price` | proceeds amount for a sale (`''` outside the `trade`-class creates) | +| 6 | `proceeds_tick` | proceeds tick for a sale (`''` outside the `trade`-class creates) | + +### Invocation points (per `action_type`) + +`from` / `to` / `amount` are **not** uniform across invocation points, and a guard that infers +them from the generic descriptions above will misread at least three of them. The values the +indexer actually passes: + +| `action_type` | Class | `from` | `to` | `amount` | `price` / `proceeds_tick` | +|---|---|---|---|---|---| +| `SEND` | `transfer` | sender | recipient | amount sent | `''` | +| `AIRDROP` | `transfer` | distributor | `''` (fans out) | total leaving the sender | `''` | +| `DIVIDEND` | `transfer` | distributor | `''` (fans out) | total leaving the sender | `''` | +| `SWEEP` | `transfer` | swept address | **sweep `DESTINATION`** | that tick's swept balance | `''` | +| `SWEEP_OWNERSHIP` | `ownership` | owner / `SOURCE` | sweep `DESTINATION` | `''` | `''` | +| `ORDER_CREATE` | `trade` | seller | `''` (no buyer yet) | `GIVE_AMOUNT`, `''` on an ownership give | `GET_AMOUNT` / `GET_TICK` | +| `SWAP_CREATE` | `trade` | seller | `''` (no buyer yet) | `GIVE_AMOUNT`, `''` on an ownership give | `GET_AMOUNT` / `GET_TICK` | +| `DISPENSER_CREATE` | `trade` | dispenser opener | `''` (no buyer yet) | `GIVE_ESCROW`, `''` on an ownership give | `GET_AMOUNT` / `GET_TICK` | +| `DESTROY` | `burn` | burner | `''` | amount burned | `''` | +| `MINT` | `mint` | **minter**, not a giver | `DESTINATION`, **falling back to the minter** when the `MINT` names none | amount minted | `''` | +| `STAKE` | `stake` | staker | `''` **always**: the target contract taking custody is not passed | amount staked | `''` | + +Two of these bite guards written against the generic wording: + +- A **mint-allowlist** guard ("only these addresses may receive newly minted supply") must + treat `to` as the minter on a `MINT` with no `DESTINATION`; it never sees `''` there. +- A **stake** guard ("only this contract may hold my token") cannot read the custody target + from `to`, because no target is passed. Today a `stake` binding is an allow/deny on staking + the token at all, not on which contract receives it. Decision semantics: - **Return normally ⇒ ALLOW.** The guard's state changes and emitted actions are committed atomically with the native action. -- **A `trade`-class create guard may return `{ payoutLegs: [{ to, bps }, …] }`** to set a - basis-point split of the sale's proceeds (see [Proceeds split](#proceeds-split-royalty-fee-payout_legs)). +- **An `ORDER_CREATE` / `SWAP_CREATE` guard may return `{ payoutLegs: [{ to, bps }, …] }`** to + set a basis-point split of the sale's proceeds (see [Proceeds split](#proceeds-split-royalty-fee-payout_legs)). + `DISPENSER_CREATE` is **veto-only**: legs returned there are discarded, not rejected. - **`revert(reason)` / out-of-gas / runtime error / missing `guard` method ⇒ DENY** (fail-closed). The native action is marked `invalid: controller ()` and everything the guard did is rolled back. @@ -204,9 +233,9 @@ sources any actions the guard emits). ## Proceeds split (royalty / fee `payout_legs`) -A `trade`-class guard sets an optional **basis-point split of a sale's proceeds** by -returning `{ payoutLegs: [ { to:
, bps: }, … ] }` from its `guard` at -**create** time. The split is declarative data carried on the order or swap row. **No guard +An `ORDER_CREATE` / `SWAP_CREATE` guard sets an optional **basis-point split of a sale's +proceeds** by returning `{ payoutLegs: [ { to:
, bps: }, … ] }` from its `guard` +at **create** time. The split is declarative data carried on the order or swap row. **No guard runs at match**, which keeps the system-triggered fill path deterministic and gas-free. This one primitive is how XChain expresses royalties, marketplace fees, and revenue share. @@ -226,10 +255,20 @@ There is no royalty-specific mechanism; "royalty" is simply the most common use DEX settlement math is unchanged; an order with no legs yields a single full credit to the seller, so the call is unconditional. -**Scope.** The split applies to **on-ledger** proceeds (the `GET_TICK` the seller receives). -Native-coin (COINPay) proceeds are off-ledger and out of scope for the split; a `trade` guard -can still `revert` to forbid such a listing. `GIVE_OWNERSHIP` sales transfer ownership rather -than a balance, so no proceeds split applies to that leg. +**Scope.** The split applies to **on-ledger** proceeds (the `GET_TICK` the seller receives) of +an `ORDER` or `SWAP` fill. Native-coin (COINPay) proceeds are off-ledger and out of scope for +the split; a `trade` guard can still `revert` to forbid such a listing. `GIVE_OWNERSHIP` sales +transfer ownership rather than a balance, so no proceeds split applies to that leg. + +> ⚠️ **Dispensers take no split, whatever the guard returns.** `DISPENSER_CREATE` runs the +> `trade` guard as a **veto only**: the indexer consumes the deny and the metered guard fee and +> **discards any `payoutLegs` the guard returns**, silently, without denying the listing. No +> split is applied at dispense either: the dispense path credits the buyer the `GIVE_TICK` and +> runs no guard and no `applyProceedsSplit`. So a royalty policy that only *returns legs* is +> routed around by vending the token through a dispenser instead of listing it. A guard that +> means to enforce a cut must `revert` on `action_type === 'DISPENSER_CREATE'` (or on the +> dispenser price it will not be paid a share of). This is a known engine gap, not a design +> rule: it is recorded as a `KNOWN GAP` at the call site in the indexer's `dispenser.js`. ### Cross-chain sales (`CROSS_CHAIN_ROYALTY`) @@ -301,9 +340,11 @@ after the rest of Cohort A. Bulk moves of a controlled token route through the `transfer` class exactly like `SEND`, but the guard gates the **aggregate outbound move, sender-side only**: one guard run per -controlled tick with `from = SOURCE`, `to = ''`, and `amount` = the total leaving the sender. -The guard is **never invoked per-recipient**. This is a deliberate protocol decision, not a -gap: +controlled tick with `from = SOURCE` and `amount` = the total leaving the sender. `to` is +**not** uniform across the three: `AIRDROP` and `DIVIDEND` fan out to many recipients and pass +`to = ''`, while a `SWEEP` has exactly one destination and passes `to = DESTINATION` (see the +[invocation points](#invocation-points-per-action_type) table). The guard is **never invoked +per-recipient**. This is a deliberate protocol decision, not a gap: - **Deterministic, bounded VM work.** A drop can have thousands of recipients; one guard run per tick keeps guard gas independent of recipient count and keeps the ceiling reservation @@ -328,11 +369,14 @@ restriction that the recipient's balance is subject to on its next outbound move the account's recourse is the same transfer-restriction model. **Guidance for guard authors:** treat `AIRDROP` / `DIVIDEND` / `SWEEP` invocations as -sender-side aggregate checks (`from` is the distributor, `to` is empty, `amount` is the -total). Do not attempt per-recipient allowlisting inside the bulk guard; there is no -per-recipient invocation to hook. If your policy requires per-recipient control, either deny -the aggregate (forcing individual guarded `SEND`s) or enforce holder eligibility in the -`transfer` guard on subsequent moves. +sender-side aggregate checks (`from` is the distributor, `amount` is the total). Branch on +`action_type`, **never** on `to === ''`: `to` is empty on `AIRDROP` and `DIVIDEND` but carries +the sweep destination on `SWEEP`, so an aggregate-vs-direct test written against an empty `to` +takes the wrong branch on every sweep of a controlled token, and a future bulk action that +names a destination would break it again. Do not attempt per-recipient allowlisting inside the +bulk guard; there is no per-recipient invocation to hook. If your policy requires per-recipient +control, either deny the aggregate (forcing individual guarded `SEND`s) or enforce holder +eligibility in the `transfer` guard on subsequent moves. **SWEEP has two legs, gated by two classes.** `SWEEP` *balance* moves are gated by the `transfer` class as above. `SWEEP` **ownership** transfers are gated separately by the @@ -470,10 +514,20 @@ holds; a balanced transfer or an escrow settlement is supply-neutral. ## Activation and availability -Controller-bound tokens ride the `CONTROLLER_GUARD` protocol flag-day. Below it, `ISSUE` v6 -and `ADDRESS` v1 bindings are not accepted and no guard runs; a token or account is exactly as -it was before the feature existed. The cross-chain proceeds-split behavior additionally rides -the `CROSS_CHAIN_ROYALTY` flag-day described [above](#cross-chain-sales-cross_chain_royalty). +Controller-bound tokens ride the `CONTROLLER_GUARD` protocol flag-day, and the flag-day scopes +**guard execution only**. Below it no guard runs: no allow/deny VM run, no `payout_legs` +written, and no guard `contract_executions` row, so a node that carries the controller layer +and one that does not settle every guarded action identically. + +**Binding is not gated by the flag-day.** `ISSUE` v6 and `ADDRESS` v1 bindings are accepted, +validated and recorded below it, accumulating in the append-only `token_controllers` / +`address_controllers` logs exactly as they do above it. Every binding already on file therefore +**begins gating at activation**, with no further action from the issuer or the account holder. +Plan a pre-activation bind and the flag-day together: the guard is inert before it, the binding +is not erased by it. + +The cross-chain proceeds-split behavior additionally rides the `CROSS_CHAIN_ROYALTY` flag-day +described [above](#cross-chain-sales-cross_chain_royalty). Because a guard's decision and side effects are consensus-relevant, the VM engine and the indexer must deploy **atomically** across the fleet: every validator must run the same guard diff --git a/protocol/flag-days.md b/protocol/flag-days.md index 5b95887..c6bc6da 100644 --- a/protocol/flag-days.md +++ b/protocol/flag-days.md @@ -30,6 +30,8 @@ simultaneously on Bitcoin, Litecoin, and Dogecoin. 4 gates do not ride it and carry a date of its own: `BATCH_ISSUANCE_LIMITS` at 2026-08-16 00:00:00 UTC, `CONTRACT_DELEGATION_MATERIALIZE` at 2026-09-15 00:00:00 UTC, `DISPENSER_ORACLE_PER_TOKEN_PRICE` at 2026-09-15 00:00:00 UTC, `CROSS_CHAIN_ROYALTY` at 2027-01-01 00:00:00 UTC. Each carries the reason it is armed separately in its registration comment, in the file the **Declared in** column names below. For how a gate is evaluated and what happens to a node that misses one, see [Protocol Activation](./protocol-activation.md). +**7 gates are UNARMED on mainnet** (`BATCH_COST_WEIGHTING`, `BATCH_ROOT_SUB_INDEX`, `CROSS_SETTLE_CAP`, `EMISSION_ISSUANCE_LIMITS`, `ISSUE_INHERITED_MINT_WINDOW`, `UNCAPPED_MAX_SUPPLY_ZERO`, `UNIFIED_FEES_SWEEP_CALLBACK`): each parks the sentinel rather than an instant, so mainnet has **never** run the post-activation behavior and will not until an operator names a date. They carry no row in the table below, because publishing the sentinel as a flag day would put a commitment on this page that nobody made. Each names its reason in its registration comment in `protocol_changes.js`. This note covers the registry only; a sibling `*_activation.js` module can park a mainnet sentinel too, and those are not enumerated here. + **Testnet and regtest are genesis-active** for the time-keyed gates: they carry threshold `0`, so a testnet or regtest stack has always run the post-activation behavior. One gate is the exception: `ISSUE_INHERITED_MINT_WINDOW` arms testnet at `1787961600` (2026-08-29 00:00:00 UTC). The reason it cannot be genesis-active there is written in its registration comment in `protocol_changes.js`. The values on this page are otherwise mainnet values only. diff --git a/protocol/nft-standard.md b/protocol/nft-standard.md index c1ce7da..142edc0 100644 --- a/protocol/nft-standard.md +++ b/protocol/nft-standard.md @@ -253,9 +253,16 @@ a token binds a controller contract (via `ISSUE` v6) whose `guard` the indexer r the token is listed for sale. The guard returns a basis-point split (`payoutLegs`) that the indexer records on the order and applies to the seller's proceeds at each DEX match; the creator's cut plus the seller's remainder, conserved exactly. Because the indexer is -the only settlement path and the same controller can also gate plain `SEND`s, the rule -**cannot be routed around**: yet it needs no custody: the token stays natively held and -natively tradeable. The binding is opt-in per token, not imposed platform-wide. +the only settlement path and the same controller can also gate plain `SEND`s, the guard +**cannot be bypassed**, and it needs no custody: the token stays natively held and natively +tradeable. The binding is opt-in per token, not imposed platform-wide. + +**The split itself covers `ORDER` and `SWAP` sales only.** A `DISPENSER` sale runs the guard +at create as a *veto* and takes no cut: legs returned there are discarded, and no split is +applied at dispense (see +[Proceeds split](./controller-bound-tokens.md#proceeds-split-royalty-fee-payout_legs)). So a +royalty guard that only *returns legs* is routed around by vending through a dispenser; +enforcing the cut means also denying the dispenser listing from inside the guard. Creators who prefer a custody model can instead implement royalties in an ordinary **marketplace contract** that takes custody via [`DEPOSIT`](./actions/deposit.md)/[`WITHDRAW`](./actions/withdraw.md) diff --git a/protocol/protocol-activation.md b/protocol/protocol-activation.md index 0f3f8dd..8bf42b8 100644 --- a/protocol/protocol-activation.md +++ b/protocol/protocol-activation.md @@ -120,15 +120,27 @@ shorthand for "a BTC height per rule, in two batches" rather than a single share whole cohort. The cohort is its **armed** rules. `constants.js` also carries validator-era maps that are inert: -`SNAPSHOT_BURIAL_ACTIVATION`, `ANCHOR_REWARD_DERIVE_ACTIVATION`, `ATTEST_BROADCAST_FEE_ACTIVATION` -and `ATTEST_REQUEST_CAP_ACTIVATION` (the per-block attestation admission ceiling, a sibling of the -attestation-admission gate in the cohort table above) each hold `null` on mainnet, which is the +`SNAPSHOT_BURIAL_ACTIVATION`, `ANCHOR_REWARD_DERIVE_ACTIVATION`, `ATTEST_BROADCAST_FEE_ACTIVATION`, +`ATTEST_REQUEST_CAP_ACTIVATION` (the per-block attestation admission ceiling, a sibling of the +attestation-admission gate in the cohort table above), `ROLLCALL_ACTIVATION` (keyed on the BTC +`EPOCH_HEIGHT` a ROLLCALL carries), `ATTEST_RESPONSIBLE_WIDENING_ACTIVATION` and +`ATTEST_RESPONSE_MIRROR_ACTIVATION` each hold `null` on mainnet, which is the encoding of "never" and the fail-closed default until an operator ratifies a height. They are not -counted above and carry no flag day yet. The enumeration is the **height-keyed validator-era** maps +counted above and carry no flag day yet. `ATTEST_RESPONSE_MIRROR_ACTIVATION` is the one of them that +is unarmed on **testnet** too, so only regtest exercises the hub response-mirror path today; the +testnet exceptions listed below are exceptions among the *armed* cohort rules and do not cover it. +The enumeration is the **height-keyed validator-era** maps specifically: the block-time [decoder-carried gates](#decoder-carried-gates) also read `null` as disarmed, and `PRICE_PAIR_WIDEN_ACTIVATION` encodes the same "not yet" as a far-future sentinel instant rather than as `null`. +`ANCHOR_ACTIVATION` is height-keyed and **armed on both live networks**, but sits outside the three +cohorts: it is keyed on the anchor's own DOGE mined height (`DOGE:mainnet` 6360000, `DOGE:testnet` +67858600, regtest 0), not on a shared instant, a BTC anchor, or each chain's own local height. At or +above it the restarted ANCHOR wire set parses (versions 0, 1 and 2 only); below it an ANCHOR of any +version is `invalid: ANCHOR before activation`. Mainnet's height sits above the DOGE tip on purpose, +so the restarted wire set has not activated there yet. Stragglers **fork**. + Regtest runs every cohort **genesis-active** (threshold 0), so a fresh regtest stack exercises the post-activation behavior end to end. Testnet runs the time-keyed (Cohort A) and BTC-height-keyed (Cohort B) gates genesis-active as well, with **three** exceptions: diff --git a/test/layout-doc-currency.test.js b/test/layout-doc-currency.test.js new file mode 100644 index 0000000..7fbe3b4 --- /dev/null +++ b/test/layout-doc-currency.test.js @@ -0,0 +1,213 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. + * + ********************************************************************** + * + * Layout-document currency gate. + * + * WHY. CONTRIBUTING.md and MAINTAINERS.md describe the repo's shape, and + * nothing linked to those descriptions, so two sweeps walked past them. The + * lowercase rename of the three root docs left CONTRIBUTING's layout tree and + * MAINTAINERS' ownership table naming BLOCKCHAINS.md / OVERVIEW.md / + * WHITEPAPER.md, which is the exact SCREAMING_CASE the same file forbids at + * CONTRIBUTING.md's file-naming rule. The operator-dashboard removal swept the + * components index, the README table, the platform map and the test counts, + * and left MAINTAINERS.md claiming 15 documented components against 14 on + * disk. Both are the same failure: a prose description of the tree with no + * derivation from the tree. + * + * WHAT IT CHECKS. + * + * 1. Every path named in CONTRIBUTING.md's repo-layout tree exists at the + * repo root, matched case-exactly. + * 2. Every path named in MAINTAINERS.md's areas-of-responsibility table + * exists at the repo root, matched case-exactly. + * 3. Every " components" claim in the prose equals the number of + * component directories under components/. + * + * CASE-EXACT IS THE WHOLE POINT OF CHECKS 1 AND 2. macOS is case-insensitive, + * so fs.existsSync('BLOCKCHAINS.md') returns true against blockchains.md and a + * guard written that way would pass on a contributor's laptop while the defect + * it exists to catch sat in the file. Every lookup here reads a directory + * listing and compares strings. + * + * WHAT IT DOES NOT CHECK: that the DESCRIPTIONS beside each path are accurate, + * or that everything on disk appears in the tree. The tree is a reader's + * orientation aid and deliberately omits bin/, lib/, test/ and the package + * files, so completeness in that direction is not a defect. + * + ********************************************************************/ + +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..'); + +/** Reads a directory listing once, so every existence test is case-exact. */ +function entries(dir) { + const out = { files: new Set(), dirs: new Set() }; + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + (e.isDirectory() ? out.dirs : out.files).add(e.name); + } + return out; +} + +const ROOT_ENTRIES = entries(ROOT); + +/** Resolves a root-relative token from prose; a trailing slash means directory. */ +function missingReason(token) { + const isDir = token.endsWith('/'); + const name = isDir ? token.slice(0, -1) : token; + if (name.includes('/')) return null; // nested paths are out of this guard's scope + if (isDir) return ROOT_ENTRIES.dirs.has(name) ? null : 'no such directory at the repo root'; + if (ROOT_ENTRIES.files.has(name)) return null; + const other = [...ROOT_ENTRIES.files].find((f) => f.toLowerCase() === name.toLowerCase()); + return other ? `the file on disk is named ${other}` : 'no such file at the repo root'; +} + +/** Pulls the entry names out of the fenced ASCII tree under a given heading. */ +function layoutTreeEntries(markdown, heading) { + const lines = markdown.split('\n'); + const start = lines.findIndex((l) => l.trim() === heading); + assert.ok(start >= 0, `CONTRIBUTING.md no longer has the "${heading}" section`); + const open = lines.indexOf('```', start); + const close = lines.indexOf('```', open + 1); + assert.ok(open > 0 && close > open, 'the repo-layout section no longer holds a fenced block'); + const out = []; + for (let i = open + 1; i < close; i += 1) { + const m = /^[├└]──\s+(\S+)/.exec(lines[i]); + if (m) out.push({ token: m[1], line: i + 1 }); + } + return out; +} + +/** Pulls inline-code path tokens out of the rows of a named Markdown table. */ +function tableCodePaths(markdown, heading) { + const lines = markdown.split('\n'); + const start = lines.findIndex((l) => l.trim() === heading); + assert.ok(start >= 0, `MAINTAINERS.md no longer has the "${heading}" section`); + const out = []; + for (let i = start + 1; i < lines.length; i += 1) { + const line = lines[i]; + if (line.startsWith('## ')) break; + if (!line.startsWith('|')) continue; + for (const m of line.matchAll(/`([^`]+)`/g)) { + const token = m[1]; + if (token.endsWith('/') || token.endsWith('.md')) out.push({ token, line: i + 1 }); + } + } + return out; +} + +/** A documented component is a subdirectory of components/. */ +function documentedComponents() { + return [...entries(path.join(ROOT, 'components')).dirs].sort(); +} + +function markdownFiles() { + const out = []; + (function walk(dir) { + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + if (e.name === 'node_modules' || e.name.startsWith('.') || e.name === 'dist') continue; + const f = path.join(dir, e.name); + if (e.isDirectory()) walk(f); + // CHANGELOG is history: its old counts were true when written. + else if (e.name.endsWith('.md') && e.name !== 'CHANGELOG.md') out.push(f); + } + })(ROOT); + return out; +} + +/* + * Counts that legitimately measure a set other than the documented components. + * Registered per claim and per occurrence, following action-count-claims.js: a + * file-level allowlist would bless every future wrong number in that file, and + * an entry that stops matching is itself a failure, so a deleted claim takes + * its exemption with it. Empty today, and that is a fact about the repo rather + * than a shortcut: every digit-form component claim currently counts the + * documented set. Release-train and library counts are spelled out in words + * ("nine components", "five component libraries"), which the digit-form regex + * below does not reach, and they count release scope rather than the docs set. + */ +const SCOPED = []; + +// Qualifiers may carry a hyphen or the xchain-* glob, which is how +// MAINTAINERS.md phrases its claim; a plain \w+ run misses it entirely. +const CLAIM = /\b(\d{1,3})\s+((?:[A-Za-z][A-Za-z0-9*-]*\s+){0,3})components?\b/g; + +test('the repo-layout tree in CONTRIBUTING.md names paths that exist', () => { + const md = fs.readFileSync(path.join(ROOT, 'CONTRIBUTING.md'), 'utf8'); + const found = layoutTreeEntries(md, '## Repo layout in 30 seconds'); + assert.ok(found.length >= 10, 'the layout tree parsed to almost nothing, so this guard is inert'); + + const bad = found + .map(({ token, line }) => { + const why = missingReason(token); + return why ? `CONTRIBUTING.md:${line} names ${token}: ${why}` : null; + }) + .filter(Boolean); + + assert.deepStrictEqual(bad, [], + 'the repo-layout tree describes a tree that no longer exists. Update the prose to match disk ' + + '(renaming the file instead is a URL change and needs a redirect entry):\n' + bad.join('\n')); +}); + +test('the areas-of-responsibility table in MAINTAINERS.md names paths that exist', () => { + const md = fs.readFileSync(path.join(ROOT, 'MAINTAINERS.md'), 'utf8'); + const found = tableCodePaths(md, '## Areas of responsibility'); + assert.ok(found.length >= 10, 'the ownership table parsed to almost nothing, so this guard is inert'); + + const bad = found + .map(({ token, line }) => { + const why = missingReason(token); + return why ? `MAINTAINERS.md:${line} names ${token}: ${why}` : null; + }) + .filter(Boolean); + + assert.deepStrictEqual(bad, [], + 'the ownership table assigns an area that no longer exists under that name:\n' + bad.join('\n')); +}); + +test('the component count is derived from components/, not typed into the docs', () => { + const components = documentedComponents(); + assert.ok(components.length > 0, 'components/ must hold one directory per documented component'); + + const allowed = components.length; + const budget = new Map(SCOPED.map((s) => [`${s.file}|${s.claim}`, s.count])); + + const bad = []; + for (const file of markdownFiles()) { + const rel = path.relative(ROOT, file); + const lines = fs.readFileSync(file, 'utf8').split('\n'); + lines.forEach((line, i) => { + CLAIM.lastIndex = 0; + let m; + while ((m = CLAIM.exec(line))) { + if (Number(m[1]) === allowed) continue; + const key = `${rel}|${m[0].trim()}`; + const left = budget.get(key) || 0; + if (left > 0) { budget.set(key, left - 1); continue; } + bad.push(`${rel}:${i + 1} claims ${m[1]}: "${m[0].trim()}"`); + } + }); + } + + const unspent = [...budget.entries()].filter(([, left]) => left > 0) + .map(([key, left]) => `${key} (${left} unmatched)`); + assert.deepStrictEqual(unspent, [], + 'SCOPED registers a claim that is no longer in the docs. Delete the entry:\n' + unspent.join('\n')); + + assert.deepStrictEqual(bad, [], + `components/ documents ${allowed} components (${components.join(', ')}). These claims say ` + + 'otherwise. If a number measures a different set, add it to SCOPED with the reason rather ' + + 'than editing prose to fit the guard:\n' + bad.join('\n')); +}); From c3f96a98969d910db5a2bdf876e02c14808a770c Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 4 Sep 2026 12:47:05 -0700 Subject: [PATCH 36/52] docs: rate limits sized to the wallet, the encoder and explorer host-env passthroughs, and the origin identity change in the wallet's data disclosure The explorer's app-wide, action-proof and checkpoint-verify defaults are now 1080, 90 and 90 per minute; the node pages document the encoder trust-proxy and rate-limit passthroughs and the explorer's five per-route caps. The data disclosure states that a real-client-IP module is loaded for the rate limiters only and that the access logs still record the edge address, so no visitor IP is written to disk. --- components/explorer/configuration.md | 8 ++++---- components/explorer/operations.md | 2 +- components/node/configuration.md | 9 ++++++++- components/wallet/privacy/data-disclosure.md | 6 ++++-- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/components/explorer/configuration.md b/components/explorer/configuration.md index 2fd8deb..a3216f9 100644 --- a/components/explorer/configuration.md +++ b/components/explorer/configuration.md @@ -327,19 +327,19 @@ The explorer uses `express-rate-limit` middleware: | Setting | Value | |---|---| | Window | 60 seconds | -| Max requests per window | 500 (override via `EXPLORER_RATE_LIMIT_RPM`) | +| Max requests per window | 1080 (override via `EXPLORER_RATE_LIMIT_RPM`) | | Scope | Per IP address | | Response on limit | HTTP 429 Too Many Requests | | Variable | Required | Default | Description | |---|---|---|---| -| `EXPLORER_RATE_LIMIT_RPM` | No | `500` | Maximum requests per IP per 60-second window. Image requests (`.png`, `.jpg`, `.jpeg`, `.gif`, `.ico`, `.svg`, `.webp`), `/icon/` paths, and `/images` paths are excluded from the limit. | -| `EXPLORER_ACTION_PROOF_RATE_LIMIT_RPM` | No | `60` | Separate, tighter limit for `/{COIN}/api/proof/action/{idx}` | +| `EXPLORER_RATE_LIMIT_RPM` | No | `1080` | Maximum requests per IP per 60-second window, derived from the measured five-address wallet profile's worst minute with retries and 3x headroom. Image requests (`.png`, `.jpg`, `.jpeg`, `.gif`, `.ico`, `.svg`, `.webp`), `/icon/` paths, and `/images` paths are excluded from the limit. | +| `EXPLORER_ACTION_PROOF_RATE_LIMIT_RPM` | No | `90` | Separate, tighter limit for `/{COIN}/api/proof/action/{idx}` and its balance, contract-state and locked-balance siblings, derived from the measured wallet profile's proof jobs with retries and 3x headroom. | | `EXPLORER_VALIDATOR_SET_PROOF_RATE_LIMIT_RPM` | No | `30` | Separate, tighter limit for `/BTC/api/proof/validator-set` | | `EXPLORER_PREFLIGHT_POST_RATE_LIMIT_RPM` | No | `60` | Separate limit for `POST /{COIN}/api/preflight`, the only unauthenticated route that accepts a large body. The limiter runs before the body parser, so a limited caller is refused without the server reading the payload. | | `EXPLORER_FEE_QUOTE_RATE_LIMIT_RPM` | No | `120` | Separate limit for the fee lookups `/{COIN}/api/feequote`, `/{COIN}/api/oraclefeequote` and `/{COIN}/api/feeschedule`. One tier looser than the proof routes because a quote is a lookup rather than a cryptographic recompute. | | `EXPLORER_CHECKPOINT_LIST_RATE_LIMIT_RPM` | No | `120` | Separate limit for `/{COIN}/api/checkpoints`, which lists stored checkpoints. | -| `EXPLORER_CHECKPOINT_VERIFY_RATE_LIMIT_RPM` | No | `60` | Separate, tighter limit for `/{COIN}/api/checkpoint/{blockIndex}/verify`, which recomputes a checkpoint rather than reading one. | +| `EXPLORER_CHECKPOINT_VERIFY_RATE_LIMIT_RPM` | No | `90` | Separate, tighter limit for `/{COIN}/api/checkpoint/{blockIndex}/verify`, which recomputes a checkpoint rather than reading one, derived from the measured wallet profile's one verify per proof job with retries and 3x headroom. | | `WS_TRUST_PROXY_HOPS` | No | `1` | Proxy hop count used to resolve the real client address for the WebSocket per-IP cap. The upgrade is handled on the raw HTTP server, where Express's `trust proxy` does not apply, so the hop count must be passed explicitly or the cap keys on a spoofable `X-Forwarded-For`. Keep it aligned with the HTTP side. | Rate limiting applies to all non-image endpoints (API, Explorer, and HTML). diff --git a/components/explorer/operations.md b/components/explorer/operations.md index 5b857a3..2d9201c 100644 --- a/components/explorer/operations.md +++ b/components/explorer/operations.md @@ -106,7 +106,7 @@ For production deployments, use certificates from a trusted CA. For development The explorer applies rate limiting to all endpoints: - **Window:** 60 seconds -- **Max requests:** 500 per window per IP (configurable via `EXPLORER_RATE_LIMIT_RPM`) +- **Max requests:** 1080 per window per IP (configurable via `EXPLORER_RATE_LIMIT_RPM`; derived from the measured wallet profile with 3x headroom) - **Response on limit:** HTTP 429 Too Many Requests Rate limiting is applied via `express-rate-limit` middleware before any route handling. diff --git a/components/node/configuration.md b/components/node/configuration.md index 498ec3f..5429971 100644 --- a/components/node/configuration.md +++ b/components/node/configuration.md @@ -199,10 +199,17 @@ signer as fatal. | `EXPLORER_CHECKPOINT_SELF_SYNC` | _(unset)_ | Opt in to a self-synced checkpoint mirror for the explorer. When set, the generated explorer config gains a `checkpoint` database descriptor whose host, port, user and password are taken from the indexer's own, plus a `_HubMirror` schema the explorer provisions and keeps current from the hub. Leave unset where `database.checkpoint` is pointed at a real hub schema by hand | | `HUB_API_URL` | derived from the hub container name and port | Base REST URL the explorer's mirror writer uses to pull hub-mirrored tables. Distinct from `HUB_API_HOST`/`HUB_PORT`, which feed the ordinary config poll rather than the mirror. Emitted only when `EXPLORER_CHECKPOINT_SELF_SYNC` is set | | `EXPLORER_VM_QUERY_ENABLED` | _(unset)_ | Passed through verbatim to the explorer to enable contract read-method simulation. The reader tests for the exact string `true`, so the value is not coerced | -| `EXPLORER_RATE_LIMIT_RPM` | _(unset; explorer defaults to `500`)_ | Passed through to the explorer: requests per minute per IP across its whole API. A private venue reached through one tunnel or proxy is a SINGLE IP to this limiter, so every browser and every automated run on that host shares one budget; a browser-driven test suite alone sustains several hundred a minute. Left unset, the explorer's public-facing default applies. | +| `EXPLORER_RATE_LIMIT_RPM` | _(unset; explorer defaults to `1080`)_ | Passed through to the explorer: requests per minute per IP across its whole API. A private venue reached through one tunnel or proxy is a SINGLE IP to this limiter, so every browser and every automated run on that host shares one budget; a browser-driven test suite alone sustains several hundred a minute. Left unset, the explorer's public-facing default applies. | | `EXPLORER_FEE_QUOTE_RATE_LIMIT_RPM` | _(unset; explorer defaults to `120`)_ | Passed through to the explorer: the tighter limit on `/{COIN}/api/feequote`, `/oraclefeequote` and `/feeschedule`. Raise it alongside the one above on a venue whose only client is a test suite composing fee-bearing actions back to back. | | `EXPLORER_PREFLIGHT_POST_RATE_LIMIT_RPM` | _(unset; explorer defaults to `60`)_ | Passed through to the explorer: the limit on `POST /{COIN}/api/preflight`, the one unauthenticated route that accepts a large body. | | `EXPLORER_TIP_MAX_AGE_S` | _(unset; explorer defaults to `21600`)_ | Passed through to the explorer: the age in seconds past which a coin's newest indexed block counts as stale, after which the explorer refuses reads for that coin with `503 COIN_DATA_STALE` and drops it from `/{COIN}/api/status`'s `available` map. `0` disables the gate. **A regtest chain advances only when someone mines it**, so an idle one crosses the six-hour default while its lag is zero; set this to `0` on a venue that serves nothing but regtest coins. The explorer's own per-coin `EXPLORER_TIP_MAX_AGE_S_` form is not carried through here - set it on the explorer directly if you need to exempt one chain rather than all of them. | +| `ENCODER_TRUST_PROXY` | _(unset; encoder defaults to `loopback, uniquelocal`)_ | Passed through to the encoder: the fronting proxy's address as the encoder sees it, e.g. the services host's egress address, so it recovers the real client IP from `X-Forwarded-For` instead of keying its per-IP limiter on the proxy's own address for every visitor. Accepts the Express `trust proxy` forms: `false`, a hop count, or an address/CIDR list. | +| `ENCODER_RATE_LIMIT_RPM` | _(unset; encoder defaults to `60`)_ | Passed through to the encoder: requests per minute per IP. A host value wins over the regtest-only `99999` this project sets by default, so an operator override still survives an `update`/`recreate` on a regtest venue. | +| `EXPLORER_CHECKPOINT_LIST_RATE_LIMIT_RPM` | _(unset; explorer defaults to `120`)_ | Passed through to the explorer: the limit on the checkpoint-list route. | +| `EXPLORER_CHECKPOINT_VERIFY_RATE_LIMIT_RPM` | _(unset; explorer defaults to `90`)_ | Passed through to the explorer: the limit on the checkpoint-verify route. | +| `EXPLORER_ACTION_PROOF_RATE_LIMIT_RPM` | _(unset; explorer defaults to `90`)_ | Passed through to the explorer: the limit on the action-proof route. | +| `EXPLORER_VALIDATOR_SET_PROOF_RATE_LIMIT_RPM` | _(unset; explorer defaults to `30`)_ | Passed through to the explorer: the limit on the validator-set-proof route. | +| `EXPLORER_VM_QUERY_RATE_LIMIT_RPM` | _(unset; explorer defaults to `20`)_ | Passed through to the explorer: the limit on the VM-query route (`contract.html` read simulation). | > **Note on `XCHAIN_NODE_EXTERNAL_DB_ROOT_PASSWORD`:** this is a credential value. Pass it via your deployment environment or secrets manager; do not store it in config files checked into version control. diff --git a/components/wallet/privacy/data-disclosure.md b/components/wallet/privacy/data-disclosure.md index 962f1e7..544609f 100644 --- a/components/wallet/privacy/data-disclosure.md +++ b/components/wallet/privacy/data-disclosure.md @@ -85,7 +85,7 @@ It was blocked here for a day on a premise that measurement dissolved. The earli Measured on the live hosts on 2026-08-02: -- All three hosts are Cloudflare-proxied, and none of them loads a real-client-IP module or configures `CF-Connecting-IP` handling. +- All three hosts are Cloudflare-proxied, and at that measurement none of them loaded a real-client-IP module or configured `CF-Connecting-IP` handling. (That has since changed for a narrower purpose; see the current posture below.) - So the logged source is a Cloudflare edge address, not a visitor's: explorer **844 of 846** distinct sources inside Cloudflare's published ranges, encoder **119 of 120**, hub **162 of 162**. - **No wallet user IP is retained, so there is no IP-to-address linkage to disclose.** - Only `explorer.xchain.io` carried wallet addresses in its request lines (857 of 7,520 that day). `encoder.xchain.io` takes them in POST bodies, which `combined` does not log; `hub.xchain.io` carries none. @@ -93,7 +93,9 @@ Measured on the live hosts on 2026-08-02: Cloudflare still sees and logs the visitor IP at its edge under its own policy. That is disclosed as a third-party contact, and it is not our retention. -**What would make this false again**, and both are things a sensible administrator might do for good reasons: enabling a real-client-IP module (which would start recording real client addresses), or moving the explorer access log back under the default rotation (which would silently restore the longer retention). [The data-collection record](data-collection.md) is where those two are re-measured; do it before every submission. +**Current posture, and why the sentence above still holds.** The explorer, encoder and hub hosts now load Apache's real-client-IP module (`mod_remoteip`, reading `CF-Connecting-IP` from Cloudflare's published ranges) for one purpose: so each host's per-visitor rate limiter can tell one visitor from another instead of throttling everyone who shares a Cloudflare exit address. The module was enabled together with a change to those hosts' access-log format, whose client column is the connection peer rather than the resolved visitor, so the logged source is still the Cloudflare edge address and no visitor IP is written to disk. The visitor's address exists only in the rate limiters' in-memory counters for the current 60-second window and is never stored or exported. + +**What would make this false again**, and both are things a sensible administrator might do for good reasons: changing those hosts' access-log format back to one whose client column is the resolved visitor (`%h` or `%a`; with the module loaded, that would start recording real client addresses), or moving the explorer access log back under the default rotation (which would silently restore the longer retention). [The data-collection record](data-collection.md) is where those two are re-measured; do it before every submission. ## Data usage: the answers From 8018fd45c0aeb2c163583bda1f113e618eded36d Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 4 Sep 2026 13:22:29 -0700 Subject: [PATCH 37/52] docs(privacy): describe the wallet's API hosts without counting or enumerating them --- components/wallet/privacy/data-disclosure.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/components/wallet/privacy/data-disclosure.md b/components/wallet/privacy/data-disclosure.md index 544609f..b469096 100644 --- a/components/wallet/privacy/data-disclosure.md +++ b/components/wallet/privacy/data-disclosure.md @@ -81,11 +81,11 @@ Ledger hardware wallets use WebHID over USB and make no network request at all. **The wallet does not collect user data, and it is a plain fact rather than an argument.** The same answer lands on all three store forms. -It was blocked here for a day on a premise that measurement dissolved. The earlier record said the explorer, encoder and hub hosts each logged the client IP alongside a request line carrying the wallet address, retained 14 days. That was read off the Apache format string (`combined` starts with `%h`) rather than off the logs. `%h` is whoever opened the TCP connection, and behind a reverse proxy that is the proxy. +It was blocked here for a day on a premise that measurement dissolved. The earlier record said the wallet's API hosts each logged the client IP alongside a request line carrying the wallet address, retained 14 days. That was read off the Apache format string (`combined` starts with `%h`) rather than off the logs. `%h` is whoever opened the TCP connection, and behind a reverse proxy that is the proxy. Measured on the live hosts on 2026-08-02: -- All three hosts are Cloudflare-proxied, and at that measurement none of them loaded a real-client-IP module or configured `CF-Connecting-IP` handling. (That has since changed for a narrower purpose; see the current posture below.) +- Every API host the wallet talks to is Cloudflare-proxied, and at that measurement none of them loaded a real-client-IP module or configured `CF-Connecting-IP` handling. (That has since changed for a narrower purpose; see the current posture below.) - So the logged source is a Cloudflare edge address, not a visitor's: explorer **844 of 846** distinct sources inside Cloudflare's published ranges, encoder **119 of 120**, hub **162 of 162**. - **No wallet user IP is retained, so there is no IP-to-address linkage to disclose.** - Only `explorer.xchain.io` carried wallet addresses in its request lines (857 of 7,520 that day). `encoder.xchain.io` takes them in POST bodies, which `combined` does not log; `hub.xchain.io` carries none. @@ -93,7 +93,7 @@ Measured on the live hosts on 2026-08-02: Cloudflare still sees and logs the visitor IP at its edge under its own policy. That is disclosed as a third-party contact, and it is not our retention. -**Current posture, and why the sentence above still holds.** The explorer, encoder and hub hosts now load Apache's real-client-IP module (`mod_remoteip`, reading `CF-Connecting-IP` from Cloudflare's published ranges) for one purpose: so each host's per-visitor rate limiter can tell one visitor from another instead of throttling everyone who shares a Cloudflare exit address. The module was enabled together with a change to those hosts' access-log format, whose client column is the connection peer rather than the resolved visitor, so the logged source is still the Cloudflare edge address and no visitor IP is written to disk. The visitor's address exists only in the rate limiters' in-memory counters for the current 60-second window and is never stored or exported. +**Current posture, and why the sentence above still holds.** The wallet's API hosts now load Apache's real-client-IP module (`mod_remoteip`, reading `CF-Connecting-IP` from Cloudflare's published ranges) for one purpose: so each host's per-visitor rate limiter can tell one visitor from another instead of throttling everyone who shares a Cloudflare exit address. The module was enabled together with a change to those hosts' access-log format, whose client column is the connection peer rather than the resolved visitor, so the logged source is still the Cloudflare edge address and no visitor IP is written to disk. The visitor's address exists only in the rate limiters' in-memory counters for the current 60-second window and is never stored or exported. **What would make this false again**, and both are things a sensible administrator might do for good reasons: changing those hosts' access-log format back to one whose client column is the resolved visitor (`%h` or `%a`; with the module loaded, that would start recording real client addresses), or moving the explorer access log back under the default rotation (which would silently restore the longer retention). [The data-collection record](data-collection.md) is where those two are re-measured; do it before every submission. From ace6b485be2b8b3ec3fc90bd12327ab85debf47d Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 4 Sep 2026 13:24:08 -0700 Subject: [PATCH 38/52] docs(privacy): describe the origin's visitor-address handling by its effect, not by the web server's mechanism --- components/wallet/privacy/data-disclosure.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/components/wallet/privacy/data-disclosure.md b/components/wallet/privacy/data-disclosure.md index b469096..dadb455 100644 --- a/components/wallet/privacy/data-disclosure.md +++ b/components/wallet/privacy/data-disclosure.md @@ -81,21 +81,21 @@ Ledger hardware wallets use WebHID over USB and make no network request at all. **The wallet does not collect user data, and it is a plain fact rather than an argument.** The same answer lands on all three store forms. -It was blocked here for a day on a premise that measurement dissolved. The earlier record said the wallet's API hosts each logged the client IP alongside a request line carrying the wallet address, retained 14 days. That was read off the Apache format string (`combined` starts with `%h`) rather than off the logs. `%h` is whoever opened the TCP connection, and behind a reverse proxy that is the proxy. +It was blocked here for a day on a premise that measurement dissolved. The earlier record said the wallet's API hosts each logged the client IP alongside a request line carrying the wallet address, retained 14 days. That was read off the web server's log format rather than off the logs. The field it names is whoever opened the TCP connection, and behind a reverse proxy that is the proxy. Measured on the live hosts on 2026-08-02: -- Every API host the wallet talks to is Cloudflare-proxied, and at that measurement none of them loaded a real-client-IP module or configured `CF-Connecting-IP` handling. (That has since changed for a narrower purpose; see the current posture below.) +- Every API host the wallet talks to is Cloudflare-proxied, and at that measurement none of them resolved the visitor's address behind the proxy. (That has since changed for a narrower purpose; see the current posture below.) - So the logged source is a Cloudflare edge address, not a visitor's: explorer **844 of 846** distinct sources inside Cloudflare's published ranges, encoder **119 of 120**, hub **162 of 162**. - **No wallet user IP is retained, so there is no IP-to-address linkage to disclose.** -- Only `explorer.xchain.io` carried wallet addresses in its request lines (857 of 7,520 that day). `encoder.xchain.io` takes them in POST bodies, which `combined` does not log; `hub.xchain.io` carries none. +- Only `explorer.xchain.io` carried wallet addresses in its request lines (857 of 7,520 that day). `encoder.xchain.io` takes them in POST bodies, which the access log does not record; `hub.xchain.io` carries none. - That one log now rotates **daily, with one generation kept**, so no wallet address survives 24 hours. Every other log is untouched at 14 days. Cloudflare still sees and logs the visitor IP at its edge under its own policy. That is disclosed as a third-party contact, and it is not our retention. -**Current posture, and why the sentence above still holds.** The wallet's API hosts now load Apache's real-client-IP module (`mod_remoteip`, reading `CF-Connecting-IP` from Cloudflare's published ranges) for one purpose: so each host's per-visitor rate limiter can tell one visitor from another instead of throttling everyone who shares a Cloudflare exit address. The module was enabled together with a change to those hosts' access-log format, whose client column is the connection peer rather than the resolved visitor, so the logged source is still the Cloudflare edge address and no visitor IP is written to disk. The visitor's address exists only in the rate limiters' in-memory counters for the current 60-second window and is never stored or exported. +**Current posture, and why the sentence above still holds.** The wallet's API hosts now resolve the visitor's real address behind the proxy for one purpose: so each host's per-visitor rate limiter can tell one visitor from another instead of throttling everyone who shares a Cloudflare exit address. That was enabled together with a change to those hosts' access-log format, so the logged source is still the Cloudflare edge address and no visitor IP is written to disk. The visitor's address exists only in the rate limiters' in-memory counters for the current 60-second window and is never stored or exported. -**What would make this false again**, and both are things a sensible administrator might do for good reasons: changing those hosts' access-log format back to one whose client column is the resolved visitor (`%h` or `%a`; with the module loaded, that would start recording real client addresses), or moving the explorer access log back under the default rotation (which would silently restore the longer retention). [The data-collection record](data-collection.md) is where those two are re-measured; do it before every submission. +**What would make this false again**, and both are things a sensible administrator might do for good reasons: changing those hosts' access-log format back to one that records the resolved visitor (which would start recording real client addresses), or moving the explorer access log back under the default rotation (which would silently restore the longer retention). [The data-collection record](data-collection.md) is where those two are re-measured; do it before every submission. ## Data usage: the answers From 69c1faf63d0acd8069843f5e214d2fa484b8faaf Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 4 Sep 2026 13:28:33 -0700 Subject: [PATCH 39/52] docs: describe host requirements without naming a provider, a role, or a count The node page recommended a setup by hardware SKU, two pages referred to a specific hub role, and the Apple privacy labels counted the wallet's first-party API hosts. Each now states the requirement or the guarantee instead. --- components/hub/configuration.md | 2 +- components/node/configuration.md | 6 +++--- components/wallet/privacy/privacy-nutrition-labels.md | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/components/hub/configuration.md b/components/hub/configuration.md index 94c33d2..e77d783 100644 --- a/components/hub/configuration.md +++ b/components/hub/configuration.md @@ -232,7 +232,7 @@ The hub reads the BTC chain tip to anchor consensus rounds. These gates stop a s |---|---|---|---| | `BTC_INDEXER_URL` | No | _(from config table)_ | BTC indexer JSON-RPC URL used by the full-node challenge round. | | `BTC_INDEXER_API_KEY` | No | _(from config table)_ | API key presented to that indexer's fail-closed federation-read gate. Treat as a credential. | -| `BTC_INDEXER_API_URL` | No | None | BTC indexer JSON-RPC URL for the validator-mode price oracle's block-height anchor (`getlatestblock`). Set it when the hub is **not** co-located with a BTC indexer, e.g. a master hub box whose BTC stack lives elsewhere. Empty falls back to local resolution. `xchain-node` forwards this from the host environment. | +| `BTC_INDEXER_API_URL` | No | None | BTC indexer JSON-RPC URL for the validator-mode price oracle's block-height anchor (`getlatestblock`). Set it when the hub is **not** co-located with a BTC indexer and must reach one over the network. Empty falls back to local resolution. `xchain-node` forwards this from the host environment. | | `MAX_INDEXER_LAG_BLOCKS` | No | `200` | Maximum blocks the BTC indexer may lag before its tip is treated as untrustworthy and ignored, degrading gracefully instead of locking in a stale validator set. | | `MAX_TIP_AGE_S` | No | `2 × ORACLE_ROUND_INTERVAL` (seconds) | Maximum age of the indexer-pushed BTC tip before it is considered stale. | | `INDEXER_COIN_CHECK` | No | enabled | Set to `0` to disable the per-coin indexer reachability check. | diff --git a/components/node/configuration.md b/components/node/configuration.md index 5429971..2a1da67 100644 --- a/components/node/configuration.md +++ b/components/node/configuration.md @@ -159,7 +159,7 @@ These configure a hub acting as the telemetry **collector**, and are forwarded i | `XCHAIN_NODE_DOGE_WIF` | **Credential.** Private key for the DOGE publisher wallet, read by `validator init --import-doge-key`, with the same prompt fallback and the same reason. | | `XCHAIN_NODE_AUTOHEAL_STATE_DIR` | Directory holding autoheal state. Defaults to the same per-user directory as `credentials.json`. Test and ops override. | | `GITHUB_TOKEN` / `GH_TOKEN` | Personal access token for GitHub downloads. Raises the anonymous API rate limit and is required to reach private module repositories. `GITHUB_TOKEN` is checked first. Treat as a credential: supply it from the environment, never a checked-in file. | -| `BTC_INDEXER_API_URL` | BTC indexer JSON-RPC URL used as the block-height anchor for the validator-mode price oracle (`hub.getlatestblock`). Read from the host environment so a hub that is **not** co-located with a BTC indexer (the master hub box, where the BTC stack lives elsewhere) can point at a reachable one. Empty by default, in which case the hub falls back to its local resolution. | +| `BTC_INDEXER_API_URL` | BTC indexer JSON-RPC URL used as the block-height anchor for the validator-mode price oracle (`hub.getlatestblock`). Read from the host environment so a hub that is **not** co-located with a BTC indexer can point at a reachable one. Empty by default, in which case the hub falls back to its local resolution. | The four below are read by the **DOGE signer** the hub mounts read-only, not by `xchain-node` itself. They live in that signer directory's own `.env`, which is @@ -240,9 +240,9 @@ These env vars override where xchain-node stores its filesystem state on the hos > > `XCHAIN_NODE_BLOCKS_DIR` avoids this trap entirely: xchain-node starts the daemon with `-blocksdir=/blocks`, which the daemon honours on every network, so all per-network subdirectories land inside the mounted path (`/blocks/testnet3/blocks/`, `/blocks/regtest/blocks/`, …). A single host bind therefore covers mainnet, testnet, and regtest uniformly. See [Disk Management](../../operations/disk-management.md) for the full disk-offload guide. -### Recommended setup for OVH RISE-3 chain-node boxes +### Recommended setup for a chain-node host with a small root volume -On the RISE-3 archetype (small `/` partition, large `/misc` SATA mirror), set these before installing: +On a host with a small `/` partition and a large secondary volume (mounted at `/misc` in this example), set these before installing: ```bash export XCHAIN_NODE_DATA_DIR=/misc/xchain-node-data diff --git a/components/wallet/privacy/privacy-nutrition-labels.md b/components/wallet/privacy/privacy-nutrition-labels.md index e7df8d3..bb9c6ec 100644 --- a/components/wallet/privacy/privacy-nutrition-labels.md +++ b/components/wallet/privacy/privacy-nutrition-labels.md @@ -28,7 +28,7 @@ Three things that appear on the Android form or in the privacy policy are delibe Apple's definition of "collect" is transmitting data off the device and keeping it longer than needed to service the request in real time. -All three first-party hosts (`explorer.xchain.io`, `encoder.xchain.io`, `hub.xchain.io`) sit behind Cloudflare, which means the address our own servers log is Cloudflare's, not the visitor's: measured across a full day of traffic, the overwhelming majority of distinct source addresses in each log fell inside Cloudflare's published ranges. **No wallet user IP is retained**, so nothing links an address to a person. +The wallet's first-party API hosts sit behind Cloudflare, which means the address our own servers log is Cloudflare's, not the visitor's: measured across a full day of traffic, the overwhelming majority of distinct source addresses in each log fell inside Cloudflare's published ranges. **No wallet user IP is retained**, so nothing links an address to a person. Only `explorer.xchain.io` carries wallet addresses in its request lines, and that log has one-day retention. `encoder.xchain.io` takes addresses in request bodies, which are not logged, and `hub.xchain.io` carries none. From a420a0c4e78a6b70290266860a3da78036f394e5 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 4 Sep 2026 21:59:03 -0700 Subject: [PATCH 40/52] docs: correct config and betting reference against shipped behaviour The canonical config reference documented CORS_ORIGIN as a single origin rather than the allowlist parseCorsOrigin implements, and betting.md omitted that controller-bound tokens are rejected as a market's wager token. The published helper-module count finding (#6813) was REJECTED: the monitor's own replacement numbers were wrong in the other direction, and applying them would have reddened xchain-e2e-test's ci chain for every lane running it with the docs sibling present. Review round 7 findings #6835, #6837, #6853, and the #6813 reject. Also carries review round 6's documentation work. --- bin/generate-flag-days.js | 104 +++++++++++++++++------ components/encoder/api.md | 3 +- components/utxo-tracker/configuration.md | 2 +- developer-guide/api-reference.md | 3 +- protocol/error-codes.md | 62 +++++++++++++- protocol/flag-days.md | 2 +- protocol/index-id-references.md | 54 +++++++++--- protocol/protocol-activation.md | 20 ++++- test/flag-day-literals.test.js | 51 ++++++++++- test/vectors.test.js | 10 +++ user-guide/betting.md | 2 +- 11 files changed, 268 insertions(+), 45 deletions(-) diff --git a/bin/generate-flag-days.js b/bin/generate-flag-days.js index ace9c88..e77ef7d 100644 --- a/bin/generate-flag-days.js +++ b/bin/generate-flag-days.js @@ -341,15 +341,17 @@ function collectSiblingGates(indexerSrc, add) { * when its mainnet_time slot holds a literal inside the time-keyed window, which * is exactly the condition under which a row went missing. * - * An identifier in that slot stays quiet on the same principle: no text scan can - * tell `PRICE_PAIR_SENTINEL` from a live timestamp, and guessing is what turns a - * build gate into noise. The constant arm needs no such test, because + * An identifier in that slot is resolved when it names one of the registry's own + * `const NAME_MAINNET_TIME = ;` declarations (see `registryCalls`), and + * the GATE NAME the call registers is what reaches the page: the constant's + * prefix is not a gate key, and the two can differ (`CROSS_SETTLE_CAP_MAINNET_TIME` + * arms `CROSS_SETTLE_PER_BLOCK_CAP`). Any other identifier stays quiet: no text + * scan can tell `PRICE_PAIR_SENTINEL` from a live timestamp, and guessing is what + * turns a build gate into noise. The constant arm needs no such test, because * `NAME_MAINNET_TIME` says in its own name that it is a time. * - * A call is also fine when its gate was collected some other way: the two cohort - * constants are declared as `const NAME_MAINNET_TIME` and then passed to - * `addChange` by identifier, so the call itself is unreadable and nothing is - * lost by it. + * A call is also fine when its gate was collected some other way, which is how a + * constant no call consumes still reaches the page under its own prefix. */ function assertEveryDeclarationParsed(rawRegistry, parsedCalls, parsedConstLines, parsedNames) { const unparsed = []; @@ -381,6 +383,50 @@ function assertEveryDeclarationParsed(rawRegistry, parsedCalls, parsedConstLines } } +/** + * The registry's `const NAME_MAINNET_TIME = ;` and + * `const NAME_TESTNET_TIME = ;` declarations as an identifier -> value + * map, read from the comment-stripped text. + */ +function registryConstants(scannable) { + const values = new Map(); + for (const m of scannable.matchAll(/const\s+([A-Z][A-Z0-9_]*_(?:MAINNET|TESTNET)_TIME)\s*=\s*(\d+)\s*;/g)) { + values.set(m[1], Number(m[2])); + } + return values; +} + +/** + * Every single-quoted `addChange('GATE', 'version', mainnet_time, testnet_time, ...)` + * call in the comment-stripped registry, as `{ index, gate, mainnet, testnet }`. + * + * A time slot holding a digit literal reads as that number. A slot holding an + * identifier reads as the value of the registry constant it names, so the GATE + * NAME the call registers is what the collectors publish; the constant's prefix + * is not a gate key `isEnabled` accepts, and the two differ for + * `CROSS_SETTLE_CAP_MAINNET_TIME` (arms `CROSS_SETTLE_PER_BLOCK_CAP`) and + * `BATCH_ROOT_SUB_INDEX_MAINNET_TIME` (arms `BATCH_SUBCOMMAND_ROOT_DISCRIMINATOR`). + * Any other identifier resolves to null and stays quiet. `consumed` names the + * constants some call resolved, so the constant pass in each collector leaves + * those to the call and publishes a prefix only for a constant no call reads. + */ +function registryCalls(scannable) { + const constants = registryConstants(scannable); + const consumed = new Set(); + const slot = (arg) => { + if (arg === undefined) return null; + if (/^\d+$/.test(arg)) return Number(arg); + if (constants.has(arg)) { consumed.add(arg); return constants.get(arg); } + return null; + }; + const callRe = /addChange\(\s*'([A-Z0-9_]+)'\s*,\s*'[0-9.]+'\s*,\s*([A-Za-z0-9_]+)(?:\s*,\s*([A-Za-z0-9_]+))?/g; + const calls = []; + for (const m of scannable.matchAll(callRe)) { + calls.push({ index: m.index, gate: m[1], mainnet: slot(m[2]), testnet: slot(m[3]) }); + } + return { calls, consumed }; +} + /** * Every mainnet time-keyed gate the indexer declares, as * `{ gate, time, source }`, sorted by time then name so the output is stable @@ -415,20 +461,24 @@ function collectGates(indexerSrc = INDEXER_SRC) { // text the check quotes in its error message. const scannable = withoutComments(registry); - // addChange('NAME', 'version', mainnet_time, ...) - const changeRe = /addChange\(\s*'([A-Z0-9_]+)'\s*,\s*'([0-9.]+)'\s*,\s*(\d+)/g; - let match; - while ((match = changeRe.exec(scannable)) !== null) { - parsedCalls.add(match.index); - parsedNames.add(match[1]); - add(match[1], Number(match[3]), 'protocol_changes.js'); + // addChange('NAME', 'version', mainnet_time, ...), the time slot a digit + // literal or a registry constant passed by name (see registryCalls). + const { calls, consumed } = registryCalls(scannable); + for (const call of calls) { + parsedCalls.add(call.index); + parsedNames.add(call.gate); + if (call.mainnet !== null) add(call.gate, call.mainnet, 'protocol_changes.js'); } // const NAME_MAINNET_TIME = 1786060800; (gates the registry declares as a - // shared constant because a second repo has to stay byte-identical to it) + // shared constant because a second repo has to stay byte-identical to it). + // A constant some call consumes is published under that call's gate name + // above; only a constant no call reads is published under its own prefix. const constRe = /const\s+([A-Z][A-Z0-9_]*)_MAINNET_TIME\s*=\s*(\d+)\s*;/g; + let match; while ((match = constRe.exec(scannable)) !== null) { parsedConstLines.add(lineAt(scannable, match.index)); + if (consumed.has(match[1] + '_MAINNET_TIME')) continue; parsedNames.add(match[1]); add(match[1], Number(match[2]), 'protocol_changes.js'); } @@ -461,11 +511,13 @@ function collectTestnetArms(indexerSrc = INDEXER_SRC) { if (!Number.isFinite(time) || time < TIMESTAMP_FLOOR || time >= SENTINEL_FLOOR) return; if (!found.has(gate)) found.set(gate, { gate, time }); }; + const { calls, consumed } = registryCalls(scannable); + for (const call of calls) if (call.testnet !== null) add(call.gate, call.testnet); let match; const constRe = /const\s+([A-Z][A-Z0-9_]*)_TESTNET_TIME\s*=\s*(\d+)\s*;/g; - while ((match = constRe.exec(scannable)) !== null) add(match[1], Number(match[2])); - const callRe = /addChange\(\s*'([A-Z0-9_]+)'\s*,\s*'[0-9.]+'\s*,\s*[A-Za-z0-9_]+\s*,\s*([1-9]\d*)/g; - while ((match = callRe.exec(scannable)) !== null) add(match[1], Number(match[2])); + while ((match = constRe.exec(scannable)) !== null) { + if (!consumed.has(match[1] + '_TESTNET_TIME')) add(match[1], Number(match[2])); + } return [...found.values()].sort((a, b) => (a.time - b.time) || a.gate.localeCompare(b.gate)); } @@ -491,11 +543,13 @@ function collectTestnetUnarmed(indexerSrc = INDEXER_SRC) { if (!Number.isFinite(time) || time < SENTINEL_FLOOR) return; if (!found.has(gate)) found.set(gate, { gate, time }); }; + const { calls, consumed } = registryCalls(scannable); + for (const call of calls) if (call.testnet !== null) add(call.gate, call.testnet); let match; const constRe = /const\s+([A-Z][A-Z0-9_]*)_TESTNET_TIME\s*=\s*(\d+)\s*;/g; - while ((match = constRe.exec(scannable)) !== null) add(match[1], Number(match[2])); - const callRe = /addChange\(\s*'([A-Z0-9_]+)'\s*,\s*'[0-9.]+'\s*,\s*[A-Za-z0-9_]+\s*,\s*(\d+)/g; - while ((match = callRe.exec(scannable)) !== null) add(match[1], Number(match[2])); + while ((match = constRe.exec(scannable)) !== null) { + if (!consumed.has(match[1] + '_TESTNET_TIME')) add(match[1], Number(match[2])); + } return [...found.values()].sort((a, b) => a.gate.localeCompare(b.gate)); } @@ -523,11 +577,13 @@ function collectMainnetUnarmed(indexerSrc = INDEXER_SRC) { if (!Number.isFinite(time) || time < SENTINEL_FLOOR) return; if (!found.has(gate)) found.set(gate, { gate, time }); }; + const { calls, consumed } = registryCalls(scannable); + for (const call of calls) if (call.mainnet !== null) add(call.gate, call.mainnet); let match; - const callRe = /addChange\(\s*'([A-Z0-9_]+)'\s*,\s*'[0-9.]+'\s*,\s*(\d+)/g; - while ((match = callRe.exec(scannable)) !== null) add(match[1], Number(match[2])); const constRe = /const\s+([A-Z][A-Z0-9_]*)_MAINNET_TIME\s*=\s*(\d+)\s*;/g; - while ((match = constRe.exec(scannable)) !== null) add(match[1], Number(match[2])); + while ((match = constRe.exec(scannable)) !== null) { + if (!consumed.has(match[1] + '_MAINNET_TIME')) add(match[1], Number(match[2])); + } return [...found.values()].sort((a, b) => a.gate.localeCompare(b.gate)); } diff --git a/components/encoder/api.md b/components/encoder/api.md index 20aaa76..404b90e 100644 --- a/components/encoder/api.md +++ b/components/encoder/api.md @@ -47,7 +47,8 @@ Errors follow JSON-RPC 2.0, returning an `error` object with a numeric `code` an | Code | Meaning | |---|---| | `-32602` | Invalid params: a parameter failed validation (TypeError or RangeError) | -| `-32603` | Internal error: encoder failure (for example insufficient UTXOs or an unsupported network) | +| `-32603` | Internal error: an unexpected encoder or node failure (for example an unsupported network, a node RPC failure, or a UTXO tracker failure on `get_utxos`) | +| `-32010` | Operational error on `create_tx` and `create_envelope_cancel_tx`: an expected, caller-actionable condition such as insufficient funds, no UTXOs, a missing change address, or an unavailable UTXO tracker. `error.data.reason` carries a stable code; the reason set is in [Error Codes](../../protocol/error-codes.md#encoder-operational-reasons) | | `-32001` | Unauthorized: missing or incorrect `x-api-key` header | | `-32029` | Rate limited: too many requests | diff --git a/components/utxo-tracker/configuration.md b/components/utxo-tracker/configuration.md index bd3dcdc..2d682d6 100644 --- a/components/utxo-tracker/configuration.md +++ b/components/utxo-tracker/configuration.md @@ -33,7 +33,7 @@ BULK_SYNC_RAM_BUDGET=768 | `UTXO_TRACKER_API_KEY` | Bearer token required on all admin JSON-RPC methods (`getbootstrap`, `getbootstrapstatus`, `restorebootstrap`, `getbootstraprestorestatus`, `get_input_from_key_pattern`). When unset these methods fail closed (HTTP 401). Read-only UTXO/balance queries are unaffected. | `""` (disabled) | | `UTXO_MAX_PAGE_LIMIT` | Maximum page size a caller may request via `?limit=`. Caps a single request so a caller cannot trigger an OOM by requesting one giant page. Independent of `UTXO_MAX_ADDRESS_OUTPUTS`. | `10000` | | `UTXO_MAX_ADDRESS_OUTPUTS` | Hard ceiling on outputs materialized for a single-address unbounded query; above this limit `/utxos` and `get_balance` return HTTP 413: callers must page via `?limit=&after=` | `500000` | -| `CORS_ORIGIN` | Allowed CORS origin. Set to a specific origin string to enable cross-origin requests. Disabled (no CORS header) when unset. | `""` (disabled) | +| `CORS_ORIGIN` | Allowed CORS origins: either one origin, or a comma-separated allowlist matched per origin, for example `capacitor://localhost,https://localhost,https://explorer.xchain.io`. Browser shells need the list form because each surface sends a different origin. Entries are trimmed and blank ones dropped, so an empty or all-blank value disables CORS (no CORS header) exactly as leaving it unset does. `*` means "any origin" only when it is the entire value; inside a list it stays a literal entry no browser sends, so `*,https://x` grants `https://x` and nothing more. | `""` (disabled) | | `XCHAIN_UNDO_BLOCKS_BTC` | Override the BTC reorg recovery window (blocks) | `12` | | `XCHAIN_UNDO_BLOCKS_LTC` | Override the LTC reorg recovery window (blocks) | `48` | | `XCHAIN_UNDO_BLOCKS_DOGE` | Override the DOGE reorg recovery window (blocks) | `120` | diff --git a/developer-guide/api-reference.md b/developer-guide/api-reference.md index 2116856..1c9f707 100644 --- a/developer-guide/api-reference.md +++ b/developer-guide/api-reference.md @@ -87,7 +87,8 @@ curl -X POST https://encoder.xchain.io/BTC/ \ | Hub | `x-api-key` required for write and admin methods; read methods are open | Per-instance, configurable | JSON-RPC errors follow the 2.0 convention with a numeric `code`: `-32602` invalid params, -`-32603` internal error, `-32001` unauthorized, `-32029` rate limited. The full registry is +`-32603` internal error, `-32001` unauthorized, `-32029` rate limited, and on the encoder +`-32010` operational error (branch on `error.data.reason`). The full registry is documented at [Error Codes](../protocol/error-codes.md). ## See also diff --git a/protocol/error-codes.md b/protocol/error-codes.md index 28af27f..27c6a8a 100644 --- a/protocol/error-codes.md +++ b/protocol/error-codes.md @@ -19,7 +19,7 @@ Errors are JSON objects: | Code | HTTP status | Meaning | Retry? | |---|---|---|---| -| `BAD_REQUEST` | 400 | The request could not be processed (malformed query, unknown lookup) | No: fix the request | +| `BAD_REQUEST` | 400 | The request could not be processed (malformed query, unknown lookup). **Historical: no explorer code path emits this code.** Every 400 now carries a narrower code: `MISSING_PARAMETER`, `INVALID_ACTION_INDEX`, `INVALID_BLOCK_INDEX`, or one of the `INVALID_*` codes further down, and an unknown lookup is a 404 `NOT_FOUND` | No: fix the request | | `MISSING_PARAMETER` | 400 | A required query parameter is absent | No: add the parameter | | `INVALID_ACTION_INDEX` | 400 | The action index is not a non-negative integer | No | | `INVALID_BLOCK_INDEX` | 400 | The block index is not a non-negative integer | No | @@ -36,6 +36,45 @@ Errors are JSON objects: | `COIN_NOT_AVAILABLE` | 503 | Coin supported but not configured for data requests here | No: use another instance | | `INDEXER_NOT_CONFIGURED` | 501 | Fee quote/schedule needs an indexer API this instance lacks | No: use another instance | | `SERVICE_UNAVAILABLE` | 503 | The endpoint cannot serve this request | No | +| `INVALID_PARAMETER` | 400 | A query or body parameter is malformed, repeated, over-long, or outside its allowed values; the generic 400 when no narrower code below applies | No: fix the request | +| `INVALID_ACTION` | 400 | The `action` parameter names no known action (fee quote and preflight routes) | No | +| `INVALID_HEIGHT` | 400 | The `height` parameter is not a non-negative integer (proof routes) | No | +| `INVALID_LIMIT` | 400 | The `limit` parameter is not a non-negative integer (checkpoint list) | No | +| `INVALID_RANGE` | 400 | `from` and `to` are not both integers, or `to` is below `from` (block-range proof) | No | +| `INVALID_CONTRACT_INDEX` | 400 | The contract index is not a non-negative integer | No | +| `INVALID_KEY_NUL` | 400 | A contract state key contains a NUL byte | No | +| `KEY_TOO_LONG` | 400 | A contract state key exceeds the VM's maximum state-key size | No | +| `STAKES_BTC_ONLY` | 400 | Validator-set proofs are served for BTC only (`stakes_root` is BTC-only) | No | +| `BAD_METHOD` | 400 | Contract simulation: `method` is missing, too long, or contains wire delimiters | No | +| `BAD_PARAMS` | 400 | Contract simulation: `params` is not an array, has too many entries, an over-long entry, or a wire delimiter | No | +| `BAD_CALLER` | 400 | Contract simulation: `caller` is not an address string | No | +| `NO_CHECKPOINT` | 404 | No quorum-signed checkpoint at or above the requested height (proof routes) | Maybe: checkpoints lag the tip | +| `ACTION_NOT_FOUND` | 404 | No such action on this server (action proof) | No | +| `CHECKPOINT_PRE_COMMITMENT` | 409 | The checkpoint predates the state-commitment flag day, so it carries no committed roots to prove against | No: choose a later height | +| `ACTION_BLOCK_NOT_CHECKPOINTED` | 409 | The action's block is not checkpointed yet, so there is no signed `block_merkle_root` to bind the proof to | Yes: after a checkpoint covers the block | +| `SNAPSHOT_NOT_YET_CHECKPOINTED` | 409 | No BTC checkpoint exists at the snapshot height yet (validator-set proof) | Yes: after the chain advances | +| `CONTRACT_STATE_NOT_COMMITTED` | 409 | `contract_state_root` is not committed at this height, so absence cannot be proven | No: choose a later height | +| `ESCROW_LEAF_NOT_COMMITTED` | 409 | The locked-balance leaf is not committed at this height, so absence cannot be proven | No: choose a later height | +| `STATE_TOO_LARGE` | 413 | Contract simulation: the contract's state exceeds the simulation limits | No | +| `SERVER_BUSY` | 429 | The explorer's global in-flight request cap is reached and the request was shed; a second 429 distinct from the per-IP `RATE_LIMITED`, with a `Retry-After` header | Yes: back off; honor `Retry-After` | +| `VM_BUSY` | 429 | Contract simulation: too many concurrent simulations, globally or from this client | Yes: back off | +| `INTERNAL_ERROR` | 500 | An unhandled failure while serving a data request | Yes: with backoff | +| `DB_ERROR` | 500 | The database query behind a data request failed | Yes: with backoff | +| `PROOF_STATE_ROOT_MISMATCH` | 500 | The committed `state_root` does not match this server's local state tree | No: the operator must investigate | +| `ACTION_LEAF_NOT_FOUND` | 500 | The action row is not present in its block's leaf set (action proof) | No: the operator must investigate | +| `PROOF_BLOCK_MERKLE_MISMATCH` | 500 | The committed `block_merkle_root` does not match this server's local block tree (action proof) | No: the operator must investigate | +| `NO_STATE_TREE` | 501 | This server does not hold the state tree (proof routes need a full indexer database) | No: use another instance | +| `INDEXER_UNAVAILABLE` | 502 | The indexer API behind a validator-set proof is unreachable | Yes: with backoff | +| `INDEXER_AUTH_REQUIRED` | 503 | The indexer API behind a validator-set proof requires a key this explorer does not carry | No: operator configuration | +| `COIN_DATA_STALE` | 503 | Indexed data for this coin is stale beyond its maximum tip age and is refused rather than served as current. Distinct from `COIN_NOT_AVAILABLE`: a client retrying `COIN_NOT_AVAILABLE` is misconfigured, one retrying `COIN_DATA_STALE` is waiting out an outage | Yes: with backoff | +| `MIRROR_NOT_CONFIGURED` | 503 | Hub-mirror self-sync is configured for this coin but no hub endpoint is set, so consensus data is refused rather than served stale | No: operator configuration | +| `MIRROR_NOT_BOOTSTRAPPED` | 503 | The hub mirror has not completed its initial bootstrap, so consensus data is unavailable rather than served empty | Yes: with backoff | +| `MIRROR_STALE` | 503 | The hub mirror is stale beyond its configured lag and fail-closed is set | Yes: with backoff | +| `VM_QUERY_DISABLED` | 503 | Contract simulation is disabled on this explorer | No: use another instance | +| `VM_QUERY_VM_DRIFT` | 503 | Contract simulation is disabled because the deployed VM is not the canonical one | No: operator action | +| `VM_MODULE_UNAVAILABLE` | 503 | Contract simulation: the VM module is not available on this host | No: use another instance | + +Errors on the Explorer WebSocket channel (`INVALID_CHANNEL`, `INVALID_TYPE`, `INVALID_ACTION`, `INVALID_PARAMS`, `SUBSCRIPTION_LIMIT`) are a separate surface with its own message shape; they are documented in [Explorer WebSocket](../components/explorer/websocket.md), not here. ## JSON-RPC services (encoder, hub, SDK API) @@ -55,6 +94,27 @@ JSON-RPC 2.0 error objects: | `-32000` | Server error | all | Yes: with backoff | | `-32001` | Unauthorized: missing/invalid API key (`x-api-key` for encoder/hub, `Authorization: Bearer` for SDK API) | all | No: fix credentials | | `-32029` | Too many requests (rate limit) | encoder | Yes: back off | +| `-32010` | Operational error: an expected, caller-actionable condition (`create_tx`, `create_envelope_cancel_tx`). `error.data.reason` carries a stable code from the table below; branch on it, never on `message` | encoder | Depends on `reason` (see below) | + +### Encoder operational reasons + +A `-32010` error always carries `error.data.reason`, a stable string that is append-only like the numeric codes, plus the reason-specific fields listed here. The `message` is encoder-authored prose and may be reworded at any time. + +| Reason | Meaning | `data` fields | Retry? | +|---|---|---|---| +| `INSUFFICIENT_FUNDS` | The selected inputs cannot cover the outputs plus fee, or every candidate input is reserved by a transaction built inside the reservation window | `required`, `available`, `outputs`, `fee`; `reservedCandidates` when every candidate is reserved | No: fund the address, or broadcast the pending transaction and wait for its change | +| `NO_UTXOS` | No UTXOs were provided and none were found for the address | none | No | +| `CHANGE_ADDRESS_REQUIRED` | The build would burn significant satoshis as fee; supply a change address | none | No: supply `change` | +| `DUPLICATE_TRANSACTION` | A transaction with the same inputs and outputs (same txid) was built inside the reservation window | `txid` | No: broadcast the one already built, or change the inputs or outputs | +| `INPUT_RESERVED` | `options.exactInputs` names outpoints reserved by a transaction built inside the reservation window | `reserved` (outpoints) | No: broadcast that transaction and rebuild, or wait for the reservation to lapse | +| `INPUT_SELECTION_RACE` | Input selection raced a concurrent reservation, so the obfuscation key is bound to an outpoint that is not the first input | `expectedFirstInput`, `actualFirstInput` | Yes: retry the request | +| `UTXO_TRACKER_ERROR` | The UTXO tracker is unreachable or returned a malformed response | none | Yes: with backoff | +| `UTXO_TRACKER_STALE` | The tracker's view lags the node past the configured threshold, or is ahead of the node (an orphaned view) | `lag`, `tracker_height`, `node_height` | Yes: with backoff | +| `UTXO_TRACKER_HALTED` | The tracker is halted (for example after an unrecoverable reorg) | `lag`, `tracker_height`, `node_height`, `halt_reason` | No: operator action | +| `UTXO_TRACKER_NOT_READY` | The tracker has not reconverged its mempool, so an already-spent confirmed output cannot be filtered | `lag`, `tracker_height`, `node_height` | Yes: with backoff | +| `ENVELOPE_RECOGNITION_UNKNOWN` | The node returned no chain height, so Taproot envelope recognition cannot be confirmed active | none | Yes: with backoff | +| `ENVELOPE_NOT_YET_ACTIVE` | Taproot envelope recognition is not active on this network yet, so the envelope is refused rather than built for decoders to ignore | `recognitionHeight`, `chainTip`, `blocksRemaining` | No: use P2WSH until the activation height | +| `ENVELOPE_CANCEL_BELOW_DUST` | The envelope-cancel sweep output would fall below the dust floor | `commitValue`, `fee`, `sweepValue` | No: spend via the reveal or CPFP | ## Where the specs live diff --git a/protocol/flag-days.md b/protocol/flag-days.md index c6bc6da..395ca48 100644 --- a/protocol/flag-days.md +++ b/protocol/flag-days.md @@ -30,7 +30,7 @@ simultaneously on Bitcoin, Litecoin, and Dogecoin. 4 gates do not ride it and carry a date of its own: `BATCH_ISSUANCE_LIMITS` at 2026-08-16 00:00:00 UTC, `CONTRACT_DELEGATION_MATERIALIZE` at 2026-09-15 00:00:00 UTC, `DISPENSER_ORACLE_PER_TOKEN_PRICE` at 2026-09-15 00:00:00 UTC, `CROSS_CHAIN_ROYALTY` at 2027-01-01 00:00:00 UTC. Each carries the reason it is armed separately in its registration comment, in the file the **Declared in** column names below. For how a gate is evaluated and what happens to a node that misses one, see [Protocol Activation](./protocol-activation.md). -**7 gates are UNARMED on mainnet** (`BATCH_COST_WEIGHTING`, `BATCH_ROOT_SUB_INDEX`, `CROSS_SETTLE_CAP`, `EMISSION_ISSUANCE_LIMITS`, `ISSUE_INHERITED_MINT_WINDOW`, `UNCAPPED_MAX_SUPPLY_ZERO`, `UNIFIED_FEES_SWEEP_CALLBACK`): each parks the sentinel rather than an instant, so mainnet has **never** run the post-activation behavior and will not until an operator names a date. They carry no row in the table below, because publishing the sentinel as a flag day would put a commitment on this page that nobody made. Each names its reason in its registration comment in `protocol_changes.js`. This note covers the registry only; a sibling `*_activation.js` module can park a mainnet sentinel too, and those are not enumerated here. +**7 gates are UNARMED on mainnet** (`BATCH_COST_WEIGHTING`, `BATCH_SUBCOMMAND_ROOT_DISCRIMINATOR`, `CROSS_SETTLE_PER_BLOCK_CAP`, `EMISSION_ISSUANCE_LIMITS`, `ISSUE_INHERITED_MINT_WINDOW`, `UNCAPPED_MAX_SUPPLY_ZERO`, `UNIFIED_FEES_SWEEP_CALLBACK`): each parks the sentinel rather than an instant, so mainnet has **never** run the post-activation behavior and will not until an operator names a date. They carry no row in the table below, because publishing the sentinel as a flag day would put a commitment on this page that nobody made. Each names its reason in its registration comment in `protocol_changes.js`. This note covers the registry only; a sibling `*_activation.js` module can park a mainnet sentinel too, and those are not enumerated here. **Testnet and regtest are genesis-active** for the time-keyed gates: they carry threshold `0`, so a testnet or regtest stack has always run the diff --git a/protocol/index-id-references.md b/protocol/index-id-references.md index 26353a1..732abba 100644 --- a/protocol/index-id-references.md +++ b/protocol/index-id-references.md @@ -12,9 +12,9 @@ it shrinks transactions and lowers fees. | Ticker | `JDOG` | `^1234` (the `index_tickers` id) | | Address | `1ExampleAddressXXXXXXXXXXXXXXXXXXX` | `^57` (the `index_addresses` id) | -The caret form is accepted anywhere the full value is accepted, EXCEPT a brand-new -value being defined for the first time (an `ISSUE` defining `TICK`, or any field that -introduces an address the network has not seen). A new value has no id yet, so it must +The caret form is accepted in every field listed below as resolved on input, EXCEPT a +brand-new value being defined for the first time (an `ISSUE` defining `TICK`, or any field +that introduces an address the network has not seen). A new value has no id yet, so it must be written in full. ## Canonical form @@ -38,14 +38,41 @@ precision. ## Where it applies +Two different questions are answered here: which fields RECEIVE an index id when an action +introduces a new value, and in which fields a `^` written on the wire is RESOLVED on +input. The first set is the consensus surface; the second is what a client may send. + **Ticker fields:** `TICK`, `GIVE_TICK`, `GET_TICK`, `DIVIDEND_TICK`, `CALLBACK_TICK`. -**Address fields:** the destination/transfer/get-address style fields of an action: +**Address fields that receive an index id:** the destination/transfer/get-address style +fields of an action: `SEND.DESTINATION`, `MINT.DESTINATION`, `MESSAGE.DESTINATION`, `SWEEP.DESTINATION`, `ISSUE.TRANSFER`, `ISSUE.TRANSFER_SUPPLY`, `DISPENSER.GET_ADDRESS`, `DISPENSER.ORACLE_ADDRESS`, `ORDER.GET_ADDRESS`, `SWAP.GET_ADDRESS`, `DEPLOY.SLASH_DESTINATION`, and `LIST.ITEM` when the list `TYPE` is address. +**Address fields where a `^` is resolved on input:** `MINT.DESTINATION`, +`MESSAGE.DESTINATION`, `SWEEP.DESTINATION`, `ISSUE.TRANSFER`, `ISSUE.TRANSFER_SUPPLY`, +`DISPENSER.GET_ADDRESS`, `DISPENSER.ORACLE_ADDRESS`, `ORDER.GET_ADDRESS`, +`SWAP.GET_ADDRESS` and `DEPLOY.SLASH_DESTINATION`. Each of these handlers resolves the +reference before its address format check. + +Two id-receiving fields are NOT resolved on input. A `^` written there is judged by +the plain address format check, so the action is rejected on chain with the fee spent: + +- `SEND.DESTINATION`: rejected as `invalid: DESTINATION (format)`. Write every `SEND` + destination in full, whether the send has one recipient or many. +- `LIST.ITEM` when the list `TYPE` is address: rejected as `invalid: ADDRESS (format)`. + Write every address list item in full. + +Two resolved-on-input fields must still be written in full by clients: +`DISPENSER.GET_ADDRESS` and `DISPENSER.ORACLE_ADDRESS`. The indexer resolves a `^` in +either, but the decoder keys dispense detection on `GET_ADDRESS` and oracle-fee +recognition on `ORACLE_ADDRESS` straight out of the payload, and it cannot resolve an id +reference because its address id space differs from the indexer's, so a compacted value +produces a dispenser that never dispenses or a create rejected as unpaid. See +[DISPENSER](./actions/dispenser.md). + Explicitly NOT address references (never compactable as `^`): - `SOURCE`: the transaction sender, taken from the transaction itself, not a payload field. @@ -61,9 +88,12 @@ from chain data alone, so the same `^` resolves to the same entity on every 1. Across actions, assignment follows `action_index`, which is total and reorg-handled. A `BATCH` sub-action has its own `action_index`, so one action is the assignment unit. 2. Within one action, the `SOURCE` address is registered first, then the new addresses - the action introduces are registered in byte-sorted (binary) order of their VALUE. - Ordering by value, not by field position, keeps the assignment stable across client - and indexer code changes. + the action introduces in its single-value fields are registered in byte-sorted (binary) + order of their VALUE. Ordering by value, not by field position, keeps the assignment + stable across client and indexer code changes. The multi-value fields + (`SEND.DESTINATION` recipients and `LIST.ITEM` entries) sit outside that pre-pass: + their handler interns them in a fixed order that is identical on every node, so they + receive deterministic ids as well. 3. Ids are assigned by an explicit dense counter (the surviving `MAX(id) + 1`), never by a database auto-increment (which does not rewind on delete). @@ -73,8 +103,9 @@ reproduces the exact same ids. This is what makes `^` safe to put on the wir id can never name two different entities across two honest nodes that reach the same tip by different reorg paths. -This assignment rule is a frozen wire rule. The set of fields above and the -value-sorted order are part of consensus; changing either is a wire-format change. +This assignment rule is a frozen wire rule. The set of id-receiving fields above, the +value-sorted order for the single-value fields and the handler order for the multi-value +fields are part of consensus; changing any of them is a wire-format change. ## SDK behavior @@ -83,7 +114,10 @@ automatically (opt out with `{ compactTickers: false }` / `{ compactAddresses: f It only ever emits a `^` for a value it has already resolved to an existing id via the explorer, and it falls back to the full value whenever an id cannot be resolved, so a client never emits an id the indexer would not recognize. Multi-recipient (array) and -type-gated list fields are left in full form by the SDK. +type-gated list fields are left in full form by the SDK, which the rules above require: +the indexer resolves no `^` in `SEND.DESTINATION` or `LIST.ITEM`. The SDK also leaves +`DISPENSER.GET_ADDRESS` and `DISPENSER.ORACLE_ADDRESS` in full form, for the decoder +reason above, even though the indexer would resolve a reference there. --- diff --git a/protocol/protocol-activation.md b/protocol/protocol-activation.md index 8bf42b8..b9f5f00 100644 --- a/protocol/protocol-activation.md +++ b/protocol/protocol-activation.md @@ -93,7 +93,7 @@ re-runs an action handler, a deploy validator, or the VM. | Service | Carries | |---|---| | `xchain-indexer` | `protocol_changes.js` (contract-era gates) + the state-commitment and validator-era activation modules | -| `xchain-vm` | the seven contract-era VM gate constants (async ban, binary-alloc metering, deploy-linter hardening, state-key NUL-reject, state-key type normalization, metering eval-order fix, call-spread metering) | +| `xchain-vm` | the seven contract-era VM gate constants (async ban, binary-alloc metering, deploy-linter hardening, state-key NUL-reject, state-key type normalization, metering eval-order fix, call-spread metering) plus three per-coin height-keyed maps: `PKG3_SANDBOX_ACTIVATION` (the armed runtime half of VM deploy-lint Pkg 3, [below](#additional-armed-gates-service-carried)), and the mainnet-unarmed `EXEC_LINT_ACTIVATION` and `LINT_GLOBAL_ALIAS_ACTIVATION` ([Unarmed VM gates](#unarmed-vm-gates-service-carried)) | | `xchain-hub` | the nine validator-era gate modules it consumes (checkpoint, equivocation header, stake-weighted quorum, anchor reward, archive reward, cross-chain royalty canonical, retraction signing, attestation relay, price signature tally). The tenth Cohort B gate, attestation admission, is indexer-only | | `xchain-decoder` | the five activation maps consumed in the decoder's own parse path: `ORACLE_FEE_OUTPUT_ACTIVATION`, `ORACLE_FEE_SET_CAPTURE_ACTIVATION`, `DISPENSER_EXPIRY_REALIGN_ACTIVATION` and `BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION` (block-time-keyed) plus `ENVELOPE_RECOGNITION_ACTIVATION` (per-chain local height) | | `xchain-sync`, `xchain-explorer`, `xchain-sdk` | the subset each needs to verify or display | @@ -181,7 +181,8 @@ gate has a second copy it is byte-identical, and that pair is the drift guard; a has no second copy, see [above](#where-the-values-live)). They are listed here so the flag-day inventory stays complete pending consolidation into the canonical file, and because [Flag-Day Values](./flag-days.md) covers only the time-keyed thresholds: every height-keyed one is -inventoried on this page. +inventoried on this page, the armed ones in this table and the mainnet-unarmed VM pair under +[Unarmed VM gates](#unarmed-vm-gates-service-carried). | Gate | Keyed on | Mainnet threshold | Straggler | Lives in | |---|---|---|---|---| @@ -212,6 +213,21 @@ contract-era timestamp, so it belongs with **Cohort A**; it is registered as a standalone twin-style module rather than a `protocol_changes.addChange` entry to keep it self-contained next to the query it gates. +## Unarmed VM gates (service-carried) + +Two further height-keyed consensus gates live in `xchain-vm` with a byte-identical indexer twin, and +neither is armed on mainnet: every mainnet entry holds `null`, the explicit unarmed sentinel, so +mainnet behaviour is byte-identical to the legacy path and stays so until an operator ratifies +per-coin train heights in BOTH copies. Testnet and regtest run both from genesis. They are listed +here rather than in the armed table above so the height-keyed inventory on this page stays complete +while their mainnet heights are still owed; arming either one is a flag day under the +[notice policy](./upgrade-notice-policy.md). + +| Gate | Keyed on | Mainnet | Straggler | Lives in | +|---|---|---|---|---| +| **Execute-time source lint** (`EXEC_LINT_ACTIVATION`, re-runs the deploy syntax validation against a contract's stored source at execute time and fails the execution deterministically when that source no longer passes the bans active for the block; the check is metered as gas, so it moves `gasUsed`) | per-chain local height | **unarmed** (`null` for BTC, LTC and DOGE, awaiting operator-ratified per-coin heights); testnet and regtest genesis-active | forks | `xchain-vm/src/index.js` (`EXEC_LINT_ACTIVATION`, resolver `isExecLintActive`); twin `xchain-indexer/src/vm_exec_lint_activation.js`, pinned to byte equality by the consensus-params suites in both repos. A height armed on one side only forks the fleet | +| **Deploy-lint global-alias refinement** (`LINT_GLOBAL_ALIAS_ACTIVATION`, makes the banned-global deploy rules resolve sloppy-mode `this` and the `globalThis` self-reference chain as reads of the same global object, which moves DEPLOY verdicts on error-severity `CONSENSUS_RULES`) | per-chain local height | **unarmed** (`null` for BTC, LTC and DOGE, awaiting operator-ratified per-coin heights); testnet and regtest genesis-active | forks | `xchain-vm/src/index.js` (`LINT_GLOBAL_ALIAS_ACTIVATION`, resolver `isLintGlobalAliasActive`); twin `xchain-indexer/src/vm_lint_global_alias_activation.js`, pinned the same way | + ## Decoder-carried gates **Five gates belong to no cohort**, and the difference is worth stating because everything above diff --git a/test/flag-day-literals.test.js b/test/flag-day-literals.test.js index 776c92e..8e90d9f 100644 --- a/test/flag-day-literals.test.js +++ b/test/flag-day-literals.test.js @@ -211,9 +211,9 @@ test('the check is structural: a legitimately value-filtered gate does not throw }); test('a call passing a collected constant by name does not throw', () => { - // The registry's own shape for the two cohort constants: declared as a - // const, then handed to addChange by identifier. The call is unreadable to - // changeRe, and nothing is lost, because the const pass already has it. + // The registry's own shape for the cohort constants: declared as a const, + // then handed to addChange by identifier. The call resolves the identifier + // through the constant map, and the gate appears exactly once. const dir = fixtureRegistry( 'const REAL_MAINNET_TIME = 1786060800;\n' + "this.addChange('REAL', '2.0.0', REAL_MAINNET_TIME, 0, 0, 0, 0, 0);\n", @@ -221,6 +221,51 @@ test('a call passing a collected constant by name does not throw', () => { assert.deepStrictEqual(gen.collectGates(dir).map((g) => g.gate), ['REAL']); }); +test('a constant passed by name publishes the gate the call registers, not the constant prefix', () => { + // The registry's two diverging pairs have this shape: the constant prefix + // is not a gate key isEnabled accepts, so publishing it names a gate that + // does not exist and hides the one that does. + const dir = fixtureRegistry( + 'const FOO_CAP_MAINNET_TIME = 1786060800;\n' + + "this.addChange('FOO_PER_BLOCK_CAP', '2.0.0', FOO_CAP_MAINNET_TIME, 0, 0, 0, 0, 0);\n", + ); + assert.deepStrictEqual(gen.collectGates(dir).map((g) => g.gate), ['FOO_PER_BLOCK_CAP'], + 'the armed table must carry the addChange gate name and never the constant prefix FOO_CAP'); +}); + +test('an unarmed sentinel passed by name is listed under the gate name', () => { + const dir = fixtureRegistry( + 'const FOO_CAP_MAINNET_TIME = 9999999999;\n' + + "this.addChange('FOO_PER_BLOCK_CAP', '2.0.0', FOO_CAP_MAINNET_TIME, 0, 0, 0, 0, 0);\n", + ); + assert.deepStrictEqual(gen.collectMainnetUnarmed(dir).map((g) => g.gate), ['FOO_PER_BLOCK_CAP'], + 'the unarmed note must name FOO_PER_BLOCK_CAP, the key an operator arms, not FOO_CAP'); +}); + +test('a testnet constant passed by name is listed under the gate name', () => { + const dir = fixtureRegistry( + 'const FOO_CAP_TESTNET_TIME = 9999999999;\n' + + 'const BAR_WINDOW_TESTNET_TIME = 1787961600;\n' + + "this.addChange('FOO_PER_BLOCK_CAP', '2.0.0', 9999999999, FOO_CAP_TESTNET_TIME, 0, 0, 0, 0);\n" + + "this.addChange('BAR_INHERITED_WINDOW', '2.0.0', 9999999999,\n" + + ' BAR_WINDOW_TESTNET_TIME, 0, 0, 0, 0);\n', + ); + assert.deepStrictEqual(gen.collectTestnetUnarmed(dir).map((g) => g.gate), ['FOO_PER_BLOCK_CAP']); + assert.deepStrictEqual(gen.collectTestnetArms(dir).map((g) => g.gate), ['BAR_INHERITED_WINDOW'], + 'a call broken across two lines still resolves its testnet slot'); +}); + +test('a constant no call consumes still reaches the page under its own prefix', () => { + // A shared constant declared for a second repo and consumed by no addChange + // call in the registry is published under its prefix, the only name it has. + const dir = fixtureRegistry( + 'const LONE_MAINNET_TIME = 1786060800;\n' + + 'const PARKED_MAINNET_TIME = 9999999999;\n', + ); + assert.deepStrictEqual(gen.collectGates(dir).map((g) => g.gate), ['LONE']); + assert.deepStrictEqual(gen.collectMainnetUnarmed(dir).map((g) => g.gate), ['PARKED']); +}); + test('a commented-out declaration is not mistaken for a live one', () => { // Written in the SHAPE THE COLLECTOR READS, single-quoted and digit-timed. // The earlier fixture double-quoted the retired name, which the diff --git a/test/vectors.test.js b/test/vectors.test.js index b53e6e3..224c608 100644 --- a/test/vectors.test.js +++ b/test/vectors.test.js @@ -27,6 +27,7 @@ const { test, describe } = require('node:test'); const constants = require('../protocol/constants.js'); const swq = require('../protocol/reference-impl/stake_weighted_quorum.js'); const eqh = require('../protocol/reference-impl/equivocation_header.js'); +const srb = require('../protocol/reference-impl/snapshot_reorg_buffer.js'); const swqVectors = require('../protocol/test-vectors/stake_weighted_quorum.json'); const eqhVectors = require('../protocol/test-vectors/equivocation_header.json'); @@ -39,6 +40,14 @@ describe('constants.js <-> reference-impl activation parity (consensus-critical) test('EQUIV_HEADER_ACTIVATION matches between constants.js and the reference impl', () => { assert.deepEqual(eqh.EQUIV_HEADER_ACTIVATION, constants.EQUIV_HEADER_ACTIVATION); }); + + test('SNAPSHOT_BURIAL_ACTIVATION matches between constants.js and the reference impl', () => { + assert.deepEqual(srb.SNAPSHOT_BURIAL_ACTIVATION, constants.SNAPSHOT_BURIAL_ACTIVATION); + }); + + test('CANONICAL_REORG_BUFFER matches between constants.js and the reference impl', () => { + assert.equal(srb.CANONICAL_REORG_BUFFER, constants.CANONICAL_REORG_BUFFER); + }); }); describe('reference-impl/stake_weighted_quorum.js (STAKE_WEIGHTED_QUORUM / WI-1)', () => { @@ -110,4 +119,5 @@ describe('reference-impl/equivocation_header.js (EQUIV_HEADER / WI-2 bump 2)', ( describe('activation predicates (KNOWN GAP: no normative vectors exist yet)', () => { test('isStakeWeightedQuorumActive: no vectors for the mainnet 960999/961000 boundary, NaN snapshotBlock, or an unknown network', { skip: true }, () => {}); test('isEquivHeaderActive: no vectors for the mainnet 960999/961000 boundary, NaN snapshotBlock, or an unknown network', { skip: true }, () => {}); + test('isSnapshotBurialActive / buriedSnapshotBlock: no vectors for the inert mainnet threshold, the empty-ish height guard, or the clamp to 0', { skip: true }, () => {}); }); diff --git a/user-guide/betting.md b/user-guide/betting.md index 64d848a..c1053f6 100644 --- a/user-guide/betting.md +++ b/user-guide/betting.md @@ -100,7 +100,7 @@ Anyone can create a market. You do not need permission, a licence from us, or a You set: - a **question** and a list of **outcomes** (between 2 and 16). Both are permanent -- the **token** wagers are made in. Betting is token-only; you cannot wager the coin itself +- the **token** wagers are made in. Betting is token-only; you cannot wager the coin itself, and you cannot use a controller-bound token: a token whose `trade` class (or the catch-all `all`) is bound to a contract is rejected when the market is created, because betting it would route around the controller's veto and its royalty legs. See [Controller-Bound Tokens](../protocol/controller-bound-tokens.md) - your **fee**, from 0% to 10% of the pot - the **deadline**, when betting closes - the **resolve window**, how long you have after the deadline to publish the result. The default is 14 days, and you may set anything from 1 hour to 1 year; a market asking for a window outside that range is rejected From ed6f3feb15c4c5a6e3680eed521bfce4be972567 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 5 Sep 2026 22:51:18 -0700 Subject: [PATCH 41/52] docs: corrections against what the code actually enforces Review-round fixes, each checked against the handler that implements it. Max supply was described as a lifetime issuance ceiling when the handlers compare against OUTSTANDING supply, so a burn reopens headroom; LOCK_MINT was described as disabling issuer supply creation; BATCH issuance limits were still taught in their pre-activation form three weeks after the mainnet gate armed; and SLEEP was described as pausing a TICK the address ISSUED when the handler gates on the OWNER, which the glossary itself defines as sellable and separate from issuance. ORACLE_BATCH_SIGN_TIMEOUT_MS is one name over two rails with two defaults: the price rail reads the validator config table and defaults to 60000, the attestation batch rail reads the environment and defaults to 15000. Both rows now say so and name each other. XCHAIN_COINBASE_MATURITY is per chain rather than a universal 100: DOGE is 240 on mainnet and testnet, 60 on regtest. Suite: 441 passing. The 2 failures are a pre-existing endpoint-count drift gate; the explorer route count is identical before and after this work. --- CONTRIBUTING.md | 4 +- MAINTAINERS.md | 2 +- architecture/component-map.md | 2 +- architecture/platform-map-app.html | 2 +- architecture/platform-map.json | 2 +- bin/generate-flag-days.js | 55 ++- components/decoder/architecture.md | 2 +- components/encoder/format-selection.md | 2 + components/explorer/api.md | 50 ++- components/explorer/architecture.md | 2 +- components/hub/README.md | 2 +- components/hub/api.md | 2 +- components/hub/architecture.md | 26 +- components/hub/configuration.md | 3 +- components/hub/database.md | 4 +- components/hub/decentralization.md | 10 +- components/hub/operations.md | 2 +- components/indexer/configuration.md | 2 +- components/sdk/actions.md | 12 +- components/sdk/batch.md | 8 +- components/sdk/encoder.md | 7 + components/sdk/errors.md | 2 +- components/sdk/light-client.md | 12 +- components/utxo-tracker/configuration.md | 2 +- components/vm/README.md | 2 +- components/vm/architecture.md | 2 +- components/wallet/ux.md | 2 +- concepts/encoding.md | 2 + concepts/gas.md | 2 +- concepts/scope-and-non-goals.md | 3 +- concepts/security-model.md | 4 +- concepts/smart-contracts.md | 10 +- developer-guide/batch-operations.md | 20 +- developer-guide/build-your-first-token.md | 4 +- developer-guide/smart-contract-development.md | 14 +- developer-guide/solidity-to-xchain.md | 8 +- getting-started/key-terms.md | 4 +- lib/env-var-doc-coverage.js | 114 ++++- operations/run-a-validator.md | 13 +- protocol/actions/address.md | 2 +- protocol/actions/anchor.md | 33 +- protocol/actions/batch.md | 10 +- protocol/actions/callback.md | 2 +- protocol/actions/dispenser.md | 2 +- protocol/actions/file.md | 3 +- protocol/actions/issue.md | 8 +- protocol/actions/list.md | 7 +- protocol/constants.js | 9 +- protocol/controller-bound-tokens.md | 50 ++- protocol/error-codes.md | 7 +- protocol/index-id-references.md | 20 +- protocol/json/README.md | 10 +- ...n-information-standard-v1.1.0-example.json | 170 +++++++ ...en-information-standard-v1.1.0-schema.json | 421 ++++++++++++++++++ protocol/nft-standard.md | 27 +- protocol/project-registry.md | 12 +- protocol/protocol-activation.md | 26 +- protocol/taproot-envelope.md | 30 +- protocol/token-gated-content.md | 7 +- protocol/token-information-standard.md | 32 +- test/consensus-wall-clock-claims.test.js | 6 +- .../contract-state-proof-availability.test.js | 113 +++++ test/env-var-doc-coverage.test.js | 133 ++++++ test/fee-and-limit-claims.test.js | 35 ++ test/flag-day-literals.test.js | 80 ++++ test/internal-link-integrity.test.js | 85 ++++ test/settlement-and-delivery-claims.test.js | 304 +++++++++++++ test/supply-lock-claims.test.js | 126 ++++++ test/tis-schema-field-coverage.test.js | 213 +++++++++ user-guide/betting.md | 2 +- user-guide/creating-tokens.md | 16 +- user-guide/cross-chain.md | 2 + user-guide/faq.md | 4 +- user-guide/trading.md | 14 +- user-guide/use-cases.md | 6 +- whitepaper.md | 10 +- 76 files changed, 2268 insertions(+), 190 deletions(-) create mode 100644 protocol/json/token-information-standard-v1.1.0-example.json create mode 100644 protocol/json/token-information-standard-v1.1.0-schema.json create mode 100644 test/contract-state-proof-availability.test.js create mode 100644 test/settlement-and-delivery-claims.test.js create mode 100644 test/supply-lock-claims.test.js create mode 100644 test/tis-schema-field-coverage.test.js diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index be39d05..7167914 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -91,7 +91,7 @@ Before writing the PR: 3. **Describe the consensus impact.** Explain whether the change is additive (no existing behavior changes), clarifying (no behavior changes, but removes ambiguity), or consensus-breaking (a compliant implementation would behave differently after the change). 4. **Consensus-breaking changes need sign-off from implementing service maintainers** before the PR merges. This means the teams responsible for `xchain-decoder`, `xchain-indexer`, and any other service the spec governs. Tag them in the issue. -Once the issue has consensus, open the PR against `master`. Keep one logical change per PR; don't batch unrelated spec edits. +Once the issue has consensus, open the PR against `develop`. GitHub preselects `master` as the base, so change it before you submit. Keep one logical change per PR; don't batch unrelated spec edits. --- @@ -137,7 +137,7 @@ All byte-level encoding examples and on-chain data samples in the spec must matc Match the existing log style: a concise subject line, then a short body explaining what changed and why. -- Branch off `master` and keep history linear (rebase, don't merge). +- Branch off `develop` and keep history linear (rebase, don't merge). `develop` is where work lands; `master` only ever receives release merges (see [`operations/release-process.md`](./operations/release-process.md)). - One logical change per commit; don't batch unrelated edits. - **No `Co-Authored-By` trailers.** This is a project policy. - **Never `--no-verify`.** If a hook fails, fix the cause; don't bypass it. diff --git a/MAINTAINERS.md b/MAINTAINERS.md index a83f094..a7ef4b3 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -63,7 +63,7 @@ If you cannot reach the relevant area maintainer within a reasonable window: | Situation | Escalate to | |---|---| | Active security incident | `security@dankest.llc` (per `SECURITY.md`) | -| A documented consensus rule, encoding, or validation requirement that is unsafe or ambiguous | Open a public issue tagged `security` AND email `security@dankest.llc` | +| A documented consensus rule, encoding, or validation requirement that is unsafe or ambiguous | GitHub Private Vulnerability Reporting, or `security@dankest.llc`; do not open a public issue (per `SECURITY.md`) | | Code-of-conduct concern | `conduct@dankest.llc` (per `CODE_OF_CONDUCT.md`) | | PR has been open without review for 14+ days | Comment `@J-Dog` on the PR; if no response within 7 more days, open an issue tagged `governance` with the PR link | diff --git a/architecture/component-map.md b/architecture/component-map.md index 2c78a6a..b8817dd 100644 --- a/architecture/component-map.md +++ b/architecture/component-map.md @@ -218,7 +218,7 @@ Key technical details: - Operates in two modes: standalone (simple config oracle) and validator mode (full PBFT consensus, P2P gossip, oracle, cross-chain attestation, governance). - Supports multi-instance deployment, multiple hub instances against shared MariaDB, with consumer fallback via `HUB_VALIDATORS`. -- Config writes go through PBFT consensus in validator mode (PRE_PREPARE → PREPARE → COMMIT with a `max(2f+1, ceil((N+1)/2))` quorum). +- Config writes go through PBFT consensus in validator mode (PRE_PREPARE → PREPARE → COMMIT, reaching a federation quorum that is stake-weighted and source-deduped at/above `STAKE_WEIGHTED_QUORUM_ACTIVATION` and the legacy `max(2f+1, ceil((N+1)/2))` signer count below it). - Decentralized price oracle: validators fetch from CoinGecko and Kraken (CoinMarketCap optional, requires API key), aggregate via trimmed median (discard top/bottom 15%), finalize via PBFT. - Cross-chain attestation engine with per-chain-pair validator subsets and confirmation thresholds (BTC: 6, LTC: 12, DOGE: 60; env-tunable via `XCHAIN_CONFIRMATIONS_`). - SWAP lifecycle tracking: initiated → attested → executed → settled. diff --git a/architecture/platform-map-app.html b/architecture/platform-map-app.html index 5529690..927dbe4 100644 --- a/architecture/platform-map-app.html +++ b/architecture/platform-map-app.html @@ -245,7 +245,7 @@

Flows

{ "id": "indexer", "label": "xchain-indexer", "type": "service", "group": "index", "tech": "Node.js, MariaDB", "description": "Reads the decoder DB and applies ACTION semantics: double-entry ledger, XCHAIN gas fees, orders/swaps/dispensers, staking capabilities, attestation validation, system-injected expiries. Executes contracts through xchain-vm and follows decoder reorgs." }, { "id": "indexerdb", "label": "Indexer DB", "type": "database", "group": "index", "tech": "MariaDB", "description": "Canonical protocol state: balances, tokens, orders, contracts, contract_state (append-only), staking, attestation and SPV tables." }, { "id": "vm", "label": "xchain-vm", "type": "library", "group": "index", "tech": "Node.js, isolated-vm", "description": "Deterministic smart-contract engine: sandboxed V8 isolates, host-side gas metering, 30s CPU / 8MB memory / call depth 4 limits, hardened sandbox (constructor neutering, RegExp removal). Emits actions (XCALL, ATTEST) back to the indexer." }, - { "id": "hub", "label": "xchain-hub", "type": "service", "group": "hub", "tech": "Node.js, MariaDB", "description": "Config oracle and cross-chain coordinator. PBFT federation (majority-floored quorum max(2f+1, ceil((N+1)/2))), five stake-qualified capabilities, price-oracle rounds, attestation engine (http_get / llm providers), reorg-retraction co-signing, governance/slash, and StateAnchorPublisher (ANCHOR v7 bundle on DOGE)." }, + { "id": "hub", "label": "xchain-hub", "type": "service", "group": "hub", "tech": "Node.js, MariaDB", "description": "Config oracle and cross-chain coordinator. PBFT federation (stake-weighted source-deduped quorum at/above STAKE_WEIGHTED_QUORUM_ACTIVATION, majority-floored count max(2f+1, ceil((N+1)/2)) below it), five stake-qualified capabilities, price-oracle rounds, attestation engine (http_get / llm providers), reorg-retraction co-signing, governance/slash, and StateAnchorPublisher (ANCHOR v7 bundle on DOGE)." }, { "id": "hubdb", "label": "Hub DB", "type": "database", "group": "hub", "tech": "MariaDB", "description": "About 20 tables: configs, validators, consensus state, cross-chain calls, price_snapshots, oracle_prices, attestation stats." }, { "id": "explorer", "label": "xchain-explorer", "type": "service", "group": "serve", "tech": "Node.js, Express, WS", "description": "Read-only REST + JSON-RPC + WebSocket API and web UI over indexer state (60+ endpoints, /{COIN} prefixed). Runs a hub-mirror sync for consensus tables, ABI introspection, and an optional sandboxed contract-simulation endpoint." }, { "id": "sync", "label": "xchain-sync", "type": "service", "group": "serve", "tech": "Node.js, WS", "description": "Replicates indexer + decoder DBs to validators: REST snapshots plus a WebSocket block feed, transactional apply with rollback, merkle transparency log (sync_meta, merkle_epochs) and pinned-validator checkpoint quorum verification." }, diff --git a/architecture/platform-map.json b/architecture/platform-map.json index d1d6fd1..5ea8c2d 100644 --- a/architecture/platform-map.json +++ b/architecture/platform-map.json @@ -98,7 +98,7 @@ "type": "service", "group": "hub", "tech": "Node.js, MariaDB", - "description": "Config oracle and cross-chain coordinator. PBFT federation (majority-floored quorum max(2f+1, ceil((N+1)/2))), five stake-qualified capabilities, price-oracle rounds, attestation engine (http_get / llm providers), reorg-retraction co-signing, governance/slash, and StateAnchorPublisher (ANCHOR v7 bundle on DOGE)." + "description": "Config oracle and cross-chain coordinator. PBFT federation (stake-weighted source-deduped quorum at/above STAKE_WEIGHTED_QUORUM_ACTIVATION, majority-floored count max(2f+1, ceil((N+1)/2)) below it), five stake-qualified capabilities, price-oracle rounds, attestation engine (http_get / llm providers), reorg-retraction co-signing, governance/slash, and StateAnchorPublisher (ANCHOR v7 bundle on DOGE)." }, { "id": "hubdb", diff --git a/bin/generate-flag-days.js b/bin/generate-flag-days.js index e77ef7d..c60bc9f 100644 --- a/bin/generate-flag-days.js +++ b/bin/generate-flag-days.js @@ -409,20 +409,63 @@ function registryConstants(scannable) { * Any other identifier resolves to null and stays quiet. `consumed` names the * constants some call resolved, so the constant pass in each collector leaves * those to the call and publishes a prefix only for a constant no call reads. + * + * THE SLOT PATTERN READS THE WHOLE ARGUMENT, to its `,` or `)`, and never a + * leading run of it. Capturing `([A-Za-z0-9_]+)` asserted no terminator, so two + * shapes went wrong in the two directions this generator exists to prevent: + * `1_786_060_800` matched whole, failed the digits test, resolved to null, and + * the gate left the page in silence; `1786060800 + 86400` matched only its + * PREFIX and published an instant a day early. Neither reached + * `assertEveryDeclarationParsed`, because `collectGates` records the call in + * `parsedCalls` and its gate in `parsedNames` whether or not the slot resolved, + * and the check skips on exactly those two sets. Both were reproduced against + * fixtures before this was written; the live registry carries neither shape + * today, so this closes a latent hole rather than correcting a published row. + * + * REFUSAL LIVES HERE rather than in the completeness check, because + * `collectTestnetArms`, `collectTestnetUnarmed` and `collectMainnetUnarmed` + * read the same calls and have no completeness check behind them: a shape this + * parse cannot read has to be loud on every arm or it is loud on one. */ function registryCalls(scannable) { const constants = registryConstants(scannable); const consumed = new Set(); - const slot = (arg) => { + const slot = (arg, gate, index) => { if (arg === undefined) return null; - if (/^\d+$/.test(arg)) return Number(arg); - if (constants.has(arg)) { consumed.add(arg); return constants.get(arg); } - return null; + const text = arg.trim(); + if (text === '') return null; + + // A decimal literal, separators and all. Written as the separator + // grammar rather than a `_`-strip so `_1786060800` stays an identifier: + // stripping first reads a leading-underscore NAME as a number. + if (/^\d(?:_?\d)*$/.test(text)) return Number(text.replace(/_/g, '')); + + // An identifier: the registry constant's value when the const pass saw + // it, and otherwise quiet by design, because no text scan can tell a + // parked sentinel from a live timestamp behind a name. + if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(text)) { + if (constants.has(text)) { consumed.add(text); return constants.get(text); } + return null; + } + + throw new Error( + `protocol_changes.js line ${lineAt(scannable, index)}: the ${gate} gate passes a time slot ` + + `this generator cannot read (\`${text}\`), so protocol/flag-days.md would publish an ` + + 'inventory that calls itself complete and is not.\n\n' + + 'A time slot reads as a decimal literal (`1786060800`, separators allowed) or as the name ' + + 'of a `const NAME_MAINNET_TIME = ;` the registry declares. Write the slot in one of ' + + 'those shapes or widen the parse in bin/generate-flag-days.js deliberately.', + ); }; - const callRe = /addChange\(\s*'([A-Z0-9_]+)'\s*,\s*'[0-9.]+'\s*,\s*([A-Za-z0-9_]+)(?:\s*,\s*([A-Za-z0-9_]+))?/g; + const callRe = /addChange\(\s*'([A-Z0-9_]+)'\s*,\s*'[0-9.]+'\s*,\s*([^,)]+)(?:\s*,\s*([^,)]+))?/g; const calls = []; for (const m of scannable.matchAll(callRe)) { - calls.push({ index: m.index, gate: m[1], mainnet: slot(m[2]), testnet: slot(m[3]) }); + calls.push({ + index: m.index, + gate: m[1], + mainnet: slot(m[2], m[1], m.index), + testnet: slot(m[3], m[1], m.index), + }); } return { calls, consumed }; } diff --git a/components/decoder/architecture.md b/components/decoder/architecture.md index eb069d0..6a16f16 100644 --- a/components/decoder/architecture.md +++ b/components/decoder/architecture.md @@ -94,7 +94,7 @@ After parsing, the decoder scans each transaction's outputs looking for XChain p | P2WSH | OP_RETURN decrypts to `XCHNp2wsh` marker | Reassembled from witness scripts across all inputs' witness data | | 1-of-3 Multisig | 6-element decompiled script with OP_1...OP_CHECKMULTISIG | Data packed into pubkeys 1 & 2 (first byte stripped), trailing zeros removed | -The fifth format, the [Taproot envelope](../../protocol/taproot-envelope.md), is not an output scan. The decoder reads input 0's witness stack from the end (control block last, script second-to-last, per BIP341) and pattern-matches the script against the envelope grammar `OP_FALSE OP_IF <"XCHN"> OP_ENDIF`, reassembling the payload from its 520-byte elements. The magic and format byte are cleartext, so recognition costs no deobfuscation attempt. Recognition is height-gated per chain and never fires on Dogecoin, which has no SegWit and therefore no Taproot. The action is attributed to the address that funded the commit transaction, the same walk-back the P2SH and P2WSH reveals use. +The fifth format, the [Taproot envelope](../../protocol/taproot-envelope.md), is not an output scan. The decoder reads input 0's witness stack from the end (control block last, script second-to-last, per BIP341) and pattern-matches the script against the envelope grammar `OP_FALSE OP_IF <"XCHN"> OP_ENDIF`, reassembling the payload from its 520-byte elements. The payload walk takes data pushes ONLY, so an element that canonicalizes to a bare opcode (a lone `0x01`-`0x10` or `0x81` byte, which `bitcoin.script.decompile` hands back as `OP_1`-`OP_16` / `OP_1NEGATE`) stops the walk early, fails the following `OP_ENDIF` check, and makes the reveal not an envelope; conforming encoders never emit that shape, rebalancing the final two pushes to `(n-1, 2)` instead. The P2SH and P2WSH chunk lanes carry the identical gate, since their redeem-script read requires a data push at position 0. See [Taproot envelope: no payload push may canonicalize to a bare opcode](../../protocol/taproot-envelope.md#no-payload-push-may-canonicalize-to-a-bare-opcode). The magic and format byte are cleartext, so recognition costs no deobfuscation attempt. Recognition is height-gated per chain and never fires on Dogecoin, which has no SegWit and therefore no Taproot. The action is attributed to the address that funded the commit transaction, the same walk-back the P2SH and P2WSH reveals use. ## AES-128-CTR Deobfuscation diff --git a/components/encoder/format-selection.md b/components/encoder/format-selection.md index 5215315..0767cb7 100644 --- a/components/encoder/format-selection.md +++ b/components/encoder/format-selection.md @@ -45,6 +45,8 @@ The payload is embedded in one or more redeem scripts. Payloads larger than a si Both transactions must be broadcast in order. The decoder reads the spend transaction's scriptSig(s) to reassemble the payload. See [Encoding](../../concepts/encoding.md) for the canonical chunking model. +**Degenerate-final-chunk rebalance.** No chunk may be a single byte in `0x01`-`0x10` or `0x81`: a script encoder turns such a byte into the bare opcode `OP_1`-`OP_16` / `OP_1NEGATE`, and the decoder, which requires a data push, skips that output and reassembles a corrupted payload. When the split would produce such a final chunk, the encoder rebalances the last two chunks to `(n-1, 2)` bytes. The rule applies identically to P2WSH and to the Taproot envelope's 520-byte pushes, where it is [normative](../../protocol/taproot-envelope.md#no-payload-push-may-canonicalize-to-a-bare-opcode). + P2SH is the auto-selected format for any payload above the 76-byte OP_RETURN limit: larger ISSUE operations, BATCH commands that combine multiple actions, or any action with additional fields. ### P2WSH: up to 8,192 bytes diff --git a/components/explorer/api.md b/components/explorer/api.md index 9d78984..16efaa9 100644 --- a/components/explorer/api.md +++ b/components/explorer/api.md @@ -1551,10 +1551,20 @@ Re-verifies the checkpoint at `blockIndex` server-side and returns everything a |---|---| | `checkpoint` | Raw checkpoint row (block_index, block_hash, ledger_hash, actions_hash, ...) | | `canonical` | Canonical signing payload (pipe-delimited string over checkpoint fields) | -| `validators` | Array of validator pubkeys that signed this checkpoint | -| `quorum` | Required signature count, the majority-floored PBFT threshold `max(2f+1, ceil((N+1)/2))` | +| `validators` | The `oracle_publish` snapshot set at the checkpoint's `snapshot_block`, as `{ pubkey, weight, source }` objects (not a list of the pubkeys that signed). `weight` is the source's snapshot stake as a decimal string, or `null` when the mirror row carries no amount, which a re-deriving client must treat as fail-closed rather than as zero | +| `is_weighted` | `true` when the checkpoint's `snapshot_block` is at or above `STAKE_WEIGHTED_QUORUM_ACTIVATION`, so the stake-weighted quorum rule applies instead of the count rule | +| `quorum` | The legacy count threshold `max(2f+1, ceil((N+1)/2))` over the snapshot set. It decides the verdict only while `is_weighted` is `false`; it is still reported when `is_weighted` is `true`, where it is informational | | `valid_sigs` | Count of signatures that verified successfully | -| `verified` | `true` when `valid_sigs >= quorum` | +| `verified` | The server's verdict. When `is_weighted` is `false`: `valid_sigs >= quorum`. When `is_weighted` is `true`: the SOURCE-DEDUPLICATED stake predicate over the valid signers, `3 x tally > 2 x S` (see [ANCHOR: Version 0/1](../../protocol/actions/anchor.md#version-0-1)). Either way it is also `false` when `commitment_missing` is `true` | +| `commitment_missing` | `true` when a post-flag-day checkpoint row is missing a commitment field, so the verdict is STRUCTURAL rather than a signature shortfall | +| `snapshot_available` | `false` when this explorer holds no `oracle_publish` snapshot for the checkpoint's `snapshot_block`. The signatures may still be valid; the client can verify them elsewhere | +| `signatures_unparseable` | `true` when the stored `validator_signatures` blob could not be parsed | + +**A light client must not verify a checkpoint as `valid_sigs >= quorum`.** That is the +pre-activation rule only. At or above `STAKE_WEIGHTED_QUORUM_ACTIVATION` a count-based +verifier accepts signature sets that carry under two thirds of the stake, which the +network rejects. Read `is_weighted` and apply the matching rule, using the `weight` and +`source` fields on `validators`. Returns HTTP 404 with `{ "error": "No checkpoint at this height", "code": "CHECKPOINT_NOT_FOUND" }` when no checkpoint exists at the requested height. @@ -1721,11 +1731,39 @@ GET /BTC/api/proof/validator-set?height={snapshotBlock}[&capabilities=oracle_pub ### Contract-State Proof +Returns an SMT inclusion / non-inclusion proof for one contract key against the committed `contract_state_root` sub-root, plus the sub-root path that binds it into the checkpoint's `state_root`. + ``` -GET /{COIN}/api/proof/contract-state/{contractIndex}/{key} +GET /{COIN}/api/proof/contract-state/{contractIndex}/{key}[?height={H}] ``` -**Status: Not yet implemented.** The contract state root is committed as EMPTY in `state_root_version` 1 (spec D1). This endpoint returns HTTP 501 with code `UNSUPPORTED_VERSION` until a future protocol version activates contract-state commitments. +**Parameters:** + +| Parameter | Location | Description | +|---|---|---| +| `contractIndex` | path | The contract index number | +| `key` | path | The contract state key, percent-encoded; may not contain a NUL byte and is capped at 1024 bytes | +| `height` | query | Optional; the proof binds to the first checkpoint at or above this height | + +A key with no value at the checkpoint height returns `leaf_value: null` (non-inclusion proof) and `state_value: null`. A tombstoned key is indistinguishable from an absent one, exactly as it is in the commitment. + +**Error codes:** + +| HTTP | Code | Meaning | +|---|---|---| +| 400 | `INVALID_CONTRACT_INDEX` | `contractIndex` is not a non-negative integer | +| 400 | `MISSING_PARAMETER` | No state key was supplied | +| 400 | `INVALID_KEY_NUL` | The state key contains a NUL byte | +| 400 | `KEY_TOO_LONG` | The state key exceeds 1024 UTF-8 bytes | +| 400 | `INVALID_HEIGHT` | `height` is not a non-negative integer | +| 404 | `UNKNOWN_COIN` | Coin prefix not configured on this explorer | +| 404 | `NO_CHECKPOINT` | No signed checkpoint at or above the requested height | +| 409 | `CHECKPOINT_PRE_COMMITMENT` | Checkpoint predates the state-commitment activation (no committed roots) | +| 409 | `CONTRACT_STATE_NOT_COMMITTED` | The `contract_state_root` slot is not committed at this height, so absence cannot be proven | +| 501 | `NO_STATE_TREE` | Server does not hold the state tree; point a full indexer DB at this instance | +| 500 | `PROOF_STATE_ROOT_MISMATCH` | Server state tree disagrees with the signed checkpoint | + +**Availability.** The route is registered and served on every explorer instance. It returns a proof only where the `contract_state_root` slot is armed for that chain and height, and the typed 409 `CONTRACT_STATE_NOT_COMMITTED` below it. The slot is armed on BTC regtest and from genesis on the BTC, LTC and DOGE testnets; mainnet is deliberately unarmed pending its flag day, so a mainnet request gets the typed 409 rather than a proof. --- @@ -2094,7 +2132,7 @@ Content-Type: application/json | `GET /{COIN}/api/proof/balance/{address}/{tick}` | SMT balance inclusion/non-inclusion proof | | `GET /{COIN}/api/proof/action/{actionIndex}` | Block-content inclusion proof for an action | | `GET /BTC/api/proof/validator-set` | Stake-weighted validator-set proof (BTC-only) | -| `GET /{COIN}/api/proof/contract-state/{idx}/{key}` | Contract-state proof (reserved; HTTP 501 in v1) | +| `GET /{COIN}/api/proof/contract-state/{idx}/{key}` | Contract-state SMT inclusion/non-inclusion proof (409 where the slot is unarmed) | | `GET /{COIN}/api/feequote` | Native-coin fee pre-flight quote | | `GET /{COIN}/api/feeschedule` | Native-coin fee schedule | | `GET /{COIN}/api/preflight` | Validity-first action pre-flight, independent of fee support | diff --git a/components/explorer/architecture.md b/components/explorer/architecture.md index dee49c5..4fab33b 100644 --- a/components/explorer/architecture.md +++ b/components/explorer/architecture.md @@ -187,7 +187,7 @@ The `ProofServer` class (`src/proofServer.js`) serves read-only Merkle proofs fo GET /{COIN}/api/proof/balance/:address/:tick - SMT balance inclusion / non-inclusion proof GET /{COIN}/api/proof/action/:actionIndex - Per-block fixed-Merkle inclusion proof GET /{COIN}/api/proof/validator-set - Stake-weight SMT proofs (BTC-only) -GET /{COIN}/api/proof/contract-state/:idx/:key - Reserved; returns 501 in state_root_version 1 +GET /{COIN}/api/proof/contract-state/:idx/:key - Contract-state SMT proof; 409 where the slot is unarmed GET /{COIN}/api/checkpoints/range - Forward-ordered checkpoint slice for light-client sync ``` diff --git a/components/hub/README.md b/components/hub/README.md index 4163bd0..443b243 100644 --- a/components/hub/README.md +++ b/components/hub/README.md @@ -15,7 +15,7 @@ The hub operates in two modes. In **standalone mode** (no `P2P_VALIDATOR_ADDR` s - **Config store**: JSON-RPC API for service configuration parameters used by all platform services - **Service discovery**: other services poll the hub to find hostnames, ports, and connection details for their dependencies -- **PBFT consensus**: config writes go through a PRE_PREPARE → PREPARE → COMMIT consensus round requiring a `max(2f+1, ceil((N+1)/2))` quorum (the majority floor keeps a small federation from collapsing to a single signer) +- **PBFT consensus**: config writes go through a PRE_PREPARE → PREPARE → COMMIT consensus round requiring a federation quorum: stake-weighted and source-deduped at/above `STAKE_WEIGHTED_QUORUM_ACTIVATION`, otherwise the legacy `max(2f+1, ceil((N+1)/2))` signer count (whose majority floor keeps a small federation from collapsing to a single signer). See [decentralization: Quorum](decentralization.md#quorum) - **P2P gossip**: WebSocket-based peer mesh with heartbeat, exponential backoff reconnection, and message deduplication - **Ed25519 validator identity**: cryptographic message signing and verification using Node.js built-in crypto - **Leader rotation**: deterministic per-sequence leader from sorted validator set with view change on timeout diff --git a/components/hub/api.md b/components/hub/api.md index fe9f793..0111ae0 100644 --- a/components/hub/api.md +++ b/components/hub/api.md @@ -1154,7 +1154,7 @@ ANCHOR publisher status (read, no auth): cumulative anchor counts plus the last- } ``` -`anchorsPublished` counts published **bundles**, one per network per cycle, not one per chain. `sectionsAnchored` counts the per-chain checkpoint sections inside them, so a healthy three-chain federation advances it by three for every bundle. `bundlesOversize` counts cycles refused because a single checkpoint section could not fit the 8189-byte wire budget even with an empty attestation tail; it should stay at 0, and a non-zero value means the federation has outgrown the budget and the anchor for that cycle was not sent. +`anchorsPublished` counts published **bundles**, one per network per cycle, not one per chain. `sectionsAnchored` counts the per-chain checkpoint sections inside them, so a healthy three-chain federation advances it by three for every bundle. `bundlesOversize` counts refusals against the 8189-byte wire budget: a single checkpoint section that cannot fit alongside the attestation tail its bundle will carry, or an assembled payload measured over the budget just before broadcast. It should stay at 0, and a non-zero value means the federation has outgrown the budget and the anchor for that cycle was not sent. `dogeBalance`/`dogeBalanceAt` are `null` until the first publish cycle reads the wallet (or when no DOGE pipeline is configured). diff --git a/components/hub/architecture.md b/components/hub/architecture.md index 587a3c9..fe9b2ab 100644 --- a/components/hub/architecture.md +++ b/components/hub/architecture.md @@ -135,7 +135,7 @@ flowchart TD | `OraclePublisher.js` | `OraclePublisher` | `oracle_publish` capability publisher: deterministic leader rotation, persistent JSONL queue, builds PRICE v0 wire format, broadcasts to DOGE via the encoder pipeline, monitors DOGE balance | | `EncoderClient.js` | `EncoderClient` | Minimal JSON-RPC client for talking to xchain-encoder (`get_utxos`, `create_tx`, `broadcast_tx`): used by `OraclePublisher` | | `HubDbBroadcaster.js` | `HubDbBroadcaster` | WebSocket subscriber registry; broadcasts `row:inserted` events from `PriceAggregator`, `StateCheckpointEngine`, `CrossChainDexEngine`, and `CrossChainCallEngine` to all connected indexers' `HubDbSync` clients | -| `StateCheckpointEngine.js` | `StateCheckpointEngine` | Quorum-signed per-chain ledger/actions/contract hash checkpoints: cadence-leader reads each chain's block-hash triple, collects XCHK_SIGN from peers, finalizes at the majority-floored quorum `max(2f+1, ceil((N+1)/2))`, writes to `state_checkpoints`, streams via `HubDbBroadcaster`, emits `checkpoint:finalized` | +| `StateCheckpointEngine.js` | `StateCheckpointEngine` | Quorum-signed per-chain ledger/actions/contract hash checkpoints: cadence-leader reads each chain's block-hash triple, collects XCHK_SIGN from peers, finalizes at the federation quorum for the checkpoint's snapshot block (stake-weighted and source-deduped at/above `STAKE_WEIGHTED_QUORUM_ACTIVATION`, otherwise the majority-floored count `max(2f+1, ceil((N+1)/2))`; see [Quorum](#quorum)), writes to `state_checkpoints`, streams via `HubDbBroadcaster`, emits `checkpoint:finalized` | | `StateAnchorPublisher.js` | `StateAnchorPublisher` | Checkpoint-bundle anchor publisher: listens for `checkpoint:finalized`, batches `cross_chain_matches` archive, and commits every checkpointed chain in ONE DOGE [ANCHOR v0](../../protocol/actions/anchor.md) action per network per publishing cycle (one section per chain, one publisher election per bundle), plus the archive, on the `ANCHOR_INTERVAL_MS` cadence | | `FullNodeChallengeRound.js` | `FullNodeChallengeRound` | Challenge-response rounds that verify `full_node` capability claimants. The elected leader issues a block-hash challenge; each claimant broadcasts its computed answer (`XNODE_ANSWER`); the leader proposes the pass list (`XNODE_SIGN_REQ`); verifiers independently recompute and co-sign (`XNODE_SIGN`); results are finalized on-chain via `XNODE_DONE`. Pass rate feeds into the full-node reward tier. | | `AttestationPublisher.js` | `AttestationPublisher` | Subscribes to `AttestationConsensus` `request:finalized` events and ships the on-chain ATTEST v1 (response) wire payload via an operator-provided hook. Writes a durable JSONL write-ahead log before any broadcast; the leader broadcasts immediately, followers step in after `failoverWindowBlocks` blocks using a rank-staggered backoff. | @@ -260,7 +260,7 @@ All types below ride the envelope above; only the `data` payload differs. Every | `ATTEST_PROPOSE` / `ATTEST_PREPARE` / `ATTEST_COMMIT` | `AttestationConsensus` | PBFT-style consensus over external attestation responses. | | `XCHAIN_ATTEST_PROPOSE` / `XCHAIN_ATTEST_PREPARE` / `XCHAIN_ATTEST_COMMIT` | `CrossChainEngine` | Consensus over cross-chain action confirmations. | | `XCALL_RELAY_PROPOSE` / `XCALL_RELAY_PREPARE` / `XCALL_RELAY_COMMIT` / `XCALL_RELAY_VIEW_CHANGE` / `XCALL_RELAY_NEW_VIEW` / `XCALL_RELAY_FINAL_SYNC` | `CrossChainCallEngine` | PBFT consensus to quorum-sign cross-chain contract call relay rows (`cross_chain_calls`). Reuses the DEX consensus engine with parameterized message types. | -| `XCHK_SIGN_REQ` / `XCHK_SIGN` / `XCHK_FINALIZED` | `StateCheckpointEngine` | Collect `max(2f+1, ceil((N+1)/2))` validator signatures over per-chain ledger/actions/contract hash checkpoints. | +| `XCHK_SIGN_REQ` / `XCHK_SIGN` / `XCHK_FINALIZED` | `StateCheckpointEngine` | Collect a quorum of validator signatures over per-chain ledger/actions/contract hash checkpoints (see [Quorum](#quorum)). | | `XANC_SIGN_REQ` / `XANC_SIGN` / `XANC_FINALIZED` / `XANC_BUNDLE_DONE` | `StateAnchorPublisher` | Co-sign the on-chain ANCHOR payload (checkpoint bundle + archive). `XANC_BUNDLE_DONE` carries the txid and the bundle's section list, back-filling `anchor_txid` on every peer's checkpoint rows to prevent duplicate anchoring. | | `XNODE_ANSWER` / `XNODE_SIGN_REQ` / `XNODE_SIGN` / `XNODE_DONE` | `FullNodeChallengeRound` | Full-node challenge-response protocol. Claimants broadcast their computed answer (`XNODE_ANSWER`); the elected leader proposes the pass list (`XNODE_SIGN_REQ`); eligible verifiers co-sign after recomputing independently (`XNODE_SIGN`); the leader finalizes and broadcasts results (`XNODE_DONE`). | @@ -294,15 +294,27 @@ Leader for sequence `N` = `validatorSet[(N + view) % validatorCount]`, where val If the leader fails to drive consensus within `PBFT_TIMEOUT` (default 30s): 1. Validators broadcast `PBFT_VIEW_CHANGE` for `view + 1`. -2. Once `max(2f+1, ceil((N+1)/2))` view-change votes are collected, the new view is adopted. +2. Once the view-change votes reach quorum (below), the new view is adopted. 3. The next leader (per the new view number) takes over. ### Quorum -`max(2f+1, ceil((N+1)/2))` where `f = floor((N-1)/3)`, tolerates `f` Byzantine validators -out of `N` total. The simple-majority floor matters for small federations: bare `2f+1` -degenerates to a quorum of 1 at N=3 (f=0), which would let a single validator finalize -alone. With the floor, N=3 requires 2 votes and N=2 requires both. +The quorum rule is activation-gated, keyed on the round's BTC-anchored snapshot block and +network. `PREPARE`, `COMMIT` and `PBFT_VIEW_CHANGE` all use the same predicate +(`Consensus._quorumMet`), as do the checkpoint and cross-chain engines. + +**At or above `STAKE_WEIGHTED_QUORUM_ACTIVATION`:** stake-weighted and source-deduplicated. +Each voting validator's signing pubkey resolves to its stake source in the federation +snapshot, each source counts at most once however many of its keys vote, and the summed +stake must satisfy `3 x tally > 2 x S`, where `S` is the snapshot's total stake over +distinct sources. Three equally weighted sources therefore need all three votes. See +[`protocol/reference-impl/stake_weighted_quorum.js`](../../protocol/reference-impl/stake_weighted_quorum.js). + +**Below activation:** the legacy signer COUNT `max(2f+1, ceil((N+1)/2))` where +`f = floor((N-1)/3)`, tolerating `f` Byzantine validators out of `N` total. The +simple-majority floor matters for small federations: bare `2f+1` degenerates to a quorum +of 1 at N=3 (f=0), which would let a single validator finalize alone. With the floor, +N=3 requires 2 votes and N=2 requires both. ## Oracle Pipeline diff --git a/components/hub/configuration.md b/components/hub/configuration.md index e77d783..c44352b 100644 --- a/components/hub/configuration.md +++ b/components/hub/configuration.md @@ -278,7 +278,7 @@ Controls `OraclePublisher`, which broadcasts finalized price rounds on-chain as | `ORACLE_BATCH_WINDOW_ROUNDS` | No | `6` | How many finalized rounds one published action carries. A round does not ride its own transaction: it is buffered, and the whole window leaves together under a single quorum signature set. Hubs configured differently elect different leaders and may publish overlapping windows, which is harmless (ingest is idempotent) but wasteful, so keep this equal across a federation. | | `ORACLE_BATCH_LANDING_RESERVE_MS` | No | `300000` | Estimated time from a window closing to its published batch being readable on-chain: assembly, the co-signing round, broadcast, and one DOGE confirmation plus indexing. Measured at roughly 180s on public testnet; the default is that with headroom. Subtracted, together with `ORACLE_BATCH_GRACE_MS`, from the fee-price staleness bound when deriving the largest `ORACLE_BATCH_WINDOW_ROUNDS` that still keeps the freshest priced snapshot inside that bound; raising it shrinks the derived window ceiling. | | `ORACLE_BATCH_GRACE_MS` | No | `300000` | How long after a window closes the elected leader waits before assembling it, giving late-finalizing peers time to agree on its contents. Armed once per window and never extended, so a trickle of stragglers cannot postpone a window indefinitely. | -| `ORACLE_BATCH_SIGN_TIMEOUT_MS` | No | `60000` | How long the leader waits for a signing quorum on an assembled window. No quorum means no publication for that window: it stays buffered and a later leader can propose it again. | +| `ORACLE_BATCH_SIGN_TIMEOUT_MS` | No | `60000` | How long the leader waits for a signing quorum on an assembled window. No quorum means no publication for that window: it stays buffered and a later leader can propose it again. One name, two rails, two defaults: here `OracleBatchSigner` reads it from the validator config table only and defaults to `60000`, while the attestation batch rail reads the same name from the environment first and defaults to `15000` (see Attestation Publishing below). Setting it in the environment therefore moves the attestation rail and leaves this one unchanged. | | `ORACLE_BATCH_BUFFER_MAX_ROUNDS` | No | `4032` | Upper bound on buffered rounds, so a hub that never leads a window cannot grow its buffer without limit. Reached only if publication has been failing for a long time; the oldest rounds are dropped first. | | `PUBLISHER_QUEUE_PATH` | No | `./data/publisher-queue.jsonl` | Durable queue file for pending publishes. Point at persistent storage so a restart does not lose queued rows. | | `PUBLISHER_MAX_ATTEMPTS` | No | `5` | Attempts before a queued publish is abandoned. | @@ -372,6 +372,7 @@ Controls `AttestationPublisher`, which writes the validator network's answers to | `BTC_ADDRESS` | No | _(from config table)_ | BTC address of this hub's publishing wallet. | | `ATTEST_BATCH_PUBLISH_ENABLED` | No | `true` | Set to `false` to stop this hub publishing attestation batches on-chain, halting the outbound DOGE spend during an incident without tearing the pipeline's configuration down. Consensus participation is unaffected. A halted publisher **skips** each window rather than buffering it, so re-enabling does not flood the rail with a backlog. Read from the environment first, then the validator config table under the same key. | | `ATTEST_BATCH_BUFFER_PATH` | No | `./data/attest-batch-buffer.jsonl` | Durable record of what each attestation batch window was built from at the moment it published, so an operator replaying a dead-lettered or quarantined window has its content instead of reconstructing it from tables that have since moved on. Point at persistent storage. The dead-letter file sits beside it, at the same path with `.deadletter.jsonl` in place of `.jsonl`. Belongs to the batch publisher alone and must not be pointed at the price publisher's buffer. Read from the environment first, then the validator config table under the same key. | +| `ORACLE_BATCH_SIGN_TIMEOUT_MS` | No | `15000` | How long the attestation batch leader waits for a signing quorum on an assembled window. Shares its name with the price rail's setting under Oracle Publishing above, and the two are NOT the same knob in practice: this one is read from the environment first and defaults to `15000`, while the price rail reads the validator config table only and defaults to `60000`. An operator exporting this variable moves this rail alone. | ### Attestation Relay diff --git a/components/hub/database.md b/components/hub/database.md index 340a605..7b4b263 100644 --- a/components/hub/database.md +++ b/components/hub/database.md @@ -491,7 +491,7 @@ Hub-authored, append-only record of who earned each ANCHOR publish reward. One r | `snapshot_block` | `BIGINT UNSIGNED NOT NULL` | BTC block selecting the `oracle_publish` set, and the reward's `block_index` | | `publisher` | `VARCHAR(64) NOT NULL` | Elected publisher pubkey credited with the reward (lowercase hex) | | `reward_amount` | `VARCHAR(32) NOT NULL` | **Audit only.** The indexer credits the frozen constant, never this wire value | -| `publisher_attestations` | `TEXT NOT NULL` | JSON `[{pubkey,sig}]`, the majority-floored `max(2f+1, ceil((N+1)/2))` quorum over the reward canonical (validated per [ANCHOR](../../protocol/actions/anchor.md)) | +| `publisher_attestations` | `TEXT NOT NULL` | JSON `[{pubkey,sig}]`, meeting the `oracle_publish` quorum over the reward canonical at the bundle's snapshot block: stake-weighted and source-deduped at/above `STAKE_WEIGHTED_QUORUM_ACTIVATION`, otherwise the legacy 2f+1 signer count (validated per [ANCHOR](../../protocol/actions/anchor.md)) | | `created_at` | `TIMESTAMP` | Insert time | **Keys:** unique `(chain, network, reward_type, round_reference, snapshot_block, publisher)`, `(network, snapshot_block)` @@ -518,7 +518,7 @@ Quorum-signed block-level hash checkpoints for each chain. Rows are append-only; | `state_root_version` | `TINYINT UNSIGNED` | `merkle.js STATE_ROOT_VERSION` the state root was computed under; NULL before flag-day | | `block_merkle_root` | `CHAR(64)` | SPV per-block content Merkle root; NULL before flag-day | | `block_merkle_version` | `TINYINT UNSIGNED` | `merkle.js BLOCK_MERKLE_VERSION`; NULL before flag-day | -| `validator_signatures` | `TEXT NOT NULL` | JSON array of `{pubkey, sig}` (the majority-floored `max(2f+1, ceil((N+1)/2))` quorum of signatures over the XCHECKPOINT canonical, validated per [ANCHOR](../../protocol/actions/anchor.md)) | +| `validator_signatures` | `TEXT NOT NULL` | JSON array of `{pubkey, sig}` signatures over the XCHECKPOINT canonical, meeting the `oracle_publish` quorum at the checkpoint's snapshot block: stake-weighted and source-deduped at/above `STAKE_WEIGHTED_QUORUM_ACTIVATION`, otherwise the legacy 2f+1 signer count (validated per [ANCHOR](../../protocol/actions/anchor.md)) | | `anchor_txid` | `VARCHAR(64)` | DOGE ANCHOR txid once published on-chain (hub-side audit only) | | `created_at` | `TIMESTAMP NOT NULL` | Record creation time | diff --git a/components/hub/decentralization.md b/components/hub/decentralization.md index 264b9f9..a5a60e4 100644 --- a/components/hub/decentralization.md +++ b/components/hub/decentralization.md @@ -41,7 +41,7 @@ Validators with the `price` capability independently fetch cryptocurrency prices ### `cross_chain`: Cross-Chain Validators -Validators with the `cross_chain` capability attest to cross-chain swap actions. Rather than running full decoder and indexer stacks for every chain, they use **xchain-sync** to replicate indexer + decoder databases, keeping them lightweight. Consensus is calculated per chain-pair; only validators supporting both chains in a swap participate in attestation, using a PBFT-derived consensus requiring `max(2f+1, ceil((N+1)/2))` agreement (simple-majority floor; see Quorum below). +Validators with the `cross_chain` capability attest to cross-chain swap actions. Rather than running full decoder and indexer stacks for every chain, they use **xchain-sync** to replicate indexer + decoder databases, keeping them lightweight. Consensus is calculated per chain-pair; only validators supporting both chains in a swap participate in attestation, using a PBFT-derived consensus requiring quorum agreement over that pair's validator set (stake-weighted and source-deduped at/above `STAKE_WEIGHTED_QUORUM_ACTIVATION`, otherwise the majority-floored count; see [Quorum](#quorum) below). ### `oracle_publish`: PRICE v0 Broadcasters @@ -116,11 +116,15 @@ flowchart TD B -->|gossip| C ``` -Each validator runs the full hub stack. Communication happens via WebSocket-based P2P gossip with Ed25519-signed messages. All consensus decisions require `max(2f+1, ceil((N+1)/2))` agreement; the simple-majority floor prevents a single validator from finalizing alone at small federation sizes (N=3 requires 2 votes; N=2 requires both). +Each validator runs the full hub stack. Communication happens via WebSocket-based P2P gossip with Ed25519-signed messages. All consensus decisions require quorum agreement, and which quorum rule applies is activation-gated (below). ### Quorum -`max(2f+1, ceil((N+1)/2))` where `f = floor((N-1)/3)`, tolerates `f` Byzantine validators out of `N` total. The simple-majority floor matters for small federations: bare `2f+1` degenerates to a quorum of 1 at N=3 (f=0), which would let a single validator finalize alone. With the floor, N=3 requires 2 votes and N=2 requires both. +The rule is keyed on the round's BTC-anchored snapshot block and network, so every hub and every indexer flips on the same anchor. + +**At or above `STAKE_WEIGHTED_QUORUM_ACTIVATION`:** stake-weighted and source-deduplicated. Each voting validator's pubkey resolves to its stake source in the federation snapshot, each source counts at most once however many of its keys vote, and the summed stake must satisfy `3 x tally > 2 x S`, where `S` is the snapshot's total stake over distinct sources. Three equally weighted sources therefore need all three votes. See [`protocol/reference-impl/stake_weighted_quorum.js`](../../protocol/reference-impl/stake_weighted_quorum.js). + +**Below activation:** the legacy signer count `max(2f+1, ceil((N+1)/2))` where `f = floor((N-1)/3)`, tolerating `f` Byzantine validators out of `N` total. The simple-majority floor matters for small federations: bare `2f+1` degenerates to a quorum of 1 at N=3 (f=0), which would let a single validator finalize alone. With the floor, N=3 requires 2 votes and N=2 requires both. ## Related diff --git a/components/hub/operations.md b/components/hub/operations.md index de6b7eb..bf1be8a 100644 --- a/components/hub/operations.md +++ b/components/hub/operations.md @@ -428,7 +428,7 @@ The P2P layer deduplicates messages using a TTL cache (default: 60 seconds). Thi ### Cross-chain attestations stuck at pending -- Verify enough validators support both chains in the chain pair (quorum requires `max(2f+1, ceil((N+1)/2))`) +- Verify enough validators support both chains in the chain pair to reach quorum. At/above `STAKE_WEIGHTED_QUORUM_ACTIVATION` that is a source-deduped stake threshold (`3 x tally > 2 x S`), not a head count, so counting validators is not enough on its own; below it, the legacy `max(2f+1, ceil((N+1)/2))` count applies. See [decentralization: Quorum](decentralization.md#quorum) - Check confirmation thresholds: BTC requires 6, LTC requires 12, DOGE requires 60 (defaults; overridable via `XCHAIN_CONFIRMATIONS_`) - Ensure `PBFT_TIMEOUT` is sufficient for consensus rounds to complete diff --git a/components/indexer/configuration.md b/components/indexer/configuration.md index e10b050..9fe6724 100644 --- a/components/indexer/configuration.md +++ b/components/indexer/configuration.md @@ -249,7 +249,7 @@ After the activation block, fees for VM and staking actions are calculated using |---|---|---| | `GAS_PRICE` | Base XCHAIN cost per unit of gas | `0.00001` | | `GAS_SCHEDULE` | Object mapping action types to their gas cost in gas units | `{ DEPLOY: 100000, EXECUTE: 10000, STAKE: 5000, ... }` | -| `UNIFIED_EXPIRATION_FEE_FREE_DAYS` | Free listing duration under the unified schedule (replaces `EXPIRATION_FEE_FREE_DAYS` post-activation) | `365` | +| `UNIFIED_EXPIRATION_FEE_FREE_DAYS` | Free listing duration under the unified schedule (replaces `EXPIRATION_FEE_FREE_DAYS` post-activation) | `90` (3 months) | | `FEE_PAYMENT_MODE` | Reserved key indicating intended fee denomination per chain (`'xchain'` on BTC, `'native'` on LTC/DOGE). **Not currently read at runtime**: see note below. | `'xchain'` (BTC) | > **Note on `FEE_PAYMENT_MODE`:** This key is currently informational only and is **not** read by the fee-processing code. Fee payment mode is detected implicitly at runtime by `detectFeePaymentMode()` in `src/utility.js`, which derives the mode from the transaction itself: if a native-coin fee output to the configured fee destination is present it returns `'native'`; if absent it returns `'xchain'` on BTC (XCHAIN balance deduction is allowed as a fallback) and `'rejected'` on LTC/DOGE (native coin is the only accepted fee on those chains). The `FEE_PAYMENT_MODE` config value is reserved for a future change that makes this detection explicit/config-driven; until then its value must mirror the implicit per-chain behavior to avoid surprising a later refactor. diff --git a/components/sdk/actions.md b/components/sdk/actions.md index 0a4fbe9..47ed69f 100644 --- a/components/sdk/actions.md +++ b/components/sdk/actions.md @@ -94,7 +94,7 @@ Configure address-level preferences for fee routing and memo requirements. | Param | Type | Required | Description | |---|---|---|---| | controller | integer | Conditional | ACTION_INDEX of the deployed guard contract. Required when `unbind` is `0`. Ignored on unbind. | -| actionClass | string | Yes | The action class to gate or release: `transfer`, `trade`, `burn`, `mint`, or `stake` | +| actionClass | string | Yes | The action class to gate or release: `transfer`, `trade`, `burn`, `mint`, `stake`, `ownership`, or the catch-all `all` | | cooldownBlocks | integer | No | Number of blocks that must pass after an unbind request before the binding is dropped (committed at bind; `0` = no cooldown) | | unbind | integer | Yes | `0` = bind the action class to the controller, `1` = unbind it | | memo | string | No | Optional note | @@ -165,11 +165,11 @@ Combine multiple action commands into a single transaction. - BATCH cannot contain nested BATCH actions. - BATCH cannot contain DEPLOY actions. - At most **one FILE** action per BATCH (one rawData payload per transaction). -- At most **one MINT** action per BATCH. +- At most **one MINT** action per distinct `tick` per BATCH. Minting several different tokens in one BATCH is allowed; minting the same token twice is not. The SDK compares tick STRINGS, which the chain's resolved-id rule makes a conservative approximation, so the builder refuses a BATCH mixing a `^` MINT tick with a named one rather than guess whether they are one token. - At most **one top-level ISSUE** action per BATCH. A child issuance, whose `tick` contains a `.` (for example `JDOG.1`), does not use that slot, so one BATCH can register a parent plus any number of its children. A `^` tick is never treated as a child. - At most **250 commands** per BATCH, counted over the raw semicolon-separated list including empty entries. - Sub-commands are **not atomic**: each is validated and settled on its own, so a command that fails does not undo the ones before it. Protocol fees are charged per command and accumulate across the batch. -- The child-issuance exemption, the 250-command cap and cumulative fee accounting are active on testnet and regtest, and activate on mainnet at `2026-08-16T00:00:00Z`. +- The child-issuance exemption, the per-distinct-token MINT rule, the 250-command cap and cumulative fee accounting are active on testnet and regtest, and have been active on mainnet since `2026-08-16T00:00:00Z`. - See [BATCH.md](./batch.md) for the fluent builder interface (`sdk.batch()`). ```js @@ -623,7 +623,7 @@ Create or update a token. Multiple update sub-formats allow targeted edits witho |---|---|---|---| | tick | string | Yes | Token whose action class is being bound or unbound | | controller | integer | Conditional | ACTION_INDEX of the deployed guard contract. Required when `unbind` is `0`. Ignored on unbind. | -| actionClass | string | Yes | The action class to gate or release: `transfer`, `trade`, `burn`, `mint`, or `stake` | +| actionClass | string | Yes | The action class to gate or release: `transfer`, `trade`, `burn`, `mint`, `stake`, `ownership`, or the catch-all `all` | | cooldownBlocks | integer | No | Number of blocks that must pass after an unbind request before the binding is dropped (committed at bind; `0` = no cooldown) | | unbind | integer | Yes | `0` = bind the action class to the controller, `1` = unbind it | | memo | string | No | Optional note | @@ -1494,12 +1494,12 @@ await sdk.collect({}); - BATCH cannot contain nested BATCH actions. - BATCH cannot contain DEPLOY actions. - At most **one FILE** per BATCH (one rawData payload per transaction). -- At most **one MINT** per BATCH. +- At most **one MINT** per distinct `tick` per BATCH; several MINTs of different tokens are allowed, two MINTs of the same token are not. - At most **one top-level ISSUE** per BATCH; child issuances (a `tick` containing a `.`, such as `JDOG.1`) are exempt and uncapped, while a `^` tick is never treated as a child. - At most **250 commands** per BATCH, counted over the raw semicolon-separated list including empty entries. - Commands settle independently, not atomically, and each pays its own protocol fee. -The child-issuance exemption, the command cap and cumulative fee accounting are active on testnet and regtest, and activate on mainnet at `2026-08-16T00:00:00Z`. +The child-issuance exemption, the per-distinct-token MINT rule, the command cap and cumulative fee accounting are active on testnet and regtest, and have been active on mainnet since `2026-08-16T00:00:00Z`. ### Encoding size limits diff --git a/components/sdk/batch.md b/components/sdk/batch.md index eb9c82a..40f4971 100644 --- a/components/sdk/batch.md +++ b/components/sdk/batch.md @@ -149,13 +149,13 @@ The BATCH protocol enforces the following rules. Violations throw `SDKValidation | No nested BATCH actions | `BATCH_CONSTRAINT` | BATCH inside BATCH is not allowed by the protocol | | No DEPLOY actions | `BATCH_CONSTRAINT` | DEPLOY payloads are too large for BATCH | | At most 1 FILE action | `BATCH_CONSTRAINT` | One rawData payload per transaction; `details.count` contains the actual count | -| At most 1 MINT action | `BATCH_CONSTRAINT` | `details.count` contains the actual count | +| At most 1 MINT action per distinct `tick` | `BATCH_CONSTRAINT` | MINTs of different tokens are allowed; two MINTs of the same token are not. `details.count` contains the actual count. The builder compares tick STRINGS, so it refuses a BATCH mixing a `^` MINT tick with a named one rather than guess whether the two name one token | | At most 1 top-level ISSUE action | `BATCH_CONSTRAINT` | Child issuances (a dotted `tick` such as `JDOG.1`) are exempt and uncapped; a `^` tick is never treated as a child. `details.count` contains the actual count | | At most 250 commands | `BATCH_CONSTRAINT` | Counted over the raw semicolon-separated list, empty entries included; `details.limit` carries the cap | All sub-actions are also fully validated by the Validator before the BATCH is built. A bad field value in any sub-action will throw the corresponding `SDKValidationError` before `.build()` returns. -**On-chain, a BATCH is not atomic.** These constraints are compose-time guards, and passing them does not mean every command will settle. The indexer validates and settles each command on its own, so a command that fails on chain is recorded invalid by itself while its siblings stand. Protocol fees are charged per command and accumulate across the batch, so fund the sending address for the whole set. The child-issuance exemption and the 250-command cap are active on testnet and regtest, and activate on mainnet at `2026-08-16T00:00:00Z`. +**On-chain, a BATCH is not atomic.** These constraints are compose-time guards, and passing them does not mean every command will settle. The indexer validates and settles each command on its own, so a command that fails on chain is recorded invalid by itself while its siblings stand. Protocol fees are charged per command and accumulate across the batch, so fund the sending address for the whole set. The child-issuance exemption, the per-distinct-token MINT rule and the 250-command cap are active on testnet and regtest, and have been active on mainnet since `2026-08-16T00:00:00Z`. --- @@ -194,13 +194,13 @@ const { SDKValidationError } = require('@xchain/sdk/src/errors'); try { await sdk.batch() .mint({ tick: 'BTC.TOKEN', amount: 100 }) - .mint({ tick: 'BTC.TOKEN', amount: 200 }) // second MINT (violates constraint) + .mint({ tick: 'BTC.TOKEN', amount: 200 }) // second MINT of the SAME tick (violates constraint) .build(); } catch (err) { if (err instanceof SDKValidationError && err.code === 'BATCH_CONSTRAINT') { console.error('Batch constraint violated:', err.message); - // "BATCH can contain at most 1 MINT action" + // "BATCH can contain at most 1 MINT action per distinct TICK" console.error('Actual count:', err.details.count); // 2 } } diff --git a/components/sdk/encoder.md b/components/sdk/encoder.md index 0a840b6..ae406b6 100644 --- a/components/sdk/encoder.md +++ b/components/sdk/encoder.md @@ -54,6 +54,13 @@ The encoder supports five encoding strategies: four script-output lanes and the | `MULTISIGN` | 60 bytes per chunk | Data spread across fake public keys in a multisig output. Requires `compressedPubKey`. Rarely used directly. | | `TAPROOT` | 390,000 bytes of payload total, pushed in 520-byte elements | The [Taproot envelope](../../protocol/taproot-envelope.md): one `createTx` call returns the commit and reveal PSBTs together, not the `p2shHash` two-call flow. It replaces the 8,192-byte ceiling with its own `ENVELOPE_MAX_PAYLOAD` of 390,000 bytes. Bitcoin and Litecoin only (Dogecoin has no SegWit), and only at or above that chain's envelope recognition height. Requires `compressedPubKey`, which becomes the envelope's internal key, and a signer that can produce a BIP341 script-path signature. | +On the chunked lanes (`P2SH`, `P2WSH`, `TAPROOT`) no chunk or push may be a single +byte in `0x01`-`0x10` or `0x81`, because a script encoder canonicalizes such a byte +into a bare opcode and the decoder then refuses the output (on `TAPROOT`, the whole +reveal). The encoder handles this for you by rebalancing the final two chunks to +`(n-1, 2)` bytes; an integrator building the scripts directly must do the same. See +[Taproot envelope: no payload push may canonicalize to a bare opcode](../../protocol/taproot-envelope.md#no-payload-push-may-canonicalize-to-a-bare-opcode). + --- ## Auto-Selection diff --git a/components/sdk/errors.md b/components/sdk/errors.md index d22928d..5e8d417 100644 --- a/components/sdk/errors.md +++ b/components/sdk/errors.md @@ -97,7 +97,7 @@ Thrown during action validation before any network call is made. | `INVALID_TICK_NAME` | None | TICK name violates naming rules (length, characters, reserved names) | | `INVALID_TICK_ID` | None | A `^ID` reference is not a valid numeric index | | `FORBIDDEN_CHARACTER` | None | A text field contains a `|` or `;` character, which would corrupt the pipe-delimited format | -| `BATCH_CONSTRAINT` | `count` (for MINT/ISSUE violations), `limit` (for the command cap) | A BATCH protocol rule was violated (nested BATCH, DEPLOY action, more than 1 FILE, more than 1 MINT, more than 1 top-level ISSUE, more than 250 commands). Child issuances such as `JDOG.1` do not count against the ISSUE limit | +| `BATCH_CONSTRAINT` | `count` (for MINT/ISSUE violations), `limit` (for the command cap) | A BATCH protocol rule was violated (nested BATCH, DEPLOY action, more than 1 FILE, more than 1 MINT of the same `tick`, more than 1 top-level ISSUE, more than 250 commands). Child issuances such as `JDOG.1` do not count against the ISSUE limit | | `BATCH_EMPTY` | None | A batch was built with no actions queued | | `ENCODING_DATA_TOO_LARGE` | `suggestion` | The serialized action string exceeds 76 bytes (the OP_RETURN user-data limit; 80 bytes total per output including the 4-byte XCHN prefix) | | `MISSING_COMPRESSED_PUBKEY` | None | A MULTISIGN encoding was requested without providing a `compressedPubKey` | diff --git a/components/sdk/light-client.md b/components/sdk/light-client.md index 5b5c3b1..b1fd3f1 100644 --- a/components/sdk/light-client.md +++ b/components/sdk/light-client.md @@ -265,7 +265,9 @@ Related helpers: For callers that fetch proofs themselves, the pure (no-network) verifiers are exposed: `verifyBalanceProof(proof, trustedStateRoot, chain, network)`, `verifyActionProof(proof, trustedBlockMerkleRoot)`, -`verifyValidatorSetProof(proof, trustedStateRoot)`, and the trustless quorum +`verifyValidatorSetProof(proof, trustedStateRoot)`, +`verifyContractStateProof(proof, trustedStateRoot, chain, network, expected)`, +and the trustless quorum helper `verifyCheckpointWithProvenSet(checkpoint, provenOraclePublish)`. The network wrapper `verifyValidatorSet({ explorerUrl, btcCoin, snapshotBlock, trustedStateRoot })` fetches and verifies the `oracle_publish` (and @@ -286,9 +288,11 @@ pinned checkpoint is present. `getPinnedCheckpoint(coin)` returns `null` today. ## Not yet supported -- **Contract state.** `verifyContractState` is reserved: the contract key-value - sub-tree is committed empty in `state_root_version` 1 and lands behind a later - version bump. +- **Contract state on mainnet.** `verifyContractStateProof` ships and verifies + proofs today, but the `contract_state_root` sub-tree is armed only on BTC + regtest and the BTC, LTC and DOGE testnets. On mainnet the slot stays unarmed + pending its flag day, so the explorer answers a typed 409 + `CONTRACT_STATE_NOT_COMMITTED` and there is no mainnet proof to verify yet. --- diff --git a/components/utxo-tracker/configuration.md b/components/utxo-tracker/configuration.md index 2d682d6..f21c6e0 100644 --- a/components/utxo-tracker/configuration.md +++ b/components/utxo-tracker/configuration.md @@ -40,7 +40,7 @@ BULK_SYNC_RAM_BUDGET=768 | `UTXO_TRACKER_RATE_LIMIT_RPM` | API requests per minute per IP | `500` | | `UTXO_TRACKER_NODE_RPC_STALE_MS` | Staleness window for the tracker`s last usable node-tip read, after which `health` reports the node RPC stale. Five times the loop`s `BLOCKCHAIN_INFO_REFRESH_MS` (30s), so a slow or skipped poll never trips it and only a sustained outage does. | `150000` | | `UTXO_MAX_RPC_BATCH` | Maximum calls accepted in one inbound JSON-RPC batch (array body). The router runs `Promise.all` over every element, so without this cap a single unauthenticated ~100kb POST fans out into thousands of concurrent read scans and node RPCs. Mirrors the decoder and encoder batch guards. | `20` | -| `XCHAIN_COINBASE_MATURITY` | Confirmations a coinbase output needs before `getUtxosAddress` will serve it as spendable. The consensus rule is 100 on BTC/LTC/DOGE and their testnet/regtest variants; serving an immature coinbase hands the caller an input every node rejects. Lower it only for test harnesses that mine short chains. | `100` | +| `XCHAIN_COINBASE_MATURITY` | Confirmations a coinbase output needs before `getUtxosAddress` will serve it as spendable. The consensus rule is **per chain**, and the tracker resolves it per network rather than assuming one number: 100 on BTC and LTC on every net; 240 on DOGE mainnet and testnet (Dogecoin Core sets `nCoinbaseMaturity` to 240 from the Digishield fork at height 145000, which every live DOGE chain is long past); 60 on DOGE regtest. Serving an immature coinbase hands the caller an input every node rejects. Set this only to override the resolved value for a test harness that mines short chains. | _(resolved per chain, see left)_ | | `XCHAIN_MAX_BLOCK_FETCH_RETRIES` | Attempts to fetch a block at one height before giving up. Raise it for slow-recovering nodes. Retries sleep 3000 ms apart. | `20` | | `BOOTSTRAP_RESTORE_ALLOW_UNVERIFIED` | Set to `1` to let `restorebootstrap` proceed when the archive has no `.sha256` sidecar. **Off by default and deliberately so:** the restore performs a destructive `/data` wipe, and without the sidecar the archive cannot be checked for truncation or tampering first. The correct fix is to publish a `.sha256` next to the archive; this flag exists as a last resort and logs a warning when used. | _(unset, fails closed)_ | | `BOOTSTRAP_RESTORE_ALLOW_UNSIGNED` | Set to `1` to let `restorebootstrap` proceed when the archive has no `.sig` signature file, or when no bootstrap signing public key is pinned. **Off by default and deliberately so:** the restore performs a destructive `/data` wipe, and an archive`s own checksums prove only that it is internally consistent, never who published it. Publish a `.sig` next to the archive instead; this flag is the last resort. | _(unset, fails closed)_ | diff --git a/components/vm/README.md b/components/vm/README.md index 03f0f04..18ccfe3 100644 --- a/components/vm/README.md +++ b/components/vm/README.md @@ -18,7 +18,7 @@ A pure function library. Takes contract code + state + inputs + block context. R - **19 emittable action types**: SEND, DESTROY, ISSUE, MINT, ORDER, DISPENSER, DIVIDEND, AIRDROP, CALLBACK, FILE, LIST, COINPAY, SWEEP, LINK, BROADCAST, MESSAGE, VOTE, plus `emit.execute` (cross-contract call) and `emit.crossExecute` (cross-chain call via XCALL) - **External attestation gateway**: `xchain.attestation.request(...)` and `getResponse(...)`. Contracts ask an HTTPS endpoint or an approved LLM, and the validator network writes a signed answer back on-chain that re-enters the contract through a callback. See [Smart Contracts; Attestation Framework](../../concepts/smart-contracts.md#asking-the-outside-world-the-attestation-framework). - **Contract-targeted staking gateway**: `xchain.contract.getStake`, `getTotalStaked`, `getStakers`, `slash`. Any contract can declare itself stakeable at deploy time and slash its own stakers per its own rules. See [Smart Contracts; Stakeable Contracts](../../concepts/smart-contracts.md#stakeable-contracts). -- **Deterministic math**: `xchain.math.*` wraps mathjs bignumber with string I/O, no floating-point at the gateway boundary +- **Deterministic math**: `xchain.math.*` wraps mathjs bignumber, taking string amounts in and returning string arithmetic results (`compare` returns -1/0/1 and the `gt`/`gte`/`lt`/`lte`/`eq`/`isZero` predicates return booleans), no floating-point at the gateway boundary - **Contract state management**: key-value store with dirty tracking, key count, key size, and value size limits - **Deploy-time validation**: V8 syntax check, acorn metering pass, reserved identifier detection, float warnings - **Per-block compilation cache**: V8 cached compilation data reused for hot contracts within a block diff --git a/components/vm/architecture.md b/components/vm/architecture.md index 8f68a6c..4c5317d 100644 --- a/components/vm/architecture.md +++ b/components/vm/architecture.md @@ -50,7 +50,7 @@ flowchart TD | `gas.js` | GasTracker class: validates gas schedule (non-negative integers), accumulates gas charges per operation, enforces ceiling, throws GasExhaustedError on overflow | | `gateway.js` | Builds the `xchain` gateway object: context accessors, state CRUD, ledger queries, oracle, cross-chain, **external attestation (`xchain.attestation.*`)**, **contract-targeted staking (`xchain.contract.*`)**, emit API, math, control flow, logging | | `gateway-emit.js` | Emit API builder: 19 action types (SEND through MESSAGE and VOTE, plus `execute` for cross-contract calls and `crossExecute` for cross-chain calls), parameter validation, gas charging | -| `math.js` | Deterministic math wrapping mathjs bignumber: all inputs/outputs are strings, wrapped in `safeMath` for ContractRevertError on failures | +| `math.js` | Deterministic math wrapping mathjs bignumber: all inputs are strings, arithmetic results are strings, `compare` returns a number (-1/0/1) and `gt`/`gte`/`lt`/`lte`/`eq`/`isZero` return booleans; wrapped in `safeMath` for ContractRevertError on failures | | `state.js` | StateManager: reads from initial snapshot, tracks writes/deletes in dirty map, enforces key count, key size, and value size limits, provides `getChanges()` for result collection | | `collector.js` | EmissionCollector: queues emitted actions (with emission cap), collects debug logs (100 entries, 1 KB UTF-8 each, with byte-aware truncation) | | `validator.js` | ActionValidator: pre-validates emitted actions against the 21 allowed action types (`SEND`, `DESTROY`, `ISSUE`, `MINT`, `ORDER`, `DISPENSER`, `DIVIDEND`, `AIRDROP`, `CALLBACK`, `FILE`, `LIST`, `COINPAY`, `SWEEP`, `LINK`, `BROADCAST`, `MESSAGE`, `ATTEST`, `SLASH`, `EXECUTE`, `XCALL`, `VOTE`) and checks params shape | diff --git a/components/wallet/ux.md b/components/wallet/ux.md index 1ff916f..aba6ae9 100644 --- a/components/wallet/ux.md +++ b/components/wallet/ux.md @@ -64,7 +64,7 @@ This document walks every primary route the wallet exposes. All routes live in ` | Attach content | `AttachContentForm.jsx` | Attach a file (with optional title) to a token as on-chain content; polls for confirmation | | Contract staked positions | `ContractStakedPositions.jsx` | Lists the wallet's active stakes against deployed contracts | | Stake on contract | `ContractStakeForm.jsx` | STAKE-to-contract form; BTC-only at launch; scoped to a specific contract by action index | -| Bind controller | `ControllerBindForm.jsx` | CONTROLLERBIND action form; sets per-class policy rules (transfer, trade, burn, mint, stake) on a token | +| Bind controller | `ControllerBindForm.jsx` | CONTROLLERBIND action form; sets per-class policy rules (transfer, trade, burn, mint, stake, ownership, all) on a token | | Manage token | `ManageToken.jsx` | Owner hub for a token: metadata, holders panel, supply, and links to admin sub-forms | | Market activity | `MarketActivity.jsx` | Live market feed; opens on the XCHAIN token by default; tap the token header to switch markets | | Menu | `MenuRoute.jsx` | Full-screen pancake menu opened from the shared app header; links to all top-level sections | diff --git a/concepts/encoding.md b/concepts/encoding.md index 2f4726f..c5042c7 100644 --- a/concepts/encoding.md +++ b/concepts/encoding.md @@ -52,6 +52,8 @@ OP_RETURN is the preferred format for short ACTIONs (simple sends, mints, basic The two-transaction pattern means the ACTION is not visible until the spend transaction is mined. The fund transaction just looks like a payment to a script hash. +**No chunk may be a single byte in `0x01`-`0x10` or `0x81`.** A script encoder canonicalizes such a byte into the bare opcode `OP_1`-`OP_16` / `OP_1NEGATE`, and the decoder's redeem-script gate takes only a data push at position 0, so that output is skipped and the reassembled payload is silently corrupted. When the split would leave a final chunk of exactly one such byte, the encoder **rebalances** the last two chunks to `(n-1, 2)` bytes. Reassembly is plain concatenation, so the payload is unchanged. The same rule governs the Taproot envelope's 520-byte pushes; see [Taproot envelope: no payload push may canonicalize to a bare opcode](../protocol/taproot-envelope.md#no-payload-push-may-canonicalize-to-a-bare-opcode). + ### P2WSH (Pay-to-Witness-Script-Hash) **Capacity**: up to 8,192 bytes of data diff --git a/concepts/gas.md b/concepts/gas.md index 108b903..ecc510e 100644 --- a/concepts/gas.md +++ b/concepts/gas.md @@ -19,7 +19,7 @@ flowchart LR GC2["gas cost"] --> GP2["GAS_PRICE"] --> XA2["XCHAIN amount"] --> DB["debit from user's
XCHAIN balance"] ``` -On BTC, the indexer uses implicit detection: if the transaction includes a native coin output to the fee destination address, it's validated as native coin payment against the oracle. If there is no fee output, the indexer debits XCHAIN from the user's balance. On LTC/DOGE, native coin payment is the only option; a missing fee output means the action is rejected. +On BTC, the indexer uses implicit detection: if the transaction includes a native coin output to the fee destination address, it's validated as native coin payment against the oracle. If there is no fee output, the indexer debits XCHAIN from the user's balance. On LTC/DOGE, native coin payment is the only option; a missing fee output means the action is rejected. Both rules are about how a fee is paid, not about whether one is owed: payment mode is resolved only when the computed fee is greater than zero. An action whose fee works out to nothing (a betting market created wholly inside the duration-fee free window, for instance) needs no fee output on any chain. The fee destination address is the per-network `ADDRESS.FEE_DESTINATION` value from the bundled coin registry (pinned per coin and network). On testnet and regtest it can be redirected at runtime with the `XCHAIN_FEE_DESTINATION__` environment variable (e.g. `XCHAIN_FEE_DESTINATION_DOGE_TESTNET`); on mainnet the override is ignored with a warning, because fee acceptance is consensus-relevant and must not depend on operator environment. If the registry value were the unset placeholder, native-coin fee detection would be disabled and the indexer would fall back to XCHAIN-balance deduction on BTC (and reject fee-bearing actions on LTC/DOGE). diff --git a/concepts/scope-and-non-goals.md b/concepts/scope-and-non-goals.md index ad77cbb..e55eafe 100644 --- a/concepts/scope-and-non-goals.md +++ b/concepts/scope-and-non-goals.md @@ -122,7 +122,8 @@ model: the root of trust is the stake-weighted validator quorum, plus DOGE proof the cold-start anchor, not host-chain-PoW SPV of XChain itself. The path is active on testnet and regtest, gated off on mainnet pending a flag-day; the reference wallet is already wired in as its first consumer, verifying balances and action history against the checkpoint locally; -locked-balance and contract-state proofs are deferred to a later version. Until you run a node or verify a checkpoint, a lightweight +locked-balance and contract-state proofs ship with the same path, armed on regtest and the three +testnets and unarmed on mainnet with the rest of it. Until you run a node or verify a checkpoint, a lightweight wallet trusts the explorer it queries, so treat third-party API data as trusted-source unless you verify it against your own node. diff --git a/concepts/security-model.md b/concepts/security-model.md index 88f3793..e7958bd 100644 --- a/concepts/security-model.md +++ b/concepts/security-model.md @@ -33,7 +33,7 @@ XChain's security properties come from several sources: the underlying blockchai **Decentralized**: Anyone can run an XChain node (decoder + indexer + explorer) and independently compute the full state of the protocol. No permission is required. No central authority controls which tokens exist, who holds what, or whether a transfer is valid; those are all determined by the blockchain data and the protocol rules. -**Hub validator network**: The xchain-hub operates as a decentralized validator network. Validators form a P2P gossip mesh with PBFT consensus, Ed25519 identity, and Byzantine fault tolerance. Configuration writes, price oracle data, cross-chain attestations, and governance decisions all require a `max(2f+1, ceil((N+1)/2))` validator agreement (the majority term keeps a small federation from collapsing to a single signer). Users who run their own full stack (all services including their own hub validator) participate directly in the validator network and do not depend on any single hub instance. See [`../components/hub/`](../components/hub/) for full architecture details. +**Hub validator network**: The xchain-hub operates as a decentralized validator network. Validators form a P2P gossip mesh with PBFT consensus, Ed25519 identity, and Byzantine fault tolerance. Configuration writes, price oracle data, cross-chain attestations, and governance decisions all require federation quorum agreement. At or above `STAKE_WEIGHTED_QUORUM_ACTIVATION` that quorum is stake-weighted and source-deduplicated (the distinct stake sources behind the agreeing validators must carry more than two thirds of the snapshot's total stake); below it, the legacy signer count `max(2f+1, ceil((N+1)/2))` applies, whose majority term keeps a small federation from collapsing to a single signer. See [hub decentralization: Quorum](../components/hub/decentralization.md#quorum). Users who run their own full stack (all services including their own hub validator) participate directly in the validator network and do not depend on any single hub instance. See [`../components/hub/`](../components/hub/) for full architecture details. ## Network Security @@ -65,7 +65,7 @@ The purpose is to prevent naive keyword scanning of the blockchain for XChain da | Token state correctness | Deterministic protocol rules + sanity checks | | Balance integrity | Double-entry ledger + block-level verification | | Independent verification | Anyone can run a full node | -| Configuration trustworthiness | Hub validator network (PBFT consensus, `max(2f+1, ceil((N+1)/2))` agreement) | +| Configuration trustworthiness | Hub validator network (PBFT consensus, stake-weighted quorum agreement at/above `STAKE_WEIGHTED_QUORUM_ACTIVATION`, legacy `max(2f+1, ceil((N+1)/2))` count below it) | | Cross-chain swap coordination | Hub validator network (PBFT consensus, cross-chain attestation) | | Network transport security | TLS + Helmet + CORS | | SQL safety | Parameterized queries + table whitelisting | diff --git a/concepts/smart-contracts.md b/concepts/smart-contracts.md index 284b1ff..81ff46e 100644 --- a/concepts/smart-contracts.md +++ b/concepts/smart-contracts.md @@ -174,7 +174,13 @@ Every figure in a **Gas** column below is in gas units, charged against the call | `xchain.math.log2(a)` | Base-2 logarithm | | `xchain.math.log10(a)` | Base-10 logarithm | -All math inputs and outputs are **strings**. This ensures deterministic precision using bignumber arithmetic. Native JavaScript arithmetic operators (`+`, `-`, `*`, `/`) use floating-point and may produce non-deterministic results across V8 versions. +All math inputs are **strings**, and every arithmetic result comes back as a +string too. This ensures deterministic precision using bignumber arithmetic. The +comparisons are the exception the table above states: `compare` returns a number +(`-1`, `0` or `1`) and `gt`/`gte`/`lt`/`lte`/`eq`/`isZero` return booleans, so +they can be used directly in a condition. Native JavaScript arithmetic operators +(`+`, `-`, `*`, `/`) use floating-point and may produce non-deterministic results +across V8 versions. ### Control Flow (0 gas) | Method | Description | @@ -235,7 +241,7 @@ The VM guarantees identical results on every indexer node replaying the same blo - **Sandboxed V8 isolates**: contracts run in `isolated-vm` with a separate heap. No access to the host process, filesystem, or network. - **Non-deterministic APIs stripped**: `Date`, `Math.random`, `setTimeout`, `setInterval`, `process`, `require`, `eval`, `Function`, `fetch`, `WeakRef`, `FinalizationRegistry`, `Proxy`, `SharedArrayBuffer`, `Atomics`, `queueMicrotask` are all removed. A deterministic `Math` subset (floor, ceil, round, abs, min, max, sign, trunc, plus constants PI and E) is preserved and frozen. The transcendentals (`sqrt`, `pow`, `log`, `log2`, `log10`) are **also stripped** from the native `Math`; IEEE 754 transcendentals can differ by ≤1 ULP across CPU architectures. Contracts access deterministic bignumber equivalents via `xchain.math.sqrt/pow/log/log2/log10` instead; the native `Math.*` forms are rejected at deploy time. - **AST-based gas metering**: contract source is parsed with acorn, `__gas()` calls are injected at control flow points, and the source is regenerated. Gas charges are based on code structure, not wall-clock time. -- **String-only math**: all token amounts pass through `xchain.math.*` which wraps `mathjs` bignumber with string I/O. No floating-point at the gateway boundary. +- **Bignumber math**: all token amounts pass through `xchain.math.*`, which wraps `mathjs` bignumber and takes string amounts in and returns string arithmetic results (the comparisons return a number or a boolean instead). No floating-point at the gateway boundary. - **Synchronous execution**: all isolated-vm APIs are synchronous. No event loop interleaving during contract execution. ### Snapshot Semantics diff --git a/developer-guide/batch-operations.md b/developer-guide/batch-operations.md index f0ddb64..7589757 100644 --- a/developer-guide/batch-operations.md +++ b/developer-guide/batch-operations.md @@ -103,7 +103,7 @@ Two limits to plan around: You can also issue against the same child ticker more than once in one batch, which lets you create it, mint into it, lock it and hand it over as a single sequence. -*These rules are live on testnet and regtest today. On mainnet they are not yet switched on; until they are, mainnet allows one ISSUE per batch whether or not its ticker has a dot.* +*These rules are live on testnet and regtest from genesis, and on mainnet since the `2026-08-16T00:00:00Z` activation. Mainnet blocks below that instant keep the older behavior: one ISSUE per batch, dotted ticker or not.* --- @@ -169,17 +169,23 @@ const action = sdk.batch() | Child ISSUEs are unlimited | An ISSUE whose ticker contains a dot (`JDOG.1`) does not use the top-level slot | | Max 250 commands per batch | Counted over the whole semicolon-separated list, empty entries included, so a trailing `;` costs a slot | | Weighted cost budget | Once cost weighting activates, command weights must sum to at most the budget; see [Command Weights](#command-weights) | -| Max one MINT per batch | Only one MINT action allowed | +| Max one MINT per distinct token | Several MINTs in one batch are fine as long as they name different tokens; two MINTs of the same token are not | | No nested BATCH | BATCH cannot contain another BATCH | | Max one FILE per batch | A BATCH can include at most one FILE action (one raw data payload per transaction) | | No DEPLOY | The DEPLOY action is not permitted inside a BATCH by the SDK builder | | Fees add up | Every command pays its own protocol fee; one command's worth of fee funds one command | ```js -// This would fail: two MINTs in one batch +// Fine: two MINTs of two DIFFERENT tokens +const valid = sdk.batch() + .mint({ tick: 'TOKEN_A', amount: '100' }) + .mint({ tick: 'TOKEN_B', amount: '200' }) + .build(); + +// This would fail: two MINTs of the SAME token const invalid = sdk.batch() .mint({ tick: 'TOKEN_A', amount: '100' }) - .mint({ tick: 'TOKEN_B', amount: '200' }) // second MINT -- batch will be invalid + .mint({ tick: 'TOKEN_A', amount: '200' }) // same token twice -- batch will be invalid .build(); ``` @@ -189,7 +195,7 @@ Most failures affect one command. These reject the batch as a single record, bef - an unknown BATCH format version - a command naming an action the protocol does not recognize, which includes an empty command from a stray `;` -- more than one MINT, or more than one top-level ISSUE +- more than one MINT of the same token, or more than one top-level ISSUE - a nested BATCH - more than 250 commands - a sending address that is asleep @@ -215,10 +221,10 @@ What that means in practice: - Weights mix arithmetically. Two `EXECUTE`s (60) plus one `AIRDROP` (25) leave a budget of 165 for ordinary sub-commands in the same batch. - A full batch of VM sub-commands is 8 (8 x 30 = 240); a full batch of fan-out sub-commands is 10 (10 x 25 = 250). - A chunked contract deployment (a format-4 `DEPLOY` chunk carrier) weighs the default 1 rather than the VM weight: carrying code bytes is a data write, not a contract run, so uploading a large contract in chunks stays cheap. -- The per-action caps above do not move: one `MINT`, one top-level `ISSUE` and at most one `DEPLOY` per batch at the protocol level (the SDK builder does not compose a `DEPLOY` at all), weighted or not. +- The per-action caps above do not move: one `MINT` per distinct token, one top-level `ISSUE` and at most one `DEPLOY` per batch at the protocol level (the SDK builder does not compose a `DEPLOY` at all), weighted or not. - The count cap is still checked first, and every weight is at least 1, so more than 250 commands always busts the budget too. -*Like the other batch limits, the weighted budget is live on testnet and regtest and not yet switched on for mainnet.* +*Unlike the batch limits above, which armed on mainnet at the `2026-08-16T00:00:00Z` activation, the weighted budget is live on testnet and regtest only; it is not yet switched on for mainnet.* --- diff --git a/developer-guide/build-your-first-token.md b/developer-guide/build-your-first-token.md index 747f391..a8ac134 100644 --- a/developer-guide/build-your-first-token.md +++ b/developer-guide/build-your-first-token.md @@ -71,7 +71,9 @@ const listAction = sdk.list({ 'bc1qallowedaddress2...', ], }); -// Returns: "LIST|0|2|bc1qallowedaddress1...|bc1qallowedaddress2..." +// Action string: "LIST|0|2||bc1qallowedaddress1...|bc1qallowedaddress2..." +// The empty segment after the type is the optional MEMO, which on LIST comes +// BEFORE the variadic items; without it the first address is read as the memo. // Encode to PSBT const listPsbt = await sdk.encoder.createPSBT({ diff --git a/developer-guide/smart-contract-development.md b/developer-guide/smart-contract-development.md index 66ffee1..3b38fbb 100644 --- a/developer-guide/smart-contract-development.md +++ b/developer-guide/smart-contract-development.md @@ -113,7 +113,19 @@ var total = parseFloat(a) + parseFloat(b); var total = xchain.math.add(a, b); ``` -All `xchain.math` operations accept and return **strings**. This ensures no precision loss. +Every `xchain.math` operation **accepts** strings, and the return type depends on +what the operation is: + +| Operations | Returns | +|---|---| +| `add`, `subtract`, `multiply`, `divide`, `mod`, `min`, `max`, `abs`, `sqrt`, `pow`, `log`, `log2`, `log10` | a decimal **string** in fixed notation (no precision loss, no scientific notation) | +| `compare` | a **number**: `-1`, `0` or `1` | +| `gt`, `gte`, `lt`, `lte`, `eq`, `isZero` | a **boolean** | + +So arithmetic keeps its result in string form, which is what avoids precision +loss, while the predicates are usable directly in `if` and +`xchain.require(...)`. Do not compare a predicate against a string +(`xchain.math.gt(a, b) === 'true'` is always false). ## State Management diff --git a/developer-guide/solidity-to-xchain.md b/developer-guide/solidity-to-xchain.md index 195845d..c07a818 100644 --- a/developer-guide/solidity-to-xchain.md +++ b/developer-guide/solidity-to-xchain.md @@ -134,7 +134,9 @@ await sdk.issue({ TICK: 'MTK', MAX_SUPPLY: '1000000', DECIMALS: '8' }, encoder); Need a transfer hook (allowlist, royalty, freeze)? That is a **controller-bound token**: deploy a guard contract and bind it at issue time, so the rule is enforced -by the protocol on every transfer and cannot be bypassed by any marketplace. +by the protocol on every action of the bound class, with no marketplace able to route +around it. Bind `all` when the rule must also cover sales: a `transfer` binding gates +`SEND`s only, while `ORDER` / `SWAP` / `DISPENSER` creates route to the `trade` class. ```javascript // guard contract: the indexer calls guard(...) before a guarded action settles @@ -149,7 +151,9 @@ module.exports = { // (optional) return a royalty split via payoutLegs from ORDER_CREATE / SWAP_CREATE } }; -// bound with ISSUE v6: CONTROLLER = , ACTION_CLASS = 'transfer' (or 'all') +// bound with ISSUE v6: CONTROLLER = , ACTION_CLASS = 'all' +// ('transfer' gates SENDs only; listings route to the 'trade' class, so a royalty or +// compliance rule that must cover sales needs 'all', or 'trade' bound alongside 'transfer') ``` ## Worked example 2: Ownable counter, side by side diff --git a/getting-started/key-terms.md b/getting-started/key-terms.md index 4d5853f..3e55232 100644 --- a/getting-started/key-terms.md +++ b/getting-started/key-terms.md @@ -91,7 +91,7 @@ A reference glossary of XChain terminology, organized by category. **mintSupply**: The amount of supply issued straight to the issuing address at ISSUE time (default 0), not the amount a public MINT produces. -**SLEEP**: An ACTION that suspends another action (such as a dispenser or order) from a start block until an end block, temporarily deactivating it without cancelling it. +**SLEEP**: An ACTION that pauses actions on the broadcasting address, or on a TICK that address owns, until a chosen resume block. Nothing is cancelled, and it does not prevent dispenser dispenses, order matches, or swap matches. **SWEEP**: An ACTION that transfers the entire token balance of the broadcasting address to a destination address in a single operation. @@ -137,7 +137,7 @@ A reference glossary of XChain terminology, organized by category. **KEY_HASH**: The hex `sha256` of a gated file's symmetric key. Stored on the `FILE` action so holders can verify the key they receive in a `MESSAGE` handoff matches the file they're decrypting. Also serves as the implicit pack identifier: two or more gated FILEs sharing the same `KEY_HASH` are pack members and unlock together. -**Key Handoff**: The act of delivering a gated file's symmetric key to a token holder via an ECIES-encrypted `MESSAGE`. Sent by the issuer at publish time (to themselves, for recoverability) and by the current holder to every new holder as part of every transfer (`BATCH(SEND, MESSAGE)`). +**Key Handoff**: The act of delivering a gated file's symmetric key to a token holder via an ECIES-encrypted `MESSAGE`. Sent by the issuer at publish time (to themselves, for recoverability) and by the current holder to a new holder as part of a direct transfer (`BATCH(SEND, MESSAGE)`), which is the only path the protocol requires it on. A buyer credited by DEX settlement, a dispense, an airdrop or a dividend receives the tokens without a key. **MESSAGE**: An ACTION that stores a short arbitrary message permanently on the blockchain. Supports plaintext, ECDH session, AES pre-shared, and ECIES (encrypted to a recipient address's pubkey). ECIES MESSAGEs carry [token-gated content](../protocol/token-gated-content.md) key handoffs. diff --git a/lib/env-var-doc-coverage.js b/lib/env-var-doc-coverage.js index 1820248..bb5aa70 100644 --- a/lib/env-var-doc-coverage.js +++ b/lib/env-var-doc-coverage.js @@ -340,6 +340,37 @@ function enclosingCallName(text, from) { return name ? name.split('.').pop() : null; } +// The tokens a line may OPEN with and still be finishing the expression the +// previous line started. Deliberately short, and every entry earns its place by +// appearing after a wrapped env read in the fleet: `||` and `??` are the +// fallback itself, `,` is the radix argument on its own line, and `)`/`]`/`}` +// close the call the read sits inside so a numeric coercion's outside fallback +// is still reachable. +// +// The set is a WHITELIST because the failure directions are not symmetric. +// Omitting a token that does continue an expression loses a default, which is +// the silent exemption this scanner already had. Admitting one that does not +// invents a default, and the gate then accuses a doc row that is correct. So +// `.`, `&&`, `?`, `:` and the arithmetic operators stay out: none of them +// introduce a default, and each one is a way to walk into a neighbouring +// expression. +const CONTINUATION_TOKENS = ['||', '??', ',', ')', ']', '}']; + +/** + * Does the source at `i` continue the expression the previous line started? + * + * JavaScript has no statement terminator this scanner can rely on: several + * repos in the fleet are written without semicolons, so a walk that crossed + * every newline would read the NEXT statement's `|| 'x'` as this read's + * default. This is the ASI-shaped boundary that stops it, applied only at the + * read's own bracket depth; inside an open `(`/`[`/`{` a newline is always a + * continuation and never reaches here. + */ +function continuesExpression(text, i) { + while (i < text.length && /\s/.test(text[i])) i++; + return CONTINUATION_TOKENS.some((t) => text.startsWith(t, i)); +} + /** * Reads the effective default off the expression that follows an env read. * @@ -359,13 +390,15 @@ function enclosingCallName(text, from) { * fallback just outside itself; * - `||` or `??` introduces a fallback: a literal is the default, an * identifier (`cfg.X`) is another lookup, so keep going; + * - a NEWLINE at the read's own depth ends the search unless the next line + * opens with a continuation token (see `continuesExpression`); * - anything else ends the search. * * `'30000'` and `30000` are the same default to an operator, so the quoting is * not carried through: what matters downstream is whether the value is a * number, since that is the part a doc row gets wrong. * - * @param {string} text the source line, or the source from the read onward + * @param {string} text the whole comment-stripped source * @param {number} from index just past the `process.env.X` read * @returns {{ value: string, numeric: boolean }|null} */ @@ -380,6 +413,8 @@ function extractDefault(text, from) { while (i < text.length) { const ch = text[i]; + if (ch === '\n' && depth === 0 && !continuesExpression(text, i + 1)) return null; + if (ch === '(' || ch === '[' || ch === '{') { depth++; i++; continue; } if (ch === ')' || ch === ']' || ch === '}') { @@ -614,24 +649,60 @@ function stripComments(source) { return out; } +/** + * Maps a character offset in `text` back to its 1-based line number. + * + * Precomputed once per file, because the scans below need a line number per + * match and counting newlines from the top for each one is quadratic on the + * larger service files. + */ +function lineIndex(text) { + const starts = [0]; + for (let i = 0; i < text.length; i++) if (text[i] === '\n') starts.push(i + 1); + return (offset) => { + let lo = 0; + let hi = starts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (starts[mid] <= offset) lo = mid; else hi = mid - 1; + } + return lo + 1; + }; +} + /** * Collects every env read in a source string. * + * SCANS THE WHOLE SOURCE, not one line at a time. The line-split version could + * not see a fallback that wrapped, so `process.env.X\n || '/tmp/…'` recorded + * `default: null`, `comparableDefault` dropped the site, and the drift checks + * never compared it: the variable could be documented with a completely wrong + * default and this gate stayed green. Two real reads sat in that blind spot + * (`XCHAIN_NODE_ENCODER_MAINTENANCE_FILE` in xchain-node, and + * `CHECKPOINT_FROZEN_TIP_TICKS` in xchain-hub), and the failure direction is a + * silent pass, which is the one this file exists to stop. + * + * `stripComments` blanks rather than deletes, so offsets into the stripped text + * name the same place in the original and `lineIndex` still reports the real + * line. The line recorded is the line of the READ, not of the default. + * * @returns {Map>} */ function scanSource(source) { - const found = new Map(); - const lines = stripComments(source).split('\n'); - - lines.forEach((code, idx) => { - ENV_READ.lastIndex = 0; - let m; - while ((m = ENV_READ.exec(code)) !== null) { - const name = m[1] || m[2]; - if (!found.has(name)) found.set(name, []); - found.get(name).push({ line: idx + 1, default: extractDefault(code, m.index + m[0].length) }); - } - }); + const found = new Map(); + const stripped = stripComments(source); + const lineAt = lineIndex(stripped); + + ENV_READ.lastIndex = 0; + let m; + while ((m = ENV_READ.exec(stripped)) !== null) { + const name = m[1] || m[2]; + if (!found.has(name)) found.set(name, []); + found.get(name).push({ + line: lineAt(m.index), + default: extractDefault(stripped, m.index + m[0].length), + }); + } return found; } @@ -709,6 +780,21 @@ function escapeLiteral(value) { // number it has just asserted: `4194304` (4 MiB), `0` (auto), `600000` (10 min). const GLOSS = '(?:\\s*\\([^)]*\\))?'; +// The end of the matched value, for the UNQUOTED prose form. +// +// `(?![A-Za-z0-9_])` alone let a decimal point through, so a row reading +// "defaults to 30.5 seconds" credited a code default of `30`: the checker +// reported the row correct precisely when it had drifted, which is the silent +// pass this library exists to stop. The backticked and bare-cell forms were +// never exposed, because a closing delimiter has to follow the digits there, +// and that asymmetry is what made the hole invisible. +// +// A `.` or `,` is refused only when a DIGIT follows it, never on its own. A row +// ends its sentence ("Defaults to 30.") and separates a clause ("Defaults to +// 30, and the hub clamps it") far more often than it carries a separator, and +// refusing those would fail correct rows fleet-wide. +const TOKEN_END = '(?![A-Za-z0-9_])(?![.,]\\d)'; + /** * Does the row ASSERT this value as the default, rather than merely contain it? * @@ -736,7 +822,7 @@ function assertsDefault(rows, value) { const quoted = `(?:"${v}"|'${v}'|${v})`; const wrapped = `(?:\`${quoted}\`|${quoted})`; const bareCell = new RegExp(`^${wrapped}${GLOSS}$`, 'i'); - const asserted = new RegExp(`\\bdefaults?\\s*(?:to|is|=|:)?\\s*${wrapped}(?![A-Za-z0-9_])`, 'i'); + const asserted = new RegExp(`\\bdefaults?\\s*(?:to|is|=|:)?\\s*${wrapped}${TOKEN_END}`, 'i'); return rows.some((r) => r.split('|').some((cell) => bareCell.test(cell.trim())) || asserted.test(r)); } diff --git a/operations/run-a-validator.md b/operations/run-a-validator.md index d9a146a..4ae6e3d 100644 --- a/operations/run-a-validator.md +++ b/operations/run-a-validator.md @@ -170,11 +170,14 @@ hub logs a low-balance warning. > **Stake only when you intend to run the hub, and stand down if you stop.** > Membership is derived from on-chain stake alone, so a validator that has > staked but is not running still counts toward every capability's validator -> count `N` while contributing nothing. Because the quorum is -> `max(2*floor((N-1)/3)+1, ceil((N+1)/2))`, adding an absent validator can -> *raise* the threshold everyone else has to meet: going from 5 validators to -> 6 moves quorum from 3 to 4. It also puts you in publisher elections you -> cannot answer. If you are going to be down for more than a short while, +> count `N` while contributing nothing. Adding an absent validator *raises* the +> threshold everyone else has to meet, under either quorum rule. At or above +> `STAKE_WEIGHTED_QUORUM_ACTIVATION` the absent validator's stake still counts +> in the denominator `S` of `3 x tally > 2 x S`, so the validators who do sign +> must carry more stake between them. Below activation the count quorum +> `max(2*floor((N-1)/3)+1, ceil((N+1)/2))` moves from 3 to 4 as the set goes +> from 5 validators to 6. It also puts you in publisher elections you cannot +> answer. If you are going to be down for more than a short while, > [unstake](#removing-yourself). One stake of **25000 XCHAIN** clears every capability floor at once: diff --git a/protocol/actions/address.md b/protocol/actions/address.md index 7a8a009..e9abe27 100644 --- a/protocol/actions/address.md +++ b/protocol/actions/address.md @@ -12,7 +12,7 @@ This action configures address specific options. | `REQUIRE_MEMO` | String | Require a `MEMO` on any received `SEND` | | `DISPENSER_PREFERENCE` | String | Set preference for how dispensrs are used | | `CONTROLLER` | String | (v1) `ACTION_INDEX` of a contract whose `guard` self-gates one `ACTION_CLASS` of this account. A `transfer` binding is **symmetric**; it gates SENDs both **outbound** (account is `SOURCE`) and **inbound** (account is `DESTINATION`); the guard distinguishes via `from`/`to` | -| `ACTION_CLASS` | String | (v1) Which class to gate: `transfer`, `trade`, `burn`, `mint`, `stake`, or the catch-all `all` (fallback for any class with no specific binding; most-specific-wins) | +| `ACTION_CLASS` | String | (v1) Which class to gate: `transfer`, `trade`, `burn`, `mint`, `stake`, `ownership`, or the catch-all `all` (fallback for any class with no specific binding; most-specific-wins) | | `COOLDOWN_BLOCKS` | String | (v1) Drop-cooldown committed at bind: blocks before a later `UNBIND` takes effect | | `UNBIND` | String | (v1) `1` drops the live binding for `ACTION_CLASS`; `0` binds | | `MEMO` | String | An optional memo to include | diff --git a/protocol/actions/anchor.md b/protocol/actions/anchor.md index e69312c..e7d0476 100644 --- a/protocol/actions/anchor.md +++ b/protocol/actions/anchor.md @@ -14,7 +14,7 @@ archive, in a single action with two legs and three version-discriminated phases state checkpoint (the per-block `ledger`/`actions`/`contract` hash triple) plus a compressed batch of full `cross_chain_matches` records (including their validator signatures and the `capability_snapshots` rows needed to re-verify them), plus the elected archive leader's - `PUBLISHER` pubkey and a `max(2f+1, ceil((N+1)/2))` `oracle_publish` attestation (the + `PUBLISHER` pubkey and a quorate `oracle_publish` attestation (the `XANCPUB` canonical) keyed on `MATCH_BATCH_SEQ`, binding which validator earns the archive reward. The `PUBLISHER` tail is **always appended**; `ATTEST_SIG_COUNT` MAY be 0 when the attestation round degrades, in which case the checkpoint and archive still index `valid`, no @@ -78,7 +78,9 @@ from the hub on 2026-06-11 after ANCHOR verified end-to-end on mainnet; rows it ## Purpose 1. **Verifiable state.** Light clients verify any indexer/explorer response against a - checkpoint signed by a `max(2f+1, ceil((N+1)/2))` quorum of `oracle_publish` validators, without trusting a single operator. + checkpoint signed by a quorum of `oracle_publish` validators (stake-weighted and + source-deduped at/above `STAKE_WEIGHTED_QUORUM_ACTIVATION`, otherwise the legacy + `max(2f+1, ceil((N+1)/2))` signer count), without trusting a single operator. 2. **Full-parse recoverability.** Cross-chain match records are the only consensus-relevant dataset not natively on-chain (they are mirror-delivered; see [Cross-Chain DEX](../cross-chain-dex.md)). The v1/v2 archive places the records themselves @@ -155,8 +157,10 @@ ANCHOR|0|NETWORK|SNAPSHOT_BLOCK|SECTION_COUNT - **One publisher tail per bundle**, whatever the section count. - **Byte budget: 8189 bytes** of wire text (`MAX_ACTION_DATA_LENGTH` 8192 minus the 3-byte push prefix). A cycle that would exceed it is split chain-ascending into as many bundles as - fit, each with its own election; a single section that cannot fit even with an empty - attestation tail is refused loudly and counted, never sent truncated. + fit, each with its own election; a single section that cannot fit alongside the + attestation tail its bundle will carry is refused loudly and counted, never sent + truncated. The assembled payload is measured once more before broadcast, so an + under-estimated tail is refused here rather than dropped by the decoder. ### Version `1`: Checkpoint + match archive + publisher attestation (validator-broadcast) - `ANCHOR|1|CHAIN|NETWORK|BLOCK_INDEX|BLOCK_HASH|LEDGER_HASH|ACTIONS_HASH|CONTRACT_HASH|CHECKPOINT_SEQ|SNAPSHOT_BLOCK|MATCH_BATCH_SEQ|MATCH_COUNT|BATCH_CRC32|TOTAL_CHUNKS|ARCHIVE_B64|SIG_COUNT|PUBKEY1|SIG1|...|PUBLISHER|ATTEST_SIG_COUNT|APUBKEY1|ASIG1|...` @@ -333,8 +337,20 @@ exact bytes): `capability_snapshots` table, exactly as cross-chain settlement resolves `cross_chain`). For a v0 section that block is the section's own `SECTION_SNAPSHOT_BLOCK`. - Each `SIG_n` must Ed25519-verify against the canonical message. -- Valid signatures must reach `max(2f+1, ceil((N+1)/2))` of the snapshot set; PBFT `2f+1` - floored at a simple majority, so N=3 requires 2 (single-validator sets require 1). +- Valid signatures must reach the snapshot set's federation quorum, and which rule that is + depends on the section's own snapshot block: + - **At or above `STAKE_WEIGHTED_QUORUM_ACTIVATION`** the quorum is STAKE-WEIGHTED and + SOURCE-DEDUPLICATED. Each valid signer's pubkey resolves to its stake source in the + snapshot; sources are counted at most once however many of their keys sign, and their + summed stake `tally` must satisfy `3 x tally > 2 x S`, where `S` is the total stake of the + snapshot set summed over distinct sources. A source whose snapshot weight is missing fails + closed. Three equally weighted sources therefore need all three signatures; two are not + enough. The predicate is `meetsStakeThreshold` in + [`protocol/reference-impl/stake_weighted_quorum.js`](../reference-impl/stake_weighted_quorum.js). + - **Below activation** the quorum is the legacy signer COUNT `max(2f+1, ceil((N+1)/2))`, + `f = floor((N-1)/3)`: PBFT `2f+1` floored at a simple majority, so N=3 requires 2 and + single-validator sets require 1. The floor is what stops bare `2f+1` degenerating to a + quorum of 1 at N=3. - `CHECKPOINT_SEQ` must be ≥ any previously accepted seq for (`CHAIN`,`NETWORK`), replays of older checkpoints are recorded but flagged `stale`, never `valid`. Equal-seq records are accepted: an exact replay is signature-bound to identical content (harmless duplicate). The @@ -360,8 +376,9 @@ exact bytes): chain. - `PUBLISHER` must be 64-hex. The attestation list (`APUBKEY_n`/`ASIG_n`) is verified as a SECOND quorum over the `XANCPUB` canonical against the `oracle_publish` snapshot at the bundle's - `SNAPSHOT_BLOCK`, reaching the same `max(2f+1, ceil((N+1)/2))` (stake-weighted at/above - `STAKE_WEIGHTED_QUORUM`) threshold. + `SNAPSHOT_BLOCK`, reaching the same threshold as the section quorum above: stake-weighted and + source-deduped at/above `STAKE_WEIGHTED_QUORUM_ACTIVATION`, otherwise the legacy + `max(2f+1, ceil((N+1)/2))` signer count. - **One reward per bundle**, not one per section: a COLLECT-spendable `validator_rewards` row keyed `(SNAPSHOT_BLOCK, anchor_bundle)`, amount = the frozen `ANCHOR_REWARD_AMOUNT`, never the wire, credited **only** when every section's quorum passed, the attestation quorum is met, and diff --git a/protocol/actions/batch.md b/protocol/actions/batch.md index 6bfe235..3773c65 100644 --- a/protocol/actions/batch.md +++ b/protocol/actions/batch.md @@ -29,7 +29,7 @@ This example registers the JDOG parent token and three of its children in one tr ## Rules - Can only use one top-level (undotted) `ISSUE` action in a `BATCH` action - Child `ISSUE` actions, whose `TICK` contains a `.` (for example `JDOG.1`), are exempt from that limit: a `BATCH` may carry any number of them, subject to the command cap below -- Can only use one `MINT` action in a `BATCH` action +- Can only use one `MINT` action per DISTINCT token in a `BATCH` action: minting several different tokens in one `BATCH` is allowed, minting the same token twice is not - Can not use `BATCH` as a action in a `BATCH` action - A `BATCH` may carry at most 250 commands - At/after the `BATCH_COST_WEIGHTING` activation, the cap becomes a weighted cost budget: the sub-commands' cost weights must sum to at most 250 (see the weighted budget note below) @@ -48,11 +48,11 @@ This example registers the JDOG parent token and three of its children in one tr | Fan-out | `AIRDROP`, `DIVIDEND` | 25 | | VM | `DEPLOY`, `EXECUTE`, `XEXEC` | 30 | - Fan-out actions write a row per recipient, so one sub-command really is worth many; the weight is flat because the recipient count is not on the wire. VM actions run contract code, and the weight is sized so a full batch of worst-case VM sub-commands stays under the cost of 250 ordinary ones. A chunk-carrier `DEPLOY` (format `4`) weighs the default 1 rather than the VM weight: it is short-circuited into chunk storage before any contract code runs, so it costs a row write, not a contract run. Every weight is an integer of at least 1, so the count cap remains a sound pre-filter and is still checked (and reported) first: a batch over the command cap is always over the budget too. The weighting replaces only the flat count; the fairness caps are unchanged and still apply independently: one `MINT`, one top-level `ISSUE`, at most one `DEPLOY`, no nested `BATCH`. The gate is **active from genesis on testnet and regtest** and **not yet armed on mainnet**; it must activate at or after `BATCH_ISSUANCE_LIMITS`, whose sub-command classification and first-position check it reuses. + Fan-out actions write a row per recipient, so one sub-command really is worth many; the weight is flat because the recipient count is not on the wire. VM actions run contract code, and the weight is sized so a full batch of worst-case VM sub-commands stays under the cost of 250 ordinary ones. A chunk-carrier `DEPLOY` (format `4`) weighs the default 1 rather than the VM weight: it is short-circuited into chunk storage before any contract code runs, so it costs a row write, not a contract run. Every weight is an integer of at least 1, so the count cap remains a sound pre-filter and is still checked (and reported) first: a batch over the command cap is always over the budget too. The weighting replaces only the flat count; the fairness caps are unchanged and still apply independently: one `MINT` per distinct token, one top-level `ISSUE`, at most one `DEPLOY`, no nested `BATCH`. The gate is **active from genesis on testnet and regtest** and **not yet armed on mainnet**; it must activate at or after `BATCH_ISSUANCE_LIMITS`, whose sub-command classification and first-position check it reuses. - **Fees and settlement value are accounted cumulatively across the batch.** One command's worth of native-coin fee funds ONE sub-command, not all of them. The same running tally covers the settlement value a `COINPAY` or a `DISPENSE` draws down and the per-oracle fee outputs a `DISPENSER` pays. Fund a `BATCH` for the sum of its commands, not for one of them; a command that reaches an exhausted fee pool fails with a fee error while its siblings stand. -- **Batch-level rejections** invalidate the whole `BATCH` as one record, before any command runs: an unknown `VERSION`, a command naming an action that is not enabled (an empty command counts), more than one `MINT` or top-level `ISSUE`, a nested `BATCH`, a sleeping `SOURCE`, going over the command cap, and a source that provably cannot pay for even the cheapest command in the list (`invalid: GAS (insufficient)`). That last check is a lower bound only: gas is billed greedily in list order against one running balance, so a source that can afford some of the commands is let through and lands exactly the ones it can pay for. -- **Activation.** The child-issuance exemption, the caret-dot rejection, the 250-command cap, the cumulative fee and settlement accounting, and the aggregate gas pre-check all activate together at `BATCH_ISSUANCE_LIMITS`. That gate is **active from genesis on testnet and regtest**, and on **mainnet it activates at `2026-08-16T00:00:00Z`**. Sub-command output capture (`BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION`, which is what lets a batched `COINPAY` or `DISPENSER` be seen at all) activates at the **same instant** on mainnet, so the two halves of batch behavior arrive together rather than leaving a window where one is live and the other is not. Below that instant, mainnet history keeps the behavior it always had: one `ISSUE` per `BATCH` (dotted or not), no command cap, and per-command fee checks that each read the transaction's untouched value. Blocks before the instant are unaffected, which is why the instant was set in the future rather than backdated. The non-atomic settlement described above is not gated: it has always been how a `BATCH` behaves. -- **Sub-action normalization (flag-day gated):** until the `BATCH_SUBACTION_NORMALIZATION` activation (mainnet: the coordinated [contract-era flag day](../flag-days.md#contract-era-flag-day); testnet/regtest active from genesis), sub-actions inside a `BATCH` are NOT normalized the way top-level actions are. `ACTION` aliases (`TRANSFER`, `ADDR`, `DROP`, `CAST`, `MSG`) invalidate the whole `BATCH` (`invalid: ACTION (unknown)`), and legacy `ISSUE`/`MINT`/`SEND` params that omit the `VERSION` field are misparsed (the first param is read as the format version). Until activation, always use canonical `ACTION` names and an explicit `VERSION` in every `BATCH` command on mainnet. At/after activation, sub-actions get the same alias rewrite and legacy VERSION-0 injection as top-level actions. +- **Batch-level rejections** invalidate the whole `BATCH` as one record, before any command runs: an unknown `VERSION`, a command naming an action that is not enabled (an empty command counts), more than one `MINT` of the SAME token, more than one top-level `ISSUE`, a nested `BATCH`, a sleeping `SOURCE`, going over the command cap, and a source that provably cannot pay for even the cheapest command in the list (`invalid: GAS (insufficient)`). That last check is a lower bound only: gas is billed greedily in list order against one running balance, so a source that can afford some of the commands is let through and lands exactly the ones it can pay for. +- **Activation.** The child-issuance exemption, the caret-dot rejection, the per-distinct-token `MINT` rule, the 250-command cap, the cumulative fee and settlement accounting, and the aggregate gas pre-check all activate together at `BATCH_ISSUANCE_LIMITS`. That gate is **active from genesis on testnet and regtest**, and on **mainnet it has been active since `2026-08-16T00:00:00Z`**. Sub-command output capture (`BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION`, which is what lets a batched `COINPAY` or `DISPENSER` be seen at all) armed at the **same instant** on mainnet, so the two halves of batch behavior arrived together rather than leaving a window where one is live and the other is not. Below that instant, mainnet history keeps the behavior it always had: one `ISSUE` per `BATCH` (dotted or not), one `MINT` per `BATCH` whatever token it names, no command cap, and per-command fee checks that each read the transaction's untouched value. Blocks before the instant are unaffected, which is why the instant was set in the future rather than backdated. The non-atomic settlement described above is not gated: it has always been how a `BATCH` behaves. +- **Sub-action normalization (flag-day gated):** at and after the `BATCH_SUBACTION_NORMALIZATION` activation, sub-actions inside a `BATCH` get the same alias rewrite and legacy VERSION-0 injection as top-level actions. That gate is **active from genesis on testnet and regtest**, and on **mainnet it has been active since the coordinated [contract-era flag day](../flag-days.md#contract-era-flag-day) at `2026-08-07T00:00:00Z`**, so canonical `ACTION` names and an explicit `VERSION` are no longer required of a `BATCH` command on any live network. Below that instant, mainnet history keeps the unnormalized behavior, and a re-decode of those blocks must reproduce it: `ACTION` aliases (`TRANSFER`, `ADDR`, `DROP`, `CAST`, `MSG`) invalidate the whole `BATCH` (`invalid: ACTION (unknown)`), and legacy `ISSUE`/`MINT`/`SEND` params that omit the `VERSION` field are misparsed (the first param is read as the format version). - A `BATCH` may contain at most one `FILE` action. The decoder stores one `raw_data` payload per transaction, so a second `FILE` in the same `BATCH` would fail at the `FILE` handler rather than the `BATCH` validator. This is an architectural limit of the wire format, not an explicit `actionLimits` rule in `batch.js`. - A `FILE` may be batched with other actions, most commonly a `MESSAGE` v2 (ECIES) carrying the file's symmetric key, so that publishing a [token-gated file](../token-gated-content.md) and committing the key happen in one transaction. The two commands still settle independently, so check that both were recorded valid rather than assuming the pair moved as a unit. - `BATCH(SEND, MESSAGE)` is the canonical composition for transferring a token that has [active gated content](./send.md), the `MESSAGE` is required and re-encrypts the content keys to the recipient. diff --git a/protocol/actions/callback.md b/protocol/actions/callback.md index ae7c880..e2461fa 100644 --- a/protocol/actions/callback.md +++ b/protocol/actions/callback.md @@ -23,7 +23,7 @@ This example calls back the JDOG token to the token owner address ``` ## Rules -- `TICK` can only be called back after `CALLBACK_BLOCK` +- `TICK` can only be called back at or after `CALLBACK_BLOCK` (the window opens *at* that height) - All `TICK` supply will be returned to `TICK` owner address - All `TICK` supply holders will receive `CALLBACK_AMOUNT` of `CALLBACK_TICK` per `UNIT` diff --git a/protocol/actions/dispenser.md b/protocol/actions/dispenser.md index 8c09903..04a6612 100644 --- a/protocol/actions/dispenser.md +++ b/protocol/actions/dispenser.md @@ -253,7 +253,7 @@ An address can hold more than one open dispenser, and a single payment to that a Each dispense record therefore reports **the amount attributed to that dispense**, which is what the explorer shows as the dispense's get-amount. It is the coin that particular fill was charged, not the whole payment that triggered it, so the get-amounts of the dispenses from one payment now add up to what the buyer spent instead of each restating the full payment. Dispenses that settle nothing, and dispenses on a chain where the gate below is not yet in force, keep the older whole-payment figure. -*(gated on `BATCH_ISSUANCE_LIMITS`, which is active from genesis on testnet and regtest and activates on mainnet at `2026-08-16T00:00:00Z`. Below that instant, each dispenser behind a paid address prices against the full payment and each record restates it.)* +*(gated on `BATCH_ISSUANCE_LIMITS`, which is active from genesis on testnet and regtest and has been active on mainnet since `2026-08-16T00:00:00Z`. Below that instant, each dispenser behind a paid address prices against the full payment and each record restates it.)* ### Oracle Front-Running Protection User oracles (PRICE v1) have a built-in anti-front-running mechanism: **every** price for a `(ORACLE_ADDRESS, COIN, TICK, FIAT)` combination, **including the first**, takes effect 86400 seconds (24 hours) after its `block_time`. An oracle operator therefore cannot see an incoming dispenser payment and rush a price update to manipulate the exchange rate; pending payments settle at the price already in effect. diff --git a/protocol/actions/file.md b/protocol/actions/file.md index c1d136c..0f439d2 100644 --- a/protocol/actions/file.md +++ b/protocol/actions/file.md @@ -55,8 +55,9 @@ This example uploads an encrypted ZIP gated by the PEPECREATURE token. `ENCRYPTI ## Rules - When `GATE_TICKER` is non-empty, the SOURCE address must be the **issuer** of the gated token (i.e. the OWNER of the most recent valid `ISSUE` for `GATE_TICKER`). Otherwise the FILE is invalid. Prevents third parties from gating spam content to popular tickers. +- When `GATE_TICKER` is non-empty, that token's ownership must not be currently escrowed (`ORDER` / `SWAP` / `DISPENSER` with `GIVE_OWNERSHIP=1` is open against this `TICK`) (see [Token Ownership Sales](./order.md#token-ownership-sales)). An issuer who has listed ownership for sale is still the OWNER, so this rejects separately from the rule above. - When `GATE_TICKER` is non-empty, `ENCRYPTION_METHOD` must be `1` (AES-256-GCM). Other values reserved for future algorithms. -- When `GATE_TICKER` is non-empty, `KEY_HASH` must be a 64-character lowercase hex string (32 bytes / 256 bits). +- When `GATE_TICKER` is non-empty, `KEY_HASH` must be a 64-character hex string (32 bytes / 256 bits). Publishers **must** emit it lowercase, which is what `sha256` produces, but validity is case-insensitive: an uppercase hash is accepted and the indexer records the canonical lowercase form, so the stored value then differs in case from the bytes the publisher signed. Pack grouping and holder-side key verification both compare the lowercased form, so neither depends on the emitted case. - When `GATE_TICKER` is non-empty, `rawData` is the ciphertext: `[12-byte nonce][16-byte GCM authentication tag][ciphertext]`. - `GATE_MIN_AMOUNT`, when present, must be a decimal amount strictly greater than zero (every zero form is invalid), at most 40 characters, digits with at most one `.`, no leading zeros unless the integer part is exactly `0`, a non-empty fractional part whenever a `.` is present, and no more decimal places than min(the gate token's divisibility, 18). A present-but-invalid value makes the FILE invalid rather than being ignored: a FILE is immutable, so a dropped threshold would leave the publisher believing one was in force while the chain recorded none. - `GATE_MIN_AMOUNT` is only meaningful with a `GATE_TICKER`; on a non-gated FILE it is invalid, since there is no balance to weigh it against. diff --git a/protocol/actions/issue.md b/protocol/actions/issue.md index b0b039f..75d3382 100644 --- a/protocol/actions/issue.md +++ b/protocol/actions/issue.md @@ -25,16 +25,16 @@ This action creates or updates a `TICK`. | `LOCK_DESCRIPTION` | String | Lock `TICK` against `DESCRIPTION` changes | | `LOCK_SLEEP` | String | Lock `TICK` against `SLEEP` command | | `LOCK_CALLBACK` | String | Lock `TICK` against `CALLBACK` command | -| `CALLBACK_BLOCK` | String | Enable `CALLBACK` command after `CALLBACK_BLOCK` | +| `CALLBACK_BLOCK` | String | Enable `CALLBACK` command at or after `CALLBACK_BLOCK` | | `CALLBACK_TICK` | String | `TICK` that users get when `CALLBACK` command is used | | `CALLBACK_AMOUNT` | String | `CALLBACK_TICK` amount that users get when `CALLBACK` command is used | | `ALLOW_LIST` | String | `ACTION_INDEX` of a `LIST` of addresses allowed to interact with this token | | `BLOCK_LIST` | String | `ACTION_INDEX` of a `LIST` of addresses NOT allowed to interact with this token | | `MINT_ADDRESS_MAX` | String | Maximum amount of supply any address can mint via `MINT` transactions | -| `MINT_START_BLOCK` | String | `BLOCK_INDEX` when `MINT` transactions are allowed (begin mint) | -| `MINT_STOP_BLOCK` | String | `BLOCK_INDEX` when `MINT` transactions are NOT allowed (end mint) | +| `MINT_START_BLOCK` | String | First `BLOCK_INDEX` at which `MINT` transactions are allowed (begin mint, inclusive) | +| `MINT_STOP_BLOCK` | String | Last `BLOCK_INDEX` at which `MINT` transactions are allowed (end mint, inclusive) | | `CONTROLLER` | String | `ACTION_INDEX` of a deployed contract whose `guard` gates one `ACTION_CLASS` of this token (see [Controller-Bound Tokens](../controller-bound-tokens.md)) | -| `ACTION_CLASS` | String | Which class the binding gates: `transfer`, `trade`, `burn`, `mint`, `stake`, or the catch-all `all` (fallback for any class with no specific binding; most-specific-wins) | +| `ACTION_CLASS` | String | Which class the binding gates: `transfer`, `trade`, `burn`, `mint`, `stake`, `ownership`, or the catch-all `all` (fallback for any class with no specific binding; most-specific-wins) | | `COOLDOWN_BLOCKS` | String | Drop-cooldown committed at bind time: blocks of friction before a later `UNBIND` takes effect | | `UNBIND` | String | `1` drops the live binding for `ACTION_CLASS`; `0` binds | | `MEMO` | String | An optional memo to include | diff --git a/protocol/actions/list.md b/protocol/actions/list.md index 0a9d4f5..272c187 100644 --- a/protocol/actions/list.md +++ b/protocol/actions/list.md @@ -51,7 +51,12 @@ This example creates a new list from an existing list (4321) and removes 2 addre ``` ## Rules -- In order for a `LIST` to be considered `valid`, all `TICK` or `ADDRESS` must be valid +- Each `ITEM` is judged on its own. An item that fails its type check (an unknown `TICK`, + or an `ADDRESS` the format check rejects) is recorded `invalid` and left OUT of the + list's item set; it does not fail the action. A `LIST` is `valid` or not on its fixed + fields alone (`VERSION`, `TYPE`/`EDIT`, `LIST_ACTION_INDEX`, `MEMO`, and a `SOURCE` + that is not sleeping), so a `LIST` whose every item was rejected still publishes, as an + empty list. Read the resulting membership back rather than assuming what you sent - A `TICK` list contains only `TICK` items - A `ADDRESS` list contains only `ADDRESS` items diff --git a/protocol/constants.js b/protocol/constants.js index cf7cbbf..9894567 100644 --- a/protocol/constants.js +++ b/protocol/constants.js @@ -244,10 +244,11 @@ const MAX_DEPLOYCHUNK_PART_BYTES = 7800; // cross-service regression suite (protocol-constant-claims.test.js) asserts // both copies and every prose claim equal this value. // -// Gated by BATCH_ISSUANCE_LIMITS in the indexer's protocol_changes.js: -// active from genesis on testnet/regtest, not yet armed on mainnet (see -// protocol/actions/batch.md). Before that instant mainnet enforces no -// command cap at all; this constant is the value the gate gives it. +// Gated by BATCH_ISSUANCE_LIMITS in the indexer's protocol_changes.js: active +// from genesis on testnet/regtest, and armed on mainnet since 1786838400 +// (2026-08-16T00:00:00Z) - see protocol/actions/batch.md. Below that instant +// mainnet history enforces no command cap at all; this constant is the value +// the gate gives it. const BATCH_COMMAND_LIMIT = 250; // ── BATCH weighted cost budget (BATCH_COST_WEIGHTING) ─────────────────────── diff --git a/protocol/controller-bound-tokens.md b/protocol/controller-bound-tokens.md index b7ae790..bd6c72b 100644 --- a/protocol/controller-bound-tokens.md +++ b/protocol/controller-bound-tokens.md @@ -62,8 +62,20 @@ is committed: 4. **`revert`, error, or run out of gas** and the action is denied: it is recorded `invalid: controller ()`, and everything the guard did is rolled back. -Exactly one guard runs per action (there is no stacking). To layer several policies, put -them inside one controller's `guard`. +Exactly one guard runs **per subject and action class** (there is no stacking): within one +subject, the specific binding overrides the catch-all `all` and only that one guard runs. A +single native action can still invoke **several** guards, because it has several subjects and +may have several legs. A direct `SEND` runs the token's `transfer` guard, then the `SOURCE` +account's outbound `transfer` guard, then the `DESTINATION` account's inbound one (see +[Account controllers](#account-address-controllers)); bulk actions (`AIRDROP` / `DIVIDEND` / +`SWEEP`) repeat the applicable guards per tick or leg. Each run is metered separately against +`GAS_SCHEDULE.VM_GUARD_GAS_CEILING` and the reservations are cumulative (see [Gas](#gas)), so +budget `GAS` for every guard an action can invoke, not for one. + +To layer several policies on the *same* subject and class, put them inside that controller's +`guard`. Note that a controller does not re-enter its own guard for the moves that guard +emits, even when it also governs the moved subject: see +[Reentrancy and determinism](#reentrancy-and-determinism). ```mermaid sequenceDiagram @@ -142,7 +154,9 @@ A seventh value, `all`, is **bindable but never routable** (see below). `all` is a class you may **bind** a controller to, but no action ever **routes** to it directly. Instead, `all` is the **fallback** when an action's specific class has no binding. -Resolution is **most-specific-wins**, and **exactly one guard ever runs**: +Resolution is **most-specific-wins**, and for one subject and one action class **exactly one +guard runs** (every other subject the same action involves resolves its own guard +independently): 1. Resolve the effective controller for the action's specific class (e.g. `transfer`). 2. If there is none, fall back to the effective `all` controller. @@ -359,10 +373,15 @@ per-recipient**. This is a deliberate protocol decision, not a gap: account needs to control who may *hold* or *receive* it, express that as a `transfer` restriction that the recipient's balance is subject to on its next outbound move: -- **Token-level:** the token's `transfer` guard gates every subsequent `SEND` or listing of - the token, so an unwanted airdropped balance is inert; it cannot move or trade without - passing the guard. An allowlist or compliance guard therefore does not need per-recipient - drop gating; unapproved holders simply cannot do anything with the drop. +- **Token-level:** the token's `transfer` guard gates every subsequent `SEND` of the drop, but + it does **not** gate listings. `ORDER` / `SWAP` / `DISPENSER` creates route to the + [`trade` class](#action-classes), and resolution falls back only to `all`, never to + `transfer`; no guard runs at match or dispense either. A `transfer`-only binding therefore + leaves an unwanted airdropped balance listable and sellable. To make such a balance inert, + bind `all`, or bind `trade` alongside `transfer`. An allowlist or compliance guard still does + not need per-recipient drop gating, but it must cover the `trade` class as well as + `transfer`; a `transfer`-only binding stops unapproved holders from sending the drop, not + from selling it. - **Account-level:** an inbound `ADDRESS` `transfer` binding (see [Account controllers](#account-address-controllers)) lets an account refuse direct unsolicited `SEND`s. Bulk drops, like DEX and dispenser deliveries, are not gated inbound; @@ -481,9 +500,20 @@ Running the guard costs VM gas, billed to the action's `SOURCE` in `XCHAIN` at - The guard runs as an ordinary deterministic VM execution, so every validator produces the identical decision and side effects. -- A guard whose `emit.send` moves **another** controlled token triggers that token's guard one - level deeper. Guard depth is capped by `VM_MAX_CALL_DEPTH` (4); exceeding it denies the - originating action. This reuses the existing cross-contract call-depth machinery. +- **No guard-of-guard, and the exemption is keyed on the CONTROLLER, not on the token.** A + guard's `emit.send` triggers a nested guard only when the moved subject resolves to a + *different* controller contract. The skip test compares the emitting contract against the + controller that would run, so a controller that also governs the moved subject never + re-enters its own guard for it, and no guard fee is charged for that move. Where a + different controller does resolve, guard depth is capped by `VM_MAX_CALL_DEPTH` (4); + exceeding it denies the originating action. This reuses the existing cross-contract + call-depth machinery. +- **Consequence for shared controllers.** Because the exemption keys on controller identity, + one controller bound to several tokens (or to a token and an account) will not see its own + guard re-run for any of those subjects within a single action: the policy it enforces on a + user-submitted action does **not** re-apply to the moves the same guard emits. A policy that + must also constrain the guard's own emissions has to enforce that inline, in the same + `guard` body. - Guard state changes and emissions are wrapped in a dedicated DB savepoint (`controller_guard___`); any emission failure rolls the whole guard back and denies, the same atomicity model as [`EXECUTE`](./actions/execute.md). diff --git a/protocol/error-codes.md b/protocol/error-codes.md index 27c6a8a..d267116 100644 --- a/protocol/error-codes.md +++ b/protocol/error-codes.md @@ -93,9 +93,14 @@ JSON-RPC 2.0 error objects: | `-32603` | Internal error (node RPC failure, encoder failure) | all | Yes: with backoff | | `-32000` | Server error | all | Yes: with backoff | | `-32001` | Unauthorized: missing/invalid API key (`x-api-key` for encoder/hub, `Authorization: Bearer` for SDK API) | all | No: fix credentials | -| `-32029` | Too many requests (rate limit) | encoder | Yes: back off | +| `-32029` | Too many requests (rate limit) | encoder, hub | Yes: back off | +| `-32005` | Too many requests (rate limit) on the **SDK API only**, which does not use `-32029`. Served as HTTP `429` with a `Retry-After` header carrying the seconds until the caller's window resets; the message names the limit and the window | SDK API | Yes: wait out `Retry-After`, then back off | | `-32010` | Operational error: an expected, caller-actionable condition (`create_tx`, `create_envelope_cancel_tx`). `error.data.reason` carries a stable code from the table below; branch on it, never on `message` | encoder | Depends on `reason` (see below) | +Rate limiting is the one condition with two codes. A client that talks to more than one of +these services must treat `-32029` and `-32005` as the same condition, or key retry on the +HTTP `429` and the `Retry-After` header, which every one of them sets. + ### Encoder operational reasons A `-32010` error always carries `error.data.reason`, a stable string that is append-only like the numeric codes, plus the reason-specific fields listed here. The `message` is encoder-authored prose and may be reworded at any time. diff --git a/protocol/index-id-references.md b/protocol/index-id-references.md index 732abba..c539975 100644 --- a/protocol/index-id-references.md +++ b/protocol/index-id-references.md @@ -58,12 +58,20 @@ fields of an action: reference before its address format check. Two id-receiving fields are NOT resolved on input. A `^` written there is judged by -the plain address format check, so the action is rejected on chain with the fee spent: - -- `SEND.DESTINATION`: rejected as `invalid: DESTINATION (format)`. Write every `SEND` - destination in full, whether the send has one recipient or many. -- `LIST.ITEM` when the list `TYPE` is address: rejected as `invalid: ADDRESS (format)`. - Write every address list item in full. +the plain address format check, which it always fails, and the transaction fee is spent +either way. What the failure costs differs, because each field is scored at its own +granularity, not at the action's: + +- `SEND.DESTINATION`: the **leg** carrying the reference is recorded + `invalid: DESTINATION (format)` and moves nothing. Each leg of a multi-recipient + `SEND` is validated on its own, so the remaining legs still settle; a single-recipient + `SEND` has one leg, so there the whole send fails. Write every `SEND` destination in + full, whether the send has one recipient or many. +- `LIST.ITEM` when the list `TYPE` is address: the **item** is recorded + `invalid: ADDRESS (format)` and left out of the materialized item set, while the `LIST` + action itself stays `valid` and publishes the rest. So a roster or allow-list that + carries a reference silently ships short rather than failing loudly. Write every + address list item in full. Two resolved-on-input fields must still be written in full by clients: `DISPENSER.GET_ADDRESS` and `DISPENSER.ORACLE_ADDRESS`. The indexer resolves a `^` in diff --git a/protocol/json/README.md b/protocol/json/README.md index e0e1651..dd7a5fd 100644 --- a/protocol/json/README.md +++ b/protocol/json/README.md @@ -5,12 +5,16 @@ Machine-readable JSON artifacts for the XChain protocol. -## Token Information Standard (v1.0.0) +## Token Information Standard (v1.1.0, current) The off-chain token metadata document referenced by a token's on-chain `DESCRIPTION` URI. -- [Schema](./token-information-standard-v1.0.0-schema.json): JSON Schema for the metadata document. -- [Example](./token-information-standard-v1.0.0-example.json): a worked example that conforms to the schema. +- [Schema](./token-information-standard-v1.1.0-schema.json): JSON Schema for the metadata document. +- [Example](./token-information-standard-v1.1.0-example.json): a worked example that conforms to the schema. + +### Previous versions + +- v1.0.0: [schema](./token-information-standard-v1.0.0-schema.json), [example](./token-information-standard-v1.0.0-example.json). Frozen as published; it predates the token-gating fields (`packs`, `title`, `data_ref`, `locked`, `pack_id`). v1.1.0 is additive over it, so every v1.0.0 document is a valid v1.1.0 document. See the [Token Information Standard](../token-information-standard.md) for the field-by-field reference. diff --git a/protocol/json/token-information-standard-v1.1.0-example.json b/protocol/json/token-information-standard-v1.1.0-example.json new file mode 100644 index 0000000..dad7771 --- /dev/null +++ b/protocol/json/token-information-standard-v1.1.0-example.json @@ -0,0 +1,170 @@ +{ + "tick": "MYTOKEN", + "description": "This is a text description of MYTOKEN", + "website": "http://www.mysite.com", + "name": "Token Name", + "html": "", + + "owner": { + "name": "John Smith", + "title": "Chief Technology Officer (CFO)", + "organization": "ABC Technologies, Inc." + }, + + "contacts": [{ + "type": "address", + "data": "1234 Main Street, Seattle, WA 98104" + },{ + "type": "email", + "data": "info@domain.com" + },{ + "type": "phone", + "data": "1-949-555-1234" + },{ + "type": "fax", + "data": "1-949-555-1234" + },{ + "type": "url", + "data": "https://domain.com" + }], + + "categories":[{ + "type": "main", + "data": "Art" + },{ + "type": "sub", + "data": "Photographer" + },{ + "type": "other", + "data": "Skyline/Sunset Photographs" + }], + + "social": [{ + "type": "github", + "data": "https://github.com/XChain-Platform" + },{ + "type": "facebook", + "data": "https://facebook.com/XChain-Platform" + },{ + "type": "twitter", + "data": "https://twitter.com/xchain_io" + },{ + "type": "telegram", + "data": "https://t.me/xchain_io" + }], + + + "images": [{ + "type": "icon", + "size": "48x48", + "data": "https://domain.com/icon.png", + "hash": "8031025a667824a188dd5479ca5cb20c5306be06ed01875f7bcc11ecb48251be" + },{ + "type": "icon", + "size": "128x128", + "data": "https://domain.com/icon128.png" + },{ + "type": "standard", + "data": "https://domain.com/image.png" + },{ + "type": "large", + "name": "Image Name / Title", + "data": "https://domain.com/image_large.png" + },{ + "type": "hires", + "name": "Image Name / Title", + "data": "https://domain.com/image_hires.png" + }], + + "audio": [{ + "type": "m4a", + "data": "https://domain.com/audio.m4a", + "name": "Audio Name / Title", + "hash": "8031025a667824a188dd5479ca5cb20c5306be06ed01875f7bcc11ecb48251be" + },{ + "type": "mp3", + "name": "Audio Name / Title", + "data": "https://domain.com/audio.mp3" + },{ + "type": "wav", + "name": "Audio Name / Title", + "data": "https://domain.com/audio.wav" + }], + + "video": [{ + "type": "mp4", + "data": "https://domain.com/video.mp4", + "name": "Video Name / Title", + "hash": "8031025a667824a188dd5479ca5cb20c5306be06ed01875f7bcc11ecb48251be" + },{ + "type": "mov", + "name": "Video Name / Title", + "data": "https://domain.com/video.mov" + },{ + "type": "wmv", + "name": "Video Name / Title", + "data": "https://domain.com/video.wmv" + }], + + "files": [{ + "type": "doc", + "data": "https://domain.com/word.doc", + "name": "File Name / Title", + "hash": "8031025a667824a188dd5479ca5cb20c5306be06ed01875f7bcc11ecb48251be" + },{ + "type": "pdf", + "name": "File Name / Title", + "data": "https://domain.com/document.pdf" + },{ + "type": "xls", + "name": "File Name / Title", + "data": "https://domain.com/excel.xls" + },{ + "type": "other", + "name": "File Name / Title", + "data": "https://domain.com/filename.ext" + },{ + "type": "pdf", + "name": "liner-notes.pdf", + "title": "Liner Notes", + "data": "https://domain.com/liner-notes-preview.pdf", + "data_ref": "action:12345", + "locked": true, + "pack_id": "deluxe" + },{ + "type": "other", + "name": "stems.zip", + "title": "Stem Pack", + "data": "https://domain.com/stems-preview.zip", + "data_ref": "action:DOGE:67890", + "locked": true, + "pack_id": "deluxe" + }], + + "packs": { + "deluxe": { + "name": "Deluxe Edition", + "description": "Liner notes and stems, unlocked by holding the gate token" + } + }, + + "dns": [{ + "type": "A", + "host": "@", + "value": "123.123.123.123" + },{ + "type": "CNAME", + "host": "www", + "value": "domain.com" + },{ + "type": "TXT", + "host": "@", + "value": "google-site-verification=ihWf7hO1uxOcEyEW5KWRI1NtscPyJtQ6ko4BYQuC1Q8" + },{ + "type": "MX", + "host": "@", + "priority": 10, + "value": "aspmx.l.google.com" + }] + +} \ No newline at end of file diff --git a/protocol/json/token-information-standard-v1.1.0-schema.json b/protocol/json/token-information-standard-v1.1.0-schema.json new file mode 100644 index 0000000..b98809b --- /dev/null +++ b/protocol/json/token-information-standard-v1.1.0-schema.json @@ -0,0 +1,421 @@ +{ + "title": "Token Information Standard Schema", + "type": "object", + "$schema": "http://json-schema.org/draft-04/schema", + "version": "1.1.0", + + "properties": { + "tick": { + "type": "string", + "description": "The TICK name that represents the token" + }, + "description": { + "type": "string", + "maxLength": 2048, + "description": "A full text description of the token" + }, + "html": { + "type": "string", + "maxLength": 10000, + "description": "A snippet of HTML which can be displayed to provide additional token information and functionality" + }, + "website": { + "type": "string", + "format": "uri", + "maxLength": 255, + "description": "A URI with more information the token" + }, + "name": { + "type": "string", + "maxLength": 127, + "description": "The full name of the token" + }, + "owner": { + "$ref": "#/definitions/owner", + "description": "Information about the owner of this token" + }, + "contacts": { + "type": "array", + "items": { "$ref": "#/definitions/contacts" }, + "uniqueItems": true, + "description": "Information about how to contact the owner of this token" + }, + "categories": { + "type": "array", + "items": { "$ref": "#/definitions/categories" }, + "uniqueItems": true, + "description": "Information on what type of categories this token falls into" + }, + "social": { + "type": "array", + "items": { "$ref": "#/definitions/social" }, + "description": "Social media accounts related to this token" + }, + "images": { + "type": "array", + "items": { "$ref": "#/definitions/images" }, + "uniqueItems": true, + "description": "One or more images used to represent the token." + }, + "audio": { + "type": "array", + "items": { "$ref": "#/definitions/audio" }, + "uniqueItems": true, + "description": "One or more audio files related to the token." + }, + "video": { + "type": "array", + "items": { "$ref": "#/definitions/video" }, + "uniqueItems": true, + "description": "One or more video files related to the token." + }, + "files": { + "type": "array", + "items": { "$ref": "#/definitions/files" }, + "uniqueItems": true, + "description": "One or more files related to the token." + }, + "dns": { + "type": "array", + "items": { "$ref": "#/definitions/dns" }, + "uniqueItems": true, + "description": "One or more DNS records related to the token." + }, + "packs": { + "type": "object", + "additionalProperties": { "$ref": "#/definitions/pack" }, + "description": "Display metadata for token-gated content packs, keyed by pack id. A file entry joins a pack through its pack_id." + } + }, + + "required": ["tick", "name"], + + "definitions": { + + "owner": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 128, + "description": "The full name of the contact for the owner of this token" + }, + "title": { + "type": "string", + "maxLength": 128, + "description": "The organization title for the owner of this token" + }, + "organization": { + "type": "string", + "maxLength": 128, + "description": "The organization name that owns this token" + } + } + }, + + "categories": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["main", "sub", "other"], + "description": "Type of category being given (main, subcategory, other)" + }, + "data": { + "type": "string", + "format": "string", + "maxLength": 255, + "description": "Description of the category" + } + }, + "required": ["type", "data"] + }, + + "contacts": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["address", "email", "phone", "fax", "url"], + "description": "Type of contact information that is being given" + }, + "data": { + "type": "string", + "format": "string", + "maxLength": 255, + "description": "The contact information (address, email, phone, etc)" + } + }, + "required": ["type", "data"], + "additionalProperties": false + }, + + "social": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Type of social media account that is being given (github, facebook, twitter, telegram, discord, etc)" + }, + "data": { + "type": "string", + "format": "string", + "maxLength": 255, + "description": "A URI for the social media account" + } + }, + "required": ["type", "data"] + }, + + "pack": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 255, + "description": "Display name of the content pack" + }, + "description": { + "type": "string", + "maxLength": 2048, + "description": "A description of what the content pack contains" + } + } + }, + + "images": { + "type": "object", + "properties": { + "type": { + "enum": ["icon", "standard", "large", "hires"], + "description": "The type of image being given" + }, + "size": { + "description": "The size of the image for pixel-based images or svg for SVG images" + }, + "data": { + "type": "string", + "format": "string", + "maxLength": 255, + "description": "A URI to the image file" + }, + "name": { + "type": "string", + "format": "string", + "maxLength": 255, + "description": "Name / Title of the image or artwork" + }, + "hash": { + "type": "string", + "format": "string", + "maxLength": 64, + "description": "A sha256 hash of the image file" + }, + "title": { + "type": "string", + "maxLength": 255, + "description": "Display title for this entry" + }, + "data_ref": { + "type": "string", + "maxLength": 255, + "description": "Reference to an on-chain FILE action carrying the image bytes, by ACTION_INDEX: action: on the token's own chain, or action:: on a sibling chain. Clients prefer data_ref over data when both are present." + }, + "locked": { + "type": "boolean", + "description": "true when the referenced FILE is encrypted and token-gated, so a client can render a locked state without fetching it" + }, + "pack_id": { + "type": "string", + "maxLength": 255, + "description": "Identifier of the content pack this entry belongs to; keys into the top-level packs map for display metadata" + } + }, + "required": ["type", "data"] + }, + + "audio": { + "type": "object", + "properties": { + "type": { + "enum": ["m4a", "mp3", "wav"], + "description": "The type of audio file being given" + }, + "data": { + "type": "string", + "format": "string", + "maxLength": 255, + "description": "A URI to the audio file" + }, + "name": { + "type": "string", + "format": "string", + "maxLength": 255, + "description": "Name / Title of the audio or artwork" + }, + "hash": { + "type": "string", + "format": "string", + "maxLength": 64, + "description": "A sha256 hash of the audio file" + }, + "title": { + "type": "string", + "maxLength": 255, + "description": "Display title for this entry" + }, + "data_ref": { + "type": "string", + "maxLength": 255, + "description": "Reference to an on-chain FILE action carrying the audio bytes, by ACTION_INDEX: action: on the token's own chain, or action:: on a sibling chain. Clients prefer data_ref over data when both are present." + }, + "locked": { + "type": "boolean", + "description": "true when the referenced FILE is encrypted and token-gated, so a client can render a locked state without fetching it" + }, + "pack_id": { + "type": "string", + "maxLength": 255, + "description": "Identifier of the content pack this entry belongs to; keys into the top-level packs map for display metadata" + } + }, + "required": ["type", "data"] + }, + + "video": { + "type": "object", + "properties": { + "type": { + "enum": ["mp4", "mov", "wmv"], + "description": "The type of video file being given" + }, + "data": { + "type": "string", + "format": "string", + "maxLength": 255, + "description": "A URI to the video file" + }, + "name": { + "type": "string", + "format": "string", + "maxLength": 255, + "description": "Name / Title of the video or artwork" + }, + "hash": { + "type": "string", + "format": "string", + "maxLength": 64, + "description": "A sha256 hash of the video file" + }, + "title": { + "type": "string", + "maxLength": 255, + "description": "Display title for this entry" + }, + "data_ref": { + "type": "string", + "maxLength": 255, + "description": "Reference to an on-chain FILE action carrying the video bytes, by ACTION_INDEX: action: on the token's own chain, or action:: on a sibling chain. Clients prefer data_ref over data when both are present." + }, + "locked": { + "type": "boolean", + "description": "true when the referenced FILE is encrypted and token-gated, so a client can render a locked state without fetching it" + }, + "pack_id": { + "type": "string", + "maxLength": 255, + "description": "Identifier of the content pack this entry belongs to; keys into the top-level packs map for display metadata" + } + }, + "required": ["type", "data"] + }, + + "files": { + "type": "object", + "properties": { + "type": { + "description": "The type of file being given (doc, xls, pdf, other)" + }, + "data": { + "type": "string", + "format": "string", + "maxLength": 255, + "description": "A URI to the file" + }, + "name": { + "type": "string", + "format": "string", + "maxLength": 255, + "description": "Name / Title of the file" + }, + "hash": { + "type": "string", + "format": "string", + "maxLength": 64, + "description": "A sha256 hash of the file" + }, + "title": { + "type": "string", + "maxLength": 255, + "description": "Display title for this entry" + }, + "data_ref": { + "type": "string", + "maxLength": 255, + "description": "Reference to an on-chain FILE action carrying the file bytes, by ACTION_INDEX: action: on the token's own chain, or action:: on a sibling chain. Clients prefer data_ref over data when both are present." + }, + "locked": { + "type": "boolean", + "description": "true when the referenced FILE is encrypted and token-gated, so a client can render a locked state without fetching it" + }, + "pack_id": { + "type": "string", + "maxLength": 255, + "description": "Identifier of the content pack this entry belongs to; keys into the top-level packs map for display metadata" + } + }, + "required": ["type", "data"] + }, + + "dns": { + "type": "object", + "properties": { + "type": { + "enum": [ "A", "AAAA", "ALIAS", "CAA", "CNAME", "NS", "SRV", "TXT", "URL", "MX", ""], + "description": "The type of DNS record" + }, + "host": { + "type": "string", + "format": "string", + "maxLength": 255, + "description": "The hostname for this DNS record" + }, + "value": { + "type": "string", + "format": "string", + "maxLength": 255, + "description": "The DNS value you wish to use for this record" + }, + "priority": { + "type": "integer", + "format": "integer", + "maxLength": 3, + "description": "The record priority level" + } + }, + "if": { + "properties": { + "type": { "const": "MX" } + }, + "required": ["type"] + }, + "then": { + "required": ["type", "host", "value", "priority"] + }, + "else": { + "required": ["type", "host", "value"] + }, + "additionalProperties": false + } + } +} \ No newline at end of file diff --git a/protocol/nft-standard.md b/protocol/nft-standard.md index 142edc0..1fdaddb 100644 --- a/protocol/nft-standard.md +++ b/protocol/nft-standard.md @@ -226,7 +226,7 @@ answerable from chain state: ## Distribution and trading -All existing rails apply to NFT-pattern tokens with no special cases: +All existing rails apply to NFT-pattern tokens, with one exception noted under Trading below: - **Drops:** `MINT` fair-mint windows (`MAX_MINT`, `MINT_ADDRESS_MAX`, `MINT_START_BLOCK`/`MINT_STOP_BLOCK`), [`AIRDROP`](./actions/airdrop.md) to holder @@ -235,7 +235,12 @@ All existing rails apply to NFT-pattern tokens with no special cases: - **Trading:** [`ORDER`](./actions/order.md) (token/token or token/native-coin pairs, including cross-chain orders settled by the validator federation) and [`SWAP`](./actions/swap.md). Indivisibility is enforced throughout; a fractional - amount of a 0-decimals token is invalid in every path. + amount of a 0-decimals token is invalid in every path. The one exception to + "no special cases": below the `CROSS_CHAIN_ROYALTY` flag-day a **cross-chain** + ORDER or SWAP whose controller guard returns `payoutLegs` is denied at create + (`royalty not enforceable cross-chain`), because the proceeds settle on a chain + that never runs the guard. Same-chain listings and unbound tokens are unaffected. + See [Cross-chain sales](./controller-bound-tokens.md#cross-chain-sales-cross_chain_royalty). - **Issuer-rights sales:** `GIVE_OWNERSHIP`/`GET_OWNERSHIP` on ORDER/SWAP/DISPENSER sell the token's *ownership record* (the right to link files, edit unlocked fields, issue children under a parent), distinct from holding its supply. Ownership trades @@ -253,9 +258,14 @@ a token binds a controller contract (via `ISSUE` v6) whose `guard` the indexer r the token is listed for sale. The guard returns a basis-point split (`payoutLegs`) that the indexer records on the order and applies to the seller's proceeds at each DEX match; the creator's cut plus the seller's remainder, conserved exactly. Because the indexer is -the only settlement path and the same controller can also gate plain `SEND`s, the guard -**cannot be bypassed**, and it needs no custody: the token stays natively held and natively -tradeable. The binding is opt-in per token, not imposed platform-wide. +the only settlement path, **no marketplace or side venue can route around a bound guard**: +it runs on every action of the class it is bound to, and it needs no custody, so the token +stays natively held and natively tradeable. The binding is opt-in per token, not imposed +platform-wide. Two limits keep that from being an absolute: a binding covers only the +classes it is bound to, so gating listings *and* plain `SEND`s means binding `all`, or +binding `trade` alongside `transfer`; and a controller's own guard emissions are exempt from +its own guard, keyed on controller identity rather than token identity (see +[Reentrancy and determinism](./controller-bound-tokens.md#reentrancy-and-determinism)). **The split itself covers `ORDER` and `SWAP` sales only.** A `DISPENSER` sale runs the guard at create as a *veto* and takes no cut: legs returned there are discarded, and no split is @@ -264,6 +274,13 @@ applied at dispense (see royalty guard that only *returns legs* is routed around by vending through a dispenser; enforcing the cut means also denying the dispenser listing from inside the guard. +**Cross-chain listings are denied while the split cannot be enforced.** Below the +`CROSS_CHAIN_ROYALTY` flag-day, an ORDER or SWAP listing on a cross-chain pair whose guard +returns legs is rejected at create rather than settled without the cut. At and above the +flag-day the legs ride the validator-signed match and are applied on the proceeds chain, +and every leg address must re-encode to `GET_COIN` at create. See +[Cross-chain sales](./controller-bound-tokens.md#cross-chain-sales-cross_chain_royalty). + Creators who prefer a custody model can instead implement royalties in an ordinary **marketplace contract** that takes custody via [`DEPOSIT`](./actions/deposit.md)/[`WITHDRAW`](./actions/withdraw.md) and enforces any fee split in contract logic; opt-in per collection, at the cost of the diff --git a/protocol/project-registry.md b/protocol/project-registry.md index 9e6a2fd..77d7c95 100644 --- a/protocol/project-registry.md +++ b/protocol/project-registry.md @@ -54,7 +54,10 @@ The roster is a **`TICK`-type `LIST`**, bound to the project by a **`LINK`** fro project's current owner: 1. `ISSUE` the project tick (once). -2. `LIST|0|1|TOKEN1|TOKEN2|…`, publish the roster as a tick list. +2. `LIST|0|1||TOKEN1|TOKEN2|…`, publish the roster as a tick list. The empty third + segment is the optional `MEMO`, which on [`LIST`](./actions/list.md) sits BEFORE the + variadic items instead of trailing them. Spend the slot even when there is no memo: + drop it and `TOKEN1` is read as the memo, so the first token never joins the roster. 3. `LINK|0|||||…`: attest the roster. `LINK` validation already enforces that, when the link target resolves to a local `ISSUE`, **the `SOURCE` must be the tick's current owner** and @@ -65,8 +68,9 @@ attest the roster. `LINK` validation already enforces that, when the link target `LIST` actions are immutable; an update publishes a **new** list and re-attests it: -1. `LIST|1|||ITEM…`, derive a new list from the - previous one (`EDIT` 1 = add items, 2 = remove items). The indexer materializes the +1. `LIST|1||||ITEM…`, derive a new list from the + previous one (`EDIT` 1 = add items, 2 = remove items). The empty fourth segment is + the same `MEMO` slot as above, one position later. The indexer materializes the full resulting item set under the new `LIST`'s `ACTION_INDEX`. 2. `LINK` the new list to the project's `ISSUE` as above. @@ -96,7 +100,7 @@ Notes on the rule: ```mermaid flowchart TD - Issue["ISSUE the project tick, once"] --> List["LIST|0|1|TOKEN1|TOKEN2|...
publish the roster as a tick list"] + Issue["ISSUE the project tick, once"] --> List["LIST|0|1||TOKEN1|TOKEN2|...
publish the roster as a tick list"] List --> Link["LINK: COIN1 = the LIST action index,
COIN2 = the project ISSUE action index"] Link --> Check{"SOURCE is the tick's current owner,
ownership not escrowed,
both sides on the project's own chain?"} Check -->|"no"| Ignored["LINK carries no authority, ignored by clients"] diff --git a/protocol/protocol-activation.md b/protocol/protocol-activation.md index b9f5f00..f6caf3c 100644 --- a/protocol/protocol-activation.md +++ b/protocol/protocol-activation.md @@ -82,10 +82,10 @@ service-carried in `xchain-indexer/protocol_changes.js` and the `xchain-vm` gate table below), byte-guarded against each other rather than against this file, pending a future consolidation. -Eleven later consensus gates are armed but **not yet folded into `constants.js`**: they currently live +Twelve later consensus gates are armed but **not yet folded into `constants.js`**: they currently live only as service-carried modules (see [Additional armed gates](#additional-armed-gates-service-carried) below). Until they are consolidated here, `constants.js` is not the complete inventory, and each of -those eleven is guarded against whatever twin it has rather than against this file. Several are +those twelve is guarded against whatever twin it has rather than against this file. Several are **indexer-only** by design: a gate on the execution path (which actions or deploys validate) has no `xchain-sync` twin at all, because `BlockHasher` replicates already-materialized rows and never re-runs an action handler, a deploy validator, or the VM. @@ -108,10 +108,23 @@ coordinated fleet rollout retire a whole batch at once. | Cohort | Keyed on | Rules | Straggler behavior | |---|---|---|---| -| **A (contract era)** | one shared **time** (all three chains) | base64 DEPLOY encoding, VM async ban, VM binary-alloc metering, VM deploy-linter hardening, VM state-key NUL-reject, VM state-key type normalization, VM metering eval-order fix, VM call-spread metering, controller guards, VM balance/token-info surface, issuance-fee exemption, unstake-cooldown completion, cross-chain royalty create-side, XCALL undeliverable-result retirement | **forks** | +| **A (contract era)** | one shared **time** (all three chains) | base64 DEPLOY encoding, VM async ban, VM binary-alloc metering, VM deploy-linter hardening, VM state-key NUL-reject, VM state-key type normalization, VM metering eval-order fix, VM call-spread metering, VM consensus wall-clock budget, controller guards, VM balance/token-info surface, issuance-fee exemption, unstake-cooldown completion, cross-chain royalty create-side, XCALL undeliverable-result retirement | **forks** | | **B (validator era)** | a **BTC height** (not always the same height across every Cohort B rule; see below) | checkpoint commitment, equivocation header, stake-weighted quorum, anchor reward, cross-chain royalty canonical, attestation admission, archive reward, retraction signing, attestation relay, price signature tally | **forks** | | **C (state commitment)** | per-chain **local height** | light-client state commitment (state root + block-merkle root) and its state-hash classes (e.g. token-supply, poll-finalize) | **halts, recoverable** | +Almost every Cohort A leg turns a rule on. One instead pins a **number**: at the contract-era +instant the wall-clock net around a single contract execution becomes +`CONSENSUS_MAX_WALL_MS`, **30,000 ms**, the same on every node. Before it the net is the node's +own `limits.maxCpuTimeMs`, which is not a consensus value, so two differently configured +validators could return a different status and a different `gasUsed` for the same execution and +an operator's config file could fork the fleet. The bound mints no activation constant of its +own: it rides the binary-alloc metering instant already in this cohort, pinned AT the fleet's +documented default so nothing a default-configured node ever executed changes outcome across +the boundary. Tightening the value later is a different change and would need a flag day of its +own. Enforcement detail is on +[VM Configuration](../components/vm/configuration.md#resource-limits); the constant lives in +`xchain-vm/src/consensus-wall-clock.js` and the activation beside it in `xchain-vm/src/index.js`. + The ten Cohort B rules arm in two batches. Six share mainnet BTC height 961000: checkpoint commitment, equivocation header, stake-weighted quorum, anchor reward, cross-chain royalty canonical, and attestation admission. The other four share 963000, one deploy-train boundary later: archive @@ -175,7 +188,7 @@ gates that carry a date of their own (`BATCH_ISSUANCE_LIMITS`, `CONTRACT_DELEGAT ## Additional armed gates (service-carried) -These eleven consensus gates are **armed** on mainnet but are not yet mirrored into +These twelve consensus gates are **armed** on mainnet but are not yet mirrored into [`constants.js`](constants.js); each currently lives only in the service module named below (where a gate has a second copy it is byte-identical, and that pair is the drift guard; an execution-path gate has no second copy, see [above](#where-the-values-live)). They are listed here so the flag-day @@ -195,14 +208,15 @@ inventoried on this page, the armed ones in this table and the mainnet-unarmed V | **List-edit resolution** (`LIST_EDIT_RESOLUTION_ACTIVATION`, resolves a list to its newest valid edit; `getList` gates BET place, ORDER/SWAP match, DISPENSE, DIVIDEND, CALLBACK and AIRDROP, so action acceptance changes) | per-chain local height | `BTC:mainnet` 963000, `LTC:mainnet` 3162000, `DOGE:mainnet` 6338000 | forks | `xchain-indexer` / `xchain-explorer` `src/list_edit_resolution_activation.js` | | **Caret-ref strict** (`CARET_REF_STRICT_ACTIVATION`, makes an unresolvable address reference a hard reject at three sites that previously failed open, which moves the block's credits and debits) | per-chain local height | `BTC:mainnet` 963000, `LTC:mainnet` 3162000, `DOGE:mainnet` 6338000 (kept value-equal to list-edit resolution) | forks | `xchain-indexer/src/caret_ref_strict_activation.js` | | **Oracle stale-round visibility** (`ORACLE_STALE_ROUND_VISIBILITY_ACTIVATION`, keeps a stale tip round in the `getPrice()` view with its price withheld instead of dropping the round outright, so a contract can tell an oracle stall apart from an oracle that never ran; VM-visible, so it changes `contract_hash`) | per-chain local height | `BTC:mainnet` 966500, `LTC:mainnet` 3175500, `DOGE:mainnet` 6370000 (pinned ahead of the tip the first release carrying the gate deploys at, so the flag day has no retroactive window; it does NOT share the list-edit resolution boundary, which rides an earlier release) | forks | `xchain-indexer/src/oracle_stale_round_visibility_activation.js` | +| **Ledger amount precision** (`LEDGER_AMOUNT_PRECISION_ACTIVATION`, quantizes every ledger write at 18 dp, the finest precision a tick can be issued with, instead of the written tick's own `decimals`; `db.createLedgerChangeRecord` takes the scale from `ledgerWriteScale` and applies it as `bcadd(amount, 0, decimals)`, so the persisted amounts and the balances projected from them both move) | per-chain local height | `BTC:mainnet` 966500, `LTC:mainnet` 3175500, `DOGE:mainnet` 6370000 (pinned to the same boundary as oracle stale-round visibility so both arm in one fleet deploy, and above each chain's tip at pinning so the flag day has no retroactive window) | forks | `xchain-indexer/src/ledger_amount_precision_activation.js` (indexer-only: the rule sits on the ledger write path, which `xchain-sync` never re-runs, so it has no twin and no twin drift guard) | | **State-key collation** (`STATE_KEY_COLLATION_ACTIVATION`) | per-chain local height | `BTC:mainnet` 962500, `LTC:mainnet` 3160000, `DOGE:mainnet` 6335000 (armed 2026-07-10, ~10 days past Cohort-B) | halts, recoverable | `xchain-indexer` / `xchain-sync` `src/state_key_collation_activation.js` | | **DISPENSE cancelling-dispenser match** (`DISPENSE_CANCELLING_MATCH_ACTIVATION`, corrects the `db.findMatchingDispensers` latest-status correlation on the native-coin DISPENSE trigger path) | block time | the coordinated 2.0.0 [contract-era flag day](./flag-days.md#contract-era-flag-day); deploy all indexers before it | forks | `xchain-indexer/src/dispense_cancelling_match_activation.js` | The SWQ source cap, slash-burns and slash-oracle-round gates are BTC-height forking rules that belong with **Cohort B**; state-key collation is a per-chain additive gate that behaves like **Cohort C** -(halts, recoverable). The six remaining per-chain gates (VM deploy-lint Pkg 3, oracle snapshot-age +(halts, recoverable). The seven remaining per-chain gates (VM deploy-lint Pkg 3, oracle snapshot-age causality, dispenser freshness, list-edit resolution, caret-ref strict, oracle stale-round -visibility) are the reason this section +visibility, ledger amount precision) are the reason this section exists rather than a cohort row: they are **keyed** like Cohort C, on each chain's own `block_index`, but they **fork** like Cohort A and B, because each changes an acceptance or deploy verdict rather than adding a commitment. Below its threshold each one runs its legacy path byte-identically, which diff --git a/protocol/taproot-envelope.md b/protocol/taproot-envelope.md index 054ca6d..2580016 100644 --- a/protocol/taproot-envelope.md +++ b/protocol/taproot-envelope.md @@ -22,13 +22,40 @@ An unrevealed commit is not an action. It is an ordinary P2TR output, indistingu OP_FALSE OP_IF <"XCHN"> // 4-byte magic, cleartext // 0x00 = this version, cleartext - // the payload, in 520-byte elements, in order + // the payload, in 520-byte elements, in order, + // no element a single byte in 0x01-0x10 or 0x81 OP_ENDIF OP_CHECKSIG ``` The `OP_FALSE OP_IF` branch never executes, so the payload is invisible to script evaluation while still being committed to by the transaction. +#### No payload push may canonicalize to a bare opcode + +Every payload element must be a genuine data **push**. A one-byte element whose +value is in `0x01`-`0x10` or is `0x81` is not: a minimal script encoder emits it as +the bare opcode `OP_1`-`OP_16` / `OP_1NEGATE`, and a decompiler hands that back as +an opcode rather than as data. So an encoder splitting a payload into 520-byte +elements MUST **rebalance** whenever the final element would be one such byte, +which happens exactly when the payload length is `≡ 1 (mod 520)` and its last byte +falls in that range: the final two pushes become `(n-1, 2)` bytes instead of +`(n, 1)`. Reassembly is plain concatenation, so moving one byte across that +boundary leaves the payload byte-identical. + +This rule is **recognition-affecting**, and therefore belongs with the consensus +rules below rather than with encoder style. An envelope containing an element that +canonicalizes to a bare opcode is **not an envelope**: the payload walk stops at +that element, the following `OP_ENDIF` check fails, and the reveal is invisible +rather than invalid. An encoder that followed "520-byte elements" literally would +therefore burn a commit and a reveal on an action that never exists, with no error +anywhere. + +The same rebalance applies to the P2SH/P2WSH chunk lanes, whose 476-byte chunks +have the identical degenerate-final-chunk case. The rule is pinned by the +`chunk_rebalance` vector in +[`test-vectors/taproot_envelope.json`](./test-vectors/taproot_envelope.json) +(`push_lengths` `[520, 519, 2]`). + The magic and format byte are **cleartext by design**. Recognition has to be free pattern-matching: if identifying an envelope required deobfuscation, every unrelated `OP_FALSE OP_IF` inscription on the chain would cost an indexer an RPC round trip and a cipher attempt. The payload is **raw** (not obfuscated), matching the shipped P2WSH lane, where redeem scripts also carry raw payload bytes. The reassembled payload is byte-identical to the compiled data stream the other lanes carry: the action-string push followed by the rawData push. @@ -44,6 +71,7 @@ These are consensus-relevant: every implementation must agree, or the fleet fork - The envelope input must be **input 0**. Anywhere else, it is not an action. - **Two or more envelope inputs** in one transaction: not an action. - An envelope **mixed with any other carrier** (an `XCHN` OP_RETURN, a chunk marker, MULTISIGN outputs): not an action. Deterministic refusal, not a preference between carriers. +- **Every payload element must be a data push, never a bare opcode.** A one-byte element in `0x01`-`0x10` or `0x81` canonicalizes to `OP_1`-`OP_16` / `OP_1NEGATE` and breaks the pattern, so the reveal is not an envelope. Encoders avoid producing that shape by rebalancing the final two pushes to `(n-1, 2)`; see [No payload push may canonicalize to a bare opcode](#no-payload-push-may-canonicalize-to-a-bare-opcode). - The reassembled payload has its own ceiling, `ENVELOPE_MAX_PAYLOAD` (390,000 bytes), measured **before** parse and **excluding** the envelope's own push framing. The ceiling is sized against transaction **weight**, not chosen as a round byte count. A payload filling the larger cap this constant carried before 2026-07-31 compiles to a reveal of 402,789 WU, over Bitcoin Core's `MAX_STANDARD_TX_WEIGHT` of 400,000 WU: the encoder builds it, the validator accepts it, and no node relays it. The current value leaves 7,050 WU of margin under the worst reveal shape, so an implementer sizing a cap of their own should derive it from weight rather than copy a byte count. Note this measures a different quantity from `MAX_ACTION_DATA_LENGTH`, which is framing-inclusive and still governs every legacy lane. ## Source attribution diff --git a/protocol/token-gated-content.md b/protocol/token-gated-content.md index b145c54..f1eb298 100644 --- a/protocol/token-gated-content.md +++ b/protocol/token-gated-content.md @@ -14,7 +14,7 @@ This is the platform's first cryptographically secure publishing capability. It - **Publish a single encrypted file** that anyone running an XChain node can see on-chain but only token holders can decrypt and read. - **Publish a multi-file pack** that unlocks atomically, owning the token decrypts every file in the pack with a single key. - **Set a minimum holding** with `GATE_MIN_AMOUNT`, so content unlocks at a balance rather than at the first satoshi of the token. Holders below the threshold receive no key until a transfer takes them over it. -- **Sell the token freely** on the built-in DEX. Whoever buys the token automatically receives the decryption key as part of the transfer transaction. +- **Sell the token freely** on the built-in DEX. The automatic key handoff is enforced on the direct `SEND` path only: `send.js` requires a `MESSAGE` v2 to the recipient in the same transaction when the post-send balance reaches a pack's threshold. DEX settlement (ORDER match, SWAP, DISPENSE) and every other credit path (`AIRDROP`, `DIVIDEND`, ownership transfer) run no such check, so a buyer acquires the token there without the key and needs it delivered afterwards in a direct send. - **Walk away after publishing.** No server to keep running, no key escrow service to maintain. The encrypted content and the key handoff machinery live entirely on the blockchain. --- @@ -32,7 +32,7 @@ The token issuer composes one or more on-chain transactions that publish the enc ### Single file -1. Issuer generates a random 256-bit symmetric key `K` and computes `KEY_HASH = sha256(K)` (hex). +1. Issuer generates a random 256-bit symmetric key `K` and computes `KEY_HASH = sha256(K)` (lowercase hex; see [`FILE`](./actions/file.md) for the case rule, which is tolerant on chain and canonicalized at record time). 2. Issuer **compresses the plaintext, then encrypts it** with AES-256-GCM under `K`. Output ciphertext is `[12-byte nonce][16-byte GCM authentication tag][ciphertext]`. The order is not a preference: GCM ciphertext is incompressible, so encrypting first throws the saving away entirely. When the compressed form is kept, the `FILE` action's `COMPRESSION` field is set to `1` and means **inflate after decrypt** (see [Compression ordering](#compression-ordering) below). 3. Issuer constructs `BATCH(FILE, MESSAGE-to-self)`: - `FILE|0|NAME|TYPE|TITLE|MEMO|GATE_TICKER|1|KEY_HASH|GATE_MIN_AMOUNT|COMPRESSION` (where `1` = AES-256-GCM in the `ENCRYPTION_METHOD` field) with the ciphertext as the action's `rawData` (transported via P2WSH per [Transaction Encoding](../concepts/encoding.md)). `GATE_MIN_AMOUNT` is optional and may be omitted entirely; the eight-field form is unchanged and still valid, so every historical `FILE` reads identically. @@ -180,6 +180,7 @@ A JSON wrapper with `KEY_HASH`-keyed base64 entries costs ~154 plaintext bytes f These are the protocol-level rules the indexer enforces. See the individual action specs for the canonical statement. - **Gated `FILE` publishing.** When `GATE_TICKER` is non-empty, the SOURCE address must be the issuer of the gated token (i.e. the OWNER returned by the token's current `ISSUE`). Otherwise the FILE is rejected. This prevents third parties from gating arbitrary content to popular tickers as spam. +- **Gated `FILE` while ownership is escrowed.** A gated `FILE` is also rejected while the gate token's ownership sits in escrow, which an issuer who has listed the token for sale will hit even though they are still the OWNER. `FILE` is one of the actions the escrow blocks; the full list is in [`ORDER`](./actions/order.md#token-ownership-sales), and [`FILE`](./actions/file.md) states it locally. - **`SEND` of a gated token.** Defined above. The indexer checks for a structurally valid sibling `MESSAGE`; it does not decrypt or validate the payload contents (it can't; the payload is encrypted to the recipient). The wallet at unlock time verifies key correctness via the `KEY_HASH` check. --- @@ -188,7 +189,7 @@ These are the protocol-level rules the indexer enforces. See the individual acti - **Album drops / track packs.** Issuer mints a token, publishes a multi-file pack of FLAC stems plus liner notes PDF. Buyers of the token unlock everything atomically the moment the transfer confirms. - **Sealed bundles.** A creator can guarantee that no one has seen any file in the pack (not even the indexer operators or block explorers) until a holder unlocks. Useful for time-locked reveals, lottery / raffle distributions, surprise drops. -- **Paid downloads.** Issuer sells the token via DISPENSER or ORDER. Anyone who buys gets the decryption key in the same transaction. No payment gateway, no checkout server. +- **Paid downloads.** Issuer sells the token via DISPENSER or ORDER. No payment gateway, no checkout server. Neither settlement path carries the key, so the seller follows the sale with a direct `SEND` that includes the key handoff, or sells by direct send in the first place. - **Holder-only resources.** Brand guidelines, board minutes, premium research: published once on-chain, accessible only to holders, durable as long as the chain exists. - **Whitepapers and supporting docs.** Sealed at issuance, opens to holders, persists forever. diff --git a/protocol/token-information-standard.md b/protocol/token-information-standard.md index 18bbac3..a0269bd 100644 --- a/protocol/token-information-standard.md +++ b/protocol/token-information-standard.md @@ -7,17 +7,33 @@ The Token Information Standard (TIS) defines standardized formats to associate i ## JSON Specifications +### v1.1.0 (current) +- [Token Information Standard JSON Schema](./json/token-information-standard-v1.1.0-schema.json) +- [Token Information Standard JSON Example](./json/token-information-standard-v1.1.0-example.json) + +v1.1.0 is additive over v1.0.0. It declares the token-gating fields (`packs`, `title`, +`data_ref`, `locked`, `pack_id`) that clients already emit and read, adds no required +field, and forbids nothing v1.0.0 allowed, so every document valid under v1.0.0 is also +valid under v1.1.0. + ### v1.0.0 - [Token Information Standard JSON Schema](./json/token-information-standard-v1.0.0-schema.json) - [Token Information Standard JSON Example](./json/token-information-standard-v1.0.0-example.json) +Frozen as published. It declares none of the gating fields, so a validator pinned to +v1.0.0 treats them as unknown extras rather than as part of the contract, and a code +generator run against it drops them. + #### JSON Field Definitions +The tables below describe **v1.1.0**. Rows marked *(since v1.1.0)* are absent from the +v1.0.0 schema. + | Field | Type | Description | :--- | :--- | :--- | tick | String | The TICK of the token | description | String | A longish description about this token. 2048 characters max. -| website | String | A link to the website for the token. 100 characters max. +| website | String | A link to the website for the token. 255 characters max. | name | String | The full name of the token | html | String | HTML code providing additional information or functionality | owner | Object | Information about the owner of this token @@ -28,7 +44,7 @@ The Token Information Standard (TIS) defines standardized formats to associate i | audio | Array | One or more audio files related to the token | video | Array | One or more video files related to the token | files | Array | One or more files related to the token -| packs | Object | Display metadata for [token-gated content packs](./token-gated-content.md). Map of pack id → `{ name, description }`. +| packs | Object | *(since v1.1.0)* Display metadata for [token-gated content packs](./token-gated-content.md). Map of pack id → `{ name, description }`. | dns | Array | One or more DNS records related to the token. #### File Entry Fields @@ -38,12 +54,12 @@ Entries inside the `files`, `audio`, `video`, and `images` arrays can carry the | Field | Type | Description | :--- | :--- | :--- | data | String | URL to the file (off-chain). Used for non-gated content. -| data_ref | String | Reference to an on-chain [`FILE`](./actions/file.md) action by `ACTION_INDEX`: `action:` (same chain as the token) or `action::` (sibling chain: base coin ticker `BTC`/`LTC`/`DOGE`, network tier implied by the token's network, same convention as [`LINK`](./actions/link.md)'s `COIN1`/`COIN2`). Lets cheap chains carry the bytes for tokens on expensive ones: e.g. a BTC token whose artwork FILE lives on DOGE. When both `data` and `data_ref` are present, clients prefer `data_ref`. +| data_ref | String | *(since v1.1.0)* Reference to an on-chain [`FILE`](./actions/file.md) action by `ACTION_INDEX`: `action:` (same chain as the token) or `action::` (sibling chain: base coin ticker `BTC`/`LTC`/`DOGE`, network tier implied by the token's network, same convention as [`LINK`](./actions/link.md)'s `COIN1`/`COIN2`). Lets cheap chains carry the bytes for tokens on expensive ones: e.g. a BTC token whose artwork FILE lives on DOGE. When both `data` and `data_ref` are present, clients prefer `data_ref`. | name | String | Filename | type | String | MIME type -| title | String | Display title -| locked | Boolean | `true` if the file is encrypted and gated. Clients use this to render locked/unlocked states without first fetching the FILE action. -| pack_id | String | (Optional) Pack identifier grouping files that share an unlock key. References the top-level `packs` map for display name and description. Does not need to be present for unlocking to work; the protocol groups by `KEY_HASH` directly. +| title | String | *(since v1.1.0)* Display title +| locked | Boolean | *(since v1.1.0)* `true` if the file is encrypted and gated. Clients use this to render locked/unlocked states without first fetching the FILE action. +| pack_id | String | *(since v1.1.0)* (Optional) Pack identifier grouping files that share an unlock key. References the top-level `packs` map for display name and description. Does not need to be present for unlocking to work; the protocol groups by `KEY_HASH` directly. ## NFT Usage @@ -111,8 +127,8 @@ Below are a number of token description formats which should be recognized by XC ### On-Chain Format (action index) - - + +
Formataction:INDEX or action:COIN:INDEX
INDEXACTION_INDEX of a FILE action whose raw bytes are a TIS JSON document (declared MIME type application/json)
COIN(optional) base coin ticker (BTC/LTC/DOGE) when the FILE lives on a sibling chain; omitted = same chain as the token. The network tier (mainnet/testnet/regtest) is implied by the token's network, same convention as LINK's COIN1/COIN2. Lets cheap chains carry the document for tokens on expensive ones.
INDEXACTION_INDEX of a FILE action whose raw bytes are a TIS JSON document (declared MIME type application/json)
COIN(optional) base coin ticker (BTC/LTC/DOGE) when the FILE lives on a sibling chain; omitted = same chain as the token. The network tier (mainnet/testnet/regtest) is implied by the token's network, same convention as LINK's COIN1/COIN2. Lets cheap chains carry the document for tokens on expensive ones.
NoteThe fully on-chain form: the TIS document itself lives on a chain, so the token's display metadata has the same permanence as the token. Combine with data_ref entries inside the document for on-chain media (also same- or sibling-chain), and LOCK_DESCRIPTION=1 for an immutable pointer. Same casing/format as data_ref, one level up. Clients resolve the bytes the same way they resolve data_ref (e.g. the explorer's /{COIN}/api/file/{INDEX}/raw).
Exampleaction:12345  ·  action:DOGE:12345
diff --git a/test/consensus-wall-clock-claims.test.js b/test/consensus-wall-clock-claims.test.js index 95d6f86..7537044 100644 --- a/test/consensus-wall-clock-claims.test.js +++ b/test/consensus-wall-clock-claims.test.js @@ -60,6 +60,10 @@ const readVm = (file) => fs.readFileSync(file, 'utf8'); const CONFIG_PAGE = 'components/vm/configuration.md'; const OPERATIONS_PAGE = 'components/vm/operations.md'; const FLAG_DAYS_PAGE = 'protocol/flag-days.md'; +// The authoritative activation inventory. It is in this guard for the same +// reason the two VM pages are: the bound is a Cohort A consensus quantity, and +// a reader who only reads the registry must find it there too. +const ACTIVATION_PAGE = 'protocol/protocol-activation.md'; // Pull `const NAME = ;` out of a VM source file. function sourceConstant(src, name, where) { @@ -86,7 +90,7 @@ test('the wall-clock budget the VM pages quote is the constant xchain-vm declare 'xchain-vm/src/consensus-wall-clock.js'); const printed = `${declared.toLocaleString('en-US')} ms`; - for (const page of [CONFIG_PAGE, OPERATIONS_PAGE]) { + for (const page of [CONFIG_PAGE, OPERATIONS_PAGE, ACTIVATION_PAGE]) { const doc = readDoc(page); assert.ok(doc.includes('CONSENSUS_MAX_WALL_MS'), `${page} does not name CONSENSUS_MAX_WALL_MS; the consensus wall-clock ` diff --git a/test/contract-state-proof-availability.test.js b/test/contract-state-proof-availability.test.js new file mode 100644 index 0000000..2e62e9c --- /dev/null +++ b/test/contract-state-proof-availability.test.js @@ -0,0 +1,113 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. + * + ********************************************************************** + * + * Contract-state proof availability in prose. + * + * WHY. The endpoint shipped, the contract_state_root slot was armed on BTC + * regtest and from genesis on the three testnets, and five reader-facing + * passages went on calling the route reserved, unimplemented, or a 501 + * UNSUPPORTED_VERSION. Nothing went red, because the handler and the prose have + * no producer in common: the wrong status reached docs.xchain.io and the + * explorer's served openapi.json, where client codegen reads it, so integrators + * could gate off a working capability. + * + * WHAT IT CHECKS. + * + * 1. No prose page calls the contract-state proof endpoint unimplemented, + * reserved, deferred, or a 501/UNSUPPORTED_VERSION answer, for as long as + * the armed map has at least one entry for contract_state_root. The gate + * is conditional on the map on purpose: before arming, those sentences + * were true, and a guard that forbids a true sentence is a guard that gets + * deleted. + * 2. UNSUPPORTED_VERSION is never named for this endpoint anywhere in the + * tree. The handler has no such branch at all, so the code is unreachable + * whatever the arming state. + * + * CHANGELOG.md is history, exempt from both: its entries were true when + * written. + * + * xchain-explorer is a sibling repo in the monorepo checkout, not a dependency + * of xchain-documentation. When it is absent (docs repo cloned on its own) the + * armed-map read skips and check 2 still runs. + * + * Run: node --test test/contract-state-proof-availability.test.js (Node 22) + * + ********************************************************************/ + +'use strict'; + +const assert = require('node:assert/strict'); +const { test, describe } = require('node:test'); +const fs = require('node:fs'); +const path = require('node:path'); + +const DOC_ROOT = path.resolve(__dirname, '..'); +const ACTIVATION = path.resolve(__dirname, '../../xchain-explorer/src/state_subtree_activation.js'); + +const haveExplorer = fs.existsSync(ACTIVATION); + +// Every tracked .md page except history and vendored trees. +function docPages(dir, out) { + out = out || []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'archive') continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { docPages(full, out); continue; } + if (!entry.name.endsWith('.md')) continue; + if (entry.name === 'CHANGELOG.md') continue; + out.push(full); + } + return out; +} + +// Lines naming the contract-state proof surface, so a stale phrase about some +// other subject cannot be scored against this endpoint. +const SUBJECT = /contract[-_ ]state|contractStateProof|verifyContractStateProof/i; +const STALE = /not yet implemented|not implemented|is reserved|\breserved;|deferred to a later|UNSUPPORTED_VERSION|returns 501|HTTP 501/i; + +function staleLines(file) { + const hits = []; + const lines = fs.readFileSync(file, 'utf8').split('\n'); + lines.forEach((line, i) => { + if (!SUBJECT.test(line)) return; + if (!STALE.test(line)) return; + hits.push(path.relative(DOC_ROOT, file) + ':' + (i + 1) + ' ' + line.trim()); + }); + return hits; +} + +describe('contract-state proof availability in documentation', () => { + test('no page calls the endpoint unimplemented while the slot is armed somewhere', { skip: !haveExplorer && 'xchain-explorer not present in this checkout' }, () => { + const { STATE_SUBTREE_ACTIVATION } = require(ACTIVATION); + const armed = Object.keys(STATE_SUBTREE_ACTIVATION.contract_state_root || {}); + if (armed.length === 0) return; // pre-arming: the stale sentences are true + + const hits = docPages(DOC_ROOT).flatMap(staleLines); + assert.deepEqual(hits, [], + 'contract_state_root is armed on ' + armed.join(', ') + + ' and the explorer serves the route, but these passages still say otherwise:\n ' + + hits.join('\n ')); + }); + + test('UNSUPPORTED_VERSION is never named for this endpoint', () => { + const hits = docPages(DOC_ROOT).flatMap((file) => { + const out = []; + fs.readFileSync(file, 'utf8').split('\n').forEach((line, i) => { + if (/UNSUPPORTED_VERSION/.test(line) && SUBJECT.test(line)) + out.push(path.relative(DOC_ROOT, file) + ':' + (i + 1)); + }); + return out; + }); + assert.deepEqual(hits, [], + 'the contract-state proof handler has no UNSUPPORTED_VERSION branch; documented at: ' + hits.join(', ')); + }); +}); diff --git a/test/env-var-doc-coverage.test.js b/test/env-var-doc-coverage.test.js index 6798e35..600c21e 100644 --- a/test/env-var-doc-coverage.test.js +++ b/test/env-var-doc-coverage.test.js @@ -267,6 +267,114 @@ describe('scanSource', () => { }); }); +// Read a fallback that wraps onto the next line: a site recorded with +// `default: null` is dropped by comparableDefault and never compared against a +// doc row, so a wrong documented default passes this gate in silence. +// +// Stop at the statement boundary all the same: several repos in this fleet are +// written WITHOUT semicolons, and a walk with no boundary takes the next +// statement's `|| 'x'` as this read's default. A wrong default is worse than a +// missing one, because it makes the gate accuse a correct doc row. +describe('scanSource across line boundaries', () => { + test('a `||` default on the next line is read, and the line is the READ line', () => { + const found = scanSource([ + 'const SENTINEL_PATH = process.env.SENTINEL', + " || '/tmp/sentinel.json'", + ].join('\n')); + assert.equal(found.get('SENTINEL')[0].default.value, '/tmp/sentinel.json'); + assert.equal(found.get('SENTINEL')[0].line, 1); + }); + + // xchain-node/src/services/EncoderMaintenanceWindow.js:46-47, verbatim. + test('the semicolon-less wrapped read in xchain-node is no longer exempt', () => { + const found = scanSource([ + 'const SENTINEL_PATH = process.env.XCHAIN_NODE_ENCODER_MAINTENANCE_FILE', + " || '/tmp/xchain-encoder-maintenance.json'", + ].join('\n')); + assert.equal( + found.get('XCHAIN_NODE_ENCODER_MAINTENANCE_FILE')[0].default.value, + '/tmp/xchain-encoder-maintenance.json' + ); + }); + + // xchain-hub/src/StateCheckpointEngine.js:190-191, verbatim: the chain + // continues through a non-literal `cfg.X` on the wrapped line. + test('a wrapped chain inside parseInt reaches the literal past a cfg lookup', () => { + const found = scanSource([ + 'this._frozenTipTicks = parseInt(process.env.CHECKPOINT_FROZEN_TIP_TICKS', + " || cfg.CHECKPOINT_FROZEN_TIP_TICKS || '60');", + ].join('\n')); + const site = found.get('CHECKPOINT_FROZEN_TIP_TICKS')[0]; + assert.equal(site.default.value, '60'); + assert.equal(site.default.numeric, true); + assert.equal(site.line, 1); + }); + + // xchain-hub/src/AttestationBatchPublisher.js:175-176: the `||` itself ends + // the line, so the operand is read off the NEXT one. + test('a `||` at end of line reaches its operand on the next line', () => { + const found = scanSource([ + 'this.signTimeoutMs = parseInt(process.env.ORACLE_BATCH_SIGN_TIMEOUT_MS ||', + " cfg.ORACLE_BATCH_SIGN_TIMEOUT_MS || '15000', 10);", + ].join('\n')); + assert.equal(found.get('ORACLE_BATCH_SIGN_TIMEOUT_MS')[0].default.value, '15000'); + }); + + test('a `??` default on the next line is read', () => { + const found = scanSource('const n = parseInt(process.env.TICKS\n ?? 42);'); + assert.equal(found.get('TICKS')[0].default.value, '42'); + }); + + // FAILURE PATH. Without a statement boundary this reports `bar` as FOO's + // default and the gate then accuses whatever the doc row correctly says. + test('a bare semicolon-less read does not steal the next statement fallback', () => { + const found = scanSource([ + 'const a = process.env.FOO', + "const b = c || 'bar'", + ].join('\n')); + assert.equal(found.get('FOO')[0].default, null); + assert.equal(found.get('FOO')[0].line, 1); + }); + + test('a bare read does not reach across a blank line either', () => { + const found = scanSource([ + 'const a = process.env.FOO', + '', + "module.exports = { a, fallback: 'bar' }", + ].join('\n')); + assert.equal(found.get('FOO')[0].default, null); + }); + + test('a wrapped bracket read is found and reports the line it starts on', () => { + const found = scanSource("const a = 1;\nconst b = process.env[\n 'GAMMA'\n];"); + assert.deepEqual([...found.keys()], ['GAMMA']); + assert.equal(found.get('GAMMA')[0].line, 2); + }); + + test('a numeric coercion closing on its own line still reaches the outside fallback', () => { + const found = scanSource([ + 'const n = parseInt(process.env.DELTA', + ') || 900;', + ].join('\n')); + assert.equal(found.get('DELTA')[0].default.value, '900'); + }); + + test('line numbers stay right for many reads spread down a file', () => { + const found = scanSource([ + 'const a = process.env.ONE;', + '', + '// a comment', + 'const b = process.env.TWO;', + 'function f() {', + ' return process.env.THREE;', + '}', + ].join('\n')); + assert.equal(found.get('ONE')[0].line, 1); + assert.equal(found.get('TWO')[0].line, 4); + assert.equal(found.get('THREE')[0].line, 6); + }); +}); + // These are the reads the scanner cannot name, so the coverage check // cannot fail on them; the ratchet below is what keeps the set from growing. describe('scanComputedReads (the blind spot the gate cannot see into)', () => { @@ -632,6 +740,31 @@ describe('doc matching', () => { assert.equal(defaultDocumented(['| `NODE_RPC_TIMEOUT` | timeout | `30000` |'], '60000'), false); }); + // The prefix test above is a BACKTICKED TABLE CELL, where a closing + // delimiter has to follow the digits, so it never exercised the unquoted + // prose form -- and that is the form the boundary let through. A row saying + // "defaults to 30.5 seconds" credited a code default of 30: the checker + // called a drifted row correct, which is the direction this library exists + // to stop. + test('a decimal in prose is not satisfied by its integer part', () => { + assert.equal(defaultDocumented(['`REVIEW_TIMEOUT` defaults to 30.5 seconds.'], '30'), false); + assert.equal(defaultDocumented(['`REVIEW_TIMEOUT` defaults to 1.55 seconds.'], '1.5'), false); + }); + + test('a thousands separator in prose is not satisfied by the leading group', () => { + assert.equal(defaultDocumented(['`X_TIMEOUT` defaults to 30,000 ms.'], '30'), false); + }); + + // The other half, and the reason the guard refuses `.`/`,` only before a + // DIGIT: a row ends its sentence and separates its clauses far more often + // than it carries a separator, and failing those would redden correct rows + // across the whole doc set. + test('an ordinary sentence period or comma after the value still asserts it', () => { + assert.equal(defaultDocumented(['`X_TIMEOUT` defaults to 30.'], '30'), true); + assert.equal(defaultDocumented(['`X_TIMEOUT` defaults to 30, and the hub clamps it.'], '30'), true); + assert.equal(defaultDocumented(['`X_TIMEOUT` defaults to `30`.'], '30'), true); + }); + test('a string default is matched and a wrong one is rejected', () => { assert.equal(defaultDocumented(['| `SYNC_MODE` | mode | `server` |'], 'server'), true); assert.equal(defaultDocumented(['| `SYNC_MODE` | mode | `client` |'], 'server'), false); diff --git a/test/fee-and-limit-claims.test.js b/test/fee-and-limit-claims.test.js index 5e401a9..c4f1b4b 100644 --- a/test/fee-and-limit-claims.test.js +++ b/test/fee-and-limit-claims.test.js @@ -159,3 +159,38 @@ test('the betting guide states the enforced refund-window bounds and per-market assert.ok(readDoc('protocol/actions/bet.md').includes(String(cap)), `protocol/actions/bet.md no longer states the ${cap} bet cap it is the reference for`); }); + +test('the unified free-listing window every fee page quotes matches the coin configs', + { skip: !haveCoins && 'sibling xchain-indexer not present in this checkout' }, () => { + const free = new Map(); + for (const [coin, file] of COIN_JS) { + const src = fs.readFileSync(file, 'utf8'); + free.set(coin, scheduleValue(src, 'UNIFIED_EXPIRATION_FEE_FREE_DAYS', + `xchain-indexer/src/coins/${coin}.js`)); + } + + // Every page states one figure for all three chains, so assert the + // premise before the number. + assert.deepStrictEqual([...new Set(free.values())], [free.get('BTC')], + `UNIFIED_EXPIRATION_FEE_FREE_DAYS differs per chain (${JSON.stringify([...free])}), but the fee ` + + 'pages and the indexer constants table each state a single free window. Split the prose per chain.'); + + const days = free.get('BTC'); + + // The constants table quotes the value as an example, and a wrong cell + // there reads as an authoritative override of the prose: it said 365 + // against a 90 the rest of the corpus already had right. + const row = readDoc('components/indexer/configuration.md').split('\n') + .find((l) => l.startsWith('|') && l.includes('UNIFIED_EXPIRATION_FEE_FREE_DAYS')); + assert.ok(row, + 'components/indexer/configuration.md no longer has a UNIFIED_EXPIRATION_FEE_FREE_DAYS table row'); + assert.ok(new RegExp('`' + days + '`').test(row), + 'components/indexer/configuration.md quotes a free window the coin configs do not define ' + + `(they define ${days}): ${row.trim()}`); + + // And the two reader-facing pages that spell the window out in prose. + for (const rel of ['concepts/gas.md', 'user-guide/trading.md']) { + assert.ok(new RegExp('first \\*{0,2}' + days + ' days').test(readDoc(rel)), + `${rel} does not state the ${days}-day free listing window the indexer enforces`); + } + }); diff --git a/test/flag-day-literals.test.js b/test/flag-day-literals.test.js index 8e90d9f..31a77f5 100644 --- a/test/flag-day-literals.test.js +++ b/test/flag-day-literals.test.js @@ -198,6 +198,86 @@ test('a drifted _MAINNET_TIME constant is refused, not skipped', () => { assert.throws(() => gen.collectGates(dir), /FOO_MAINNET_TIME/); }); +/* ------------------------------------------------------------------ + * The call's TIME ARGUMENT, read whole + * ------------------------------------------------------------------ + * + * The slot capture asserted no argument terminator, so it failed in both + * directions at once and neither reached the completeness check: a + * separator-bearing literal matched whole, failed the digits test and left the + * page in silence, while an arithmetic slot matched only its PREFIX and + * published an instant a day early. Every call fixture above passes a bare + * digit literal, which is exactly the one shape that cannot show either. + */ + +test('a separator-bearing mainnet slot publishes its gate instead of vanishing', () => { + const dir = fixtureRegistry( + "this.addChange('REAL', '1.0.0', 1786060800, 0, 0, 0, 0, 0);\n" + + "this.addChange('SEPARATED', '1.0.0', 1_786_060_800, 0, 0, 0, 0, 0);\n", + ); + const gates = gen.collectGates(dir); + assert.deepStrictEqual(gates.map((g) => g.gate).sort(), ['REAL', 'SEPARATED']); + assert.strictEqual(gates.find((g) => g.gate === 'SEPARATED').time, 1786060800, + 'the separators must be stripped, not read as part of a name'); +}); + +test('a separator-bearing testnet slot reaches collectTestnetArms', () => { + const dir = fixtureRegistry( + "this.addChange('SEPARATED', '1.0.0', 9999999999, 1_787_961_600, 0, 0, 0, 0);\n", + ); + assert.deepStrictEqual( + gen.collectTestnetArms(dir).map((g) => [g.gate, g.time]), + [['SEPARATED', 1787961600]], + ); +}); + +test('an arithmetic mainnet slot is refused, not published at its prefix', () => { + // The dangerous direction: the old capture stopped at the space and + // published 1786060800 for a gate that arms a day later, on the page + // implementers plan fleet upgrades from. + const dir = fixtureRegistry( + "this.addChange('EXPR', '1.0.0', 1786060800 + 86400, 0, 0, 0, 0, 0);\n", + ); + assert.throws(() => gen.collectGates(dir), /EXPR/); + assert.throws(() => gen.collectGates(dir), /1786060800 \+ 86400/); +}); + +test('an arithmetic slot is refused on the arms that have no completeness check', () => { + // collectTestnetArms/Unarmed and collectMainnetUnarmed read the same calls + // and never reach assertEveryDeclarationParsed, so refusing from the parse + // itself is what makes them loud too. + const dir = fixtureRegistry( + "this.addChange('EXPR', '1.0.0', 9999999999, 1787961600 + 86400, 0, 0, 0, 0);\n", + ); + assert.throws(() => gen.collectTestnetArms(dir), /EXPR/); + assert.throws(() => gen.collectMainnetUnarmed(dir), /EXPR/); +}); + +test('a leading-underscore name in a time slot stays a name, not a number', () => { + // Stripping separators before testing for digits reads `_1786060800` as a + // number; it is an identifier, and an identifier no const pass saw is quiet. + const dir = fixtureRegistry( + "this.addChange('REAL', '1.0.0', 1786060800, 0, 0, 0, 0, 0);\n" + + "this.addChange('NAMED', '1.0.0', _1786060800, 0, 0, 0, 0, 0);\n", + ); + assert.deepStrictEqual(gen.collectGates(dir).map((g) => g.gate), ['REAL']); +}); + +test('a bare digit slot and a constant by name still read exactly as before', () => { + // The widened capture must not change the two shapes the live registry + // actually uses; `unchanged` from the generator is the fleet-scale version + // of this, and this is the one that runs without a sibling checkout. + const dir = fixtureRegistry( + 'const REAL_MAINNET_TIME = 1786060800;\n' + + "this.addChange('REAL', '2.0.0', REAL_MAINNET_TIME, 0, 0, 0, 0, 0);\n" + + "this.addChange('PLAIN', '1.0.0', 1787961600, 0, 0, 0, 0, 0);\n", + ); + assert.deepStrictEqual( + gen.collectGates(dir).map((g) => [g.gate, g.time]), + [['REAL', 1786060800], ['PLAIN', 1787961600]], + ); +}); + test('the check is structural: a legitimately value-filtered gate does not throw', () => { // A block-height threshold and the unarmed sentinel are both READ and then // dropped by the value filter. That is correct, and must not read as an diff --git a/test/internal-link-integrity.test.js b/test/internal-link-integrity.test.js index d7d91ba..bb59ca5 100644 --- a/test/internal-link-integrity.test.js +++ b/test/internal-link-integrity.test.js @@ -82,6 +82,52 @@ for (const f of FILES) { const LINK = /\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g; const rel = (p) => path.relative(DOC_ROOT, p); +// Raw HTML links. The pages that use for the on-chain format specs +// carry their cross-references as rather than as markdown, and +// LINK cannot see those: two dead ./actions/FILE.md hrefs sat on the normative +// action-resolution table while this suite reported green. +const HTML_LINK = /(?:href|src)\s*=\s*"([^"]+)"/g; + +// Pull the relative in-repo targets out of one page's HTML attributes. Fenced +// blocks and inline code spans are skipped: `\n```\n' + + 'inline `` too\n'), + [], + 'a snippet the reader pastes into their own page is not a link this repo resolves'); + + assert.equal(existsCaseExact(path.join(DOC_ROOT, 'protocol/actions/file.md')), true); + assert.equal(existsCaseExact(path.join(DOC_ROOT, 'protocol/actions/FILE.md')), false, + 'a case-mismatched target must be rejected even where the local filesystem is ' + + 'case-insensitive; the host serving the site is not'); + assert.equal(existsCaseExact(path.join(DOC_ROOT, 'protocol/actions/nope.md')), false); + }); + + test('every relative HTML href/src resolves, case included', () => { + const broken = []; + for (const f of FILES) { + for (const target of htmlTargets(fs.readFileSync(f, 'utf8'))) { + const abs = path.resolve(path.dirname(f), target.split('#')[0]); + if (!abs.startsWith(DOC_ROOT + path.sep)) { broken.push(`${rel(f)} -> ${target} (escapes the repo)`); continue; } + if (!existsCaseExact(abs)) broken.push(`${rel(f)} -> ${target}`); + } + } + assert.deepEqual(broken, [], + 'raw HTML links pointing at files that do not exist under that exact spelling. ' + + 'The docs host is case-sensitive, so ./actions/FILE.md is a 404 even though ' + + 'protocol/actions/file.md is right there:\n ' + broken.join('\n ')); + }); + test('every #fragment matches a heading in its target file', () => { const dangling = []; for (const f of FILES) { diff --git a/test/settlement-and-delivery-claims.test.js b/test/settlement-and-delivery-claims.test.js new file mode 100644 index 0000000..4881566 --- /dev/null +++ b/test/settlement-and-delivery-claims.test.js @@ -0,0 +1,304 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. + * + ********************************************************************** + * + * Settlement- and delivery-timing claims in the user guide. + * + * WHY. Five guide sentences promised a settlement the handlers do not make. + * Each one is a promise a reader acts on with their own money: + * + * 1. The Mint Supply bullet said "editing the token issues that much again". + * Only the v2 mint-params edit carries MINT_SUPPLY; the v1 description + * edit has no such field, and issue.js credits supply only when the field + * is present in the action. + * 2. Cancelling an order was described as returning escrow "immediately". + * order.js writes status 'cancelling' when the order carries pending + * COINPay obligations and releases the escrow only when they resolve; + * order_expire.js does the same as 'expiring'. Cancelling a DISPENSER + * enters a DISPENSER_CLOSE_DELAY window before the leftover tokens come + * back. Dispenser EXPIRY does not, which is why the guide must not carry + * a blanket caveat either. + * 3. Issuer-rights sales were called atomic without qualification. That holds + * on one chain; a cross-chain swap settles each leg on its own chain. + * 4. The gated-archive and DEX-sale bullets said a buyer receives the + * decryption key with the token. send.js is the ONLY action that requires + * the key-handoff MESSAGE, and transferTokenOwnership emits none. + * 5. Betting said any LTC/DOGE market create or bet place without a + * native-coin fee output is rejected. bet.js resolves the payment mode + * only when the computed fee is above zero, and a market inside the + * duration-fee free window computes to zero. + * + * WHAT IT CHECKS. Both halves of every claim: the SOURCE fact the corrected + * wording rests on, read out of the sibling indexer, and the PROSE, which must + * no longer carry the old absolute and must state the condition that makes it + * conditional. The prose half runs unconditionally, so the guard can never come + * back all-skip; only the source half skips when the sibling checkout is absent. + * + * XCHAIN_DOCS_ROOT overrides the docs root. It exists so the negative control is + * runnable: point it at a checkout of an older commit and every prose assertion + * below goes red, which is how these were verified to be capable of failing. + */ +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); + +const DOC_ROOT = process.env.XCHAIN_DOCS_ROOT || path.join(__dirname, '..'); +const INDEXER = path.resolve(path.join(__dirname, '..'), '../xchain-indexer/src'); + +const haveIndexer = fs.existsSync(path.join(INDEXER, 'actions', 'order.js')); +const skipNoIndexer = !haveIndexer && 'sibling xchain-indexer not present in this checkout'; + +const readSrc = (rel) => fs.readFileSync(path.join(INDEXER, rel), 'utf8'); +const readDoc = (rel) => fs.readFileSync(path.join(DOC_ROOT, rel), 'utf8'); + +const creating = readDoc('user-guide/creating-tokens.md'); +const trading = readDoc('user-guide/trading.md'); +const faq = readDoc('user-guide/faq.md'); +const useCases = readDoc('user-guide/use-cases.md'); +const crossChain= readDoc('user-guide/cross-chain.md'); +const betting = readDoc('user-guide/betting.md'); +const gated = readDoc('protocol/token-gated-content.md'); +const nft = readDoc('protocol/nft-standard.md'); + +// Slice a markdown section by its heading, up to the next heading of any depth. +function section(md, heading, label){ + const lines = md.split('\n'); + const start = lines.findIndex((l) => l.trim() === heading); + assert.notStrictEqual(start, -1, `${label} no longer has the "${heading}" heading`); + let end = lines.length; + for(let i = start + 1; i < lines.length; i++){ + if(/^#{1,6}\s/.test(lines[i])){ end = i; break; } + } + return lines.slice(start, end).join('\n'); +} + +/* 1. MINT_SUPPLY is issued at create, and again only when an edit resupplies it. */ + +test('the mint-supply source facts still hold', { skip: skipNoIndexer }, () => { + const issue = readSrc('actions/issue.js'); + const v1 = issue.match(/this\.formats\[1\]\s*=\s*'([^']*)'/); + const v2 = issue.match(/this\.formats\[2\]\s*=\s*'([^']*)'/); + assert.ok(v1 && v2, 'issue.js no longer declares formats[1] and formats[2] as string literals'); + assert.ok(!v1[1].includes('MINT_SUPPLY'), + 'issue.js format 1 (description edit) now carries a MINT_SUPPLY field, so the guide\'s ' + + '"a description edit mints nothing" wording is no longer accurate'); + assert.ok(v2[1].includes('MINT_SUPPLY'), + 'issue.js format 2 (mint-params edit) no longer carries MINT_SUPPLY, so the guide\'s ' + + '"resupplying Mint Supply issues more" wording is no longer accurate'); + assert.match(issue, /if\(data\['MINT_SUPPLY'\]\)\s*\n\s*credits\.push/, + 'issue.js no longer credits MINT_SUPPLY only when the field is present in the action'); +}); + +test('the Mint Supply bullet does not say that editing a token re-mints', () => { + const bullet = creating.split('\n').find((l) => l.startsWith('- **Mint Supply**:')); + assert.ok(bullet, 'creating-tokens.md no longer carries a "- **Mint Supply**:" bullet'); + assert.ok(!/issues that much again/.test(bullet), + 'the Mint Supply bullet still says an edit "issues that much again". A description edit ' + + 'has no MINT_SUPPLY field (issue.js formats[1]) and mints nothing.'); + assert.match(bullet, /mint-settings edit/, + 'the Mint Supply bullet no longer names the mint-settings edit as the thing that issues ' + + 'more supply, which is the condition that makes the claim true'); + assert.match(bullet, /Lock Mint Supply/, + 'the Mint Supply bullet no longer names Lock Mint Supply as the way to close that path'); +}); + +/* 2. Escrow release is deferred by a pending coin payment, and by a dispenser close window. */ + +test('the deferred-escrow source facts still hold', { skip: skipNoIndexer }, () => { + const order = readSrc('actions/order.js'); + const orderExpire = readSrc('actions/order_expire.js'); + const dispenser = readSrc('actions/dispenser.js'); + const dispenserExpire = readSrc('actions/dispenser_expire.js'); + const config = readSrc('config.js'); + + assert.match(order, /getPendingCoinpayObligationsByOrder/, + 'order.js no longer checks for pending COINPay obligations before cancelling'); + assert.match(order, /createOrderStatus\([^)]*'cancelling'\)/, + 'order.js no longer defers a cancel to the two-phase \'cancelling\' status'); + assert.match(orderExpire, /createOrderStatus\([^)]*'expiring'\)/, + 'order_expire.js no longer defers an expiry to the two-phase \'expiring\' status'); + assert.match(dispenser, /createDispenserStatus\([^)]*'cancelling'/, + 'dispenser.js no longer routes a cancel through the \'cancelling\' close window'); + assert.match(config, /DISPENSER_CLOSE_DELAY'\]\s*=\s*3600/, + 'DISPENSER_CLOSE_DELAY is no longer 3600 seconds, so the guide\'s "one-hour" wording ' + + 'needs to change with it'); + assert.match(dispenserExpire, /createDispenserStatus\([^)]*'expired'\)/, + 'dispenser_expire.js no longer closes an expired dispenser directly. The guide says ' + + 'expiry returns tokens immediately BECAUSE it does not take the close window.'); + assert.doesNotMatch(dispenserExpire, /'cancelling'/, + 'dispenser_expire.js now routes expiry through \'cancelling\'; the guide\'s ' + + '"expiry needs no closing window" sentence would then be wrong'); +}); + +test('the guide does not promise an immediate escrow return on cancellation', () => { + for(const [label, md] of [['trading.md', trading], ['faq.md', faq]]){ + assert.ok(!/escrowed tokens are immediately returned/.test(md), + `${label} still promises escrow is "immediately returned" on cancel. order.js defers ` + + 'the release to \'cancelling\' while a COINPay obligation is outstanding.'); + } +}); + +test('the cancellation sections state the condition that defers the release', () => { + const cancelling = section(trading, '### Cancelling an Order', 'trading.md'); + assert.match(cancelling, /settles or lapses|settle or lapse/, + 'trading.md "Cancelling an Order" no longer says the escrow is released when an ' + + 'outstanding coin payment settles or lapses'); + assert.match(cancelling, /expiration/, + 'trading.md "Cancelling an Order" no longer says the same timing applies at expiration'); + assert.match(faq, /settles or its deadline passes/, + 'faq.md\'s cancel answer no longer carries the outstanding-coin-payment condition'); +}); + +test('the dispenser section separates a cancel close window from an immediate expiry', () => { + const dispensers = section(trading, '### Editing or Cancelling a Dispenser', 'trading.md'); + assert.match(dispensers, /one-hour closing window/, + 'trading.md no longer says a dispenser cancel enters a one-hour closing window ' + + '(DISPENSER_CLOSE_DELAY) before the leftover tokens come back'); + assert.match(dispensers, /Expiry needs no closing window/, + 'trading.md no longer distinguishes expiry, which closes the dispenser directly ' + + '(dispenser_expire.js), from a cancel, which does not'); + assert.ok(!/exactly as if you had cancelled it/.test(dispensers), + 'trading.md again equates dispenser expiry with a cancellation. They differ exactly in ' + + 'the close window, which is the point of the correction.'); +}); + +/* 3. and 4. What an ownership sale delivers, and what it does not. */ + +test('the ownership-sale and key-handoff source facts still hold', { skip: skipNoIndexer }, () => { + const send = readSrc('actions/send.js'); + const utility = readSrc('utility.js'); + + assert.match(send, /gated token transfer requires key handoff message/, + 'send.js no longer enforces the key-handoff MESSAGE, so the guide\'s ' + + '"only a direct send carries the key" wording is no longer accurate'); + + const others = ['actions/order_match.js', 'actions/dispense.js', 'actions/cross_settle.js'] + .filter((rel) => fs.existsSync(path.join(INDEXER, rel))); + assert.ok(others.length > 0, 'none of the DEX settlement handlers were found to check'); + for(const rel of others){ + assert.ok(!/requires key handoff message/.test(readSrc(rel)), + `${rel} now enforces a key handoff. If a settlement path delivers the key, the ` + + 'guide\'s "a buyer on the DEX gets no key" wording must change.'); + } + + const fn = utility.match(/async transferTokenOwnership\([\s\S]*?\n \}/); + assert.ok(fn, 'utility.js no longer defines transferTokenOwnership as expected'); + assert.ok(!/MESSAGE/.test(fn[0]), + 'transferTokenOwnership now emits a MESSAGE. If an ownership sale delivers key ' + + 'material, use-cases.md\'s archive bullet must change back.'); + + const crossSettle = readSrc('actions/cross_settle.js'); + assert.match(crossSettle, /transferTokenOwnership\(/, + 'cross_settle.js no longer settles an ownership leg locally, which is the fact behind ' + + 'the "each chain hands over its own side" wording'); +}); + +test('the guide scopes ownership-sale atomicity to a single chain', () => { + const answer = faq.split('\n').find((l) => l.includes('receives the issuer role')); + assert.ok(answer, 'faq.md no longer carries the issuer-rights sale answer'); + assert.match(answer, /settles on one chain|single-chain/, + 'faq.md again claims an issuer-rights sale settles in a single blockchain transaction ' + + 'without scoping it to one chain. A cross-chain swap settles each leg separately ' + + '(cross_settle.js).'); + assert.match(answer, /cross-chain\.md#residual-risk/, + 'faq.md no longer points at the cross-chain residual-risk section'); + + const ownership = section(useCases, '### Token Ownership Trading', 'use-cases.md'); + assert.match(ownership, /single-chain sale/, + 'use-cases.md again calls ownership transfer atomic without scoping it to one chain'); + assert.match(ownership, /cross-chain\.md#residual-risk/, + 'use-cases.md no longer points at the cross-chain residual-risk section'); +}); + +test('the guide does not claim a sale delivers the decryption keys', () => { + const ownership = section(useCases, '### Token Ownership Trading', 'use-cases.md'); + assert.ok(!/keys, future republish rights, and everything/.test(ownership), + 'use-cases.md again says an ownership sale hands over the archive keys. ' + + 'transferTokenOwnership writes a synthetic ISSUE and no MESSAGE.'); + assert.match(ownership, /direct send/, + 'use-cases.md no longer names the direct send as the separate key-delivery step'); + + assert.ok(!/Whoever buys the token automatically receives the decryption key/.test(gated), + 'token-gated-content.md again says any DEX buyer automatically receives the key. ' + + 'send.js is the only handler that requires the handoff MESSAGE.'); + assert.ok(!/Anyone who buys gets the decryption key in the same transaction/.test(gated), + 'token-gated-content.md\'s paid-downloads bullet again says a DISPENSER or ORDER buyer ' + + 'gets the key in the same transaction'); +}); + +/* 5. Cross-chain royalty listings are denied below the flag day. */ + +test('the cross-chain royalty source facts still hold', { skip: skipNoIndexer }, () => { + const swap = readSrc('actions/swap.js'); + const order = readSrc('actions/order.js'); + const changes = readSrc('protocol_changes.js'); + + for(const [label, src] of [['swap.js', swap], ['order.js', order]]){ + assert.match(src, /royalty not enforceable cross-chain/, + `${label} no longer denies a royalty-bearing cross-chain listing, so the guide's ` + + 'availability caveat is no longer accurate'); + assert.match(src, /isEnabled\('CROSS_CHAIN_ROYALTY'/, + `${label} no longer gates that denial on CROSS_CHAIN_ROYALTY`); + } + assert.match(changes, /'CROSS_CHAIN_ROYALTY'[^\n]*1798761600/, + 'CROSS_CHAIN_ROYALTY no longer activates on mainnet at 1798761600 (2027-01-01); the ' + + 'date the guide prints must move with it'); +}); + +test('the cross-chain guide carries the royalty availability caveat', () => { + const pairs = section(crossChain, '## Available Pairs', 'cross-chain.md'); + assert.match(pairs, /royalty not enforceable cross-chain/, + 'cross-chain.md "Available Pairs" no longer names the rejection a royalty-bearing ' + + 'cross-chain listing gets at create'); + assert.match(pairs, /CROSS_CHAIN_ROYALTY/, + 'cross-chain.md "Available Pairs" no longer names the flag day that lifts the restriction'); + // Deliberately NOT the date itself: flag-day-literals.test.js forbids quoting a + // flag-day value in prose, because a repin would rot it. Assert the link instead. + assert.match(pairs, /protocol\/flag-days\.md/, + 'cross-chain.md "Available Pairs" no longer links to the flag-day table, which is where ' + + 'the activation date is allowed to live'); +}); + +test('the NFT standard does not claim the rails have no special cases', () => { + assert.ok(!/All existing rails apply to NFT-pattern tokens with no special cases/.test(nft), + 'nft-standard.md again claims the rails apply with no special cases, while a ' + + 'royalty-bearing cross-chain listing is denied at create'); + assert.match(nft, /CROSS_CHAIN_ROYALTY/, + 'nft-standard.md no longer names the cross-chain royalty exception anywhere'); +}); + +/* 6. A fee output is required only when a fee is actually owed. */ + +test('the zero-fee source facts still hold', { skip: skipNoIndexer }, () => { + const bet = readSrc('actions/bet.js'); + const utility = readSrc('utility.js'); + + assert.match(bet, /if\(!error && this\.util\.bcgt\(fees\['AMOUNT'\], 0\)\)\{\s*\n\s*let paymentMode/, + 'bet.js no longer resolves the fee payment mode only when the computed fee is above ' + + 'zero, so a free-window market may now need a fee output after all'); + assert.match(bet, /getUnifiedDurationFee\(data\['EXPIRE_AT'\]/, + 'bet.js no longer prices market creation on the duration schedule'); + assert.match(utility, /chargeableDays[\s\S]{0,200}if\(this\.bcgt\(chargeableDays, 0\)\)/, + 'getUnifiedDurationFee no longer charges nothing inside the free-day window'); +}); + +test('the betting guide ties the fee-output requirement to a fee being owed', () => { + assert.ok(!/a market created, or a bet placed, without a native-coin fee output is rejected/.test(betting), + 'betting.md again says any market create or bet place without a native-coin fee output ' + + 'is rejected. bet.js validates payment only when the fee is above zero, and a market ' + + 'inside the free window owes nothing.'); + assert.match(betting, /an action that owes a fee/, + 'betting.md no longer conditions the native-coin fee-output requirement on a fee ' + + 'actually being owed'); + assert.match(betting, /free window owes nothing/, + 'betting.md no longer says a market inside the free window needs no fee output'); +}); diff --git a/test/supply-lock-claims.test.js b/test/supply-lock-claims.test.js new file mode 100644 index 0000000..39cc1c7 --- /dev/null +++ b/test/supply-lock-claims.test.js @@ -0,0 +1,126 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. + * + ********************************************************************** + * + * Supply-permanence claims in the token-creation guide. + * + * WHY. Two of the guide's promises were absolute where the handlers are + * conditional, and both are the kind a token buyer prices in. + * + * 1. MAX_SUPPLY was described as a lifetime issuance ceiling ("how many + * tokens can ever exist ... like a gold mine"). The handler compares + * SUPPLY + AMOUNT against MAX_SUPPLY, where SUPPLY is the LEDGER total + * (credits - debits + escrows) and DESTROY pushes a debit. Burning + * therefore returns mint headroom, so the cap bounds what is OUTSTANDING + * at one time, never what has been issued over a token's life. + * 2. LOCK_MINT was described as closing supply creation outright. It gates + * the MINT command alone. The issuer's own MINT_SUPPLY path is gated by + * the separate LOCK_MINT_SUPPLY flag, and LOCK_MINT is consulted nowhere + * in the ISSUE handler, so an owner can still create supply with + * LOCK_MINT set and LOCK_MINT_SUPPLY unset. + * + * WHAT IT CHECKS. Both sides, because either one alone is a half guard: + * + * - The SOURCE facts the corrected wording rests on still hold in the + * sibling indexer. If LOCK_MINT ever becomes a guard in issue.js, or the + * ceiling stops being compared against ledger supply, the doc sentence is + * no longer the accurate one and this guard says so. + * - The PROSE no longer states either permanence claim, and does state the + * condition that makes it conditional. + * + * The prose side runs unconditionally; only the source side skips when the + * sibling checkout is absent, so the guard can never come back all-skip. + */ +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); + +const DOC_ROOT = path.join(__dirname, '..'); +const INDEXER = path.resolve(DOC_ROOT, '../xchain-indexer/src'); +const GUIDE = path.join(DOC_ROOT, 'user-guide', 'creating-tokens.md'); + +const haveIndexer = fs.existsSync(path.join(INDEXER, 'actions', 'mint.js')); +const readSrc = (rel) => fs.readFileSync(path.join(INDEXER, rel), 'utf8'); +const guide = fs.readFileSync(GUIDE, 'utf8'); + +// Slice a markdown section by its heading, up to the next heading of any depth. +function section(md, heading){ + const lines = md.split('\n'); + const start = lines.findIndex((l) => l.trim() === heading); + assert.notStrictEqual(start, -1, `creating-tokens.md no longer has the "${heading}" heading`); + let end = lines.length; + for(let i = start + 1; i < lines.length; i++){ + if(/^#{1,6}\s/.test(lines[i])){ end = i; break; } + } + return lines.slice(start, end).join('\n'); +} + +const supplySection = section(guide, '### Supply'); +const lockSection = section(guide, '## Building Trust: Locking Parameters'); +const lockMintBullet = lockSection.split('\n').find((l) => l.startsWith('- **LOCK_MINT**:')); + +const skipNoIndexer = !haveIndexer && 'sibling xchain-indexer not present in this checkout'; + +test('the source facts the supply wording rests on still hold', { skip: skipNoIndexer }, () => { + const mint = readSrc('actions/mint.js'); + const db = readSrc('db.js'); + const destroy = readSrc('actions/destroy.js'); + + assert.match(mint, /bcadd\(data\['SUPPLY'\],data\['AMOUNT'\]/, + 'mint.js no longer compares SUPPLY + AMOUNT against the ceiling; the guide\'s ' + + '"outstanding at one time" wording may need to change back'); + assert.match(mint, /MAX_SUPPLY/, 'mint.js no longer names MAX_SUPPLY'); + assert.match(db, /bcadd\(this\.util\.bcsub\(credits, debits, exact\), escrows, decimals\)/, + 'db.js getTokenSupply no longer computes supply as credits - debits + escrows, ' + + 'so burning may no longer return mint headroom'); + assert.match(destroy, /debits\.push\(\[destroy\['TICK'\], destroy\['AMOUNT'\], destroy\['SOURCE'\]\]\)/, + 'destroy.js no longer debits the burned amount, so DESTROY may no longer lower supply'); +}); + +test('the source facts the LOCK_MINT wording rests on still hold', { skip: skipNoIndexer }, () => { + const mint = readSrc('actions/mint.js'); + const issue = readSrc('actions/issue.js'); + + assert.match(mint, /tokenInfo\['LOCK_MINT'\]==1/, + 'mint.js no longer gates the MINT command on LOCK_MINT'); + assert.match(issue, /tokenInfo\['LOCK_MINT_SUPPLY'\]==1/, + 'issue.js no longer gates MINT_SUPPLY on LOCK_MINT_SUPPLY'); + assert.doesNotMatch(issue, /tokenInfo\['LOCK_MINT'\]/, + 'issue.js now consults LOCK_MINT; if it gates MINT_SUPPLY, the guide\'s ' + + '"LOCK_MINT_SUPPLY is also required" wording is no longer accurate'); +}); + +test('the Supply section does not promise a lifetime issuance ceiling', () => { + for(const phrase of ['can ever exist', 'no more can be created', 'gold mine']){ + assert.ok(!supplySection.includes(phrase), + `creating-tokens.md "### Supply" states "${phrase}". MAX_SUPPLY bounds OUTSTANDING ` + + 'supply (mint.js compares SUPPLY + AMOUNT), and DESTROY returns headroom.'); + } +}); + +test('the Supply section says the cap is on outstanding supply and that burning frees headroom', () => { + assert.match(supplySection, /outstanding/i, + 'creating-tokens.md "### Supply" no longer describes the cap as an outstanding-supply limit'); + assert.match(supplySection, /destroy|burn/i, + 'creating-tokens.md "### Supply" no longer says that destroying tokens frees headroom, ' + + 'which is the fact that makes the cap conditional'); +}); + +test('the LOCK_MINT bullet scopes itself to the MINT command and names the companion lock', () => { + assert.ok(lockMintBullet, 'creating-tokens.md no longer carries a "- **LOCK_MINT**:" bullet'); + assert.ok(!lockMintBullet.includes('no new supply can ever be created'), + 'the LOCK_MINT bullet still promises that no new supply can ever be created. LOCK_MINT ' + + 'gates the MINT command only; issue.js gates MINT_SUPPLY on LOCK_MINT_SUPPLY.'); + assert.match(lockMintBullet, /LOCK_MINT_SUPPLY/, + 'the LOCK_MINT bullet does not name LOCK_MINT_SUPPLY, so a reader is not told that ' + + 'the issuer path stays open'); +}); diff --git a/test/tis-schema-field-coverage.test.js b/test/tis-schema-field-coverage.test.js new file mode 100644 index 0000000..67eee37 --- /dev/null +++ b/test/tis-schema-field-coverage.test.js @@ -0,0 +1,213 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. + * + ********************************************************************** + * + * Token Information Standard: field table vs published JSON Schema. + * + * WHY. The TIS prose grew the token-gating fields (`packs`, `title`, + * `data_ref`, `locked`, `pack_id`) as the explorer and indexer started using + * them, and the published schema never did. They validated anyway, because the + * media definitions do not set `additionalProperties: false`, so they passed by + * omission rather than by contract and a strict validator or a code generator + * dropped them. The `website` bound drifted the same silent way: 100 in the + * prose, 255 in the schema, with nothing comparing the two. + * + * WHAT IT CHECKS. + * + * 1. Every row of the two TIS field tables is declared in the CURRENT schema + * (top-level rows as top-level properties; file-entry rows in all four of + * the images/audio/video/files definitions). + * 2. Every character bound the prose states equals the schema's maxLength. + * 3. The worked example parses and uses no key the schema does not declare. + * 4. v1.0.0 stays frozen: still stamped 1.0.0 and still without the gating + * fields, so drift is never "fixed" by rewriting a published version. + * + * FLOORS, BECAUSE A PARSER THAT MATCHES NOTHING READS AS GREEN. A markdown + * table lint that silently stops matching passes forever. The row floors below + * fail with "the table format changed" rather than reporting full coverage of + * an empty set, and the parser and comparators are exercised on fixtures so a + * refactor that breaks them is caught here rather than by their silence. + * + ********************************************************************/ + +const assert = require('node:assert/strict'); +const { test, describe } = require('node:test'); +const fs = require('node:fs'); +const path = require('node:path'); + +const DOC_ROOT = path.join(__dirname, '..'); +const SPEC = path.join(DOC_ROOT, 'protocol/token-information-standard.md'); +const JSON_DIR = path.join(DOC_ROOT, 'protocol/json'); + +const CURRENT = '1.1.0'; +const MEDIA = ['images', 'audio', 'video', 'files']; + +// A row is `| field | Type | Description`. The header and the `| :--- |` +// separator are not rows; neither is anything outside the named section. +function fieldRows(markdown, heading) { + const lines = markdown.split('\n'); + const start = lines.findIndex((l) => l.trim() === heading); + if (start === -1) return []; + const rows = []; + let seenTable = false; + for (const line of lines.slice(start + 1)) { + if (/^#{1,6}\s/.test(line)) break; + if (!line.startsWith('|')) { if (seenTable) break; continue; } + seenTable = true; + const cells = line.split('|').slice(1).map((c) => c.trim()); + const name = cells[0]; + if (!name || /^:?-{3,}/.test(name) || name === 'Field') continue; + rows.push({ name, type: cells[1] || '', description: cells.slice(2).join('|').trim() }); + } + return rows; +} + +// "2048 characters max." in the prose is a claim about the schema's maxLength. +function statedBound(description) { + const m = /(\d+)\s+characters max/i.exec(description); + return m ? Number(m[1]) : null; +} + +function readJson(name) { + return JSON.parse(fs.readFileSync(path.join(JSON_DIR, name), 'utf8')); +} + +const SPEC_TEXT = fs.readFileSync(SPEC, 'utf8'); +const TOP_ROWS = fieldRows(SPEC_TEXT, '#### JSON Field Definitions'); +const ENTRY_ROWS = fieldRows(SPEC_TEXT, '#### File Entry Fields'); + +const schema = readJson(`token-information-standard-v${CURRENT}-schema.json`); +const example = readJson(`token-information-standard-v${CURRENT}-example.json`); + +describe('TIS field table / schema coverage', () => { + + // Fixtures, not the real page. A parser that returned [] and a bound + // reader that returned null would leave every assertion below vacuously + // true, and neither failure is visible from a green run over real files. + test('the table parser and the bound reader can both say no', () => { + const sample = [ + '#### JSON Field Definitions', + '', + '| Field | Type | Description', + '| :--- | :--- | :---', + '| tick | String | The TICK of the token', + '| website | String | A link. 255 characters max.', + '', + '#### File Entry Fields', + '', + '| Field | Type | Description', + '| :--- | :--- | :---', + '| data_ref | String | *(since v1.1.0)* A ref. See [`FILE`](./actions/file.md).', + '', + ].join('\n'); + + assert.deepEqual(fieldRows(sample, '#### JSON Field Definitions').map((r) => r.name), + ['tick', 'website'], 'the header and the :--- separator are not field rows'); + assert.deepEqual(fieldRows(sample, '#### File Entry Fields').map((r) => r.name), + ['data_ref'], 'a second table must not bleed into the first'); + assert.deepEqual(fieldRows(sample, '#### No Such Heading'), [], + 'a renamed heading yields nothing, which is what the floors below catch'); + + assert.equal(statedBound('A link. 255 characters max.'), 255); + assert.equal(statedBound('A link. 100 characters max.'), 100); + assert.equal(statedBound('The TICK of the token'), null); + }); + + test('both field tables were actually read', () => { + assert.ok(TOP_ROWS.length >= 12, + `only ${TOP_ROWS.length} top-level field rows parsed out of ` + + 'protocol/token-information-standard.md; the table format changed and this gate ' + + 'is no longer reading it'); + assert.ok(ENTRY_ROWS.length >= 5, + `only ${ENTRY_ROWS.length} file-entry field rows parsed; the table format changed`); + }); + + test(`every documented top-level field is declared in the v${CURRENT} schema`, () => { + const undeclared = TOP_ROWS.map((r) => r.name).filter((n) => !(n in schema.properties)); + assert.deepEqual(undeclared, [], + 'field-table rows with no property in ' + + `token-information-standard-v${CURRENT}-schema.json. A field that only the prose ` + + 'declares validates by omission, and a code generator drops it:\n ' + + undeclared.join('\n ')); + }); + + test(`every documented file-entry field is declared in all four media definitions`, () => { + const undeclared = []; + for (const def of MEDIA) { + const props = schema.definitions[def].properties; + for (const row of ENTRY_ROWS) + if (!(row.name in props)) undeclared.push(`${def}.${row.name}`); + } + assert.deepEqual(undeclared, [], + 'the file-entry table says it applies to files, audio, video and images alike, so a ' + + 'field missing from any one of them is a contract that differs by array:\n ' + + undeclared.join('\n ')); + }); + + test('every character bound the prose states matches the schema maxLength', () => { + const drifted = []; + for (const row of TOP_ROWS) { + const stated = statedBound(row.description); + if (stated === null) continue; + const declared = (schema.properties[row.name] || {}).maxLength; + if (declared !== stated) + drifted.push(`${row.name}: prose says ${stated}, schema says ${declared}`); + } + assert.deepEqual(drifted, [], + 'a publisher truncating to the documented bound and a validator enforcing the ' + + 'schema disagree about what is valid:\n ' + drifted.join('\n ')); + }); + + test('the worked example uses no key the schema does not declare', () => { + const unknown = Object.keys(example).filter((k) => !(k in schema.properties)); + assert.deepEqual(unknown, [], 'top-level keys in the example that the schema omits'); + + const perEntry = []; + for (const def of MEDIA) { + const props = schema.definitions[def].properties; + for (const entry of example[def] || []) + for (const k of Object.keys(entry)) + if (!(k in props)) perEntry.push(`${def}[].${k}`); + } + assert.deepEqual(perEntry, [], 'entry keys in the example that the schema omits'); + + // The example has to exercise the gating fields, or it documents them + // by describing them and by showing nothing. + const entries = MEDIA.flatMap((def) => example[def] || []); + for (const field of ['title', 'data_ref', 'locked', 'pack_id']) + assert.ok(entries.some((e) => field in e), + `no entry in the v${CURRENT} example uses ${field}`); + assert.ok(example.packs && Object.keys(example.packs).length > 0, + `the v${CURRENT} example declares no packs map`); + for (const entry of entries) + if (entry.pack_id) + assert.ok(entry.pack_id in example.packs, + `example pack_id "${entry.pack_id}" keys into no packs entry`); + }); + + test('v1.0.0 stays frozen at what it published', () => { + const published = readJson('token-information-standard-v1.0.0-schema.json'); + assert.equal(published.version, '1.0.0'); + assert.equal('packs' in published.properties, false, + 'the gating fields postdate v1.0.0; declaring them there rewrites a published ' + + 'contract instead of superseding it'); + for (const def of MEDIA) + for (const field of ['title', 'data_ref', 'locked', 'pack_id']) + assert.equal(field in published.definitions[def].properties, false, + `v1.0.0 ${def}.${field} appeared; publish v${CURRENT} instead`); + }); + + test(`the current schema and example are stamped v${CURRENT}`, () => { + assert.equal(schema.version, CURRENT); + assert.ok(SPEC_TEXT.includes(`token-information-standard-v${CURRENT}-schema.json`), + 'the spec page must link the current schema, or readers land on the frozen one'); + }); +}); diff --git a/user-guide/betting.md b/user-guide/betting.md index c1053f6..754ae8b 100644 --- a/user-guide/betting.md +++ b/user-guide/betting.md @@ -127,7 +127,7 @@ The market fee is priced by **how long the market lives**, counted all the way t | 1 year | 1.5125 XCHAIN | | 2 years (the maximum) | 3.52 XCHAIN | -Those prices are always denominated in XCHAIN, but XCHAIN is not always what pays them. On **Litecoin and Dogecoin, paying in the native coin is the only option**: a market created, or a bet placed, without a native-coin fee output is rejected. On **Bitcoin** you may instead have the fee deducted from an XCHAIN balance, if you hold one and prefer that. +Those prices are always denominated in XCHAIN, but XCHAIN is not always what pays them. On **Litecoin and Dogecoin, paying in the native coin is the only option**: an action that owes a fee is rejected if it carries no native-coin fee output. That applies to every bet you place, and to any market whose life runs past the free window; a market inside the free window owes nothing and needs no fee output at all. On **Bitcoin** you may instead have the fee deducted from an XCHAIN balance, if you hold one and prefer that. Placing a bet costs the bettor a small fee, priced in XCHAIN and paid the same way as above. **Resolving is free**, no matter how many bets are on the book, so a busy market never costs you more to settle honestly. **Cancelling is free** too. diff --git a/user-guide/creating-tokens.md b/user-guide/creating-tokens.md index ce91b0d..8ded7f7 100644 --- a/user-guide/creating-tokens.md +++ b/user-guide/creating-tokens.md @@ -45,7 +45,7 @@ When you create a token, you configure a set of properties that define how it be ### Supply -**Max Supply** is the ceiling on how many tokens can ever exist. Once that ceiling is reached through minting, no more can be created. Think of it like a gold mine with a finite amount of gold; once it is dug out, there is no more. +**Max Supply** is the ceiling on how many tokens can be outstanding at one time. Every mint is checked against the current supply plus the amount being minted, so while supply sits at the ceiling, further minting is refused. Destroying tokens lowers the current supply and frees that much headroom again, so a max supply is not a limit on how much can be issued over a token's lifetime. Think of it as a tank with a fixed capacity rather than a mine with a finite amount of ore: draining it makes room to refill. To close issuance for good, lock the minting paths (see Locking below) rather than relying on the ceiling alone. Setting a max supply of zero means the supply is unlimited, which is appropriate for some use cases (like reward points that grow over time) but not others (like collectibles where scarcity matters). @@ -65,7 +65,7 @@ A short text description of your token. This appears in explorers and wallets. K **Minting** is the act of creating new tokens and adding them to circulation. You set the rules for how minting works at creation time. -- **Mint Supply**: How much supply is issued straight to you at the moment you create the token, up to Max Supply. For example, if mint supply is 100, creating the token credits you 100 tokens. This is your own issued supply, not the amount a public mint produces; editing the token issues that much again unless you lock it. +- **Mint Supply**: How much supply is issued straight to you at the moment you create the token, up to Max Supply. For example, if mint supply is 100, creating the token credits you 100 tokens. This is your own issued supply, not the amount a public mint produces. It is issued once, when the token is created: editing the description, or changing any other setting on its own, mints nothing further. You issue more only by sending a mint-settings edit that fills in Mint Supply again, which credits you that amount a second time, still capped by Max Supply. Set Lock Mint Supply to close that path for good. - **Max Mint**: The largest amount of supply any single mint transaction may create. It caps how much one mint produces, not how many mints can happen; left unset (`0`) there is no per-transaction cap and Max Supply is the only ceiling. - **Mint Start Block / Mint Stop Block**: You can schedule a minting window. Before the start block, minting is not allowed. After the stop block, minting closes. This is how you run a timed token launch; a window opens, people mint during it, and it closes automatically. - **Per-Address Limit**: You can cap the total amount a single address is allowed to mint, added up across every mint that address makes. This prevents one person from minting everything in a public launch. @@ -103,7 +103,9 @@ You choose which kinds of action a controller gates: - **stake**: staking the token into a contract - **ownership**: handing over the token's ownership record -There is also **all**, a catch-all you can bind on its own or underneath the specific classes. Exactly one guard ever runs for any action: the most specific binding wins, and `all` is the fallback for any class you have not bound directly. Binding `all` is therefore a single action that gates everything, which is what makes it a "freeze this token entirely" or "compliance-gate everything" policy. +There is also **all**, a catch-all you can bind on its own or underneath the specific classes. Your token runs at most one guard per action: the most specific binding wins, and `all` is the fallback for any class you have not bound directly. Binding `all` is therefore a single action that gates everything, which is what makes it a "freeze this token entirely" or "compliance-gate everything" policy. + +One thing that surprises people: "one guard per token" is not "one guard per action". The sender's and the recipient's own accounts can each have a controller bound too, and a single `SEND` runs all of them in turn: your token's guard, then the sender's, then the recipient's. Each one costs gas, so the sender needs enough `GAS` for every guard the action can invoke, not just for yours. Four things to know before you bind one: @@ -122,18 +124,20 @@ One of the most powerful features in XChain is the ability to **lock** a paramet Why would you want to lock your own token? Because it builds trust. -Imagine you are launching a collectible token and you tell buyers "only 10,000 will ever exist." That is a promise. If you lock the max supply, it becomes a verifiable, unbreakable guarantee written into the blockchain itself. Buyers do not have to trust your word; they can verify the lock themselves. +Imagine you are launching a collectible token and you tell buyers "no more than 10,000 will ever be held at once." That is a promise. If you lock the max supply, the ceiling becomes a verifiable, unbreakable guarantee written into the blockchain itself: nobody, you included, can raise it later. Buyers do not have to trust your word; they can verify the lock themselves. Locking the ceiling does not by itself stop new tokens being minted into headroom that earlier burns opened up, so a promise about the total ever issued needs the mint locks below as well. Parameters you can lock include: -- **LOCK_MAX_SUPPLY**: the `MAX_SUPPLY` ceiling can never be raised, proving the total cannot be inflated beyond what is set now -- **LOCK_MINT**: no one can ever run the MINT command against this token again, so no new supply can ever be created +- **LOCK_MAX_SUPPLY**: the `MAX_SUPPLY` ceiling can never be raised, proving the amount outstanding at any one time cannot be inflated beyond what is set now +- **LOCK_MINT**: no one can ever run the `MINT` command against this token again. That closes public minting only; as the issuer you can still create supply with `MINT_SUPPLY` on a re-issue unless `LOCK_MINT_SUPPLY` is also set - **LOCK_MINT_SUPPLY**: the token is frozen against you issuing any further supply to yourself via `MINT_SUPPLY`; public minting is unaffected - **LOCK_MAX_MINT**: the `MAX_MINT` per-transaction amount cap is frozen permanently and can never be edited again - **LOCK_DESCRIPTION**: proves the token's description cannot be swapped out - **LOCK_SLEEP**: the token can never be paused by the SLEEP command; useful for tokens that must always be tradeable - **Callback settings** (`LOCK_CALLBACK`): proves the recall terms cannot be altered after the fact +No single flag forecloses all supply creation. Set **LOCK_MINT** and **LOCK_MINT_SUPPLY** together to close both issuance paths, and add **LOCK_MAX_SUPPLY** if you also want the ceiling itself frozen. + Locking is a one-way door. Think carefully before locking anything. Once it is done, there is no going back. Not even for you. One thing the lock flags do not cover is a **controller binding**. There is no `LOCK_CONTROLLER`, so a binding cannot be frozen the way a max supply can, and one can be added to a token after it has been issued. The drop-cooldown you commit to at bind time is the only friction on changing or removing one. Anyone weighing up a token's guarantees should read its bindings alongside its locks. diff --git a/user-guide/cross-chain.md b/user-guide/cross-chain.md index 5444bcd..7fec630 100644 --- a/user-guide/cross-chain.md +++ b/user-guide/cross-chain.md @@ -94,6 +94,8 @@ Because the two chains settle their legs independently, there is one edge case t Any token that exists on one supported chain can potentially be swapped for any token on another supported chain, as long as there is a willing counterparty. Today that means trades between Bitcoin, Litecoin, and Dogecoin tokens. +There is one exception for now. A token bound to a controller contract that takes a cut of each sale (a royalty or fee split) cannot be listed cross-chain: the proceeds settle on the counterparty's chain, which does not run the contract, so the cut could not be collected there. Rather than let the sale go through and quietly drop the cut, the protocol refuses the listing when it is created, with the error `invalid: royalty not enforceable cross-chain`. Tokens with no such binding are unaffected, and so are same-chain sales of a bound token. The restriction lifts on mainnet at the `CROSS_CHAIN_ROYALTY` flag day, whose date is listed in [Flag Days](../protocol/flag-days.md), and is already lifted on testnet and regtest. See [Cross-chain sales](../protocol/controller-bound-tokens.md#cross-chain-sales-cross_chain_royalty) and [Flag Days](../protocol/flag-days.md). + As XChain adds support for more Bitcoin-compatible blockchains, the number of available cross-chain trading pairs grows automatically. Every new chain that joins the platform opens up swap routes with every existing chain. --- diff --git a/user-guide/faq.md b/user-guide/faq.md index 03745e6..173a54d 100644 --- a/user-guide/faq.md +++ b/user-guide/faq.md @@ -61,7 +61,7 @@ Yes. You can publish a file (or a whole pack of files) to the blockchain encrypt ### Can I sell my token's issuer rights? -Yes. A token has two separate things attached to it: the *balances* (who holds how many tokens) and the *ownership* (who can update the token's settings, mint new supply, change the description, etc.). You can sell ownership on its own (keeping or distributing the balances any way you like) using a standard order, swap, or dispenser with the "give ownership" flag set. The transfer is atomic with the trade: the seller receives the payment and the buyer receives the issuer role in a single blockchain transaction, with no off-chain trust between them. +Yes. A token has two separate things attached to it: the *balances* (who holds how many tokens) and the *ownership* (who can update the token's settings, mint new supply, change the description, etc.). You can sell ownership on its own (keeping or distributing the balances any way you like) using a standard order, swap, or dispenser with the "give ownership" flag set. On a sale that settles on one chain, the transfer is atomic with the trade: the seller receives the payment and the buyer receives the issuer role in a single blockchain transaction, with no off-chain trust between them. A swap across two chains cannot work that way, because no single transaction exists on both chains; each chain hands over its own side once it sees the signed match, so the same residual risk applies as to any cross-chain trade. See the [Cross-Chain guide](./cross-chain.md#residual-risk). ### What are sub-tokens? @@ -93,7 +93,7 @@ Yes. When you place a sell order or set up a swap, your tokens are moved into pr ### Can I cancel an order once it is placed? -Yes. You can cancel any of your open orders at any time before they are filled. When you cancel, your escrowed tokens are immediately returned to your available balance. You do not need anyone's permission to cancel your own order. +Yes. You can cancel any of your open orders at any time before they are filled, and you do not need anyone's permission to cancel your own order. Cancelling stops further matching straight away, and your escrowed tokens go back to your available balance in the same transaction, unless a buyer who matched you still owes a coin payment on it: that escrow is released when the payment settles or its deadline passes. See [Cancelling an Order](./trading.md#cancelling-an-order). --- diff --git a/user-guide/trading.md b/user-guide/trading.md index 009f7a7..83be3a8 100644 --- a/user-guide/trading.md +++ b/user-guide/trading.md @@ -35,15 +35,19 @@ Matching happens as orders are processed by the indexer. You do not need to be o ### Order Expiration -Orders do not stay open forever. When you place an order, you set an expiration. After that time (set as a date/time), any unfilled portion of your order is automatically cancelled and your escrowed tokens are returned. This prevents stale orders from clogging the exchange. +Orders do not stay open forever. When you place an order, you set an expiration. After that time (set as a date/time), any unfilled portion of your order is automatically cancelled and your escrowed tokens are returned, on the same timing as a cancellation you make yourself (see below). This prevents stale orders from clogging the exchange. ### Cancelling an Order -You can cancel any of your open orders at any time before they are filled. When you cancel, your escrowed tokens are immediately returned to your available balance. You do not need anyone's permission to cancel your own order. +You can cancel any of your open orders at any time before they are filled. You do not need anyone's permission to cancel your own order. + +Cancelling stops any further matching straight away. When your order is priced in tokens on both sides, or has matched nothing that still owes you a coin payment, your escrowed tokens go back to your available balance in the same transaction that cancels the order. + +Coin payments are the one exception. When a buyer takes your order and pays in bitcoin, litecoin or dogecoin, that payment is not made in the transaction that matched you; the buyer owes it, and the protocol tracks the debt until it is paid or its deadline passes. Cancelling while such a payment is still outstanding closes the order to new matches now, and releases what is left in escrow once that payment settles or lapses. The same is true when the order reaches its expiration instead of being cancelled. ### Safety During a Trade -Your tokens are never at risk during an open order. They sit in protocol-level escrow. Not on a company's server, not in a wallet someone else controls. The protocol guarantees they can only be released in two ways: to a matching buyer, or back to you upon cancellation or expiration. There is no third outcome. +Your tokens are never at risk during an open order. They sit in protocol-level escrow. Not on a company's server, not in a wallet someone else controls. The protocol guarantees there are only two addresses they can ever reach: a matching buyer's, or your own on cancellation or expiration. Nobody else can be paid out of that escrow. What can vary is the timing of the return, not the destination: if a buyer still owes you a coin payment, the release waits for that payment to settle or lapse, as described above. --- @@ -91,9 +95,9 @@ Think of a dispenser like a coin-operated machine at a store. You set it up once You can edit an active dispenser to add more tokens to it (a refill), change its expiration, or update its allow and block lists. A refill resets the dispense count to zero, so the dispenser can serve another 1,000 dispenses; you get 5 refills (the 6th is rejected), for a lifetime ceiling of 6 fills, or 6,000 dispenses. Its **price and the amount dispensed per purchase are fixed when you create it and cannot be changed**; if you need a different price, cancel the dispenser and create a new one. -You can cancel a dispenser at any time, and any tokens still in it are returned to your balance. +You can cancel a dispenser at any time, and any tokens still in it are returned to your balance. Cancelling does not close the dispenser on the spot: it enters a one-hour closing window first, so that a buyer whose payment was already on its way is still served rather than left paying into a machine that has gone. Your leftover tokens come back when that window ends. -**Every dispenser expires.** You set an expiration when you create it; if you do not choose one, a default of 90 days is applied. When that deadline passes, the dispenser closes automatically and any tokens still in it are returned to your balance, exactly as if you had cancelled it. A dispenser is never open indefinitely, so extend the expiration by editing the dispenser if you want it to keep selling. +**Every dispenser expires.** You set an expiration when you create it; if you do not choose one, a default of 90 days is applied. When that deadline passes, the dispenser closes automatically and any tokens still in it are returned to your balance. Expiry needs no closing window, so unlike a cancellation the return is immediate. A dispenser is never open indefinitely, so extend the expiration by editing the dispenser if you want it to keep selling. --- diff --git a/user-guide/use-cases.md b/user-guide/use-cases.md index 297002d..9d519da 100644 --- a/user-guide/use-cases.md +++ b/user-guide/use-cases.md @@ -160,16 +160,16 @@ XChain actions involved: SWAP. ### Token Ownership Trading -A token has two separate things attached to it: the *balances* (who holds how much) and the *ownership* (who can update the token's settings, mint new supply, change the description, etc.). Until recently, only balances could be traded. Now you can sell ownership of an entire token on the DEX, atomically, with no off-chain trust. +A token has two separate things attached to it: the *balances* (who holds how much) and the *ownership* (who can update the token's settings, mint new supply, change the description, etc.). Until recently, only balances could be traded. Now you can sell ownership of an entire token on the DEX, with no off-chain trust; on a sale that settles on one chain, payment and handover are the same transaction. **What this enables:** - **Selling a finished project.** A creator who built a token, distributed it to holders, and now wants to step away can sell the issuer role outright. The buyer takes over future updates, the seller cashes out. -- **Selling a gated content archive.** Combined with [token-gated publishing](#token-gated-encrypted-content-and-packs), the issuer can sell the entire archive (keys, future republish rights, and everything) to a buyer in a single trade. +- **Selling a gated content archive.** Combined with [token-gated publishing](#token-gated-encrypted-content-and-packs), the issuer can sell the right to republish and to manage the token in a single trade. The decryption keys are not part of that trade: an ownership sale moves the issuer record, not the key material, so hand the keys over yourself in a direct send to the buyer to complete the handover. - **Brand or sub-token portfolios.** Sell a parent token together with the right to issue its sub-tokens, transferring an entire token namespace as one asset. - **Auctioning a launched token.** Set up a dispenser that hands out the ownership role at a fixed price. The first buyer to send the asking amount becomes the new issuer. -Ownership transfer is atomic with the trade; there is no moment where the seller has the payment but the buyer doesn't yet have ownership. +On a single-chain sale, ownership transfer is atomic with the trade; there is no moment where the seller has the payment but the buyer doesn't yet have ownership. A swap across two chains cannot be settled by one transaction, so each chain hands over its own side against the signed match and the usual cross-chain residual risk applies. See [Residual risk](./cross-chain.md#residual-risk). XChain actions involved: ORDER, SWAP, or DISPENSER (each with the `GIVE_OWNERSHIP` / `GET_OWNERSHIP` flag). diff --git a/whitepaper.md b/whitepaper.md index 12c8412..f6b26cc 100644 --- a/whitepaper.md +++ b/whitepaper.md @@ -292,7 +292,7 @@ The protocol defines 37 named ACTIONs across ten categories. Of these, 31 are us - **ISSUE** creates a token or updates one you own. v0 is full creation (supply, decimals, mint window, lists, callback terms, locks); v1-v5 are targeted edits (description; mint params; lock flags; callback params; access lists); v6 binds or unbinds a controller contract that guards a class of the token's actions (§7.8). `DECIMALS` is immutable once supply exists. Non-fungible and edition tokens use these same fields (§5.3). - **MINT** mints supply within the token's rules. Permissionless within an open mint window; the owner may mint beyond `MAX_MINT`; nothing may exceed `MAX_SUPPLY`. - **DESTROY** permanently burns the holder's balance. v0 single; v1/v2 multi-token batches. -- **CALLBACK** lets the owner force-recall all outstanding supply after `CALLBACK_BLOCK`, paying holders the defined compensation token. Fee scales with holder count. +- **CALLBACK** lets the owner force-recall all outstanding supply at or after `CALLBACK_BLOCK`, paying holders the defined compensation token. Fee scales with holder count. - **SLEEP** pauses an address (v0) or a token (v1) until a resume block. Dispenser dispenses, order matches, and swap matches are exempt; a token-level SLEEP can itself be batched (pause, operate, unpause). ### 6.2 Transfers @@ -405,7 +405,7 @@ Emitted actions are queued during execution and applied **only after the VM retu ### 7.8 Controller-bound tokens: settlement-time guards *(contract-era flag day)* -Sections 7.1-7.7 describe contracts that users call. The controller mechanism is the inverse: **the protocol calls a contract**. A token (via `ISSUE` v6) or an account (via `ADDRESS` v1, self-signed) may bind a deployed contract as its **controller** for one action class (`transfer`, `trade`, `mint`, `burn`, `stake`, `ownership`, or the `all` fallback; resolution is most-specific-wins and exactly one guard ever runs). Once bound, the indexer invokes the controller's `guard` method after an action of that class passes normal validation and **before it settles**, inside the same atomic scope. The guard is an ordinary deterministic VM execution: it may read and write its own state, emit actions, return normally to allow, or `revert` to deny; a revert, error, missing method, or out-of-gas **fails closed**, rolling back everything the guard did and recording the action invalid. Because the indexer is the only settlement path, a bound rule is unavoidable: there is no marketplace or side venue where it can be sidestepped, which is the property goodwill-based royalty schemes on other platforms never achieved. +Sections 7.1-7.7 describe contracts that users call. The controller mechanism is the inverse: **the protocol calls a contract**. A token (via `ISSUE` v6) or an account (via `ADDRESS` v1, self-signed) may bind a deployed contract as its **controller** for one action class (`transfer`, `trade`, `mint`, `burn`, `stake`, `ownership`, or the `all` fallback; resolution is most-specific-wins, so exactly one guard runs per subject and action class, and a single action can still invoke several: the token's guard plus the sender's and the recipient's account guards, each metered separately). Once bound, the indexer invokes the controller's `guard` method after an action of that class passes normal validation and **before it settles**, inside the same atomic scope. The guard is an ordinary deterministic VM execution: it may read and write its own state, emit actions, return normally to allow, or `revert` to deny; a revert, error, missing method, or out-of-gas **fails closed**, rolling back everything the guard did and recording the action invalid. Because the indexer is the only settlement path, a bound rule is unavoidable: there is no marketplace or side venue where it can be sidestepped, which is the property goodwill-based royalty schemes on other platforms never achieved. ```mermaid flowchart TD @@ -539,7 +539,7 @@ The hub is a decentralized validator network: a WebSocket P2P flood-fill gossip ### 10.2 PBFT consensus -All consensus domains use a simplified three-phase PBFT (pre-prepare, prepare, commit). The fault-tolerance floor is a count quorum of `max(2f+1, ceil((N+1)/2))` where `f = floor((N-1)/3)`; the majority term keeps a small federation from collapsing to a single signer (for `N=3`, quorum is 2, not 1). Leaders are chosen by deterministic round-robin over the pubkey-sorted set; a leader that stalls past the timeout triggers a view change to the next leader once enough view-change votes accumulate. The consensus **sequence number** is persisted so validators resume correctly after restart (the view number and pending proposals are in-memory and reset on restart). With no peers connected, a single instance executes operations directly (degenerate single-node mode). +All consensus domains use a simplified three-phase PBFT (pre-prepare, prepare, commit). The quorum rule is activation-gated on the round's BTC-anchored snapshot block: at or above `STAKE_WEIGHTED_QUORUM_ACTIVATION` it is a source-deduplicated STAKE threshold, where the distinct stake sources behind the voting validators must satisfy `3 x tally > 2 x S` against the snapshot's total stake, so three equally weighted sources need all three votes; below activation it is the legacy count quorum `max(2f+1, ceil((N+1)/2))` where `f = floor((N-1)/3)`, whose majority term keeps a small federation from collapsing to a single signer (for `N=3`, quorum is 2, not 1). Leaders are chosen by deterministic round-robin over the pubkey-sorted set; a leader that stalls past the timeout triggers a view change to the next leader once enough view-change votes accumulate. The consensus **sequence number** is persisted so validators resume correctly after restart (the view number and pending proposals are in-memory and reset on restart). With no peers connected, a single instance executes operations directly (degenerate single-node mode). ```mermaid sequenceDiagram @@ -743,8 +743,8 @@ XChain demonstrates that a complete digital-asset platform, including tokens, an | Cross-chain attestation/XCALL confirmations (default) | BTC 6 / LTC 12 / DOGE 60 | | Cross-chain DEX matching source-confirmation depth (default) | 1, per-chain operator-tunable (§9.2) | | Controller action classes | transfer / trade / mint / burn / stake / ownership, plus `all` fallback (§7.8) | -| PBFT count quorum | `max(2f+1, ceil((N+1)/2))`, `f = floor((N-1)/3)` | -| Stake-weighted quorum (gated on the validator-era batch, §10.2) | combined signer stake > 2/3 of total active stake | +| PBFT count quorum (below `STAKE_WEIGHTED_QUORUM_ACTIVATION` only) | `max(2f+1, ceil((N+1)/2))`, `f = floor((N-1)/3)` | +| Stake-weighted quorum (at/above `STAKE_WEIGHTED_QUORUM_ACTIVATION`; gated on the validator-era batch, §10.2) | combined signer stake, deduplicated by stake SOURCE, > 2/3 of total active stake | | Trimmed-median trim | top/bottom 15% | | Governance | 7-day vote, 50% quorum, two-thirds approval, 14-day re-proposal cooldown | | utxo-tracker reorg undo window | BTC 12 / LTC 48 / DOGE 120 blocks (default, env-overridable) | From 3dab3e27ef60f6e512093b148502d60e7a5efa59 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 5 Sep 2026 23:51:35 -0700 Subject: [PATCH 42/52] docs(config): document six environment variables the services already read The fleet-wide coverage gate reads every process.env access in each service and requires a row on that component's own configuration page. Seven reads had none: explorer, indexer HUB_DB_SYNC_HTTP_DEADLINE (vendored in both, so both pages) hub ANCHOR_RATELIMIT_MAX_WAIT_MS, ANCHOR_RATELIMIT_MAX_WAITS hub LLM_SPEND_LOG_FALLBACK_PATH indexer HUB_CALL_DEADLINE_MS sdk ENCODER_API_KEY Each row states the meaning and asserts the default the code actually applies, so a later drift between the two is a gate failure rather than a silent lie. The encoder key is marked a credential, with the note that an unpinned encoder URL lets a hub overlay repoint the client and send the key to the host it named. Rows only. No existing row, heading or table shape changed. --- components/explorer/configuration.md | 1 + components/hub/configuration.md | 3 +++ components/indexer/configuration.md | 2 ++ components/sdk/configuration.md | 1 + 4 files changed, 7 insertions(+) diff --git a/components/explorer/configuration.md b/components/explorer/configuration.md index a3216f9..7acfa79 100644 --- a/components/explorer/configuration.md +++ b/components/explorer/configuration.md @@ -71,6 +71,7 @@ See [WEBSOCKET.md](websocket.md) for the full WebSocket API reference. | `HUB_RETRY_ATTEMPTS` | No | `4` | Attempts per hub config fetch, with exponential backoff. After a power cycle the hub and its MariaDB can take several seconds to come up; a single-pass fetch loses that race and leaves the explorer with no config. `ping()` opts out so liveness checks stay fast. | | `HUB_RETRY_DELAY_MS` | No | `2000` | Base backoff between hub config retry attempts. Tests set `0`. | | `HUB_DB_SYNC_POLL_INTERVAL` | No | `30000` | Interval in milliseconds between hub-mirror table sync polls. | +| `HUB_DB_SYNC_HTTP_DEADLINE` | No | `120000` | Total wall-clock budget in milliseconds for one hub-mirror snapshot GET. The request's own `timeout` option is an idle-socket timer that resets on every byte received, so a hub drip-feeding a body would otherwise hold the request, and the whole mirror bootstrap, open indefinitely; this is set to four times that idle timer so a large snapshot page has room to stream and only a wedged request reaches the ceiling. | | `HUB_SYNC_WATERMARK_INTERVAL_MS` | No | `10000` | Interval in milliseconds at which the hub-mirror sync persists its progress watermark. | | `HUB_SYNC_BARRIER_HOLD_CEILING_S` | No | `900` (15 min) | Seconds a caller may hold at a mirror-completeness barrier before the mirror client is willing to force a resync of its own accord (`requestResync`): tearing down and reconnecting its hub-DB WebSocket, or re-kicking its bootstrap directly in poll mode. `0` disables the forced resync. The explorer's own mirror manager does not currently call `requestResync` anywhere, so setting this here has no observable effect in the explorer today; the mirror client (`hub_db_sync.js`) is a vendored twin of the indexer's, where the block loop does call it against a completeness barrier the explorer has no equivalent of. | | `HUB_SYNC_BATCH_APPLY` | No | `true` | Set to `false` to disable batched multi-row upserts when applying `price_snapshots` rows during a hub-mirror bootstrap drain, falling back to applying rows one at a time. Throughput only. | diff --git a/components/hub/configuration.md b/components/hub/configuration.md index c44352b..e268f38 100644 --- a/components/hub/configuration.md +++ b/components/hub/configuration.md @@ -314,6 +314,8 @@ Controls `StateAnchorPublisher` (commits checkpoints and the cross-chain match a | `ANCHOR_MAX_BATCH` | No | `1000` | Maximum `cross_chain_matches` rows drained into one publish cycle. | | `ANCHOR_CHUNK_MAX_BYTES` | No | `6000` | Maximum payload bytes per ANCHOR archive chunk. | | `ANCHOR_ROUND_TIMEOUT_MS` | No | `120000` | Timeout for one ANCHOR signing round. | +| `ANCHOR_RATELIMIT_MAX_WAIT_MS` | No | `60000` | Caps a single honoured `Retry-After` wait when the encoder rate-limits an anchor chunk upload. | +| `ANCHOR_RATELIMIT_MAX_WAITS` | No | `3` | Caps how many rate-limit waits one broadcast may take before the anchor defers to a later flush instead of stalling the current one. | | `ANCHOR_AMBIGUOUS_POLL_ATTEMPTS` | No | `3` | Re-polls before an ambiguous publish result (broadcast may or may not have landed) is resolved. | | `ANCHOR_AMBIGUOUS_POLL_MS` | No | `5000` | Delay between those re-polls. | | `ANCHOR_ANNOUNCE_RETRY_MS` | No | `300000` | Delay between retries of the anchor announcement (5 minutes). | @@ -525,6 +527,7 @@ Backs the `ATTEST` path where a contract asks an approved model a question. See | `LLM_MAX_BUDGET_USD` | No | _(built-in cap)_ | Spend ceiling in USD for LLM attestation calls. A kill-switch against runaway cost. | | `CLAUDE_BIN` | No | `claude` | Path to the Claude CLI binary the provider spawns. Override when it is not on `PATH`. | | `LLM_SPEND_LOG_PATH` | No | `./data/llm-spend.jsonl` | File the provider appends each spend record to, written before the call so the audit trail cannot be lost to a crash mid-request. | +| `LLM_SPEND_LOG_FALLBACK_PATH` | No | `llm-spend.jsonl` inside the OS temp directory | Where a per-dispatch LLM spend audit line is written when the primary sink (`LLM_SPEND_LOG_PATH`) cannot be written. The aggregate spend-state file cannot stand in for it: that file carries a rolling window of costs and no per-dispatch identity, so an operator reconciling a vendor invoice against it cannot tell which call was which. | > **Cost note.** Each on-chain checkpoint anchor spends real DOGE on three transactions (BTC + LTC + DOGE checkpoints all broadcast on the DOGE chain). State recovery (`recovery.js`) only needs the **latest** anchored checkpoint per chain, so anchoring every intermediate `checkpoint_seq` is optional. With daily checkpoints (`CHECKPOINT_INTERVAL_BLOCKS=144`), `ANCHOR_CHECKPOINT_EVERY_N=2` halves anchor spend (on-chain recovery point then trails the tip by up to ~2 checkpoint intervals). `checkpoint_seq` is consensus data, so the gate is deterministic across every hub. diff --git a/components/indexer/configuration.md b/components/indexer/configuration.md index 9fe6724..75fc6bf 100644 --- a/components/indexer/configuration.md +++ b/components/indexer/configuration.md @@ -36,6 +36,7 @@ Configuration is loaded from a `.env` file and environment variables. Copy the ` | `HUB_CONFIG_URL` | Hub API base URL for the config-oracle poll (`getallconfigs`). The hub keeps that method off its public feed port because the answer carries every service's DB credentials, so an indexer pointed at a validator's feed port pushes and mirrors correctly but fails its config poll once a minute forever, silently freezing the hub-supplied params at their startup values. Point this at a private hub API port to separate the two roles. Unset falls back to `HUB_API_URL`, so a single-hub deployment is unchanged. | _(unset)_ | | `HUB_CONFIG_API_KEY` | API key sent with the config-oracle poll when `HUB_CONFIG_URL` is a separately keyed port. Unset falls back to `HUB_API_KEY`. Treat as a credential. | _(unset)_ | | `HUB_REORG_API_KEY` | Separate key for the hub's retraction rails (`pushpricereorg`, `pushxcallreorg`, `pushdexreorg`) when the hub gates them independently. Unset falls back to `HUB_API_KEY`, which is the legacy single-key behaviour. Treat as a credential. | _(falls back to `HUB_API_KEY`)_ | +| `HUB_CALL_DEADLINE_MS` | Wall-clock ceiling in milliseconds for a single hub call from the indexer's hub client. The request's own `timeout` option is an idle-socket timer that resets on every byte received, so it bounds a silent socket and nothing else; a hub drip-feeding a body holds the call open forever inside it. A non-finite or non-positive value falls back to the built-in default. | `60000` | | `INDEXER_ALLOW_UNAUTHENTICATED` | Set to `true` to restore keyless pass-through on the gated methods (validator-reward writes, federation reads, gated exec). With no API key configured those methods otherwise fail closed. This is the explicit escape hatch for single-host and regtest nodes; do not set it on a node reachable beyond its own host. | _(unset, fails closed)_ | | `UTXO_TRACKER_URL` | UTXO-tracker hostname. Optional overall, but required for the DISPENSER fresh-address check. | _(unset)_ | | `UTXO_TRACKER_API_PORT` | UTXO-tracker port, paired with `UTXO_TRACKER_URL`. | _(unset)_ | @@ -91,6 +92,7 @@ A BTC indexer with no DOGE wiring **defers every block** from the first epoch cl |---|---|---| | `HUB_CONFIG_POLL_INTERVAL_MS` | Interval between hub config refresh polls | `60000` | | `HUB_DB_SYNC_POLL_INTERVAL` | Interval between hub-mirror table sync polls (used when `HUB_DB_SYNC_ENABLED=true`) | `30000` | +| `HUB_DB_SYNC_HTTP_DEADLINE` | Total wall-clock budget for one hub-mirror snapshot GET. The request's own `timeout` option is an idle-socket timer that resets on every byte received, so a hub drip-feeding a body would otherwise hold the request, and the whole mirror bootstrap, open indefinitely; set to four times that idle timer so a large snapshot page has room to stream and only a wedged request reaches the ceiling. | `120000` | | `HUB_SYNC_WATERMARK_INTERVAL_MS` | Interval at which the hub-mirror sync persists its progress watermark | `10000` | | `HUB_SYNC_BATCH_APPLY` | Set to `false` to disable batched multi-row upserts when applying `price_snapshots` rows during a hub-mirror bootstrap drain, falling back to applying rows one at a time. Throughput only: no barrier, floor, or mirrored row depends on it. | `true` | | `HUB_SYNC_BATCH_APPLY_ROWS` | Number of buffered `price_snapshots` rows a hub-mirror bootstrap drain collects before flushing them as one multi-row upsert statement. Values below `2` fall back to the default. | `500` | diff --git a/components/sdk/configuration.md b/components/sdk/configuration.md index dbc0a89..7ebe499 100644 --- a/components/sdk/configuration.md +++ b/components/sdk/configuration.md @@ -87,6 +87,7 @@ The SDK reads these environment variables at construction time. A `.env` file in | `EXPLORER_PORT` | Port of the xchain-explorer server. | Explorer client | | `ENCODER_URL` | Hostname or IP of the xchain-encoder server. | Encoder client | | `ENCODER_PORT` | Port of the xchain-encoder server. | Encoder client | +| `ENCODER_API_KEY` | API key sent as a header to the xchain-encoder service; also usable as the `encoderApiKey` client option. With no pinned `encoderUrl`, a hub overlay can repoint the client, and the key then goes to whatever encoder host the hub named. Treat as a credential. | Encoder client | | `HUB_API_HOST` | Hostname or IP of the xchain-hub server. | Hub connector | | `HUB_PORT` | Port of the xchain-hub server. | Hub connector | | `HUB_URL` | Full hub base URL used by the interactive REPL (`npm run repl`), as an alternative to `HUB_API_HOST` + `HUB_PORT`. | REPL | From 3e3c3c8a6c7b1dcc2a298210636faf3df9818946 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 6 Sep 2026 07:20:20 -0700 Subject: [PATCH 43/52] docs(indexer): say why a fast chain must lower the anchor-attest grace The row described what the setting does and that it is regtest-only, but not the one thing an operator needs before they need it: on a chain whose blocks are stamped at about wall clock, the default cannot be satisfied at all. The barrier waits for the mirror watermark to reach the block's timestamp plus the grace, and where the watermark also tracks wall clock a freshly mined block can never be two minutes behind it. Every affected block then waits out the full timeout and carries on regardless, so the venue does not fail, it crawls. The numbers are in the row because they are the argument: 367 blocks in six hours at the default against two thousand in under two once lowered, with a hundred and sixty deferrals waiting on a condition that could not arrive. That cost a release matrix a full cycle and read as a wedged run rather than a misconfiguration, which is exactly the confusion a line of documentation prevents. Off regtest nothing changes: the value is a consensus input there and the frozen constant wins whatever the environment says. --- components/indexer/configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/indexer/configuration.md b/components/indexer/configuration.md index 75fc6bf..6d1e482 100644 --- a/components/indexer/configuration.md +++ b/components/indexer/configuration.md @@ -69,7 +69,7 @@ Configuration is loaded from a `.env` file and environment variables. Copy the ` | `INDEXER_POLL_SILENT_MS` | How long the block-poll loop may go without completing an iteration before the health payload reports it silent and the monitor crits. Deliberately **not** part of the `/status` 503 gate: one block can hold a single iteration across several sequential barrier waits, and restarting the container is the wrong answer to a slow block, so the default doubles the stall grace again. Measures loop **liveness**, never chain progress, so unlike the stall signal it has nothing to do with commits. Operational only, **not** a consensus parameter. | `2 × INDEXER_HEALTH_STALL_GRACE_MS` | | `XCALL_DIRECT_PRESENCE_TIMEOUT_MS` | Call-presence barrier timeout in direct-hub-DB mode. With no HubDbSync mirror the cross-chain-call sync barrier is skipped, but reading the hub's MariaDB directly does not guarantee an in-flight relay row has landed, so the indexer waits this long for it before the cross-chain-call pass. | `10000` | | `CHAIN_TIP_PUSH_MAX_LAG` | Skip pushing the chain tip to the hub while the indexer is more than this many blocks behind the decoder tip. During a bulk re-index, pushing a tip per historical block floods the hub's rate limiter with `429`s for no value: the hub only cares about the live tip. | `100` | -| `HUB_SYNC_ANCHOR_ATTEST_GRACE_S` | Grace margin on the anchor-reward attestation mirror barrier. The BTC indexer only derives an anchor/archive reward once the hub mirror is certified to hold everything produced up to the block being processed; a node that cannot certify that defers the block rather than deriving a partial reward set. Honoured on regtest only: elsewhere it is a consensus input and the frozen value wins. | `120` | +| `HUB_SYNC_ANCHOR_ATTEST_GRACE_S` | Grace margin on the anchor-reward attestation mirror barrier. The BTC indexer only derives an anchor/archive reward once the hub mirror is certified to hold everything produced up to the block being processed; a node that cannot certify that defers the block rather than deriving a partial reward set. Honoured on regtest only: elsewhere it is a consensus input and the frozen value wins. **A fast chain must lower this or it cannot make progress.** The barrier waits for the mirror watermark to reach `block_time + this value`, which costs nothing where blocks are minutes apart, because the watermark is long past by the time a block is processed. Where blocks are stamped at about wall clock, and the watermark tracks wall clock too, a freshly mined block can never be this far behind it: the wait cannot be satisfied and every affected block burns the full barrier timeout before proceeding anyway. Measured on a regtest e2e venue at the default: 367 blocks in six hours against 2013 in under two once lowered, with 160 deferrals that were waiting on a condition that could not arrive. Set it to a small fraction of the block interval on any venue that mines quickly. | `120` | | `HUB_SYNC_ATTEST_RESPONSE_GRACE_S` | Grace margin on the attestation-response mirror barrier. Above the response-mirror activation height a finalized attestation response reaches the indexer through the hub mirror rather than as its own transaction, and a node that has not received the row would fire the contract callback at a different block from its peers, which is a fork rather than a lag. So this barrier has no chain-only escape: the block waits until the mirror's stream watermark reaches the block's protocol time plus this margin. The margin only has to cover ordinary stream lag, because the real forward margin travels inside the signed row. **Honoured on `regtest` only**; elsewhere a differing value is ignored with a startup warning and the frozen protocol value wins, because two nodes resolving it differently would settle blocks differently. | `120` | | `DOGE_INDEXER_URL` | DOGE indexer JSON-RPC URL the BTC indexer uses to re-prove that a mirrored anchor reward's DOGE anchor was actually mined (`getanchorconfirmations`), before crediting it. `DOGE_INDEXER_API_URL` takes precedence when both are set. Required on a BTC indexer once the anchor-reward derive flag-day is armed: unset, no reward can be proven and the block defers. | _(unset)_ | | `DOGE_INDEXER_API_KEY` | API key sent as `x-api-key` with that read (`getanchorconfirmations` is a federation-read method on the DOGE indexer). | _(unset)_ | From 87953ad02d6a407374ef3bb4bff901d85df6a16b Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 6 Sep 2026 09:45:16 -0700 Subject: [PATCH 44/52] docs(hub): document the direct-tip age gate MAX_DIRECT_TIP_AGE_S bounds how long the hub keeps trusting a direct getlatestblock height that has not advanced past the pushed tip it just rejected. It is separate from MAX_TIP_AGE_S on purpose: that gate falls through to one more HTTP call, while this one is terminal and reports no BTC tip at all, so its bound is sized for an ordinary long block gap on a healthy chain. A height that beats the pushed tip is accepted whatever the tip's age. --- components/hub/configuration.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/components/hub/configuration.md b/components/hub/configuration.md index e268f38..4c66a1d 100644 --- a/components/hub/configuration.md +++ b/components/hub/configuration.md @@ -234,7 +234,8 @@ The hub reads the BTC chain tip to anchor consensus rounds. These gates stop a s | `BTC_INDEXER_API_KEY` | No | _(from config table)_ | API key presented to that indexer's fail-closed federation-read gate. Treat as a credential. | | `BTC_INDEXER_API_URL` | No | None | BTC indexer JSON-RPC URL for the validator-mode price oracle's block-height anchor (`getlatestblock`). Set it when the hub is **not** co-located with a BTC indexer and must reach one over the network. Empty falls back to local resolution. `xchain-node` forwards this from the host environment. | | `MAX_INDEXER_LAG_BLOCKS` | No | `200` | Maximum blocks the BTC indexer may lag before its tip is treated as untrustworthy and ignored, degrading gracefully instead of locking in a stale validator set. | -| `MAX_TIP_AGE_S` | No | `2 × ORACLE_ROUND_INTERVAL` (seconds) | Maximum age of the indexer-pushed BTC tip before it is considered stale. | +| `MAX_TIP_AGE_S` | No | `2 × ORACLE_ROUND_INTERVAL` (seconds) | Maximum age of the indexer-pushed BTC tip before it is considered stale. Rejecting it costs one HTTP call: the hub falls through to a direct `getlatestblock`. | +| `MAX_DIRECT_TIP_AGE_S` | No | `7200` (seconds) | Age at which the hub stops trusting a direct `getlatestblock` height that has **not** advanced past the pushed tip just rejected, and reports no BTC tip at all. Separate from `MAX_TIP_AGE_S` on purpose: this gate is terminal, so its bound is sized so an ordinary long block gap on a healthy chain never trips it. A height that beats the pushed tip is always accepted, whatever the tip's age. | | `INDEXER_COIN_CHECK` | No | enabled | Set to `0` to disable the per-coin indexer reachability check. | ### Oracle From ce583bb96b05f68c52db398a57ba091376362945 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 6 Sep 2026 10:55:47 -0700 Subject: [PATCH 45/52] docs(protocol): record the envelope-cancel outpoint-reserved error The encoder now refuses an envelope cancel whose commit outpoint a foreign reservation already holds, and returns it as an operational error naming the outpoint. Documented alongside the other reservation errors so a client can map the code rather than discover it. --- protocol/error-codes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/protocol/error-codes.md b/protocol/error-codes.md index d267116..b18dfa9 100644 --- a/protocol/error-codes.md +++ b/protocol/error-codes.md @@ -120,6 +120,7 @@ A `-32010` error always carries `error.data.reason`, a stable string that is app | `ENVELOPE_RECOGNITION_UNKNOWN` | The node returned no chain height, so Taproot envelope recognition cannot be confirmed active | none | Yes: with backoff | | `ENVELOPE_NOT_YET_ACTIVE` | Taproot envelope recognition is not active on this network yet, so the envelope is refused rather than built for decoders to ignore | `recognitionHeight`, `chainTip`, `blocksRemaining` | No: use P2WSH until the activation height | | `ENVELOPE_CANCEL_BELOW_DUST` | The envelope-cancel sweep output would fall below the dust floor | `commitValue`, `fee`, `sweepValue` | No: spend via the reveal or CPFP | +| `ENVELOPE_CANCEL_OUTPOINT_RESERVED` | The commit outpoint the cancel would sweep is reserved by a different transaction built inside the reservation window | `outpoint` | No: broadcast that transaction and rebuild, or wait for the reservation to lapse. Replaying the same cancel is never refused | ## Where the specs live From ac951d301b658a94cd21669e169fa164f8c4e023 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 6 Sep 2026 12:46:36 -0700 Subject: [PATCH 46/52] feat(protocol): the dispenser cancellation-grace activation gate is declared here The decoder vendors this constant byte-equal, so the canonical declaration has to exist for that copy to be verified against anything. Keyed on block time with >= semantics and consensus-affecting. Mainnet is null, which fails closed and awaits a ratified per-network instant. Testnet and regtest run from genesis, matching the sibling expiry-realign gate under the pre-launch rule that every feature is active on testnet: the gate closes a defect that spends a payer's native coin and returns nothing, so a public testnet reaches it, and testnet decoder state is rebuilt from the chain before launch. --- protocol/constants.js | 54 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/protocol/constants.js b/protocol/constants.js index 9894567..d9e4dcc 100644 --- a/protocol/constants.js +++ b/protocol/constants.js @@ -1018,6 +1018,59 @@ const DISPENSER_EXPIRY_REALIGN_ACTIVATION = { regtest: 0, }; +// DISPENSER_CANCEL_GRACE_ACTIVATION (dispenser cancellation grace capture): the flag-day +// at/above which the DECODER keeps a just-expired dispenser in the block loop's payment +// CAPTURE SET for a grace window past its expiration. Keyed on BLOCK TIME with the same >= +// semantics as DISPENSER_EXPIRY_REALIGN_ACTIVATION, because dispensers settle on BTC, LTC +// and DOGE, whose heights diverge. +// +// WHY IT EXISTS: the indexer keeps a CANCELLED dispenser fillable past its own expiration. +// It excludes `cancelling` rows from its expiration pass (xchain-indexer/src/db.js +// getExpiredItems, `s2.status='open'`), keeps them matchable through +// `status IN ('open','cancelling')` in findMatchingDispensers, and closes only at the +// cancel's block time plus DISPENSER_CLOSE_DELAY (3600s). The decoder mirrors no cancel at +// all, by design, so it soft-expires that dispenser at its raw expiration and drops the +// address from the capture set. Cancel a funded dispenser shortly before its expiration and +// a window opens: the indexer still settles fills, the decoder captures no output, and the +// buyer's native coin reaches the seller with no DISPENSE record and no inventory release. +// +// At/above the gate the CAPTURE SET alone widens: a row whose expiration is no older than +// the grace window stays an eligible payment destination even once the soft-expire has +// stamped it. The soft-expire itself, the expiry MARK, the extend mirror, the oracle-address +// resolution and the hard purge all keep their current timing, which confines the change to +// the over-capture direction the decoder's advisory contract (xchain-decoder/src/db.js, +// above extendOpenDispenserExpirationBySource) calls safe. Delaying the MARK instead reaches +// the legacy single-pick oracle resolution, whose ORDER BY ... LIMIT 1 then ranks a dead row +// first and captures nothing at all: the under-capture direction, a second money-bearing +// defect rather than a fix. Widen the capture set, never the mark. +// +// CONSENSUS-AFFECTING: it changes the set of outputs persisted to transaction_outputs, so an +// ungated widening breaks from-genesis byte-identity and forks validators. The unwidened +// capture set therefore stays live BELOW the gate, and a re-decode of pre-flag-day history +// reproduces exactly what the fleet wrote. +// +// null means DISARMED (never active), the fail-closed default: mainnet keeps the unwidened +// capture set until that network's maintainers ratify an instant, chosen with the fleet's +// upgrade state in hand, because arming it too early forks the chain and arming it in the +// past rewrites agreed history. +// +// DEPLOY DEADLINE, once an instant is armed: EVERY decoder on that network MUST be running +// the armed value before the instant, or the fleet splits on the first block whose header +// time passes a cancelled dispenser's expiration. +// +// Vendored byte-equal into xchain-decoder/src/protocol/constants.js; the conformance suite +// keeps the two copies in lockstep. +const DISPENSER_CANCEL_GRACE_ACTIVATION = { + mainnet: null, // DISARMED: awaiting the operator's ratified per-network instant + // ARMED AT GENESIS (instant 0 = always in force), matching the sibling + // DISPENSER_EXPIRY_REALIGN_ACTIVATION under the pre-launch ruling that every feature must + // be ACTIVE on testnet. This gate closes a defect that spends a payer's native coin and + // gives nothing back, so a public testnet WILL hit it. Safe at 0 because testnet + // decoder/indexer state is REBUILT from the chain before launch. + testnet: 0, + regtest: 0, +}; + // BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION (payment-output capture through a BATCH): the // flag-day at/above which the DECODER decides which native-coin outputs to persist by looking // at a BATCH's SUB-COMMANDS instead of only at the top-level ACTION name. Keyed on BLOCK TIME @@ -1324,6 +1377,7 @@ module.exports = { ORACLE_FEE_OUTPUT_ACTIVATION, ORACLE_FEE_SET_CAPTURE_ACTIVATION, DISPENSER_EXPIRY_REALIGN_ACTIVATION, + DISPENSER_CANCEL_GRACE_ACTIVATION, BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION, ENVELOPE_RECOGNITION_ACTIVATION, COMPRESSION_CODE_DEFLATE_RAW, From 6f1b9bb1f47a9dcbb55cd4a0d230b40bc217be3f Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 6 Sep 2026 12:50:18 -0700 Subject: [PATCH 47/52] docs(e2e): the helper layer gained a module The e2e action suites gained a helper that decides whether a case depending on the legacy on-chain ATTEST response path can run at all, so the published counts move from 52 to 53. Both figures are asserted against the tree by the action suite-count guard, which is what caught the drift. --- components/e2e-test/README.md | 2 +- components/e2e-test/architecture.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/components/e2e-test/README.md b/components/e2e-test/README.md index bf73875..6a34d3d 100644 --- a/components/e2e-test/README.md +++ b/components/e2e-test/README.md @@ -67,7 +67,7 @@ flowchart TD subgraph E2E["xchain-e2e-test"] CH["cryptoHelper
BIP39/BIP32
wallet mgmt"] TH["transactionHelper
PSBT/P2SH"] - AH["action helpers (52 modules)
message construction"] + AH["action helpers (53 modules)
message construction"] SC["Service Connectors (src/)
BlockchainConnector, XChainEncoderConnector
XChainUtxoTrackerConn, XChainDecoderConnector
XChainIndexerConnector, XChainExplorerConnector
XChainHubConnector, RegtestMinerConnector
Database (MariaDB)"] CH --> SC TH --> SC diff --git a/components/e2e-test/architecture.md b/components/e2e-test/architecture.md index 15464ee..b56dd3c 100644 --- a/components/e2e-test/architecture.md +++ b/components/e2e-test/architecture.md @@ -179,7 +179,7 @@ xchain-e2e-test/ │ ├── cryptoHelper.js # BIP39/BIP32 wallet management │ ├── transactionHelper.js # PSBT construction, signing, broadcast │ ├── actions/ # 79 action test files (live, ordered), covering 31 ACTION names -│ ├── helpers/ # 52 modules (action helpers + federation/fee/utility helpers) +│ ├── helpers/ # 53 modules (action helpers + federation/fee/utility helpers) │ ├── unit/ # 350+ unit tests (stubbed, no services) │ ├── integration/ # 150+ integration tests (stubbed I/O) │ │ ├── fixtures/ # mockMariadb, services, dbRows, hub From 81fd918aa6745f6cfd7d8bcade5ef0a188d0f1c9 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 6 Sep 2026 14:17:16 -0700 Subject: [PATCH 48/52] docs(indexer): document the regtest COINPay expiration override The indexer resolves XCHAIN_COINPAY_EXPIRATION_S through one function so the regtest-only rule for a consensus input (ignore with a warning off regtest, positive-integer check on it) lives in one place. That read is computed, so the env-var coverage scanner cannot see the name and the computed-read ratchet held the indexer at its old count. Add the row the variable needs, in the barriers table beside the two grace windows that share its mechanism, and raise the indexer baseline to match the committed tree. --- components/indexer/configuration.md | 1 + lib/env-var-doc-coverage.js | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/components/indexer/configuration.md b/components/indexer/configuration.md index 6d1e482..a7d48a3 100644 --- a/components/indexer/configuration.md +++ b/components/indexer/configuration.md @@ -71,6 +71,7 @@ Configuration is loaded from a `.env` file and environment variables. Copy the ` | `CHAIN_TIP_PUSH_MAX_LAG` | Skip pushing the chain tip to the hub while the indexer is more than this many blocks behind the decoder tip. During a bulk re-index, pushing a tip per historical block floods the hub's rate limiter with `429`s for no value: the hub only cares about the live tip. | `100` | | `HUB_SYNC_ANCHOR_ATTEST_GRACE_S` | Grace margin on the anchor-reward attestation mirror barrier. The BTC indexer only derives an anchor/archive reward once the hub mirror is certified to hold everything produced up to the block being processed; a node that cannot certify that defers the block rather than deriving a partial reward set. Honoured on regtest only: elsewhere it is a consensus input and the frozen value wins. **A fast chain must lower this or it cannot make progress.** The barrier waits for the mirror watermark to reach `block_time + this value`, which costs nothing where blocks are minutes apart, because the watermark is long past by the time a block is processed. Where blocks are stamped at about wall clock, and the watermark tracks wall clock too, a freshly mined block can never be this far behind it: the wait cannot be satisfied and every affected block burns the full barrier timeout before proceeding anyway. Measured on a regtest e2e venue at the default: 367 blocks in six hours against 2013 in under two once lowered, with 160 deferrals that were waiting on a condition that could not arrive. Set it to a small fraction of the block interval on any venue that mines quickly. | `120` | | `HUB_SYNC_ATTEST_RESPONSE_GRACE_S` | Grace margin on the attestation-response mirror barrier. Above the response-mirror activation height a finalized attestation response reaches the indexer through the hub mirror rather than as its own transaction, and a node that has not received the row would fire the contract callback at a different block from its peers, which is a fork rather than a lag. So this barrier has no chain-only escape: the block waits until the mirror's stream watermark reaches the block's protocol time plus this margin. The margin only has to cover ordinary stream lag, because the real forward margin travels inside the signed row. **Honoured on `regtest` only**; elsewhere a differing value is ignored with a startup warning and the frozen protocol value wins, because two nodes resolving it differently would settle blocks differently. | `120` | +| `XCHAIN_COINPAY_EXPIRATION_S` | COINPay obligation expiration window, in seconds. **Honoured on `regtest` only**; elsewhere a differing value is ignored with a startup warning and the frozen protocol value wins, because the window is a consensus input and two nodes expiring an obligation at different times would settle differently. Exists so a fast regtest venue can expire an obligation inside a few blocks instead of jumping the node clock two hours past the deadline: a block mined under a jumped clock is stamped in the future, and the anchor-attest barrier above then waits real time out for the hub watermark to reach it. Must be a positive integer; an invalid value fails startup. The xchain-node regtest compose forwards it to the indexer by name, so set it once on the node. | `7200` (2 hours, the frozen `COINPAY_EXPIRATION` constant) | | `DOGE_INDEXER_URL` | DOGE indexer JSON-RPC URL the BTC indexer uses to re-prove that a mirrored anchor reward's DOGE anchor was actually mined (`getanchorconfirmations`), before crediting it. `DOGE_INDEXER_API_URL` takes precedence when both are set. Required on a BTC indexer once the anchor-reward derive flag-day is armed: unset, no reward can be proven and the block defers. | _(unset)_ | | `DOGE_INDEXER_API_KEY` | API key sent as `x-api-key` with that read (`getanchorconfirmations` is a federation-read method on the DOGE indexer). | _(unset)_ | | `ANCHOR_PROOF_TIMEOUT_MS` | Per-request timeout for the DOGE anchor proof read, and for the ROLLCALL signer read below. A timeout is treated as "cannot tell", which defers the block; it is never read as "not mined". | `15000` | diff --git a/lib/env-var-doc-coverage.js b/lib/env-var-doc-coverage.js index bb5aa70..d43e81f 100644 --- a/lib/env-var-doc-coverage.js +++ b/lib/env-var-doc-coverage.js @@ -1117,8 +1117,14 @@ function checkDivergentDefaults(survey) { const COMPUTED_READ_BASELINE = { // Measured 2026-08-11 against the committed trees of all 11 gated // components: 95 sites in 37 files across 10 of them. - decoder: 4, encoder: 4, explorer: 7, hub: 33, indexer: 6, node: 23, + decoder: 4, encoder: 4, explorer: 7, hub: 33, indexer: 7, node: 23, 'regtest-miner': 7, sdk: 4, sync: 9, 'utxo-tracker': 8, vm: 0, + // indexer 6 -> 7 on 2026-09-06: config.js resolveCoinpayExpiration() reads + // process.env[envKey] for XCHAIN_COINPAY_EXPIRATION_S, which carries a row in + // components/indexer/configuration.md. Raised deliberately: the indirection is + // what lets one resolver enforce the regtest-only rule (ignore-with-warning + // off regtest, positive-integer check on it) for a consensus input, instead + // of repeating that guard at the read site. // hub 31 -> 33 on 2026-09-03: RollcallRound._resolveTunable() reads // process.env[name] twice for the three ROLLCALL_*_BLOCKS tunables, and // attest_response_timing.js reads ATTEST_RESPONSE_FORWARD_S_OVERRIDE by its From 09375aa40533c173b8920485acca0f426af6b2ac Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 4 Sep 2026 06:56:55 -0700 Subject: [PATCH 49/52] release: v0.15.0 --- CHANGELOG.md | 24 ++++++++++++++---------- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df04208..4d86a36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,18 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.14.0] - 2026-09-02 - -### Added -- Documented the attestation responsible-set widening flag day and its activation heights. - -### Activation -- Attestation responsible-set widening activates on Bitcoin testnet at block 150780 and on regtest from genesis. Mainnet is unratified and the rule is inert there. Below the height, and on an unratified network, behaviour is byte-for-byte unchanged. -- This train changes state derived from existing bytes, so nodes on either side of the height disagree once a widened response lands. Upgrade every indexer and hub before the height. - -## [Unreleased] +## [0.15.0] - 2026-09-07 ### Added +- The ATTEST response-mirror activation height, its two hub overrides, the attestation batch publisher settings, the round cadence knobs and the indexer's three hub-mirror grace windows are documented, with the `attestation_responses` mirror table. +- A two-chain regtest venue can opt in to roll-call activation, and the roll-call, frozen-tip and config-oracle variables are documented for the hub and indexer. +- Flag-day gates parked on the unarmed testnet sentinel are reported. +- The v0.14.0 release train is recorded on the releases page. - ROLLCALL, a validator liveness action published on Dogecoin, is documented: wire format, EQUIV canonical, the rules each chain judges, the accept window and its cut, and the eviction rule. - Eight frozen ROLLCALL consensus constants are declared in `protocol/constants.js`, with mainnet shipping inert at `null`. - `XROLLCALL` joins `ENGINE_TAGS` with header vectors; it is namespacing only and deliberately not a SLASH family. @@ -61,6 +56,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed - The operator-dashboard pages and every listing of it (components index, README table, platform map, test counts); it is internal operator tooling, not part of the public platform. +## [0.14.0] - 2026-09-02 + +### Added +- Documented the attestation responsible-set widening flag day and its activation heights. + +### Activation +- Attestation responsible-set widening activates on Bitcoin testnet at block 150780 and on regtest from genesis. Mainnet is unratified and the rule is inert there. Below the height, and on an unratified network, behaviour is byte-for-byte unchanged. +- This train changes state derived from existing bytes, so nodes on either side of the height disagree once a widened response lands. Upgrade every indexer and hub before the height. + ## [0.12.1] - 2026-08-13 ### Added diff --git a/package-lock.json b/package-lock.json index ff3ae63..6e2a879 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "xchain-documentation", - "version": "0.12.0", + "version": "0.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "xchain-documentation", - "version": "0.12.0", + "version": "0.15.0", "license": "AGPL-3.0-or-later", "devDependencies": { "mathjs": "15.2.0" diff --git a/package.json b/package.json index a5faa9e..222284c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xchain-documentation", "description": "XChain Platform protocol specification, architecture guides, and developer documentation", - "version": "0.14.0", + "version": "0.15.0", "license": "AGPL-3.0-or-later", "repository": { "type": "git", From f9e8dd4865301e00d4bbd4328f82c5282c2a7f57 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 6 Sep 2026 19:06:40 -0700 Subject: [PATCH 50/52] docs(releases): mirror the v0.15.0 train --- operations/releases.md | 67 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/operations/releases.md b/operations/releases.md index 1a45bd5..b74ec1f 100644 --- a/operations/releases.md +++ b/operations/releases.md @@ -9,6 +9,73 @@ Each train tag is GPG-signed with the platform release key. See [Release Signing](./release-signing.md) to verify a download, and [Release Process](./release-process.md) for how a train is cut. +## v0.15.0 + +Released 2026-09-07. [Release notes and artifacts](https://github.com/XChain-Platform/xchain-node/releases/tag/v0.15.0) + +A full train: every one of the thirteen components moves to 0.15.0, the first +time since v0.12.0 that the whole set has moved together. The train carries two +new consensus mechanisms, both shipped inert on the live networks: ATTEST +responses delivered over the hub mirror, and ROLLCALL, a validator liveness +action published on Dogecoin. Each activates only on regtest, from genesis, so +this is a minor train with no activation height to plan around. + +| Component | Version | +|---|---| +| xchain-node | 0.15.0 | +| xchain-hub | 0.15.0 | +| xchain-indexer | 0.15.0 | +| xchain-explorer | 0.15.0 | +| xchain-decoder | 0.15.0 | +| xchain-encoder | 0.15.0 | +| xchain-sync | 0.15.0 | +| xchain-utxo-tracker | 0.15.0 | +| xchain-vm | 0.15.0 | +| xchain-sdk | 0.15.0 | +| xchain-contracts | 0.15.0 | +| xchain-e2e-test | 0.15.0 | +| xchain-regtest-miner | 0.15.0 | + +An ATTEST response used to be a Bitcoin transaction each responding validator +paid for. On this train a response is written to a hub mirror table, gossiped to +every hub, verified before it is stored, and applied by the indexer at the block +its signed effective time predicts, with a per-block cap. Each hour the +finalized responses are published as one signed ATTEST batch on Dogecoin, and +the indexer reassembles chunked batches per author. The response body is capped +before anyone signs it, the effective time is inside the signed canonical, and +`getattestationresponsibleset` answers which validators a request drew. A +federated hub sizes quorum from the federation rather than from its own +validator set. + +ROLLCALL lets the network measure validator liveness on chain: validators +answer a per-epoch roll call on Dogecoin, and a validator absent from enough +consecutive rolled epochs is deactivated, with no governance action and no +penalty: its stake refunds after the ordinary cooldown and it may re-enter. The +wire format, canonical bytes and consensus constants are documented on this +site. + +Elsewhere on the train: a reorg no longer aborts on the roll-call tables; a +mirror hold that outlasts its ceiling forces a resync and is reported on the +indexer health endpoint; a hub rate-limit reply holds the push queue instead of +burning attempts; a price window that closed while the hub was down is +published on restart; SWEEP and CALLBACK are priced on the unified fee +schedule; the SDK completes the XCALL surface and hardens its MuSig2 session +guards, and its MCP tool surface ships as a second package on the same version; +the explorer sizes its serving limits to the measured wallet profile; the +rollback path in the sync layer restores contract stake correctly and scopes an +orphaned archive chunk to its own publisher; the VM moves to a prebuilt +isolated-vm so an install no longer needs a compiler; and the node CLI stops a +chain daemon gracefully on update, forces a bootstrap republish after a +reindex, and no longer mints a fresh hub API key on a repeated +`validator init`. + +**Nothing changes on the live networks at this height.** The response mirror +and ROLLCALL are armed on regtest only; on testnet and mainnet both stay on the +unarmed sentinel, so an updated node and one still on v0.14.0 judge every block +identically. Arming testnet is its own later train with its own flag day. Hubs +and indexers do exchange a wider mirror schema on this train, so update a hub +and the indexers that follow it together. + ## v0.14.0 Released 2026-09-02. [Release notes and artifacts](https://github.com/XChain-Platform/xchain-node/releases/tag/v0.14.0) From 0b0a96f662b1f57deccfd6ab7ece02097fdd7e8e Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 6 Sep 2026 21:01:46 -0700 Subject: [PATCH 51/52] feat(activation): arm the ATTEST response mirror on Bitcoin testnet Operator ruling 2026-09-07: the point of this train is to exercise the response mirror and roll call on testnet, and a train that ships them dark there is not worth cutting. The mirror is armed at block 151324, the chain tip when the ruling was made, so it is active the moment a node updates rather than waiting on a future height. Roll call needed no change: it was already armed at 151200, which the chain passed some time ago. Mainnet stays unratified for both, so its behaviour is byte for byte unchanged. On testnet this changes state derived from existing bytes, so the changelogs now carry an Activation section saying so, and every hub and the indexers following it must update together rather than one at a time. The activation map is mirrored in five places and all five move together: both service copies, the documented canonical, the vendored copy the test helper reads, and the assertion that used testnet as its example of an unratified network, which it no longer is. --- CHANGELOG.md | 5 +++++ node_modules | 1 + operations/releases.md | 20 +++++++++++--------- protocol/constants.js | 2 +- 4 files changed, 18 insertions(+), 10 deletions(-) create mode 120000 node_modules diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d86a36..d0653a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed - The operator-dashboard pages and every listing of it (components index, README table, platform map, test counts); it is internal operator tooling, not part of the public platform. +### Activation +- The ATTEST response mirror activates on Bitcoin testnet at block 151324 and on regtest from genesis. Mainnet is unratified and the legacy on-chain response path runs there byte for byte. +- ROLLCALL activates on Bitcoin testnet at block 151200, which the chain has already passed, so it is live from the moment a node updates. Mainnet is unratified. +- Both change state derived from existing bytes on testnet, so an updated node and one still on 0.14.0 judge a mirrored response differently once one lands. Update every indexer and hub together. + ## [0.14.0] - 2026-09-02 ### Added diff --git a/node_modules b/node_modules new file mode 120000 index 0000000..d66e3f4 --- /dev/null +++ b/node_modules @@ -0,0 +1 @@ +../../../xchain-documentation/node_modules \ No newline at end of file diff --git a/operations/releases.md b/operations/releases.md index b74ec1f..721f651 100644 --- a/operations/releases.md +++ b/operations/releases.md @@ -15,10 +15,11 @@ Released 2026-09-07. [Release notes and artifacts](https://github.com/XChain-Pla A full train: every one of the thirteen components moves to 0.15.0, the first time since v0.12.0 that the whole set has moved together. The train carries two -new consensus mechanisms, both shipped inert on the live networks: ATTEST +new consensus mechanisms, both armed on Bitcoin testnet: ATTEST responses delivered over the hub mirror, and ROLLCALL, a validator liveness -action published on Dogecoin. Each activates only on regtest, from genesis, so -this is a minor train with no activation height to plan around. +action published on Dogecoin. Both are armed on Bitcoin testnet at heights the +chain has already passed, so both are live there as soon as a node updates, and +both remain unratified on mainnet. | Component | Version | |---|---| @@ -69,12 +70,13 @@ chain daemon gracefully on update, forces a bootstrap republish after a reindex, and no longer mints a fresh hub API key on a repeated `validator init`. -**Nothing changes on the live networks at this height.** The response mirror -and ROLLCALL are armed on regtest only; on testnet and mainnet both stay on the -unarmed sentinel, so an updated node and one still on v0.14.0 judge every block -identically. Arming testnet is its own later train with its own flag day. Hubs -and indexers do exchange a wider mirror schema on this train, so update a hub -and the indexers that follow it together. +**This train changes state derived from existing bytes on testnet.** The response mirror +and ROLLCALL are armed on Bitcoin testnet, at blocks 151324 and 151200, both of +which the chain has already passed, so each is live as soon as a node updates. +Mainnet is unratified for both, and its behaviour is byte for byte unchanged. +On testnet, a node on this train and one still on v0.14.0 will judge a mirrored +response differently once one lands, so update every hub and the indexers that +follow it together rather than one at a time. ## v0.14.0 diff --git a/protocol/constants.js b/protocol/constants.js index d9e4dcc..8a1c8ee 100644 --- a/protocol/constants.js +++ b/protocol/constants.js @@ -841,7 +841,7 @@ const ATTEST_RESPONSIBLE_WIDENING = { // by the activation-constants parity suite. const ATTEST_RESPONSE_MIRROR_ACTIVATION = { mainnet: null, // INERT: operator-owned height, unratified. The legacy on-chain response path runs byte for byte. - testnet: null, // UNARMED: operator-armed after the regtest milestone is REACHED and the synchronized schema-5 fleet window closes. + testnet: 151324, // ARMED 2026-09-07 at the chain tip on the operator ruling: exercising the mirror on testnet is the point of this train, so it activates on deploy rather than waiting on a future height. regtest: 0, // ARMED at genesis so the e2e mirror venue exercises the mirror path }; From 283628aeb746577d35df498bd954300614d11e55 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 6 Sep 2026 21:07:02 -0700 Subject: [PATCH 52/52] docs(activation): the response mirror is ratified on testnet, not unarmed there The activation page still described the response mirror as the one height-keyed rule unarmed on testnet, which stopped being true when the v0.15.0 train ratified it at block 151324. Mainnet is untouched and still carries the null that encodes never. --- node_modules | 1 - protocol/protocol-activation.md | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) delete mode 120000 node_modules diff --git a/node_modules b/node_modules deleted file mode 120000 index d66e3f4..0000000 --- a/node_modules +++ /dev/null @@ -1 +0,0 @@ -../../../xchain-documentation/node_modules \ No newline at end of file diff --git a/protocol/protocol-activation.md b/protocol/protocol-activation.md index f6caf3c..f06ee04 100644 --- a/protocol/protocol-activation.md +++ b/protocol/protocol-activation.md @@ -139,9 +139,9 @@ attestation-admission gate in the cohort table above), `ROLLCALL_ACTIVATION` (ke `EPOCH_HEIGHT` a ROLLCALL carries), `ATTEST_RESPONSIBLE_WIDENING_ACTIVATION` and `ATTEST_RESPONSE_MIRROR_ACTIVATION` each hold `null` on mainnet, which is the encoding of "never" and the fail-closed default until an operator ratifies a height. They are not -counted above and carry no flag day yet. `ATTEST_RESPONSE_MIRROR_ACTIVATION` is the one of them that -is unarmed on **testnet** too, so only regtest exercises the hub response-mirror path today; the -testnet exceptions listed below are exceptions among the *armed* cohort rules and do not cover it. +counted above and carry no mainnet flag day yet. `ATTEST_RESPONSE_MIRROR_ACTIVATION` was the one of +them unarmed on **testnet** as well; it was ratified there at block 151324 in the v0.15.0 train, so +testnet exercises the hub response-mirror path alongside regtest from that height on. The enumeration is the **height-keyed validator-era** maps specifically: the block-time [decoder-carried gates](#decoder-carried-gates) also read `null` as disarmed, and `PRICE_PAIR_WIDEN_ACTIVATION` encodes the same "not yet" as a far-future sentinel