From 4699b2746aa935d8a3f33255a2b6a1aa6e6c8ce2 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 12 Oct 2026 11:27:06 -0700 Subject: [PATCH 01/84] Fix 64 review findings in xchain-documentation - concepts/actions.md says BATCH is all-or-none; the rest of the corpus (and the same file) says BATCH is not atomic - Cross-chain SWAP described as atomic ('both sides settle, or neither does') against user-guide pages that document a residual non-atomic window - Decoder table count given as 9 in decoder README/operations and sync docs, but the decoder schema page lists 10 tables - utxo-tracker architecture gives a fixed 2 GB heap-flush trigger; its configuration page derives the threshold from the memory budget (256-2048 MB) - AUX_POW: operations and decoder pages say set it for Dogecoin only; utxo-tracker pages say it is required for Litecoin (HogEx) too - Testnet cross-chain pair recipe states the cross_chain quorum as bare 2f+1, which the corpus elsewhere says is not the rule - component-map describes wallet storage as browser localStorage; wallet architecture puts the vault in IndexedDB, chrome.storage, an Electron encrypted - Decoder architecture flowchart deletes expired dispensers inside the block transaction; the corpus elsewhere says soft-expire, then purge outside the - sync/operations.md says the decoder hard-DELETEs expired dispensers each block; sync/configuration.md and data-retention.md say rows are soft-expired - Threat model still lists background auto-lock as an open item, though it has shipped - ux.md says the popup auto-locks on tab/window blur; no blur trigger exists - Docs give the default auto-lock timeout as 5 minutes; the schema default is 15 - LLM provider default base fee: providers/llm.md says 0.50, smart-contracts.md says '0' - Android Data safety page says the update check sends the running version; two other privacy pages say it doesn't - Indexer database page lists every validator_rewards reward_type but leaves out rollcall_publish - SDK actions page shows UNSTAKE v0 and COLLECT without the optional partial AMOUNT that other pages document - Hub architecture says three offense types are monitored; database and decentralization pages list four - protocol-activation.md gives 36 registered ACTIONs (21 + 16), but concepts/actions.md gives 38 (21 + 17) - Indexer README says 16 ACTIONs are at protocol version 0.2.0; concepts/actions.md says 17 - Wallet build-release.md uses a 1.0.0-rc.N version line that conflicts with the 0.338.0 current version stated elsewhere - Wallet pages say the native iOS app is still on the post-launch roadmap; the release docs describe a shipping iOS build lane - decentralization.md says the full-node reward tranche is paid, but other docs say it is not minted - NODEPROOF says PRICE derives the full-node reward, but COLLECT says PRICE derives no reward rows - PRICE says an oracle_publish + price pubkey earns both rewards per round, but oracle rewards are not minted - gas.md "Complete Fee Schedule" omits the shipped LIST_SHARE / LIST_SHARED_EDIT_BASE / LIST_SHARED_EDIT_PER_ITEM gas keys - Smart-contract dev guide cites stale pre-rename doc paths (protocol/actions/DEPLOY.md, protocol/Cross_Chain_Calls.md, protocol/actions/XCALL.md) that - Destructured banned-global spellings (`LINT_DESTRUCTURE` / `enforceLintDestructure`) are missing from the author-facing deploy-rule docs, and shipped - Action index lists BET as v0-v3 'end to end' but the manifest and bet.md ship a user-encodable v4 (edit feed lists) - Deploy-validation docs list 10 deploy-blocking rules; shipped CONSENSUS_RULES has 11 and validateSyntax runs a nesting-depth check first - Docs say the `with`-statement deploy ban is unarmed on testnet; shipped VM and indexer arm it on all three testnet chains - Protocol-activation and VM architecture docs call the destructure, BigInt-strip and nesting-depth gates inert on testnet; shipped maps arm them - VM configuration 'Additional Internal Limits' table cites stale source locations for the return-value and binary-expression limits - encoding.md says the 8,192-byte decoder ceiling applies to 'every embedding format', but the shipped Taproot envelope lane is exempt - protocol-activation.md says LINT_DESTRUCTURE_ACTIVATION is inert on mainnet/testnet, but the shipped DEPLOY path enforces the destructure refinement a - send.md says only gated FILEs that have "not been superseded" count, but nothing can supersede a gated FILE - BATCH spec says the handoff MESSAGE is always required, ignoring the GATE_MIN_AMOUNT exemption - TIS pack_id row says the protocol groups packs by KEY_HASH alone; the spec and indexer use (publisher, GATE_TICKER, KEY_HASH) - Current TIS example uses `action:12345` both as public on-chain artwork and as a locked, gated PDF - TIS lets a `locked` entry point at a sibling-chain FILE, but SEND key-handoff enforcement only sees gated FILEs on the token's own chain - send.md says the gated threshold uses the total across all legs, but on mainnet the indexer still sums per literal TICK spelling - checkpointSkipsAnchoring and attestRotationStep accept pre-commitment checkpoints that carry unsigned, server-attached roots - WS `stale` marker is nested in `data` on WELCOME but sits at the frame top level on every other frame - SDK WS client ignores the explorer's `stale: true` marker and closes its catch-up gap on a stale replay - /api/status shows a decoder stopped on a REORG_HALT as 'healthy': health() halt fields are never read - Explorer error-code coverage guard does not scan src/contract/vm_query.js or the mirror-gate `blocked:` shape, so codes emitted there can ship without - index-id-references.md says new single-value addresses get ids in byte-sorted order, but ISSUE, SWEEP and DEPLOY assign them in handler order - protocol-activation.md xchain-vm row says six VM gates are unarmed on testnet; the code arms all six on testnet - UPSTREAM_ERROR is registered as a fee-service failure, but the explorer also returns it when the action pre-flight upstream fails - blockchains.md says BTC testnet4 contract deploys cannot be mined, but the indexer records mined TBTC contracts - trading.md says listing edits are priced on the 90-day-free schedule, but the indexer charges every added day once the new total passes 90 - trading.md leaves out that dispenser allow/block list edits only take effect after a one-hour delay - Decoder transaction quarantines have no signal of their own: lumped into a noisy parse_errors counter that the dashboard ignores - Published operations doc still says the fee pin is always settxfee with fallback to the estimate; BTC now never calls settxfee and pins fee_rate per c - status field table omits wallet_balance / wallet_funded / wallet_balance_at, the published surface of the 5s getbalance refresh contract - X402Client spending ceiling checks only the number, not the token, and the remote server picks the token - websocket.md TOKEN_UPDATE example shows `holders` as a JSON number, but schema v2 sends it as a decimal string - Spec says the envelope ceiling applies after compression, but the encoder rejects oversize payloads before compressing - controller-bound-tokens.md class table lists only SEND/DEPOSIT/WITHDRAW as transfer-class guard action_types, but the indexer also passes AIRDROP, DIV - Bind/unbind rejection table omits the ownership-escrowed and non-owner verdicts that ISSUE v6 actually returns - Docs say desktop keychain auto-unlock is opt-in and off by default, but the master key is always written to session.bin when safeStorage is available - State-tree orphan-node reclaim silently self-disables above 2M nodes, with no log or metric - Decoder deploy-precondition 2026-06-02-widen-ids-to-bigint.sql has no MIGRATION_PRECONDITIONS baseline, so a fresh decoder DB never records it and the - Hub metrics heartbeat covers only OracleRound; attestation, checkpoint, publisher and abandoned-round signals are RPC-only - Lifecycle frames are routed only to the source address; the action row's `destinations` are dropped and `extractAddresses` still checks a `destination --- README.md | 2 +- ai-agents/charging-agents.md | 2 +- architecture/component-map.md | 6 +- architecture/data-pipeline.md | 2 +- architecture/platform-map-app.html | 6 +- architecture/platform-map.json | 6 +- blockchains.md | 2 - components/README.md | 2 +- components/decoder/README.md | 2 +- components/decoder/architecture.md | 4 +- components/decoder/configuration.md | 4 +- components/decoder/database.md | 2 +- components/decoder/operations.md | 14 +- components/explorer/api.md | 2 +- components/explorer/websocket.md | 20 ++- components/hub/architecture.md | 5 +- components/hub/configuration.md | 2 +- components/hub/database.md | 6 +- components/hub/decentralization.md | 8 +- components/hub/operations.md | 30 ++++ components/indexer/README.md | 2 +- components/indexer/actions.md | 14 +- components/indexer/data-retention.md | 9 +- components/indexer/database.md | 2 +- components/node/configuration.md | 2 +- components/regtest-miner/operations.md | 34 ++++- components/sdk/actions.md | 38 ++++- components/sdk/errors.md | 5 +- components/sdk/light-client.md | 6 +- components/sdk/websocket.md | 9 ++ components/sync/README.md | 2 +- components/sync/configuration.md | 6 +- components/sync/operations.md | 5 +- components/utxo-tracker/README.md | 2 +- components/utxo-tracker/architecture.md | 6 +- components/utxo-tracker/configuration.md | 2 +- components/vm/architecture.md | 4 +- components/vm/configuration.md | 4 +- components/vm/operations.md | 13 +- components/wallet/README.md | 8 +- components/wallet/architecture.md | 6 +- components/wallet/build-release.md | 46 +++--- components/wallet/configuration.md | 2 +- components/wallet/features.md | 7 +- components/wallet/keys-signing.md | 2 +- components/wallet/privacy/data-safety.md | 4 +- .../privacy/privacy-nutrition-labels.md | 2 +- components/wallet/release/README.md | 2 +- components/wallet/release/verify-release.md | 6 +- components/wallet/security.md | 4 +- components/wallet/shell-desktop.md | 18 ++- components/wallet/shell-web.md | 6 +- components/wallet/threat-model.md | 1 - components/wallet/ux.md | 7 +- concepts/actions.md | 4 +- concepts/cross-chain.md | 12 +- concepts/encoding.md | 2 +- concepts/gas.md | 3 + concepts/smart-contracts.md | 4 +- developer-guide/adding-a-blockchain.md | 2 - developer-guide/cross-chain-swap.md | 2 +- developer-guide/smart-contract-development.md | 8 +- getting-started/key-terms.md | 6 +- getting-started/quickstart-validator.md | 4 - getting-started/what-is-xchain.md | 2 +- operations/configuration.md | 10 +- operations/disk-management.md | 3 +- operations/run-a-validator.md | 4 - operations/testnet-cross-chain-pair.md | 4 +- operations/troubleshooting.md | 2 +- protocol/actions/README.md | 4 +- protocol/actions/batch.md | 2 +- protocol/actions/collect.md | 2 +- protocol/actions/deploy.md | 12 +- protocol/actions/file.md | 4 + protocol/actions/issue.md | 2 +- protocol/actions/nodeproof.md | 10 +- protocol/actions/price.md | 2 +- protocol/actions/send.md | 10 +- protocol/actions/swap.md | 4 +- protocol/activation-catalogue.md | 3 + protocol/constants.js | 2 +- protocol/controller-bound-tokens.md | 23 ++- protocol/error-codes.md | 2 +- protocol/index-id-references.md | 27 ++-- protocol/json/README.md | 2 + ...n-information-standard-v1.1.2-example.json | 4 +- protocol/protocol-activation.md | 6 +- protocol/providers/llm.md | 6 +- protocol/taproot-envelope.md | 4 +- protocol/token-gated-content.md | 4 +- protocol/token-information-standard.md | 6 +- test/action-activation-model.test.js | 64 +++++++- test/controller-invocation-table.test.js | 79 +++++++++- test/error-code-registry-coverage.test.js | 99 +++++++++--- test/fee-and-limit-claims.test.js | 41 ++++- test/gated-pack-scope-claims.test.js | 143 ++++++++++++++++++ test/send-gated-total-tick-id-page.test.js | 31 ++++ test/vm-deploy-lint-gate-claims.test.js | 140 +++++++++++++++++ test/vm-twin-gates-registry-pages.test.js | 30 ++++ user-guide/trading.md | 12 +- whitepaper.md | 2 +- 102 files changed, 1005 insertions(+), 262 deletions(-) create mode 100644 test/gated-pack-scope-claims.test.js create mode 100644 test/vm-deploy-lint-gate-claims.test.js diff --git a/README.md b/README.md index f41ac959..a84686f1 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 32 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, Electron desktop, and Capacitor mobile (Android shipped, iOS later) from a single codebase; software + Trezor + Ledger + remote signers (multisig runs as flows over these); 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 pending App Store release) from a single codebase; software + Trezor + Ledger + remote signers (multisig runs as flows over these); 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 diff --git a/ai-agents/charging-agents.md b/ai-agents/charging-agents.md index 7c192a62..753e9090 100644 --- a/ai-agents/charging-agents.md +++ b/ai-agents/charging-agents.md @@ -7,7 +7,7 @@ A commercial license is available, contact legal@dankest.llc. # Charging Agents for Data and APIs -You have something an AI agent wants; a data feed, an API, a file. XChain lets the agent pay for it in your token, inside the request itself: the agent asks, your server answers "402, here's the price," the agent pays on-chain, retries, and gets the goods. No accounts, no API keys, no card forms. This guide is for the **seller** side; agents paying are covered by the SDK's `X402Client` (one call: `client.fetchUrl(url)`). +You have something an AI agent wants; a data feed, an API, a file. XChain lets the agent pay for it in your token, inside the request itself: the agent asks, your server answers "402, here's the price," the agent pays on-chain, retries, and gets the goods. No accounts, no API keys, no card forms. This guide is for the **seller** side; agents paying are covered by the SDK's `X402Client` (one call: `client.fetchUrl(url)`, on a client built with a per-tick ceiling such as `maxAmount: { YOURTICK: '5' }`; without one it refuses to pay). ```mermaid sequenceDiagram diff --git a/architecture/component-map.md b/architecture/component-map.md index b2701c17..46e658f8 100644 --- a/architecture/component-map.md +++ b/architecture/component-map.md @@ -327,17 +327,17 @@ See [`../components/vm/`](../components/vm/) for full documentation. | | | |---|---| -| **Purpose** | Self-custodial multi-chain reference wallet; browser SPA, Chrome MV3 extension, Electron desktop app, and Capacitor mobile app (Android shipped, iOS later) | +| **Purpose** | Self-custodial multi-chain reference wallet; browser SPA, Chrome MV3 extension, Electron desktop app, and Capacitor mobile app (Android shipped, iOS pending App Store release) | | **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 | +| **Storage** | Client-side only; no server-side state. The AES-256-GCM-encrypted vault lives in IndexedDB (web), `chrome.storage.local` (extension), an encrypted file under Electron `userData` (desktop), or an app-private file via the native `XChainVault` plugin under an OS-keystore key (mobile). The vault never sits in `localStorage`. Browser-based shells do keep small side records there beside UI state, such as the web shell's KDF parameters and, with biometric unlock on, the authenticator-wrapped copy of the password. See [wallet architecture](../components/wallet/architecture.md) and [wallet security](../components/wallet/security.md) | | **Communication** | Outbound JSON-RPC and REST to xchain-encoder, xchain-explorer, and xchain-hub; no inbound API | Key technical details: - Built on xchain-sdk; all action construction goes through the SDK's 32 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), an Electron desktop application, and a Capacitor mobile app wrapping the same web build (Android shipped, iOS later). +- 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 pending App Store release). - 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/data-pipeline.md b/architecture/data-pipeline.md index d19b7a5d..2c2d49ef 100644 --- a/architecture/data-pipeline.md +++ b/architecture/data-pipeline.md @@ -67,7 +67,7 @@ From this point, the transaction is a normal blockchain transaction. Miners pick The decoder polls the coin node every few seconds via JSON-RPC (`getblockcount`, `getblockhash`, `getblock`, `getrawtransaction`). When it finds a new block: 1. **Parses each transaction** using bitcoinjs-lib. Coin-specific pre-processing is applied first: - - Litecoin: strip the HogEx flag from the block header before parsing + - Litecoin: strip the MWEB/HogEx marker and flag from each raw transaction before parsing - Dogecoin: strip AuxPoW merge-mining headers before parsing 2. **Scans outputs** for the `XCHN` magic prefix after deobfuscation. Deobfuscation uses the first input's txid, so the decoder must retrieve the full transaction (including inputs) to reconstruct the key and IV. diff --git a/architecture/platform-map-app.html b/architecture/platform-map-app.html index c1c17515..7f31ed8f 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, 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": "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 pending App Store release), 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." }, @@ -275,7 +275,7 @@

Flows

{ "id": "r13", "source": "hub", "target": "hubdb", "label": "config / consensus / oracle state", "kind": "write" }, { "id": "r14", "source": "hub", "target": "encoder", "label": "publishes ANCHOR / ATTEST / PRICE actions", "kind": "rpc" }, { "id": "r15", "source": "sync", "target": "indexerdb", "label": "snapshots + follows state", "kind": "read" }, - { "id": "r16", "source": "sync", "target": "decoderdb", "label": "snapshots decoded txs (8 of 9 tables)", "kind": "read" }, + { "id": "r16", "source": "sync", "target": "decoderdb", "label": "snapshots decoded txs (8 of 10 tables)", "kind": "read" }, { "id": "r17", "source": "sync", "target": "replicas", "label": "REST snapshots + WebSocket block feed", "kind": "async" }, { "id": "r18", "source": "miner", "target": "coinnode", "label": "auto-mines mempool (regtest)", "kind": "rpc" }, { "id": "r19", "source": "sdk", "target": "encoder", "label": "create_tx / broadcast", "kind": "rpc" }, @@ -391,7 +391,7 @@

Flows

"description": "How a validator or replica node acquires and verifies platform state without trusting the source.", "steps": [ { "n": 1, "node": "sync", "edge": "r15", "action": "Sync server snapshots the indexer DB and tails new blocks." }, - { "n": 2, "node": "sync", "edge": "r16", "action": "Decoder DB is snapshotted too (8 of 9 tables; transparency/mempool excluded by design)." }, + { "n": 2, "node": "sync", "edge": "r16", "action": "Decoder DB is snapshotted too (8 of 10 tables; mempool_transactions and dispenser_extension_undo excluded by design)." }, { "n": 3, "node": "sync", "edge": "r17", "action": "Replicas bootstrap over REST snapshots then follow the WebSocket block feed." }, { "n": 4, "node": "replicas", "action": "ClientApplier applies transactionally (batches of 100, rollback support); merkle epochs and pinned-validator checkpoint quorum verify the stream." } ] diff --git a/architecture/platform-map.json b/architecture/platform-map.json index 177767cb..74f6a6e1 100644 --- a/architecture/platform-map.json +++ b/architecture/platform-map.json @@ -153,7 +153,7 @@ "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." + "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 pending App Store release), plus Trezor/Ledger signer packages. Signs PSBTs locally; keys never leave the device." }, { "id": "node", @@ -306,7 +306,7 @@ "id": "r16", "source": "sync", "target": "decoderdb", - "label": "snapshots decoded txs (8 of 9 tables)", + "label": "snapshots decoded txs (8 of 10 tables)", "kind": "read" }, { @@ -776,7 +776,7 @@ "n": 2, "node": "sync", "edge": "r16", - "action": "Decoder DB is snapshotted too (8 of 9 tables; transparency/mempool excluded by design)." + "action": "Decoder DB is snapshotted too (8 of 10 tables; mempool_transactions and dispenser_extension_undo excluded by design)." }, { "n": 3, diff --git a/blockchains.md b/blockchains.md index 565517ba..202d9f22 100644 --- a/blockchains.md +++ b/blockchains.md @@ -21,8 +21,6 @@ Each chain supports three network types: | **Testnet** | Public test network: free test coins, mirrors mainnet behavior | | **Regtest** | Local regression testing network: instant block generation, fully controlled environment | -> **BTC testnet4 contract limitation:** Contract deploys on BTC testnet4 cannot be mined at current miner block sizes. Use LTC or DOGE testnet for contract deployment and testing. - ## Chain-Specific Differences The same protocol specification applies across all chains. Chain-specific differences are limited to: diff --git a/components/README.md b/components/README.md index 147dd9c5..d04bb981 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, Electron desktop, and Capacitor mobile (Android shipped, iOS later) | +| [wallet](./wallet/) | Reference self-custodial multi-chain wallet: browser, Chrome extension, Electron desktop, and Capacitor mobile (Android shipped, iOS pending App Store release) | | [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/decoder/README.md b/components/decoder/README.md index 2d796116..af7bec0a 100644 --- a/components/decoder/README.md +++ b/components/decoder/README.md @@ -30,7 +30,7 @@ The decoder's job is extraction only; it does not interpret action semantics. It |---|---| | [Architecture](architecture.md) | Data pipeline, internal components, polling loop, transaction parsing, deobfuscation | | [Configuration](configuration.md) | Environment variables, internal constants, network-specific settings | -| [Database](database.md) | Full schema reference: 9 tables covering blocks, transactions, dispensers, pubkeys, indexes, and events | +| [Database](database.md) | Full schema reference: 10 tables covering blocks, transactions, mempool, dispensers, pubkeys, indexes, events, and reorg bookkeeping | | [Operations](operations.md) | Running, Docker, API endpoints, reorg handling, mempool, troubleshooting | ## Installation diff --git a/components/decoder/architecture.md b/components/decoder/architecture.md index c25e1ead..1a76cdf2 100644 --- a/components/decoder/architecture.md +++ b/components/decoder/architecture.md @@ -58,7 +58,7 @@ flowchart TD | `src/chain/XChainBlockDecoder.js` | `XChainBlockDecoder` | Block and transaction parsing via bitcoinjs-lib with coin-specific fixes (Litecoin MWEB, Dogecoin AuxPoW) | | `src/chain/crypto_networks.js` | `CryptoNetworks` | Network configuration: bitcoinjs-lib network objects and start block indexes for all 9 chain/network combinations | | `src/util.js` | None | Utility functions: sleep, SHA256, hex conversion, timer | -| `src/sql/*.sql` | None | Table creation SQL for all 9 database tables | +| `src/sql/*.sql` | None | Table creation SQL for all 10 database tables | ## Block Polling Loop @@ -71,7 +71,7 @@ flowchart TD S3["3. Fetch and parse next block
- getBlockHash → getBlock (hex)
- For Dogecoin: strip AuxPoW first
- Parse block with bitcoinjs-lib"] S4["4. Reorg detection
- Compare previous_block_hash
- If mismatch: delete bad block, log reorg event, retry"] S5["5. Process each transaction
- Parse outputs for XChain data
- Deobfuscate (AES-128-CTR)
- Validate XCHN prefix
- Resolve source address
- Check for dispenser payments"] - S6["6. Write to database
- Begin transaction
- Insert block, transactions, dispensers, outputs
- Delete expired dispensers
- Commit transaction"] + S6["6. Write to database
- Begin transaction
- Insert block, transactions, dispensers, outputs
- Soft-expire expired dispensers (stamp expired_block_index)
- Commit transaction
- After commit: hard-purge rows soft-expired at reorg-safe depth"] S7["7. Mempool updates (when synced)
- Every 60 seconds
- Fetch mempool txids
- Diff against DB, add new, remove stale
- Batch fetch in 1000-tx chunks"] S1 --> S2 --> S3 --> S4 --> S5 --> S6 --> S7 diff --git a/components/decoder/configuration.md b/components/decoder/configuration.md index 124c1f38..7a9e1bf3 100644 --- a/components/decoder/configuration.md +++ b/components/decoder/configuration.md @@ -27,7 +27,7 @@ Configuration is loaded from a `.env` file via `dotenv`. All variables are read | Variable | Description | Default | |---|---|---| -| `AUX_POW` | Enable AuxPoW header stripping (Dogecoin) | _(unset)_ | +| `AUX_POW` | Legacy; read but ignored. AuxPoW header stripping is chosen from `NETWORK` (Dogecoin only) | _(unset)_ | | `DECODER_RATE_LIMIT_RPM` | API requests per minute per IP | `100` | | `GETMEMPOOL_CACHE_MS` | How long the `getmempool` JSON-RPC response is cached, in milliseconds. That method reads the database, so without a cache a burst of unauthenticated requests contends with the block loop; the window is read once and sliced per request, and the underlying snapshot only changes every 60 seconds. | `5000` | | `FEE_DESTINATION` | Native-coin protocol fee destination override for this coin+network. The decoder persists outputs paying the resolved address to `transaction_outputs` so the indexer can validate native-coin fee payments. By default the address comes from the bundled coin registry (`src/coins`, pinned per coin/network), so capture is on for a stock install. This variable overrides the default on testnet/regtest only; on mainnet it is ignored with a warning, because fee acceptance is consensus and must not depend on operator environment. | _(coin registry)_ | @@ -45,7 +45,7 @@ Configuration is loaded from a `.env` file via `dotenv`. All variables are read | `DECODER_POLL_SILENT_MS` | How long the block loop may go without completing a single iteration before `/live` reports unhealthy (503) and the container restart policy recycles the process. Measures the loop, not the chain: the stall window `DECODER_STALL_ALERT_MS` (default `900000` ms, documented under Operations) asks whether the chain is advancing, and a caught-up decoder advances nothing for hours while being perfectly healthy, so only an iteration count separates "idle" from "the loop is gone". Defaults to twice the stall window, because every normal path through the loop, the node-outage retry included, returns to the loop top far inside it. | `1800000` (30 minutes) | | `XCHAIN_INDEXER_DIR` | Path override for the sibling `xchain-indexer` checkout that `bin/sync-batch-limits.js` reads to regenerate the vendored BATCH limit tables (`src/protocol/indexer_batch_limits.js`) from the indexer's own `src/actions/batch.js` and `src/protocol_changes.js`. A maintenance-tool setting only; the decoder service itself never reads it. | `../../xchain-indexer` relative to `bin/` (the sibling checkout layout) | -The `AUX_POW` variable should be set to any truthy value when running against Dogecoin nodes. It enables the `getBlockWithoutAuxPow()` code path that strips merge-mining headers before parsing. +The `AUX_POW` variable is legacy: the decoder still parses it but no longer consults it. The `getBlockWithoutAuxPow()` code path that strips merge-mining headers before parsing is selected from the coin's wire format, so it is always on for a `dogecoin-*` `NETWORK` and never on for Bitcoin or Litecoin, whatever `AUX_POW` says. ## Network Identifier Format diff --git a/components/decoder/database.md b/components/decoder/database.md index 00a5f1b7..f012b0e3 100644 --- a/components/decoder/database.md +++ b/components/decoder/database.md @@ -190,7 +190,7 @@ Notable applied migrations: | File | Mode | Effect | |---|---|---| | `2026-05-28-unique-index-tables.sql` | manual | De-duplicates `index_addresses`/`index_transactions` rows, then adds UNIQUE indexes | -| `2026-06-02-widen-ids-to-bigint.sql` | manual | Widens all `INTEGER UNSIGNED` id/key columns to `BIGINT UNSIGNED` across seven tables (`index_addresses`, `index_transactions`, `pubkeys`, `blocks`, `transactions`, `dispensers`, `transaction_outputs`). `mempool_transactions` is not included; the `2026-06-15-mempool-raw-strings.sql` migration later drops and recreates that table entirely with the correct types. | +| `2026-06-02-widen-ids-to-bigint.sql` | manual | Widens all `INTEGER UNSIGNED` id/key columns to `BIGINT UNSIGNED` across seven tables (`index_addresses`, `index_transactions`, `pubkeys`, `blocks`, `transactions`, `dispensers`, `transaction_outputs`). `mempool_transactions` is not included; the `2026-06-15-mempool-raw-strings.sql` migration later drops and recreates that table entirely with the correct types. Recorded as applied without running where every one of those columns is already `BIGINT UNSIGNED` and the `pubkeys_ibfk_1` foreign key exists, which is the shape a fresh install builds. | | `2026-06-13-dispensers-expiration-bigint.sql` | manual | Changes `dispensers.expiration` from `DATETIME` to `BIGINT UNSIGNED` | | `2026-06-15-events-data-mediumtext.sql` | auto | Widens `events.data` from `TEXT` to `MEDIUMTEXT` | | `2026-06-15-mempool-raw-strings.sql` | manual | Drops and recreates `mempool_transactions` with raw `VARCHAR` columns instead of FK integer IDs | diff --git a/components/decoder/operations.md b/components/decoder/operations.md index 87236f2f..c95587fc 100644 --- a/components/decoder/operations.md +++ b/components/decoder/operations.md @@ -22,7 +22,7 @@ On startup, the decoder: 2. Starts the Express JSON-RPC API server on `DECODER_API_PORT` 3. Validates the database name (alphanumeric + underscores only) 4. Creates the database if it doesn't exist -5. Creates all 9 tables if they don't exist +5. Creates all 10 tables if they don't exist 6. Waits for the coin node to reach 99% verification progress 7. Begins parsing from the configured start block (or last parsed block + 1) @@ -103,6 +103,9 @@ Detailed health status including decoder state. "lag_blocks": 1, "rpc_errors": 0, "parse_errors": 0, + "quarantined_txs": 0, + "last_quarantined_block": null, + "last_quarantined_tx_hash": null, "dispenser_purge_failures": 0, "error": null }, @@ -132,7 +135,10 @@ Detailed health status including decoder state. | `reorg_halt_parked` | `boolean` | `true` once the parse loop has stopped on the halt and is waiting for the clear. `false` on a decoder that carries the marker but is still parsing forward, which is the only thing that separates the two | | `reorg_halt_parked_at` | `string\|null` | When the parse loop parked (ISO 8601); `null` when it is not parked | | `rpc_errors` | `integer` | Combined RPC error count from the decoder and its `BlockchainConnector` | -| `parse_errors` | `integer` | Number of transactions quarantined due to parse failures | +| `parse_errors` | `integer` | Transactions the decoder rejected or failed to parse since process start. It includes routine rejections such as other protocols' data on the chain, so it rises on a healthy decoder; `quarantined_txs` is the permanent-skip count | +| `quarantined_txs` | `integer` | Transactions this decoder skipped for good since process start, after `TX_PARSE_MAX_RETRIES` whole-block retries, each recorded as a `PARSE_ERROR` event. Also exported as `xchain_decoder_quarantined_tx_total`. Reported only: it never changes `status` or the HTTP code, since a restart cannot undo a skip. A rise means a transaction may be missing from this instance's ledger and from everything downstream | +| `last_quarantined_block` | `integer\|null` | Block height of the most recent quarantine; `null` until the first one | +| `last_quarantined_tx_hash` | `string\|null` | Hash of the most recently quarantined transaction; `null` until the first one or when the id itself could not be read | | `dispenser_purge_failures` | `integer` | Post-commit expired-dispenser purges that failed since process start, also exported as `xchain_decoder_dispenser_purge_failures_total`. A failed purge keeps soft-expired rows longer and retries at the next commit; a count that keeps rising means the dispensers table is no longer being trimmed | | `error` | `string\|null` | Error message if the decoder crashed, otherwise `null` | @@ -142,7 +148,7 @@ Returns HTTP 200 with `{status: "healthy", db, running}` when the decoder is run ### `GET /live` (REST) -The liveness probe, and the route the Docker HEALTHCHECK runs. Everything `/status` reports, plus `stalled`, `poll_silent`, `last_poll_at`, `last_processed_block`, `node_height`, `lag`, `parse_errors`, and `rpc_errors`. Returns HTTP 503 when the decoder is not running, MariaDB is unreachable, the block loop is stalled, **or** the block loop has stopped iterating. +The liveness probe, and the route the Docker HEALTHCHECK runs. Everything `/status` reports, plus `stalled`, `poll_silent`, `last_poll_at`, `last_processed_block`, `node_height`, `lag`, `parse_errors`, `quarantined_txs`, `last_quarantined_block`, `last_quarantined_tx_hash`, `last_quarantined_at`, and `rpc_errors`. Returns HTTP 503 when the decoder is not running, MariaDB is unreachable, the block loop is stalled, **or** the block loop has stopped iterating. Stalled means the loop is alive and retrying but no longer making progress the chain is waiting on: either one height has failed to fetch on many consecutive attempts, or nothing advanced for `DECODER_STALL_ALERT_MS` (default `900000` ms, 15 minutes) while the node tip was fresh and visibly ahead. The block loop never skips a block on a fetch or parse fault, because skipping would corrupt the index, so a deterministic fault at one height retries forever with the process alive and the DB answering. That case is invisible to `/status`, which is why autoheal probes `/live` instead. @@ -262,7 +268,7 @@ Mempool tracking pauses if the decoder falls more than 3 blocks behind the tip, - Verify the coin node is accessible at `NODE_URL:NODE_PORT` - Check that `verificationprogress` is >= 0.99 (run `bitcoin-cli getblockchaininfo`) -- If using Dogecoin, ensure `AUX_POW` is set in the environment +- If using Dogecoin, ensure `NETWORK` names the dogecoin chain (for example `dogecoin-mainnet`); AuxPoW stripping follows it, and `AUX_POW` has no effect ### Decoder is stuck / not advancing diff --git a/components/explorer/api.md b/components/explorer/api.md index 1e672f60..7a711345 100644 --- a/components/explorer/api.md +++ b/components/explorer/api.md @@ -165,7 +165,7 @@ The example above shows the gate in action: `RBTC` has a frozen tip, so it is | `replica_halted` | Per-coin durable consensus-divergence halt verdict, read from the sync client's `sync_halt` table on the same replica this instance reads (`true` when an active, uncleared halt row exists). A halted replica keeps reporting a small lag until its source mints past it, so this catches what neither `stale` nor `tip_age_seconds` can see; it composes with `stale` rather than replacing it (this field flags the halt immediately, `stale`/`available` only drop the coin once its tip actually ages out). `true`/`false` only once the table was read successfully; `null` when the signal could not be determined (no live pool, the table doesn't exist on this replica, or the read failed), and `null` is never coerced to `false`. Only coins this instance measures appear here | | `chain_tip` | Per-coin chain tip as reported by the decoder's own health endpoint (what the coin node sees) | | `chain_lag_blocks` | `chain_tip − decoder_tip` (how far the decoder trails the chain) | -| `decoder_health` | Per-coin decoder health string: `"healthy"`, `"unhealthy"` (decoder up but reporting problems), `"node-stale"` (the decoder's cached coin-node height is frozen, so `chain_tip` and `chain_lag_blocks` are nulled), `"unreachable"` (decoder not responding), or `"unconfigured"` (no decoder endpoint resolves for this coin, from either the explorer's configuration or `DECODER_API_URL[__]`; see [Configuration](configuration.md)) | +| `decoder_health` | Per-coin decoder health string: `"healthy"`, `"unhealthy"` (decoder up but reporting problems), `"reorg-halt-parked"` (the decoder has stopped on a durable REORG_HALT marker that only an operator clear removes), `"reorg-halted"` (the decoder carries that marker but is still parsing; both halt values outrank every value after them), `"node-stale"` (the decoder's cached coin-node height is frozen, so `chain_tip` and `chain_lag_blocks` are nulled), `"unreachable"` (decoder not responding), or `"unconfigured"` (no decoder endpoint resolves for this coin, from either the explorer's configuration or `DECODER_API_URL[__]`; see [Configuration](configuration.md)) | | `hub_consensus_hash_mismatch` | Explorer-to-hub coin-configuration integrity verdict: `true`, `false`, or `null`. `true` means at least one consensus hash served by the hub differs from the explorer's bundled coin registry, `false` means every hash served for a bundled coin matches, and `null` means no verdict is available because the explorer has not received the hash map or the hub is too old to send it | | `hub_consensus_hash_mismatch_details` | One string per mismatch in `TICK/network: hub HASH vs bundled HASH` form. Empty when hashes match or no verdict is available | diff --git a/components/explorer/websocket.md b/components/explorer/websocket.md index 2b4c44d6..c576fcc3 100644 --- a/components/explorer/websocket.md +++ b/components/explorer/websocket.md @@ -189,10 +189,19 @@ renamed, retyped, or restructured in a way an existing consumer could mis-parse (additive optional fields do not bump it). The official SDK warns when the server reports a newer schema than it was built against. +**`stale: true`** sits at the top level of the envelope on `WELCOME`, +replayed catch-up frames, `CATCH_UP_COMPLETE`, `SNAPSHOT` and live frames +while the coin's indexed tip is older than its maximum age. The rows are +still real, but they end at a tip that is behind the chain. `WELCOME` also +repeats it as `data.stale`, beside the tip indices it qualifies. The field +is absent, never `false`, while the tip is fresh, so one `if (msg.stale)` +check covers every frame. + **Wire types under schema v2:** BigInt-backed fields (`action_index`, `block_index`, `block_time`, `latest_block_index`, `latest_action_index`, -the `NEW_BLOCK` counts `tx_count` and `action_count`, and other -chain-index/amount fields) serialize as **decimal strings**, not +the `NEW_BLOCK` counts `tx_count` and `action_count`, the `TOKEN_UPDATE` +holder count `holders`, and other chain-index/amount fields) serialize as +**decimal strings**, not JSON numbers, matching the REST API. Compare them as strings or parse with `BigInt(...)`; a numeric `===` against them will silently fail. `timestamp` and rate-limit metadata remain JSON numbers. The payload examples below show @@ -291,7 +300,7 @@ Channel: `token` "data": { "tick": "PEPE", "supply": "100000000.00000000", - "holders": 1234, + "holders": "1234", "last_action_index": "45680" } } @@ -425,7 +434,10 @@ the `BET_CLOSED` cursor is. ### Order Lifecycle Events -These fire on both the `actions` channel and the `address` channel for involved addresses. +These fire on the `actions` channel and on the `address` channel of each address the frame's +`data` names: its `source`, or on `COINPAY_REQUIRED` its `payer_address` and `payee_address`. +A lifecycle frame does not carry the `destinations` a `NEW_ACTION` lists, and it is not sent to +their `address` channels. #### ORDER_MATCH diff --git a/components/hub/architecture.md b/components/hub/architecture.md index 3765964c..44908b06 100644 --- a/components/hub/architecture.md +++ b/components/hub/architecture.md @@ -497,15 +497,16 @@ On each finalized oracle round, `RewardTracker` splits `ORACLE_REWARD_PER_ROUND` ### Slash Detection -Three offense types are monitored: +Four offense types are monitored: | Offense | Trigger | Description | |---|---|---| | `price_deviation` | >5% from consensus | Submission deviates more than `SLASH_DEVIATION_THRESHOLD` from the finalized price | | `repeated_deviation` | 3+ in 24 hours | Three or more deviations within a rolling 24-hour window | | `non_participation` | 30+ missed rounds | `SLASH_MISSED_ROUNDS_THRESHOLD` consecutive rounds without a submission | +| `attestation_divergence` | Diverged body on a `byte_equality` provider | A validator's `ok` PROPOSE body hash differs from the quorum-agreed winner; only `byte_equality` providers are checked, and a `provider_error` report never counts | -Detection is recorded in the `slash_proposals` table for governance review. All three offenses are hub-local: the strongest outcome of a governance vote is `validators.status='suspended'`, which excludes the validator from PBFT rounds and leaves on-chain stake untouched. Stake is burned only when the indexer processes a permissionless SLASH proof of equivocation, which no offense in this table produces. See [Decentralization](decentralization.md) for the three penalty lanes. +Detection is recorded in the `slash_proposals` table for governance review. All four offenses are hub-local: the strongest outcome of a governance vote is `validators.status='suspended'`, which excludes the validator from PBFT rounds and leaves on-chain stake untouched. Stake is burned only when the indexer processes a permissionless SLASH proof of equivocation, which no offense in this table produces. See [Decentralization](decentralization.md) for the three penalty lanes. --- diff --git a/components/hub/configuration.md b/components/hub/configuration.md index c795341b..3dcbc5e9 100644 --- a/components/hub/configuration.md +++ b/components/hub/configuration.md @@ -533,7 +533,7 @@ Controls `FullNodeChallengeRound`, the periodic possession challenge proving a v | `FULLNODE_PROOF_WINDOW_BLOCKS` | No | per-coin | Blocks a challenged node has to submit a proof. | | `FULLNODE_REWARD_PASS_WINDOW_BLOCKS` | No | per-coin | Blocks in the reward-pass window. | | `FULLNODE_MIN_PASS_RATE_BPS` | No | per-coin | Minimum pass rate, in basis points, for a node to be treated as passing. | -| `FULLNODE_REWARD_SHARE` | No | per-coin | Reward share for passing full nodes. | +| `FULLNODE_REWARD_SHARE` | No | per-coin | Full-node fraction of the designed oracle-round reward split; a consensus parameter that must match the coin default. Nothing pays the full-node tranche today (see [COLLECT](../../protocol/actions/collect.md#reward-population-path)). | | `FULLNODE_GENESIS_VERIFIERS` | No | per-coin | Comma-separated genesis verifier pubkeys (lowercased). | | `XCHAIN_HUB_SKIP_FULLNODE_ASSERT` | No | _(unset, assertion active)_ | Set to `1` to skip the canonical-FULLNODE assertion at startup and warn instead. Intended as a loud one-off bypass for a venue running its own challenge cadence and verifier set: divergent `NODEPROOF` knobs fork the challenge schedule, so this is not a setting to leave on. | diff --git a/components/hub/database.md b/components/hub/database.md index 2b76da1b..cfbcbdc8 100644 --- a/components/hub/database.md +++ b/components/hub/database.md @@ -480,14 +480,14 @@ Tracks XCHAIN rewards earned by validators for participating in oracle rounds. R ### `slash_proposals` -Records detected validator misbehavior for governance review. The offenses recorded here (`price_deviation`, `repeated_deviation`, `non_participation`, attestation divergence) are hub-local: a governance vote can suspend the validator, and on-chain stake is untouched. Stake is burned only when the indexer processes a permissionless SLASH proof of equivocation, which is a separate on-chain path this table does not feed. +Records detected validator misbehavior for governance review. The offenses recorded here (`price_deviation`, `repeated_deviation`, `non_participation`, `attestation_divergence`) are hub-local: a governance vote can suspend the validator, and on-chain stake is untouched. Stake is burned only when the indexer processes a permissionless SLASH proof of equivocation, which is a separate on-chain path this table does not feed. | Column | Type | Description | |---|---|---| | `id` | `BIGINT AUTO_INCREMENT` | Primary key | | `validator_pubkey` | `CHAR(64) NOT NULL` | Offending validator's pubkey | -| `offense_type` | `VARCHAR(30) NOT NULL` | Type: `price_deviation`, `repeated_deviation`, `non_participation` | -| `round_number` | `BIGINT` | Round where offense occurred | +| `offense_type` | `VARCHAR(30) NOT NULL` | Type: `price_deviation`, `repeated_deviation`, `non_participation`, `attestation_divergence` | +| `round_number` | `BIGINT` | Round where offense occurred (for `attestation_divergence`, a pseudo-round taken from the request id) | | `evidence` | `TEXT` | Serialized evidence details | | `status` | `ENUM('pending','approved','rejected','expired')` | Proposal status (default: `pending`) | | `created_at` | `DATETIME` | Detection time | diff --git a/components/hub/decentralization.md b/components/hub/decentralization.md index 092dd68b..aa6db63c 100644 --- a/components/hub/decentralization.md +++ b/components/hub/decentralization.md @@ -31,7 +31,7 @@ The validator network uses a **capability model** rather than fixed tiers. A val | `cross_chain` | Attest to cross-chain swap actions per chain-pair | CrossChainEngine | | `oracle_publish` | Broadcast finalized PRICE v0 rounds on-chain (typically DOGE for low fees) | PRICE v0 publisher, formerly "Tier 3" | | `attestation` | Fetch external attestation requests via registered providers (`http_get`, `llm`) and PBFT-finalize the response | AttestationRound, AttestationConsensus | -| `full_node` | Claim the full-node reward tier by passing the chain-derived possession challenges run by `FullNodeChallengeRound` (answer: the `scriptPubKey` of a seed-selected output in a buried block) | FullNodeChallengeRound | +| `full_node` | Pass the chain-derived possession challenges run by `FullNodeChallengeRound` (answer: the `scriptPubKey` of a seed-selected output in a buried block); passing qualifies a validator for the full-node reward tier, which ships inert and is not minted today | FullNodeChallengeRound | A single pubkey can hold any combination of capabilities, there is no overlap restriction. `min_stake[capability]` is governance-configurable; defaults are set by hub config. The historical model where the cross-chain tier required 5× the oracle stake is preserved by appropriate `min_stake` defaults but the protocol does not enforce a hierarchy. @@ -68,7 +68,7 @@ All staking operations (STAKE, UNSTAKE, DELEGATE, COLLECT) are standard XChain a | Deactivation delay | 6 BTC blocks on UNSTAKE (same reorg safety) | | Cooldown | 1000 BTC blocks before staked XCHAIN returns to source (configurable via `STAKING.COOLDOWN_BLOCKS`) | | Delegation | DELEGATE manages signing keys, never stake: v0 adds a signing key that may sign for the broadcaster's own capability stake and v2 revokes one (contract stakes use v1 / v3). A delegated key is backed by the delegating address's active stake; staked amounts never move or change, and there is no third-party stake delegation. See [DELEGATE](../../protocol/actions/delegate.md). | -| Rewards | Not weighted by stake: stake decides which capabilities a pubkey qualifies for, not the size of its share. The per-round oracle budget is split equally across the qualified signers of each finalized round (see [COLLECT reward sources](../../protocol/actions/collect.md#reward-sources)). A pubkey holding both `price` and `oracle_publish` earns both per-round (consensus + broadcast). | +| Rewards | Not weighted by stake: stake decides which capabilities a pubkey qualifies for, not the size of its share. The per-round oracle budget is designed to split equally across the qualified signers of each finalized round, but no consensus rail mints an oracle round reward today, so none is claimable with `COLLECT`; the hub keeps a local per-round ledger for visibility only (see [COLLECT reward sources](../../protocol/actions/collect.md#reward-sources) and [Reward Population Path](../../protocol/actions/collect.md#reward-population-path)). A pubkey may hold both `price` and `oracle_publish`; the claimable rewards tied to `oracle_publish` are the ANCHOR publisher rewards (`anchor_bundle`). | > **Note:** Capability staking above is BTC-only, so the activation/deactivation delays are stated in BTC blocks (~1 hour). Contract-targeted staking (STAKE v3 / UNSTAKE v1 / DELEGATE v1/v3) runs on every chain and calibrates this delay per chain for equivalent ~60-min reorg protection (**6 blocks on BTC, 24 on LTC, 60 on DOGE**) since a flat 6 blocks would give DOGE only ~6 minutes. See `protocol/Contract_Staking.md`. @@ -81,7 +81,7 @@ All staking operations (STAKE, UNSTAKE, DELEGATE, COLLECT) are standard XChain a | **Price data** | `price`-capable validators with trimmed median consensus; published on-chain by `oracle_publish`-capable validators | | **Cross-chain coordination** | `cross_chain`-capable validator attestation with per-chain-pair PBFT | | **External attestation** | `attestation`-capable validators fetch from registered providers (`http_get`, `llm`) and PBFT-finalize; result submitted on-chain as `ATTEST` v1 (response) | -| **Full-node verification** | `full_node`-capable validators answer chain-derived possession challenges (the `scriptPubKey` of a seed-selected output in a buried block) run by `FullNodeChallengeRound`; passing validators earn the full-node reward tranche (NODEPROOF) | +| **Full-node verification** | `full_node`-capable validators answer chain-derived possession challenges (the `scriptPubKey` of a seed-selected output in a buried block) run by `FullNodeChallengeRound`; passing validators get a NODEPROOF verdict, and the full-node reward tranche it qualifies them for ships inert and is not minted today (see [Rewards, and what is not live yet](../../getting-started/running-a-validator.md#rewards-and-what-is-not-live-yet)) | | **Governance** | Off-chain PBFT voting with 7-day period, 2/3+ approval | ### Transport auth follows on-chain key rotation @@ -100,7 +100,7 @@ A validator's P2P signing key is authorized by the **union** of the hub's local | 5 | **Open validator set + governance**; Off-chain PBFT voting for parameter changes, version signaling in heartbeats | v2.0.0 | Complete | | 6 | **Capability model + external attestation framework**; Replace Tier 1/2 with four independent capabilities (`price`, `cross_chain`, `oracle_publish`, `attestation`) auto-qualified by stake amount; block-boundary federation snapshots; external attestation framework (`http_get` byte_equality + `llm` judge_model providers); STAKE rewritten as `VERSION|AMOUNT|SIGNING_PUBKEY` with v1=new / v2=top-up; UNSTAKE rewritten as pubkey-based | 2026-05 | Complete | | 7 | **Federation key rotation + quorum hardening**: addr-keyed `rotatevalidator`/`deregistervalidator` RPC; stake-key revocation via `DELEGATE` v2; Option A union-membership transport auth (on-chain effective signer set + local registry, never fail-open); anchor reward archive (shipped as the `anchor_` reward_type, since superseded by `anchor_bundle`, see [COLLECT](../../protocol/actions/collect.md)) + hub-local oracle/attest_fee reward separation; simple-majority quorum floor (`max(2f+1, ceil((N+1)/2))`) across all consensus engines | 2026-06 | Complete | -| 8 | **Full-node verified tier**: `full_node` capability and `FullNodeChallengeRound`; derived possession challenge (no broadcast of the question); answer = scriptPubKey of a seed-selected UTXO in a buried block, provably absent from a sync mirror; verdict collected as NODEPROOF; full-node reward tranche paid via indexer price.js; `failed_full_node_challenge` slash detection (later removed; tier is reward-only, no slashing) | 2026-06 (bcbe4b8) | Complete | +| 8 | **Full-node verified tier**: `full_node` capability and `FullNodeChallengeRound`; derived possession challenge (no broadcast of the question); answer = scriptPubKey of a seed-selected UTXO in a buried block, provably absent from a sync mirror; verdict collected as NODEPROOF; full-node reward tranche (`oracle_full_node`) defined but shipped inert (`FULLNODE.REWARD_SHARE` 0, no genesis verifiers), so no consensus rail mints it (see [COLLECT](../../protocol/actions/collect.md#reward-population-path)); `failed_full_node_challenge` slash detection (later removed; tier is reward-only, no slashing) | 2026-06 (bcbe4b8) | Complete | ## Architecture Summary diff --git a/components/hub/operations.md b/components/hub/operations.md index e48e31d5..374e95ab 100644 --- a/components/hub/operations.md +++ b/components/hub/operations.md @@ -364,13 +364,43 @@ These series are served on the metrics endpoint only when [`METRICS_ENABLED`](./ | `xchain_oracle_current_round` | Gauge | None | Oracle round number the round timer last opened; flat means the round loop itself is wedged. | | `xchain_oracle_consecutive_skipped_rounds` | Gauge | None | Trailing streak of oracle rounds that did not finalize. | | `xchain_oracle_round_timeouts_total` | Counter | None | Oracle rounds evicted before reaching commit quorum, leader and follower seats alike. | +| `xchain_oracle_abandoned_rounds_total` | Counter | None | Oracle rounds this hub watched open and then recorded as abandoned, counted apart from other finalize-phase losses. | | `xchain_oracle_single_source_rounds_total` | Counter | None | Oracle rounds finalized with one uncorrelated price source on a normally-multi-source pair. | | `xchain_oracle_price_source_live` | Gauge | `source` | `1` when that upstream returned at least one usable price on this hub's last fetch and `0` when it did not; absent before the first fetch. | | `xchain_oracle_price_source_fetch_attempts_total` | Counter | `source` | Cumulative fetch dispatches per upstream this hub attempted this round, live or dead; the dispatch evidence behind the liveness gauge. | | `xchain_oracle_price_source_bound_rejects_total` | Counter | `source` | Upstream values dropped on the ingest bound. | +| `xchain_oracle_publisher_published_total` | Counter | None | Oracle price publishes this hub broadcast. | +| `xchain_oracle_publisher_abandoned_total` | Counter | None | Oracle rounds this hub gave up publishing and moved to the dead-letter file. | +| `xchain_oracle_publisher_takeover_attempts_total` | Counter | None | Windows this hub re-assembled to cover a publish leader that went silent. | +| `xchain_oracle_publisher_takeover_published_total` | Counter | None | Takeover windows this hub went on to publish; flat while attempts climb means takeovers are not reaching chain. | +| `xchain_oracle_publisher_batch_sign_timeouts_total` | Counter | None | PRICE batch signing rounds that timed out waiting for co-signatures. | +| `xchain_oracle_publisher_last_published_round` | Gauge | None | Last oracle round this hub published; absent until it has published one. | +| `xchain_oracle_publisher_unconfirmed_publishes` | Gauge | None | Publishes this hub broadcast that have not yet reached a block. | Single-source warning lines now name the live and dead upstreams (for example, `live: coingecko; dead: kraken, coinbase`) and the pairs that went single-source with their submitters, so an operator can tell which source dropped. +## Attestation and Checkpoint Metrics + +These series are served under the same [`METRICS_ENABLED`](./configuration.md#metrics-and-log-shipping) switch. Each engine's series stay absent on a hub that does not run that engine. + +| Series | Type | Labels | What it tells you | +|---|---|---|---| +| `xchain_attestation_consensus_timeouts_total` | Counter | None | Attestation rounds torn down by the consensus timeout before reaching quorum. | +| `xchain_attestation_poll_errors_total` | Counter | `kind` | Attestation request polls that failed, by cause: `rpc` rejection, `transport` failure or `auth` refusal. | +| `xchain_attestation_last_successful_poll_timestamp_seconds` | Gauge | None | Unix time of the last usable attestation request poll; absent until one succeeds. | +| `xchain_attestation_poll_unsuccessful_seconds` | Gauge | None | Seconds since the last usable attestation poll, or since the first attempt when none has succeeded. | +| `xchain_attestation_failed_rounds` | Gauge | None | Attestation rounds this hub failed to fetch locally that are still inside the round retention window. | +| `xchain_checkpoint_round_timeouts_total` | Counter | None | Checkpoint rounds this hub led that timed out below quorum. | +| `xchain_checkpoint_sub_quorum_finalized_total` | Counter | None | FINALIZED checkpoint broadcasts this hub dropped as a follower for carrying too few signatures. | +| `xchain_checkpoint_malformed_finalized_total` | Counter | None | FINALIZED checkpoint broadcasts this hub dropped as malformed. | +| `xchain_checkpoint_seq_conflicts_total` | Counter | None | Quorum-signed checkpoints refused because a different payload already holds that sequence; any rise is an equivocation report. | +| `xchain_checkpoint_seq_double_sign_refusals_total` | Counter | None | Signature requests refused because this hub already signed a different payload at that sequence. | +| `xchain_checkpoint_cadence_stalls_total` | Counter | None | Checkpoint cadence ticks that were due but this hub could not lead. | +| `xchain_checkpoint_cadence_stalled` | Gauge | None | `1` while the checkpoint cadence is stalled with a named reason, `0` once a round clears it. | +| `xchain_checkpoint_observer_idle` | Gauge | None | `1` when this hub sits outside the checkpoint signer set and deliberately opens no round. | +| `xchain_checkpoint_frozen_tip_ticks` | Gauge | None | Consecutive not-my-slot checkpoint ticks that saw the same BTC block. | +| `xchain_checkpoint_frozen_tip_stall_ticks` | Gauge | None | The tick count at which a frozen tip is metered as a cadence stall; alert when `xchain_checkpoint_frozen_tip_ticks` reaches it. | + ## Resilience and Recovery ### Database Connection Recovery diff --git a/components/indexer/README.md b/components/indexer/README.md index 000c3558..f4dc4250 100644 --- a/components/indexer/README.md +++ b/components/indexer/README.md @@ -21,7 +21,7 @@ Every XChain ACTION; SEND, ISSUE, MINT, ORDER, DISPENSER, SWAP, ANCHOR, XCALL, S - **Staking**: capability staking (STAKE v1/v2, UNSTAKE v0, DELEGATE v0/v2, COLLECT) is BTC-only; contract-targeted staking (STAKE v3, UNSTAKE v1, DELEGATE v1/v3) works on any chain - **Virtual Machine**: DEPLOY, EXECUTE, DEPOSIT, WITHDRAW with isolated-vm sandbox and gas metering - **Unified Gas Fee Schedule**: gas-based fee system for VM and staking actions, replaces per-action flat fees post-activation -- **Protocol versioning**: actions are gated by the indexer's own protocol version (21 at `0.1.0`, 16 at `0.2.0`, all with zero activation heights); block-height and timestamp flag-days gate behaviour changes to already-live actions, per network +- **Protocol versioning**: actions are gated by the indexer's own protocol version (21 at `0.1.0`, 17 at `0.2.0`, all with zero activation heights); block-height and timestamp flag-days gate behaviour changes to already-live actions, per network - **Action mapping**: creates address↔ticker↔action_index cross-references for fast lookups - **Circuit-breaker DB connections**: automatic failure detection and recovery for database connectivity - **Watchdog timeout**: configurable per-block processing timeout detects deadlocks diff --git a/components/indexer/actions.md b/components/indexer/actions.md index 3a5832f0..5f0015d5 100644 --- a/components/indexer/actions.md +++ b/components/indexer/actions.md @@ -135,11 +135,11 @@ Two staking systems share the same four action names. **Capability staking** (ST | **STAKE** | Lock tokens against a signing pubkey. v1 = new capability stake (XCHAIN), v2 = top-up of existing capability stake (XCHAIN), v3 = contract-targeted stake (any token, targets a stakeable contract: see DEPLOY v1) | VERSION valid (1/2/3), AMOUNT positive, SIGNING_PUBKEY is 64-char hex Ed25519. v1/v2: aggregate per-pubkey active stake auto-qualifies the pubkey for each of five capabilities (`price`, `cross_chain`, `oracle_publish`, `attestation`, `full_node`) based on governance `min_stake[capability]`. v3: target contract must be stakeable; row keyed by `(target, pubkey, tick, source)`. | | **UNSTAKE** | Release staked tokens. v0 = capability unstake keyed by pubkey. v1 = contract-targeted unstake keyed by `(target, pubkey, tick)`. Both take an optional trailing `AMOUNT` (gated by `PARTIAL_UNSTAKE_COLLECT`): absent means a full sweep, present moves only `AMOUNT` into cooldown and the residual stays staked ([Partial Unstake](../../protocol/actions/unstake.md#partial-unstake-optional-amount)). | Pubkey has active stake of matching type; sets `deactivation_block`. v1 cooldown is per-contract (set at DEPLOY v1 time); v0 uses the global `STAKING.COOLDOWN_BLOCKS`. | | **DELEGATE** | Manage the signing key for a stake. v0 = capability rotate, v1 = contract rotate, v2 = capability revoke, v3 = contract revoke. | Active stake/delegation of matching type exists. For rotates, new pubkey valid and unused. Takes effect after the activation delay: 6 blocks for capability rotate/revoke (v0/v2); the per-chain delay (6 blocks on BTC, 24 on LTC, 60 on DOGE) for contract rotate/revoke (v1/v3). | -| **COLLECT** | Collect accumulated rewards | Address has unclaimed rewards > 0. `attest_fee` rewards are derived by the indexer during block processing; the oracle labels (`oracle_round` / `oracle_base` / `oracle_full_node`) are not minted by any consensus rail today (see [COLLECT](../../protocol/actions/collect.md#reward-population-path)); `anchor_bundle` and `anchor_archive` rewards are derived by the indexer from the on-chain ANCHOR bytes at or above their own flag-days (`ANCHOR_REWARD_ACTIVATION` / `ARCHIVE_REWARD_ACTIVATION`); below those heights they were pushed from `xchain-hub` via `pushvalidatorrewards`, a method since removed (a call answers JSON-RPC `-32601`), and those rows are restored by ANCHOR full-parse recovery. | +| **COLLECT** | Collect accumulated rewards | Address has unclaimed rewards > 0. `attest_fee` rewards are derived by the indexer during block processing; `rollcall_publish` rewards are derived at the BTC-side ROLLCALL epoch close and pay the frozen `ROLLCALL_REWARD_AMOUNT` to that epoch's elected leader (see [ROLLCALL](../../protocol/actions/rollcall.md)); the oracle labels (`oracle_round` / `oracle_base` / `oracle_full_node`) are not minted by any consensus rail today (see [COLLECT](../../protocol/actions/collect.md#reward-population-path)); `anchor_bundle` and `anchor_archive` rewards are derived by the indexer from the on-chain ANCHOR bytes at or above their own flag-days (`ANCHOR_REWARD_ACTIVATION` / `ARCHIVE_REWARD_ACTIVATION`); below those heights they were pushed from `xchain-hub` via `pushvalidatorrewards`, a method since removed (a call answers JSON-RPC `-32601`), and those rows are restored by ANCHOR full-parse recovery. | ### `oracle_publish` capability (formerly "Tier 3") -The publisher role for broadcasting finalized PRICE v0 transactions to a chain (DOGE recommended for low fees). Auto-qualifies when a pubkey's aggregate active stake ≥ `min_stake[oracle_publish]`. A pubkey may hold the `oracle_publish` and `price` capabilities simultaneously (and earn both rewards in the same round). +The publisher role for broadcasting finalized PRICE v0 transactions to a chain (DOGE recommended for low fees). Auto-qualifies when a pubkey's aggregate active stake ≥ `min_stake[oracle_publish]`. A pubkey may hold the `oracle_publish` and `price` capabilities simultaneously. No oracle round reward is minted today (see [COLLECT → Reward Population Path](../../protocol/actions/collect.md#reward-population-path)); the claimable reward tied to `oracle_publish` is the ANCHOR publisher reward (`anchor_bundle`). ## Oracle Actions @@ -165,7 +165,7 @@ Virtual Machine actions are available on **all chains** (BTC, LTC, DOGE). DEPLOY | Action | Purpose | Key Validations | |---|---|---| -| [**ANCHOR**](../../protocol/actions/anchor.md) | Commit quorum-signed state checkpoints and a compressed archive of cross-chain match rows on-chain. DOGE-only, validator-broadcast action. | Valid only on the DOGE chain (all networks). No protocol fee. On parse, the indexer writes to `anchor_actions` and records checkpoint hashes. The archived data makes all platform state recoverable from a full chain re-parse. See `src/actions/anchor/index.js` and `protocol/actions/ANCHOR.md`. | +| [**ANCHOR**](../../protocol/actions/anchor.md) | Commit quorum-signed state checkpoints and a compressed archive of cross-chain match rows on-chain. DOGE-only, validator-broadcast action. | Valid only on the DOGE chain (all networks). No protocol fee. On parse, the indexer writes to `anchor_actions` and records checkpoint hashes. The archived data makes all platform state recoverable from a full chain re-parse. See `src/actions/anchor/index.js` and [`protocol/actions/anchor.md`](../../protocol/actions/anchor.md). | ## Attestation & Validator Actions @@ -173,10 +173,10 @@ These are the Attestation and Validator categories named under Protocol Versioni | Action | Purpose | Key Validations | |---|---|---| -| [**ATTEST**](../../protocol/actions/attest.md) | External-data attestation lifecycle in three version-discriminated phases: v0 (VM-emitted request), v1 (validator-broadcast response), v2 (system-synthesized expiry). | Governance-registered `PROVIDER_ID`; v1 responses are quorum-signed by the attestation-capable validator set; deterministic `REQUEST_ID` derivation. See `protocol/actions/ATTEST.md`. | -| [**NODEPROOF**](../../protocol/actions/nodeproof.md) | Records an on-chain, quorum-signed verdict of which validators answered a periodic possession challenge, proving they run a real coin full node rather than mirroring DBs via `xchain-sync`. | `EPOCH_HEIGHT` a multiple of `CHALLENGE_INTERVAL_BLOCKS`; passing pubkeys quorum-signed; challenge derived from the epoch. See `protocol/actions/NODEPROOF.md`. | -| [**SLASH**](../../protocol/actions/slash.md) | Permissionless equivocation proof that burns a capability validator's entire bond when they signed two conflicting values for the same consensus slot. | `CAPABILITY` matches the engine the `EQUIV_KEY` names (derived, not trusted); both signatures verify against `OFFENDER_PUBKEY`; writes a `capability_slash_events` audit row with the bounty/treasury split. See `protocol/actions/SLASH.md`. | -| [**VOTE**](../../protocol/actions/vote.md) | Token-weighted governance polls in four version-discriminated phases: v0 (create), v1 (ballot), v2 (system finalization), v3 (delegate). | Weight measured from on-chain holdings of `TICK` at the poll's effective close (never read from payload), so finalization needs no validator consensus round; `QUORUM` / `MIN_VOTERS` thresholds gate a winner. See `protocol/actions/VOTE.md`. | +| [**ATTEST**](../../protocol/actions/attest.md) | External-data attestation lifecycle in three version-discriminated phases: v0 (VM-emitted request), v1 (validator-broadcast response), v2 (system-synthesized expiry). | Governance-registered `PROVIDER_ID`; v1 responses are quorum-signed by the attestation-capable validator set; deterministic `REQUEST_ID` derivation. See [`protocol/actions/attest.md`](../../protocol/actions/attest.md). | +| [**NODEPROOF**](../../protocol/actions/nodeproof.md) | Records an on-chain, quorum-signed verdict of which validators answered a periodic possession challenge, proving they run a real coin full node rather than mirroring DBs via `xchain-sync`. | `EPOCH_HEIGHT` a multiple of `CHALLENGE_INTERVAL_BLOCKS`; passing pubkeys quorum-signed; challenge derived from the epoch. See [`protocol/actions/nodeproof.md`](../../protocol/actions/nodeproof.md). | +| [**SLASH**](../../protocol/actions/slash.md) | Permissionless equivocation proof that burns a capability validator's entire bond when they signed two conflicting values for the same consensus slot. | `CAPABILITY` matches the engine the `EQUIV_KEY` names (derived, not trusted); both signatures verify against `OFFENDER_PUBKEY`; writes a `capability_slash_events` audit row with the bounty/treasury split. See [`protocol/actions/slash.md`](../../protocol/actions/slash.md). | +| [**VOTE**](../../protocol/actions/vote.md) | Token-weighted governance polls in four version-discriminated phases: v0 (create), v1 (ballot), v2 (system finalization), v3 (delegate). | Weight measured from on-chain holdings of `TICK` at the poll's effective close (never read from payload), so finalization needs no validator consensus round; `QUORUM` / `MIN_VOTERS` thresholds gate a winner. See [`protocol/actions/vote.md`](../../protocol/actions/vote.md). | --- diff --git a/components/indexer/data-retention.md b/components/indexer/data-retention.md index 5f5df3d3..8da25242 100644 --- a/components/indexer/data-retention.md +++ b/components/indexer/data-retention.md @@ -95,7 +95,14 @@ reference. | `STATE_ROOT_RETENTION_BLOCKS` | unset | A positive integer turns retention on and sets the phase-1 window. Unset or `0` means off (keep everything, the historical default behavior). | | `STATE_NODE_RECLAIM` | off | `1` or `true` additionally enables phase-2 orphan-node reclaim. Ignored unless retention is on. | | `STATE_RETENTION_INTERVAL_MS` | `21600000` (6h) | Sweep cadence. | -| `STATE_TREE_METRIC_MAX_NODES` | `2000000` | Shared with the orphan-growth metric: above this node count, the in-memory mark (and thus phase-2 reclaim) is skipped to bound memory. Phase-1 root prune still runs. | +| `STATE_TREE_METRIC_MAX_NODES` | `2000000` | Shared with the orphan-growth metric: above this node count, the in-memory mark (and thus phase-2 reclaim) is skipped to bound memory. Phase-1 root prune still runs. The table only grows, so once a chain passes the cap, reclaim stays off until the cap is raised. | + +With `STATE_NODE_RECLAIM` on, the indexer logs a warning the first time a sweep +skips reclaim at the cap, naming the node count and the cap, and one info line +when reclaim runs again. Every such sweep also emits a +`state_retention_node_reclaim` `[METRIC]` line carrying `skipped`, `reason`, +`total_nodes`, `reachable_nodes`, `max_nodes`, `deleted`, `sweep_duration_ms` +and `consecutive_skips`. Operational guidance: a retention window must be wider than the deepest reorg a chain will ever serve, and wide enough for the SPV proof horizon the diff --git a/components/indexer/database.md b/components/indexer/database.md index 31893bdd..0c44a57a 100644 --- a/components/indexer/database.md +++ b/components/indexer/database.md @@ -146,7 +146,7 @@ The indexer creates and manages all tables in this database. SQL schema files li | `stakes` | Active and historical capability-staking STAKE records (`version` 1=new / 2=top-up): `signing_pubkey_id`, `amount`, `activation_block` (`block_index + 6`), `deactivation_block` (set on UNSTAKE), `status_id`, `source_id`. Capabilities (`price`, `cross_chain`, `oracle_publish`, `attestation`, `full_node`) are derived from a pubkey's aggregate active `amount` against the governance-configured minimums: there is no `tier` column. | | `unstakes` | Capability UNSTAKE v0 records: `signing_pubkey_id`, `amount`, `cooldown_end_block`, `status_id`; links back to the originating stake by pubkey. The cooldown end is `block_index + STAKING.COOLDOWN_BLOCKS`; the cooldown length is governance-configurable via the `STAKING.COOLDOWN_BLOCKS` parameter (default 1000 blocks), not a hardcoded constant. Contract-targeted UNSTAKE v1 records do **not** appear here; they are written to `contract_unstakes` with a per-contract cooldown (see below). | | `delegations` | Active and historical DELEGATE records: `signing_pubkey_id`, `activation_block`, `deactivation_block` (set on DELEGATE v2 revoke), `status_id` | -| `validator_rewards` | Per-validator accumulated rewards: `source_id`, `signing_pubkey_id`, `reward_type` (`oracle_round`, `oracle_base`, `oracle_full_node`, `attest_fee`, `anchor_bundle`, or `anchor_archive`), `round_reference`, `amount`, `block_index`. `attest_fee` rows are derived by the indexer during block processing; the oracle labels (`oracle_round` / `oracle_base` / `oracle_full_node`) are reserved by the schema, but no consensus rail mints them today (which one a rail would use depends on whether the full-node reward tier is active: see [COLLECT](../../protocol/actions/collect.md#reward-population-path)); `anchor_bundle` and `anchor_archive` rows are derived by the indexer from the on-chain ANCHOR bytes at or above their own flag-days (`ANCHOR_REWARD_ACTIVATION` / `ARCHIVE_REWARD_ACTIVATION`); rows below those heights were pushed from the hub via `pushvalidatorrewards` and are restored by ANCHOR full-parse recovery rather than re-derived. One `anchor_bundle` row is written per published bundle, keyed on the bundle's snapshot block, not one per chain. | +| `validator_rewards` | Per-validator accumulated rewards: `source_id`, `signing_pubkey_id`, `reward_type` (`oracle_round`, `oracle_base`, `oracle_full_node`, `attest_fee`, `rollcall_publish`, `anchor_bundle`, or `anchor_archive`), `round_reference`, `amount`, `block_index`. `attest_fee` rows are derived by the indexer during block processing; a `rollcall_publish` row is derived at the BTC-side ROLLCALL epoch close, paying the frozen `ROLLCALL_REWARD_AMOUNT` to the elected leader of a rolled epoch (an unrolled epoch mints none), and a rollback deletes it with its derive block (see [ROLLCALL](../../protocol/actions/rollcall.md)); the oracle labels (`oracle_round` / `oracle_base` / `oracle_full_node`) are reserved by the schema, but no consensus rail mints them today (which one a rail would use depends on whether the full-node reward tier is active: see [COLLECT](../../protocol/actions/collect.md#reward-population-path)); `anchor_bundle` and `anchor_archive` rows are derived by the indexer from the on-chain ANCHOR bytes at or above their own flag-days (`ANCHOR_REWARD_ACTIVATION` / `ARCHIVE_REWARD_ACTIVATION`); rows below those heights were pushed from the hub via `pushvalidatorrewards` and are restored by ANCHOR full-parse recovery rather than re-derived. One `anchor_bundle` row is written per published bundle, keyed on the bundle's snapshot block, not one per chain. | | `stake_key_revocations` | Records DELEGATE v2 revocations of the original stake signing key: `source_id`, `signing_pubkey_id`, `deactivation_block`, `action_index`, `block_index`, `status_id`. A later STAKE v2 (higher `action_index`) by the same `(source, pubkey)` clears the revocation. Queried via `createStakeKeyRevocation` / `getStakeKeyRevocation` in `src/db/stakes.js`. | | `reward_claims` | COLLECT records: `source_id`, `amount`, `status_id`, `block_index` | diff --git a/components/node/configuration.md b/components/node/configuration.md index 02ee453e..62d3135c 100644 --- a/components/node/configuration.md +++ b/components/node/configuration.md @@ -275,7 +275,7 @@ These env vars override where xchain-node stores its filesystem state on the hos > | LTC testnet | `testnet4/blocks/` | > | DOGE / LTC regtest | `regtest/blocks/` | > -> The `testnet4/` path above is Litecoin's testnet directory. BTC testnet4 is a separate network, where contract deploys cannot be mined at current miner block sizes. Use LTC or DOGE testnet for contract deployment and testing. +> The `testnet4/` path above is Litecoin's testnet directory. BTC testnet4 is a separate network. > > This matters if you ever try to free up disk by hand-mounting *only* the bare `blocks/` path, e.g. `-v /misc/dogecoin/testnet/blocks:/root/.dogecoin/blocks`. On testnet/regtest the daemon writes to `testnet3/blocks/` (etc.), which that bind does **not** cover, so the mount silently catches nothing and blocks keep accumulating on the default disk. No error is raised. > diff --git a/components/regtest-miner/operations.md b/components/regtest-miner/operations.md index 8ce36cdd..8e3861c0 100644 --- a/components/regtest-miner/operations.md +++ b/components/regtest-miner/operations.md @@ -96,11 +96,17 @@ Return the current mining loop state for operator diagnostics and CI health chec "jsonrpc": "2.0", "result": { "wallet_ready": true, + "wallet_balance": 50.0, + "wallet_funded": true, + "wallet_balance_at": 1718900003000, "mempool_size": 0, + "idle_mine_interval_ms": 0, "blocks_mined": 42, "last_mine_at": 1718900000000, "consecutive_errors": 0, - "mining_paused": false + "mine_failures": 0, + "mining_paused": false, + "mining_started": true }, "id": 2 } @@ -108,12 +114,20 @@ Return the current mining loop state for operator diagnostics and CI health chec | Field | Type | Description | |---|---|---| -| `wallet_ready` | boolean | `true` once `prepareWallet` has completed; gates on mine-readiness, not just server availability | +| `wallet_ready` | boolean | `true` once `prepareWallet` has completed; gates on mine-readiness, not just server availability. It marks startup completion only and stays `true` across a reorg, even one that leaves the wallet unable to fund a send; read `wallet_funded` for that. | +| `wallet_balance` | number or null | The wallet's spendable balance in coin units from the last `getbalance` read. `null` when no read has happened or the last read failed; neither `null` nor `0` counts as funded. | +| `wallet_funded` | boolean | `true` only when `wallet_balance` is a number above 0. Use this, not `wallet_ready`, to learn whether the wallet can still fund a send (for example after a deep `invalidate_block`). | +| `wallet_balance_at` | number or null | Unix timestamp in milliseconds of the last balance read attempt, successful or not; `null` before the first read | | `mining_paused` | boolean | `true` while the auto-mine loop is paused (`pause_mining`, or a `fill_mempool`/`invalidate_block` that never resumed). If mining seems stalled, check this first. | +| `mining_started` | boolean | `true` once wallet preparation finished and the auto-mine loop started; a pause never resets it. A miner still starting reads `mining_paused: true` with `mining_started: false`. | | `mempool_size` | number | Number of transactions currently in the mempool as of the last polling cycle | +| `idle_mine_interval_ms` | number | Current mine-empty heartbeat interval in milliseconds; `0` means mining is driven only by the mempool (see `set_idle_mine_interval`) | | `blocks_mined` | number | Cumulative count of blocks mined by this miner instance since startup | | `last_mine_at` | number or null | Unix timestamp in milliseconds (`Date.now()`) of the most recent successful block mine; `null` if no block has been mined yet in this session | | `consecutive_errors` | number | Running count of consecutive RPC failures (either `getrawmempool` or `generatetoaddress`). Resets to 0 on the next successful RPC call. Useful for alerting: a non-zero value means the miner is retrying with exponential backoff. | +| `mine_failures` | number | Consecutive failed block mines, on a streak of its own: a successful mempool read zeroes `consecutive_errors`, so that counter cannot show a miner that answers RPC but cannot generate a block. Resets on the next mined block; `health` reports it as a stall reason. | + +**Balance freshness.** The balance is read at startup, re-read before `invalidate_block` and `reconsider_block` return, and refreshed by the auto-mine loop at most every 5 seconds, including while mining is paused or `fill_mempool` is running. A single failed read, a node restart included, shows as `wallet_balance: null` and `wallet_funded: false` for one interval and then recovers, so re-poll once before concluding the wallet is drained. A `wallet_balance_at` well over 5 seconds old while `mining_started` is `true` means the refresh has stalled. ### `health` @@ -375,7 +389,21 @@ When the coin node is unreachable (ECONNREFUSED, timeout, DNS failure), the mine ### Pinned Wallet Fee Rate -On a matured regtest chain, the node's `estimatesmartfee` can inflate to absurd rates, pushing funding-send fees above `-maxtxfee` and failing `send_funds`/`fill_mempool` with RPC error -6 (easily misread as UTXO exhaustion). `prepareWallet` therefore pins a fixed wallet fee rate via `settxfee` (best-effort; a daemon that rejects `settxfee` falls back to the estimate path, and startup logs say which happened). +On a matured regtest chain, the node's `estimatesmartfee` can inflate to absurd rates, pushing funding-send fees above `-maxtxfee` and failing `send_funds`/`fill_mempool` with RPC error -6 (easily misread as UTXO exhaustion). `prepareWallet` therefore pins a fixed funding fee ceiling, by whichever mechanism the coin's daemon implements. The ceiling is 0.001 coins/kB, which is the same rate as 100 sat/vB. + +The coin is the part of `NETWORK` before the first `-` (`bitcoin`, `litecoin`, `dogecoin`); a bare `regtest` has no coin. + +| Mechanism | Used by | What `sendtoaddress` sends | +|---|---|---| +| `fee_rate` | Bitcoin, always: Core 31 deleted `settxfee`, so the miner never calls it on BTC. Also Litecoin or a bare `regtest` whose `settxfee` answers method-not-found (-32601), and Litecoin whose `settxfee` stays unreachable through its retries. | Named params `{ address, amount, fee_rate: 100 }` on every call | +| `settxfee` | Litecoin, Dogecoin and a bare `regtest` whose daemon accepts `settxfee 0.001` (the normal path for LTC v0.21 and DOGE v1.14) | Positional `[address, amount]`; the wallet-wide pin caps the fee | +| none (fee estimate) | A daemon that refuses the rate (any other RPC error, or a reply that is not `true`); Dogecoin without `settxfee`, since DOGE v1.14 refuses `fee_rate`; Dogecoin or a bare `regtest` whose `settxfee` stays unreachable | Positional `[address, amount]`; `estimatesmartfee` sets the fee | + +Bitcoin has no fallback to the estimate: falling back would give the ceiling up on exactly the chain that needs it. A `settxfee` that times out, loses its transport or answers warmup (-28) is tried up to three times before it counts as unreachable. + +`settxfee` is in-memory wallet state and is lost when the node restarts, so the miner pins again after it reloads a lost wallet, both on the `send_funds` retry and from the background balance read. A re-pin whose `settxfee` cannot be reached keeps a per-call `fee_rate` already in force. + +Startup logs name the mechanism in force: `via the per-call fee_rate argument`, `via settxfee`, or `funding sends use the fee estimate`. On Bitcoin a funding -6 never comes with a `settxfee` call or log line, so look for the per-call `fee_rate` line instead. ### Error Sanitization diff --git a/components/sdk/actions.md b/components/sdk/actions.md index 655426e0..17376abe 100644 --- a/components/sdk/actions.md +++ b/components/sdk/actions.md @@ -1457,19 +1457,30 @@ await sdk.session(wif).stakeToContract({ Begin unstaking cooldown for a previously staked pubkey. Two flavors with different chain reach: -- **v0:** capability unstake.** BTC-only. Returns the full aggregate capability stake for the pubkey (v1 original + any v2 top-ups). -- **v1:** contract-targeted unstake.** Works on any chain (BTC, LTC, DOGE). Releases the single `(targetContractIndex, signingPubkey, tick)` stake row. +- **v0:** capability unstake.** BTC-only. Without `amount`, returns the full aggregate capability stake for the pubkey (v1 original + any v2 top-ups); with `amount`, only that much enters cooldown and the residual stays staked. +- **v1:** contract-targeted unstake.** Works on any chain (BTC, LTC, DOGE). Releases the single `(targetContractIndex, signingPubkey, tick)` stake row, or only `amount` of it when `amount` is given. ```js -// Capability unstake +// Capability unstake (full sweep) await sdk.unstake({ signingPubkey: 'aabb...' }); +// Partial capability unstake: 250 enters cooldown, the rest stays staked +await sdk.unstake({ signingPubkey: 'aabb...', amount: '250' }); + // Contract-targeted unstake (convenience wrapper forces version: 1) await sdk.session(wif).unstakeFromContract({ signingPubkey: 'aabb...', targetContractIndex: 500, tick: 'MYTOKEN' }); + +// Partial contract-targeted unstake +await sdk.session(wif).unstakeFromContract({ + signingPubkey: 'aabb...', + targetContractIndex: 500, + tick: 'MYTOKEN', + amount: '40' +}); ``` | Field | Required | Description | @@ -1478,13 +1489,16 @@ await sdk.session(wif).unstakeFromContract({ | `signingPubkey` | Yes | Ed25519 pubkey of the stake to release | | `targetContractIndex` | v1 only | `action_index` of the stakeable contract | | `tick` | v1 only | Ticker of the stake row to release | +| `amount` | Optional | Partial-unstake amount; omit for a full sweep. Must be greater than 0, no finer than the token's decimals, and no more than the active staked balance (an over-ask is rejected, never clamped). See [Partial Unstake](../../protocol/actions/unstake.md#partial-unstake-optional-amount) | ### Formats | Version | Format | |---|---| -| 0 | `VERSION\|SIGNING_PUBKEY` (capability) | -| 1 | `VERSION\|SIGNING_PUBKEY\|TARGET_CONTRACT_INDEX\|TICK` (contract-targeted) | +| 0 | `VERSION\|SIGNING_PUBKEY[\|AMOUNT]` (capability) | +| 1 | `VERSION\|SIGNING_PUBKEY\|TARGET_CONTRACT_INDEX\|TICK[\|AMOUNT]` (contract-targeted) | + +The trailing `AMOUNT` is gated by the `PARTIAL_UNSTAKE_COLLECT` protocol change; below it a present `AMOUNT` is ignored and the unstake is a full sweep. --- @@ -1540,17 +1554,27 @@ await sdk.delegate({ version: 3, signingPubkey: 'aabb...', targetContractIndex: ## COLLECT -Collect all accrued validator rewards (BTC chain only). No additional parameters required. +Collect accrued validator rewards (BTC chain only). With no parameters it claims the full unclaimed total; an optional `amount` claims only that much and leaves the remainder pending (see [Partial Claim](../../protocol/actions/collect.md#partial-claim-optional-amount)). ```js +// Claim everything await sdk.collect({}); + +// Claim 25 and leave the remainder pending +await sdk.collect({ amount: '25' }); ``` +| Field | Required | Description | +|---|---|---| +| `amount` | Optional | Partial-claim amount; omit to claim the full unclaimed total | + ### Formats | Version | Format | |---|---| -| 0 | `VERSION` | +| 0 | `VERSION[\|AMOUNT]` | + +The trailing `AMOUNT` is gated by the `PARTIAL_UNSTAKE_COLLECT` protocol change; below it a present `AMOUNT` is ignored and the full total is claimed. --- diff --git a/components/sdk/errors.md b/components/sdk/errors.md index 2c8889d8..93ef6448 100644 --- a/components/sdk/errors.md +++ b/components/sdk/errors.md @@ -272,7 +272,10 @@ Thrown by the `X402Gateway` and `X402Client` during HTTP 402 payment flows. See | `X402_DEPOSIT_EXHAUSTED` | Deposit-scheme: payer's deposited balance is insufficient for one more call | | `X402_STATE_CORRUPT` | An invoice file or deposit ledger file is unreadable | | `X402_NO_USABLE_SCHEME` | Client-side: the gateway's challenge contains no `xchain-send` offer | -| `X402_PRICE_TOO_HIGH` | Client-side: the gateway's offered amount exceeds the client's `maxAmount` | +| `X402_PRICE_TOO_HIGH` | Client-side: the gateway's offered amount exceeds the client's `maxAmount` ceiling for the offered tick | +| `X402_TICK_NOT_ALLOWED` | Client-side: the gateway's offer names a tick the client's `maxAmount` sets no ceiling for (every tick, when `maxAmount` is omitted) | +| `X402_BAD_OFFER` | Client-side: the gateway's `xchain-send` offer has a malformed amount, or no tick or `payTo` | +| `X402_CONFIG` | Construction-time: the gateway or client was built with a missing or malformed option (on the client, a missing session or fetch, or a malformed `maxAmount`) | | `X402_PAYMENT_NOT_ACCEPTED` | Client-side: payment was broadcast but the gateway did not accept it after all retries | --- diff --git a/components/sdk/light-client.md b/components/sdk/light-client.md index 6e9b784a..90eb47b4 100644 --- a/components/sdk/light-client.md +++ b/components/sdk/light-client.md @@ -197,8 +197,10 @@ sdk.light.followForward({ ``` It returns the new rolling trust root (`trusted`), the chain of `adopted` -checkpoints, and stops at the first step that fails (`VALIDATOR_SET_UNVERIFIED@h` -or `QUORUM_FAILED@h`). +checkpoints, and stops at the first step that fails (`ROOTS_NOT_SIGNED@h`, +`VALIDATOR_SET_UNVERIFIED@h` or `QUORUM_FAILED@h`). `ROOTS_NOT_SIGNED@h` is a +step below the checkpoint-commitment flag day, whose roots are outside the +signed bytes and so cannot carry the trust root forward. --- diff --git a/components/sdk/websocket.md b/components/sdk/websocket.md index 0e00a442..2043abee 100644 --- a/components/sdk/websocket.md +++ b/components/sdk/websocket.md @@ -325,6 +325,15 @@ ws.on('resync_required', (msg) => { }); ``` +When the replay comes from an explorer whose indexed tip is behind the chain, its `CATCH_UP_COMPLETE` carries `stale: true` and the client emits `stale_replay`. Every row it replayed is real, and the cursor still moves to the last one, but the replay ends at that tip rather than at the chain's. Later rows arrive live once the explorer catches up, so treat the event as a "you are behind" signal, not a gap to backfill: + +```javascript +ws.on('stale_replay', (msg) => { + // msg.data: { channels, since_action_index, latest_action_index } + showBehindBanner(msg.data.latest_action_index); +}); +``` + ```mermaid sequenceDiagram participant Client diff --git a/components/sync/README.md b/components/sync/README.md index 96bf1652..f669e449 100644 --- a/components/sync/README.md +++ b/components/sync/README.md @@ -7,7 +7,7 @@ xchain-sync is the database replication layer of the XChain Platform. It enables lightweight validators and other consumers to obtain and stay current with both the indexer database and the decoder database without running their own decoder+indexer stacks. The service runs as a single long-lived Node.js process with an embedded Express REST API and a WebSocket server, both served on the same port. -All REST and WebSocket endpoints include a `/:dbType/` path segment. The `dbType` parameter is either `indexer` (full table set, with transparency log) or `decoder` (8 of the 9 decoder DB tables; `mempool_transactions` is excluded because it is non-deterministic across nodes, and the transparency log is not applicable to decoder data). +All REST and WebSocket endpoints include a `/:dbType/` path segment. The `dbType` parameter is either `indexer` (full table set, with transparency log) or `decoder` (8 of the 10 decoder DB tables; `mempool_transactions` is excluded because it is non-deterministic across nodes, `dispenser_extension_undo` is excluded because it is decoder-local reorg bookkeeping, and the transparency log is not applicable to decoder data). The service operates in two modes. In **server mode**, it runs alongside authoritative indexers and decoders on an xchain-node, polls each database for new blocks, and serves the data to remote clients via REST snapshots and real-time WebSocket streaming. In **client mode**, it connects to one or more remote sync servers, downloads a full database snapshot for initial bootstrap, then subscribes to a WebSocket stream for ongoing block-by-block replication into a local MariaDB instance. diff --git a/components/sync/configuration.md b/components/sync/configuration.md index 86213819..a3bd4911 100644 --- a/components/sync/configuration.md +++ b/components/sync/configuration.md @@ -202,7 +202,7 @@ XChain_{TICKER}_{Network}_{Component} In **server mode**, the service reads from the authoritative indexer and decoder databases; the same ones the indexer/decoder write to. -In **client mode**, the service creates replica databases with the same names and schema. Indexer replicas contain an exact copy of the indexer data; decoder replicas contain 8 of the 9 decoder tables (`mempool_transactions` excluded). +In **client mode**, the service creates replica databases with the same names and schema. Indexer replicas contain an exact copy of the indexer data; decoder replicas contain 8 of the 10 decoder tables (`mempool_transactions` and `dispenser_extension_undo` excluded). ## Database Schema @@ -227,14 +227,14 @@ One additional table is used by the sync service itself for indexer replicas onl ### Decoder replicas -Decoder replica databases use a smaller schema derived from the decoder DB. Of the 9 tables in the decoder DB, xchain-sync replicates 8 (`mempool_transactions` is excluded as it is non-deterministic across nodes): +Decoder replica databases use a smaller schema derived from the decoder DB. Of the 10 tables in the decoder DB, xchain-sync replicates 8 (`mempool_transactions` is excluded as it is non-deterministic across nodes, and `dispenser_extension_undo` is decoder-local reorg bookkeeping that stays local): - `blocks`, `transactions`, `transaction_outputs`, `dispensers` - `index_addresses`, `index_transactions`, `pubkeys`, `events` The transparency log table (`sync_meta`) is not created for decoder replicas. -**Note on `dispensers` replication:** `dispensers` is present in the replica but does NOT ride the per-block WebSocket stream. The decoder soft-expires dispenser rows each block (updating `expired_block_index`) and defers hard-purges to a separate job; neither mutation is block-bound. Streaming only inserted rows would cause a follower's count to grow monotonically above the source. Instead, `dispensers` converges to the current source state via full snapshot only. Do not use the per-block row count for `dispensers` as a completeness signal. +**Note on `dispensers` replication:** `dispensers` is present in the replica but does NOT ride the per-block WebSocket stream. The decoder soft-expires dispenser rows each block (updating `expired_block_index`) and hard-purges them later, once that height is reorg-safe-deep, in a step that runs after the block commits; neither change is an insert, so the per-block stream cannot carry it. Streaming only inserted rows would cause a follower's count to grow monotonically above the source. Instead, `dispensers` converges to the current source state via full snapshot only. Do not use the per-block row count for `dispensers` as a completeness signal. ## Connection Pool Configuration diff --git a/components/sync/operations.md b/components/sync/operations.md index cbadc87a..25c29bee 100644 --- a/components/sync/operations.md +++ b/components/sync/operations.md @@ -734,19 +734,20 @@ Decoder data is fully deterministic from the coin node (there are no synthetic c ### Tables replicated -The decoder DB contains 9 tables. xchain-sync replicates 8 of them: +The decoder DB contains 10 tables. xchain-sync replicates 8 of them: | Table | Replicated | Notes | |---|---|---| | `blocks` | Yes | Block-scoped | | `transactions` | Yes | Block-scoped | | `transaction_outputs` | Yes | TX-scoped | -| `dispensers` | Yes (snapshot only) | Excluded from the per-block stream; the decoder prunes expired dispensers each block with a count-reducing DELETE that streaming inserts can't represent, so it converges via the full snapshot instead | +| `dispensers` | Yes (snapshot only) | Excluded from the per-block stream; the decoder soft-expires dispensers inside the block transaction (an UPDATE that stamps `expired_block_index`) and, after the block commits, hard-purges rows soft-expired at a reorg-safe depth with a DELETE. Neither change is an insert the stream can carry, so the replica converges via the initial snapshot and then a periodic full-table reconcile (`DISPENSERS_RECONCILE_EVERY`, `DISPENSERS_RECONCILE_MAX_INTERVAL_MS`) | | `index_addresses` | Yes | Append-only index | | `index_transactions` | Yes | Append-only index | | `pubkeys` | Yes | Append-only index | | `events` | Yes | Operational/logging | | `mempool_transactions` | **No** | Excluded: non-deterministic across nodes | +| `dispenser_extension_undo` | **No** | Excluded: decoder-local reorg bookkeeping (each dispenser row's values before a block's first expiration extend) | Additionally, the transparency log table (`sync_meta`) is **not** created for decoder replicas; it is indexer-only. diff --git a/components/utxo-tracker/README.md b/components/utxo-tracker/README.md index f8d7e1a5..fa267d2d 100644 --- a/components/utxo-tracker/README.md +++ b/components/utxo-tracker/README.md @@ -24,7 +24,7 @@ In addition to confirmed block data, the tracker maintains a separate in-memory - **Batch writes**: LevelDB writes batched in groups of 200 blocks for throughput efficiency with atomic commit - **Two-pass transaction processing**: outputs inserted before inputs within each block, correctly handling intra-block spends - **Multi-chain support**: Bitcoin, Litecoin, and Dogecoin today, on mainnet, testnet, and regtest -- **AuxPoW block parsing**: Dogecoin and Litecoin HogEx block header stripping for correct decoding +- **AuxPoW block parsing**: Dogecoin AuxPoW block header stripping for correct decoding; Litecoin MWEB/HogEx transactions are handled separately by a per-transaction flag strip - **Bootstrap support**: compressed tar archive backup and restore for fast initial sync without re-scanning the full chain - **REST + JSON-RPC API**: dual interface for UTXO queries, balance lookups, address info, and bootstrap operations - **Rolling ETA**: 1000-block rolling window with day/hour/minute display for sync progress estimation diff --git a/components/utxo-tracker/architecture.md b/components/utxo-tracker/architecture.md index e261b517..cc37b1be 100644 --- a/components/utxo-tracker/architecture.md +++ b/components/utxo-tracker/architecture.md @@ -53,7 +53,7 @@ flowchart TD | `src/store/level_up_db.js` | `LevelUpStore` | LevelDB abstraction: binary key encoding/decoding, batch transactions, range scans, all 12 prefix type operations | | `src/chain/blockchain_connector.js` | `BlockchainConnector` | HTTP JSON-RPC facade: wires transport, batch fetching, block queries, mempool tracking, AuxPoW codec, RPC helpers, and connection pooling | | `src/chain/blockchain_connector/` | (multiple) | Part modules: auxpow_codec (AuxPoW header encoding and decoding), batch_fetch (parallel block and hash fetching), block_queries (blockchain info and block data retrieval), rpc_helpers (RPC error handling and node health tracking), transport_and_mempool (HTTP client and mempool synchronization), constants (logging configuration) | -| `src/chain/XChainBlockDecoder.js` | `XChainBlockDecoder` | Block and transaction parser: standard Bitcoin blocks, AuxPoW header stripping for Dogecoin/Litecoin HogEx | +| `src/chain/XChainBlockDecoder.js` | `XChainBlockDecoder` | Block and transaction parser: standard Bitcoin blocks, AuxPoW header stripping for Dogecoin, per-transaction MWEB/HogEx flag stripping for Litecoin | | `src/chain/crypto_networks.js` | `CryptoNetworks` | Network parameter lookup: maps network names to bitcoinjs-lib network objects for 9 network variants | | `src/common/util.js` | None | Utility functions: timing, hex/uint8 conversion, formatting | | `src/chain/bufferutils.js` | `BufferReader`, `BufferWriter` | Binary buffer reading/writing: UInt8/16/32/64LE, VarInt, slices | @@ -139,11 +139,11 @@ Within each block, transactions are processed in two passes: ### Concurrent Block Prefetch -To minimize RPC idle time, the tracker maintains a prefetch queue of up to `PREFETCH_SIZE` (10) blocks. For non-AuxPoW chains, block hashes and hex data are fetched in two batch HTTP requests. For AuxPoW chains (Dogecoin/Litecoin with HogEx), blocks are fetched individually because AuxPoW headers must be stripped before parsing. +To minimize RPC idle time, the tracker maintains a prefetch queue of up to `PREFETCH_SIZE` (10) blocks. For non-AuxPoW chains (Bitcoin, Litecoin), block hashes and hex data are fetched in two batch HTTP requests. For the AuxPoW chain (Dogecoin), the batch also fetches each block header, and the AuxPoW section is stripped from each block before parsing. ### Batch Writes -LevelDB writes are accumulated in an in-memory transaction (Map of put/del operations) and flushed atomically via `db.batch()` every 200 blocks or when the tracker reaches the chain tip (or when heap usage exceeds 2 GB). This minimizes write amplification and ensures all-or-nothing semantics; a crash mid-batch loses at most 200 blocks of progress, which are re-indexed on restart. +LevelDB writes are accumulated in an in-memory transaction (Map of put/del operations) and flushed atomically via `db.batch()` every 200 blocks or when the tracker reaches the chain tip (or earlier, when the V8 heap exceeds `HEAP_FLUSH_THRESHOLD_MB`: by default an eighth of the tracker's memory budget, kept between 256 MB and 2048 MB; see [Memory Budget](configuration.md#memory-budget)). This minimizes write amplification and ensures all-or-nothing semantics; a crash mid-batch loses at most 200 blocks of progress, which are re-indexed on restart. ## Reorg Handling diff --git a/components/utxo-tracker/configuration.md b/components/utxo-tracker/configuration.md index 6f5e5455..f259eda7 100644 --- a/components/utxo-tracker/configuration.md +++ b/components/utxo-tracker/configuration.md @@ -30,7 +30,7 @@ BULK_SYNC_RAM_BUDGET=768 | Variable | Description | Default | |---|---|---| -| `AUX_POW` | Enable AuxPoW block header stripping (required for Dogecoin and Litecoin HogEx blocks) | `undefined` (falsy) | +| `AUX_POW` | Legacy; read but ignored. AuxPoW block header stripping is chosen from `NETWORK` (Dogecoin only); Litecoin MWEB/HogEx handling is a separate per-transaction flag strip that no variable controls | `undefined` (falsy) | | `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` | diff --git a/components/vm/architecture.md b/components/vm/architecture.md index 9ebc79c3..e466075c 100644 --- a/components/vm/architecture.md +++ b/components/vm/architecture.md @@ -54,8 +54,8 @@ flowchart TD | `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 | -| `lint-core.js` | Dependency-light (no isolated-vm) acorn-only consensus validation, exporting `lintSource()`, `analyzeContract()`, and per-rule detectors: `findBannedMathCalls`, `findBannedLiterals`, `findBannedAsync`, `findBannedGenerator` (`function*`/`yield`), `findBannedWasm` (global `WebAssembly` reference), `findBannedRest` (unmeterable rest positions), `findBannedWith` (the `with` statement), `findBannedExponentiation` (`**`/`**=`, hardened-only), `findBannedProtoMethods` (advisory, name-only match, never consensus), `findBannedStrippedGlobals` (advisory, reads of sandbox-deleted globals, never consensus), `findReservedControlBinding` (`CONTRACT_WRAPPER` bindings, hardened-only), and `codeSizeBytes`/`MAX_CODE_SIZE` for the 64KiB deploy cap. Under `VM_LINT_HARDENING`, the Math ban widens from the five named transcendentals to the complement of the `SAFE_MATH_MEMBERS` whitelist. Under `LINT_GLOBAL_ALIAS` (the `globalAlias` option), the banned-math, banned-async and banned-wasm detectors also treat sloppy-mode `this` and the `globalThis.globalThis…` self-reference chain as the global object. Under `LINT_OPTIONAL_CHAIN` (the `optionalChain` option), the same three detectors also look through a parenthesized optional chain, so `(globalThis?.Math).pow(2, 3)` reads as a Math call. Holds `CONSENSUS_RULES` (the 10-member set of deploy-blocking rule names: `invalid-type`, `unsupported-syntax`, `reserved-identifier`, `banned-math`, `banned-literal`, `banned-async`, `banned-generator`, `banned-rest`, `banned-wasm`, `banned-with`) and `lintSource()`. Its detectors and constants live in helper modules under `lint-core/` and are re-exported here. Vendored byte-for-byte into `xchain-sdk/src/contract/lint-core.js`; a CI SHA-256 parity guard fails on drift. | -| `syntax.js` | Deploy-time validation: V8 syntax check (throwaway isolate, the one step requiring isolated-vm; blocks via a separate early return and is not itself a `CONSENSUS_RULES` member), then delegates all acorn-coverable consensus rules to `lint-core.lintSource()`. Re-exports `findBannedMathCalls`, `findBannedLiterals`, `findBannedAsync`, `findBannedGenerator`, `findBannedWasm`, and `findBannedRest` for backward compatibility. `validateSyntax(code, opts)` threads ten independent consensus-gate flags, and a caller that leaves one out enforces that rule at every height: `enforceBannedAsync` (the `VM_BANNED_ASYNC` flag-day), `enforceLintHardening` (the `VM_LINT_HARDENING` flag-day, widening the Math ban and adding the exponentiation/reserved-binding/dynamic-import rules), `enforceLintGlobalAlias` (the per-coin [`LINT_GLOBAL_ALIAS_ACTIVATION`](../../protocol/protocol-activation.md#vm-gates-service-carried) height gate, armed at genesis on every network, making banned-math, banned-async and banned-wasm resolve sloppy-mode `this` and the `globalThis` self-reference chain as the global object), `enforceLintOptionalChain` (the per-coin [`LINT_OPTIONAL_CHAIN_ACTIVATION`](../../protocol/protocol-activation.md#vm-gates-service-carried) height gate, unarmed on mainnet, forwarded to lint-core as `optionalChain` so the banned-math, banned-async and banned-wasm rules look through a parenthesized optional chain such as `(globalThis?.Math).pow(2, 3)`), `enforceBannedGenerator`/`enforceBannedWasm` (two flags on the Package 3 per-coin sandbox height gate, active from genesis on testnet/regtest), `enforceBannedRest` (the separate [`REST_PATTERN_METER`](../../protocol/flag-days.md) gate, likewise active from genesis on testnet/regtest), `enforceBannedWith` (the `banned-with` rule on its own per-coin [`LINT_BANNED_WITH_ACTIVATION`](../../protocol/protocol-activation.md#where-the-values-live) height gate), `enforceLintNestingDepth` (the pre-parse nesting-depth rule on the per-coin `VM_LINT_NESTING_DEPTH_ACTIVATION` height gate, unarmed on public networks and active from genesis on regtest), and `enforceLintDestructure` (the per-coin [`LINT_DESTRUCTURE_ACTIVATION`](../../protocol/protocol-activation.md#where-the-values-live) height gate). Each defaults to `true` for author-facing callers so a from-genesis replay reproduces the historical deploy verdict at every activation. | +| `lint-core.js` | Dependency-light (no isolated-vm) acorn-only consensus validation, exporting `lintSource()`, `analyzeContract()`, and per-rule detectors: `findBannedMathCalls`, `findBannedLiterals`, `findBannedAsync`, `findBannedGenerator` (`function*`/`yield`), `findBannedWasm` (global `WebAssembly` reference), `findBannedRest` (unmeterable rest positions), `findBannedWith` (the `with` statement), `findBannedExponentiation` (`**`/`**=`, hardened-only), `findBannedProtoMethods` (advisory, name-only match, never consensus), `findBannedStrippedGlobals` (advisory, reads of sandbox-deleted globals, never consensus), `findReservedControlBinding` (`CONTRACT_WRAPPER` bindings, hardened-only), and `codeSizeBytes`/`MAX_CODE_SIZE` for the 64KiB deploy cap. Under `VM_LINT_HARDENING`, the Math ban widens from the five named transcendentals to the complement of the `SAFE_MATH_MEMBERS` whitelist. Under `LINT_GLOBAL_ALIAS` (the `globalAlias` option), the banned-math, banned-async and banned-wasm detectors also treat sloppy-mode `this` and the `globalThis.globalThis…` self-reference chain as the global object. Under `LINT_OPTIONAL_CHAIN` (the `optionalChain` option), the same three detectors also look through a parenthesized optional chain, so `(globalThis?.Math).pow(2, 3)` reads as a Math call. Under `LINT_DESTRUCTURE` (the `destructure` option), the same three detectors also catch a banned global read through an object destructuring pattern, such as `const { Promise } = globalThis` or `const { pow } = Math`. Holds `CONSENSUS_RULES` (the 11-member set of deploy-blocking rule names: `invalid-type`, `nesting-depth`, `unsupported-syntax`, `reserved-identifier`, `banned-math`, `banned-literal`, `banned-async`, `banned-generator`, `banned-rest`, `banned-wasm`, `banned-with`) and `lintSource()`. Its detectors and constants live in helper modules under `lint-core/` and are re-exported here. Vendored byte-for-byte into `xchain-sdk/src/contract/lint-core.js`; a CI SHA-256 parity guard fails on drift. | +| `syntax.js` | Deploy-time validation: first the pre-parse nesting-depth check (`nestingDepthFinding`, more than 64 nested delimiters is rejected), then the V8 syntax check (throwaway isolate, the one step requiring isolated-vm; blocks via a separate early return and is not itself a `CONSENSUS_RULES` member), then delegates all acorn-coverable consensus rules to `lint-core.lintSource()`. Re-exports `findBannedMathCalls`, `findBannedLiterals`, `findBannedAsync`, `findBannedGenerator`, `findBannedWasm`, and `findBannedRest` for backward compatibility. `validateSyntax(code, opts)` threads ten independent consensus-gate flags, and a caller that leaves one out enforces that rule at every height: `enforceBannedAsync` (the `VM_BANNED_ASYNC` flag-day), `enforceLintHardening` (the `VM_LINT_HARDENING` flag-day, widening the Math ban and adding the exponentiation/reserved-binding/dynamic-import rules), `enforceLintGlobalAlias` (the per-coin [`LINT_GLOBAL_ALIAS_ACTIVATION`](../../protocol/protocol-activation.md#vm-gates-service-carried) height gate, armed at genesis on every network, making banned-math, banned-async and banned-wasm resolve sloppy-mode `this` and the `globalThis` self-reference chain as the global object), `enforceLintOptionalChain` (the per-coin [`LINT_OPTIONAL_CHAIN_ACTIVATION`](../../protocol/protocol-activation.md#vm-gates-service-carried) height gate, unarmed on mainnet, forwarded to lint-core as `optionalChain` so the banned-math, banned-async and banned-wasm rules look through a parenthesized optional chain such as `(globalThis?.Math).pow(2, 3)`), `enforceBannedGenerator`/`enforceBannedWasm` (two flags on the Package 3 per-coin sandbox height gate, active from genesis on testnet/regtest), `enforceBannedRest` (the separate [`REST_PATTERN_METER`](../../protocol/flag-days.md) gate, likewise active from genesis on testnet/regtest), `enforceBannedWith` (the `banned-with` rule on its own per-coin [`LINT_BANNED_WITH_ACTIVATION`](../../protocol/protocol-activation.md#where-the-values-live) height gate, unarmed on mainnet, armed on testnet at `BTC:testnet` 156230, `LTC:testnet` 4912984 and `DOGE:testnet` 67989993, and active from genesis on regtest), `enforceLintNestingDepth` (the pre-parse nesting-depth rule on the per-coin `VM_LINT_NESTING_DEPTH_ACTIVATION` height gate, unarmed on mainnet, armed on testnet at `BTC:testnet` 156230, `LTC:testnet` 4912984 and `DOGE:testnet` 67989993, and active from genesis on regtest), and `enforceLintDestructure` (the per-coin [`LINT_DESTRUCTURE_ACTIVATION`](../../protocol/protocol-activation.md#where-the-values-live) height gate, unarmed on mainnet, armed on testnet at `BTC:testnet` 156230, `LTC:testnet` 4912984 and `DOGE:testnet` 67989993, and active from genesis on regtest, forwarded to lint-core as `destructure`). Each defaults to `true` for author-facing callers so a from-genesis replay reproduces the historical deploy verdict at every activation. | | `errors.js` | ContractRevertError (thrown by `revert()`/`require()`) and GasExhaustedError (thrown when gas ceiling exceeded) | ## JSON Bridge Protocol diff --git a/components/vm/configuration.md b/components/vm/configuration.md index 932022dd..3a76d64d 100644 --- a/components/vm/configuration.md +++ b/components/vm/configuration.md @@ -101,9 +101,9 @@ These limits are hardcoded in the VM and not configurable: |---|---|---| | Log entries per execution | 100 | `collector.js` | | Log entry size | 1,024 bytes UTF-8 (truncated with `...(truncated)` marker) | `collector.js` | -| Return value size | 65,536 UTF-16 code units (truncated) | `index.js` | +| Return value size | 65,536 UTF-16 code units (truncated) | `index/runtime/contract_io.js` | | Throwaway isolate memory | 8 MB | `isolate.js`, `syntax.js` | -| Binary expression metering depth | 10 | `metering.js` | +| Binary expression metering depth | 10 | `metering/visitor_instrumentation.js` | The return-value cap is measured in UTF-16 code units, with `String.length` over the serialized value, not in UTF-8 bytes: a non-ASCII return at the cap can occupy up to roughly 196,608 UTF-8 bytes, so size a return against code units rather than the encoded size. Truncation is a UTF-16 `substring`, so a value cut in the middle of a surrogate pair ends with an unpaired surrogate. The log-entry limit directly above is genuinely byte-measured and is the contrasting case. diff --git a/components/vm/operations.md b/components/vm/operations.md index 830e4b5f..8f5f5ba5 100644 --- a/components/vm/operations.md +++ b/components/vm/operations.md @@ -144,7 +144,9 @@ The indexer uses database savepoints to ensure these guarantees extend to the pe ## Syntax Validation (Deploy-Time) -Before a contract is deployed, `vm.validateSyntax(code, opts)` runs the following checks in order. The indexer passes the per-block activation flags that `resolveLintFlags` in `deploy/lint.js` resolves for the DEPLOY's block. Any flag omitted from `opts` defaults on, which is the author-facing preflight behavior rather than the replay-safe deploy verdict, so a consensus caller must pass each gate it resolves. The V8 syntax check blocks a deploy via a separate early return and is not itself a `lint-core.CONSENSUS_RULES` member; checks 2-10 below are all deploy-blocking consensus rules (`invalid-type`, `unsupported-syntax`, `reserved-identifier`, `banned-math`, `banned-literal`, `banned-async`, `banned-generator`, `banned-rest`, `banned-wasm`, `banned-with`), and all must pass or the DEPLOY action is rejected: +Before a contract is deployed, `vm.validateSyntax(code, opts)` runs the following checks in order. The indexer passes the per-block activation flags that `resolveLintFlags` in `deploy/lint.js` resolves for the DEPLOY's block. Any flag omitted from `opts` defaults on, which is the author-facing preflight behavior rather than the replay-safe deploy verdict, so a consensus caller must pass each gate it resolves. The V8 syntax check blocks a deploy via a separate early return and is not itself a `lint-core.CONSENSUS_RULES` member. The nesting-depth pre-check and checks 2-10 below are the 11 deploy-blocking consensus rules (`invalid-type`, `nesting-depth`, `unsupported-syntax`, `reserved-identifier`, `banned-math`, `banned-literal`, `banned-async`, `banned-generator`, `banned-rest`, `banned-wasm`, `banned-with`), and all must pass or the DEPLOY action is rejected. + +Before check 1, a **nesting-depth pre-check** (consensus-gated, its own per-coin `VM_LINT_NESTING_DEPTH_ACTIVATION` height gate, `enforceLintNestingDepth`) tokenizes the source and rejects it once its `(`, `[`, `{` and `${` delimiters nest more than 64 deep (`MAX_NESTING_DEPTH`), with `nesting depth exceeds limit (64) at line N`. Delimiters inside comments, strings, regular expressions and template text do not count. The gate is unarmed on BTC, LTC and DOGE mainnet, armed on testnet at a per-chain local block height (`BTC:testnet` 156230, `LTC:testnet` 4912984, `DOGE:testnet` 67989993), and genesis-active on regtest; the `xchain-lint` CLI and the SDK linter enforce it by default. 1. **V8 syntax check**: compiles the code in a throwaway 8 MB isolate to catch syntax errors (the only step requiring `isolated-vm`) 2. **Acorn metering pass**: runs `meterCode()` to ensure acorn can parse the source (effective ES2020 ceiling) @@ -155,11 +157,12 @@ Before a contract is deployed, `vm.validateSyntax(code, opts)` runs the followin 7. **Banned generator check** (consensus-gated, Pkg 3 sandbox): rejects `function*`, generator methods, and any `yield`; live from genesis on testnet/regtest. 8. **Banned rest-pattern check** (consensus-gated, its own [`REST_PATTERN_METER`](../../protocol/flag-days.md) gate, not the Pkg 3 one): rejects a rest pattern in the four positions the metering transform cannot charge, because it charges a rest destructure by wrapping the source expression and these have none: a rest parameter in a function parameter list, a nested rest inside a destructuring pattern, a catch-clause rest, and a rest in a `for-of`/`for-in` loop head. Live from genesis on testnet/regtest, and on mainnet at/after that gate's block time; the `xchain-lint` CLI and the SDK linter enforce it today by default. 9. **Banned WebAssembly check** (consensus-gated, Pkg 3 sandbox): rejects any reference to the global `WebAssembly`, bare or [global-object-qualified](#global-object-spellings); live from genesis on testnet/regtest. -10. **Banned `with` statement check** (consensus-gated, its own per-coin [`LINT_BANNED_WITH_ACTIVATION`](../../protocol/protocol-activation.md#where-the-values-live) height gate, `enforceBannedWith`, not the Pkg 3 one): rejects any `with` statement. A `with` block resolves free identifiers against an arbitrary object at runtime, which bypasses the identifier-based deploy bans and the metering rewrite; read the properties through the object instead. Live from genesis on regtest and unarmed on mainnet and testnet until an operator arms a height; the `xchain-lint` CLI and the SDK linter enforce it today by default. +10. **Banned `with` statement check** (consensus-gated, its own per-coin [`LINT_BANNED_WITH_ACTIVATION`](../../protocol/protocol-activation.md#where-the-values-live) height gate, `enforceBannedWith`, not the Pkg 3 one): rejects any `with` statement. A `with` block resolves free identifiers against an arbitrary object at runtime, which bypasses the identifier-based deploy bans and the metering rewrite; read the properties through the object instead. Unarmed on BTC, LTC and DOGE mainnet, armed on testnet at a per-chain local block height (`BTC:testnet` 156230, `LTC:testnet` 4912984, `DOGE:testnet` 67989993), and live from genesis on regtest; the `xchain-lint` CLI and the SDK linter enforce it today by default. ```mermaid flowchart TD START["DEPLOY action: code"] + S0{"Nesting-depth pre-check (VM_LINT_NESTING_DEPTH_ACTIVATION gate)"} S1{"1. V8 syntax check (throwaway isolate)"} S2{"2. Acorn metering pass"} S3{"3. Reserved identifier check"} @@ -173,7 +176,9 @@ flowchart TD ACCEPT["DEPLOY accepted"] REJECT["DEPLOY rejected"] - START --> S1 + START --> S0 + S0 -->|"nesting deeper than 64, gate active"| REJECT + S0 -->|"within the limit, or gate not active"| S1 S1 -->|"syntax error"| REJECT S1 -->|"compiles"| S2 S2 -->|"parse failure"| REJECT @@ -204,6 +209,8 @@ Checks 4, 6 and 9 match a banned global read through the global object as well a Under the [`LINT_OPTIONAL_CHAIN_ACTIVATION`](../../protocol/protocol-activation.md#vm-gates-service-carried) gate (`enforceLintOptionalChain`), checks 4, 6 and 9 also see through a parenthesized optional chain, so `(globalThis?.globalThis).Promise`, `(this?.globalThis)?.WebAssembly` and `(globalThis?.Math).pow(2, 3)` are rejected exactly like their plain spellings. Below the gate, those three spellings lint clean as they always have: on mainnet, and on testnet below that chain's height. An unparenthesized `globalThis?.Promise` is already rejected on every network and does not depend on the gate. The gate is unarmed on BTC, LTC and DOGE mainnet, armed on testnet at each chain's own local block height (`BTC:testnet` 155001, `LTC:testnet` 4906040, `DOGE:testnet` 67962387), and genesis-active on regtest. The contract simulator's deploy gate and the execute-time lint read the same per-coin arming as the indexer's deploy verdict, so they agree with it and never enforce the rule below a chain's height. +Under the [`LINT_DESTRUCTURE_ACTIVATION`](../../protocol/protocol-activation.md#where-the-values-live) gate (`enforceLintDestructure`), checks 4, 6 and 9 also catch a banned global read through an object destructuring pattern, in a declaration or an assignment. `const { pow } = Math`, `const { Math: { pow } } = globalThis`, `const { Promise } = globalThis`, `({ Promise: P } = this)` and `const { WebAssembly } = globalThis` are rejected exactly like `Math.pow`, `Promise` and `WebAssembly`, while a destructured SafeMath member such as `const { floor } = Math` stays legal. The gate is unarmed on BTC, LTC and DOGE mainnet, armed on testnet at each chain's own local block height (`BTC:testnet` 156230, `LTC:testnet` 4912984, `DOGE:testnet` 67989993), and genesis-active on regtest. Below it, the deploy verdict accepts those spellings, while the `xchain-lint` CLI and the SDK linter reject them by default. + ## Troubleshooting ### isolated-vm won't compile diff --git a/components/wallet/README.md b/components/wallet/README.md index 89e6077e..0a98ad25 100644 --- a/components/wallet/README.md +++ b/components/wallet/README.md @@ -5,13 +5,13 @@ ## What is xchain-wallet -xchain-wallet is the reference self-custodial wallet for the XChain Platform. It runs as a browser web app, a Chrome MV3 extension (popup + full-screen + side panel), a desktop application (Windows / macOS / Linux), and a Capacitor mobile app (Android now, iOS later), all from a single React codebase published as a pnpm workspace. The wallet supports Bitcoin, Litecoin, and Dogecoin at launch, with additional chains added as the platform adds them. It consumes [xchain-sdk](../sdk/) as its only data and signing layer and never duplicates SDK functionality. +xchain-wallet is the reference self-custodial wallet for the XChain Platform. It runs as a browser web app, a Chrome MV3 extension (popup + full-screen + side panel), a desktop application (Windows / macOS / Linux), and a Capacitor mobile app (Android shipped, iOS pending App Store release), all from a single React codebase published as a pnpm workspace. The wallet supports Bitcoin, Litecoin, and Dogecoin at launch, with additional chains added as the platform adds them. It consumes [xchain-sdk](../sdk/) as its only data and signing layer and never duplicates SDK functionality. The wallet implements every XChain feature exposed by the platform: all 32 user-encodable ACTION types, a built-in DEX surface, encrypted messaging (ECIES / ECDH / AES), smart contracts, BTC staking + delegation, classical n-of-m + MuSig2 multisig, cross-chain flows, dispensers, parimutuel betting markets, on-chain governance voting, a `window.xchain` dApp bridge, and air-gapped PSBT signing via animated QR transport. ## Features -- **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 +- **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 pending App Store release); all share `@xchain-wallet/core` for routes, components, flows, and signers - **All 32 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, XBRIDGE. COLLECT is BTC-only; the other 31 are offered on all three chains - **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 @@ -26,7 +26,7 @@ The wallet implements every XChain feature exposed by the platform: all 32 user- - **Air-gapped PSBT signing**: BIP21 / multisig PSBT envelope / chunked PSBT-QR; QR scanner + AnimatedQrFrames for offline cosigner round-trips; `prefers-reduced-motion` honored with manual frame stepping - **Sign-screen safety rails**: plain-English action decoder shows `to` / `amount` / `asset` as the user typed them, even if the encoder fabricates output; per-action expectation summaries; explicit user confirmation - **Onboarding and recovery**: create / import / Counterwallet-migrate / dry-run-restore / discover-used-addresses gap-limit scan; view-private-key + export-WIF gated by a "Before you continue" warning in an unlocked session (no password re-entry; a locked wallet must be unlocked first) -- **Lock / unlock / auto-lock**: Argon2id-derived session key cached in `chrome.storage.session` (extension) or in-memory (web/desktop); foreground auto-lock; manual lock action; OS keychain auto-unlock on desktop +- **Lock / unlock / auto-lock**: Argon2id-derived session key cached in `chrome.storage.session` (extension), an OS-keychain-encrypted file (desktop, when a keychain backend is available) or in-memory (web, and desktop without a keychain); foreground auto-lock, plus a background auto-lock on the extension and desktop; manual lock action; OS keychain auto-unlock on desktop - **i18n + a11y**: string registry under `core/src/i18n`; static a11y audit gate (button label / img alt / input label / textarea label / div-onclick role+tabIndex) blocks regressions in CI; WCAG 2.2 AA target for the external audit - **Reproducible builds**: Level-2 reproducibility of the pre-signing Linux desktop bundle: digest-pinned base image, frozen lockfile, `SOURCE_DATE_EPOCH` from `git log`, `RELEASE_HASHES.txt` SHA-256 manifest, 18-rule static repro-build audit - **URI scheme handling**: registered handlers for `bitcoin:`, `dogecoin:`, `litecoin:`, and `xchain:` URIs across all three shells @@ -78,7 +78,7 @@ The repository depends on a sibling `xchain-sdk` checkout, both `packages/web/pa pnpm --filter @xchain-wallet/web dev ``` -Vite serves the wallet at `http://localhost:5173`. The web shell is mobile-responsive, open it on a phone for the mobile UX. A native Android app, the Capacitor wrapper of this web build, has shipped (`v0.336.0`); a native iOS app is still on the post-launch roadmap. +Vite serves the wallet at `http://localhost:5173`. The web shell is mobile-responsive, open it on a phone for the mobile UX. A native Android app, the Capacitor wrapper of this web build, has shipped (`v0.336.0`); a native iOS app, the same Capacitor wrapper, is built and pending App Store release. ### Build the Chrome extension diff --git a/components/wallet/architecture.md b/components/wallet/architecture.md index efa86b17..cfe0a9a4 100644 --- a/components/wallet/architecture.md +++ b/components/wallet/architecture.md @@ -8,7 +8,7 @@ This document describes how the wallet is organized at the package level, how th ## Four shells, one core -The wallet is a pnpm workspace with four product surfaces (a browser web app, a Chrome MV3 extension, an Electron desktop app, and a Capacitor mobile app for Android now, iOS later) and a shared `@xchain-wallet/core` that contains everything *except* the host-specific glue. +The wallet is a pnpm workspace with four product surfaces (a browser web app, a Chrome MV3 extension, an Electron desktop app, and a Capacitor mobile app, shipped on Android and pending App Store release on iOS) and a shared `@xchain-wallet/core` that contains everything *except* the host-specific glue. ``` @xchain-wallet/core @@ -38,7 +38,7 @@ Each shell wraps the core in a small amount of host-specific glue: |---|---|---|---| | `@xchain-wallet/web` | Vite SPA + `hostBridge.js` | IndexedDB | in-memory only | | `@xchain-wallet/extension` | service worker + content script + injected provider + popup + approval window | `chrome.storage.local` | `chrome.storage.session` | -| `@xchain-wallet/desktop` | Electron main / preload / renderer | encrypted file via main process | OS keychain (optional) | +| `@xchain-wallet/desktop` | Electron main / preload / renderer | encrypted file via main process | OS-keychain-encrypted `session.bin` when a keychain backend is available, else in-memory | | `@xchain-wallet/mobile` | Capacitor WebView over the built `@xchain-wallet/web` SPA, plus native glue of its own: the `XChainVault` plugin and a biometric sidecar on both Android and iOS | app-private file written by the native vault plugin, encrypted under an OS-keystore key (Android Keystore / iOS Keychain, device-bound and excluded from backups) | in-memory only (same as web) | Every route renders the same React tree across shells; only the host bridge differs. Mobile packages the web shell's own build and inherits its bridge and its in-memory session behavior, but it does **not** inherit the web vault. WebView storage is evictable, and in an installed app whose backup posture is deliberately off it would hold the only copy of the vault in existence, so the web build detects the native shell at boot and swaps its IndexedDB and `localStorage` backends for the `XChainVault` plugin that ships in `@xchain-wallet/mobile` (`packages/web/src/storage/backends.js`, `CapacitorStorageBackend.js`). A native shell whose plugin failed to register is a blocking error, never a silent fallback to WebView storage. @@ -156,7 +156,7 @@ Each shell maps this same logical schema onto a different physical store: | Logical store | Web | Extension | Desktop | Mobile | |---|---|---|---|---| | Vault (encrypted seed, accounts, addresses, contacts, settings, connected sites) | IndexedDB | `chrome.storage.local` | Electron `userData` (encrypted file) | app-private file via the native `XChainVault` plugin, under an OS-keystore key (Android Keystore / iOS Keychain) | -| Session (master key after unlock) | in-memory only | `chrome.storage.session` (cleared on browser close) | OS keychain (with consent) or in-memory | in-memory only | +| Session (master key after unlock) | in-memory only | `chrome.storage.session` (cleared on browser close) | OS-keychain-encrypted `session.bin` when a keychain backend is available, else in-memory | in-memory only | | Ephemeral metadata (toast state, demo flag, last-view) | `localStorage` | `localStorage` | `localStorage` | `localStorage` (WebView) | The mobile column is not the web column: the vault blob, the kdfParams record and the pre-unlock guard record all sit in native slots rather than in WebView storage. Only genuinely ephemeral UI state is left in the WebView's own `localStorage`, where losing it costs nothing. diff --git a/components/wallet/build-release.md b/components/wallet/build-release.md index c59f224a..9553248a 100644 --- a/components/wallet/build-release.md +++ b/components/wallet/build-release.md @@ -21,7 +21,7 @@ All packages in the repository ship at the **same version number**: The `test/e2e/package.json` (`@xchain-wallet/e2e`) is **excluded** from synchronized versioning. It tracks its own Playwright suite version independently and is not bumped in the release bump procedure. -Rationale: users running the web app, the extension, and the desktop app need an obvious way to confirm they're on the same build. Every shell's About screen surfaces its own `package.json.version`, so synchronized versions let users diff `1.0.0-rc.6-extension` vs `1.0.0-rc.6-web` vs `1.0.0-rc.6-desktop` and immediately know they're on the same codebase. +Rationale: users running the web app, the extension, and the desktop app need an obvious way to confirm they're on the same build. Every shell's About screen surfaces its own `package.json.version`, so synchronized versions let users check that the extension, the web app, and the desktop app all show the same `X.Y.Z` and immediately know they're on the same codebase. `CHANGELOG.md` at the repo root is authoritative; sub-packages don't maintain their own changelogs. @@ -39,12 +39,13 @@ Chrome's `version` field rejects semver prerelease tags like `1.0.0-rc.6`. The w | Wallet semver | Chrome `version` | Chrome `version_name` | |---|---|---| +| `0.341.2` | `0.341.2` | `0.341.2` | | `1.0.0` | `1.0.0` | `1.0.0` | | `1.0.0-rc.1` | `0.1.0.1` | `1.0.0-rc.1` | | `1.0.0-rc.6` | `0.1.0.6` | `1.0.0-rc.6` | | `1.2.3` | `1.2.3` | `1.2.3` | -The leading `0` on prereleases pins them strictly below every stable tuple with M ≥ 1, so the CWS upgrade ordering stays monotonic across the RC → GA cut. Stable bumps are unchanged (`1.2.3` → `1.2.3`). +The wallet releases on the stable `0.x` line, so every release takes the stable rule and its `version` equals its semver. The prerelease rows show what the script does with an `-rc.N` input; no release on the current line carries one. The leading `0` on prereleases pins them strictly below every stable tuple with M ≥ 1, which keeps CWS upgrade ordering monotonic from an RC to its GA. It does not keep an RC above the `0.x` versions already published: `1.0.0-rc.1` derives `0.1.0.1`, which Chrome sorts below `0.341.2`, so cutting an RC from the current line would break CWS upgrade ordering. Stable bumps are unchanged (`1.2.3` → `1.2.3`). ## Per-shell build @@ -53,7 +54,7 @@ The leading `0` on prereleases pins them strictly below every stable tuple with | Web | `pnpm --filter @xchain-wallet/web build` | `packages/web/dist/`: static SPA | | Extension | `pnpm --filter @xchain-wallet/extension build` | `packages/extension/dist/`: unpacked Chrome extension | | Desktop renderer | `pnpm --filter @xchain-wallet/desktop build:renderer` | `packages/desktop/build/`: bundled into the asar | -| Desktop installers | `pnpm --filter @xchain-wallet/desktop dist` | `packages/desktop/dist/`: `.dmg` / `.exe` / `.AppImage` | +| Desktop installers | `pnpm --filter @xchain-wallet/desktop dist` | `packages/desktop/dist/`: the host platform's installers, for x64 and arm64 (`.dmg` and `-mac.zip` on macOS, `.exe` and `-win.zip` on Windows, `.AppImage` and `.deb` on Linux) | | Desktop pre-signing (reproducible) | `pnpm --filter @xchain-wallet/desktop dist:unpacked` | `packages/desktop/dist/linux-unpacked/` + `RELEASE_HASHES.txt` | All builds run with the workspace's `pnpm install --frozen-lockfile` to lock dep versions to the shipped lockfile. Drift in transitive deps is the most common source of reproducibility failures; the frozen lockfile is the first line of defense. @@ -77,7 +78,11 @@ Signing identities live with the publisher (Dankest, LLC). Independent verifiers |---|---| | Web | Static-hosted at the canonical XChain Wallet URL | | Extension | Chrome Web Store (and per-vendor stores for Edge / Brave once added) | -| Desktop | GitHub Releases + a maintainer-controlled update feed consumed by `electron-updater` | +| Desktop | `https://downloads.xchain.io/wallet/desktop/`, which is also the update feed `electron-updater` reads; macOS and Linux are live, Windows is pending (see [desktop/windows.md](release/desktop/windows.md)) | +| Mobile | Android APK at `https://downloads.xchain.io/wallet/android/`; Google Play distribution is pending (see [mobile/android-play.md](release/mobile/android-play.md)), and iOS is pending App Store release (see [mobile/ios-app-store.md](release/mobile/ios-app-store.md)) | +| Desktop stores | Mac App Store, Microsoft Store, and Snap Store lanes are pending; each has its page under [Release engineering](release/README.md) | + +Which lanes have shipped is declared in the wallet repo's `tools/release/shipped-lanes.txt`, and the release signing gate reads it. Auto-update for desktop: @@ -125,20 +130,21 @@ CWS submission is one of the three remaining user-driven items before v1.0.0 GA. ## Release artifact list -A v1.0.0 GA release publishes: - -| Artifact | Source | -|---|---| -| `xchain-wallet-1.0.0.dmg` | macOS desktop installer | -| `xchain-wallet-1.0.0.exe` | Windows desktop installer | -| `xchain-wallet-1.0.0.AppImage` | Linux desktop bundle | -| `xchain-wallet-1.0.0-linux-unpacked.tar.gz` | Pre-signing Linux bundle (reproducible target) | -| `RELEASE_HASHES.txt` | SHA-256 manifest of the pre-signing Linux bundle contents | -| `xchain-wallet-extension-1.0.0.zip` | Unpacked Chrome extension (informational; CWS hosts the canonical) | -| `xchain-wallet-web-1.0.0.tar.gz` | Static SPA bundle (informational) | -| `RELEASE.md` | Release notes: pulled from the `[1.0.0]` section of `CHANGELOG.md` | +The authoritative list is the wallet repo's `tools/release/expected-artifacts.txt`: the signing gate refuses a release missing a required artifact and refuses any staged file no row declares. For a release `vX.Y.Z`: -Independent verifiers download the source tarball + the pre-signing Linux bundle + `RELEASE_HASHES.txt`, run the reproduce script in a Docker container, and verify the output bundle matches the manifest. +| Artifact | Source | `expected-artifacts.txt` row | +|---|---|---| +| `xchain-wallet-web-vX.Y.Z.tar.gz` | Static SPA bundle | required | +| `xchain-wallet-extension-vX.Y.Z.zip` | Chrome extension package (the Chrome Web Store hosts the canonical install) | required | +| `xchain-wallet-X.Y.Z-.dmg` and `xchain-wallet-X.Y.Z--mac.zip` | macOS installer and update archive, x64 and arm64 | required | +| `xchain-wallet-setup-X.Y.Z-.exe` and `xchain-wallet-X.Y.Z--win.zip` | Windows installer and portable zip, x64 and arm64 | required | +| `xchain-wallet-X.Y.Z-.AppImage` and `xchain-wallet_X.Y.Z_.deb` | Linux bundle and Debian package, x64 and arm64 | required | +| `xchain-wallet-vX.Y.Z.apk` and `xchain-wallet-android-vX.Y.Z.aab` | Android direct APK and the Play bundle (the `.aab` goes to Play and is never hosted) | optional | +| `xchain-wallet-ios-vX.Y.Z.ipa` | iOS App Store package | optional | +| `*-mas.pkg`, `*-appx.appx`, `*.snap` | Mac App Store, Microsoft Store, and Snap Store packages | optional | +| `RELEASE_HASHES/vX.Y.Z.txt` and its `.asc` signature | The signed SHA-256 manifest of every artifact the release carries | none (written by the signing step, not declared) | + +Hosted artifacts are published under `https://downloads.xchain.io/wallet/` (`web/`, `extension/`, `desktop/`, `android/`, and `RELEASE_HASHES/`). Independent verifiers rebuild from the tag with the reproduce script and compare the packaged-artifact hashes against the published manifest, as [Reproducible Builds](reproducible-builds.md) describes. ## Pre-launch readiness gates @@ -149,8 +155,8 @@ Before tagging `v1.0.0` GA: | All 4 implementation phases closed | ✅ Done at v0.95.0 | | Static a11y audit passes (5 rules over 64 routes + 9 primitives) | ✅ Done at v0.100.0 | | Reproducible-build scaffolding audit passes (18 rules) | ✅ Done at v0.101.0 | -| §56.3 pre-launch autonomous track closed | ✅ Done at v1.0.0-rc.1 | -| §56.3 pre-launch user-initiated track autonomous portion closed | ✅ Done at v1.0.0-rc.6 | +| §56.3 pre-launch autonomous track closed | ✅ Done 2026-04-24, in the release then labelled `1.0.0-rc.1` | +| §56.3 pre-launch user-initiated track autonomous portion closed | ✅ Done 2026-04-24, in the release then labelled `1.0.0-rc.6` | | External security audit complete | ⬜ Pending: readiness packet shipped | | External accessibility audit complete | ⬜ Pending: readiness packet shipped | | Chrome Web Store submission approved | ⬜ Pending: submission playbook shipped | @@ -158,6 +164,8 @@ Before tagging `v1.0.0` GA: The first three pending items are user-driven and require external coordination; the fourth is a one-shot procedure run by the maintainer on a clean Docker host. +The `1.0.0-rc.N` labels in the two completed rows were retired the next day: the wallet CHANGELOG's `0.103.0` entry renumbers `1.0.0-rc.6` to `0.102.0`, and every release since is on the `0.x` line. + ## Release notes Every release ships a CHANGELOG entry describing: diff --git a/components/wallet/configuration.md b/components/wallet/configuration.md index f895131e..53b0a06f 100644 --- a/components/wallet/configuration.md +++ b/components/wallet/configuration.md @@ -33,7 +33,7 @@ Settings persist in the encrypted vault under the `settings` collection (`core/s | Section | Keys | |---|---| -| Security | `autoLockTimeoutMs`, `osKeychainAutoUnlock`, `requirePasswordToViewKey`, `confirmEverySign` | +| Security | `autolockMinutes` (minutes; default 15, `0` = Never), `requirePasswordToViewKey`, `confirmEverySign` | | Chains | per-chain `encoderUrl`, `explorerUrl`, `hubUrl`, `addressType`, `feeStrategy` | | Display | `locale`, `theme`, `reducedMotionOverride`, `chartTimeframe` | | Notifications | `desktopNotifications`, `webSocketNotifications`, `notificationLevel` | diff --git a/components/wallet/features.md b/components/wallet/features.md index 4e4f161e..1e358e55 100644 --- a/components/wallet/features.md +++ b/components/wallet/features.md @@ -174,11 +174,12 @@ For users who keep keys on an offline device: ## Lock / unlock / auto-lock -- Argon2id-derived session key cached in `chrome.storage.session` (extension), OS keychain (desktop, optional), or in-memory only (web) -- Foreground auto-lock with configurable timeout (default 5 minutes) +- Argon2id-derived session key cached in `chrome.storage.session` (extension), an OS-keychain-encrypted `session.bin` (desktop, when a keychain backend is available, else in memory), or in-memory only (web) +- Foreground auto-lock with configurable timeout (default 15 minutes) +- Background auto-lock on the extension (popup closed) and desktop (at launch, and with no window open), using the same timeout - Manual lock action accessible from the global menu and via keyboard shortcut - Browser-close drops the session-key namespace automatically -- OS keychain auto-unlock on desktop is disabled by default; opt-in via Settings → Security +- OS keychain auto-unlock on desktop is on by default wherever a keychain backend is available, bounded by the auto-lock window at launch; Lock clears it (see [Shell; Desktop](shell-desktop.md#os-keychain-auto-unlock)) ## Internationalization & accessibility diff --git a/components/wallet/keys-signing.md b/components/wallet/keys-signing.md index 8e707e6f..2e631c45 100644 --- a/components/wallet/keys-signing.md +++ b/components/wallet/keys-signing.md @@ -23,7 +23,7 @@ Calibration: on first wallet creation the wallet runs Argon2id with the floor pa After derivation the master key: - Decrypts the vault blob via AES-256-GCM -- Is cached for the session (`chrome.storage.session` on the extension, OS keychain on desktop if enabled, in-memory only on web and mobile) +- Is cached for the session (`chrome.storage.session` on the extension, an OS-keychain-encrypted file on desktop when a keychain backend is available, in-memory only on web and mobile) - Is zeroed when the wallet locks The password itself is zeroed immediately after derivation. diff --git a/components/wallet/privacy/data-safety.md b/components/wallet/privacy/data-safety.md index 4ac17409..82aeb9c4 100644 --- a/components/wallet/privacy/data-safety.md +++ b/components/wallet/privacy/data-safety.md @@ -15,11 +15,11 @@ This page mirrors the Data safety declaration published for the Android app on G | `https://hub.xchain.io/` | signed chain-registry snapshot, fee data | nothing identifying beyond the request itself | | `https://api.coingecko.com/api/v3` | fiat price display | the requesting IP; no addresses, no amounts | | a host the token issuer chose, plus `ipfs.io` / `arweave.net` | the information document a token links from its own on-chain description | the requesting IP, and which token was opened, to a third party neither we nor the user chose | -| `https://downloads.xchain.io/wallet/android/latest.json` | "is there a newer version?", for a directly-downloaded APK only, never for a Play install | the requesting IP and the running version; no addresses, no identifiers | +| `https://downloads.xchain.io/wallet/android/latest.json` | "is there a newer version?", for a directly-downloaded APK only, never for a Play install | the requesting IP only; the running version is not sent (the comparison happens on the device); no addresses, no identifiers | The token-metadata row is the one that matters most on this form: when the metadata-fetch setting is on, which is the default, the app follows a URL out of a token's on-chain description and resolves `ipfs://` and `ar:` links through public gateways. It is a third-party contact, on by default, to a destination chosen by whoever issued the token, and it is disclosed both here and in the privacy policy. -**A Play-installed copy never makes the update-check request, on any setting.** The check is gated at runtime on how the app was installed. A Play install answers `store` and no update-check request is ever made; only a directly-downloaded APK, which no store keeps current, ever asks. It is declared here anyway, since disclosing a request the Play build does not make is the safer direction on this form. The request itself carries the requesting IP and the running version, no addresses and no identifiers, at most once a day, and can be switched off in Settings under About. +**A Play-installed copy never makes the update-check request, on any setting.** The check is gated at runtime on how the app was installed. A Play install answers `store` and no update-check request is ever made; only a directly-downloaded APK, which no store keeps current, ever asks. It is declared here anyway, since disclosing a request the Play build does not make is the safer direction on this form. The request itself carries only the requesting IP: it fetches a fixed static file with no query string, and the running version is not sent, since the app compares the latest release against itself on the device. It carries no addresses and no identifiers, is made at most once a day, and can be switched off in Settings under About. Remote token media (images, audio, video) is not fetched at all: the app's content policy admits no remote origin for images and has no path for audio or video, so those elements never load. Only the metadata document itself is fetched. diff --git a/components/wallet/privacy/privacy-nutrition-labels.md b/components/wallet/privacy/privacy-nutrition-labels.md index bb9c6ecc..76739e95 100644 --- a/components/wallet/privacy/privacy-nutrition-labels.md +++ b/components/wallet/privacy/privacy-nutrition-labels.md @@ -4,7 +4,7 @@ # App Store privacy nutrition labels (iOS) -This page mirrors the App Store privacy nutrition label published for the iOS app. The label, the [privacy policy](privacy-policy.md) and the traffic the app actually generates all have to say the same thing, so these answers are derived from what the app calls, never from what it was meant to call. The Google Play equivalent is [Data safety](data-safety.md). +This page mirrors the App Store privacy nutrition label entered in App Store Connect for the iOS app, which appears on the App Store listing once the app is published. The label, the [privacy policy](privacy-policy.md) and the traffic the app actually generates all have to say the same thing, so these answers are derived from what the app calls, never from what it was meant to call. The Google Play equivalent is [Data safety](data-safety.md). ## What the app contacts, in the terms Apple asks about diff --git a/components/wallet/release/README.md b/components/wallet/release/README.md index 48cfff33..e2c5f7f9 100644 --- a/components/wallet/release/README.md +++ b/components/wallet/release/README.md @@ -23,7 +23,7 @@ Start with [Verify a release](verify-release.md) if you just want to check a dow | Extension: Chrome Web Store | [extension/chrome-web-store.md](extension/chrome-web-store.md) | Chrome Web Store submission and update lane | | Extension: test dApp runbook | [extension/test-dapp-runbook.md](extension/test-dapp-runbook.md) | Exercising the `window.xchain` bridge against the reference test dApp | | Mobile: Google Play | [mobile/android-play.md](mobile/android-play.md) | Android build and Play Store distribution | -| Mobile: App Store | [mobile/ios-app-store.md](mobile/ios-app-store.md) | iOS build and App Store distribution | +| Mobile: App Store | [mobile/ios-app-store.md](mobile/ios-app-store.md) | iOS build and App Store distribution, pending App Store release | ## Shared references diff --git a/components/wallet/release/verify-release.md b/components/wallet/release/verify-release.md index 595c30ef..c4cad47d 100644 --- a/components/wallet/release/verify-release.md +++ b/components/wallet/release/verify-release.md @@ -167,13 +167,13 @@ grep '^#' RELEASE_HASHES.txt ``` # XChain Wallet release manifest # manifest-version: 2 -# tag: v0.333.1 +# tag: vX.Y.Z # tag-commit: 9f3c... # built: 2026-07-31T18:02:11Z # dev-mock-gate: enforced # artifacts: 8 -# profile default: ./xchain-wallet-web-v0.333.1.tar.gz -# profile store: ./xchain-wallet-ios-v0.333.1.ipa +# profile default: ./xchain-wallet-web-vX.Y.Z.tar.gz +# profile store: ./xchain-wallet-vX.Y.Z.apk ``` These lines are inside the signed bytes, so a good signature vouches for them too. Five things to read: diff --git a/components/wallet/security.md b/components/wallet/security.md index 4de266a6..72819361 100644 --- a/components/wallet/security.md +++ b/components/wallet/security.md @@ -12,7 +12,7 @@ This document names what the wallet defends against, what it deliberately doesn' | BIP39 seed phrase | Only in the user's possession (paper / hardware). Encrypted at rest in the vault. | Full loss of funds across every chain the wallet derives | | Wallet master key | Derived from password via Argon2id; held in memory while unlocked | Decryption of the persisted vault → access to all private keys | | Vault blob | `chrome.storage.local` (extension), IndexedDB (web), encrypted file (desktop), or an app-private file under an OS-keystore key via the native vault plugin (mobile). AES-256-GCM with the master key | Offline access to the ciphertext. Still requires the password to decrypt | -| Session master key | `chrome.storage.session` (extension), OS keychain (desktop, optional), or in-memory (web and mobile). Cleared on browser close / tab close | Skips the Argon2id cost of re-unlocking. Not the raw password | +| Session master key | `chrome.storage.session` (extension), an OS-keychain-encrypted `session.bin` (desktop, when a keychain backend is available, else in-memory), or in-memory (web and mobile). Cleared on browser close / tab close; on desktop it survives a quit and is cleared by Lock, auto-lock (including the launch check) or reset | Skips the Argon2id cost of re-unlocking. Not the raw password | | User password | In memory only during unlock / sign, zeroed after use. Persisted in one case and one only: with biometric unlock enabled, an encrypted copy is kept, released only after a successful biometric check (see **Biometric password wrap** below) | Full access to every locked vault on the device | | Connected-site grants | Persisted in the vault's `connectedSites` collection | Silent approval of dApp requests the user previously granted | @@ -63,7 +63,7 @@ The wallet is the first consumer of the platform's light client (`sdk.light`). I - **Zero-day browser sandbox escapes.** If the browser is compromised, so is the wallet. Users with extreme threat models should use air-gapped PSBT-QR flows (see [URI Schemes](uri-schemes.md)) or hardware wallets. - **Vendor firmware bugs.** Hardware-signer firmware (Trezor, Ledger) is out-of-scope for the wallet's audit; the wallet trusts the vendor's signed firmware. - **Supply-chain attacks on vendored deps.** Mitigated by `pnpm audit --prod --audit-level=high` in CI + the per-dep review in [Dependencies](dependencies.md). Reproducible builds (Level-2, see [Reproducible Builds](reproducible-builds.md)) narrow the blast radius; a verifier can prove the published artifact came from public source. -- **Physical access to an unlocked device.** No wallet can defend against this. Mitigations: foreground auto-lock (configurable in Settings) and manual lock action. +- **Physical access to an unlocked device.** No wallet can defend against this. Mitigations: foreground auto-lock (configurable in Settings) and manual lock action. The extension and desktop also enforce the same idle timeout when no wallet window is open: the extension's background worker checks it on a recurring alarm and locks with the popup closed, and desktop drops an expired cached session at launch (failing closed when its idle record is missing or unreadable) and checks the timeout on a timer while the app runs with no window. The web shell has the foreground lock only, and a closed tab already drops its in-memory keys. - **Raw-password attacks.** Outside the wallet's design; Argon2id raises the cost, the user's password choice does the rest. - **Blockchain consensus.** The wallet trusts the platform's encoding / decoding logic and the underlying coin nodes' chain selection. For displayed balances and actions it no longer trusts a single indexer: those are SPV-verified against checkpoints signed by a stake-weighted quorum of the federation (see [Proof verification](#proof-verification-spv)), so trust there bottoms out at the federation quorum rather than one server. The platform's correctness is audited separately. diff --git a/components/wallet/shell-desktop.md b/components/wallet/shell-desktop.md index 6ff7954f..f2d99310 100644 --- a/components/wallet/shell-desktop.md +++ b/components/wallet/shell-desktop.md @@ -30,7 +30,7 @@ Keys never cross the IPC boundary into the renderer. Even if the renderer is com | `main/index.js` | Boots Electron, creates the `BrowserWindow`, and injects the OS notification adapter (`electron.Notification`) that delivers §46 transaction and price-alert notifications via native desktop toasts | | `main/runtime.js` | Lifecycle: app-ready, before-quit, second-instance handling | | `main/storage.js` | File-backed Vault adapter; encrypted at rest with the same AES-256-GCM scheme as the extension | -| `main/keychain.js` | OS keychain integration for opt-in session-master-key persistence | +| `main/keychain.js` | Session-master-key cache: an OS-keychain-encrypted `session.bin` when a keychain backend is available, in memory otherwise | | `main/messageHost.js` | `MessageHost` integration: same as the extension's service worker | | `main/protocol.js` | URI scheme registration (`bitcoin:` / `dogecoin:` / `litecoin:` / `xchain:`) via `app.setAsDefaultProtocolClient` | | `main/permissions.js` | Per-origin permission grants; same `connectedSites` schema as the extension | @@ -52,17 +52,19 @@ The renderer process runs with `nodeIntegration: false`, `contextIsolation: true ## OS keychain auto-unlock -Opt-in. When the user enables Settings → Security → "Auto-unlock with system keychain": +On by default whenever a real OS keychain backend is available; there is no setting that turns it off. After an unlock, the main process encrypts the **session master key** (32 bytes, not the password) with Electron `safeStorage` and writes the ciphertext to `session.bin` in the app's user-data directory (mode `0600`). The key material that decrypts it stays in the OS keychain: -| OS | Backend | +| OS | `safeStorage` backend | |---|---| -| macOS | Keychain Access | -| Windows | Credential Manager | -| Linux | Secret Service (GNOME Keyring / KWallet) | +| macOS | Keychain | +| Windows | DPAPI | +| Linux | Secret Service (GNOME Keyring) or KWallet, via libsecret | -The wallet stores the **session master key** (32 bytes) (not the password) under a wallet-specific keychain entry. On launch, the main process reads the entry, decrypts the vault, and the wallet boots already unlocked. The user can disable the auto-unlock in Settings; doing so deletes the keychain entry. +When no keychain backend is available, or Electron reports the `basic_text` fallback, nothing is written to disk: the key is held in memory for the running process only, and every launch asks for the password. -The keychain integration is gated behind explicit user opt-in because keychain compromise is a real attacker capability on shared machines. Default-on would be wrong. +On a later launch, the main process decrypts `session.bin` and the wallet boots already unlocked, but only within the user's auto-lock window: a launch gate drops the cached key, and the user sees the lock screen, when the idle window has elapsed or the idle record is missing or unreadable. With auto-lock set to Never (and on a demo wallet) the gate keeps the cached key with no time limit. Lock, whether manual or by auto-lock, and wallet reset delete `session.bin`, which is how a user makes the next launch ask for the password. If the ciphertext cannot be decrypted (a different OS user, a reset keychain), the wallet asks for the password. + +The file is only as safe as the OS keychain: code running as the same OS user while the keychain is unlocked may be able to decrypt `session.bin`. On a shared machine, set an auto-lock timeout rather than Never, and lock before quitting. ## Hardware signer transports diff --git a/components/wallet/shell-web.md b/components/wallet/shell-web.md index 9291565f..c7dd1ca3 100644 --- a/components/wallet/shell-web.md +++ b/components/wallet/shell-web.md @@ -8,7 +8,7 @@ ## When to use the web shell - **No-install access.** The web shell loads from a URL. No extension install, no desktop download. Lowest friction for first-time users. -- **Mobile.** The native Android app, a Capacitor wrapper of this web build, has shipped (`v0.336.0`); a native iOS app is still on the post-launch roadmap. The web SPA remains the mobile path on iOS and anywhere the native app is not installed; the layout is responsive between phone and desktop viewports. +- **Mobile.** The native Android app, a Capacitor wrapper of this web build, has shipped (`v0.336.0`); a native iOS app, the same Capacitor wrapper, is built and pending App Store release. Until it is published, the web SPA remains the mobile path on iOS and anywhere the native app is not installed; the layout is responsive between phone and desktop viewports. - **Power-user expansion.** Users who already have the extension installed sometimes prefer the web's full-screen working surface for big workflows (multi-step issue + airdrop + dispenser flows). The web app surfaces an extension-detect banner offering to relaunch in the extension. The web shell's key isolation is fundamentally weaker than the extension's, same-origin scripts on `wallet.xchain.io` could in principle access in-memory key material. Mitigations are below; users with extreme threat models should prefer the extension or desktop shells. @@ -79,7 +79,7 @@ All sign screens collapse to a single-column form on mobile so the review pane i | `getUserMedia` | QR scanner only | Runtime prompt the first time; user can revoke per browser settings | | WebHID | Hardware signer pair / sign | Requires HTTPS; modern Chromium browsers; not available on iOS Safari | -iOS Safari's lack of WebHID means hardware signers don't work on iPhone via the web shell; that user-flow waits for the native iOS app on the post-launch roadmap. +iOS Safari's lack of WebHID means hardware signers don't work on iPhone via the web shell; that user-flow waits for the native iOS app, which is pending App Store release. ## Extension-detect banner @@ -113,7 +113,7 @@ The policy is one of the few mitigations the web shell has against XSS; the wall - **Tab close**: session master key zeroed; vault re-encrypted-at-rest in IndexedDB - **Tab refresh**: same: in-memory state cleared, wallet returns to the locked state -- **Foreground auto-lock**: same timeout as the extension and desktop (default 5 minutes idle) +- **Foreground auto-lock**: same timeout as the extension and desktop (default 15 minutes idle) - **Multi-tab**: each tab has its own in-memory session; unlocking one tab doesn't unlock others. (Convention rather than a security guarantee; IndexedDB sharing means an unlocked tab and a different unlocked tab would race on writes.) Refresh-equals-relock is unusual for a web app and is a deliberate trade; the alternative (persisting the session key to IndexedDB) would mean a same-origin XSS could simply re-decrypt the vault. We hold the line at "your session is gone the moment your tab is gone". diff --git a/components/wallet/threat-model.md b/components/wallet/threat-model.md index b291223d..0439ae87 100644 --- a/components/wallet/threat-model.md +++ b/components/wallet/threat-model.md @@ -82,7 +82,6 @@ These are tracked but not release-blocking; each has a short pointer to where th |---|---| | PSBT byte-level simulator before sign | Planned, next iteration of the sign-screen safety rail | | Hardware-wallet transport coverage | Ongoing, expands with each hardware signer added | -| Background-mediated auto-lock that survives popup close/reopen | Tracked in the changelog | | Reproducible-build pipeline | Live for desktop; extension and web tracked in [Reproducible Builds](reproducible-builds.md) | | External security review | Pending, required before a general-availability mainnet release | | Cross-tab reservation ledger for the web shell | Open; the extension shell is protected, the web shell is not (see the scenario above) | diff --git a/components/wallet/ux.md b/components/wallet/ux.md index 05111353..6a8ac625 100644 --- a/components/wallet/ux.md +++ b/components/wallet/ux.md @@ -103,9 +103,10 @@ Optional 25th-word passphrase is offered on both Create and Import, captured onc - **Manual lock**: accessible from the global menu and via keyboard shortcut. Zeroes the in-memory master key + session key. - **Unlock**: password entry; on success, master key is derived (Argon2id) and the vault decrypts. -- **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. +- **Foreground auto-lock**: configurable timeout in Settings. Default: 15 minutes idle (choices from 1 minute to 4 hours, or Never). One idle timer serves the popup, web and desktop shells; mouse, keyboard, scroll, click and touch activity resets it. Switching tabs or windows does not lock the wallet. +- **Background auto-lock**: on the extension, the background worker enforces the same idle timeout after the popup closes. On desktop, an expired cached session is dropped at launch, and the timeout is still checked while the app runs with no window open. The web shell has no background lock; closing the tab drops its keys. - **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. +- **OS keychain auto-unlock**: desktop only, on by default wherever an OS keychain backend is available, with no setting to turn it off. The session master key is encrypted through the OS keychain (Keychain on macOS, DPAPI on Windows, Secret Service or KWallet on Linux) and kept in a `session.bin` file, so a relaunch inside the auto-lock window skips the password; with auto-lock set to Never it skips it until the user locks. Lock and wallet reset delete the file. - **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 @@ -226,7 +227,7 @@ Power users get: Settings are organized under sections: -- **Security**: auto-lock timeout, OS-keychain auto-unlock (desktop), view private key, change password, export backup +- **Security**: auto-lock timeout, view private key, change password, export backup - **Chains**: per-chain endpoint URLs (encoder / explorer / hub), default fee strategy, address-type preference - **Accounts**: manage HD accounts under each wallet - **Signers**: registered hardware + remote signers, pair-new, remove, firmware version diff --git a/concepts/actions.md b/concepts/actions.md index f6349d47..44b442a6 100644 --- a/concepts/actions.md +++ b/concepts/actions.md @@ -83,7 +83,7 @@ The platform defines 38 named ACTIONs; 37 ACTION types are decoded from the wire | `ORDER` | Place a buy or sell order on the platform's order book. Orders are matched by the indexer automatically. Supports native coin pairs (BTC/LTC/DOGE) via the COINPay settlement model. | | `COINPAY` | Fulfills a native coin payment obligation created by an ORDER_MATCH. The transaction includes both the action data and a native coin output paying the seller. | | `DISPENSER` | Create a vending machine that sells tokens at a fixed rate in exchange for coin payments. Closes when it is depleted, cancelled, or reaches its `EXPIRATION` (90 days by default), returning any tokens still escrowed to the creating address. | -| `SWAP` | Initiate or fulfill a cross-chain atomic token swap, coordinated by the hub. | +| `SWAP` | Initiate or fulfill a cross-chain token swap: each chain escrows its own leg and settles it against a validator-signed match coordinated by the hub. | ### Cross-Chain Bridge @@ -104,7 +104,7 @@ The platform defines 38 named ACTIONs; 37 ACTION types are decoded from the wire | ACTION | What it does | |---|---| | `ADDRESS` | Set per-address preferences, such as requiring a memo for all incoming transfers. | -| `BATCH` | Combine multiple ACTIONs into a single transaction. All or none execute: if any ACTION in the batch is invalid, the entire batch fails. | +| `BATCH` | Combine multiple ACTIONs into a single transaction. Not atomic: each ACTION is validated and settled on its own, in order, so an invalid ACTION is recorded invalid by itself while the others stand. Only a structural fault (an unrecognized action, a nested BATCH, an exceeded limit) rejects the whole batch. See [BATCH](#batch-composing-multiple-actions) below. | | `LINK` | Create a cross-reference to another ACTION by its `ACTION_INDEX`. Used for associating related operations across transactions or chains. | | `LIST` | Define a named list of addresses or tickers. Referenced by ISSUE (for allow/block lists) and AIRDROP (for recipient lists). | diff --git a/concepts/cross-chain.md b/concepts/cross-chain.md index fd23d1f4..19cfed71 100644 --- a/concepts/cross-chain.md +++ b/concepts/cross-chain.md @@ -16,7 +16,7 @@ Each chain maintains: There is no shared state between chains at the protocol level. An address that holds 1000 MYTOKEN on Bitcoin has no claim on MYTOKEN on Litecoin; those are two separate tokens that happen to share a name, owned by whoever issued them on each chain. -## SWAP: Cross-Chain Atomic Trades +## SWAP: Cross-Chain Escrowed Trades The `SWAP` ACTION enables trustless exchange of tokens across chains. The mechanism is coordinated by the xchain-hub but does not require trusting the hub with custody of funds, tokens are held in escrow on each chain by the protocol, not by the hub. @@ -28,7 +28,7 @@ The `SWAP` ACTION enables trustless exchange of tokens across chains. The mechan 3. **Counterparty match**: A user on chain B who wants to accept the offer sends a corresponding `SWAP` ACTION on chain B with the mirrored token pair and amounts. The hub federation matches the two offers automatically. Their tokens are locked in escrow by the indexer on chain B. -4. **Settlement**: The hub signals both indexers that a match exists. Each indexer releases the escrowed tokens to the counterparty address. The swap completes atomically, both sides settle, or neither does. +4. **Settlement**: Once both escrows are buried under their chain's confirmation floor, the `cross_chain` validators reach consensus and sign one match record, which is streamed to every indexer. Each indexer verifies the signatures and releases its own leg's escrow to the counterparty address. The two legs settle separately, normally within a few blocks of each other. They are not one atomic step: a reorg on one chain deeper than its confirmation floor, after the other leg has settled, could undo one leg while the other stands. See [Residual risk](../user-guide/cross-chain.md#residual-risk) and [Residual reorg risk](../protocol/cross-chain-dex.md#residual-reorg-risk). 5. **Timeout / cancellation**: If no counterparty matches within the offer's validity window, the originating user can cancel and recover their escrowed tokens. @@ -48,11 +48,13 @@ sequenceDiagram UserB->>IndexerB: SWAP action (mirrored token pair, amounts) IndexerB->>IndexerB: Lock offered tokens in escrow Hub->>Hub: Federation matches the two offers - Hub->>IndexerA: Signal match exists - Hub->>IndexerB: Signal match exists + Hub->>IndexerA: Quorum-signed match record + Hub->>IndexerB: Quorum-signed match record + IndexerA->>IndexerA: Verify validator signatures + IndexerB->>IndexerB: Verify validator signatures IndexerA->>UserB: Release escrowed tokens IndexerB->>UserA: Release escrowed tokens - Note over IndexerA,IndexerB: Both sides settle, or neither does + Note over IndexerA,IndexerB: Each leg settles on its own chain. The residual risk is a reorg deeper than the confirmation floor else No counterparty matches in time UserA->>IndexerA: Cancel offer IndexerA->>UserA: Recover escrowed tokens diff --git a/concepts/encoding.md b/concepts/encoding.md index dabab870..d399eb90 100644 --- a/concepts/encoding.md +++ b/concepts/encoding.md @@ -60,7 +60,7 @@ The two-transaction pattern means the ACTION is not visible until the spend tran **Transactions**: 2 in total, however many chunks (one fund, one spend) **Mechanism**: Identical in concept to P2SH but uses SegWit witness data for the reveal; the data chunk in the witness script is likewise embedded **raw**, with a single obfuscated marker `OP_RETURN` output (`XCHN` magic plus a `p2wsh` tag). The witness discount does not raise capacity: P2WSH shares P2SH's chunking and ceiling. What it changes is cost, since witness bytes count at one quarter of the weight of non-witness bytes for fee purposes. That fee advantage makes P2WSH the preferred format for large payloads (file uploads, long broadcast messages, dense batch operations). -**The 8,192-byte ceiling is a decoder-wide limit, not a P2WSH-specific one.** `MAX_ACTION_DATA_LENGTH` in `xchain-decoder/src/XChainDecoder.js` applies to every embedding format: OP_RETURN, multisig, P2SH (across all its chunks), and P2WSH alike. The cap is measured on the **compiled** on-chain push (the OP_PUSHDATA-prefixed buffer as it appears on chain, before `bitcoin.script.decompile` strips the push prefix), not on the decoded payload: the decoded ACTION string is 1-3 bytes shorter than the compiled push it came from. A decoded payload as small as ~8,190 bytes can still compile to more than 8,192 bytes and be silently dropped; encoders must budget for the push-prefix overhead, not just the decoded byte count. +**The 8,192-byte ceiling covers every script-output (legacy) lane, not only P2WSH.** `MAX_ACTION_DATA_LENGTH` in `xchain-decoder/src/XChainDecoder.js` applies to OP_RETURN, multisig, P2SH (across all its chunks), and P2WSH alike. The TAPROOT envelope below is the one exception: it has its own, larger ceiling. The cap is measured on the **compiled** on-chain push (the OP_PUSHDATA-prefixed buffer as it appears on chain, before `bitcoin.script.decompile` strips the push prefix), not on the decoded payload: the decoded ACTION string is 1-3 bytes shorter than the compiled push it came from. A decoded payload as small as ~8,190 bytes can still compile to more than 8,192 bytes and be silently dropped; encoders must budget for the push-prefix overhead, not just the decoded byte count. ### TAPROOT (envelope) diff --git a/concepts/gas.md b/concepts/gas.md index dd252fb5..28540432 100644 --- a/concepts/gas.md +++ b/concepts/gas.md @@ -49,6 +49,9 @@ The fee destination address is the per-network `ADDRESS.FEE_DESTINATION` value f | **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 | | **XBRIDGE lock/burn** (`XBRIDGE_BASE`) | 5,000 | 0.05 | Flat, charged once per user-broadcast lock (v0, v3) or burn (v1, v4), whatever the amount. Sized at `SWEEP_BASE` so the smallest bridge action on LTC/DOGE still buys a native-coin fee output above the dust threshold. The system-injected settle legs (v2, v5) pay nothing. Applies only from the chain's `XCHAIN_BRIDGE_ACTIVATION` (v0 to v2) or `TOKEN_BRIDGE_ACTIVATION` (v3 to v5) height; see [XBRIDGE](../protocol/actions/xbridge.md) | +| **LIST share** (`LIST_SHARE`) | 100,000 | 1.0 | Flat, charged once per LIST SHARE (`VERSION 2`); on par with ISSUE. Applies only from the chain's `LIST_SHARE_ACTIVATION` height; see [LIST](../protocol/actions/list.md#rules) | +| **LIST shared-edit base** (`LIST_SHARED_EDIT_BASE`) | 5,000 | 0.05 | Flat, charged once per edit of a shared list: a format 1 add or remove edit, or a format 5 SET META from `LIST_META_ACTIVATION`. Sized at `SWEEP_BASE` so the smallest shared edit still buys a native-coin fee output above the dust threshold on LTC/DOGE. Creating a list, editing a local list and TRANSFER (`VERSION 3`) pay nothing. Same gate as `LIST_SHARE` | +| **LIST shared-edit per item** (`LIST_SHARED_EDIT_PER_ITEM`) | 100/item | 0.001/item | Per item a format 1 edit of a shared list actually adds or removes; format 5 pays no per-item fee. Same gate as `LIST_SHARE` | | **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 | diff --git a/concepts/smart-contracts.md b/concepts/smart-contracts.md index 9178edc7..5dabe9fb 100644 --- a/concepts/smart-contracts.md +++ b/concepts/smart-contracts.md @@ -259,7 +259,7 @@ block **after** the one where it was applied; the same one-block visibility rule ### Cross-Chain Contract Calls | Method | Gas | Description | |---|---|---| -| `xchain.emit.crossExecute({targetChain, contractIndex, method, params?, gasLimit, callbackMethod, callbackParams?, deadlineBlocks?})` | 500 + 2,000 + gasLimit + 20,000 | Asynchronously invoke a `crossCallable` method on a contract on another chain; the outcome arrives via `callbackMethod(callId, targetChain, status, payload, ...callbackParams)`. Returns the deterministic 64-hex `callId`. No refunds; hop budget 2 (`xchain.getCrossHops()`). See `protocol/Cross_Chain_Calls.md`. | +| `xchain.emit.crossExecute({targetChain, contractIndex, method, params?, gasLimit, callbackMethod, callbackParams?, deadlineBlocks?})` | 500 + 2,000 + gasLimit + 20,000 | Asynchronously invoke a `crossCallable` method on a contract on another chain; the outcome arrives via `callbackMethod(callId, targetChain, status, payload, ...callbackParams)`. Returns the deterministic 64-hex `callId`. No refunds; hop budget 2 (`xchain.getCrossHops()`). See [`protocol/cross-chain-calls.md`](../protocol/cross-chain-calls.md). | ### External Attestation: asking the outside world (see [framework section below](#asking-the-outside-world-the-attestation-framework)) | Method | Gas | Description | @@ -417,7 +417,7 @@ The `askIsTrue` method emits ATTEST v0 and returns. A handful of blocks later, t Each request may carry two distinct escrows: -**(a) GAS_ESCROW / `per_call_base_fee_xchain`**: the provider's governance-configured base fee (LLM: 0.50 XCHAIN; http_get: 0.01 XCHAIN). Both providers currently default this to `'0'`, so this escrow is not yet collected in production. On `ok` it would be paid out to the responding validators; on expiry the redistribution mechanism is future economic work. +**(a) GAS_ESCROW / `per_call_base_fee_xchain`**: the provider's governance-configured base fee (LLM: 0.50 XCHAIN; http_get: 0.01 XCHAIN). Those are the registry defaults, but the fee is not yet charged: the indexer records `GAS_ESCROW` as `'0'` on every request and nothing reads `per_call_base_fee_xchain` at request time, so this escrow is not collected in production (see [ATTEST](../protocol/actions/attest.md)). On `ok` it would be paid out to the responding validators; on expiry the redistribution mechanism is future economic work. **(b) Optional paid-attestation fee (`feeTick` / `feeAmount`)**: an explicit fee a contract includes in the request (E1 feature, active now). Passing `feeAmount > 0` escrows that amount from the calling contract (`FEE_PAYER`) at request time. On `ok` the fee is split among the responsible validators. On expiry or provider error (service not rendered) the fee is **refunded in full to the caller**. diff --git a/developer-guide/adding-a-blockchain.md b/developer-guide/adding-a-blockchain.md index 32d96124..9c7c80a5 100644 --- a/developer-guide/adding-a-blockchain.md +++ b/developer-guide/adding-a-blockchain.md @@ -91,8 +91,6 @@ confirmations: 6, // default cross-chain attestation depth Each network block carries: -> **BTC testnet4 contract limitation:** Contract deploys on BTC testnet4 cannot be mined at current miner block sizes. Use LTC or DOGE testnet when validating contract support. - - **`net`**: the bitcoinjs-lib network object (`messagePrefix`, `bech32`, `bip32`, `pubKeyHash`, `scriptHash`, `wif`, `dustThreshold`, `minStandardTxNonWitnessSize`, `singleOpReturnPolicy`). The decoder, encoder, diff --git a/developer-guide/cross-chain-swap.md b/developer-guide/cross-chain-swap.md index 1c867244..63dd2277 100644 --- a/developer-guide/cross-chain-swap.md +++ b/developer-guide/cross-chain-swap.md @@ -146,7 +146,7 @@ Bob's `LTCTOKEN` is now escrowed on Litecoin. ## Step 4: Settlement -The hub detects that both sides of the swap are in their respective confirmed blocks. It signals both indexers to settle: +Once both escrows are confirmed to each chain's required depth, the `cross_chain` validators sign a match record, which the hub mirror streams to every indexer. Each indexer verifies the signed match and settles its own leg: - The Bitcoin indexer credits `bc1qbobsbtcaddress...` with 1 `RAREPEPE`. - The Litecoin indexer credits `ltc1qalicesltcaddress...` with 1000 `LTCTOKEN`. diff --git a/developer-guide/smart-contract-development.md b/developer-guide/smart-contract-development.md index 8b102471..a6313be1 100644 --- a/developer-guide/smart-contract-development.md +++ b/developer-guide/smart-contract-development.md @@ -262,14 +262,14 @@ If any emitted action fails validation (e.g., insufficient balance), ALL state c 2. Encode the UTF-8 source per the active `CODE_ENCODING`: base64 (`Buffer.from(source, 'utf8').toString('base64')`) at or after the [`DEPLOY_BASE64_CODE` activation](../protocol/flag-days.md#contract-era-flag-day) - on mainnet, hex-encoded before it. See `protocol/actions/DEPLOY.md`. + on mainnet, hex-encoded before it. See [`protocol/actions/deploy.md`](../protocol/actions/deploy.md). 3. Broadcast a DEPLOY action: `DEPLOY|0|||` The indexer validates the code syntax before charging gas. If syntax is invalid, the deployment is rejected without cost. ### Deploy-Time Validation -The VM performs the following checks before deployment: +The VM performs the following checks before deployment. Ahead of check 1, a **nesting-depth pre-check** (consensus-gated, its own per-coin `VM_LINT_NESTING_DEPTH_ACTIVATION` gate) rejects source whose `(`, `[`, `{` and `${` delimiters nest more than 64 deep, with `nesting depth exceeds limit (64) at line N`; delimiters inside comments, strings and template text do not count. Flatten deeply nested literals or expressions into named intermediate values. 1. **V8 syntax check**; the code must parse as valid JavaScript 2. **Acorn metering pass**; the code must be parseable by acorn (ES2020 maximum) @@ -282,7 +282,7 @@ The VM performs the following checks before deployment: 9. **Banned WebAssembly check** (consensus-gated, Pkg 3 sandbox): any reference to the global `WebAssembly` is rejected. 10. **Banned `with` statement check** (consensus-gated, its own `LINT_BANNED_WITH_ACTIVATION` gate): any `with` statement is rejected. A `with` block resolves free identifiers against an object at runtime, which would get past the name-based checks above and the gas-metering rewrite. Read the properties through the object instead (`obj.x`, not `with (obj) { x }`). -Checks 7 and 9 are live today on testnet/regtest (the Pkg 3 sandbox gate is unconditionally active on those networks) even though their mainnet activation is a separate flag-day; a regtest deploy that trips either one is rejected now. Check 8 rides its own `REST_PATTERN_METER` gate rather than the Pkg 3 one, and is likewise unconditionally active on testnet/regtest, so the `xchain-lint` CLI, the SDK linter and a regtest deploy all reject an unmeterable rest pattern today. Check 10 rides its own per-coin [`LINT_BANNED_WITH_ACTIVATION`](../protocol/protocol-activation.md#where-the-values-live) height gate: it is live from genesis on regtest and unarmed on mainnet and testnet until an operator arms a height, while the `xchain-lint` CLI and the SDK linter enforce it by default, so a local lint or a regtest deploy containing `with` is rejected today. The `VM_LINT_HARDENING` widenings under checks 3, 4, and 6 activate at the same instant as `VM_BANNED_ASYNC` and are default-on for the SDK and CLI already. Checks 4, 6, and 9 also match the global-object spellings of `Math`, `Promise`, and `WebAssembly` (`globalThis.Math.pow(...)`, and under the genesis-armed `LINT_GLOBAL_ALIAS_ACTIVATION` gate `this.Math.pow(...)` and `globalThis.globalThis.Promise`), so qualifying the name does not get it past the check; see [Global-object spellings](../components/vm/operations.md#global-object-spellings). +Checks 7 and 9 are live today on testnet/regtest (the Pkg 3 sandbox gate is unconditionally active on those networks) even though their mainnet activation is a separate flag-day; a regtest deploy that trips either one is rejected now. Check 8 rides its own `REST_PATTERN_METER` gate rather than the Pkg 3 one, and is likewise unconditionally active on testnet/regtest, so the `xchain-lint` CLI, the SDK linter and a regtest deploy all reject an unmeterable rest pattern today. Check 10 rides its own per-coin [`LINT_BANNED_WITH_ACTIVATION`](../protocol/protocol-activation.md#where-the-values-live) height gate: it is live from genesis on regtest, armed on testnet at each chain's own local block height (`BTC:testnet` 156230, `LTC:testnet` 4912984, `DOGE:testnet` 67989993), and unarmed on mainnet, while the `xchain-lint` CLI and the SDK linter enforce it by default, so a local lint, a regtest deploy or a testnet deploy above that height containing `with` is rejected today. The nesting-depth pre-check follows the same arming on its own `VM_LINT_NESTING_DEPTH_ACTIVATION` gate. The `VM_LINT_HARDENING` widenings under checks 3, 4, and 6 activate at the same instant as `VM_BANNED_ASYNC` and are default-on for the SDK and CLI already. Checks 4, 6, and 9 also match the global-object spellings of `Math`, `Promise`, and `WebAssembly` (`globalThis.Math.pow(...)`, and under the genesis-armed `LINT_GLOBAL_ALIAS_ACTIVATION` gate `this.Math.pow(...)` and `globalThis.globalThis.Promise`), so qualifying the name does not get it past the check. Under the `LINT_DESTRUCTURE_ACTIVATION` gate, which has the same arming as check 10, a destructured read such as `const { pow } = Math` or `const { Promise } = globalThis` is rejected too; see [Global-object spellings](../components/vm/operations.md#global-object-spellings). A non-blocking **float warning** is also generated if decimal number literals are detected in the code. This warning appears in the execution record but does not prevent deployment. @@ -654,7 +654,7 @@ This allowlist is the security boundary: the federation's signed dispatch can on - **No value transfer:** params and a return payload only. Move tokens with the cross-chain DEX, not calls. - Not available from constructors. -Protocol details: `protocol/Cross_Chain_Calls.md` and `protocol/actions/XCALL.md`. +Protocol details: [`protocol/cross-chain-calls.md`](../protocol/cross-chain-calls.md) and [`protocol/actions/xcall.md`](../protocol/actions/xcall.md). ## Declaring the manifest diff --git a/getting-started/key-terms.md b/getting-started/key-terms.md index 7080c1aa..8efe7e88 100644 --- a/getting-started/key-terms.md +++ b/getting-started/key-terms.md @@ -107,7 +107,7 @@ A reference glossary of XChain terminology, organized by category. **ORDER**: An ACTION that places a sell order on the built-in DEX. Specifies the token being sold, the price, and the quantity. Buyers fill orders by sending payment. -**SWAP**: An ACTION that enables atomic cross-chain token exchanges, trading a token on one blockchain for a token on another, without a bridge or custodian. +**SWAP**: An ACTION that enables escrowed cross-chain token exchanges, trading a token on one blockchain for a token on another, without a bridge or custodian. Each chain settles its own leg against a validator-signed match, so a cross-chain swap is not atomic in the single-chain sense (see [Residual risk](../user-guide/cross-chain.md#residual-risk)). --- @@ -135,13 +135,13 @@ A reference glossary of XChain terminology, organized by category. **gated FILE**: A `FILE` action whose `GATE_TICKER` field is non-empty. The raw file data is ciphertext (AES-256-GCM); only holders of `GATE_TICKER` receive the symmetric key (via ECIES `MESSAGE`) and can decrypt. Published only by the token's issuer. See [Token-Gated Content](../protocol/token-gated-content.md). -**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_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. Together with the publisher and `GATE_TICKER`, it implicitly identifies a pack: two or more gated FILEs by the same publisher with the same `GATE_TICKER` and `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 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. -**Pack**: A group of token-gated files sharing the same `KEY_HASH` and `GATE_TICKER`. Pack membership is implicit in the shared key hash; the protocol does not have a separate "pack" ACTION or table. Holders unlock the entire pack atomically with one key. Optionally surfaced in TIS via `pack_id` on file entries and a top-level `packs` display map. +**Pack**: A group of token-gated files by the same publisher sharing the same `GATE_TICKER` and `KEY_HASH`. Pack membership is implicit in that triple, so a former issuer's files never merge into the current issuer's pack (see [FILE pack semantics](../protocol/actions/file.md#pack-semantics)); the protocol does not have a separate "pack" ACTION or table. Holders unlock the entire pack atomically with one key. Optionally surfaced in TIS via `pack_id` on file entries and a top-level `packs` display map. **Token Gating**: Restricting access to content or capability based on whether an address holds a specific token. XChain supports cryptographic token gating for files (see **gated FILE**) and access-control patterns for off-chain systems via signed-challenge verification. diff --git a/getting-started/quickstart-validator.md b/getting-started/quickstart-validator.md index 090da0db..171dc23c 100644 --- a/getting-started/quickstart-validator.md +++ b/getting-started/quickstart-validator.md @@ -73,10 +73,6 @@ somewhere off this machine now.** There is no recovery if they are lost. - Send **0.02 testnet DOGE** to the second address, from any Dogecoin testnet faucet. -The BTC testnet4 funding above supports validator setup. Contract deploys on -BTC testnet4 cannot be mined at current miner block sizes; use -LTC or DOGE testnet for contract deployment and testing. - ## Step 4: stake ```bash diff --git a/getting-started/what-is-xchain.md b/getting-started/what-is-xchain.md index 183807ed..aa36b60b 100644 --- a/getting-started/what-is-xchain.md +++ b/getting-started/what-is-xchain.md @@ -55,7 +55,7 @@ XChain has a native decentralized exchange built directly into the protocol. You ### Swap Across Chains -The SWAP action enables atomic cross-chain exchanges, trading a token on Bitcoin for a token on Litecoin, for example. This happens without a bridge or a trusted intermediary. +The SWAP action enables escrowed cross-chain exchanges, trading a token on Bitcoin for a token on Litecoin, for example. This happens without a bridge or a trusted intermediary. Each chain settles its own leg against a validator-signed match, so the exchange is not atomic in the single-chain sense (see [Residual risk](../user-guide/cross-chain.md#residual-risk)). ### Store Data and Messages diff --git a/operations/configuration.md b/operations/configuration.md index db90d81e..3e6aac2a 100644 --- a/operations/configuration.md +++ b/operations/configuration.md @@ -53,11 +53,7 @@ All services that communicate with a coin node (decoder, encoder, UTXO tracker) | `NODE_USER` | RPC username | `rpc` | | `NODE_PASSWORD` | RPC password | (set per deployment) | -For Dogecoin, also set: -``` -AUX_POW=1 -``` -This enables AuxPoW header stripping in the block parser. +No extra variable is needed for Dogecoin: AuxPoW header stripping is selected from `NETWORK` (on for `dogecoin-*`, never on for Bitcoin or Litecoin). `AUX_POW` is a legacy variable that the decoder and UTXO tracker still read but ignore. ### MariaDB Connection @@ -111,7 +107,7 @@ This table is the canonical service port map. It matches what the xchain-node in | `NODE_URL` / `NODE_PORT` / `NODE_USER` / `NODE_PASSWORD` | Coin node connection | Required | | `DECODER_DB_*` | MariaDB connection | Required | | `DECODER_API_PORT` | JSON-RPC API port | `3002` | -| `AUX_POW` | Set truthy for Dogecoin AuxPoW | unset | +| `AUX_POW` | Legacy; read but ignored. AuxPoW stripping is chosen from `NETWORK` (Dogecoin only) | unset | The decoder polls for new blocks every ~1 second. It waits for the coin node to report `verificationprogress >= 0.99` before processing. Mempool is refreshed every 60 seconds once synced. @@ -167,7 +163,7 @@ The hub uses MariaDB for all data storage. The database and tables are auto-crea | `NETWORK` | Chain + network string | | `NODE_URL` / `NODE_PORT` / `NODE_USER` / `NODE_PASSWORD` | Coin node connection | | `UTXO_TRACKER_API_PORT` | API port | -| `AUX_POW` | Set truthy for Dogecoin | +| `AUX_POW` | Legacy; read but ignored. AuxPoW stripping is chosen from `NETWORK` (Dogecoin only) | The tracker processes blocks in batches of up to 200 (`DB_TRANSACTION_BLOCKS_QUANTITY`; flush may trigger earlier under heap pressure). LevelDB data is stored at `/data/xchain-utxo-tracker/`. Bootstrap archives are stored at `/bootstrap/xchain-utxo-tracker/`. diff --git a/operations/disk-management.md b/operations/disk-management.md index e852a946..2b57ad94 100644 --- a/operations/disk-management.md +++ b/operations/disk-management.md @@ -25,8 +25,7 @@ behaviour, inherited by both forks: | DOGE / LTC regtest | `regtest/blocks/` | The `testnet4/` path above is Litecoin's testnet directory. BTC testnet4 is a -separate network, where contract deploys cannot be mined at current miner block -sizes. Use LTC or DOGE testnet for contract deployment and testing. +separate network. Any disk-offload approach has to account for the network subdirectory. The two safe options below do; the anti-pattern does not. diff --git a/operations/run-a-validator.md b/operations/run-a-validator.md index 472678ed..95aa0db1 100644 --- a/operations/run-a-validator.md +++ b/operations/run-a-validator.md @@ -156,10 +156,6 @@ So a first-time stake from zero (three mints plus the stake) runs about XCHAIN action on testnet is a Bitcoin transaction, so this is also the balance to top up if you ever want to change your stake. -This BTC testnet4 path is suitable for validator setup, but contract deploys on -BTC testnet4 cannot be mined at current miner block sizes. Use -LTC or DOGE testnet for contract deployment and testing. - **DOGE address** (TDOGE): send testnet dogecoin. This is the wallet your hub **spends from** when it is the elected publisher for a price round or a state anchor, which is what the `oracle_publish` capability means. Qualifying for diff --git a/operations/testnet-cross-chain-pair.md b/operations/testnet-cross-chain-pair.md index 8ec07720..97d700d5 100644 --- a/operations/testnet-cross-chain-pair.md +++ b/operations/testnet-cross-chain-pair.md @@ -5,7 +5,7 @@ A recipe for an operator who already runs a testnet validator per [Run a Validator](./run-a-validator.md). It adds a Litecoin testnet indexer beside your Bitcoin testnet one, places a crossing pair of cross-chain `ORDER` actions, and reads the settlement from the public explorer. -> **Settlement needs the live federation.** Your indexers never match or settle a cross-chain trade on their own. The validator federation matches the two offers, signs one match record with a `cross_chain`-capability quorum (2f+1), and each chain's indexer then releases its escrow from that record. If the live testnet federation has fewer than 2f+1 validators serving `cross_chain`, the orders in this recipe stay open and nothing settles. A stack of your own does not substitute for the federation. See [Cross-Chain DEX](../protocol/cross-chain-dex.md) for the model. +> **Settlement needs the live federation.** Your indexers never match or settle a cross-chain trade on their own. The validator federation matches the two offers, signs one match record with a `cross_chain`-capability quorum (stake-weighted and source-deduped at or above `STAKE_WEIGHTED_QUORUM_ACTIVATION`, otherwise `max(2f+1, ceil((N+1)/2))` signers by count), and each chain's indexer then releases its escrow from that record. If the live testnet federation's `cross_chain` validators cannot reach that quorum, the orders in this recipe stay open and nothing settles. A stack of your own does not substitute for the federation. See [Cross-Chain DEX](../protocol/cross-chain-dex.md#trust-model) for the model. ## Prerequisites @@ -108,7 +108,7 @@ Settled looks like this: ⬜ A `cross_chain_settlements` row exists on each chain ⬜ Each maker's `GET_ADDRESS` holds the counter-token in `balances` -If the orders stay `open` and no match row appears, the usual causes are, in order: the federation has no 2f+1 `cross_chain` quorum on testnet right now, an indexer is behind its chain tip, or the hub mirror from Step 2 is not delivering. +If the orders stay `open` and no match row appears, the usual causes are, in order: the testnet federation cannot reach its `cross_chain` quorum right now (see the note at the top of this page), an indexer is behind its chain tip, or the hub mirror from Step 2 is not delivering. --- diff --git a/operations/troubleshooting.md b/operations/troubleshooting.md index 7e545511..7d818de4 100644 --- a/operations/troubleshooting.md +++ b/operations/troubleshooting.md @@ -45,7 +45,7 @@ This document covers common problems, their symptoms, and how to diagnose and re **Litecoin-specific:** The decoder includes special handling for Litecoin HogEx transactions (stripping the `0x08` flag). If a new type of malformed block appears, the decoder may fail to parse it. Check logs for `bitcoinjs-lib` parse exceptions. -**Dogecoin-specific:** AuxPoW stripping must be enabled (`AUX_POW=1`). If this variable is missing, block parsing will fail on every Dogecoin block. +**Dogecoin-specific:** AuxPoW stripping is selected automatically from `NETWORK`; the legacy `AUX_POW` variable has no effect. If every Dogecoin block fails to parse, first check that `NETWORK` names a dogecoin network (for example `dogecoin-mainnet`). **Fix:** For transient failures, restarting the decoder usually resolves the issue. If the error recurs on the same block, file a bug report with the block hash and error message. diff --git a/protocol/actions/README.md b/protocol/actions/README.md index 484bf586..32a5a195 100644 --- a/protocol/actions/README.md +++ b/protocol/actions/README.md @@ -76,14 +76,14 @@ The same ACTION specifications apply across all chains. Chain-specific behavior | ACTION | Description | |---|---| | [`PRICE`](./price.md) | Publishes oracle price data on-chain (v0: validator COIN/FIAT snapshots, v1: user TOKEN/FIAT oracles) | -| [`ATTEST`](./attest.md) | External-data attestation lifecycle: v0=request (VM-emitted), v1=response (validator-broadcast), v2=expire (system-synthesized) | +| [`ATTEST`](./attest.md) | External-data attestation lifecycle: v0=request (VM-emitted), v1=response (validator-broadcast), v2=expire (system-synthesized), v3=relay request (cross-chain, BTC only), v4=relay response (origin chain only), v5=batch head and v6=batch continuation (system-published, Dogecoin only) | | [`ANCHOR`](./anchor.md) | Validator-broadcast, DOGE-only: the quorum-signed checkpoint bundle (v0, one anchor per network per cycle carrying every chain as a section, with SPV light-client roots and a publisher attestation), plus the cross-chain match archive (v1, publisher attestation always appended; v2 continuation chunks) for full-parse recoverability; at or above `ANCHOR_FOLD_ACTIVATION`, v3 folds the checkpoint sections and at most one archive section into a single ANCHOR action per cycle | ### Betting | ACTION | Description | |---|---| -| [`BET`](./bet.md) | Parimutuel betting markets end to end: v0=create market, v1=cancel, v2=place bet, v3=resolve | +| [`BET`](./bet.md) | Parimutuel betting markets end to end: v0=create market, v1=cancel, v2=place bet, v3=resolve, v4=edit feed lists (at or above `BET_FEED_LIST_EDIT_ACTIVATION`) | ### Governance diff --git a/protocol/actions/batch.md b/protocol/actions/batch.md index 1424a11b..b050fa5f 100644 --- a/protocol/actions/batch.md +++ b/protocol/actions/batch.md @@ -56,7 +56,7 @@ This example registers the JDOG parent token and three of its children in one tr - **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, and nothing on the indexing path enforces it. The decoder exposes one `raw_data` payload per transaction and the batch dispatcher carries that same payload into every sub-command, so a second `FILE` is not rejected: it is recorded valid and stored with the FIRST file's payload duplicated into its record. Because `FILE` records are immutable once mined, the mistake is silent and permanent. This is an architectural limit of the wire format, not an explicit `actionLimits` rule in `batch.js`; the SDK's batch builder refuses to compose such a `BATCH`, but a caller hand-rolling encoder input must enforce the limit itself. - 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. +- `BATCH(SEND, MESSAGE)` is the canonical composition for transferring a token that has [active gated content](./send.md): the `MESSAGE` v2 re-encrypts the content keys to the recipient. It is required unless every pack gating that token sets an unlock threshold the recipient will still be below after the transfer (see [`GATE_MIN_AMOUNT`](../token-gated-content.md#unlock-thresholds-gate_min_amount)); in that case a plain `SEND` with no `MESSAGE` is valid. --- diff --git a/protocol/actions/collect.md b/protocol/actions/collect.md index d72538ba..92ce68ee 100644 --- a/protocol/actions/collect.md +++ b/protocol/actions/collect.md @@ -106,7 +106,7 @@ flowchart TD - By default all pending rewards are collected in a single action; a partial claim is available via the optional trailing `AMOUNT` (see Partial Claim above) - Rewards may be collected at any time while a stake is active - Rewards can also be collected after initiating `UNSTAKE` during the cooldown period -- A single pubkey can earn from multiple capabilities in the same round, e.g. a validator with both `price` and `oracle_publish` capabilities can earn the per-round consensus reward AND the per-publish broadcast reward in the same round +- A single pubkey can accrue rewards from several capabilities, e.g. `attest_fee` from `attestation` and `anchor_bundle` from `oracle_publish`; the oracle round labels are not minted today (see Reward Population Path), so holding `price` alongside `oracle_publish` adds no per-round reward --- diff --git a/protocol/actions/deploy.md b/protocol/actions/deploy.md index b53528f7..e529bea8 100644 --- a/protocol/actions/deploy.md +++ b/protocol/actions/deploy.md @@ -70,7 +70,7 @@ Final slice of the same group; a later DEPLOY|2 (or DEPLOY|3) then assembles by - Available on all chains - `CODE_ENCODING` must be valid UTF-8 JavaScript source code (base64-encoded at/after the `DEPLOY_BASE64_CODE` activation, hex-encoded before it; see Encoding activation below) and must not exceed 64KB (65536 bytes decoded) - `GAS_LIMIT` must be a positive integer -- The VM validates syntax before charging gas: +- The VM validates syntax before charging gas. Ahead of step 1, a nesting-depth pre-check (consensus-gated on its own per-coin `VM_LINT_NESTING_DEPTH_ACTIVATION` height gate) rejects source whose `(`, `[`, `{` and `${` delimiters nest more than 64 deep; it is live from genesis on regtest, armed on testnet at each chain's own local block height (`BTC:testnet` 156230, `LTC:testnet` 4912984, `DOGE:testnet` 67989993), and unarmed on mainnet: 1. V8 compilation check, rejects JavaScript syntax errors 2. Acorn metering pass, rejects syntax beyond ES2020 (the supported syntax set) 3. Reserved identifier check, rejects code containing `__gas` (reserved for gas metering), the allocator metering helpers (`__concat`, `__setconcat`, `__setconcatL`, `__tmpl`, `__tmpltag`, `__tmpltagm`, `__arrspread`, `__objspread`, `__objspreadmeter`), or the call-depth metering helpers (`__depth_enter`, `__depth_exit`); all are harness-injected and a contract may not define or reference them @@ -80,14 +80,16 @@ Final slice of the same group; a later DEPLOY|2 (or DEPLOY|3) then assembles by 7. Banned generator check (consensus-gated), rejects `function*`, generator methods, and `yield`; live from genesis on testnet/regtest 8. Banned rest-pattern check (consensus-gated on its own [`REST_PATTERN_METER`](../flag-days.md) gate), rejects a rest pattern the gas meter cannot charge: a rest parameter, a nested rest inside a destructuring pattern, a catch-clause rest, and a rest in a `for-of`/`for-in` loop head; live from genesis on testnet/regtest 9. Banned WebAssembly check (consensus-gated), rejects any reference to the global `WebAssembly`; live from genesis on testnet/regtest - 10. Banned `with` statement check (consensus-gated on its own per-coin [`LINT_BANNED_WITH_ACTIVATION`](../protocol-activation.md#where-the-values-live) height gate), rejects any `with` statement, whose runtime identifier rebinding would get past the name-based checks and the gas meter; live from genesis on regtest, unarmed on mainnet and testnet until an operator arms a height -- Checks 4, 6 and 9 also reject the global-object-qualified spellings (`globalThis.Math.pow`, `this.Promise`, `globalThis.globalThis.WebAssembly`); see [Global-object spellings](../../components/vm/operations.md#global-object-spellings) + 10. Banned `with` statement check (consensus-gated on its own per-coin [`LINT_BANNED_WITH_ACTIVATION`](../protocol-activation.md#where-the-values-live) height gate), rejects any `with` statement, whose runtime identifier rebinding would get past the name-based checks and the gas meter; live from genesis on regtest, armed on testnet at each chain's own local block height (`BTC:testnet` 156230, `LTC:testnet` 4912984, `DOGE:testnet` 67989993), and unarmed on mainnet +- Checks 4, 6 and 9 also reject the global-object-qualified spellings (`globalThis.Math.pow`, `this.Promise`, `globalThis.globalThis.WebAssembly`) and, under the `LINT_DESTRUCTURE_ACTIVATION` gate (armed like check 10), destructured reads such as `const { pow } = Math` and `const { Promise } = globalThis`; see [Global-object spellings](../../components/vm/operations.md#global-object-spellings) - If syntax validation fails, the deployment is rejected with `invalid: CODE_ENCODING ()` and no gas is charged ```mermaid flowchart TD - Start["DEPLOY submitted"] --> V8{"1. V8 compilation check"} - V8 -->|"syntax error"| Reject["invalid: CODE_ENCODING (reason), no gas charged"] + Start["DEPLOY submitted"] --> Nest{"Nesting-depth pre-check, VM_LINT_NESTING_DEPTH_ACTIVATION gate"} + Nest -->|"fail"| Reject["invalid: CODE_ENCODING (reason), no gas charged"] + Nest -->|"pass"| V8{"1. V8 compilation check"} + V8 -->|"syntax error"| Reject V8 -->|"pass"| Acorn{"2. Acorn metering pass, ES2020 syntax set"} Acorn -->|"fail"| Reject Acorn -->|"pass"| Reserved{"3. Reserved identifier check, __gas and metering helpers"} diff --git a/protocol/actions/file.md b/protocol/actions/file.md index 7b2fc37c..dbe022df 100644 --- a/protocol/actions/file.md +++ b/protocol/actions/file.md @@ -82,6 +82,10 @@ fee, are what bound that growth: - the serve-time decompression ratio cap, which bounds what a crafted payload can inflate to in a reader. +The encoder applies the envelope ceiling to the uncompressed file, so `COMPRESSION` lowers +the cost of a file that fits but does not raise the largest file the envelope accepts; see +[Taproot envelope: Choosing it](../taproot-envelope.md#choosing-it). + If FILE volume ever makes storage growth an operational problem, the ceiling is the lever to reach for. Anyone sizing indexer or explorer capacity should plan against the ceiling and expected FILE rate rather than assuming a fee throttles demand, because none does. diff --git a/protocol/actions/issue.md b/protocol/actions/issue.md index 3938a258..706faefa 100644 --- a/protocol/actions/issue.md +++ b/protocol/actions/issue.md @@ -134,7 +134,7 @@ This example issues a TEST token with a max supply of 100, and a maximum mint of - **Coin-qualified ticker namespace.** At or above `LIST_TICK_COIN_ACTIVATION`, a new top-level `TICK` whose name begins with a coin root (`BTC`, `LTC` or `DOGE`, case-folded) followed by a colon, or with a `RESERVED_FUTURE_ROOTS` entry followed by a colon, is refused with `invalid: TICK (reserved)`, unless a token of that name already exists. Existing tickers and the `^id` form are unaffected. Below the gate the handler is unchanged. - **Format `7` refusals**, in addition to the field checks above: a destination not in `COINS` other than this chain is `invalid: BRIDGE_CHAINS`; on a row with `LOCK_BRIDGE` already set, a format `7` that changes `BRIDGE_CHAINS` to a different value is `invalid: BRIDGE_CHAINS (locked)` and one that changes `MIN_DEPTH` to a different value is `invalid: MIN_DEPTH (locked)` (a field left empty, or restated with its current value, is not refused); a non-empty `BRIDGE_CHAINS` naming a native tick that contains a dot is `invalid: TICK (subassets are not bridgeable yet)`; a non-empty `BRIDGE_CHAINS` on a token carrying a live `ALLOW_LIST`, `BLOCK_LIST` or controller binding is `invalid: TICK (policy-bound tokens are not bridgeable yet)`; a non-empty `BRIDGE_CHAINS` whose `ALLOW_LIST` or `BLOCK_LIST` membership exceeds `XPOLICY_MAX_MEMBERS` (10,000) is `invalid: TICK (policy list exceeds XPOLICY_MAX_MEMBERS)`. Behind `TOKEN_POLICY_INHERITANCE_ACTIVATION` the policy-bound refusal lifts (see [Token Bridge](../token-bridge.md#policy-inheritance)); the controller refusal and the membership ceiling never lift on their own. - **Formats `5` and `6` on a bridged token.** While a row's `BRIDGE_CHAINS` is non-empty or its `bridged` bit is set (the bit is set by the first applied bridge lock and never cleared), a format `5` (list edit) or format `6` (controller bind/unbind), and a format `0` re-issue carrying a non-empty `ALLOW_LIST` or `BLOCK_LIST`, are refused with `invalid: TICK (bridged tokens cannot be policy-bound yet)`. Emptying `BRIDGE_CHAINS` afterward does not reopen the door while bridged copies are outstanding. Behind `TOKEN_POLICY_INHERITANCE_ACTIVATION`, formats `5` and a listed format `0` on a bridged token apply and propagate to every bridged copy (see [Token Bridge](../token-bridge.md#policy-inheritance)); a controller bind (format `6`) on a bridged token stays refused either way. -- **Format `6` refusals** (controller bind/unbind) are listed in [Bind and unbind rejections](../controller-bound-tokens.md#bind-and-unbind-rejections). A class whose unbind is still inside its `COOLDOWN_BLOCKS` cooldown refuses both a new bind (`invalid: ACTION_CLASS (already bound)`) and a second unbind (`invalid: ACTION_CLASS (already unbinding)`), so replacing a controller means unbind, wait out the cooldown, then bind. +- **Format `6` refusals** (controller bind/unbind) specific to a binding, including the sleeping-`SOURCE`, owner and ownership-escrow gates a bind or unbind meets first, are listed in [Bind and unbind rejections](../controller-bound-tokens.md#bind-and-unbind-rejections); a format `6` also runs the general checks in these Rules. A class whose unbind is still inside its `COOLDOWN_BLOCKS` cooldown refuses both a new bind (`invalid: ACTION_CLASS (already bound)`) and a second unbind (`invalid: ACTION_CLASS (already unbinding)`), so replacing a controller means unbind, wait out the cooldown, then bind. ## Notes - `ISSUE` `TICK` with `MAX_SUPPLY` and `MINT_SUPPLY` set to any non `0` value, to mint supply until `MAX_SUPPLY` is reached (owner can mint beyond `MAX_MINT`) diff --git a/protocol/actions/nodeproof.md b/protocol/actions/nodeproof.md index 1b8a0a65..17358e5e 100644 --- a/protocol/actions/nodeproof.md +++ b/protocol/actions/nodeproof.md @@ -42,7 +42,7 @@ A verdict for epoch height 2016 with 1 passing validator, signed by 2 verifiers - Each signature must come from a member of the eligible set, must be a valid Ed25519 signature over the canonical message, and must not be duplicated; the total count of valid, deduplicated signers must meet quorum - For each `PASS_PK` that holds `full_node` capability stake at `EPOCH_HEIGHT`, the indexer writes a `full_node_verifications` row; the insert is idempotent on `(epoch_height, signing_pubkey)` - This action is validator-broadcast; it is not VM-emitted and not meaningful for end users -- This action writes no ledger rows; reward eligibility is derived later during `PRICE` finalization +- This action writes no ledger rows; its `full_node_verifications` rows are the input a full-node reward derivation would read, and no consensus rail reads them for rewards today (see [COLLECT → Reward Population Path](./collect.md#reward-population-path)) ### Canonical signed message @@ -56,13 +56,13 @@ At and above the EQUIV flag-day, this canonical string is wrapped with the unifo ### Reward eligibility -A staking source earns the full-node reward tranche at block `B` only if: +This is the designed eligibility rule. It is not applied today, because no consensus rail mints an oracle-round reward (see [COLLECT → Reward Population Path](./collect.md#reward-population-path)). Once one does, a staking source earns the full-node reward tranche at block `B` only if: - It has a `passed` `full_node_verifications` row with `block_index` in `(B - PROOF_WINDOW_BLOCKS, B]` and still holds `full_node` capability stake at `B` (this makes the node verifier-eligible: able to vouch in later verdicts) - Over the trailing `REWARD_PASS_WINDOW_BLOCKS`, the source answered at least `MIN_PASS_RATE_BPS` basis points of the challenge epochs that actually produced a verdict (the denominator counts only epochs the federation ran, so an outage never costs anyone) The gate uses integer math: `passed_epochs * 10000 >= MIN_PASS_RATE_BPS * total_epochs`. The verified set is deduplicated by staking source (one operator, one full node, one share). A validator that does not run a full node simply fails the challenges, earns nothing from the full-node tranche, and is never penalised: its `full_node` bond is untouched and there is no failed-challenge slash. -See `db.getFullNodeParticipation` and `PRICE` for the reward derivation. +`db.getFullNodeParticipation` implements this participation gate, but no production path calls it today: the indexer's PRICE v0 parse derives no reward rows, so the tranche is neither computed nor paid (see [COLLECT → Reward Population Path](./collect.md#reward-population-path)). ## The Derived Challenge @@ -106,7 +106,7 @@ sequenceDiagram | `FULLNODE.CONFIRM_DEPTH` | `100` | Target-block burial depth (reorg safety) | | `FULLNODE.PROOF_WINDOW_BLOCKS` | `300` | How long a passed proof keeps a node verifier-eligible (able to vouch in later verdicts) | | `FULLNODE.VERDICT_ACCEPT_WINDOW_BLOCKS` | `24` | Max lag from epoch to accepted verdict | -| `FULLNODE.REWARD_SHARE` | `'0'` | Fraction of the oracle-round budget routed to full nodes (raise to `'0.25'` to enable; see `PRICE`) | +| `FULLNODE.REWARD_SHARE` | `'0'` | Fraction of the oracle-round budget the designed split routes to full nodes; a consensus parameter pinned in the coin bundle. Raising it alone pays nothing: the tranche also needs a consensus rail that mints oracle-round rewards (none does today; see [COLLECT → Reward Population Path](./collect.md#reward-population-path)) and a seeded `GENESIS_VERIFIERS` | | `FULLNODE.REWARD_PASS_WINDOW_BLOCKS` | `1008` | Trailing window the reward pass-rate is measured over (= 7 daily challenge epochs) | | `FULLNODE.MIN_PASS_RATE_BPS` | `7000` | Min pass rate to earn the tranche, in basis points (`7000` = 70%, i.e. pass at least 5 of 7, miss up to 2) | | `FULLNODE.GENESIS_VERIFIERS` | `[]` | Bootstrap verifier pubkeys (operator-seeded) | @@ -124,7 +124,7 @@ With no seeded `GENESIS_VERIFIERS` and no verified nodes, no verdict can reach q - The only thing that reaches the wire is the federation's signed verdict over who answered correctly, exactly the trust model of `ATTEST v1` (the indexer verifies signatures and schedule; the federation verifies the off-chain fact) - `PUBKEY` is the consensus token name for verifier public keys (consistent with ANCHOR, ATTEST, and PRICE) - See [`ATTEST`](./attest.md) for the signature-verification and responsible-set template this models on -- See [`PRICE`](./price.md) for oracle-round reward derivation (two-tranche split) and `validator_rewards` / `COLLECT` for the reward ledger and claim flow +- See [COLLECT → Reward Sources](./collect.md#reward-sources) for the designed two-tranche split (`oracle_base` / `oracle_full_node`), [Reward Population Path](./collect.md#reward-population-path) for why nothing mints it today, and `validator_rewards` / `COLLECT` for the reward ledger and claim flow --- diff --git a/protocol/actions/price.md b/protocol/actions/price.md index a37b8ff4..e9ac3630 100644 --- a/protocol/actions/price.md +++ b/protocol/actions/price.md @@ -206,7 +206,7 @@ PRICE v0 publishers are auto-qualified by the capability model. No special "Tier | Role | Broadcasts finalized PRICE v0 rounds on-chain | | Stake gate | `SUM(amount)` across the pubkey's active stake rows ≥ `min_stake[oracle_publish]` (governance default; see hub `ProviderRegistry` / capability config) | | Chain | STAKE happens on BTC; PRICE v0 can be published on any chain (DOGE recommended for fees) | -| Overlap | Allowed: same pubkey may hold both `price` and `oracle_publish` (and earn both rewards in the same round) | +| Overlap | Allowed: same pubkey may hold both `price` and `oracle_publish`. No oracle round reward is minted today (see [COLLECT → Reward Population Path](./collect.md#reward-population-path)); the claimable reward tied to `oracle_publish` is the ANCHOR publisher reward (`anchor_bundle`) | | Cooldown | Configurable, default 1000 blocks | | Activation delay | 6 BTC blocks (~1 hour) | diff --git a/protocol/actions/send.md b/protocol/actions/send.md index 9613d5c0..9f043d42 100644 --- a/protocol/actions/send.md +++ b/protocol/actions/send.md @@ -57,17 +57,21 @@ This example sends 5 BRRR tokens to 1ExampleAddressXXXXXXXXXXXXXXXXXXX and 1 TES - `MEMO` characters **NOT** allowed are : - pipe `|` (used as field separator) - semicolon `;` (used as command separator) -- **Token-gated transfer rule.** If `TICK` has at least one active gated [`FILE`](./file.md) (a `FILE` with a non-empty `GATE_TICKER = TICK` that has not been superseded), the `SEND` is only valid when it appears in the **same transaction** as a [`MESSAGE` v2](./message.md) (ECIES) addressed to the `DESTINATION`. Typically the sending wallet composes this as `BATCH(SEND, MESSAGE)`. The rule is judged per leg, after legs to the same `DESTINATION` and `TICK` are consolidated: a leg whose `DESTINATION` has no sibling `MESSAGE` is recorded `invalid: gated token transfer requires key handoff message` and moves nothing, while the other legs of a multi-recipient send still settle and sibling actions (if any) survive. The indexer enforces the structural presence of the MESSAGE, checking only its `VERSION` `2` and matching `DESTINATION`, never the sibling `MESSAGE`'s own verdict; the wallet enforces the cryptographic correctness of the key payload at unlock time. +- **Token-gated transfer rule.** If `TICK` has at least one active gated [`FILE`](./file.md) (a `FILE` recorded `valid` with a non-empty `GATE_TICKER = TICK`), the `SEND` is only valid when it appears in the **same transaction** as a [`MESSAGE` v2](./message.md) (ECIES) addressed to the `DESTINATION`. Typically the sending wallet composes this as `BATCH(SEND, MESSAGE)`. The rule is judged per leg, after legs to the same `DESTINATION` and `TICK` are consolidated: a leg whose `DESTINATION` has no sibling `MESSAGE` is recorded `invalid: gated token transfer requires key handoff message` and moves nothing, while the other legs of a multi-recipient send still settle and sibling actions (if any) survive. The indexer enforces the structural presence of the MESSAGE, checking only its `VERSION` `2` and matching `DESTINATION`, never the sibling `MESSAGE`'s own verdict; the wallet enforces the cryptographic correctness of the key payload at unlock time. The requirement is CONDITIONAL where the gating files set an unlock threshold (`GATE_MIN_AMOUNT`). Each pack gating `TICK` (same publisher, `GATE_TICKER` and `KEY_HASH`) has an effective threshold equal to the MINIMUM `GATE_MIN_AMOUNT` across its files, and a pack with any threshold-less file is unconditional. A pack requires the handoff when it is unconditional, or when the DESTINATION's balance of `TICK` after this action (its pre-action balance plus everything this action sends it, totalled across all legs) reaches that threshold. The MESSAGE is required if at least one pack requires it; if every pack sits above the recipient's post-send balance, a plain `SEND` with no MESSAGE is valid and the recipient deliberately receives no key. See [Token-Gated Content](../token-gated-content.md). + How that total is summed depends on [`SEND_GATED_TOTAL_TICK_ID_ACTIVATION`](../flag-days.md), which is not yet armed on mainnet. Before the gate is active, the total is summed per `DESTINATION` and literal `TICK` as written, so spelling variants of one tick are measured apart. Once the gate is active, it is summed per `DESTINATION` and resolved `TICK_ID`, so variants of one tick, for example a different letter case or the `^` form, count together toward the threshold. The gate changes only whether the handoff is owed; it never changes which legs settle. + + A gated `FILE` stays active once it is recorded `valid`. No later action supersedes, retires or revokes it: republishing content under a new key adds a pack beside the old one, and a transfer of the token's ownership leaves the former issuer's packs counting. Only a chain reorg that rolls back the block holding the `FILE` removes it. + The `GATE_TICKER = TICK` match depends on how `TICK` is written. At or above `SEND_CARET_PACK_KEY_ACTIVATION`, a `^` `TICK` is resolved to the ticker name before the match. Below it (the gate is not yet armed on mainnet), `TICK` is matched as written, so a `SEND` that writes it as `^` finds no gated `FILE` and owes no handoff. See [Index ID References](../index-id-references.md). ```mermaid flowchart TD - Start["Each consolidated SEND leg (TICK, DESTINATION)"] --> Gated{"TICK has an active gated FILE,
non-empty GATE_TICKER, not superseded?"} + Start["Each consolidated SEND leg (TICK, DESTINATION)"] --> Gated{"TICK has a valid gated FILE,
non-empty GATE_TICKER?
(valid gated FILEs stay active)"} Gated -->|"no"| PlainOK["Plain SEND is valid, no MESSAGE needed"] - Gated -->|"yes"| Threshold{"Any pack gating TICK is unconditional,
no GATE_MIN_AMOUNT,
or DESTINATION's post-send TICK balance
reaches that pack's effective threshold?"} + Gated -->|"yes"| Threshold{"Any pack gating TICK is unconditional,
no GATE_MIN_AMOUNT,
or DESTINATION's post-send TICK balance
reaches that pack's effective threshold?
(action total per literal TICK before
SEND_GATED_TOTAL_TICK_ID_ACTIVATION,
per resolved TICK_ID once it is active)"} Threshold -->|"no pack requires it"| PlainOK Threshold -->|"yes, at least one pack requires it"| NeedMsg["MESSAGE v2 (ECIES) to DESTINATION
required in the same transaction"] NeedMsg --> Present{"Sibling MESSAGE v2 present
in the same transaction?"} diff --git a/protocol/actions/swap.md b/protocol/actions/swap.md index 06034d03..09dd1841 100644 --- a/protocol/actions/swap.md +++ b/protocol/actions/swap.md @@ -59,7 +59,7 @@ This example sells JDOG (BTC-chain) ownership for 5,000,000 WOWCOIN on LTC. `GET ``` SWAP|0|BTC|JDOG||1|LTC|WOWPEPE||1|LJDogZS6tQcSxwfxhv6XKKjcyicYA4Feev||||Swap JDOG ownership for WOWPEPE ownership cross-chain -This example atomically swaps ownership of JDOG (BTC chain) for ownership of WOWPEPE (LTC chain). `GET_ADDRESS` is the LTC address that will become the new owner of WOWPEPE for this order's SOURCE. +This example swaps ownership of JDOG (BTC chain) for ownership of WOWPEPE (LTC chain); each chain transfers its own leg against the signed match (see [Residual reorg risk](../cross-chain-dex.md#residual-reorg-risk)). `GET_ADDRESS` is the LTC address that will become the new owner of WOWPEPE for this order's SOURCE. ``` ## Rules @@ -82,7 +82,7 @@ This example atomically swaps ownership of JDOG (BTC chain) for ownership of WOW - Additional `ORDER`/`SWAP`/`DISPENSER` ownership offers for this `TICK` from the original owner - Holder-side actions are unaffected: `SEND`, `MINT` (if mint window open), `DIVIDEND` payouts, `DEPOSIT`/`WITHDRAW`, `DESTROY` of held balance - Child `TICK`s (e.g. `JDOG.SUB1`) have independent ownership records and are **not** transferred when a parent's ownership is sold -- On match: ownership transfers atomically to the counterparty's SOURCE +- On match: ownership transfers to the counterparty's SOURCE. On a cross-chain swap each chain transfers its own leg against the signed match, so the two legs settle separately rather than in one step - On cancel (Version 1) or `EXPIRATION`: ownership returns to the original SOURCE ### Sweep Closure diff --git a/protocol/activation-catalogue.md b/protocol/activation-catalogue.md index b51aca6b..113569a0 100644 --- a/protocol/activation-catalogue.md +++ b/protocol/activation-catalogue.md @@ -47,6 +47,7 @@ same chain or field. | `attest_responsible_widening_activation` | `ATTEST_RESPONSIBLE_WIDENING_ACTIVATION` | height | | `attest_zero_conf_activation` | `ATTEST_ZERO_CONF_ACTIVATION` | height | | `bet_feed_list_edit_activation` | `BET_FEED_LIST_EDIT_ACTIVATION` | height | +| `bigint_surface_strip_heights` | `BIGINT_SURFACE_STRIP_ACTIVATION` | height | | `bridge_policy_detach_activation` | `BRIDGE_POLICY_DETACH` | height | | `bridge_policy_refusal_record_activation` | `BRIDGE_POLICY_REFUSAL_RECORD_ACTIVATION` | height | | `bridge_row_fields_terminal_activation` | `BRIDGE_ROW_FIELDS_TERMINAL_ACTIVATION` | height | @@ -138,6 +139,8 @@ same chain or field. | `train_activation` | `TRAIN_ACTIVATION` | ruleset | | `vm_deploy_lint_pkg3_activation` | `VM_DEPLOY_LINT_PKG3_ACTIVATION` | height | | `vm_exec_lint_activation` | `VM_EXEC_LINT_ACTIVATION` | height | +| `vm_lint_banned_with_heights` | `VM_LINT_BANNED_WITH_ACTIVATION` | height | +| `vm_lint_destructure_heights` | `VM_LINT_DESTRUCTURE_ACTIVATION` | height | | `vm_lint_global_alias_activation` | `VM_LINT_GLOBAL_ALIAS_ACTIVATION` | height | | `vm_lint_nesting_depth_activation` | `VM_LINT_NESTING_DEPTH_ACTIVATION` | height | | `vm_lint_optional_chain_heights` | `VM_LINT_OPTIONAL_CHAIN_ACTIVATION` | height | diff --git a/protocol/constants.js b/protocol/constants.js index 73b568a6..14f3ba40 100644 --- a/protocol/constants.js +++ b/protocol/constants.js @@ -888,7 +888,7 @@ const ATTEST_REQUEST_CAPS = { // ATTEST_RELAY_ACTIVATION (attestation Phase 5 cross-chain delivery): the flag-day // at/above which the two relay legs of the §12 CrossChainEngine model are accepted on chain: -// ATTEST v3 (an LTC/DOGE-origin request materialized onto BTC, carrying 2f+1 cross_chain +// ATTEST v3 (an LTC/DOGE-origin request materialized onto BTC, carrying a quorum of cross_chain // signatures) and ATTEST v4 (the BTC response relayed back to the origin chain, same signature // rail). Both are new VERSION values on an existing action, so an indexer that has NOT been // upgraded rejects them as an unknown VERSION while an upgraded one accepts: every indexer and diff --git a/protocol/controller-bound-tokens.md b/protocol/controller-bound-tokens.md index 187d1181..0debc04c 100644 --- a/protocol/controller-bound-tokens.md +++ b/protocol/controller-bound-tokens.md @@ -129,7 +129,13 @@ VERSION|TICK|CONTROLLER|ACTION_CLASS|COOLDOWN_BLOCKS|UNBIND|MEMO Rules: -- Only the token owner can bind or unbind (the standard `ISSUE` owner gate). +- Only the token owner can bind or unbind (the standard `ISSUE` owner gate, + `invalid: issued by another address`). +- While the token's ownership record is escrowed in an open ownership offer (`ORDER`, `SWAP` or + `DISPENSER` with `GIVE_OWNERSHIP=1`), the owner can neither bind nor unbind any class, + including starting an unbind: `invalid: TICK (ownership escrowed)`. The block lifts when the + offer is cancelled, expires, matches or is swept; see + [Token Ownership Sales](./actions/order.md#token-ownership-sales). - At bind time the indexer verifies `CONTROLLER` resolves to an existing contract in `valid` (active) state, mirroring the contract check in [`EXECUTE`](./actions/execute.md). - Bindings are **append-only** events in `token_controllers`. The effective controller for a @@ -161,12 +167,19 @@ stateDiagram-v2 ### Bind and unbind rejections -Every bind/unbind refusal below is the `STATUS` of the `ISSUE` v6 or [`ADDRESS`](./actions/address.md) -v1 row, byte for byte. `ADDRESS` v1 emits the same strings for an account binding, except the -`TICK` ones, which only a token binding can hit. +The table lists every refusal specific to a bind or unbind, and each verdict is the `STATUS` of +the `ISSUE` v6 or [`ADDRESS`](./actions/address.md) v1 row, byte for byte. `ADDRESS` v1 emits +the same strings for an account binding, except the `TICK` ones and the owner gate, which only +a token binding can hit. A bind or unbind also runs the general checks its action applies to +every format (for `ISSUE`, the `TICK` name rules, the issuance fee and `MEMO`; see +[`ISSUE` Rules](./actions/issue.md#rules)); those verdicts are not repeated here. The `SOURCE` +and owner rows are checked before any `CONTROLLER` or `ACTION_CLASS` row. | Verdict | Bind / unbind | Format | When | |---|---|---|---| +| `invalid: SOURCE (sleeping)` | both | both | `SOURCE` is asleep at this block (see [`SLEEP`](./actions/sleep.md)). | +| `invalid: issued by another address` | both | `ISSUE` v6 | `SOURCE` is not the token's current owner (the standard `ISSUE` owner gate). | +| `invalid: TICK (ownership escrowed)` | both | `ISSUE` v6 | The token's ownership record is escrowed in an open `ORDER`, `SWAP` or `DISPENSER` offer (`GIVE_OWNERSHIP=1`). This also refuses starting an unbind; close the offer first. | | `invalid: TICK (unknown)` | both | `ISSUE` v6 | `TICK` names no existing token. | | `invalid: CONTROLLER (unknown)` | both | both | `CONTROLLER` is set but no contract has that `ACTION_INDEX`. | | `invalid: CONTROLLER (not active)` | both | both | The `CONTROLLER` contract exists but its status is not `valid`. | @@ -201,7 +214,7 @@ running any guard; see [Actions refused outright](#actions-refused-outright-on-a | Class | Gates | Guard `action_type` | |---|---|---| -| `transfer` | `SEND`, [`DEPOSIT`](./actions/deposit.md), [`WITHDRAW`](./actions/withdraw.md) (and the balance leg of bulk moves) | `SEND` / `DEPOSIT` / `WITHDRAW` | +| `transfer` | `SEND`, [`DEPOSIT`](./actions/deposit.md), [`WITHDRAW`](./actions/withdraw.md) (and the balance leg of bulk moves) | `SEND` / `AIRDROP` / `DIVIDEND` / `SWEEP` / `DEPOSIT` / `WITHDRAW` | | `trade` | [`ORDER`](./actions/order.md) / [`SWAP`](./actions/swap.md) / [`DISPENSER`](./actions/dispenser.md) create, and a `DISPENSER` refill from the `DISPENSER_REFILL` flag day | `ORDER_CREATE` / `SWAP_CREATE` / `DISPENSER_CREATE` / `DISPENSER_REFILL` | | `burn` | [`DESTROY`](./actions/destroy.md) | `DESTROY` | | `mint` | [`MINT`](./actions/mint.md) supply creation | `MINT` | diff --git a/protocol/error-codes.md b/protocol/error-codes.md index 155b7e8a..2c354462 100644 --- a/protocol/error-codes.md +++ b/protocol/error-codes.md @@ -33,7 +33,7 @@ Errors are JSON objects: | `CHECKPOINT_NOT_FOUND` | 404 | No quorum-signed checkpoint at that height | Maybe: checkpoints lag the tip | | `RATE_LIMITED` | 429 | Per-IP request budget exhausted (app-wide default 1080/min; some routes have tighter limits, see [explorer rate limiting](../components/explorer/configuration.md#rate-limiting)) | Yes: back off; honor `RateLimit-*` headers | | `SERVER_ERROR` | 500 | Unexpected internal failure | Yes: with backoff | -| `UPSTREAM_ERROR` | 502 | The colocated indexer fee service failed | Yes: with backoff | +| `UPSTREAM_ERROR` | 502 | The colocated indexer fee service failed. Four indexer proxy routes return it when their indexer call fails: native fee quote, oracle fee quote, fee schedule and action pre-flight (a validator-set proof reports the same failure as `INDEXER_UNAVAILABLE`) | Yes: with backoff | | `COIN_NOT_AVAILABLE` | 503 | Coin supported but not configured for data requests here | No: use another instance | | `INDEXER_NOT_CONFIGURED` | 501 | The route needs an indexer API this instance lacks for the coin/network: native and oracle fee quote, fee schedule, action pre-flight, and validator-set proof | No: use another instance | | `SERVICE_UNAVAILABLE` | 503 | The endpoint cannot serve this request | No | diff --git a/protocol/index-id-references.md b/protocol/index-id-references.md index 265398da..53c75ffe 100644 --- a/protocol/index-id-references.md +++ b/protocol/index-id-references.md @@ -153,13 +153,18 @@ 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 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. +2. Within one action, the `SOURCE` address is registered first. For the actions with a + fixed positional wire layout (`MINT`, `MESSAGE`, `DISPENSER`, `ORDER` and `SWAP`), a + pre-pass then registers the new addresses in their single-value fields in byte-sorted + (binary) order of their VALUE, before the action's handler runs. Ordering by value, not + by field position, keeps that assignment stable across client and indexer code changes. + Every other id-receiving field sits outside that pre-pass: the single-value fields of + `ISSUE` (`TRANSFER`, `TRANSFER_SUPPLY`), `SWEEP` (`DESTINATION`) and `DEPLOY` + (`SLASH_DESTINATION`), and the multi-value fields (`SEND.DESTINATION` recipients and + `LIST.ITEM` entries). Their handler interns them in a fixed order that is identical on + every node, so they receive deterministic ids as well. That order is the handler's, not + the byte order of the values, so two new addresses introduced by one `ISSUE` are not + assigned by the byte-sorted rule above. 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). @@ -169,9 +174,11 @@ 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 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. +This assignment rule is a frozen wire rule. The set of id-receiving fields above, the set +of actions the value-sorted pre-pass covers, the value-sorted order for those actions' +single-value fields and the handler order for every other id-receiving field are part of +consensus; changing any of them, including moving an action into or out of the pre-pass, +is a wire-format change. ## SDK behavior diff --git a/protocol/json/README.md b/protocol/json/README.md index 4f9c0a34..2a2609fc 100644 --- a/protocol/json/README.md +++ b/protocol/json/README.md @@ -20,6 +20,8 @@ v1.1.2 changes no field. It states the `dns` entry requirement in draft-04 keywo - v1.1.0: [schema](./token-information-standard-v1.1.0-schema.json), [example](./token-information-standard-v1.1.0-example.json). Frozen as published; its four media definitions require `["type", "data"]`, so a `data_ref`-only entry fails validation against it. Its `dns` rule has the same draft-04 gap as v1.1.1. - 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 (no new required field), so a v1.0.0 document is a valid v1.1.0 document unless it already used one of those five names as an extra with a type or length v1.1.0 now declares (`locked` must be a boolean; `title`, `data_ref` and `pack_id` strings of at most 255 characters; `packs` an object of pack entries). Its `dns` rule has the same draft-04 gap as v1.1.1. +The frozen v1.1.0 and v1.1.1 examples show a `locked` entry (`stems.zip`) whose `data_ref` points at a sibling-chain `FILE`, which the token cannot gate, and the v1.1.1 example also points its public artwork at the same `FILE` as a `locked` entry. Follow the current example: a `locked` entry references a same-chain `FILE`, and no public entry shares a `data_ref` with a `locked` one. + 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.2-example.json b/protocol/json/token-information-standard-v1.1.2-example.json index 4357d079..74b6d6c0 100644 --- a/protocol/json/token-information-standard-v1.1.2-example.json +++ b/protocol/json/token-information-standard-v1.1.2-example.json @@ -77,7 +77,7 @@ },{ "type": "standard", "name": "On-chain artwork", - "data_ref": "action:12345" + "data_ref": "action:DOGE:67890" }], "audio": [{ @@ -140,7 +140,7 @@ "name": "stems.zip", "title": "Stem Pack", "data": "https://domain.com/stems-preview.zip", - "data_ref": "action:DOGE:67890", + "data_ref": "action:12346", "locked": true, "pack_id": "deluxe" }], diff --git a/protocol/protocol-activation.md b/protocol/protocol-activation.md index e52b30e6..0fd9065b 100644 --- a/protocol/protocol-activation.md +++ b/protocol/protocol-activation.md @@ -49,8 +49,8 @@ choice matters (below). The check is per network, so mainnet, testnet, and regte own threshold and cross independently. A rule may also leave **both** at 0, in which case condition 1 is the whole gate. That is how the -ACTIONs themselves are registered: all 36 carry `0` for every time and height, 21 at version `0.1.0` -and 16 at `0.2.0`, so an action becomes available as soon as the node runs new enough code. Every +ACTIONs themselves are registered: all 38 carry `0` for every time and height, 21 at version `0.1.0` +and 17 at `0.2.0`, so an action becomes available as soon as the node runs new enough code. Every non-zero threshold in the registry therefore belongs to a *behaviour* change applied to an already-live action, not to the arrival of an action. @@ -101,7 +101,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) plus the own-date `REST_PATTERN_METER_GATE_BLOCK_TIME` (destructuring rest-pattern metering and the deploy rejection of rest positions the meter cannot reach; it does not ride the contract-era instant, its value is on [Flag-Day Values](./flag-days.md), and its indexer twin is `REST_PATTERN_METER`) plus `JSON_STRINGIFY_HOOK_GATE_BLOCK_TIME` (hook-aware depth guarding for values transformed by `JSON.stringify`; it keys on its own flag day and stays inert until the operator arms it, its status is on [Flag-Day Values](./flag-days.md), and its indexer twin is `JSON_STRINGIFY_HOOK`) plus five constant-less contract-era riders that key on the binary-alloc instant instead of minting a constant ([Cohort A riders that mint no constant](#cohort-a-riders-that-mint-no-constant)) plus seven per-coin height-keyed maps: `PKG3_SANDBOX_ACTIVATION` (the armed runtime half of VM deploy-lint Pkg 3, [below](#additional-armed-gates-service-carried)), the genesis-armed `EXEC_LINT_ACTIVATION` and `LINT_GLOBAL_ALIAS_ACTIVATION`, `LINT_BANNED_WITH_ACTIVATION` (the deploy linter's `with`-statement ban, which the indexer's DEPLOY lint resolves through its `vm_lint_banned_with_heights` registry row; inert on mainnet and testnet until an operator arms it; regtest runs it from genesis), the mainnet-unarmed `LINT_OPTIONAL_CHAIN_ACTIVATION` ([VM gates](#vm-gates-service-carried)), `BIGINT_SURFACE_STRIP_ACTIVATION` in `index/bigint_surface_strip_heights.js` (inert on mainnet and testnet until an operator arms it; regtest runs it from genesis), and `LINT_DESTRUCTURE_ACTIVATION` in `index/lint_destructure_heights.js` (the deploy linter's destructuring checks; inert on mainnet and testnet until an operator arms it; regtest runs it from genesis) plus four network-keyed block-time maps, all unarmed on mainnet: `ACCESSOR_OWN_KEY_ACTIVATION` in `readonly-accessors.js` (a readonly snapshot lookup resolves only own keys, so an inherited name such as `constructor` reads as absent; testnet and regtest run it from genesis, and its indexer twin is `READONLY_ACCESSOR_OWN_KEY`), `GAS_CEILING_SUCCESS_ACTIVATION` in `gas.js` (a run that reached the host as a success although a charge crossed the gas ceiling inside the isolate fails instead; it is a static on `GasTracker`, not a module export; it is unarmed on mainnet and testnet, regtest runs it from genesis, and its indexer twin is `GAS_CEILING_SUCCESS`), `ITER_SET_METER_ACTIVATION` in `index/runtime/activations.js` (meters iterator helpers, String well-formedness and the Set algebra family, and while active also turns on call-spread metering; only regtest runs it, from genesis, and its indexer twin is `ITER_SET_METER`) and `APPLY_LENGTH_METER_ACTIVATION` in `index/apply_length_meter.js` (charges long-argument `Function.prototype.apply` by argument count and `ArrayBuffer` `resize`, `transfer` and `transferToFixedLength` by their new byte length; only regtest runs it, from genesis, and its indexer twin is `APPLY_LENGTH_METER`) | +| `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 the own-date `REST_PATTERN_METER_GATE_BLOCK_TIME` (destructuring rest-pattern metering and the deploy rejection of rest positions the meter cannot reach; it does not ride the contract-era instant, its value is on [Flag-Day Values](./flag-days.md), and its indexer twin is `REST_PATTERN_METER`) plus `JSON_STRINGIFY_HOOK_GATE_BLOCK_TIME` (hook-aware depth guarding for values transformed by `JSON.stringify`; it keys on its own flag day, arms testnet at its own instant and stays inert on mainnet until the operator arms it, its status is on [Flag-Day Values](./flag-days.md), and its indexer twin is `JSON_STRINGIFY_HOOK`) plus five constant-less contract-era riders that key on the binary-alloc instant instead of minting a constant ([Cohort A riders that mint no constant](#cohort-a-riders-that-mint-no-constant)) plus seven per-coin height-keyed maps: `PKG3_SANDBOX_ACTIVATION` (the armed runtime half of VM deploy-lint Pkg 3, [below](#additional-armed-gates-service-carried)), the genesis-armed `EXEC_LINT_ACTIVATION` and `LINT_GLOBAL_ALIAS_ACTIVATION`, `LINT_BANNED_WITH_ACTIVATION` (the deploy linter's `with`-statement ban, which the indexer's DEPLOY lint resolves through its `vm_lint_banned_with_heights` registry row; armed on testnet at each chain's own height, unarmed on mainnet until an operator arms it; regtest runs it from genesis), the mainnet-unarmed `LINT_OPTIONAL_CHAIN_ACTIVATION` ([VM gates](#vm-gates-service-carried)), `BIGINT_SURFACE_STRIP_ACTIVATION` in `index/bigint_surface_strip_heights.js` (armed on testnet at each chain's own height, unarmed on mainnet until an operator arms it; regtest runs it from genesis), and `LINT_DESTRUCTURE_ACTIVATION` in `index/lint_destructure_heights.js` (the deploy linter's destructuring checks, which the indexer's DEPLOY lint resolves through its `vm_lint_destructure_heights` registry row; armed on testnet at each chain's own height, unarmed on mainnet until an operator arms it; regtest runs it from genesis) plus four network-keyed block-time maps, all unarmed on mainnet: `ACCESSOR_OWN_KEY_ACTIVATION` in `readonly-accessors.js` (a readonly snapshot lookup resolves only own keys, so an inherited name such as `constructor` reads as absent; testnet and regtest run it from genesis, and its indexer twin is `READONLY_ACCESSOR_OWN_KEY`), `GAS_CEILING_SUCCESS_ACTIVATION` in `gas.js` (a run that reached the host as a success although a charge crossed the gas ceiling inside the isolate fails instead; it is a static on `GasTracker`, not a module export; it is armed on testnet at its own instant and unarmed on mainnet, regtest runs it from genesis, and its indexer twin is `GAS_CEILING_SUCCESS`), `ITER_SET_METER_ACTIVATION` in `index/runtime/activations.js` (meters iterator helpers, String well-formedness and the Set algebra family, and while active also turns on call-spread metering; testnet runs it from its own instant and regtest from genesis, and its indexer twin is `ITER_SET_METER`) and `APPLY_LENGTH_METER_ACTIVATION` in `index/apply_length_meter.js` (charges long-argument `Function.prototype.apply` by argument count and `ArrayBuffer` `resize`, `transfer` and `transferToFixedLength` by their new byte length; testnet runs it from its own instant and regtest from genesis, and its indexer twin is `APPLY_LENGTH_METER`) | | `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 six 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` and `ENVELOPE_CARRIER_RECOGNITION_ACTIVATION` (per-chain local height) | | `xchain-sync`, `xchain-explorer`, `xchain-sdk` | the subset each needs to verify or display | diff --git a/protocol/providers/llm.md b/protocol/providers/llm.md index 2b91dde8..c2a1a851 100644 --- a/protocol/providers/llm.md +++ b/protocol/providers/llm.md @@ -5,7 +5,7 @@ The `llm` provider lets a contract delegate a prompt to a governance-approved large language model. It's a thin specialization of the External Attestation Framework: payloads are JSON envelopes, consensus uses a judge-model strategy, and the chosen model is recorded in the on-chain response's `META` field. -For the framework itself see `actions/ATTEST.md` (covers v0=request, v1=response, v2=expire). +For the framework itself see [`actions/attest.md`](../actions/attest.md) (its request, response and expire lifecycle is v0=request, v1=response, v2=expire). ## Request @@ -122,11 +122,11 @@ No other state needed. The hub bills per token directly through the Anthropic AP | Component | Default | Notes | | -------------------------- | -------------- | ------------------------------------------------------------------------------ | -| `per_call_base_fee_xchain` | `0.50` | Higher than `http_get` (`0.01`); each call has a real upstream cost. | +| `per_call_base_fee_xchain` | `0.50` | Higher than `http_get` (`0.01`); each call has a real upstream cost. Configured but not yet charged: requests currently escrow `0` (see `protocol/actions/attest.md`). | | `min_stake_xchain` | `25000` | Higher than `http_get` (`10000`): reflects larger trust + ongoing API spend. Enforced on the responsible set at/above `STAKE_WEIGHTED_QUORUM_ACTIVATION`: a staking source below the floor is dropped before ranking, so its keys never hold a responsible slot. See `protocol/actions/attest.md`. | | `min_fee_xchain` | `0` | Governance-configurable hub-local floor on the optional paid-attestation fee. Requests whose on-chain `FEE_AMOUNT` is below this value are skipped by validators and the request expires, refunding the caller. | -Validators on `claude_spawn` amortize their subscription across requests they serve; validators on `anthropic_api` pay-as-they-go and rely on the base fee + escrow reimbursement to make the call profitable. +Validators on `claude_spawn` amortize their subscription across requests they serve; validators on `anthropic_api` pay per token. Because the base fee is not yet collected, their only per-call compensation today is the optional paid-attestation fee (`feeTick` / `feeAmount`, above); whether a feeless request is served depends on the governance-synced `min_fee_xchain` floor. ## Recommendations diff --git a/protocol/taproot-envelope.md b/protocol/taproot-envelope.md index 77c63ff8..1097defe 100644 --- a/protocol/taproot-envelope.md +++ b/protocol/taproot-envelope.md @@ -118,7 +118,9 @@ Callers can ask the encoder to choose: `encoding: "AUTO"` selects the smallest-f Auto-selection also consults the **signer**. An account whose signer cannot produce a tapscript script-path signature never gets the envelope, and this fails closed - a signer capability must be affirmed, not assumed. The reason is not tidiness: the reveal has to be signable before the commit is broadcast, so choosing the envelope for a signer that cannot spend it does not produce an error message, it produces stranded funds. -A payload that still exceeds the envelope ceiling after compression is refused with an error naming the cap. It is never silently split across multiple envelopes, and never falls back to a spray of chunk outputs. +The encoder checks the envelope ceiling against the caller's bytes **before** compression. When `encoding` is `TAPROOT` or `AUTO`, it measures the request as submitted (the `data` push plus the `rawData` push, each with its own push framing) against `ENVELOPE_MAX_PAYLOAD`, and refuses anything over the cap, including a payload that compression would have brought under it. After compressing, it checks the compiled bytes it is about to write a second time; that check can still refuse a payload within a few bytes of the cap, because the `COMPRESSION` field lengthens the action string. So compression lowers the cost of a payload that fits, but never admits one whose uncompressed size is over the cap. Both refusals are invalid-params errors (`-32602`) that name the cap. This is the encoder's admission rule; the consensus ceiling under [Rules](#rules-that-decide-whether-an-envelope-is-an-action) measures the reassembled on-chain payload. + +An oversize payload is never silently split across multiple envelopes, and never falls back to a spray of chunk outputs. ## Relay diff --git a/protocol/token-gated-content.md b/protocol/token-gated-content.md index ac77c3dd..e0dcc022 100644 --- a/protocol/token-gated-content.md +++ b/protocol/token-gated-content.md @@ -116,6 +116,8 @@ Format rules for the field: a decimal amount strictly greater than zero (every z The rule enforces the **presence** of a key-handoff `MESSAGE`, not the correctness of what is inside it. Consensus cannot look inside the ciphertext, so an adversarial sender can satisfy the rule with garbage. Recipients verify the delivered key against the file's `KEY_HASH` client-side (step 5 of the unlock flow below) and ignore anything that does not match. The protocol guarantees a sender cannot *quietly omit* the handoff; it cannot guarantee the sender was honest. The omission guarantee covers a `SEND` whose `TICK` the pack lookup can match: below `SEND_CARET_PACK_KEY_ACTIVATION` (not yet armed on mainnet), a `SEND` that writes `TICK` as `^` finds no pack and owes no handoff, as [Index ID References](./index-id-references.md) describes. +The rule covers only gated `FILE`s on the token's own chain. A gated `FILE`'s `GATE_TICKER` is looked up in the token registry of the chain the `FILE` is published on, and a `SEND` weighs only the gated `FILE`s of the chain it is mined on. A `FILE` published on a sibling chain is therefore never gated by this chain's token: no `SEND` of the token owes a handoff for it, and its `GATE_MIN_AMOUNT` is never weighed against a holder's balance. A holder can decrypt it only with a key delivered some other way, such as a handoff owed for a same-chain pack that shares its `KEY_HASH`. + --- ## How unlock works (no on-chain transaction required) @@ -199,7 +201,7 @@ These are the protocol-level rules the indexer enforces. See the individual acti ## Non-goals (initial release) -- **Key rotation.** If a key is leaked, the only recourse is to republish the affected files under a new key. +- **Key rotation.** If a key is leaked, the only recourse is to republish the affected files under a new key. Republishing does not retire the old files: a valid gated `FILE` stays active, so an old pack with no `GATE_MIN_AMOUNT` keeps requiring the key-handoff `MESSAGE` on every later `SEND` the pack lookup matches, whatever threshold the republished pack sets. - **Per-file granular gating.** All gated files for a token are unlocked atomically when the holder receives the keys. If a creator wants tiered access, they should mint multiple tokens. - **Revocation.** Knowledge can't be ungranted. A holder who unlocks and then transfers the token still has the bytes. The chain reflects who holds it now, not who has ever read it. - **DRM.** A holder can rehost plaintext anywhere. diff --git a/protocol/token-information-standard.md b/protocol/token-information-standard.md index b85081c2..b139455c 100644 --- a/protocol/token-information-standard.md +++ b/protocol/token-information-standard.md @@ -86,14 +86,14 @@ all four arrays. | Field | Type | Description | :--- | :--- | :--- | data | String | URL to the file (off-chain). Used for non-gated content. -| 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`. +| 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. A sibling-chain FILE is not gated by the token, so use this form for public content and keep `locked` entries on the token's own chain. When both `data` and `data_ref` are present, clients prefer `data_ref`. | name | String | Filename | hash | String | (Optional) A sha256 hash of the file. 64 characters max. | size | String | *(images only)* (Optional) The image's pixel dimensions, such as `48x48`, or `svg` for an SVG image. Read together with `type` to pick a token icon. | type | String | Entry classification, drawn from the vocabulary of the array the entry sits in, and NOT a MIME type. `images`: display role, one of `icon`, `standard`, `large`, `hires`, paired with `size` so clients can pick a token icon. `audio`: container, one of `m4a`, `mp3`, `wav`. `video`: container, one of `mp4`, `mov`, `wmv`. `files`: free-form category such as `doc`, `pdf`, `xls`, `other`. The schema pins the first three lists as enums and leaves the `files` vocabulary open. The media type of the bytes comes from elsewhere: a `data_ref` entry inherits it from the referenced [`FILE`](./actions/file.md) action's `TYPE`, and a `data` URL from the server's `Content-Type`. | 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. +| 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. Key-handoff enforcement on `SEND` and unlock thresholds apply only to a gated FILE on the token's own chain, so a `locked` entry should use a same-chain `action:` reference; a gated FILE on a sibling chain unlocks only with a key delivered some other way (see [Token-Gated Content](./token-gated-content.md#what-the-handoff-rule-does-and-does-not-guarantee)). +| 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: clients match each file's key by its `KEY_HASH` (`sha256(K) == KEY_HASH`). The protocol defines a pack by the (publisher, `GATE_TICKER`, `KEY_HASH`) triple on the token's own chain (see [FILE pack semantics](./actions/file.md#pack-semantics)), so files that share a `KEY_HASH` but come from different publishers are separate packs with independent thresholds and should not share a `pack_id`. ## NFT Usage diff --git a/test/action-activation-model.test.js b/test/action-activation-model.test.js index b02a0438..428202c1 100644 --- a/test/action-activation-model.test.js +++ b/test/action-activation-model.test.js @@ -13,7 +13,7 @@ * ACTION activation-model gate. * * WHY. Three separate pages told readers that the non-genesis ACTIONs - * "activate at later block heights". They do not. Every one of the 36 actions + * "activate at later block heights". They do not. Every one of the 38 actions * in protocol_changes.js is registered with an activation time AND height of 0 * on all three networks; what gates them is condition 1 of isEnabled(), the * indexer's own protocol version. Non-zero thresholds in that registry all @@ -27,11 +27,13 @@ * WHAT IT CHECKS, against xchain-indexer/src/protocol_changes.js: * * 1. No ACTION carries a non-zero activation time or height. - * 2. The version split the docs state (21 at 0.1.0, 15 at 0.2.0) is real. + * 2. The version split the docs state (21 at 0.1.0, 17 at 0.2.0) is real. * 3. BET specifically is a 0.1.0 action, since that is the one the audit * got backwards. * 4. The docs do not reintroduce the "actions activate at block heights" * phrasing. + * 5. Every page that states the version split states the same one, it sums + * to the number of action pages, and any stated total matches too. * * The registry is REQUIRED, not parsed. Its rows live in the part files under * src/protocol_changes/ as array literals the entry assembles, and the entry @@ -42,7 +44,7 @@ * the row shape, and this file asserts values, not literals. * * xchain-indexer is a sibling repo, not a dependency. Source-derived - * assertions skip when it is absent; the prose check always runs. + * assertions skip when it is absent; the prose checks always run. * ********************************************************************/ @@ -57,7 +59,7 @@ const REGISTRY = path.resolve(DOC_ROOT, '../xchain-indexer/src/protocol_changes. // Skips by name on a bare clone; throws under XCHAIN_REQUIRE_SIBLINGS=1 when the registry is unreadable. const indexer = sibling('xchain-indexer', [REGISTRY]); -// The 36 documented ACTIONs: one page per action under protocol/actions/. +// The 38 documented ACTIONs: one page per action under protocol/actions/. const ACTIONS = fs.readdirSync(path.join(DOC_ROOT, 'protocol/actions')) .filter((f) => f.endsWith('.md') && f !== 'README.md') // Filenames are lowercase-kebab (the naming standard); an ACTION name is the @@ -128,6 +130,58 @@ describe('ACTION activation model', () => { walk(DOC_ROOT); assert.deepEqual(bad, [], 'these pages say ACTIONs activate at block heights; they are gated by indexer version ' + - '(all 36 carry zero thresholds):\n ' + bad.join('\n ')); + '(all 38 carry zero thresholds):\n ' + bad.join('\n ')); + }); + + test('every page states the same version split and total as the action pages', () => { + const mismatches = splitClaimMismatches( + (rel) => fs.readFileSync(path.join(DOC_ROOT, rel), 'utf8'), ACTIONS.length); + assert.deepEqual(mismatches, [], + 'these pages state an ACTION version split or total that disagrees with the ' + + ACTIONS.length + ' action pages or with each other:\n ' + mismatches.join('\n ')); + }); + + test('the split check catches a stale count', () => { + const pages = Object.fromEntries(SPLIT_CLAIMS.map((c) => [c.page, + fs.readFileSync(path.join(DOC_ROOT, c.page), 'utf8')])); + pages['protocol/protocol-activation.md'] = pages['protocol/protocol-activation.md'] + .replace(/all \d+ carry `0`/, 'all 36 carry `0`') + .replace(/and \d+ at `0\.2\.0`/, 'and 16 at `0.2.0`'); + const mismatches = splitClaimMismatches((rel) => pages[rel], ACTIONS.length); + assert.ok(mismatches.some((m) => m.startsWith('protocol/protocol-activation.md')), + 'a 36 total with a 16 split on protocol-activation.md went unflagged'); }); }); + +// Pages that state the ACTION version split; `total` names the capture group of a stated total. +const SPLIT_CLAIMS = [ + { page: 'protocol/protocol-activation.md', + re: /all (\d+) carry `0` for every time and height, (\d+) at version `0\.1\.0`\s+and (\d+) at `0\.2\.0`/, + total: 1, v1: 2, v2: 3 }, + { page: 'components/indexer/README.md', + re: /\((\d+) at `0\.1\.0`, (\d+) at `0\.2\.0`/, v1: 1, v2: 2 }, + { page: 'concepts/actions.md', + re: /(\d+) are registered at version `0\.1\.0` and (\d+) at `0\.2\.0`, and all (\d+) carry/, + v1: 1, v2: 2, total: 3 }, + { page: 'components/indexer/actions.md', + re: /(\d+) actions are registered at version `0\.1\.0` \([^)]*\) and the other (\d+) at `0\.2\.0`/, + v1: 1, v2: 2 }, +]; + +/** Lists each split claim that is missing, disagrees with the others, or does not sum to `actionCount`. */ +function splitClaimMismatches(read, actionCount) { + const out = []; + const seen = []; + for (const c of SPLIT_CLAIMS) { + const m = read(c.page).match(c.re); + if (!m) { out.push(c.page + ': split sentence not found; update SPLIT_CLAIMS if it was reworded'); continue; } + const v1 = Number(m[c.v1]); + const v2 = Number(m[c.v2]); + if (v1 + v2 !== actionCount) out.push(`${c.page}: ${v1} + ${v2} is not ${actionCount}`); + if (c.total && Number(m[c.total]) !== actionCount) out.push(`${c.page}: total ${m[c.total]} is not ${actionCount}`); + seen.push({ page: c.page, split: v1 + '/' + v2 }); + } + const splits = new Set(seen.map((s) => s.split)); + if (splits.size > 1) out.push('pages disagree: ' + seen.map((s) => s.page + ' ' + s.split).join(', ')); + return out; +} diff --git a/test/controller-invocation-table.test.js b/test/controller-invocation-table.test.js index ab73f2f7..46868565 100644 --- a/test/controller-invocation-table.test.js +++ b/test/controller-invocation-table.test.js @@ -75,7 +75,46 @@ test('pins the live controller invocation table', () => { }); const classes = parseActionClassesTable(page); - assert.deepEqual(classes.transfer, ['SEND', 'DEPOSIT', 'WITHDRAW']); + assert.deepEqual(classes.transfer, + ['SEND', 'AIRDROP', 'DIVIDEND', 'SWEEP', 'DEPOSIT', 'WITHDRAW']); +}); + +// Compare each Action classes row with the action_types the Invocation points table routes to it. +function assertClassRowsMatchInvocations(markdown) { + const routed = {}; + for (const [actionType, actionClass] of Object.entries(parseInvocationTable(markdown))) { + (routed[actionClass] ??= []).push(actionType); + } + const listed = parseActionClassesTable(markdown); + assert.deepEqual(Object.keys(listed).sort(), Object.keys(routed).sort(), + 'the Action classes and Invocation points tables name different classes'); + for (const [actionClass, actionTypes] of Object.entries(routed)) { + assert.deepEqual([...listed[actionClass]].sort(), [...actionTypes].sort(), + `the ${actionClass} row lists different guard action_types than the invocation table`); + } +} + +test('every action class row lists exactly the action_types the invocation table routes to it', () => { + assertClassRowsMatchInvocations(readControllerBoundTokensPage()); +}); + +test('the class row cross-check rejects a transfer row missing AIRDROP', () => { + const missingAirdrop = ` +### Invocation points + +| \`action_type\` | Class | +|---|---| +| \`SEND\` | \`transfer\` | +| \`AIRDROP\` | \`transfer\` | + +### Action classes + +| Class | Gates | Guard \`action_type\` | +|---|---|---| +| \`transfer\` | sends | \`SEND\` | +`; + assert.throws(() => assertClassRowsMatchInvocations(missingAirdrop), + /the transfer row lists different guard action_types/); }); /* Bind/unbind verdicts and the guard savepoint name. Clients match the @@ -91,8 +130,14 @@ const BINDING_SRC = 'src/actions/issue/controller_binding.js'; const ADDRESS_SRC = 'src/actions/address.js'; const COOLDOWN_SRC = 'src/db/database/controllers_vm.js'; const GUARD_EFFECTS_SRC = 'src/actions/execute/guard_effects.js'; +const OWNERSHIP_SRC = 'src/actions/issue/token_state.js'; const indexerSrc = sibling('xchain-indexer', - [BINDING_SRC, ADDRESS_SRC, COOLDOWN_SRC, GUARD_EFFECTS_SRC]); + [BINDING_SRC, ADDRESS_SRC, COOLDOWN_SRC, GUARD_EFFECTS_SRC, OWNERSHIP_SRC]); + +// Verdicts a bind or unbind meets before the binding handlers: the ISSUE owner gate and the +// sleeping-SOURCE check both formats run. +const GATE_VERDICTS = ['invalid: SOURCE (sleeping)', 'invalid: issued by another address', + 'invalid: TICK (ownership escrowed)']; const readIndexer = (rel) => fs.readFileSync(path.join(indexerSrc.root, rel), 'utf8'); // Slice a `###` section up to the next heading of depth three or less. @@ -145,11 +190,39 @@ test('the binding handlers emit exactly the verdicts the table lists', } const bridged = 'invalid: TICK (bridged tokens cannot be policy-bound yet)'; for (const verdict of documented) { - assert.ok(tokenVerdicts.has(verdict) || verdict === bridged, + assert.ok(tokenVerdicts.has(verdict) || verdict === bridged + || GATE_VERDICTS.includes(verdict), `the rejection table lists ${verdict}, which no binding handler emits`); } }); +test('the rejection table names the owner, escrow and sleeping gates', () => { + const rejections = headedSection(readControllerBoundTokensPage(), '### Bind and unbind rejections'); + for (const verdict of GATE_VERDICTS) { + assert.ok(rejections.includes(`| \`${verdict}\` |`), + `the rejection table no longer names ${verdict}`); + } +}); + +test('the ISSUE ownership gate and ADDRESS fields emit the gate verdicts the table lists', + { skip: indexerSrc.skip }, () => { + const tokenState = readIndexer(OWNERSHIP_SRC); + const start = tokenState.indexOf('async function validateOwnership('); + const end = tokenState.indexOf('module.exports', start); + assert.ok(start !== -1 && end > start, 'token_state.js validateOwnership not found'); + const ownership = verdictsIn(tokenState.slice(start, end)); + for (const verdict of GATE_VERDICTS) { + assert.ok(ownership.has(verdict), + `validateOwnership no longer emits ${verdict}, so its rejection row is stale`); + } + const address = readIndexer(ADDRESS_SRC); + const fieldsStart = address.indexOf('async validateFields('); + const fieldsEnd = address.indexOf('async validateControllerBinding(', fieldsStart); + assert.ok(fieldsStart !== -1 && fieldsEnd > fieldsStart, 'address.js validateFields not found'); + assert.ok(verdictsIn(address.slice(fieldsStart, fieldsEnd)).has('invalid: SOURCE (sleeping)'), + 'ADDRESS no longer refuses a sleeping SOURCE, so the "both" format cell is stale'); + }); + test('the cooldown lockout source facts still hold', { skip: indexerSrc.skip }, () => { assert.match(readIndexer(COOLDOWN_SRC), /Number\(atBlock\) < Number\(row\.cooldown_end_block\)/, 'an unbind row no longer gates until cooldown_end_block, so the lockout rule is stale'); diff --git a/test/error-code-registry-coverage.test.js b/test/error-code-registry-coverage.test.js index f53f057c..7cd1e12a 100644 --- a/test/error-code-registry-coverage.test.js +++ b/test/error-code-registry-coverage.test.js @@ -21,16 +21,23 @@ * at all, so the registry was the only contract surface and it was incomplete. * * This test re-derives the emitted set from the explorer source on every run and - * fails naming any code that has no registry row. + * fails naming any code that has no row in the page's Explorer REST API section, + * and any row in that section that nothing emits unless the row is marked + * historical. * - * Collection is by emit shape rather than by a bare literal scan. Three shapes + * Collection is by emit shape rather than by a bare literal scan. Five shapes * are read: a `code:` line, so the ternary fallback form * (`code: cond ? x : 'READ_FAILED'`) is caught alongside the plain `code: 'X'` * form; a status-map row (`CODE: [409, 'message']`), which the proof routes - * declare and then send by reference as `code: code`; and a positional helper + * declare and then send by reference as `code: code`; a positional helper * call (`error(res, 503, 'message', 'CODE')`), which the bridge panel routes - * use. A new emit shape needs its own rule here, or its codes go unchecked. - * Only the three REST-side sources are read for the registry: + * use; a `VmQueryError('CODE', ...)` throw, which the contract simulation + * route forwards to the client as `code: e.code`; and a mirror-gate verdict + * (`blocked: 'CODE'`), which the mirror gate sends by reference as + * `code: blocked`. A new emit shape needs its own rule here, or its codes go + * unchecked. Only the four REST-side sources are read for the registry + * (contract/vm_query.js is one because its throws reach the wire through the + * state-proof routes): * src/ws/ carries the WebSocket channel codes, which are a separate surface * documented in components/explorer/websocket.md and explicitly excluded by the * registry page's own closing note. The second test below holds that page's @@ -57,7 +64,7 @@ const EXPLORER_SRC = path.join(EXPLORER, 'src'); const REGISTRY_PAGE = path.join(ROOT, 'protocol/error-codes.md'); // REST-side emit sites. src/ws/ is deliberately absent (separate surface). -const SOURCES = ['XChainExplorer.js', 'api.js', 'http/concurrency_gate.js'] +const SOURCES = ['XChainExplorer.js', 'api.js', 'http/concurrency_gate.js', 'contract/vm_query.js'] .map((file) => path.join(EXPLORER_SRC, file)); // An entry file whose long methods were split keeps its path and gains a @@ -106,9 +113,15 @@ const STATUS_MAP_KEY = /\b([A-Z][A-Z0-9_]{2,})\s*:\s*\[\s*[0-9]{3}\s*,/g; // A positional error helper called as `helper(res, status, message, 'CODE')`. const STATUS_HELPER_CALL = /\(\s*res\s*,\s*[0-9]{3}\s*,/; +// A contract simulation throw, `new VmQueryError('CODE', message, status)`. +const VM_QUERY_THROW = /\bVmQueryError\(\s*'([A-Z][A-Z0-9_]{2,})'/g; + +// A mirror-gate verdict, `{ blocked: 'CODE', annotate }`, sent later as `code: blocked`. +const MIRROR_BLOCKED = /\bblocked\s*:\s*'([A-Z][A-Z0-9_]{2,})'/g; + // Every SCREAMING_SNAKE string literal on a line that carries `code:` or a -// positional helper call, plus every status-map key, minus a logger event name -// leading that line's log call. +// positional helper call, plus every status-map key, simulation throw and +// mirror-gate verdict, minus a logger event name leading that line's log call. function emittedCodes() { const found = new Map(); const files = SOURCES.slice(); @@ -127,6 +140,8 @@ function emittedCodes() { const record = (code) => { if (!found.has(code)) found.set(code, where); }; const emitted = line.replace(LOGGER_EVENT, ''); for (const m of emitted.matchAll(STATUS_MAP_KEY)) record(m[1]); + for (const m of emitted.matchAll(VM_QUERY_THROW)) record(m[1]); + for (const m of emitted.matchAll(MIRROR_BLOCKED)) record(m[1]); if (!line.includes('code:') && !STATUS_HELPER_CALL.test(line)) return; for (const quoted of emitted.match(/'([A-Z][A-Z0-9_]{2,})'/g) || []) record(quoted.slice(1, -1)); @@ -135,25 +150,69 @@ function emittedCodes() { return found; } -test('every explorer REST error code has a registry row', { skip: noExplorer }, () => { - const registry = fs.readFileSync(REGISTRY_PAGE, 'utf8'); - const emitted = emittedCodes(); +// The Explorer REST API section of the registry page, up to the next `## ` +// heading: the JSON-RPC table below it uses the same row format, so a page-wide +// match would let a JSON-RPC row stand in for a REST one. +function restSection(page) { + const start = page.indexOf('\n## Explorer REST API\n'); + assert.ok(start >= 0, 'protocol/error-codes.md lost its "## Explorer REST API" section'); + return page.slice(start + 1).split(/\n## /)[0]; +} - assert.ok(emitted.size > 20, - `collected only ${emitted.size} codes from the explorer source; the emit ` - + 'shape changed, re-point the collection in this test'); +// Codes and historical markers of the REST table rows. +function restRows(section) { + const rows = new Map(); + for (const m of section.matchAll(/^\| `([A-Z][A-Z0-9_]+)` \|.*$/gm)) + rows.set(m[1], m[0].includes('**Historical:')); + return rows; +} +// Emitted codes with no REST row, and REST rows nothing emits unless marked historical. +function registryDrift(emitted, rows) { const missing = []; for (const [code, where] of emitted) { if (code in DOCUMENTED_ELSEWHERE) continue; - if (!registry.includes('| `' + code + '` |')) - missing.push(`${code} (emitted at ${where})`); + if (!rows.has(code)) missing.push(`${code} (emitted at ${where})`); } + const phantoms = [...rows].filter(([code, historical]) => !historical && !emitted.has(code)) + .map(([code]) => code); + return { missing, phantoms }; +} - assert.deepEqual(missing, [], - 'protocol/error-codes.md calls itself the complete append-only registry, ' - + 'so every code the explorer emits needs a row there. Missing:\n ' - + missing.join('\n ')); +test('every explorer REST error code has a registry row', { skip: noExplorer }, () => { + const rows = restRows(restSection(fs.readFileSync(REGISTRY_PAGE, 'utf8'))); + const emitted = emittedCodes(); + + assert.ok(emitted.size > 20, + `collected only ${emitted.size} codes from the explorer source; the emit ` + + 'shape changed, re-point the collection in this test'); + + assert.deepEqual(registryDrift(emitted, rows), { missing: [], phantoms: [] }, + 'protocol/error-codes.md calls itself the complete append-only registry, so every ' + + 'code the explorer emits needs a REST row there, and a REST row nothing emits must ' + + 'carry a **Historical:** marker (or the emit shape that sends it needs a rule here)'); +}); + +test('the REST registry check fails on a dropped row or an unemitted row', { skip: noExplorer }, () => { + const page = fs.readFileSync(REGISTRY_PAGE, 'utf8'); + const emitted = emittedCodes(); + for (const code of ['VM_BUSY', 'MIRROR_STALE']) { + assert.ok(emitted.has(code), `${code} is no longer collected from the explorer source`); + const dropped = page.replace(new RegExp('^\\| `' + code + '` \\|.*\\n', 'm'), ''); + assert.notEqual(dropped, page, `falsification fixture did not drop the ${code} row`); + assert.deepEqual(registryDrift(emitted, restRows(restSection(dropped))).missing.map((m) => m.split(' ')[0]), + [code]); + } + const fake = '| `FAKE_GONE_CODE` | 400 | test | No |\n'; + const inRest = page.replace('| `MISSING_PARAMETER` |', fake + '| `MISSING_PARAMETER` |'); + assert.deepEqual(registryDrift(emitted, restRows(restSection(inRest))).phantoms, ['FAKE_GONE_CODE']); + const marked = inRest.replace('| test |', '| test. **Historical: test.** |'); + assert.deepEqual(registryDrift(emitted, restRows(restSection(marked))).phantoms, []); + const rpcRow = page.indexOf('\n| `', page.indexOf('\n## JSON-RPC')); + assert.ok(page.indexOf('\n## JSON-RPC') >= 0 && rpcRow >= 0, 'protocol/error-codes.md lost its JSON-RPC table'); + const inRpc = page.slice(0, rpcRow + 1) + fake + page.slice(rpcRow + 1); + assert.ok(inRpc.includes(fake), 'falsification fixture did not add the JSON-RPC row'); + assert.equal(restRows(restSection(inRpc)).has('FAKE_GONE_CODE'), false); }); // The WebSocket error codes: a `sendError(client, 'CODE'` call or a `code: 'CODE'` diff --git a/test/fee-and-limit-claims.test.js b/test/fee-and-limit-claims.test.js index 2a4c5038..d75f162b 100644 --- a/test/fee-and-limit-claims.test.js +++ b/test/fee-and-limit-claims.test.js @@ -10,7 +10,7 @@ * ********************************************************************** * - * Cross-repo drift lint for four consensus numbers the guides restate in prose. + * Cross-repo drift lint for consensus numbers the guides restate in prose. * * Each of these was found stated wrongly or not at all, and each is a number a * user pays or hits: @@ -26,6 +26,7 @@ * absent from the betting guide. * - XBRIDGE_BASE, the flat bridge lock/burn fee, absent from the page that * calls itself the complete fee schedule. + * - LIST_SHARE and the two LIST_SHARED_EDIT keys, absent from that same page. * * The values live in sibling repos, not in this one, so they are read as source * and parsed with a regex, the same convention protocol-constant-claims.test.js @@ -131,6 +132,44 @@ test('the complete fee schedule prices XBRIDGE_BASE at the gas schedule value', assert.equal(cells[3], String(Number((gas * price).toFixed(8))), `XBRIDGE_BASE row XCHAIN: ${row}`); }); +const LIST_FEE_KEYS = ['LIST_SHARE', 'LIST_SHARED_EDIT_BASE', 'LIST_SHARED_EDIT_PER_ITEM']; + +// Read one fee key and GAS_PRICE from every chain's schedule, asserting the chains agree. +function agreedSchedule(key) { + const vals = COIN_JS.map(([coin, file]) => { + const src = fs.readFileSync(file, 'utf8'); + const where = `xchain-indexer/src/coins/${coin}.js`; + return `${scheduleValue(src, key, where)}@${scheduleValue(src, 'GAS_PRICE', where)}`; + }); + assert.equal(new Set(vals).size, 1, `${key} or GAS_PRICE differs per chain: ${vals}`); + return vals[0].split('@').map(Number); +} + +// Compare a fee row's gas and XCHAIN cells numerically ("1.0" and "1" are the same price). +function assertFeeRow(doc, key, gas, price) { + const row = doc.split('\n').find((l) => l.includes(`\`${key}\``)); + assert.ok(row, `concepts/gas.md Complete Fee Schedule has no ${key} row`); + const cells = row.split('|').map((c) => c.trim()); + const num = (cell) => Number(cell.replace(/,/g, '').replace(/\/item$/, '')); + assert.equal(num(cells[2]), gas, `${key} row gas: ${row}`); + assert.equal(num(cells[3]), Number((gas * price).toFixed(8)), `${key} row XCHAIN: ${row}`); +} + +test('the complete fee schedule prices the LIST share and shared-edit keys at the gas schedule values', + { skip: noCoins }, () => { + const doc = readDoc('concepts/gas.md'); + for (const key of LIST_FEE_KEYS) assertFeeRow(doc, key, ...agreedSchedule(key)); + }); + +test('the LIST fee-row check fails on a missing row or a drifted price', { skip: noCoins }, () => { + const doc = readDoc('concepts/gas.md'); + const [gas, price] = agreedSchedule('LIST_SHARE'); + assert.throws(() => assertFeeRow(doc.replaceAll('`LIST_SHARE`', 'removed'), 'LIST_SHARE', gas, price), + /no LIST_SHARE row/); + assert.throws(() => assertFeeRow(doc, 'LIST_SHARE', gas + 1, price), /LIST_SHARE row gas/); + assert.throws(() => assertFeeRow(doc, 'LIST_SHARED_EDIT_BASE', 5000, price * 2), /XCHAIN/); +}); + // Every page that restates the ticker character set, and must therefore restate // it exactly. protocol/actions/issue.md and components/indexer/configuration.md // were already correct and are pinned here so they stay that way. diff --git a/test/gated-pack-scope-claims.test.js b/test/gated-pack-scope-claims.test.js new file mode 100644 index 00000000..a274d7d8 --- /dev/null +++ b/test/gated-pack-scope-claims.test.js @@ -0,0 +1,143 @@ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ +/* Copyright © 2025-2026 Dankest, LLC */ + +/* + * Pins what the gated-content pages say about which gated FILEs count toward a + * SEND's key handoff: every valid one, on the token's own chain, grouped per + * (publisher, GATE_TICKER, KEY_HASH), with the GATE_MIN_AMOUNT exemption stated + * wherever the handoff requirement is. XCHAIN_DOCS_ROOT aims the prose half at + * another checkout so a tree with the old wording can be shown to fail. + */ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); +const { readModuleSource } = require('../lib/indexer-source.js'); +const { sibling } = require('./helpers/sibling_checkout.js'); + +const DOC_ROOT = process.env.XCHAIN_DOCS_ROOT || path.join(__dirname, '..'); +const INDEXER = path.resolve(path.join(__dirname, '..'), '../xchain-indexer/src'); +const FILES_DB = path.join(INDEXER, 'db', 'files', 'index.js'); +const FILE_ACT = path.join(INDEXER, 'actions', 'file.js'); + +const skipNoIndexer = sibling('xchain-indexer', [[FILES_DB], [FILE_ACT]]).skip; + +const readDoc = (rel) => fs.readFileSync(path.join(DOC_ROOT, rel), 'utf8'); +const readJson = (rel) => JSON.parse(readDoc(rel)); + +const send = readDoc('protocol/actions/send.md'); +const batch = readDoc('protocol/actions/batch.md'); +const gated = readDoc('protocol/token-gated-content.md'); +const tis = readDoc('protocol/token-information-standard.md'); +const terms = readDoc('getting-started/key-terms.md'); +const paper = readDoc('whitepaper.md'); + +// Return the markdown line that starts with the given prefix, or fail naming it. +function lineStarting(md, prefix, label){ + const line = md.split('\n').find((l) => l.startsWith(prefix)); + assert.ok(line, `${label} has no line starting ${JSON.stringify(prefix)}`); + return line; +} + +// List every data_ref that a locked entry and an unlocked entry both use. +function sharedLockedRefs(doc){ + const entries = ['images', 'audio', 'video', 'files'].flatMap((k) => doc[k] || []); + const locked = new Set(entries.filter((e) => e.locked === true && e.data_ref).map((e) => e.data_ref)); + return entries.filter((e) => e.locked !== true && locked.has(e.data_ref)).map((e) => e.data_ref); +} + +// List every locked entry whose data_ref names a sibling chain (action::). +function siblingChainLocked(doc){ + const entries = ['images', 'audio', 'video', 'files'].flatMap((k) => doc[k] || []); + return entries.filter((e) => e.locked === true && /^action:[A-Z]+:/.test(String(e.data_ref || ''))) + .map((e) => e.name); +} + +test('no gated-content page says a gated FILE can be superseded', () => { + for(const [label, md] of [['send.md', send], ['token-gated-content.md', gated]]){ + assert.ok(!/not (?:been )?superseded/i.test(md), `${label} again says a gated FILE can be superseded; ` + + 'the indexer counts every valid gated FILE with no retirement path'); + } +}); + +test('send.md and the key rotation non-goal state that valid gated FILEs stay active', () => { + assert.match(send, /A gated `FILE` stays active once it is recorded `valid`/, + 'send.md no longer says a valid gated FILE stays active'); + assert.match(send, /Only a chain reorg that rolls back the block holding the `FILE` removes it/, + 'send.md no longer names a reorg as the only way a gated FILE stops counting'); + const rotation = lineStarting(gated, '- **Key rotation.**', 'token-gated-content.md'); + assert.match(rotation, /Republishing does not retire the old files/, + 'the key rotation non-goal no longer says republishing leaves the old pack counting'); +}); + +test('every statement of the handoff requirement carries the threshold exemption', () => { + const composition = lineStarting(batch, '- `BATCH(SEND, MESSAGE)` is the canonical composition', 'batch.md'); + assert.match(composition, /unless every pack gating that token sets an unlock threshold/, + 'batch.md states the handoff MESSAGE as always required'); + assert.match(composition, /token-gated-content\.md#unlock-thresholds-gate_min_amount/, + 'batch.md no longer links the unlock-threshold section'); + const sendBullet = lineStarting(paper, '- **SEND** transfers balances.', 'whitepaper.md'); + assert.match(sendBullet, /unless every pack gating that token sets a `GATE_MIN_AMOUNT` unlock threshold/, + 'the white paper SEND bullet states the handoff MESSAGE as always required'); + assert.match(gated, /^### Unlock thresholds \(`GATE_MIN_AMOUNT`\)$/m, + 'the unlock-threshold heading batch.md links to is gone'); +}); + +test('the TIS and glossary group packs by publisher, GATE_TICKER and KEY_HASH', () => { + const packRow = lineStarting(tis, '| pack_id ', 'token-information-standard.md'); + assert.ok(!/groups by `KEY_HASH` directly/.test(packRow), + 'the TIS pack_id row again says the protocol groups packs by KEY_HASH alone'); + assert.match(packRow, /\(publisher, `GATE_TICKER`, `KEY_HASH`\) triple/, + 'the TIS pack_id row no longer names the publisher-inclusive pack triple'); + const keyHash = lineStarting(terms, '**KEY_HASH**:', 'key-terms.md'); + assert.ok(!/implicit pack identifier: two or more gated FILEs sharing the same `KEY_HASH`/.test(keyHash), + 'the KEY_HASH glossary entry again makes KEY_HASH alone the pack identifier'); + const pack = lineStarting(terms, '**Pack**:', 'key-terms.md'); + assert.match(pack, /by the same publisher sharing the same `GATE_TICKER` and `KEY_HASH`/, + 'the Pack glossary entry no longer includes the publisher in the pack key'); +}); + +test('the pages state that gating covers only same-chain gated FILEs', () => { + assert.match(gated, /The rule covers only gated `FILE`s on the token's own chain/, + 'token-gated-content.md no longer says the handoff rule covers only same-chain gated FILEs'); + const lockedRow = lineStarting(tis, '| locked ', 'token-information-standard.md'); + assert.match(lockedRow, /apply only to a gated FILE on the token's own chain/, + 'the TIS locked row no longer carries the same-chain caveat'); + const refRow = lineStarting(tis, '| data_ref ', 'token-information-standard.md'); + assert.match(refRow, /A sibling-chain FILE is not gated by the token/, + 'the TIS data_ref row no longer says a sibling-chain FILE is ungated'); +}); + +test('the current TIS example keeps locked entries same-chain and unshared', () => { + const current = readJson('protocol/json/token-information-standard-v1.1.2-example.json'); + assert.deepEqual(sharedLockedRefs(current), [], + 'a public entry in the current TIS example points at the same FILE as a locked entry'); + assert.deepEqual(siblingChainLocked(current), [], + 'a locked entry in the current TIS example points at a sibling-chain FILE the token cannot gate'); + // Prove both detectors fire: the frozen v1.1.1 example carries both layouts. + const frozen = readJson('protocol/json/token-information-standard-v1.1.1-example.json'); + assert.deepEqual(sharedLockedRefs(frozen), ['action:12345'], + 'the shared-ref detector no longer sees the frozen v1.1.1 collision'); + assert.deepEqual(siblingChainLocked(frozen), ['stems.zip'], + 'the sibling-chain detector no longer sees the frozen v1.1.1 stems entry'); +}); + +test('the gated pack source facts behind the prose still hold', { skip: skipNoIndexer }, () => { + const db = readModuleSource(FILES_DB); + const fn = db.match(/async getGatedPackThresholds\(tick\)\{[\s\S]*?\n \},/); + assert.ok(fn, 'db/files/index.js no longer defines getGatedPackThresholds as expected'); + const where = fn[0].match(/WHERE([\s\S]*?)ORDER BY/); + assert.ok(where, 'getGatedPackThresholds no longer has a WHERE ... ORDER BY query'); + assert.deepEqual(where[1].replace(/\s+/g, ' ').trim(), "gf.gate_ticker = ? AND s.status = 'valid'", + 'getGatedPackThresholds now filters gated FILEs on more than ticker and valid status; ' + + 'the "stays active" wording in send.md and token-gated-content.md must be revisited'); + assert.match(fn[0], /let key\s*= publisher \+ '\|' \+ keyHash;/, + 'getGatedPackThresholds no longer keys a pack by publisher and KEY_HASH within a ticker'); + const file = readModuleSource(FILE_ACT); + assert.match(file, /getTokenInfo\(data\['GATE_TICKER'\]/, + 'file.js no longer resolves GATE_TICKER against its own chain\'s token registry'); + assert.match(file, /tokenInfo\['OWNER'\] !== data\['SOURCE'\]/, + 'file.js no longer requires the gated FILE publisher to own GATE_TICKER'); +}); diff --git a/test/send-gated-total-tick-id-page.test.js b/test/send-gated-total-tick-id-page.test.js index f779bce2..b80cc15d 100644 --- a/test/send-gated-total-tick-id-page.test.js +++ b/test/send-gated-total-tick-id-page.test.js @@ -14,6 +14,7 @@ const { sibling } = require('./helpers/sibling_checkout.js'); const ROOT = path.resolve(__dirname, '..'); const FLAG_DAYS = fs.readFileSync(path.join(ROOT, 'protocol', 'flag-days.md'), 'utf8'); const CONTENT = fs.readFileSync(path.join(ROOT, 'protocol', 'token-gated-content.md'), 'utf8'); +const SEND = fs.readFileSync(path.join(ROOT, 'protocol', 'actions', 'send.md'), 'utf8'); const GATE = 'SEND_GATED_TOTAL_TICK_ID_ACTIVATION'; const noIndexer = sibling('xchain-indexer', [gen.REGISTRY]).skip; @@ -74,3 +75,33 @@ test('the page guard fails when a required total claim is removed', () => { assert.throws(() => assertTickIdTotalClaims(CONTENT.replace(marker, 'removed'))); } }); + +// Check that the SEND spec's threshold rule names the gate and both ways of summing the total. +function assertSendPageTotalClaims(markdown) { + const start = markdown.indexOf('The requirement is CONDITIONAL'); + assert.ok(start >= 0, 'send.md is missing its conditional handoff paragraph'); + const end = markdown.indexOf('```mermaid', start); + assert.ok(end > start, 'send.md is missing the flowchart after its handoff rule'); + const rule = markdown.slice(start, end); + assert.match(rule, /\[`SEND_GATED_TOTAL_TICK_ID_ACTIVATION`\]\(\.\.\/flag-days\.md\)/); + assert.match(rule, /Before the gate is active[\s\S]*summed per `DESTINATION` and literal `TICK` as written/); + assert.match(rule, /Once the gate is active[\s\S]*summed per `DESTINATION` and resolved `TICK_ID`/); + assert.match(rule, /different letter case or the `\^` form[\s\S]*count together toward the threshold/); + assert.match(rule, /changes only whether the handoff is owed; it never changes which legs settle/); +} + +test('the SEND spec qualifies its gated total by the gate, like the token-gated content page', () => { + assertSendPageTotalClaims(SEND); +}); + +test('the SEND spec guard fails when a required total claim is removed', () => { + for (const marker of [ + 'SEND_GATED_TOTAL_TICK_ID_ACTIVATION', + 'literal `TICK` as written', + 'resolved `TICK_ID`', + 'different letter case or the `^` form', + 'it never changes which legs settle', + ]) { + assert.throws(() => assertSendPageTotalClaims(SEND.replace(marker, 'removed'))); + } +}); diff --git a/test/vm-deploy-lint-gate-claims.test.js b/test/vm-deploy-lint-gate-claims.test.js new file mode 100644 index 00000000..f72565a8 --- /dev/null +++ b/test/vm-deploy-lint-gate-claims.test.js @@ -0,0 +1,140 @@ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ +/* Copyright © 2025–2026 Dankest, LLC */ + +'use strict'; + +// Pins the deploy-lint pages to the shipped VM rule set and the per-coin testnet arming of +// the with-ban, destructure and nesting-depth gates, read from the sibling sources. + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { test } = require('node:test'); +const { sibling } = require('./helpers/sibling_checkout.js'); + +const VM_CONSTANTS = 'src/lint-core/constants.js'; +const VM_MAPS = { + LINT_BANNED_WITH_ACTIVATION: 'src/index/lint_banned_with_heights.js', + LINT_DESTRUCTURE_ACTIVATION: 'src/index/lint_destructure_heights.js', +}; +const NESTING_ROW = 'vm_lint_nesting_depth_activation.VM_LINT_NESTING_DEPTH_ACTIVATION'; +const vm = sibling('xchain-vm', [VM_CONSTANTS, ...Object.values(VM_MAPS)]); +const indexer = sibling('xchain-indexer', ['src/protocol_changes']); + +const read = (rel) => fs.readFileSync(path.resolve(__dirname, '..', rel), 'utf8'); +const PAGES = { + operations: read('components/vm/operations.md'), + architecture: read('components/vm/architecture.md'), + guide: read('developer-guide/smart-contract-development.md'), + deploy: read('protocol/actions/deploy.md'), +}; +const STALE = /(?:unarmed|inert) on (?:mainnet and testnet|public networks)/; + +// Read every `'COIN:testnet': height` slot from one map body. +function testnetHeights(body, where) { + const heights = [...body.matchAll(/'([A-Z]+):testnet':\s*(\d+)/g)].map((m) => `\`${m[1]}:testnet\` ${m[2]}`); + assert.equal(heights.length, 3, `parsed ${heights.length} testnet heights from ${where}`); + return heights; +} + +function vmMapHeights(root, name) { + const src = fs.readFileSync(path.join(root, VM_MAPS[name]), 'utf8'); + const map = src.match(new RegExp(`const ${name} = Object\\.freeze\\(\\{([\\s\\S]*?)\\}\\);`)); + assert.ok(map, `xchain-vm no longer declares ${name} as a frozen map`); + return testnetHeights(map[1], VM_MAPS[name]); +} + +function nestingRowHeights(root) { + const dir = path.join(root, 'src/protocol_changes'); + const text = fs.readdirSync(dir).map((f) => fs.readFileSync(path.join(dir, f), 'utf8')).join('\n'); + const row = text.match(new RegExp(`addGate\\('${NESTING_ROW.replace('.', '\\.')}', 'height', \\{([\\s\\S]*?)\\}\\);`)); + assert.ok(row, `indexer registry no longer carries ${NESTING_ROW}`); + return testnetHeights(row[1], NESTING_ROW); +} + +function consensusRules(root) { + const src = fs.readFileSync(path.join(root, VM_CONSTANTS), 'utf8'); + const set = src.match(/const CONSENSUS_RULES = new Set\(\[([\s\S]*?)\]\);/); + assert.ok(set, 'xchain-vm no longer declares CONSENSUS_RULES as a Set literal'); + return [...set[1].replace(/\/\/.*$/gm, '').matchAll(/'([a-z-]+)'/g)].map((m) => m[1]); +} + +// Every page states each armed testnet height and never calls these gates unarmed on testnet. +function assertArming(pages, heights) { + for (const [name, text] of Object.entries(pages)) { + assert.doesNotMatch(text.split('\n').filter((l) => /with|destructur|nesting/i.test(l)).join('\n'), STALE, + `${name} still calls a deploy-lint gate unarmed on testnet`); + for (const quoted of heights) assert.ok(text.includes(quoted), `${name} omits ${quoted}`); + } +} + +// The rule count and names on the operations and architecture pages match CONSENSUS_RULES. +function assertRuleSet(pages, rules) { + assert.match(pages.architecture, new RegExp(`the ${rules.length}-member set of deploy-blocking rule names`)); + assert.match(pages.operations, new RegExp(`are the ${rules.length} deploy-blocking consensus rules`)); + for (const rule of rules) { + assert.ok(pages.architecture.includes(`\`${rule}\``), `architecture.md omits ${rule}`); + assert.ok(pages.operations.includes(`\`${rule}\``), `operations.md omits ${rule}`); + } +} + +function assertDestructureSpellings(operations) { + const section = operations.match(/### Global-object spellings\n([\s\S]*?)(?=\n## )/); + assert.ok(section, 'operations.md is missing its Global-object spellings section'); + for (const marker of ['LINT_DESTRUCTURE_ACTIVATION', 'enforceLintDestructure', 'const { Promise } = globalThis']) { + assert.ok(section[1].includes(marker), `Global-object spellings omits ${marker}`); + } +} + +test('the deploy-lint pages quote each armed testnet height of the with, destructure and nesting gates', + { skip: vm.skip || indexer.skip }, () => { + const heights = new Set([ + ...vmMapHeights(vm.root, 'LINT_BANNED_WITH_ACTIVATION'), + ...vmMapHeights(vm.root, 'LINT_DESTRUCTURE_ACTIVATION'), + ...nestingRowHeights(indexer.root), + ]); + assertArming(PAGES, [...heights]); + }); + +test('the deploy-lint pages state the shipped CONSENSUS_RULES count and names', { skip: vm.skip }, () => { + assertRuleSet(PAGES, consensusRules(vm.root)); +}); + +test('Global-object spellings documents the destructured banned-global reads', () => { + assertDestructureSpellings(PAGES.operations); +}); + +// The activation page's parenthetical for each testnet-armed VM map must not call testnet inert. +function assertActivationRow(markdown) { + const row = markdown.split('\n').find((line) => line.startsWith('| `xchain-vm` |')); + assert.ok(row, 'protocol activation page is missing the xchain-vm service row'); + for (const name of ['LINT_BANNED_WITH_ACTIVATION', 'LINT_DESTRUCTURE_ACTIVATION', 'BIGINT_SURFACE_STRIP_ACTIVATION']) { + const note = row.match(new RegExp(`\`${name}\`[^(]*\\(([^)]*)\\)`)); + assert.ok(note, `xchain-vm row has no note for ${name}`); + assert.doesNotMatch(note[1], STALE, `xchain-vm row calls ${name} unarmed on testnet`); + assert.match(note[1], /armed on testnet/, `xchain-vm row omits the testnet arming of ${name}`); + } +} + +test('the activation page records the testnet arming of the with, destructure and BigInt-strip maps', () => { + assertActivationRow(read('protocol/protocol-activation.md')); +}); + +test('the deploy-lint guards fail on a stale claim, a dropped height or a miscounted rule set', + { skip: vm.skip || indexer.skip }, () => { + const heights = vmMapHeights(vm.root, 'LINT_BANNED_WITH_ACTIVATION'); + const stale = { ...PAGES, deploy: `${PAGES.deploy}\nThe with ban is unarmed on mainnet and testnet.` }; + assert.throws(() => assertArming(stale, heights), /unarmed on testnet/); + const dropped = { ...PAGES, guide: PAGES.guide.replaceAll(heights[0], 'removed') }; + assert.throws(() => assertArming(dropped, heights), /guide omits/); + const rules = consensusRules(vm.root); + assert.throws(() => assertRuleSet(PAGES, [...rules, 'banned-future']), /member set|consensus rules/); + const lost = { ...PAGES, operations: PAGES.operations.replaceAll('`nesting-depth`', 'removed') }; + assert.throws(() => assertRuleSet(lost, rules), /operations\.md omits nesting-depth/); + assert.throws(() => assertDestructureSpellings(PAGES.operations.replaceAll('enforceLintDestructure', 'x'))); + const activation = read('protocol/protocol-activation.md'); + const inert = activation.replace(/(`LINT_DESTRUCTURE_ACTIVATION` in [^(]*\()[^)]*\)/, + '$1inert on mainnet and testnet until an operator arms it)'); + assert.notEqual(inert, activation, 'falsification fixture did not rewrite the destructure note'); + assert.throws(() => assertActivationRow(inert), /LINT_DESTRUCTURE_ACTIVATION/); + }); diff --git a/test/vm-twin-gates-registry-pages.test.js b/test/vm-twin-gates-registry-pages.test.js index 1a8b6455..c8aa8c29 100644 --- a/test/vm-twin-gates-registry-pages.test.js +++ b/test/vm-twin-gates-registry-pages.test.js @@ -108,6 +108,20 @@ function vmNetworkTables(sources) { }).filter(Boolean); } +// Wording that calls a gate off on testnet, which a table armed on testnet contradicts. +const STALE_TESTNET_NOTE = /(?:unarmed|inert) on mainnet and testnet|only regtest runs it|stays inert until/; + +// A testnet-armed table's note, from its name to its twin, must not call testnet off. +function assertTestnetArmedNote(row, gate) { + const testnet = gate.thresholds.testnet; + if (testnet === null || testnet >= SENTINEL_FLOOR) return; + const start = row.indexOf(`\`${gate.marker}\``); + const end = row.indexOf(`indexer twin is \`${gate.registry}\``, start); + if (start < 0 || end < 0) return; + assert.doesNotMatch(row.slice(start, end), STALE_TESTNET_NOTE, + `xchain-vm row calls ${gate.vm} off on testnet, but its table arms testnet`); +} + function assertTwinRegistryPages(activation, flagDays, tables) { const row = vmServiceRow(activation); const rosters = { @@ -122,6 +136,7 @@ function assertTwinRegistryPages(activation, flagDays, tables) { row.includes(`indexer twin is \`${gate.registry}\``), `xchain-vm row does not pair ${gate.vm} with ${gate.registry}`, ); + assertTestnetArmedNote(row, gate); for (const network of ['mainnet', 'testnet']) { const threshold = gate.thresholds[network]; const unarmed = threshold === null || threshold >= SENTINEL_FLOOR; @@ -179,6 +194,21 @@ test('the registry-page guard fails when a VM gate, twin, or roster entry is rem ); } } + const armed = tables.filter((gate) => gate.thresholds.testnet !== null + && gate.thresholds.testnet > 0 && gate.thresholds.testnet < SENTINEL_FLOOR); + assert.ok(armed.length > 0, 'no VM table arms testnet at an instant, so the stale-note guard has no subject'); + for (const gate of armed) { + const start = ACTIVATION.indexOf(`\`${gate.marker}\``); + const twin = `indexer twin is \`${gate.registry}\``; + const end = ACTIVATION.indexOf(twin, start); + assert.ok(start >= 0 && end > start, `${gate.vm} has no note in the xchain-vm row`); + for (const stale of ['it is unarmed on mainnet and testnet, ', 'only regtest runs it, from genesis, and ', + 'it stays inert until the operator arms it, ']) { + const page = ACTIVATION.slice(0, end) + stale + ACTIVATION.slice(end); + assert.throws(() => assertTwinRegistryPages(page, FLAG_DAYS, tables), + new RegExp(`calls ${gate.vm} off on testnet`)); + } + } const future = sources.concat( 'const FUTURE_METER_ACTIVATION = Object.seal({\nmainnet: null,\ntestnet: null,\nregtest: 0,\n});', ); diff --git a/user-guide/trading.md b/user-guide/trading.md index a99743ca..d0b330a1 100644 --- a/user-guide/trading.md +++ b/user-guide/trading.md @@ -93,11 +93,11 @@ Think of a dispenser like a coin-operated machine at a store. You set it up once ### Editing or Cancelling a Dispenser -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 edit an active dispenser to add more tokens to it (a refill), change its expiration, or update its allow and block lists. A new expiration takes effect immediately, but new allow and block lists take effect only about an hour after your edit is confirmed, the same wait as the cancel closing window described below. Until then the previous lists still decide who can buy, so a buyer you have just blocked can still purchase during that hour. 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. 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. 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. +**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. An extension that takes the dispenser past 90 days has its own pricing; see [Fees](#fees) below. --- @@ -118,7 +118,13 @@ This transparency is a core property of the protocol. All market data is derived DEX listings are priced by **how long the listing lives**, not by how early you create one. **The first 90 days are free**, so an ordinary listing that expires within 90 days carries no duration fee. Past that, every day beyond the 90th is charged at a per-day rate. The free window is identical on every supported chain (Bitcoin, Litecoin, and Dogecoin today); it is not a Bitcoin-only or time-limited promotion. -That listing fee is what creating or editing a listing costs: placing an order and creating a dispenser are charged on the duration schedule above, priced in XCHAIN. **Cancelling is free.** Taking an order or a dispenser off the book carries no protocol fee at all, so you are never charged for withdrawing a listing. You still pay the ordinary miner transaction fee on the underlying chain for any action you broadcast. +That listing fee is what creating a listing costs: placing an order and creating a dispenser are charged on the duration schedule above, priced in XCHAIN. **Cancelling is free.** Taking an order or a dispenser off the book carries no protocol fee at all, so you are never charged for withdrawing a listing. You still pay the ordinary miner transaction fee on the underlying chain for any action you broadcast. + +**Extending a listing is priced differently.** An edit that moves a listing's expiration later measures the new lifetime from when the listing was created. If that lifetime is still 90 days or less, the edit carries no duration fee. Once it passes 90 days, every day the edit adds is charged at the per-day rate, including added days that fall inside the first 90. An edit that keeps or shortens the expiration carries no duration fee. An extension that passes 90 days can therefore cost more than, the same as, or less than a fresh listing of the new length, depending on the lifetime the listing had before the edit (figures at the current gas price): + +- **From under 90 days, it costs more.** Extending a 30-day listing to 100 days charges all 70 added days (38,500 gas, 0.385 XCHAIN), while a listing created with a 100-day life pays for only 10 days (5,500 gas, 0.055 XCHAIN). +- **From exactly 90 days, such as the 90-day default, it costs the same.** Going from 90 to 180 days charges 90 days (49,500 gas, 0.495 XCHAIN), what a fresh 180-day listing pays. +- **From over 90 days, it costs less.** Going from 120 to 180 days charges only the 60 added days (33,000 gas, 0.33 XCHAIN), against 90 days (49,500 gas, 0.495 XCHAIN) for a fresh 180-day listing. **Listings that hand over ownership cost extra.** A listing that escrows a token's **ownership** instead of a balance (the ownership dispenser described above, and the equivalent order or swap) also pays a flat **ownership-escrow premium of 50,000 gas, 0.5 XCHAIN at the current gas price**. It is charged on top of any duration fee, and it applies inside the 90-day window too, so an ownership listing is never free. The premium is charged when the listing is created; editing an existing ownership listing does not pay it again, and cancelling is still free. It is the same amount on Bitcoin, Litecoin, and Dogecoin. diff --git a/whitepaper.md b/whitepaper.md index dbdf5e7e..61e302e8 100644 --- a/whitepaper.md +++ b/whitepaper.md @@ -297,7 +297,7 @@ The protocol defines 38 named ACTIONs across ten categories. Of these, 32 are us ### 6.2 Transfers -- **SEND** transfers balances. v0 single; v1 multi (same tick); v2 multi (mixed ticks); v3 per-transfer memos. For a token with active gated content, a SEND must be batched with a `MESSAGE v2` carrying the re-encrypted content key to the recipient (§15). +- **SEND** transfers balances. v0 single; v1 multi (same tick); v2 multi (mixed ticks); v3 per-transfer memos. For a token with active gated content, a SEND must be batched with a `MESSAGE v2` carrying the re-encrypted content key to the recipient, unless every pack gating that token sets a `GATE_MIN_AMOUNT` unlock threshold the recipient will still be below after the transfer (§15). - **SWEEP** moves all balances and/or ownerships from an address, optionally closing market positions. Five flags: `BALANCES`, `OWNERSHIPS` (default on), `ORDERS`, `SWAPS`, `DISPENSERS` (default off). Escrowed value is only reachable via the corresponding position flag. - **AIRDROP** distributes a token to every member of a `LIST` (of addresses, or of holders of listed tokens). v0-v3 scale from single to multi-token, multi-list, with memos. Fee scales with recipient count. - **DIVIDEND** pays a dividend token pro-rata to all holders of a token. The source is excluded; sub-unit rounding excludes zero-share holders. Fee scales with recipients. From 77cd27eb042afb39493a8cdd9aa1fa28a0609727 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 12 Oct 2026 11:57:29 -0700 Subject: [PATCH 02/84] docs: update platform map for ANCHOR v3 Describe activation-gated folded anchor bundles in both map copies and allow the documented v3 wire version in the integrity gate. --- architecture/platform-map-app.html | 6 +++--- architecture/platform-map.json | 6 +++--- test/platform-map-integrity.test.js | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/architecture/platform-map-app.html b/architecture/platform-map-app.html index c1c17515..bffb3bab 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 (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 v0 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 v0/v3 bundles on DOGE)." }, { "id": "hubdb", "label": "Hub DB", "type": "database", "group": "hub", "tech": "MariaDB", "description": "Hub state tables, one per file in src/sql/: 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." }, @@ -399,10 +399,10 @@

Flows

{ "id": "f8", "name": "State anchoring (ANCHOR on DOGE)", - "description": "Quorum-signed per-chain state checkpoints are anchored on Dogecoin as one ANCHOR v0 bundle per network, for chain-parse recoverability.", + "description": "Quorum-signed per-chain state checkpoints are anchored on Dogecoin in ANCHOR v0 checkpoint bundles or activation-gated v3 folded bundles, for chain-parse recoverability.", "steps": [ { "n": 1, "node": "hub", "edge": "r13", "action": "StateAnchorPublisher runs one publisher election per bundle by hash ordering and assembles every checkpointed chain's quorum-signed checkpoint as its own bundle section, plus the compressed cross-chain match archive." }, - { "n": 2, "node": "hub", "edge": "r14", "action": "ONE ANCHOR v0 checkpoint bundle per network per publishing cycle is built and submitted through the DOGE encoder from the elected publisher's own DOGE wallet." }, + { "n": 2, "node": "hub", "edge": "r14", "action": "The elected publisher builds and submits ANCHOR v0 checkpoint bundles, or v3 folded bundles at or above ANCHOR_FOLD_ACTIVATION, through the DOGE encoder from its own DOGE wallet." }, { "n": 3, "node": "encoder", "edge": "r4", "action": "Anchor transaction is broadcast on Dogecoin." }, { "n": 4, "node": "decoder", "edge": "r6", "action": "DOGE decoder picks the ANCHOR out of the block." }, { "n": 5, "node": "indexer", "edge": "r8", "action": "Indexers verify and record the checkpoint; RewardTracker records publisher rewards." } diff --git a/architecture/platform-map.json b/architecture/platform-map.json index 177767cb..ef149e89 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 (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 v0 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 v0/v3 bundles on DOGE)." }, { "id": "hubdb", @@ -794,7 +794,7 @@ { "id": "f8", "name": "State anchoring (ANCHOR on DOGE)", - "description": "Quorum-signed per-chain state checkpoints are anchored on Dogecoin as one ANCHOR v0 bundle per network, for chain-parse recoverability.", + "description": "Quorum-signed per-chain state checkpoints are anchored on Dogecoin in ANCHOR v0 checkpoint bundles or activation-gated v3 folded bundles, for chain-parse recoverability.", "steps": [ { "n": 1, @@ -806,7 +806,7 @@ "n": 2, "node": "hub", "edge": "r14", - "action": "ONE ANCHOR v0 checkpoint bundle per network per publishing cycle is built and submitted through the DOGE encoder from the elected publisher's own DOGE wallet." + "action": "The elected publisher builds and submits ANCHOR v0 checkpoint bundles, or v3 folded bundles at or above ANCHOR_FOLD_ACTIVATION, through the DOGE encoder from its own DOGE wallet." }, { "n": 3, diff --git a/test/platform-map-integrity.test.js b/test/platform-map-integrity.test.js index 0bab124b..daabc634 100644 --- a/test/platform-map-integrity.test.js +++ b/test/platform-map-integrity.test.js @@ -114,13 +114,13 @@ describe('platform-map scrub', () => { }); describe('platform-map ANCHOR wire versions', () => { - // protocol/actions/anchor.md closes the version space at 0, 1 and 2; the + // protocol/actions/anchor.md closes the version space at 0 through 3; the // retired wires reused those bytes, so a higher number names a shape that no longer exists. for (const file of [HTML, JSON_FILE]) { - test(`${path.basename(file)} names only ANCHOR versions 0, 1 and 2`, () => { + test(`${path.basename(file)} names only ANCHOR versions 0 through 3`, () => { const text = fs.readFileSync(file, 'utf8'); const bad = [...text.matchAll(/ANCHOR (v[\d/v]+)/g)] - .filter((m) => m[1].split('/').some((v) => Number(v.replace(/^v/, '')) > 2)) + .filter((m) => m[1].split('/').some((v) => Number(v.replace(/^v/, '')) > 3)) .map((m) => m[0]); assert.deepEqual(bad, [], `${path.basename(file)}: retired ANCHOR versions ${bad.join(', ')}`); }); From d589e70d0d4b102afe63b8c4050fd96be98bc21b Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 12 Oct 2026 11:57:45 -0700 Subject: [PATCH 03/84] Clarify anchor archive reward termination Document the conjunctive fold termination boundary. Guard the whitepaper ANCHOR summary against fold-only reward retirement claims. --- protocol/protocol-activation.md | 6 ++-- test/corpus-consistency-claims.test.js | 46 ++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/protocol/protocol-activation.md b/protocol/protocol-activation.md index e52b30e6..16918a2d 100644 --- a/protocol/protocol-activation.md +++ b/protocol/protocol-activation.md @@ -230,8 +230,10 @@ only the mainnet slot of a registry row like this one, so it lists the gate once carries every checkpointed chain section plus at most one archive section in one transaction. It is keyed on the anchor's own DOGE mined height like `ANCHOR_ACTIVATION` and always sits at or above it. Below the gate, version 3 is `invalid: VERSION (unknown)`; at or above it, versions 0, 1 and 2 -stay valid. At the gate, `anchor_archive` stops being minted as a reward. The fold gate uses the -house UNARMED sentinel on mainnet. Testnet is armed per chain through the map's `BTC:testnet`, +stay valid. The fold changes the publishing shape, but `anchor_archive` minting stops only when +`ANCHOR_ARCHIVE_FOLD_TERM_ACTIVATION` and `ANCHOR_FOLD_ACTIVATION` are both active. If either gate +is inactive, v1 archive rewards retain their pre-fold behavior. The fold gate uses the house +UNARMED sentinel on mainnet. Testnet is armed per chain through the map's `BTC:testnet`, `LTC:testnet` and `DOGE:testnet` heights in [`constants.js`](./constants.js). Its regtest entry is null by default, and `XC_ANCHOR_FOLD_REGTEST_ACTIVATION` arms it for a private regtest venue. diff --git a/test/corpus-consistency-claims.test.js b/test/corpus-consistency-claims.test.js index 4f1b58cd..3cfa0ee1 100644 --- a/test/corpus-consistency-claims.test.js +++ b/test/corpus-consistency-claims.test.js @@ -340,6 +340,52 @@ test('falsification: the retired audit-anchor sentence is caught in §9', () => assert.throws(() => assertMatchArchiveClaim(stale)); }); +const ANCHOR_ARCHIVE_TERMINATION = + /(?:anchor_archive[^.\n]{0,120}(?:stop|retir|terminat)|(?:stop|retir|terminat)[^.\n]{0,120}anchor_archive)/i; + +function anchorActionBullet(markdown) { + const line = markdown.split('\n').find((candidate) => /^- \*\*ANCHOR\*\*/.test(candidate)); + assert.ok(line, 'whitepaper.md no longer has its ANCHOR action bullet'); + return line; +} + +function anchorArchiveTerminationGate(markdown) { + const heading = /^### Archive reward termination \(`([^`]+)`\)$/m.exec(markdown); + assert.ok(heading, 'anchor.md no longer has its archive reward termination heading'); + const section = markdown.slice(heading.index, markdown.indexOf('\n### ', heading.index + heading[0].length)); + assert.match(section, /only when it \*\*and\*\* `ANCHOR_FOLD_ACTIVATION` are active/, + 'anchor.md no longer states that archive reward termination requires both gates'); + return heading[1]; +} + +function assertWhitepaperAnchorRewardTermination(whitepaper, anchor) { + const termGate = anchorArchiveTerminationGate(anchor); + const terminationClaims = anchorActionBullet(whitepaper) + .split(/(?<=[.!?])\s+/) + .filter((sentence) => ANCHOR_ARCHIVE_TERMINATION.test(sentence)); + for (const sentence of terminationClaims) { + assert.match(sentence, new RegExp(`\\b${termGate}\\b`), + `whitepaper.md attributes anchor_archive reward termination without ${termGate}`); + } +} + +test('the whitepaper ANCHOR bullet follows anchor.md on archive reward termination', () => { + assertWhitepaperAnchorRewardTermination( + readDoc('whitepaper.md'), + readDoc('protocol/actions/anchor.md')); +}); + +test('falsification: fold-only anchor_archive reward termination is caught', () => { + const page = readDoc('whitepaper.md'); + const stale = page.replace( + 'v0, v1 and v2 stay valid beside it', + 'v0, v1 and v2 stay valid beside it. At `ANCHOR_FOLD_ACTIVATION`, `anchor_archive` minting stops'); + assert.notEqual(stale, page, 'the ANCHOR bullet moved; re-point this falsification'); + assert.throws(() => assertWhitepaperAnchorRewardTermination( + stale, + readDoc('protocol/actions/anchor.md'))); +}); + /* ---------------------------------------------------------------- claim 7 */ const WS_DOC = 'components/explorer/websocket.md'; From 4b3154ea5ee081dd10e7a294d62ac2c0008d772a Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 12 Oct 2026 19:02:19 +0000 Subject: [PATCH 04/84] test: require ANCHOR version references in platform map The wire-version test passed vacuously when no ANCHOR version reference was present. Require at least one reference and that both v0 and v3 are named in each file. --- test/platform-map-integrity.test.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/test/platform-map-integrity.test.js b/test/platform-map-integrity.test.js index daabc634..c8c228c8 100644 --- a/test/platform-map-integrity.test.js +++ b/test/platform-map-integrity.test.js @@ -119,7 +119,14 @@ describe('platform-map ANCHOR wire versions', () => { for (const file of [HTML, JSON_FILE]) { test(`${path.basename(file)} names only ANCHOR versions 0 through 3`, () => { const text = fs.readFileSync(file, 'utf8'); - const bad = [...text.matchAll(/ANCHOR (v[\d/v]+)/g)] + const refs = [...text.matchAll(/ANCHOR (v[\d/v]+)/g)]; + // Guard against a vacuous pass: the map must still name the live wires. + assert.ok(refs.length > 0, `${path.basename(file)}: no ANCHOR version references found`); + const named = new Set(refs.flatMap((m) => m[1].split('/').map((v) => Number(v.replace(/^v/, ''))))); + for (const v of [0, 3]) { + assert.ok(named.has(v), `${path.basename(file)}: no reference to ANCHOR v${v}`); + } + const bad = refs .filter((m) => m[1].split('/').some((v) => Number(v.replace(/^v/, '')) > 3)) .map((m) => m[0]); assert.deepEqual(bad, [], `${path.basename(file)}: retired ANCHOR versions ${bad.join(', ')}`); From 42f7e0f5998216b0e3a0caca43065ac28dc63d46 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 12 Oct 2026 15:04:46 -0700 Subject: [PATCH 05/84] Document the response mirror hub source requirement Clarify that the local oracle-price fallback cannot replace the finalized ATTEST response source on armed BTC networks. Add a source-backed documentation guard with marker-removal falsification coverage. --- components/indexer/README.md | 2 +- components/indexer/configuration.md | 14 +++-- ...indexer-response-mirror-hub-source.test.js | 51 +++++++++++++++++++ 3 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 test/indexer-response-mirror-hub-source.test.js diff --git a/components/indexer/README.md b/components/indexer/README.md index f4dc4250..69d0d25d 100644 --- a/components/indexer/README.md +++ b/components/indexer/README.md @@ -70,7 +70,7 @@ INDEXER_COIN=BTC INDEXER_NETWORK=mainnet ``` -> **Optional: Hub database variables.** If `HUB_DB_HOST` and `HUB_DB_NAME` are not set, the indexer logs a `WARNING: HUB_DB_HOST / HUB_DB_NAME not set` message at startup and reads price/oracle tables from its own local database instead. This is correct for single-host deployments. On a distributed node where the hub runs on a separate host, set these variables (along with `HUB_DB_PORT`, `HUB_DB_USER`, `HUB_DB_PASS`) to avoid using stale or absent fee/price data. See [Configuration](configuration.md) for the full variable list. +> **Hub database variables.** `HUB_DB_HOST` and `HUB_DB_NAME` are required for a BTC testnet or regtest indexer while `ATTEST_RESPONSE_MIRROR_ACTIVATION` is armed. Finalized responses in `attestation_responses` are off-chain consensus input, and AnchorRecovery cannot reconstruct it, so `INDEXER_ALLOW_LOCAL_PRICE_SOURCE` does not bypass this startup check. This requirement currently applies to BTC testnet and regtest. On currently unpinned BTC mainnet, a single-host indexer may still read price/oracle tables from its local database when `INDEXER_ALLOW_LOCAL_PRICE_SOURCE=true`; other testnet/regtest chains retain the local-database fallback. A distributed node should set the hub variables (along with `HUB_DB_PORT`, `HUB_DB_USER`, `HUB_DB_PASS`) in every case. See [Configuration](configuration.md) for the full topology and variable list. Start the indexer: diff --git a/components/indexer/configuration.md b/components/indexer/configuration.md index 92915e1a..85d8641d 100644 --- a/components/indexer/configuration.md +++ b/components/indexer/configuration.md @@ -299,6 +299,14 @@ Native-coin fee validation and FIAT settlement read the oracle tables (`price_sn - **Single-host:** the indexer's own database already holds the synced hub copy, so no separate hub DB connection is needed and the oracle tables are read locally. +That price-source choice does not cover the finalized ATTEST response mirror. A **BTC** indexer on a +network where `ATTEST_RESPONSE_MIRROR_ACTIVATION` is armed must set `HUB_DB_HOST` and `HUB_DB_NAME`. +The `attestation_responses` table is an off-chain consensus input that settles request fees, and +AnchorRecovery cannot reconstruct it. The indexer therefore refuses to boot without a hub database +source on BTC testnet and regtest, where the gate is currently armed. The gate remains unpinned on +BTC mainnet. `INDEXER_ALLOW_LOCAL_PRICE_SOURCE=true` acknowledges only the local oracle-price +topology and cannot bypass this response-mirror requirement. + The two are indistinguishable from config alone: a node with no hub DB looks the same whether that is intentional (single-host) or an operator forgot `HUB_DB_HOST` / `HUB_DB_NAME` on a distributed node. In the latter case the indexer would silently value native-coin fees against stale or empty local price @@ -306,12 +314,12 @@ data, which on mainnet can diverge from the canonical fleet and fork the ledger. To make the intent explicit, a **mainnet** indexer fails closed at startup when no hub DB is configured, unless `INDEXER_ALLOW_LOCAL_PRICE_SOURCE=true` is set to confirm an intentional single-host node. On -testnet and regtest, single-host is the norm and there is no canonical fleet to diverge from, so the -missing hub DB only logs a warning. +testnet and regtest the local-price acknowledgement is ignored. A missing hub DB only logs a warning +for a chain that is not subject to the armed BTC response-mirror refusal above. | Variable | Description | Default | |---|---|---| -| `INDEXER_ALLOW_LOCAL_PRICE_SOURCE` | Acknowledge an intentional single-host setup (read oracle tables from the local indexer DB). Required to boot a **mainnet** node that has no `HUB_DB_HOST` / `HUB_DB_NAME`; ignored on testnet/regtest. | unset (mainnet fails closed) | +| `INDEXER_ALLOW_LOCAL_PRICE_SOURCE` | Acknowledge an intentional single-host setup (read oracle tables from the local indexer DB). Required to boot a **mainnet** node that has no `HUB_DB_HOST` / `HUB_DB_NAME`; ignored on testnet/regtest. This does not bypass the armed BTC response-mirror hub database requirement above. | unset (mainnet fails closed) | ## Coin-Specific Configuration diff --git a/test/indexer-response-mirror-hub-source.test.js b/test/indexer-response-mirror-hub-source.test.js new file mode 100644 index 00000000..3f3c0498 --- /dev/null +++ b/test/indexer-response-mirror-hub-source.test.js @@ -0,0 +1,51 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const fs = require('node:fs'); +const path = require('node:path'); +const { sibling } = require('./helpers/sibling_checkout.js'); + +const DOC_ROOT = path.resolve(__dirname, '..'); +const README = fs.readFileSync(path.join(DOC_ROOT, 'components/indexer/README.md'), 'utf8'); +const CONFIG = fs.readFileSync(path.join(DOC_ROOT, 'components/indexer/configuration.md'), 'utf8'); +const STARTUP_REL = 'src/XChainIndexer/startup.js'; +const indexer = sibling('xchain-indexer', [STARTUP_REL]); + +const REQUIRED_DOC_MARKERS = [ + 'ATTEST_RESPONSE_MIRROR_ACTIVATION', + 'attestation_responses', + 'AnchorRecovery cannot reconstruct it', + 'INDEXER_ALLOW_LOCAL_PRICE_SOURCE', + 'BTC testnet and regtest', +]; + +function missingMarkers(text) { + return REQUIRED_DOC_MARKERS.filter((marker) => !text.includes(marker)); +} + +test('the indexer pages state the armed BTC response-mirror hub database requirement', () => { + for (const [name, text] of [['README', README], ['configuration', CONFIG]]) { + assert.deepEqual(missingMarkers(text), [], `${name} is missing response-mirror boot markers`); + assert.match(text, /HUB_DB_HOST[^\n]+HUB_DB_NAME/, + `${name} does not name both required hub database variables together`); + assert.match(text, /INDEXER_ALLOW_LOCAL_PRICE_SOURCE[\s\S]{0,240}(?:does not|cannot) bypass/, + `${name} presents the local-price escape hatch as bypassing the response-mirror guard`); + } +}); + +test('the documentation guard fails when a required operational claim is removed', () => { + for (const marker of REQUIRED_DOC_MARKERS) { + const changed = CONFIG.split(marker).join(''); + assert.ok(missingMarkers(changed).includes(marker), `removing ${marker} did not trip the guard`); + } +}); + +test('the indexer still enforces the documented startup refusal', { skip: indexer.skip }, () => { + const startup = fs.readFileSync(path.join(indexer.root, STARTUP_REL), 'utf8'); + assert.match(startup, /ATTEST_RESPONSE_MIRROR_ACTIVATION/); + assert.match(startup, /config\[['"]COIN['"]\]\s*===\s*['"]BTC['"]/); + assert.match(startup, /HUB_DB_HOST\s*\/\s*HUB_DB_NAME/); + assert.match(startup, /attestation_responses/); + assert.match(startup, /AnchorRecovery cannot rebuild it/); +}); From 613605bff23769787757c0088c37b50d734a0139 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 12 Oct 2026 18:31:49 -0700 Subject: [PATCH 06/84] Document the suite title map environment variables Add hub configuration rows for NODE_OPTIONS, XCHAIN_SUITE_TITLE_REPO_ROOT and XCHAIN_DOCS_DIR, which bin/suite-title-map.js reads and resets around the mocha children it spawns. --- components/hub/configuration.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/components/hub/configuration.md b/components/hub/configuration.md index 3dcbc5e9..1eb6c6a1 100644 --- a/components/hub/configuration.md +++ b/components/hub/configuration.md @@ -676,6 +676,9 @@ Read-only operator tools; neither broadcasts nor writes anything and neither is | `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. | | `XCHAIN_HUB_DIR` | No | `../xchain-hub` | Sibling-checkout override `bin/lib/carrier_logic_pin.js`'s `siblingDir()` resolves for cross-repo carrier-logic comparison; the `repo_guards` twin test points it at a second checkout with `XCHAIN_REQUIRE_SIBLINGS=1`. Never read by the running hub process. | | `XCHAIN_INDEXER_DIR` | No | `../xchain-indexer` | Sibling-checkout override for the `xchain-indexer` tree the same `siblingDir()` resolves when the pin tool compares against the indexer's canonical copy; read by literal name so the coverage gate can see it. Never read by the running hub process. | +| `NODE_OPTIONS` | No | _(unset)_ | Node's own option string. `bin/suite-title-map.js` appends `--require=` to the caller's value while it collects suite titles, so every mocha child it spawns loads the sibling mask, and restores the caller's value afterwards. Never read by the running hub process. | +| `XCHAIN_SUITE_TITLE_REPO_ROOT` | No | _(unset)_ | Set only by `bin/suite-title-map.js` for the mocha children it spawns: the checkout being collected. When present, the child hides every adjacent `xchain-*` checkout from file and module reads, so a pin collected beside a platform checkout matches one collected in standalone CI. Not an operator setting; never read by the running hub process. | +| `XCHAIN_DOCS_DIR` | No | `../xchain-documentation` | Sibling-checkout override for the documentation tree the hub's conformance tests read their vectors from. `bin/suite-title-map.js` unsets it, together with `XCHAIN_INDEXER_DIR`, for the mocha children it spawns and restores both afterwards, so an override cannot point a child back at a sibling the mask hides. Never read by the running hub process. | | `XCHAIN_SYNC_DIR` | No | `../xchain-sync` | Sibling-checkout override for the `xchain-sync` tree the same `siblingDir()` resolves when the pin tool compares against sync's copy; read by literal name so the coverage gate can see it. Never read by the running hub process. | ## Database Schema From 307e9dfd8c2d07121fae3ed839363d74ea83b353 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 12 Oct 2026 19:52:47 -0700 Subject: [PATCH 07/84] Document the regtest switch for the exact-integer wire fields activation --- components/indexer/configuration.md | 1 + 1 file changed, 1 insertion(+) diff --git a/components/indexer/configuration.md b/components/indexer/configuration.md index 85d8641d..b594a603 100644 --- a/components/indexer/configuration.md +++ b/components/indexer/configuration.md @@ -161,6 +161,7 @@ carries the credit. Arming the leg is an edit to that bundle plus a `xchain-hub/bin/sync-coins.sh` re-vendoring wave, never a per-node export. | `CROSS_CHAIN_ROYALTY_REGTEST_TIME` | **Regtest only.** Override the cross-chain royalty activation time so the OFF/deny path stays drillable on a single-node stack. Deliberately regtest-scoped: two nodes with different values would disagree on consensus. | `0` (activate at genesis) | | `CONTROLLER_CUSTODY_GUARD_REGTEST_TIME` | **Regtest only.** Override the controller custody guard activation time so the pre-activation bypass and the guarded path stay drillable on a single-node stack. Deliberately regtest-scoped: two nodes with different values would disagree on consensus. | `0` (activate at genesis) | +| `EXACT_INTEGER_WIRE_FIELDS_REGTEST_TIME` | **Regtest only.** Arm the exact-integer wire fields activation at this time so a regtest venue can drive the strict wire parsing; a set non-negative integer arms it (0 means active from genesis). Deliberately regtest-scoped: testnet and mainnet read their pinned activation times. | unset (unarmed) | ### A7 replay-equivalence harness From 1cbf4e18c7b70f2e960fbf99eeb698b9f4b5e610 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 12 Oct 2026 23:24:26 -0700 Subject: [PATCH 08/84] Catalogue the source-author reissue and active-first slash gates and regenerate flag days --- protocol/activation-catalogue.md | 2 ++ protocol/flag-days.md | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/protocol/activation-catalogue.md b/protocol/activation-catalogue.md index 113569a0..6b697e84 100644 --- a/protocol/activation-catalogue.md +++ b/protocol/activation-catalogue.md @@ -29,6 +29,7 @@ same chain or field. | `anchor_bundle_order_activation` | `ANCHOR_BUNDLE_ORDER_ACTIVATION` | height | | `anchor_empty_fold_reject_activation` | `ANCHOR_EMPTY_FOLD_REJECT_ACTIVATION` | height | | `anchor_fold_activation` | `ANCHOR_FOLD_ACTIVATION` | height | +| `anchor_fold_reissue_source_author_activation` | `ANCHOR_FOLD_REISSUE_SOURCE_AUTHOR_ACTIVATION` | height | | `anchor_preactivation_status_activation` | `ANCHOR_PREACTIVATION_STATUS_ACTIVATION` | height | | `anchor_reward_activation` | `ANCHOR_REWARD_ACTIVATION` and related anchor/archive reward gates | height | | `archive_batch_author_activation` | `ARCHIVE_BATCH_AUTHOR_ACTIVATION` | height | @@ -55,6 +56,7 @@ same chain or field. | `caret_ref_strict_activation` | `CARET_REF_STRICT_ACTIVATION` | height | | `checkpoint_commitment_activation` | `CHECKPOINT_COMMITMENT_ACTIVATION` | height | | `consolidation_leg_amount_activation` | `CONSOLIDATION_LEG_AMOUNT_ACTIVATION` | time | +| `contract_slash_active_first_activation` | `CONTRACT_SLASH_ACTIVE_FIRST_ACTIVATION` | height | | `cooldown_maturity_escrow_reversal_activation` | `COOLDOWN_MATURITY_ESCROW_REVERSAL_ACTIVATION` | height | | `cross_chain_offer_list_export_activation` | `CROSS_CHAIN_OFFER_LIST_EXPORT` | height | | `cross_chain_remote_token_activation` | `CROSS_CHAIN_REMOTE_TOKEN_ACTIVATION` | height | diff --git a/protocol/flag-days.md b/protocol/flag-days.md index d0078377..2a7bb424 100644 --- a/protocol/flag-days.md +++ b/protocol/flag-days.md @@ -104,13 +104,13 @@ simultaneously on Bitcoin, Litecoin, and Dogecoin. 5 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, `REST_PATTERN_METER` 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). -**25 gates are UNARMED on mainnet** (`AMOUNT_REPRESENTABILITY_ACTIVATION`, `APPLY_LENGTH_METER`, `ATTEST_RELAY_RESPONSE_DEADLINE_ACTIVATION`, `BROADCAST_FEE_LENGTH`, `CONTROLLER_CUSTODY_GUARD`, `CONTROLLER_GUARD_LEG_SAVEPOINTS`, `DELEGATION_ROTATE_IN_WINDOW`, `DISPENSER_FRESHNESS_PROVEN_USE_ACTIVATION`, `DISPENSER_REFILL`, `DISPENSER_SETTLEMENT_PRICE_ACTIVATION`, `EXACT_INTEGER_WIRE_FIELDS`, `GAS_CEILING_SUCCESS`, `ITER_SET_METER`, `JSON_STRINGIFY_HOOK`, `LIST_EDIT_REMOVE_ACTIVATION`, `OWNER_WITHDRAW_OPT_IN`, `PRICE_V1_CANONICAL_ACTIVATION`, `READONLY_ACCESSOR_OWN_KEY`, `SEND_GATED_TOTAL_TICK_ID_ACTIVATION`, `SLASH_ATTEST_MULTIROUND_EXEMPT`, `SLASH_XANCPUB_PUBLISHER_PAIR`, `STAKE_DELEGATED_SIGNING_KEY`, `STAKE_SNAPSHOT_DECIMAL_STRINGS`, `STAKE_SNAPSHOT_SLASH_WINDOW`, `UNCAPPED_MAX_SUPPLY_ZERO`): 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 under `xchain-indexer/src/protocol_changes/`. This note covers the registry only; a sibling `*_activation.js` module can park a mainnet sentinel too, and those are not enumerated here. +**26 gates are UNARMED on mainnet** (`AMOUNT_REPRESENTABILITY_ACTIVATION`, `APPLY_LENGTH_METER`, `ARCHIVE_REPLAY_WRAPPER_WATERMARK`, `ATTEST_RELAY_RESPONSE_DEADLINE_ACTIVATION`, `BROADCAST_FEE_LENGTH`, `CONTROLLER_CUSTODY_GUARD`, `CONTROLLER_GUARD_LEG_SAVEPOINTS`, `DELEGATION_ROTATE_IN_WINDOW`, `DISPENSER_FRESHNESS_PROVEN_USE_ACTIVATION`, `DISPENSER_REFILL`, `DISPENSER_SETTLEMENT_PRICE_ACTIVATION`, `EXACT_INTEGER_WIRE_FIELDS`, `GAS_CEILING_SUCCESS`, `ITER_SET_METER`, `JSON_STRINGIFY_HOOK`, `LIST_EDIT_REMOVE_ACTIVATION`, `OWNER_WITHDRAW_OPT_IN`, `PRICE_V1_CANONICAL_ACTIVATION`, `READONLY_ACCESSOR_OWN_KEY`, `SEND_GATED_TOTAL_TICK_ID_ACTIVATION`, `SLASH_ATTEST_MULTIROUND_EXEMPT`, `SLASH_XANCPUB_PUBLISHER_PAIR`, `STAKE_DELEGATED_SIGNING_KEY`, `STAKE_SNAPSHOT_DECIMAL_STRINGS`, `STAKE_SNAPSHOT_SLASH_WINDOW`, `UNCAPPED_MAX_SUPPLY_ZERO`): 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 under `xchain-indexer/src/protocol_changes/`. 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. 20 gates are the exception: `ISSUE_INHERITED_MINT_WINDOW` arms testnet at `1787961600` (2026-08-29 00:00:00 UTC), `DEPLOY_DEFERRED_ASSEMBLY` arms testnet at `1788868800` (2026-09-08 12:00:00 UTC), `CONTRACT_META_REQUIRED` arms testnet at `1789257600` (2026-09-13 00:00:00 UTC), `OWNER_WITHDRAW_OPT_IN` arms testnet at `1790492400` (2026-09-27 07:00:00 UTC), `UNIFIED_FEES_SWEEP_CALLBACK` arms testnet at `1790812800` (2026-10-01 00:00:00 UTC), `BROADCAST_FEE_LENGTH` arms testnet at `1791061097` (2026-10-03 20:58:17 UTC), `CONTROLLER_CUSTODY_GUARD` arms testnet at `1791061097` (2026-10-03 20:58:17 UTC), `CONTROLLER_GUARD_LEG_SAVEPOINTS` arms testnet at `1791061097` (2026-10-03 20:58:17 UTC), `JSON_STRINGIFY_HOOK` arms testnet at `1791061097` (2026-10-03 20:58:17 UTC), `APPLY_LENGTH_METER` arms testnet at `1791804379` (2026-10-12 11:26:19 UTC), `DELEGATION_ROTATE_IN_WINDOW` arms testnet at `1791804379` (2026-10-12 11:26:19 UTC), `DISPENSER_REFILL` arms testnet at `1791804379` (2026-10-12 11:26:19 UTC), `EXACT_INTEGER_WIRE_FIELDS` arms testnet at `1791804379` (2026-10-12 11:26:19 UTC), `GAS_CEILING_SUCCESS` arms testnet at `1791804379` (2026-10-12 11:26:19 UTC), `ITER_SET_METER` arms testnet at `1791804379` (2026-10-12 11:26:19 UTC), `SLASH_ATTEST_MULTIROUND_EXEMPT` arms testnet at `1791804379` (2026-10-12 11:26:19 UTC), `SLASH_XANCPUB_PUBLISHER_PAIR` arms testnet at `1791804379` (2026-10-12 11:26:19 UTC), `STAKE_DELEGATED_SIGNING_KEY` arms testnet at `1791804379` (2026-10-12 11:26:19 UTC), `STAKE_SNAPSHOT_DECIMAL_STRINGS` arms testnet at `1791804379` (2026-10-12 11:26:19 UTC), `STAKE_SNAPSHOT_SLASH_WINDOW` arms testnet at `1791804379` (2026-10-12 11:26:19 UTC). The reason it cannot be genesis-active there is written in its registration comment under `xchain-indexer/src/protocol_changes/`. The values on this page are otherwise mainnet values only. -**5 gates are UNARMED on testnet** (`AMOUNT_REPRESENTABILITY_ACTIVATION`, `DISPENSER_FRESHNESS_PROVEN_USE_ACTIVATION`, `DISPENSER_SETTLEMENT_PRICE_ACTIVATION`, `LIST_EDIT_REMOVE_ACTIVATION`, `PRICE_V1_CANONICAL_ACTIVATION`): 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 under `xchain-indexer/src/protocol_changes/`. +**6 gates are UNARMED on testnet** (`AMOUNT_REPRESENTABILITY_ACTIVATION`, `ARCHIVE_REPLAY_WRAPPER_WATERMARK`, `DISPENSER_FRESHNESS_PROVEN_USE_ACTIVATION`, `DISPENSER_SETTLEMENT_PRICE_ACTIVATION`, `LIST_EDIT_REMOVE_ACTIVATION`, `PRICE_V1_CANONICAL_ACTIVATION`): 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 under `xchain-indexer/src/protocol_changes/`. ## Mainnet time-keyed gates From 7f63187c14e480bc068c9633bbb9e62466eb42b4 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 00:41:57 -0700 Subject: [PATCH 09/84] Document the explorer static rate limit --- components/explorer/configuration.md | 1 + 1 file changed, 1 insertion(+) diff --git a/components/explorer/configuration.md b/components/explorer/configuration.md index f9d4d352..531ed248 100644 --- a/components/explorer/configuration.md +++ b/components/explorer/configuration.md @@ -392,6 +392,7 @@ The explorer uses `express-rate-limit` middleware: | `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 | `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. | +| `EXPLORER_STATIC_RATE_LIMIT_RPM` | No | `1200` | Separate limit for static files (scripts, stylesheets, components, themes and fonts). The default is ten cold page loads of about 110 files each; these are disk reads, so the bucket only has to stop a flood, and a warm visitor sends almost none. | | `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). From f664fcea370d35beb5f81adddec1f65b9a2652d4 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 04:19:02 -0700 Subject: [PATCH 10/84] Document the explorer dense-rows, bind host and fee schedule cache variables and the utxo-tracker trust proxy hops --- components/explorer/configuration.md | 4 ++++ components/utxo-tracker/configuration.md | 1 + 2 files changed, 5 insertions(+) diff --git a/components/explorer/configuration.md b/components/explorer/configuration.md index 531ed248..757578ae 100644 --- a/components/explorer/configuration.md +++ b/components/explorer/configuration.md @@ -30,6 +30,10 @@ Hub-sourced configuration takes precedence for database connection details, allo | `EXPLORER_FORCE_HTTPS` | No | None | Explicitly enable (`1`) or disable (`0`) the HTTPS-hardening headers (HSTS and `upgrade-insecure-requests`). By default they are active only when `NODE_ENV=production`, so plain-HTTP dev/regtest deploys are not broken. Set to `1` when running behind a TLS-terminating proxy without `NODE_ENV=production`. | | `EXPLORER_HOLDERS_CACHE_MS` | No | `15000` | TTL (ms) of the per-tick holders-query result cache. `getHolders` requires an unindexable full-table filesort, so results are cached briefly to bound repeated-query cost. | | `EXPLORER_HOLDERS_CACHE_MAX` | No | `500` | Maximum number of distinct holders-query results kept in the cache. | +| `EXPLORER_TWO_PARTY_DENSE_ROWS` | No | `10000` | Row count at or over which an address of a two-party action family is paged through the ordered-walk query instead of the id-set query. 10,000 balances a family of about 1.1M rows; a non-numeric or non-positive value falls back to the default. | +| `EXPLORER_BIND_HOST` | No | `127.0.0.1` | Interface both API listeners bind. Loopback by default so the one trusted proxy hop is the only way in; the container image sets `0.0.0.0` so its published port reaches it. An empty value counts as unset. | +| `EXPLORER_FEESCHEDULE_CACHE_MS` | No | `15000` | Lifetime (ms) of a cached per-indexer fee schedule before it is refetched. Read per call through the config env view. | +| `EXPLORER_FEESCHEDULE_STALE_MAX_MS` | No | `120000` | Longest age (ms) at which a cached fee schedule is still served while the indexer is failing, bridging a short indexer outage. Read per call through the config env view. | | `EXPLORER_TOTALS_CACHE_MS` | No | `60000` | TTL (ms) of the platform-totals query cache. | | `MEMPOOL_COUNT_CACHE_MS` | No | `15000` | TTL (ms) of the mempool-count cache. | | `PRICE_CACHE_MS` | No | `60000` | TTL (ms) of the oracle-price cache. | diff --git a/components/utxo-tracker/configuration.md b/components/utxo-tracker/configuration.md index f259eda7..21feb04f 100644 --- a/components/utxo-tracker/configuration.md +++ b/components/utxo-tracker/configuration.md @@ -39,6 +39,7 @@ BULK_SYNC_RAM_BUDGET=768 | `XCHAIN_UNDO_BLOCKS_LTC` | Override the LTC reorg recovery window (blocks). The default is per network: 120 on mainnet and regtest, 5000 on testnet (litecoin testnet outran the previous 48 on 2026-09-01, and its deep public-testnet forks need the 5000-block class). | `120` (testnet `5000`) | | `XCHAIN_UNDO_BLOCKS_DOGE` | Override the DOGE reorg recovery window (blocks). 120 on every network. | `120` | | `UTXO_TRACKER_RATE_LIMIT_RPM` | API requests per minute per IP | `500` | +| `UTXO_TRACKER_TRUST_PROXY_HOPS` | Number of reverse proxies in front of the tracker, handed to Express `trust proxy` so the per-IP limiter keys on the right `X-Forwarded-For` entry. Above 10 logs an error and falls back to `0`. | `0` (trust no `X-Forwarded-For`) | | `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 **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)_ | From 7353685a46b55538c3c1fa12459efc8ebca0e0f1 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 04:27:02 -0700 Subject: [PATCH 11/84] Add the nested execute gas sum catalogue row, the applied policy error code row and the bridge panel routes to the registry scan --- protocol/activation-catalogue.md | 1 + protocol/error-codes.md | 1 + test/error-code-registry-coverage.test.js | 4 ++-- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/protocol/activation-catalogue.md b/protocol/activation-catalogue.md index 6b697e84..ccb5a687 100644 --- a/protocol/activation-catalogue.md +++ b/protocol/activation-catalogue.md @@ -99,6 +99,7 @@ same chain or field. | `market_list_source_activation` | `MARKET_LIST_SOURCE_ACTIVATION` | height | | `mirror_admission_activation` | `MIRROR_ADMISSION_ACTIVATION` and `MIRROR_ADMISSION_CONSUMER_ACTIVATION` | height | | `mirror_admission_margin_activation` | `ADMIT_CHAIN_MARGIN_ACTIVATION` | height | +| `nested_execute_gas_sum_activation` | `NESTED_EXECUTE_GAS_SUM_ACTIVATION` | height | | `oracle_hourly_window_activation` | `ORACLE_HOURLY_WINDOW_FIRST_ROUND` | constant | | `oracle_preload_causality_activation` | `ORACLE_PRELOAD_CAUSALITY_ACTIVATION` | height | | `oracle_price_age_hourly_activation` | `ORACLE_PRICE_AGE_HOURLY_ACTIVATION` | height | diff --git a/protocol/error-codes.md b/protocol/error-codes.md index 2c354462..9266651f 100644 --- a/protocol/error-codes.md +++ b/protocol/error-codes.md @@ -80,6 +80,7 @@ Errors are JSON objects: | `INVALID_TICK` | 400 | Bridge panel routes (`GET /{COIN}/api/bridge-invariant/{tick}`, `GET /{COIN}/api/bridge-transfers/{tick}`): the `tick` path segment is empty, longer than 250 characters, or contains a control character | No: fix the request | | `BRIDGE_INVARIANT_UNAVAILABLE` | 503 | `GET /{COIN}/api/bridge-invariant/{tick}`: this explorer has no hub endpoint, or the hub's bridge-invariant read failed or returned nothing | Maybe: a failed hub read may be transient; an explorer without a hub endpoint answers this every time | | `BRIDGE_TRANSFERS_UNAVAILABLE` | 503 | `GET /{COIN}/api/bridge-transfers/{tick}`: this explorer holds no hub-mirror source for the coin, or the bridge-transfer read failed | Maybe: a failed read may be transient; a coin with no mirror source answers this every time | +| `APPLIED_POLICY_UNAVAILABLE` | 503 | `GET /{COIN}/api/applied-policy/{tick}`: the coin has no checkpoint source, the explorer cannot read applied policy, or the read failed | Maybe: a failed read may be transient; a coin with no checkpoint source answers this every time | | `INVALID_ADDRESSES` | 400 | Batch address routes (`POST /{COIN}/api/balances`, `POST /{COIN}/api/coinpay_obligations`): the body's `addresses` field is missing, not an array, empty, or holds a non-string entry, and nothing is read. The SDK's client-side pre-flight throws the same code before a request is sent (see [SDK explorer methods](../components/sdk/explorer.md)) | No: fix the request | | `TOO_MANY_ADDRESSES` | 400 | Batch address routes: more than 20 addresses in one body, counted before duplicates are collapsed, so a body of repeats is refused rather than quietly served | No: send at most 20 per request | | `INVALID_ADDRESS` | 400 | Batch address routes: one entry is not a well-formed address; the offending entry is echoed (truncated) in the `error` text. Unrelated to the SDK library's own `INVALID_ADDRESS` typed error, which is a separate surface (see [SDK errors](../components/sdk/errors.md)) | No | diff --git a/test/error-code-registry-coverage.test.js b/test/error-code-registry-coverage.test.js index 7cd1e12a..2d4ac53d 100644 --- a/test/error-code-registry-coverage.test.js +++ b/test/error-code-registry-coverage.test.js @@ -35,7 +35,7 @@ * route forwards to the client as `code: e.code`; and a mirror-gate verdict * (`blocked: 'CODE'`), which the mirror gate sends by reference as * `code: blocked`. A new emit shape needs its own rule here, or its codes go - * unchecked. Only the four REST-side sources are read for the registry + * unchecked. Only the five REST-side sources are read for the registry * (contract/vm_query.js is one because its throws reach the wire through the * state-proof routes): * src/ws/ carries the WebSocket channel codes, which are a separate surface @@ -64,7 +64,7 @@ const EXPLORER_SRC = path.join(EXPLORER, 'src'); const REGISTRY_PAGE = path.join(ROOT, 'protocol/error-codes.md'); // REST-side emit sites. src/ws/ is deliberately absent (separate surface). -const SOURCES = ['XChainExplorer.js', 'api.js', 'http/concurrency_gate.js', 'contract/vm_query.js'] +const SOURCES = ['XChainExplorer.js', 'api.js', 'http/concurrency_gate.js', 'contract/vm_query.js', 'http/bridge_panel_routes.js'] .map((file) => path.join(EXPLORER_SRC, file)); // An entry file whose long methods were split keeps its path and gains a From b438bd3315f6144c77d967d2ef108330fd735f26 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 04:46:39 -0700 Subject: [PATCH 12/84] Refresh the explorer route counts and the generated flag-day page --- architecture/component-map.md | 4 ++-- protocol/flag-days.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/architecture/component-map.md b/architecture/component-map.md index 46e658f8..7b26f5ba 100644 --- a/architecture/component-map.md +++ b/architecture/component-map.md @@ -85,8 +85,8 @@ See [`../components/indexer/`](../components/indexer/) for full documentation. Key technical details: -- 305 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: - - 175 `/{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. +- 320 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: + - 190 `/{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. - 18 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), the two POST batch reads (balances, COINPay obligations), 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 117 HTML page routes plus `/openapi.json`, `/icon`, `/relay`, and the static asset mounts. - JSON-RPC 2.0 interface compatible with Counterparty-style tooling. diff --git a/protocol/flag-days.md b/protocol/flag-days.md index 2a7bb424..01550fe7 100644 --- a/protocol/flag-days.md +++ b/protocol/flag-days.md @@ -104,13 +104,13 @@ simultaneously on Bitcoin, Litecoin, and Dogecoin. 5 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, `REST_PATTERN_METER` 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). -**26 gates are UNARMED on mainnet** (`AMOUNT_REPRESENTABILITY_ACTIVATION`, `APPLY_LENGTH_METER`, `ARCHIVE_REPLAY_WRAPPER_WATERMARK`, `ATTEST_RELAY_RESPONSE_DEADLINE_ACTIVATION`, `BROADCAST_FEE_LENGTH`, `CONTROLLER_CUSTODY_GUARD`, `CONTROLLER_GUARD_LEG_SAVEPOINTS`, `DELEGATION_ROTATE_IN_WINDOW`, `DISPENSER_FRESHNESS_PROVEN_USE_ACTIVATION`, `DISPENSER_REFILL`, `DISPENSER_SETTLEMENT_PRICE_ACTIVATION`, `EXACT_INTEGER_WIRE_FIELDS`, `GAS_CEILING_SUCCESS`, `ITER_SET_METER`, `JSON_STRINGIFY_HOOK`, `LIST_EDIT_REMOVE_ACTIVATION`, `OWNER_WITHDRAW_OPT_IN`, `PRICE_V1_CANONICAL_ACTIVATION`, `READONLY_ACCESSOR_OWN_KEY`, `SEND_GATED_TOTAL_TICK_ID_ACTIVATION`, `SLASH_ATTEST_MULTIROUND_EXEMPT`, `SLASH_XANCPUB_PUBLISHER_PAIR`, `STAKE_DELEGATED_SIGNING_KEY`, `STAKE_SNAPSHOT_DECIMAL_STRINGS`, `STAKE_SNAPSHOT_SLASH_WINDOW`, `UNCAPPED_MAX_SUPPLY_ZERO`): 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 under `xchain-indexer/src/protocol_changes/`. This note covers the registry only; a sibling `*_activation.js` module can park a mainnet sentinel too, and those are not enumerated here. +**29 gates are UNARMED on mainnet** (`AMOUNT_REPRESENTABILITY_ACTIVATION`, `APPLY_LENGTH_METER`, `ARCHIVE_REPLAY_WRAPPER_WATERMARK`, `ATTEST_RELAY_RESPONSE_DEADLINE_ACTIVATION`, `BROADCAST_FEE_LENGTH`, `CONTROLLER_CUSTODY_GUARD`, `CONTROLLER_GUARD_LEG_SAVEPOINTS`, `DELEGATION_ROTATE_IN_WINDOW`, `DISPENSER_FRESHNESS_PROVEN_USE_ACTIVATION`, `DISPENSER_REFILL`, `DISPENSER_SETTLEMENT_PRICE_ACTIVATION`, `EMISSION_DEPTH_EXECUTE_ONLY`, `EXACT_INTEGER_WIRE_FIELDS`, `GAS_CEILING_SUCCESS`, `GUARD_STAKE_SNAPSHOT_DECIMAL_STRINGS`, `ITER_SET_METER`, `JSON_STRINGIFY_HOOK`, `LEGACY_ANCHOR_REWARD_PIN`, `LIST_EDIT_REMOVE_ACTIVATION`, `OWNER_WITHDRAW_OPT_IN`, `PRICE_V1_CANONICAL_ACTIVATION`, `READONLY_ACCESSOR_OWN_KEY`, `SEND_GATED_TOTAL_TICK_ID_ACTIVATION`, `SLASH_ATTEST_MULTIROUND_EXEMPT`, `SLASH_XANCPUB_PUBLISHER_PAIR`, `STAKE_DELEGATED_SIGNING_KEY`, `STAKE_SNAPSHOT_DECIMAL_STRINGS`, `STAKE_SNAPSHOT_SLASH_WINDOW`, `UNCAPPED_MAX_SUPPLY_ZERO`): 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 under `xchain-indexer/src/protocol_changes/`. 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. 20 gates are the exception: `ISSUE_INHERITED_MINT_WINDOW` arms testnet at `1787961600` (2026-08-29 00:00:00 UTC), `DEPLOY_DEFERRED_ASSEMBLY` arms testnet at `1788868800` (2026-09-08 12:00:00 UTC), `CONTRACT_META_REQUIRED` arms testnet at `1789257600` (2026-09-13 00:00:00 UTC), `OWNER_WITHDRAW_OPT_IN` arms testnet at `1790492400` (2026-09-27 07:00:00 UTC), `UNIFIED_FEES_SWEEP_CALLBACK` arms testnet at `1790812800` (2026-10-01 00:00:00 UTC), `BROADCAST_FEE_LENGTH` arms testnet at `1791061097` (2026-10-03 20:58:17 UTC), `CONTROLLER_CUSTODY_GUARD` arms testnet at `1791061097` (2026-10-03 20:58:17 UTC), `CONTROLLER_GUARD_LEG_SAVEPOINTS` arms testnet at `1791061097` (2026-10-03 20:58:17 UTC), `JSON_STRINGIFY_HOOK` arms testnet at `1791061097` (2026-10-03 20:58:17 UTC), `APPLY_LENGTH_METER` arms testnet at `1791804379` (2026-10-12 11:26:19 UTC), `DELEGATION_ROTATE_IN_WINDOW` arms testnet at `1791804379` (2026-10-12 11:26:19 UTC), `DISPENSER_REFILL` arms testnet at `1791804379` (2026-10-12 11:26:19 UTC), `EXACT_INTEGER_WIRE_FIELDS` arms testnet at `1791804379` (2026-10-12 11:26:19 UTC), `GAS_CEILING_SUCCESS` arms testnet at `1791804379` (2026-10-12 11:26:19 UTC), `ITER_SET_METER` arms testnet at `1791804379` (2026-10-12 11:26:19 UTC), `SLASH_ATTEST_MULTIROUND_EXEMPT` arms testnet at `1791804379` (2026-10-12 11:26:19 UTC), `SLASH_XANCPUB_PUBLISHER_PAIR` arms testnet at `1791804379` (2026-10-12 11:26:19 UTC), `STAKE_DELEGATED_SIGNING_KEY` arms testnet at `1791804379` (2026-10-12 11:26:19 UTC), `STAKE_SNAPSHOT_DECIMAL_STRINGS` arms testnet at `1791804379` (2026-10-12 11:26:19 UTC), `STAKE_SNAPSHOT_SLASH_WINDOW` arms testnet at `1791804379` (2026-10-12 11:26:19 UTC). The reason it cannot be genesis-active there is written in its registration comment under `xchain-indexer/src/protocol_changes/`. The values on this page are otherwise mainnet values only. -**6 gates are UNARMED on testnet** (`AMOUNT_REPRESENTABILITY_ACTIVATION`, `ARCHIVE_REPLAY_WRAPPER_WATERMARK`, `DISPENSER_FRESHNESS_PROVEN_USE_ACTIVATION`, `DISPENSER_SETTLEMENT_PRICE_ACTIVATION`, `LIST_EDIT_REMOVE_ACTIVATION`, `PRICE_V1_CANONICAL_ACTIVATION`): 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 under `xchain-indexer/src/protocol_changes/`. +**9 gates are UNARMED on testnet** (`AMOUNT_REPRESENTABILITY_ACTIVATION`, `ARCHIVE_REPLAY_WRAPPER_WATERMARK`, `DISPENSER_FRESHNESS_PROVEN_USE_ACTIVATION`, `DISPENSER_SETTLEMENT_PRICE_ACTIVATION`, `EMISSION_DEPTH_EXECUTE_ONLY`, `GUARD_STAKE_SNAPSHOT_DECIMAL_STRINGS`, `LEGACY_ANCHOR_REWARD_PIN`, `LIST_EDIT_REMOVE_ACTIVATION`, `PRICE_V1_CANONICAL_ACTIVATION`): 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 under `xchain-indexer/src/protocol_changes/`. ## Mainnet time-keyed gates From 38cb5b8802f0d3c0d1714b324d89c0a47b92cd57 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 04:53:17 -0700 Subject: [PATCH 13/84] Raise the explorer computed env read baseline to 8 for the fee schedule cache --- lib/env-var-doc-coverage.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/env-var-doc-coverage.js b/lib/env-var-doc-coverage.js index cd655a1c..fcb08558 100644 --- a/lib/env-var-doc-coverage.js +++ b/lib/env-var-doc-coverage.js @@ -1233,13 +1233,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: 6, indexer: 8, node: 5, + decoder: 4, encoder: 4, explorer: 8, hub: 6, indexer: 8, node: 5, 'regtest-miner': 4, sdk: 5, sync: 8, 'utxo-tracker': 7, vm: 0, // indexer 8 -> 9: regtestTimeOverride(envName) adds the computed read. // indexer 9 -> 8: consensus-identity.js reads the arming env by literal name. // hub 5 -> 6: its gate_registry/regtest_env.js twin of regtestTimeOverride adds the same read. // sync 7 -> 8: its vendored regtest_env.js twin of regtestTimeOverride adds the same read. // sdk 4 -> 5: its vendored regtest_env.js twin of regtestTimeOverride adds the same read. + // explorer 7 -> 8: src/explorer/fees_preflight/fee_schedule_cache.js:53 reads configEnv()[name] for EXPLORER_FEESCHEDULE_CACHE_MS and EXPLORER_FEESCHEDULE_STALE_MAX_MS, both documented. // explorer 6 -> 7: its vendored regtest_env.js twin of regtestTimeOverride adds the same read. // sync 8 -> 7 on 2026-09-25, re-measured by the gate against xchain-sync origin/develop a063a71: // the L019-6m-sync renames turned src/api.js:72 into a named read, leaving src/coins/index.js:120, From 4a2ef2ebd67d1d1ab17c9c3860e1e196e7b1b7a2 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 04:59:09 -0700 Subject: [PATCH 14/84] Pin the explorer computed env read baseline at 8 in the twin baseline test --- test/env-coverage-twin-baselines.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/env-coverage-twin-baselines.test.js b/test/env-coverage-twin-baselines.test.js index eb386c9e..d523baee 100644 --- a/test/env-coverage-twin-baselines.test.js +++ b/test/env-coverage-twin-baselines.test.js @@ -8,5 +8,5 @@ const { COMPUTED_READ_BASELINE } = require('../lib/env-var-doc-coverage.js'); test('computed-read baselines include every vendored regtest time-override twin', () => { assert.equal(COMPUTED_READ_BASELINE.sync, 8); assert.equal(COMPUTED_READ_BASELINE.sdk, 5); - assert.equal(COMPUTED_READ_BASELINE.explorer, 7); + assert.equal(COMPUTED_READ_BASELINE.explorer, 8); }); From 7ef1765a4e86a0083b543d00220b6ebbc797dd25 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 17:41:43 +0000 Subject: [PATCH 15/84] Add mainnet launch gate section to protocol activation Document the pre-launch skip rule, the validator action exemption, the pinned seed source exemption, and that testnet and regtest are never gated. --- protocol/protocol-activation.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/protocol/protocol-activation.md b/protocol/protocol-activation.md index ed895873..5d4d024b 100644 --- a/protocol/protocol-activation.md +++ b/protocol/protocol-activation.md @@ -508,6 +508,34 @@ takes a future instant and the two archive gates (`ARCHIVE_INVALID_HEIGHT_KEY`, `ARCHIVE_ROLLBACK_AUTHOR_SCOPE`) take future per-chain heights, all sized to the release that carries them and published on [Flag-Day Values](./flag-days.md). +## The mainnet launch gate + +On mainnet a wire transaction whose block time is below the mainnet launch instant is skipped before any +address, tx-hash, tx index or action row is written for it. Nothing from before launch enters the index, +so the indexed mainnet history starts clean at the launch instant. The instant is published on +[Flag-Day Values](./flag-days.md) and is not restated here. + +**The two exemptions.** A pre-launch transaction is still indexed when either of these holds: + +- **It is a validator action.** The validator action set is: ANCHOR in every version, PRICE format 0, + NODEPROOF, ROLLCALL, and the quorum-signed ATTEST formats 1, 3 and 4 together with the batch head and + the batch continuation. These are written by the validator set, not by users, and the chain needs them + whenever they appear. +- **It comes from a pinned seed source.** Each chain has a list of seed source addresses whose + pre-launch transactions are admitted. The per-chain seed source list is consensus data: it is + filled after the key ceremony and is not known to this document. + +**User actions are gated.** PRICE format 1 and ATTEST formats 0 and 2 are user actions, not validator +actions, so a pre-launch one is skipped like any other user action unless it comes from a seed source. +The test is the format of the action, not its type name alone. + +**The validator exemption applies at every height.** It is not limited to a window near launch. +Dogecoin mainnet already holds ANCHOR actions (see [the genesis arm](#the-mainnet-genesis-arm)), and those +must keep indexing exactly as before, so the exemption holds for any height below the launch instant. + +**Testnet and regtest are never gated.** The launch gate reads the mainnet network only. Testnet and +regtest index every transaction from their own first block, as they did before the gate existed. + ## Straggler behavior What happens to a node that crosses a flag day still running pre-activation code depends on whether From fc544d2cb06594078859cc93b79c3c495c491d0d Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 11:43:09 -0700 Subject: [PATCH 16/84] Document the encoder mainnet prelaunch override variable --- components/encoder/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/components/encoder/README.md b/components/encoder/README.md index c8cd6c67..8080a312 100644 --- a/components/encoder/README.md +++ b/components/encoder/README.md @@ -143,6 +143,7 @@ npm run api | `API_KEY` | No | Disabled | API key for `x-api-key` header authentication | | `XCHAIN_COMPRESSION_DEFAULT` | No | On | Set to `0`/`false`/`off` to stop compressing FILE payloads by default. A deploy-time lever, not a feature switch: compressed FILEs are unreadable to a reader that predates compression support, so this exists to let the code release and the behaviour change land separately (readers everywhere first, then the encoder). Callers can always override per request with the `compress` parameter | | `ENCODER_RATE_LIMIT_RPM` | No | `60` | Maximum requests per minute per IP | +| `ENCODER_MAINNET_PRELAUNCH_OVERRIDE` | No | Unset | On a mainnet encoder before the 2027-01-01 launch instant, `create_tx` refuses every action except the validator set (ANCHOR, PRICE format 0, NODEPROOF, ROLLCALL and quorum ATTEST formats). Set to `1` only on the foundation's bootstrap encoder; the encoder logs a warning at start when it is set | | `ENCODER_MAX_RPC_BATCH` | No | `20` | Maximum calls permitted in one inbound JSON-RPC batch. The router runs `Promise.all` over a batch while the rate limiter counts the batch as a single request, so without this bound one ~1MB array fans out into thousands of concurrent `estimate_fee` / `create_tx` handlers | | `ENCODER_TRUST_PROXY` | No | `loopback, uniquelocal` | Express `trust proxy` setting. The default recovers the real client IP behind a host proxy (loopback) or a Docker bridge (uniquelocal), and ignores a forged `X-Forwarded-For` when the encoder is exposed directly. Override with `false`, a hop count (e.g. `1`), or an address/CIDR list. Mirrors the hub's `HUB_TRUST_PROXY` | | `UTXO_TRACKER_MAX_LAG_BLOCKS` | No | `2` | Maximum blocks the utxo-tracker may report as lag (its per-response `sync` field) before `create_tx` refuses to select UTXOs from it and fails with `UTXO_TRACKER_STALE` | From dfd1d9472319d843cb084dba2f784fa975ee5fb8 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 11:50:45 -0700 Subject: [PATCH 17/84] Register the encoder MAINNET_PRELAUNCH operational reason --- protocol/error-codes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/protocol/error-codes.md b/protocol/error-codes.md index 9266651f..25f9587a 100644 --- a/protocol/error-codes.md +++ b/protocol/error-codes.md @@ -125,6 +125,7 @@ A `-32010` error always carries `error.data.reason`, a stable string that is app |---|---|---|---| | `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 | +| `MAINNET_PRELAUNCH` | A mainnet encoder refuses to build any action outside the validator set (ANCHOR, PRICE format 0, NODEPROOF, ROLLCALL, quorum ATTEST) before the mainnet launch instant, unless its operator sets `ENCODER_MAINNET_PRELAUNCH_OVERRIDE` | `opens_at` (Unix seconds of the launch instant) | Yes: after `opens_at` | | `NO_CONFIRMED_UTXO` | Input selection reached the maximum input count without enough value to fund the transaction | `selectedInputCount`, `maximum` | Yes: wait for a larger output to confirm, or consolidate the confirmed outputs | | `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 | From 187ebe9e274bf2f40390074a02e9c86cd87d3d25 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 19:03:30 +0000 Subject: [PATCH 18/84] Add mainnet launch activation and prelaunch seed sources to the canon MAINNET_LAUNCH_ACTIVATION is the time-keyed launch gate and PRELAUNCH_SEED_SOURCES holds the per-coin mainnet seed lists, empty until the key ceremony. Both are exported at the end of the list, and the canonical index in flag-days.md lists the new gate. --- protocol/constants.js | 15 +++++++++++++++ protocol/flag-days.md | 1 + 2 files changed, 16 insertions(+) diff --git a/protocol/constants.js b/protocol/constants.js index 14f3ba40..ecc88caf 100644 --- a/protocol/constants.js +++ b/protocol/constants.js @@ -2452,6 +2452,19 @@ const ANCHOR_ATTEST_BARRIER_ACTIVATION = Object.freeze({ regtest: resolveMirrorAdmissionRegtest(process.env), // shares the family's arming seam so one venue lever arms both }); +// MAINNET_LAUNCH_ACTIVATION: the launch gate, keyed by network and measured in unix seconds +// of block time, not height. Testnet and regtest carry 0 so the gate is open there from the +// first block; only mainnet waits for its instant. +const MAINNET_LAUNCH_ACTIVATION = Object.freeze({ mainnet: 1798761600, testnet: 0, regtest: 0 }); + +// PRELAUNCH_SEED_SOURCES: the sources a node may seed a mainnet coin from before the launch +// gate opens, keyed by "COIN:network". Every list is empty until the key ceremony fills it. +const PRELAUNCH_SEED_SOURCES = Object.freeze({ + 'BTC:mainnet': Object.freeze([]), + 'LTC:mainnet': Object.freeze([]), + 'DOGE:mainnet': Object.freeze([]), +}); + module.exports = { MAX_ACTION_DATA_LENGTH, ENVELOPE_MAX_PAYLOAD, @@ -2589,4 +2602,6 @@ module.exports = { LIST_TICK_COIN_ACTIVATION, TICK_NAMESPACE_ACTIVATION, RESERVED_FUTURE_ROOTS, + MAINNET_LAUNCH_ACTIVATION, + PRELAUNCH_SEED_SOURCES, }; diff --git a/protocol/flag-days.md b/protocol/flag-days.md index 01550fe7..93efdcf4 100644 --- a/protocol/flag-days.md +++ b/protocol/flag-days.md @@ -64,6 +64,7 @@ discoverable here even when it has no mainnet date for the table below. - `LIST_TICK_COIN_ACTIVATION` - `LIST_TRANSFER_ACTIVATION` - `LIST_UNION_ACTIVATION` +- `MAINNET_LAUNCH_ACTIVATION` - `MIRROR_ADMISSION_ACTIVATION` - `MIRROR_ADMISSION_CONSUMER_ACTIVATION` - `ORACLE_FEE_OUTPUT_ACTIVATION` From a5868e8503c92377d600249a9e535ac8150b60cf Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 13:40:40 -0700 Subject: [PATCH 19/84] Route contributions to develop and guard master against non-release pull requests Contributors now branch off develop and open pull requests against it, as the docs and PR template say. A new pull_request_target check fails any pull request into master that is not this repository's own develop, release/* or hotfix/* branch, and CI now runs on pull requests into develop. --- .github/pull_request_template.md | 4 +++ .github/workflows/base-branch-guard.yml | 40 +++++++++++++++++++++++++ CONTRIBUTING.md | 4 +-- 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/base-branch-guard.yml diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 6241305c..061bdbb5 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,5 +1,9 @@ +## Base branch + +- [ ] This pull request targets `develop`. `master` only takes release merges, and a pull request into it from a fork cannot be merged. + ## What this changes diff --git a/.github/workflows/base-branch-guard.yml b/.github/workflows/base-branch-guard.yml new file mode 100644 index 00000000..ad3e8dab --- /dev/null +++ b/.github/workflows/base-branch-guard.yml @@ -0,0 +1,40 @@ +# Fails any pull request into master that does not come from this repository's +# own develop, release/* or hotfix/* branch. master is what production runs, so +# outside contributions land on develop first and reach master only in a release +# merge (hotfix/* is the documented emergency path, see the release process). +# master's branch protection requires this check, so a fork pull request aimed +# at master cannot be merged by mistake. +# +# pull_request_target reads this file from the repository's own branch, never +# from the pull request, so a pull request cannot edit the guard away. The job +# reads only event metadata and never checks out pull request code. +name: Base branch guard + +on: + pull_request_target: + branches: [master] + # edited fires when an open pull request is retargeted onto master. + types: [opened, reopened, synchronize, edited] + +permissions: {} + +jobs: + base-branch-guard: + name: base-branch guard + runs-on: ubuntu-latest + steps: + - name: Require develop, release/* or hotfix/* from this repository + # Event fields go through env, never inline, so a branch name cannot inject shell. + env: + HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + THIS_REPO: ${{ github.repository }} + run: | + if [ "$HEAD_REPO" != "$THIS_REPO" ]; then + echo "::error::Pull requests from forks go to develop, not master. Change the base branch to develop." + exit 1 + fi + case "$HEAD_REF" in + develop|release/*|hotfix/*) echo "ok: $HEAD_REF into master" ;; + *) echo "::error::master only accepts develop, release/* or hotfix/*; retarget $HEAD_REF to develop."; exit 1 ;; + esac diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4c923ee1..1f59a427 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 `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. +Once the issue has consensus, open the PR against `develop`, which is the default branch. 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 `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)). +- Branch off `develop` and keep history linear (rebase, don't merge). `develop` is where work lands; `master` only ever receives release merges, and a pull request into `master` from a fork or any other branch fails the `base-branch guard` check (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. From 870dbdedd4409067a6678febebbbba8cc51c64cf Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 13:53:17 -0700 Subject: [PATCH 20/84] Keep the note that GitHub preselects master master stays the default branch, so contributors still switch the base to develop by hand. --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1f59a427..3d752876 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 `develop`, which is the default branch. 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. --- From 2a1dced033b883a350c5d6a1eed54d99a3a6ba21 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 21:13:27 +0000 Subject: [PATCH 21/84] Name the launch instant and seed source constants in the mainnet launch gate section The section now says the launch instant is the time-keyed MAINNET_LAUNCH_ACTIVATION map in protocol/constants.js and the per-chain seed lists are PRELAUNCH_SEED_SOURCES. --- protocol/protocol-activation.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/protocol/protocol-activation.md b/protocol/protocol-activation.md index 5d4d024b..64ee3c50 100644 --- a/protocol/protocol-activation.md +++ b/protocol/protocol-activation.md @@ -512,8 +512,9 @@ them and published on [Flag-Day Values](./flag-days.md). On mainnet a wire transaction whose block time is below the mainnet launch instant is skipped before any address, tx-hash, tx index or action row is written for it. Nothing from before launch enters the index, -so the indexed mainnet history starts clean at the launch instant. The instant is published on -[Flag-Day Values](./flag-days.md) and is not restated here. +so the indexed mainnet history starts clean at the launch instant. The instant is the time-keyed +`MAINNET_LAUNCH_ACTIVATION` map in `protocol/constants.js`, keyed by network and published on +[Flag-Day Values](./flag-days.md); it is not restated here. **The two exemptions.** A pre-launch transaction is still indexed when either of these holds: @@ -523,7 +524,8 @@ so the indexed mainnet history starts clean at the launch instant. The instant i whenever they appear. - **It comes from a pinned seed source.** Each chain has a list of seed source addresses whose pre-launch transactions are admitted. The per-chain seed source list is consensus data: it is - filled after the key ceremony and is not known to this document. + filled after the key ceremony and is not known to this document. The per-chain lists are + `PRELAUNCH_SEED_SOURCES` in `protocol/constants.js`, keyed `:mainnet` and empty until the key ceremony. **User actions are gated.** PRICE format 1 and ATTEST formats 0 and 2 are user actions, not validator actions, so a pre-launch one is skipped like any other user action unless it comes from a seed source. From 97df85ded3db4f1b5c66ac36ecfe2d02390a95eb Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 15:25:24 -0700 Subject: [PATCH 22/84] Add the mainnet launch activation catalogue row and regenerate the flag-day page --- protocol/activation-catalogue.md | 1 + protocol/flag-days.md | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/protocol/activation-catalogue.md b/protocol/activation-catalogue.md index ccb5a687..0b90461b 100644 --- a/protocol/activation-catalogue.md +++ b/protocol/activation-catalogue.md @@ -96,6 +96,7 @@ same chain or field. | `list_tick_coin_activation` | `LIST_TICK_COIN_ACTIVATION` for coin-qualified ticker LIST items and the ISSUE colon-root refusal | height | | `list_transfer_activation` | `LIST_TRANSFER_ACTIVATION` | height | | `list_union_activation` | `LIST_UNION_ACTIVATION` | height | +| `mainnet_launch_activation` | `MAINNET_LAUNCH_ACTIVATION` | time | | `market_list_source_activation` | `MARKET_LIST_SOURCE_ACTIVATION` | height | | `mirror_admission_activation` | `MIRROR_ADMISSION_ACTIVATION` and `MIRROR_ADMISSION_CONSUMER_ACTIVATION` | height | | `mirror_admission_margin_activation` | `ADMIT_CHAIN_MARGIN_ACTIVATION` | height | diff --git a/protocol/flag-days.md b/protocol/flag-days.md index 93efdcf4..a4995574 100644 --- a/protocol/flag-days.md +++ b/protocol/flag-days.md @@ -103,7 +103,7 @@ simultaneously on Bitcoin, Litecoin, and Dogecoin. | **UTC instant** | 2026-08-07 00:00:00 UTC | | **Gates riding it** | 37 | -5 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, `REST_PATTERN_METER` 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). +6 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, `MAINNET_LAUNCH_ACTIVATION` at 2027-01-01 00:00:00 UTC, `REST_PATTERN_METER` 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). **29 gates are UNARMED on mainnet** (`AMOUNT_REPRESENTABILITY_ACTIVATION`, `APPLY_LENGTH_METER`, `ARCHIVE_REPLAY_WRAPPER_WATERMARK`, `ATTEST_RELAY_RESPONSE_DEADLINE_ACTIVATION`, `BROADCAST_FEE_LENGTH`, `CONTROLLER_CUSTODY_GUARD`, `CONTROLLER_GUARD_LEG_SAVEPOINTS`, `DELEGATION_ROTATE_IN_WINDOW`, `DISPENSER_FRESHNESS_PROVEN_USE_ACTIVATION`, `DISPENSER_REFILL`, `DISPENSER_SETTLEMENT_PRICE_ACTIVATION`, `EMISSION_DEPTH_EXECUTE_ONLY`, `EXACT_INTEGER_WIRE_FIELDS`, `GAS_CEILING_SUCCESS`, `GUARD_STAKE_SNAPSHOT_DECIMAL_STRINGS`, `ITER_SET_METER`, `JSON_STRINGIFY_HOOK`, `LEGACY_ANCHOR_REWARD_PIN`, `LIST_EDIT_REMOVE_ACTIVATION`, `OWNER_WITHDRAW_OPT_IN`, `PRICE_V1_CANONICAL_ACTIVATION`, `READONLY_ACCESSOR_OWN_KEY`, `SEND_GATED_TOTAL_TICK_ID_ACTIVATION`, `SLASH_ATTEST_MULTIROUND_EXEMPT`, `SLASH_XANCPUB_PUBLISHER_PAIR`, `STAKE_DELEGATED_SIGNING_KEY`, `STAKE_SNAPSHOT_DECIMAL_STRINGS`, `STAKE_SNAPSHOT_SLASH_WINDOW`, `UNCAPPED_MAX_SUPPLY_ZERO`): 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 under `xchain-indexer/src/protocol_changes/`. This note covers the registry only; a sibling `*_activation.js` module can park a mainnet sentinel too, and those are not enumerated here. @@ -111,7 +111,7 @@ simultaneously on Bitcoin, Litecoin, and Dogecoin. threshold `0`, so a testnet or regtest stack has always run the post-activation behavior. 20 gates are the exception: `ISSUE_INHERITED_MINT_WINDOW` arms testnet at `1787961600` (2026-08-29 00:00:00 UTC), `DEPLOY_DEFERRED_ASSEMBLY` arms testnet at `1788868800` (2026-09-08 12:00:00 UTC), `CONTRACT_META_REQUIRED` arms testnet at `1789257600` (2026-09-13 00:00:00 UTC), `OWNER_WITHDRAW_OPT_IN` arms testnet at `1790492400` (2026-09-27 07:00:00 UTC), `UNIFIED_FEES_SWEEP_CALLBACK` arms testnet at `1790812800` (2026-10-01 00:00:00 UTC), `BROADCAST_FEE_LENGTH` arms testnet at `1791061097` (2026-10-03 20:58:17 UTC), `CONTROLLER_CUSTODY_GUARD` arms testnet at `1791061097` (2026-10-03 20:58:17 UTC), `CONTROLLER_GUARD_LEG_SAVEPOINTS` arms testnet at `1791061097` (2026-10-03 20:58:17 UTC), `JSON_STRINGIFY_HOOK` arms testnet at `1791061097` (2026-10-03 20:58:17 UTC), `APPLY_LENGTH_METER` arms testnet at `1791804379` (2026-10-12 11:26:19 UTC), `DELEGATION_ROTATE_IN_WINDOW` arms testnet at `1791804379` (2026-10-12 11:26:19 UTC), `DISPENSER_REFILL` arms testnet at `1791804379` (2026-10-12 11:26:19 UTC), `EXACT_INTEGER_WIRE_FIELDS` arms testnet at `1791804379` (2026-10-12 11:26:19 UTC), `GAS_CEILING_SUCCESS` arms testnet at `1791804379` (2026-10-12 11:26:19 UTC), `ITER_SET_METER` arms testnet at `1791804379` (2026-10-12 11:26:19 UTC), `SLASH_ATTEST_MULTIROUND_EXEMPT` arms testnet at `1791804379` (2026-10-12 11:26:19 UTC), `SLASH_XANCPUB_PUBLISHER_PAIR` arms testnet at `1791804379` (2026-10-12 11:26:19 UTC), `STAKE_DELEGATED_SIGNING_KEY` arms testnet at `1791804379` (2026-10-12 11:26:19 UTC), `STAKE_SNAPSHOT_DECIMAL_STRINGS` arms testnet at `1791804379` (2026-10-12 11:26:19 UTC), `STAKE_SNAPSHOT_SLASH_WINDOW` arms testnet at `1791804379` (2026-10-12 11:26:19 UTC). The reason it cannot be genesis-active there is written in its registration comment under `xchain-indexer/src/protocol_changes/`. The values on this page are otherwise mainnet values only. -**9 gates are UNARMED on testnet** (`AMOUNT_REPRESENTABILITY_ACTIVATION`, `ARCHIVE_REPLAY_WRAPPER_WATERMARK`, `DISPENSER_FRESHNESS_PROVEN_USE_ACTIVATION`, `DISPENSER_SETTLEMENT_PRICE_ACTIVATION`, `EMISSION_DEPTH_EXECUTE_ONLY`, `GUARD_STAKE_SNAPSHOT_DECIMAL_STRINGS`, `LEGACY_ANCHOR_REWARD_PIN`, `LIST_EDIT_REMOVE_ACTIVATION`, `PRICE_V1_CANONICAL_ACTIVATION`): 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 under `xchain-indexer/src/protocol_changes/`. +**10 gates are UNARMED on testnet** (`AMOUNT_REPRESENTABILITY_ACTIVATION`, `ARCHIVE_REPLAY_WRAPPER_WATERMARK`, `DISPENSER_FRESHNESS_PROVEN_USE_ACTIVATION`, `DISPENSER_SETTLEMENT_PRICE_ACTIVATION`, `EMISSION_DEPTH_EXECUTE_ONLY`, `GUARD_STAKE_SNAPSHOT_DECIMAL_STRINGS`, `LEGACY_ANCHOR_REWARD_PIN`, `LIST_EDIT_REMOVE_ACTIVATION`, `MAINNET_LAUNCH_ACTIVATION`, `PRICE_V1_CANONICAL_ACTIVATION`): 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 under `xchain-indexer/src/protocol_changes/`. ## Mainnet time-keyed gates @@ -158,6 +158,7 @@ post-activation behavior. 20 gates are the exception: `ISSUE_INHERITED_MINT_WIND | `CONTRACT_DELEGATION_MATERIALIZE` | `1789430400` | 2026-09-15 00:00:00 UTC | own date | `protocol_changes/changes_3.js` | | `DISPENSER_ORACLE_PER_TOKEN_PRICE` | `1789430400` | 2026-09-15 00:00:00 UTC | own date | `protocol_changes/changes_2.js` | | `CROSS_CHAIN_ROYALTY` | `1798761600` | 2027-01-01 00:00:00 UTC | own date | `protocol_changes/changes_2.js` | +| `MAINNET_LAUNCH_ACTIVATION` | `1798761600` | 2027-01-01 00:00:00 UTC | own date | `protocol_changes/gates_5.js` | | `REST_PATTERN_METER` | `1798761600` | 2027-01-01 00:00:00 UTC | own date | `protocol_changes/changes_2.js` | Thresholds keyed on a **block height** rather than a block time (the From 784f2efd09c23b9a430f420fd7c55265b544c101 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 17:32:55 -0700 Subject: [PATCH 23/84] Document deterministic Linux package modes --- components/wallet/reproducible-builds.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/components/wallet/reproducible-builds.md b/components/wallet/reproducible-builds.md index ccca4472..cc048eba 100644 --- a/components/wallet/reproducible-builds.md +++ b/components/wallet/reproducible-builds.md @@ -38,6 +38,7 @@ The audit catches regressions automatically on every commit. The run-twice verif - **Pinned Node version**, declared in the toolchain configuration and honored by every reproduction container. Tooling outside the container (a verifier's own local Node or pnpm) does not affect the build. - **`SOURCE_DATE_EPOCH`**, derived from the release commit's author date, is injected by each shell's reproduce script and honored by Vite, electron-builder's asar packing, and the archive tools used for Linux packaging. Web bundles sidestep embedded timestamps entirely. - **`LC_ALL=C.UTF-8` and `TZ=UTC`**, pinned in both the container image and the in-container environment, so locale-sensitive tools (`sort`, `date`) emit deterministic output. +- **Canonical Linux file modes**, applied before the desktop AppImage and `.deb` are packed, so permissions inherited from a verifier's checkout umask cannot change either artifact. - **Vite**, with source maps off in production, deterministic chunk and asset filenames, and no plugin that captures wall-clock time or generates random IDs. - **A digest-pinned base image**, so the toolchain a verifier's container installs is byte-identical to the one the release lane used, not just "the same major version" resolved on different days. - **The release lane builds inside that same container**, running the same reproduce script a verifier runs rather than building on the bare CI runner. This is what extends the guarantee to compiled files. The desktop bundle carries a native addon for elliptic-curve maths, compiled from source by the dependency install, so a release built outside the container would compile it against the runner's C compiler and system libraries while the verifier compiled it against the image's. Every other file would still match, and that one, plus the archive header that records its hash, would not. @@ -123,7 +124,7 @@ diff <(grep -v '^#' official.txt | grep -E '\.(AppImage|deb)$' | sort) \ <(grep -v '^#' RELEASE_HASHES.txt | sort) ``` -A zero-byte diff means the verifier's bytes are the maintainer's bytes. Two independently run builds of the same commit in the pinned container produce byte-identical `.AppImage` and `.deb` output on both architectures; getting there required two fixes to the archive-packing step described below, so a nonzero diff on a current release should be reported. +A zero-byte diff means the verifier's bytes are the maintainer's bytes. The verifier does not need to match the maintainer's checkout umask: two clean checkouts created under umasks `022` and `002` produce byte-identical `.AppImage` and `.deb` output on both architectures. A nonzero diff on a current release should be reported. Any difference between two runs is diagnostic: @@ -131,9 +132,13 @@ Any difference between two runs is diagnostic: - **Timestamp leakage.** A path that missed `SOURCE_DATE_EPOCH` propagation; this is a bug on the maintainer's side, worth reporting. - **Supply-chain tampering.** The maintainer's build environment produced something different from what source alone produces; worth investigating. -### Archive-packing determinism +### Linux packaging determinism -Two extra fixes were needed beyond `SOURCE_DATE_EPOCH` to get a byte-identical AppImage, because the squashfs tool used to build it predates that variable's adoption and does not honor it. A wrapper installed ahead of the pinned squashfs binary normalizes both the archive's internal creation time and its per-file modification times to the commit's author date, which the underlying tool otherwise reads from the wall clock and from the build's staging directory respectively. The `.deb` packaging path was unaffected; only the AppImage needed this. A dedicated smoke test guards this behavior. +`SOURCE_DATE_EPOCH` is not sufficient for a byte-identical AppImage because the pinned squashfs tool predates support for it. A wrapper installed ahead of the squashfs binary normalizes both the archive's internal creation time and its per-file modification times to the commit's author date. Without the wrapper, the tool reads those values from the wall clock and the build staging directory. + +File modes need separate normalization for both the AppImage and `.deb`. Git tracks whether a file is executable, but a checkout's umask affects the permissions with which Git creates it. For example, the same non-executable file can be `0644` in a checkout created under umask `022` and `0664` under umask `002`. Packaging preserves those modes, so equal file contents alone do not guarantee equal package hashes. + +`packages/desktop/scripts/deterministic-modes.cjs` derives each packaged entry's mode from its owner bits. The owner permissions stay unchanged; group and other receive the owner's read and execute permissions but never write permission. Special bits, including the setuid bit required by `chrome-sandbox`, are preserved, and symlinks are skipped. In `packages/desktop/electron-builder.config.cjs`, the Linux `beforePack` hook applies this rule to build resources and the `afterPack` hook applies it to the packed application tree. `packages/desktop/scripts/mksquashfs-deterministic.cjs` applies the same rule once more to the complete AppImage staging tree. Together these steps give the `.AppImage` and `.deb` identical hashes across `022` and `002` checkouts. A dedicated smoke test guards the timestamp and mode rules. The timestamp used throughout is the commit's author date, not its committer date, the two differ whenever a commit is rebased or amended after the fact, and using the wrong one silently changes the reproduced bytes for any tag where that happened. From fad784e2a69e3d7e0eb83291575a70d1f710d8a4 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 17:37:40 -0700 Subject: [PATCH 24/84] Document wallet UI layout and component rules --- components/wallet/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/components/wallet/README.md b/components/wallet/README.md index 0a98ad25..05db11cd 100644 --- a/components/wallet/README.md +++ b/components/wallet/README.md @@ -41,6 +41,7 @@ The wallet implements every XChain feature exposed by the platform: all 32 user- | [Keys & Signing](keys-signing.md) | BIP39 + passphrase, HD derivation, vault encryption, signer interface, software / Trezor / Ledger / Remote / Multisig | | [Security & Threat Model](security.md) | Protected assets, in-scope and out-of-scope threats, sign-screen safety rails, audit posture | | [UX Surfaces](ux.md) | Onboarding, lock/unlock, balances, history, send/receive, sign screens, contacts, QR scanner, command palette, settings | +| [UI Style Guide](ui-style-guide.md) | Contributor rules for page layout, responsive control sizing, vertical rhythm, templates, and shared UI components | | [Features](features.md) | Token issuance, DEX, messaging, dispensers, contracts, staking, multisig, cross-chain: surface-by-surface | | [Bridge](bridge.md) | `window.xchain` dApp bridge: connect, signMessage, signPsbt, signAction, signIn, events, error codes | | [URI Schemes](uri-schemes.md) | BIP21 + chain URIs + multisig PSBT envelope + chunked PSBT-QR transport | From ae63221208cf18c642743c0cd4da71221f2b3f89 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 17:37:43 -0700 Subject: [PATCH 25/84] Add wallet device layout release checklist --- components/wallet/release/qa-checklist.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/components/wallet/release/qa-checklist.md b/components/wallet/release/qa-checklist.md index 5f20e312..33ab99bc 100644 --- a/components/wallet/release/qa-checklist.md +++ b/components/wallet/release/qa-checklist.md @@ -172,6 +172,22 @@ Use the reference test dApp against the extension under test. --- +## Device layout + +Run the full pass before every release on an Android LG K51-class phone, an iOS simulator, desktop, and the extension. + +- ⬜ Every screen has equal left and right page gutters; phone gutters are 12 px. +- ⬜ No screen or control overflows horizontally. +- ⬜ Controls that share a row match in height, corner radius, and font size within 2 px. +- ⬜ Icons in icon-only buttons are centered within 1 px horizontally and vertically. +- ⬜ The header shows no more than three controls on the right. +- ⬜ The license gate fills the screen, keeps **Accept and continue** at the bottom, and remains locked until the terms are scrolled to the end. +- ⬜ Android Back closes the top sheet, dropdown, or popover before leaving the screen. +- ⬜ Test networks show no dollar values. +- ⬜ Every confirm screen opens scrolled to the top. + +--- + ## Accessibility - ⬜ Tab from the unlocked Home reveals the skip-to-main-content link as the first focusable element. From 6c7957f404a655d19e5afa3a7f2c11a4778b1971 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 17:37:46 -0700 Subject: [PATCH 26/84] Refresh wallet testing documentation --- components/wallet/testing.md | 192 ++++++++++------------------------- 1 file changed, 54 insertions(+), 138 deletions(-) diff --git a/components/wallet/testing.md b/components/wallet/testing.md index ff59eb97..f6d1ffb4 100644 --- a/components/wallet/testing.md +++ b/components/wallet/testing.md @@ -3,180 +3,96 @@ # Testing -The wallet's test strategy combines headless unit tests (vitest), headless smokes (plain Node), browser-driven E2E (Playwright), static audit gates (a11y, manifest, repro-build), and a reference test-dapp for manual bridge runbooks. +The wallet uses Vitest for unit and focused test tiers, standalone Node scripts for smoke coverage, Node's built-in test runner for developer tools, and Playwright for browser and live-chain end-to-end coverage. Run commands from the wallet workspace root unless a command says otherwise. -## Smoke suite +## Test suites -`pnpm test:smoke` (run from the workspace root) runs the full smoke suite via Node. **365 smoke files** run; CI fails on any regression. - -The smokes are deliberately *static*; they exercise component imports, render trees, registry validation, schema migrations, and audit scripts without spinning up real backends. They run in seconds and gate every commit. - -Counts below are file counts per `test/smoke/` subdirectory, measured 2026-07-27. - -| Group | Files | Coverage | +| Suite | Command | Discovered tests | |---|---|---| -| `ui/` | 170 | Screens, layouts, and UI primitives: render trees, empty and error states, QR transport, capslock and paste handling, confirm-screen composition, reduced-motion behaviour | -| `actions/` | 37 | Per-action authoring forms and their validation, one file per action surface (`airdrop-form`, `batch-composer`, `broadcast-form`, `address-preferences`, …) | -| `audits/` | 28 | a11y-audit, repro-build-audit, extension-manifest-audit, release-gates, and the other static gates | -| `core/` | 26 | Registry validation, schema migrations, shared helpers | -| `markets/` | 14 | DEX surfaces | -| `bridge/` | 12 | `window.xchain` dApp bridge and the approval broker | -| `signers/` | 12 | Software, Trezor, Ledger, Remote, Multisig, hw-factories, hw-sign-e2e, signer-port-protocol | -| `shells/` | 11 | Web SPA, extension popup/full-screen/side-panel, desktop | -| `multisig/`, `onboarding/`, `staking/` | 8 each | Classical n-of-m + MuSig2, vault creation and import, staking and delegation | -| `dispensers/`, `contracts/` | 7, 6 | Dispenser lifecycle; contract deploy/execute surfaces | -| `addresses/`, `security/`, `governance/`, `messaging/` | 5, 4, 3, 3 | Address list, security gates, governance polls, encrypted messaging | -| `wallet/`, `desktop/`, `docs/` | 1 each | Vault, Electron shell, docs conformance | -| **Total** | **365** | All run on every commit | - -The test files live under `test/smoke/` (workspace root), organised into subdirectories by surface area. Each smoke is named for the surface it covers (`send-form.smoke.js`, `multisig-signing.smoke.js`, etc.); the convention makes it easy to find the right file when extending coverage. - -## Vitest suites - -Beyond the smokes, the wallet runs eight vitest suites. Measured 2026-07-27: - -| Suite | Command | Tests | -|---|---|---| -| Unit | `pnpm test:unit` | 4,264 across 291 files | -| Integration | `pnpm test:integration` | 80 | -| Boundary | `pnpm test:boundary` | 49 | -| Security | `pnpm test:security` | 30 | -| Chaos | `pnpm test:chaos` | 16 | -| Fuzz | `pnpm test:fuzz` | 10 | -| Accessibility | `pnpm test:a11y` | 8 | -| Regression | `pnpm test:regression` | 4 | - -`pnpm test:all` chains all of them plus the smoke suite, for a wallet total of 4,825 tests. - -## Audit gates - -Three static audits run as part of the smoke suite. CI fails if any audit surfaces violations. - -### A11y audit (`a11y-audit.js`) - -Five rules over every JSX file under `packages/core/src/shared/` + `packages/core/src/ui/`: - -| Rule | What it requires | -|---|---| -| `button-needs-text-or-aria-label` | Every `