diff --git a/.github/workflows/vm-differential-fuzz.yml b/.github/workflows/vm-differential-fuzz.yml index 984cdf3..987fbd8 100644 --- a/.github/workflows/vm-differential-fuzz.yml +++ b/.github/workflows/vm-differential-fuzz.yml @@ -109,6 +109,13 @@ jobs: with: node-version: "22" + # compare only diffs already-recorded JSON manifests (no VM execution, + # so isolated-vm need not build from source here), but differential.js + # itself requires fast-check/acorn/astring, so those still need to be + # on disk before the compare step below can run at all. + - name: Install deps + run: npm install --ignore-scripts + - name: Download all manifests uses: actions/download-artifact@v4 with: @@ -121,14 +128,27 @@ jobs: set -e REF="manifests/differential.linux-x64-glibc.json" test -f "$REF" || { echo "reference manifest missing"; exit 1; } - status=0 + divergence=0 + crash=0 for m in manifests/differential.*.json; do [ "$m" = "$REF" ] && continue echo "::group::compare $(basename "$m") vs reference" - node test/fuzz/helpers/differential_run.js compare "$REF" "$m" || status=1 + out="$(node test/fuzz/helpers/differential_run.js compare "$REF" "$m" 2>&1)" && rc=0 || rc=$? + printf '%s\n' "$out" + if [ "$rc" -ne 0 ]; then + if printf '%s\n' "$out" | grep -q 'DIFFERENTIAL DIVERGENCE:'; then + divergence=1 + else + crash=1 + fi + fi echo "::endgroup::" done - if [ "$status" -ne 0 ]; then + if [ "$crash" -ne 0 ]; then + echo "::error::VM differential fuzz tool crashed before it could render a verdict (see the compare group's output above); this is not a consensus divergence" + exit 1 + fi + if [ "$divergence" -ne 0 ]; then echo "::error::VM differential fuzz found a cross-build consensus divergence" exit 1 fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 6271e98..0d252f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.20.1] - 2026-09-23 + +### Fixed +- Aligned simulator deploy gating, oracle round floors, and balance scoping with chain execution behavior. + + ## [0.20.0] - 2026-09-17 ### Changed diff --git a/README.md b/README.md index 24416a0..c754b80 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # XChain Platform Virtual Machine (VM)

- Version + Version Tests Node License @@ -201,8 +201,10 @@ runGate(source); // { ok, errors, advisories, warnings, g // string ("invalid: CONTRACT_MANIFEST (meta required)"). A meta the static walk // cannot read (computed, spread, non-literal) is an advisory, not a block. -const sim = new ContractSimulator({ coin: 'BTC' }); -sim.setBalance('alice', 'GOLD', '1000'); // seed read-only ledger/oracle state +const sim = new ContractSimulator({ coin: 'BTC', defaultCaller: 'alice' }); +// Seed read-only ledger/oracle state. A node preloads balances for the caller and +// the contract's own address only, so seed those two; any other address reads null on chain. +sim.setBalance('alice', 'GOLD', '1000'); sim.setAttestationResponse('req-1', '{"ok":true}'); // and the async read surfaces const { contractIndex } = await sim.deploy(source, { constructorParams: ['5'] }); const res = await sim.call(contractIndex, 'increment', ['3']); // state persists across calls @@ -310,7 +312,7 @@ Cross-run and cross-process determinism guarantees in `test/determinism/`: golde Latency/throughput assertions in `test/performance/` (`npm run test:performance`), distinct from the `bench/` scenario scripts below. -### Toolkit Tests (52) +### Toolkit Tests (141) `xchain-foundry` / `create-xchain-contract` developer-toolkit coverage: gate, scaffold, and TypeScript-strip logic run on any OS; simulator-backed cases need the isolated-vm binding (Node 22 / Linux). @@ -413,7 +415,7 @@ xchain-vm/ | |-- smoke/ (10 tests) | |-- determinism/ (79 tests, incl. a known-red probe subset) | |-- performance/ (5 tests, distinct from bench/ below) -| |-- toolkit/ (52 tests: xchain-foundry gate/scaffold/simulate) +| |-- toolkit/ (141 tests: xchain-foundry gate/scaffold/simulate) | |-- e2e/ (10 E2E test files, 64 tests + helpers + contracts) | |-- fuzz/ (9 fuzz test files, 57 tests + harness + generators) | |-- chaos/ (3-phase chaos tests, 76 tests + helpers + contracts) diff --git a/bin/ci-full.sh b/bin/ci-full.sh index 3c4eb30..5673a9c 100755 --- a/bin/ci-full.sh +++ b/bin/ci-full.sh @@ -46,7 +46,35 @@ SELF="$(pwd)" SIB="$(cd .. && pwd)" FAILED="" +# >>> ci-tier (generated block; re-run the tier wirer to update) >>> +# Tier classes. A push grades the FAST tier only: the unit job, the pin and +# drift guards, and the structure and hygiene checks the hook runs before it +# dispatches. The tiers named below (coverage re-runs, perf scenarios) are +# skipped when the gate sets CI_TIER=fast, and each skip is recorded so the +# closing verdict can never claim a green it did not earn. Nothing stops +# being graded: a scheduled sweep re-runs this same script with CI_TIER=full +# on every repo every three hours and before any release or deploy, and a +# red there is tracked down and fixed first. CI_TIER is unset for a hand +# run, so a bare `npm run ci:full` still runs every tier as it always did. +CI_TIER_FULL_ONLY=( + "coverage ratchet (coverage:check)" +) +DEFERRED="" +ci_tier_deferred() { + [ "${CI_TIER:-full}" = "fast" ] || return 1 + local t + for t in ${CI_TIER_FULL_ONLY[@]+"${CI_TIER_FULL_ONLY[@]}"}; do + if [ "$t" = "$1" ]; then + DEFERRED="$DEFERRED [$1]" + echo; echo "ci:full ===== $1 DEFERRED (CI_TIER=fast, runs in the full sweep) =====" + return 0 + fi + done + return 1 +} +# <<< ci-tier <<< run_tier() { + ci_tier_deferred "$1" && return 0 # ci-tier guard (generated) local name="$1"; shift echo; echo "ci:full ===== $name =====" if "$@"; then @@ -106,8 +134,20 @@ run_tier "identity pin (vendored lint trio)" identity_pin_check run_tier "coverage ratchet (coverage:check)" npm run coverage:check echo +# >>> ci-tier summary (generated) >>> +echo "ci:full: tier class ${CI_TIER:-full}" +if [ -n "${DEFERRED:-}" ]; then + echo "ci:full: DEFERRED to the full sweep:$DEFERRED" +fi +# <<< ci-tier summary <<< if [ -n "$FAILED" ]; then echo "ci:full: RED tiers:$FAILED" exit 1 fi -echo "ci:full: all tiers green (same set GitHub CI runs)" +# >>> ci-tier verdict (generated) >>> +if [ "${CI_TIER:-full}" = "fast" ]; then + echo "ci:full: all FAST tiers green; the DEFERRED tiers above were NOT graded here" +else + echo "ci:full: all tiers green (same set GitHub CI runs)" +fi +# <<< ci-tier verdict <<< diff --git a/package-lock.json b/package-lock.json index 159fcbd..38ce2f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "xchain-vm", - "version": "0.20.0", + "version": "0.20.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "xchain-vm", - "version": "0.20.0", + "version": "0.20.1", "license": "AGPL-3.0-or-later", "dependencies": { "acorn": "8.16.0", diff --git a/package.json b/package.json index 4eaf011..37fe2ee 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xchain-vm", "description": "XChain Platform Virtual Machine (VM) runtime - deterministic smart contract execution in sandboxed V8 isolates", - "version": "0.20.0", + "version": "0.20.1", "ciPlatform": "linux", "license": "AGPL-3.0-or-later", "main": "src/index.js", diff --git a/src/gateway.d.ts b/src/gateway.d.ts index 75b5cb7..9aa3800 100644 --- a/src/gateway.d.ts +++ b/src/gateway.d.ts @@ -113,6 +113,12 @@ export interface PollResult { total_voters: number; decided_early: boolean; options: Array<{ index: number; weight: string; voters: number }>; + /** + * The poll's electorate token tick (null for a poll bound to no token). + * Present only once the host enables VOTE_POLL_TICK_VISIBLE; absent below it. + * Electorate-pinning contracts (treasury.executeProposal) read it. + */ + tick?: string | null; } /** Terminal outcome of a cross-chain call this chain originated. */ diff --git a/src/index.js b/src/index.js index 8a2bc5d..e4b1ba3 100644 --- a/src/index.js +++ b/src/index.js @@ -1716,7 +1716,7 @@ class XChainVM { // Map. // meterCode() is a pure AST transform (acorn parse + walk + astring regen over // up to maxCodeSize bytes), the single most expensive step of a warm execute, - // and its output depends ONLY on the contract source plus the two consensus + // and its output depends ONLY on the contract source plus the consensus // gate flags (specEvalOrder, meterCallSpread, meterRestPattern), all baked into // the key. So a // hit returns byte-identical metered source to a fresh call: no consensus @@ -1731,7 +1731,7 @@ class XChainVM { this.limits.maxMeteredCacheSize = this.limits.maxBlockCacheSize || 1000; // Execute-time lint-verdict cache: - // Map. + // Map, {valid, error?}>. // INVARIANT: the key carries the code digest plus EVERY consensus flag the // verdict depends on, with no count written down here that a new flag can // falsify. Adding a flag to getLintVerdict without adding its bit lets a warm @@ -2614,7 +2614,8 @@ module.exports.isSlashTokenDelimGuardActive = isSlashTokenDelimGuardActive; // exported so a test can pin it against the indexer's MAX_TOKEN_DECIMALS. module.exports.isSlashAmountPrecisionActive = isSlashAmountPrecisionActive; module.exports.MAX_SLASH_AMOUNT_DECIMALS = MAX_SLASH_AMOUNT_DECIMALS; -// Cross-CHAIN call (XCALL) protocol constants, same canonical source. +// Export the cross-chain call protocol limits so validator hosts can pin the +// VM's bounds against the values they revalidate while processing emissions. module.exports.XCALL_MIN_GAS = XCALL_MIN_GAS; module.exports.XCALL_MAX_GAS = XCALL_MAX_GAS; module.exports.XCALL_MAX_HOPS = XCALL_MAX_HOPS; diff --git a/src/index/gateway_injection.js b/src/index/gateway_injection.js index c47ceef..44677c7 100644 --- a/src/index/gateway_injection.js +++ b/src/index/gateway_injection.js @@ -23,6 +23,120 @@ const ivm = require('isolated-vm'); const { ContractRevertError, GasExhaustedError } = require('../errors.js'); +function injectAccessors(g, gateway, bridge) { + // Context accessors (0 gas) + g.setSync('__getBlockHeight', bridge(gateway.getBlockHeight)); + g.setSync('__getBlockTimestamp', bridge(gateway.getBlockTimestamp)); + g.setSync('__getBlockHash', bridge(gateway.getBlockHash)); + g.setSync('__getSourceAddress', bridge(gateway.getSourceAddress)); + g.setSync('__getContractAddress', bridge(gateway.getContractAddress)); + g.setSync('__getInputParams', bridge(gateway.getInputParams)); + g.setSync('__getInputParam', bridge(gateway.getInputParam)); + g.setSync('__getInputParamCount', bridge(gateway.getInputParamCount)); + g.setSync('__getCallDepth', bridge(gateway.getCallDepth)); + g.setSync('__getCrossHops', bridge(gateway.getCrossHops)); +} + +function injectLedger(g, gateway, bridge) { + // Ledger queries (metered) + g.setSync('__getBalance', bridge(gateway.getBalance)); + g.setSync('__getTokenInfo', bridge(gateway.getTokenInfo)); + g.setSync('__getPollResult', bridge(gateway.getPollResult)); +} + +function injectState(g, gateway, bridge) { + // State (metered) + g.setSync('__state_get', bridge(gateway.state.get)); + g.setSync('__state_has', bridge(gateway.state.has)); + g.setSync('__state_set', bridge(gateway.state.set)); + g.setSync('__state_delete', bridge(gateway.state.delete)); +} + +function injectOracle(g, gateway, bridge) { + // Oracle (metered) + g.setSync('__oracle_getPrice', bridge(gateway.oracle.getPrice)); + g.setSync('__oracle_getPriceAtRound', bridge(gateway.oracle.getPriceAtRound)); + g.setSync('__oracle_getSnapshotAge', bridge(gateway.oracle.getSnapshotAge)); +} + +function injectCrossChain(g, gateway, bridge) { + // Cross-chain (metered) + g.setSync('__crossChain_getAttestation', bridge(gateway.crossChain.getAttestation)); + g.setSync('__crossChain_isSettled', bridge(gateway.crossChain.isSettled)); + g.setSync('__crossChain_getCallResult', bridge(gateway.crossChain.getCallResult)); + + g.setSync('__attestation_request', bridge(gateway.attestation.request)); + g.setSync('__attestation_getResponse', bridge(gateway.attestation.getResponse)); +} + +function injectStaking(g, gateway, bridge) { + // Contract-targeted staking (metered) + g.setSync('__contract_getStake', bridge(gateway.contract.getStake)); + g.setSync('__contract_getTotalStaked', bridge(gateway.contract.getTotalStaked)); + g.setSync('__contract_getStakers', bridge(gateway.contract.getStakers)); + g.setSync('__contract_slash', bridge(gateway.contract.slash)); +} + +function injectEmit(g, gateway, bridge) { + // Emit (metered) + g.setSync('__emit_send', bridge(gateway.emit.send)); + g.setSync('__emit_destroy', bridge(gateway.emit.destroy)); + g.setSync('__emit_issue', bridge(gateway.emit.issue)); + g.setSync('__emit_mint', bridge(gateway.emit.mint)); + g.setSync('__emit_order', bridge(gateway.emit.order)); + g.setSync('__emit_dispenser', bridge(gateway.emit.dispenser)); + g.setSync('__emit_dividend', bridge(gateway.emit.dividend)); + g.setSync('__emit_airdrop', bridge(gateway.emit.airdrop)); + g.setSync('__emit_callback', bridge(gateway.emit.callback)); + g.setSync('__emit_file', bridge(gateway.emit.file)); + g.setSync('__emit_list', bridge(gateway.emit.list)); + g.setSync('__emit_coinpay', bridge(gateway.emit.coinpay)); + g.setSync('__emit_sweep', bridge(gateway.emit.sweep)); + g.setSync('__emit_link', bridge(gateway.emit.link)); + g.setSync('__emit_broadcast', bridge(gateway.emit.broadcast)); + g.setSync('__emit_message', bridge(gateway.emit.message)); + g.setSync('__emit_vote', bridge(gateway.emit.vote)); + g.setSync('__emit_execute', bridge(gateway.emit.execute)); + g.setSync('__emit_crossExecute', bridge(gateway.emit.crossExecute)); +} + +function injectMath(g, gateway, bridge) { + // Math + g.setSync('__math_add', bridge(gateway.math.add)); + g.setSync('__math_subtract', bridge(gateway.math.subtract)); + g.setSync('__math_multiply', bridge(gateway.math.multiply)); + g.setSync('__math_divide', bridge(gateway.math.divide)); + g.setSync('__math_mod', bridge(gateway.math.mod)); + g.setSync('__math_compare', bridge(gateway.math.compare)); + g.setSync('__math_gt', bridge(gateway.math.gt)); + g.setSync('__math_gte', bridge(gateway.math.gte)); + g.setSync('__math_lt', bridge(gateway.math.lt)); + g.setSync('__math_lte', bridge(gateway.math.lte)); + g.setSync('__math_eq', bridge(gateway.math.eq)); + g.setSync('__math_min', bridge(gateway.math.min)); + g.setSync('__math_max', bridge(gateway.math.max)); + g.setSync('__math_abs', bridge(gateway.math.abs)); + g.setSync('__math_isZero', bridge(gateway.math.isZero)); + g.setSync('__math_sqrt', bridge(gateway.math.sqrt)); + g.setSync('__math_pow', bridge(gateway.math.pow)); + g.setSync('__math_log', bridge(gateway.math.log)); + g.setSync('__math_log2', bridge(gateway.math.log2)); + g.setSync('__math_log10', bridge(gateway.math.log10)); +} + +function injectControlFlow(g, gateway, bridge) { + // Control flow (gas-free) + g.setSync('__revert', bridge(gateway.revert)); + g.setSync('__require', bridge(gateway.require)); +} + +function injectLogging(g, gateway, bridge) { + // Logging (gas-free) + g.setSync('__log', bridge(gateway.log)); + g.setSync('__isLogFull', bridge(gateway.isLogFull)); + g.setSync('__getLogCount', bridge(gateway.getLogCount)); +} + module.exports = { /** * Inject gateway methods into the isolate context as ivm.Reference objects. @@ -53,98 +167,15 @@ module.exports = { } }); - // Context accessors (0 gas) - g.setSync('__getBlockHeight', bridge(gateway.getBlockHeight)); - g.setSync('__getBlockTimestamp', bridge(gateway.getBlockTimestamp)); - g.setSync('__getBlockHash', bridge(gateway.getBlockHash)); - g.setSync('__getSourceAddress', bridge(gateway.getSourceAddress)); - g.setSync('__getContractAddress', bridge(gateway.getContractAddress)); - g.setSync('__getInputParams', bridge(gateway.getInputParams)); - g.setSync('__getInputParam', bridge(gateway.getInputParam)); - g.setSync('__getInputParamCount', bridge(gateway.getInputParamCount)); - g.setSync('__getCallDepth', bridge(gateway.getCallDepth)); - g.setSync('__getCrossHops', bridge(gateway.getCrossHops)); - - // Ledger queries (metered) - g.setSync('__getBalance', bridge(gateway.getBalance)); - g.setSync('__getTokenInfo', bridge(gateway.getTokenInfo)); - g.setSync('__getPollResult', bridge(gateway.getPollResult)); - - // State (metered) - g.setSync('__state_get', bridge(gateway.state.get)); - g.setSync('__state_has', bridge(gateway.state.has)); - g.setSync('__state_set', bridge(gateway.state.set)); - g.setSync('__state_delete', bridge(gateway.state.delete)); - - // Oracle (metered) - g.setSync('__oracle_getPrice', bridge(gateway.oracle.getPrice)); - g.setSync('__oracle_getPriceAtRound', bridge(gateway.oracle.getPriceAtRound)); - g.setSync('__oracle_getSnapshotAge', bridge(gateway.oracle.getSnapshotAge)); - - // Cross-chain (metered) - g.setSync('__crossChain_getAttestation', bridge(gateway.crossChain.getAttestation)); - g.setSync('__crossChain_isSettled', bridge(gateway.crossChain.isSettled)); - g.setSync('__crossChain_getCallResult', bridge(gateway.crossChain.getCallResult)); - - g.setSync('__attestation_request', bridge(gateway.attestation.request)); - g.setSync('__attestation_getResponse', bridge(gateway.attestation.getResponse)); - - // Contract-targeted staking (metered) - g.setSync('__contract_getStake', bridge(gateway.contract.getStake)); - g.setSync('__contract_getTotalStaked', bridge(gateway.contract.getTotalStaked)); - g.setSync('__contract_getStakers', bridge(gateway.contract.getStakers)); - g.setSync('__contract_slash', bridge(gateway.contract.slash)); - - // Emit (metered) - g.setSync('__emit_send', bridge(gateway.emit.send)); - g.setSync('__emit_destroy', bridge(gateway.emit.destroy)); - g.setSync('__emit_issue', bridge(gateway.emit.issue)); - g.setSync('__emit_mint', bridge(gateway.emit.mint)); - g.setSync('__emit_order', bridge(gateway.emit.order)); - g.setSync('__emit_dispenser', bridge(gateway.emit.dispenser)); - g.setSync('__emit_dividend', bridge(gateway.emit.dividend)); - g.setSync('__emit_airdrop', bridge(gateway.emit.airdrop)); - g.setSync('__emit_callback', bridge(gateway.emit.callback)); - g.setSync('__emit_file', bridge(gateway.emit.file)); - g.setSync('__emit_list', bridge(gateway.emit.list)); - g.setSync('__emit_coinpay', bridge(gateway.emit.coinpay)); - g.setSync('__emit_sweep', bridge(gateway.emit.sweep)); - g.setSync('__emit_link', bridge(gateway.emit.link)); - g.setSync('__emit_broadcast', bridge(gateway.emit.broadcast)); - g.setSync('__emit_message', bridge(gateway.emit.message)); - g.setSync('__emit_vote', bridge(gateway.emit.vote)); - g.setSync('__emit_execute', bridge(gateway.emit.execute)); - g.setSync('__emit_crossExecute', bridge(gateway.emit.crossExecute)); - - // Math - g.setSync('__math_add', bridge(gateway.math.add)); - g.setSync('__math_subtract', bridge(gateway.math.subtract)); - g.setSync('__math_multiply', bridge(gateway.math.multiply)); - g.setSync('__math_divide', bridge(gateway.math.divide)); - g.setSync('__math_mod', bridge(gateway.math.mod)); - g.setSync('__math_compare', bridge(gateway.math.compare)); - g.setSync('__math_gt', bridge(gateway.math.gt)); - g.setSync('__math_gte', bridge(gateway.math.gte)); - g.setSync('__math_lt', bridge(gateway.math.lt)); - g.setSync('__math_lte', bridge(gateway.math.lte)); - g.setSync('__math_eq', bridge(gateway.math.eq)); - g.setSync('__math_min', bridge(gateway.math.min)); - g.setSync('__math_max', bridge(gateway.math.max)); - g.setSync('__math_abs', bridge(gateway.math.abs)); - g.setSync('__math_isZero', bridge(gateway.math.isZero)); - g.setSync('__math_sqrt', bridge(gateway.math.sqrt)); - g.setSync('__math_pow', bridge(gateway.math.pow)); - g.setSync('__math_log', bridge(gateway.math.log)); - g.setSync('__math_log2', bridge(gateway.math.log2)); - g.setSync('__math_log10', bridge(gateway.math.log10)); - - // Control flow (gas-free) - g.setSync('__revert', bridge(gateway.revert)); - g.setSync('__require', bridge(gateway.require)); - - // Logging (gas-free) - g.setSync('__log', bridge(gateway.log)); - g.setSync('__isLogFull', bridge(gateway.isLogFull)); - g.setSync('__getLogCount', bridge(gateway.getLogCount)); + injectAccessors(g, gateway, bridge); + injectLedger(g, gateway, bridge); + injectState(g, gateway, bridge); + injectOracle(g, gateway, bridge); + injectCrossChain(g, gateway, bridge); + injectStaking(g, gateway, bridge); + injectEmit(g, gateway, bridge); + injectMath(g, gateway, bridge); + injectControlFlow(g, gateway, bridge); + injectLogging(g, gateway, bridge); }, }; diff --git a/src/index/lint_and_metering.js b/src/index/lint_and_metering.js index 311bcdf..3a51471 100644 --- a/src/index/lint_and_metering.js +++ b/src/index/lint_and_metering.js @@ -81,11 +81,10 @@ module.exports = { * Return the execute-time consensus source-lint verdict for `code` under the * resolved ban flags, from the verdict cache when possible. * - * validateSyntax() is a pure function of (code, enforceBannedAsync, - * enforceLintHardening, enforceBannedGenerator === enforceBannedWasm, - * enforceLintGlobalAlias), all folded into the key, so a hit returns the verdict a - * fresh call would produce. The two Pkg 3 rules share ONE activation and are - * therefore threaded as one bit; the global-alias refinement rides its own. + * validateSyntax() is a pure function of the code plus every flag parameter of this + * method, all folded into the key, so a hit returns the verdict a fresh call would + * produce. The two Pkg 3 rules share ONE activation and are therefore threaded as + * one bit; the global-alias and banned-rest refinements each ride their own. * * This exists because validateSyntax spawns an ivm.Isolate for its V8 syntax check * and then acorn-parses the source; paying that on every execute of a hot contract diff --git a/src/readonly_accessors.js b/src/readonly_accessors.js index 6a1b06e..3cf059d 100644 --- a/src/readonly_accessors.js +++ b/src/readonly_accessors.js @@ -145,7 +145,10 @@ function buildPollAccessor(snap) { // Frozen result of a finalized VOTE poll, or null if the poll does not // exist or has not finalized yet. Keys are poll indices (the VOTE v0 // action_index). Shape: { status, winning_option, total_weight, - // total_voters, decided_early, options:[{index,weight,voters}] }. + // total_voters, decided_early, options:[{index,weight,voters}], tick? }, + // where `tick` is present only once VOTE_POLL_TICK_VISIBLE is enabled. + // Return the host entry verbatim: never project or whitelist its fields + // (dropping a flag-gated field makes an electorate-pinned contract revert). getPollResult: (pollIndex) => { const p = polls[String(pollIndex)]; return p != null ? p : null; diff --git a/src/toolkit/authoring/authoring_knowledge.js b/src/toolkit/authoring/authoring_knowledge.js index c50da3c..dd2c0c9 100644 --- a/src/toolkit/authoring/authoring_knowledge.js +++ b/src/toolkit/authoring/authoring_knowledge.js @@ -22,7 +22,7 @@ const MODEL_SHIFTS = [ const NATIVE_PRIMITIVES = [ { want: 'A fungible token', solidity: 'write an ERC-20', xchain: 'ISSUE action (tokens are first-class); no contract' }, { want: 'Transfer a token', solidity: 'transfer()', xchain: 'SEND action' }, - { want: 'Check a balance', solidity: 'balanceOf()', xchain: 'explorer/SDK query, or in-contract getBalance(addr, tick)' }, + { want: 'Check a balance', solidity: 'balanceOf()', xchain: 'explorer/SDK query, or in-contract getBalance(addr, tick) for the caller or the contract itself only (any other address reads null)' }, { want: 'An NFT', solidity: 'ERC-721', xchain: 'ISSUE with DECIMALS=0 + LOCK_MAX_SUPPLY=1' }, { want: 'Token sale', solidity: 'crowdsale contract', xchain: 'DISPENSER action, or the crowdsale template' }, { want: 'Swap two tokens', solidity: 'a DEX pair', xchain: 'ORDER / SWAP actions, or the amm template' }, diff --git a/src/toolkit/gate/meta_validation.js b/src/toolkit/gate/meta_validation.js index fc34c8f..6985f3d 100644 --- a/src/toolkit/gate/meta_validation.js +++ b/src/toolkit/gate/meta_validation.js @@ -14,6 +14,7 @@ const acorn = require('acorn'); const walk = require('acorn-walk'); +const { CONTRACT_ECMA_VERSION } = require('../../metering.js'); // --- Contract identity (`meta`) ------------------------------------------ // @@ -34,9 +35,14 @@ const walk = require('acorn-walk'); // contract the chain would accept on a shape it merely cannot see. // Frozen consensus tokens, copied verbatim from spec 2.3 so the author reads the -// same string the chain will write into the action's status. +// same string the chain will write into the action's status. READ_FAILED, +// NOT_OBJECT and OVERSIZE need the evaluated read, so only the simulator's deploy +// gate reaches them; the static walk here never does. const META_VERDICTS = { + READ_FAILED: 'invalid: CONTRACT_MANIFEST (manifest read failed)', REQUIRED: 'invalid: CONTRACT_MANIFEST (meta required)', + NOT_OBJECT: 'invalid: CONTRACT_MANIFEST (meta must be a plain object)', + OVERSIZE: 'invalid: CONTRACT_MANIFEST (meta exceeds 4096 characters)', NAME: 'invalid: CONTRACT_MANIFEST (meta.name must be a string of 1..64 bytes, printable, trimmed)', DESCRIPTION: 'invalid: CONTRACT_MANIFEST (meta.description must be a string of 1..512 bytes, printable, trimmed)', VERSION: 'invalid: CONTRACT_MANIFEST (meta.version must be a string of 1..32 bytes, printable, trimmed)' @@ -195,7 +201,7 @@ function getExportedMeta(source) { try { // acorn is a hard dependency of lint_core, already required above, so an // unparseable source is the only way to land here. - ast = acorn.parse(String(source), { ecmaVersion: 2020, sourceType: 'script', locations: true }); + ast = acorn.parse(String(source), { ecmaVersion: CONTRACT_ECMA_VERSION, sourceType: 'script', locations: true }); } catch (e) { return { status: 'undecidable' }; } diff --git a/src/toolkit/simulator.js b/src/toolkit/simulator.js index a005d1f..e7b6f5a 100644 --- a/src/toolkit/simulator.js +++ b/src/toolkit/simulator.js @@ -50,7 +50,10 @@ * What it does NOT do (out of scope; that is the real indexer + regtest): * - process emitted ACTIONs against the ledger (SEND/ISSUE/... are captured * for assertions, never applied to balances). getBalance reads only what - * the author seeds. + * the author seeds. A node also SCOPES that snapshot: it preloads balances + * for the action's SOURCE and the contract's own address only, so a seed + * for any third address reads back here and is null on chain. call() warns + * once when such a seed is present (see warnIfBalanceOutOfScope). * - resolve emit.execute / emit.crossExecute call trees. Those emissions are * captured; the callee is not auto-run. * - ASSIGN the identity discriminators a real node assigns. txHash, @@ -59,10 +62,16 @@ * matches chain only when the caller supplies the real values. * - populate the read-only snapshots. They start empty and read back * null / '0' / [] until seeded, which is the same answer a node gives for - * data that genuinely does not exist; a stale seed is the author's. + * data that genuinely does not exist; a stale seed is the author's. The + * oracle snapshot hides no history by default (roundFloor 0), while a node + * ships a bounded window; setOracleRoundFloor(n) simulates that window, so + * getPriceAtRound on an unseeded round below n returns the chain's + * { price: null, outsideWindow: true } row instead of plain null. * - REFUSE a deploy the chain's deploy gate would reject. deploy() runs that - * gate (code size + validateSyntax, resolved at the configured network / - * coin / block) and hands the verdict back as `deployGate`, warning once on a + * gate's three legs (code size, validateSyntax, then the manifest read with + * its permissions / maxTakeBps rows and the CONTRACT_META_REQUIRED meta + * ladder, all resolved at the configured network / coin / block) and hands + * the verdict back as `deployGate`, warning once on a * reject, but it still registers the contract: simulating a source the chain * would not accept is a legitimate move, and this repo's own fixtures do it to * measure the runtime strips. A `deployGate.valid === false` means the later @@ -197,6 +206,8 @@ function initializeBlockContext(opts) { this._preHeightGateWarned = false; // Likewise for the deploy-gate rejection warning (see warnDeployGate). this._deployGateWarned = false; + // Likewise for the balance-scope warning (see warnIfBalanceOutOfScope). + this._balanceScopeWarned = false; } class ContractSimulator { @@ -254,7 +265,8 @@ class ContractSimulator { // Read-only snapshots the author seeds. this.balances = {}; // address -> tick -> amountStr this.tokenInfo = {}; // tick -> info object - this.oracle = { snapshotAge: 0, prices: {}, rounds: {} }; + // roundFloor: oldest round the snapshot guarantees; 0 means no history is hidden. + this.oracle = { snapshotAge: 0, prices: {}, rounds: {}, roundFloor: 0 }; this.crossChainData = { attestations: {}, settled: {}, calls: {} }; // The remaining read-only snapshots the gateway reads. Shapes are the // ones src/readonly_accessors.js documents; an empty snapshot is diff --git a/src/toolkit/simulator/constants.js b/src/toolkit/simulator/constants.js index f662d3b..1f00281 100644 --- a/src/toolkit/simulator/constants.js +++ b/src/toolkit/simulator/constants.js @@ -96,11 +96,24 @@ const GUARD_PARAM_ORDER = Object.freeze([ 'actionType', 'from', 'to', 'tick', 'amount', 'price', 'proceedsTick' ]); +// Activation instants of CONTRACT_META_REQUIRED, the indexer's deploy rule that a +// contract export a conforming `meta`. The rule is an indexer registry row with no +// VM twin, so these are a deliberate second home for its flag times +// (xchain-indexer/src/protocol_changes/flag_times.js), compared against that file by +// test/determinism/simulator_manifest_gate_cross_repo.test.js. Mainnet and regtest are +// genesis-active; any network not named here resolves like mainnet. +const CONTRACT_META_REQUIRED_TIMES = Object.freeze({ + mainnet: 0, + testnet: 1789257600, + regtest: 0 +}); + module.exports = { GATE_BLOCK_TIMES, GATE_FALLBACK_BLOCK_TIME, HEIGHT_GATES, GENESIS_ACTIVE_NETWORKS, + CONTRACT_META_REQUIRED_TIMES, GUARD_GAS_CEILING, GUARD_METHOD, GUARD_PARAM_ORDER diff --git a/src/toolkit/simulator/execution.js b/src/toolkit/simulator/execution.js index 94dee20..ef3e758 100644 --- a/src/toolkit/simulator/execution.js +++ b/src/toolkit/simulator/execution.js @@ -53,17 +53,20 @@ module.exports = { * @param {string[]} [opts.constructorParams] - if present, runs `initialize` * @param {string} [opts.caller] * @returns {Promise<{contractIndex, contractAddress, initResult, deployGate}>} - * deployGate is the chain's deploy verdict, `{ valid: true }` or - * `{ valid: false, error }`. Advisory: a reject warns once and the - * contract is still registered (see deployGateVerdict). + * deployGate is the chain's deploy verdict over all three legs (size + * cap, validateSyntax, manifest + meta), `{ valid: true }` or + * `{ valid: false, error }`, or `valid: null` when a leg could not run + * on this host. Advisory: a reject warns once and the contract is + * still registered (see deployGateVerdict, manifestGateVerdict). */ async deploy(code, opts = {}) { const src = toContractJs(code, opts.filename || ''); - const deployGate = this.deployGateVerdict(src); - if (deployGate.valid !== true) this.warnDeployGate(deployGate); const index = (opts.contractIndex != null) ? Number(opts.contractIndex) : this._nextIndex; if (index >= this._nextIndex) this._nextIndex = index + 1; const address = opts.contractAddress || ('C:' + this.coin + ':' + index); + let deployGate = this.deployGateVerdict(src); + if (deployGate.valid === true) deployGate = await this.manifestGateVerdict(src, address); + if (deployGate.valid !== true) this.warnDeployGate(deployGate); this.contracts.set(index, { code: src, @@ -144,15 +147,17 @@ module.exports = { if (!contract) { throw new Error('no contract deployed at index ' + contractIndex); } + const caller = opts.caller || this.defaultCaller; this.warnIfPreGate(); this.warnIfPreHeightGate(contract.address); + this.warnIfBalanceOutOfScope(caller, contract.address); const execOpts = { code: contract.code, state: contract.state, method: method || 'default', params: Array.isArray(params) ? params : [], - caller: opts.caller || this.defaultCaller, + caller, contractAddress: contract.address, contractIndex: Number(contractIndex), network: this.network, diff --git a/src/toolkit/simulator/gate_warnings.js b/src/toolkit/simulator/gate_warnings.js index 7eae04a..3437264 100644 --- a/src/toolkit/simulator/gate_warnings.js +++ b/src/toolkit/simulator/gate_warnings.js @@ -16,6 +16,11 @@ const XChainVM = require('../../index.js'); const { HEIGHT_GATES, GENESIS_ACTIVE_NETWORKS } = require('./constants.js'); const { heightGateThreshold, defaultBlockHeight } = require('./block_time_gates.js'); +const { + isContractMetaRequiredActive, + manifestPolicyError, + contractMetaError +} = require('./manifest_gate.js'); module.exports = { // ---- block control ------------------------------------------------------ @@ -95,8 +100,32 @@ module.exports = { }, /** - * Run the chain's DEPLOY gate over already-transpiled source and return its - * verdict as `{ valid, error }`. ADVISORY: deploy() reports it and warns once, + * Warn once per simulator when a seeded balance sits outside the two addresses + * a node's snapshot carries. xchain-indexer builds the getBalance snapshot for + * the action's SOURCE and the contract's own address only (run_vm.js, and the + * constructor and controller-guard paths alike), so any other seeded address + * reads a value here and null on chain. Advisory: a suite that seeds several + * callers is legitimate, and the seed is left in place. + */ + warnIfBalanceOutOfScope(caller, contractAddress) { + if (this._balanceScopeWarned) return; + const outside = Object.keys(this.balances) + .filter((a) => a !== caller && a !== contractAddress); + if (!outside.length) return; + this._balanceScopeWarned = true; + console.warn( + '[xchain-vm simulator] balances are seeded for ' + JSON.stringify(outside) + + ', outside this call\'s snapshot scope (caller ' + JSON.stringify(caller) + + ', contract ' + JSON.stringify(contractAddress) + '). A node preloads only ' + + 'those two addresses, so a contract reading getBalance for any other one ' + + 'gets null on chain whatever it reads here.' + ); + }, + + /** + * Run the first two legs of the chain's DEPLOY gate (size cap, validateSyntax) + * over already-transpiled source and return their verdict as `{ valid, error }`; + * manifestGateVerdict is the third leg. ADVISORY: deploy() reports it and warns once, * it never refuses, because simulating a source the chain would reject is a * legitimate move (this repo's own fixtures deploy a WebAssembly probe to * measure the runtime strip) and a public toolkit API that started throwing @@ -143,6 +172,36 @@ module.exports = { } }, + /** + * Run the third leg of the chain's DEPLOY gate: read the contract's manifest the + * way xchain-indexer/src/actions/deploy/manifest.js does (same call, same block + * context, the real contract address the sandbox gate derives its coin from), + * then judge the policy rows and, once CONTRACT_META_REQUIRED is armed at this + * block, the meta ladder. A module top level that throws is a real reject (the + * chain records `manifest read failed`), so only a read that throws, a host + * fault rather than a verdict, returns the null "gate did not run" verdict. + * @param {string} src - transpiled source that already passed the first two legs + * @param {string} contractAddress + * @returns {Promise<{valid: (boolean|null), error?: string}>} + */ + async manifestGateVerdict(src, contractAddress) { + let read; + try { + read = await this.vm.readManifest(src, { + network: this.network, + contractAddress, + blockContext: { height: this.block.height, timestamp: this.block.timestamp } + }); + } catch (e) { + return { valid: null, error: 'deploy gate could not run on this host: ' + e.message }; + } + const policy = manifestPolicyError(read); + if (policy) return { valid: false, error: policy }; + if (!isContractMetaRequiredActive(this.network, this.block.timestamp)) return { valid: true }; + const meta = contractMetaError(read); + return meta ? { valid: false, error: meta } : { valid: true }; + }, + /** * Warn once per simulator when the deploy gate rejects a source. Fires only on * a REJECT, so a clean contract keeps the simulator silent, and once per @@ -158,9 +217,12 @@ module.exports = { 'the acorn half of the same gate without an isolate.'); return; } + // A manifest-leg error is already the status the chain writes; a lint-leg one is wrapped. + const status = /^invalid: /.test(String(verdict.error)) + ? verdict.error : 'invalid: CODE_ENCODING (...)'; console.warn( '[xchain-vm simulator] the DEPLOY gate rejects this contract: ' + verdict.error + - '. On chain xchain-indexer records `invalid: CODE_ENCODING (...)` and the contract ' + + '. On chain xchain-indexer records `' + status + '` and the contract ' + 'never exists, so any call() result below is simulation-only. The verdict rides ' + 'back on deploy() as `deployGate`; `xchain-foundry lint` reports it without an isolate.' ); diff --git a/src/toolkit/simulator/manifest_gate.js b/src/toolkit/simulator/manifest_gate.js new file mode 100644 index 0000000..f6e46d8 --- /dev/null +++ b/src/toolkit/simulator/manifest_gate.js @@ -0,0 +1,114 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * The third leg of the chain's DEPLOY gate, judged over a vm.readManifest() + * result: the permissions / maxTakeBps policy rows and the + * CONTRACT_META_REQUIRED verdict ladder, in the order the indexer walks them + * (xchain-indexer/src/actions/deploy/manifest.js and contract_meta.js). The + * verdict strings are consensus tokens the chain writes into the action's + * status, so each one here is the indexer's byte for byte; + * test/determinism/simulator_manifest_gate_cross_repo.test.js compares them. + ********************************************************************/ +// @ts-nocheck + +const { + META_VERDICTS, + META_NAME_MAX_BYTES, + META_DESCRIPTION_MAX_BYTES, + META_VERSION_MAX_BYTES, + isValidMetaText +} = require('../gate/meta_validation.js'); +const { CONTRACT_META_REQUIRED_TIMES } = require('./constants.js'); + +// Policy-row rejections, written verbatim into the DEPLOY status by applyManifestPolicy. +const MANIFEST_POLICY_VERDICTS = Object.freeze({ + PERMISSIONS_ARRAY: 'invalid: CONTRACT_MANIFEST (permissions must be an array)', + PERMISSIONS_STRINGS: 'invalid: CONTRACT_MANIFEST (permissions must be action-type strings)', + MAX_TAKE_BPS: 'invalid: CONTRACT_MANIFEST (maxTakeBps must be an integer in [0, 10000])' +}); + +/** + * Whether CONTRACT_META_REQUIRED is armed at this block time. The indexer reads + * median-time-past off mainnet; the simulator has only the block's own stamp. + * @param {string} network + * @param {number} blockTime - seconds + * @returns {boolean} + */ +function isContractMetaRequiredActive(network, blockTime) { + const known = Object.prototype.hasOwnProperty.call(CONTRACT_META_REQUIRED_TIMES, network); + const at = CONTRACT_META_REQUIRED_TIMES[known ? network : 'mainnet']; + return Number(blockTime) >= at; +} + +/** + * The permissions and maxTakeBps rows. Ungated on chain, and judged only over a + * read that succeeded, exactly as applyManifestPolicy is. + * @param {?object} read - vm.readManifest() result + * @returns {?string} the chain's status string, or null when both rows pass + */ +function manifestPolicyError(read) { + if (!read || !read.success || !read.manifest) return null; + const m = read.manifest; + if (m.permissionsType !== 'undefined') { + if (m.permissionsType !== 'array' || !Array.isArray(m.permissions)) + return MANIFEST_POLICY_VERDICTS.PERMISSIONS_ARRAY; + if (!m.permissions.every((p) => typeof p === 'string')) + return MANIFEST_POLICY_VERDICTS.PERMISSIONS_STRINGS; + } + if (m.maxTakeBpsType !== 'undefined') { + const mtb = m.maxTakeBps; + if (m.maxTakeBpsType !== 'number' || !Number.isInteger(mtb) || mtb < 0 || mtb > 10000) + return MANIFEST_POLICY_VERDICTS.MAX_TAKE_BPS; + } + return null; +} + +/** + * Rows 1 to 4 of the meta ladder, then the host's own parse of the reported + * JSON; first failure wins, as in evaluateContractMeta. + * @param {?object} read - vm.readManifest() result + * @returns {?string} the chain's status string, or null for a conforming meta + */ +function contractMetaError(read) { + if (!read || read.success !== true || !read.manifest) return META_VERDICTS.READ_FAILED; + const m = read.manifest; + const metaType = (m.metaType === undefined || m.metaType === null) ? 'undefined' : m.metaType; + if (metaType === 'undefined') return META_VERDICTS.REQUIRED; + if (metaType !== 'object' || m.metaError === true) return META_VERDICTS.NOT_OBJECT; + if (m.metaOversize === true) return META_VERDICTS.OVERSIZE; + let parsed = null; + if (typeof m.metaJson === 'string') { + try { parsed = JSON.parse(m.metaJson); } catch (e) { parsed = null; } + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) + return META_VERDICTS.NOT_OBJECT; + return metaFieldsError(parsed); +} + +// Rows 5 to 7: name, description, then version when the key is present at all. +function metaFieldsError(parsed) { + if (!isValidMetaText(parsed.name, META_NAME_MAX_BYTES, false)) return META_VERDICTS.NAME; + if (!isValidMetaText(parsed.description, META_DESCRIPTION_MAX_BYTES, true)) + return META_VERDICTS.DESCRIPTION; + if (Object.prototype.hasOwnProperty.call(parsed, 'version') && + !isValidMetaText(parsed.version, META_VERSION_MAX_BYTES, false)) + return META_VERDICTS.VERSION; + return null; +} + +module.exports = { + MANIFEST_POLICY_VERDICTS, + isContractMetaRequiredActive, + manifestPolicyError, + contractMetaError +}; diff --git a/src/toolkit/simulator/world_state_setters.js b/src/toolkit/simulator/world_state_setters.js index 4886307..f303249 100644 --- a/src/toolkit/simulator/world_state_setters.js +++ b/src/toolkit/simulator/world_state_setters.js @@ -60,6 +60,18 @@ module.exports = { return this; }, + /** + * Set the oldest oracle round this simulated host guarantees. 0 (the default) + * hides no history. A positive floor is the bounded window a node ships: an + * unseeded round below it reads back from oracle.getPriceAtRound as + * { price: null, outsideWindow: true } rather than as plain null. + */ + setOracleRoundFloor(roundFloor) { + const n = Number(roundFloor); + this.oracle.roundFloor = (Number.isFinite(n) && n > 0) ? n : 0; + return this; + }, + /** * Seed a settled ATTEST response (read by attestation.getResponse in a * callback method). Keys are request_ids; the simulator does not derive @@ -74,7 +86,9 @@ module.exports = { * Seed a finalized VOTE poll result (read by poll.getPollResult). * @param {number|string} pollIndex - the VOTE v0 action_index * @param {object} result - { status, winning_option, total_weight, - * total_voters, decided_early, options:[{index,weight,voters}] } + * total_voters, decided_early, options:[{index,weight,voters}], + * tick? } (tick: the electorate token, present once the host + * enables VOTE_POLL_TICK_VISIBLE) */ setPollResult(pollIndex, result) { this.pollData.polls[String(pollIndex)] = result; diff --git a/src/vm_worker.js b/src/vm_worker.js index fbf77a6..5a30747 100644 --- a/src/vm_worker.js +++ b/src/vm_worker.js @@ -67,80 +67,98 @@ function send(msg) { } } -process.on('message', (msg) => { +function handleInitMessage(msg) { + // Force in-process mode in the child (never recurse into another fork). + vm = new XChainVM(Object.assign({}, msg.config, { execution: 'in-process' })); + send({ type: 'ready' }); +} + +function handleBeginBlockMessage() { + enqueue(() => { if (vm) vm.beginBlock(); }); +} + +function handleEndBlockMessage() { + enqueue(() => { if (vm) { vm.endBlock(); flushCoverage(); } }); +} + +// vm.execute() throwing is a HOST fault, not a contract outcome, and +// the two host-fault shapes need OPPOSITE handling. +// +// A HostFaultError says THIS MACHINE cannot run the contract at all: +// index.js raises it when isolateManager.createIsolate() fails, and +// syntax.js raises it when the execute-time lint isolate cannot be +// spawned (reaching execute() through getLintVerdict, outside its own +// try block). Both are properties of this host's memory/thread budget, +// not of the contract, so every healthy peer commits a normal result +// for the same execution. Dying here handed the parent's crash clamp a +// dispatched entry, which resolved a committed +// 'out_of_resource: execution host terminated' at gasUsed = ceiling -- +// a unilateral fork, and the exact laundering index.js:classifyError +// re-throws HostFaultError to prevent. Report it instead; the parent +// rejects the request so the caller HALTS and retries, which is the +// rule process_executor already enforces for queued and shutdown work. +// Tested with instanceof, never e.code or the message: an error that +// crossed the isolate boundary arrives as a plain host Error built from +// contract-controlled text, and a name/code match would let a contract +// spoof a chain-wide halt (same anti-spoof rule as classifyError). +// +// Every OTHER throw still dies into the parent's deterministic +// host-termination machinery (onExit -> hostTerminatedResult), which +// clamps the request to its caller-funded ceiling identically on every +// validator, exactly as an in-isolate resource failure would. +function handleExecuteMessage(msg) { + enqueue(async () => { + let result; + try { + result = await vm.execute(msg.opts); + } catch (e) { + if (e instanceof HostFaultError) { + send({ + type: 'hostfault', + id: msg.id, + reason: String((e && e.message) || 'executor unavailable').slice(0, 200) + }); + // The isolate never came up, so worker state is intact (execute()'s + // finally disposes nothing when createIsolate threw). Staying alive + // avoids a respawn for what is usually a transient pressure blip. + flushCoverage(); + return; + } + process.exit(1); + return; + } + send({ type: 'result', id: msg.id, result }); + // Persist this execution's coverage before the parent can SIGKILL the + // worker (inert unless a coverage harness set NODE_V8_COVERAGE). + flushCoverage(); + }); +} + +function dispatchMessage(msg) { if (!msg) return; if (msg.type === 'init') { - // Force in-process mode in the child (never recurse into another fork). - vm = new XChainVM(Object.assign({}, msg.config, { execution: 'in-process' })); - send({ type: 'ready' }); + handleInitMessage(msg); return; } if (msg.type === 'beginBlock') { - enqueue(() => { if (vm) vm.beginBlock(); }); + handleBeginBlockMessage(); return; } if (msg.type === 'endBlock') { - enqueue(() => { if (vm) { vm.endBlock(); flushCoverage(); } }); + handleEndBlockMessage(); return; } if (msg.type === 'execute') { - enqueue(async () => { - let result; - try { - result = await vm.execute(msg.opts); - } catch (e) { - // vm.execute() throwing is a HOST fault, not a contract outcome, and - // the two host-fault shapes need OPPOSITE handling. - // - // A HostFaultError says THIS MACHINE cannot run the contract at all: - // index.js raises it when isolateManager.createIsolate() fails, and - // syntax.js raises it when the execute-time lint isolate cannot be - // spawned (reaching execute() through getLintVerdict, outside its own - // try block). Both are properties of this host's memory/thread budget, - // not of the contract, so every healthy peer commits a normal result - // for the same execution. Dying here handed the parent's crash clamp a - // dispatched entry, which resolved a committed - // 'out_of_resource: execution host terminated' at gasUsed = ceiling -- - // a unilateral fork, and the exact laundering index.js:classifyError - // re-throws HostFaultError to prevent. Report it instead; the parent - // rejects the request so the caller HALTS and retries, which is the - // rule process_executor already enforces for queued and shutdown work. - // Tested with instanceof, never e.code or the message: an error that - // crossed the isolate boundary arrives as a plain host Error built from - // contract-controlled text, and a name/code match would let a contract - // spoof a chain-wide halt (same anti-spoof rule as classifyError). - // - // Every OTHER throw still dies into the parent's deterministic - // host-termination machinery (onExit -> hostTerminatedResult), which - // clamps the request to its caller-funded ceiling identically on every - // validator, exactly as an in-isolate resource failure would. - if (e instanceof HostFaultError) { - send({ - type: 'hostfault', - id: msg.id, - reason: String((e && e.message) || 'executor unavailable').slice(0, 200) - }); - // The isolate never came up, so worker state is intact (execute()'s - // finally disposes nothing when createIsolate threw). Staying alive - // avoids a respawn for what is usually a transient pressure blip. - flushCoverage(); - return; - } - process.exit(1); - return; - } - send({ type: 'result', id: msg.id, result }); - // Persist this execution's coverage before the parent can SIGKILL the - // worker (inert unless a coverage harness set NODE_V8_COVERAGE). - flushCoverage(); - }); + handleExecuteMessage(msg); return; } -}); +} + +process.on('message', dispatchMessage); // If the parent disconnects (shutdown / crash), flush any pending coverage // (best-effort; races the parent's SIGKILL) and exit cleanly. diff --git a/test/determinism/simulator_manifest_gate_cross_repo.test.js b/test/determinism/simulator_manifest_gate_cross_repo.test.js new file mode 100644 index 0000000..54d282a --- /dev/null +++ b/test/determinism/simulator_manifest_gate_cross_repo.test.js @@ -0,0 +1,97 @@ +'use strict'; + +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * Parity gate for the simulator's third DEPLOY leg against the indexer that + * owns the rule. The toolkit carries second copies of three consensus facts: + * the CONTRACT_MANIFEST verdict strings, the policy-row strings, and the + * CONTRACT_META_REQUIRED flag times. Each is compared here against the + * sibling xchain-indexer, so a change on that side reddens this repo instead + * of leaving the simulator quoting a status the chain no longer writes. + * + * Sibling reads skip when the sibling is absent, except where + * XCHAIN_REQUIRE_SIBLINGS=1 is exported (bin/ci-all.sh), where a missing + * sibling is a hard failure rather than a green-by-skip. + ********************************************************************/ + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const { META_VERDICTS } = require('../../src/toolkit/gate/meta_validation.js'); + +// constants.js loads the VM, so the flag-time and policy checks skip with it. +let simConstants = null; +let manifestGate = null; +try { + simConstants = require('../../src/toolkit/simulator/constants.js'); + manifestGate = require('../../src/toolkit/simulator/manifest_gate.js'); +} catch (e) { + console.log('Skipping simulator manifest-gate parity (isolated-vm unavailable):', e.message); +} + +const PLATFORM_ROOT = path.resolve(__dirname, '..', '..', '..'); +const REQUIRE_SIBLINGS = process.env.XCHAIN_REQUIRE_SIBLINGS === '1'; + +// Resolves a sibling path, skipping (or failing under XCHAIN_REQUIRE_SIBLINGS) when absent. +function siblingOrSkip(ctx, rel) { + const abs = path.join(PLATFORM_ROOT, rel); + if (fs.existsSync(abs)) return abs; + if (REQUIRE_SIBLINGS) { + assert.fail('manifest-gate parity cannot run: ' + rel + ' missing at ' + abs + + '; XCHAIN_REQUIRE_SIBLINGS=1 forbids the green-by-skip'); + } + ctx.skip(); + return null; +} + +describe('simulator deploy-gate manifest leg agrees with the indexer', function () { + + it('carries the indexer contract_meta VERDICTS byte for byte', function () { + const file = siblingOrSkip(this, path.join('xchain-indexer', 'src', 'actions', 'deploy', 'contract_meta.js')); + if (!file) return; + const { VERDICTS } = require(file); + assert.deepStrictEqual(Object.assign({}, META_VERDICTS), Object.assign({}, VERDICTS), + 'toolkit META_VERDICTS drifted from the consensus strings the indexer writes'); + }); + + it('carries every manifest policy string the indexer writes', function () { + if (!manifestGate) this.skip(); + const file = siblingOrSkip(this, path.join('xchain-indexer', 'src', 'actions', 'deploy', 'manifest.js')); + if (!file) return; + const src = fs.readFileSync(file, 'utf8'); + const written = src.match(/'invalid: CONTRACT_MANIFEST \([^']*\)'/g) || []; + assert.ok(written.length > 0, 'no CONTRACT_MANIFEST literal found in ' + file + '; re-point this scrape'); + const ours = Object.values(manifestGate.MANIFEST_POLICY_VERDICTS).map((s) => "'" + s + "'"); + assert.deepStrictEqual(ours.slice().sort(), written.slice().sort(), + 'the policy strings in manifest.js and MANIFEST_POLICY_VERDICTS differ'); + }); + + it('arms CONTRACT_META_REQUIRED at the indexer flag times', function () { + if (!simConstants) this.skip(); + const flagFile = siblingOrSkip(this, path.join('xchain-indexer', 'src', 'protocol_changes', 'flag_times.js')); + if (!flagFile) return; + const rowFile = siblingOrSkip(this, path.join('xchain-indexer', 'src', 'protocol_changes', 'changes_4.js')); + if (!rowFile) return; + const flags = require(flagFile); + const row = fs.readFileSync(rowFile, 'utf8').match( + /\['CONTRACT_META_REQUIRED',\s*'[^']*',\s*CONTRACT_META_REQUIRED_MAINNET_TIME,\s*CONTRACT_META_REQUIRED_TESTNET_TIME,\s*(\d+)/); + assert.ok(row, 'CONTRACT_META_REQUIRED row not found in ' + rowFile + '; re-point this scrape'); + assert.deepStrictEqual(Object.assign({}, simConstants.CONTRACT_META_REQUIRED_TIMES), { + mainnet: flags.CONTRACT_META_REQUIRED_MAINNET_TIME, + testnet: flags.CONTRACT_META_REQUIRED_TESTNET_TIME, + regtest: Number(row[1]) + }, 'the simulator arms the meta rule at a different instant than the chain'); + }); +}); diff --git a/test/e2e/helpers/MockLedger.js b/test/e2e/helpers/MockLedger.js index a078187..411b0c1 100644 --- a/test/e2e/helpers/MockLedger.js +++ b/test/e2e/helpers/MockLedger.js @@ -268,7 +268,8 @@ class MockLedger { // Seed a finalized poll readable via xchain.getPollResult. `result` mirrors // the indexer's getPollResultsForVM map entries: { status, winning_option, - // total_weight, total_voters, decided_early, options:[{index,weight,voters}] }. + // total_weight, total_voters, decided_early, options:[{index,weight,voters}], + // tick? } (tick present once the host enables VOTE_POLL_TICK_VISIBLE). seedPollResult(pollIndex, result) { this.pollResults[String(pollIndex)] = result; } diff --git a/test/integration/process_executor.test/07_a_queued_request_never_times_out_on_queue_wait_watchdog_starts_at_dispatch.test.js b/test/integration/process_executor.test/07_a_queued_request_never_times_out_on_queue_wait_watchdog_starts_at_dispatch.test.js index 0c37f9c..2236358 100644 --- a/test/integration/process_executor.test/07_a_queued_request_never_times_out_on_queue_wait_watchdog_starts_at_dispatch.test.js +++ b/test/integration/process_executor.test/07_a_queued_request_never_times_out_on_queue_wait_watchdog_starts_at_dispatch.test.js @@ -45,15 +45,24 @@ const { assert, hashResult, GAS_SCHEDULE, LIMITS, GAS_CEILING, makeVM, BASE, HAV // Shrink the watchdog so queue wait spans several windows, then // hold dispatch closed (exactly the backed-up/respawning state). + const defaultWatchdogMs = exec._watchdogMs; exec._watchdogMs = 250; exec._sawReady = false; const queued = exec.execute({ ...BASE, code: `module.exports = function(){ return 'queued'; };` }); + // Observation window, not a synchronization point: do not convert to + // a poll. The claim is that the queue does NOT drain while 3+ shrunk + // windows elapse, and a timer armed at acceptance fires before this + // one does, so host load cannot turn a regression green. await new Promise((r) => setTimeout(r, 800)); assert.strictEqual(exec._queue.length, 1, 'request must still be queued after 3+ watchdog windows, not resolved'); + // Restore the real bound before dispatch so a loaded host cannot trip + // the shrunk window while the contract itself runs. + exec._watchdogMs = defaultWatchdogMs; + // Worker becomes dispatchable again → the contract must RUN. exec._sawReady = true; exec.flush(); diff --git a/test/toolkit/meta_validation_codepoints.test.js b/test/toolkit/meta_validation_codepoints.test.js new file mode 100644 index 0000000..342031b --- /dev/null +++ b/test/toolkit/meta_validation_codepoints.test.js @@ -0,0 +1,54 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * Toolkit gate: the meta-text code-point tables (isBannedMetaCodePoint, + * isMetaEdgeCodePoint) in isolation from the rest of the gate. + * + * Requires meta_validation.js directly, never the toolkit index, so this + * suite stays runnable where isolated-vm cannot dlopen. + ********************************************************************/ +// @ts-nocheck + +const assert = require('assert'); +const { isBannedMetaCodePoint, isMetaEdgeCodePoint } = require('../../src/toolkit/gate/meta_validation.js'); + +describe('Toolkit gate: meta text code-point tables', function () { + + describe('isBannedMetaCodePoint', function () { + it('bans LF (U+000A) when allowLf is false', function () { + assert.strictEqual(isBannedMetaCodePoint(0x0A, false), true); + }); + + it('allows LF (U+000A) when allowLf is true', function () { + assert.strictEqual(isBannedMetaCodePoint(0x0A, true), false); + }); + + it('bans the word joiner (U+2060)', function () { + assert.strictEqual(isBannedMetaCodePoint(0x2060, false), true); + }); + + it('does not ban an ordinary ASCII letter (U+0041)', function () { + assert.strictEqual(isBannedMetaCodePoint(0x41, false), false); + }); + }); + + describe('isMetaEdgeCodePoint', function () { + it('treats the ASCII space (U+0020) as an edge code point', function () { + assert.strictEqual(isMetaEdgeCodePoint(0x20, false), true); + }); + + it('does not treat an ordinary ASCII letter (U+0041) as an edge code point', function () { + assert.strictEqual(isMetaEdgeCodePoint(0x41, false), false); + }); + }); +}); diff --git a/test/toolkit/simulator.test.js b/test/toolkit/simulator.test.js index d81a85a..6fb9037 100644 --- a/test/toolkit/simulator.test.js +++ b/test/toolkit/simulator.test.js @@ -30,6 +30,7 @@ try { const COUNTER = ` module.exports = { + meta: { name: 'Counter', description: 'A persisted counter' }, initialize: function(xchain) { var start = xchain.getInputParam(0); if (start === null || start === undefined) start = '0'; @@ -83,7 +84,8 @@ module.exports = { }); it('exposes seeded balances to getBalance', async function() { - const sim = new ContractSimulator({ coin: 'BTC' }); + // alice is the caller, one of the two addresses a node's snapshot carries. + const sim = new ContractSimulator({ coin: 'BTC', defaultCaller: 'alice' }); sim.setBalance('alice', 'GOLD', '1000'); try { const dep = await sim.deploy( @@ -136,14 +138,16 @@ module.exports = { } finally { await sim.close(); } }); - it('reports the chain deploy verdict, on mainnet too, where nothing else lints', async function() { + it('reports the mainnet deploy verdict and rejects banned syntax again at call time', async function() { // This gate is what surfaces a chain-rejected source at DEPLOY, where the // author sees it. The 2026-09-09 ruling armed EXEC_LINT_ACTIVATION on mainnet, // so a mainnet simulator now re-lints at call() time too, but only for a source // that is actually called; a deploy-rejected source that is never called stays // silent without this gate. The case asserted here is the readable one because // banned-math also strips Math.sqrt, so the rejection is observable either way. - const sim = new ContractSimulator({ coin: 'BTC', network: 'mainnet' }); + const sim = new ContractSimulator({ + coin: 'BTC', network: 'mainnet', block: { height: 0 } + }); const warned = []; const real = console.warn; console.warn = (...a) => warned.push(a.join(' ')); @@ -156,6 +160,9 @@ module.exports = { // Advisory by design: the contract is still registered, so a fixture that // deliberately simulates a chain-rejected source keeps working. assert.strictEqual(sim.contracts.size, 1); + const res = await sim.call(dep.contractIndex, 'run', []); + assert.strictEqual(res.success, false); + assert.match(String(res.error), /^error: banned syntax: .*Math\.sqrt/); // Warned once, not once per deploy. await sim.deploy( 'module.exports = { run: function(xchain){ return String(Math.pow(2, 3)); } };'); @@ -187,7 +194,8 @@ module.exports = { // accept a source whose only violation rides that unarmed gate, exactly as // the chain accepted it at that height. Hardcoding the flags would reject it // and teach the author their historical contract was never deployable. - const WASM = 'module.exports = { probe: function(xchain){ return typeof WebAssembly; } };'; + const WASM = 'module.exports = { meta: { name: "Wasm probe", description: "Reads WebAssembly" }, ' + + 'probe: function(xchain){ return typeof WebAssembly; } };'; const pre = new ContractSimulator({ coin: 'BTC', network: 'mainnet', block: { height: 1 } }); const at = new ContractSimulator({ coin: 'BTC', network: 'mainnet' }); const real = console.warn; @@ -216,7 +224,8 @@ module.exports = { // predicate on both sides of the activation. const XChainVM = require('../../src/index.js'); const GATE = XChainVM.REST_PATTERN_METER_GATE_BLOCK_TIME; - const REST = 'module.exports = { run: function(xchain, ...rest){ return String(rest.length); } };'; + const REST = 'module.exports = { meta: { name: "Rest probe", description: "Counts rest params" }, ' + + 'run: function(xchain, ...rest){ return String(rest.length); } };'; const pre = new ContractSimulator({ coin: 'BTC', network: 'mainnet', block: { timestamp: GATE - 1 } }); const at = new ContractSimulator({ coin: 'BTC', network: 'mainnet', block: { timestamp: GATE } }); const real = console.warn; diff --git a/test/toolkit/simulator.test/02_read_surface_scope.test.js b/test/toolkit/simulator.test/02_read_surface_scope.test.js new file mode 100644 index 0000000..b73ede3 --- /dev/null +++ b/test/toolkit/simulator.test/02_read_surface_scope.test.js @@ -0,0 +1,119 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * Toolkit: ContractSimulator read-surface scope. Needs the isolated-vm + * binding, so the require is guarded and the suite SKIPS where it cannot + * dlopen. Runs for real on Node 22 / Linux (CI). + ********************************************************************/ +// @ts-nocheck + +const assert = require('assert'); + +let ContractSimulator = null; +try { + ({ ContractSimulator } = require('../../../src/toolkit/simulator.js')); +} catch (e) { + console.log('Skipping toolkit simulator tests (isolated-vm unavailable):', e.message); +} + +// Reads the round named by param 0 and classifies the answer the way a price-bet settle branch does. +const ROUND_READER = + 'module.exports = function(xchain){ var r = xchain.oracle.getPriceAtRound("BTC/USD", ' + + 'Number(xchain.getInputParam(0))); return r ? (r.outsideWindow === true ? "outside" : "row") : "null"; };'; + +const BALANCE_READER = + 'module.exports = function(xchain){ return xchain.getBalance(xchain.getInputParam(0), "GOLD"); };'; + +// Captures console.warn lines for the duration of fn, restoring it even on a throw. +async function captureWarnings(fn) { + const lines = []; + const real = console.warn; + console.warn = (...a) => lines.push(a.join(' ')); + try { await fn(); } finally { console.warn = real; } + return lines; +} + +(ContractSimulator ? describe : describe.skip)('Toolkit ContractSimulator: oracle round floor', function() { + this.timeout(30000); + + it('carries roundFloor 0 by default, so a missing old round reads plain null', async function() { + const sim = new ContractSimulator(); + sim.setPrice('BTC/USD', { price: '65000', roundNumber: 100 }); + try { + assert.strictEqual(sim.oracle.roundFloor, 0); + const dep = await sim.deploy(ROUND_READER); + const res = await sim.call(dep.contractIndex, 'default', ['10']); + assert.strictEqual(JSON.parse(res.returnValue), 'null', res.error); + } finally { await sim.close(); } + }); + + it('answers outsideWindow below a floor set with setOracleRoundFloor', async function() { + const sim = new ContractSimulator(); + sim.setPrice('BTC/USD', { price: '65000', roundNumber: 100 }).setOracleRoundFloor(50); + try { + const dep = await sim.deploy(ROUND_READER); + const below = await sim.call(dep.contractIndex, 'default', ['10']); + assert.strictEqual(JSON.parse(below.returnValue), 'outside', below.error); + const seeded = await sim.call(dep.contractIndex, 'default', ['100']); + assert.strictEqual(JSON.parse(seeded.returnValue), 'row', seeded.error); + const aboveFloor = await sim.call(dep.contractIndex, 'default', ['60']); + assert.strictEqual(JSON.parse(aboveFloor.returnValue), 'null', aboveFloor.error); + } finally { await sim.close(); } + }); + + it('normalizes a non-positive or non-numeric floor to 0', async function() { + const sim = new ContractSimulator(); + try { + assert.strictEqual(sim.setOracleRoundFloor(-5).oracle.roundFloor, 0); + assert.strictEqual(sim.setOracleRoundFloor('x').oracle.roundFloor, 0); + assert.strictEqual(sim.setOracleRoundFloor('7').oracle.roundFloor, 7); + } finally { await sim.close(); } + }); +}); + +(ContractSimulator ? describe : describe.skip)('Toolkit ContractSimulator: balance snapshot scope', function() { + this.timeout(30000); + + it('warns once, naming the address, when a balance is seeded outside caller + contract', async function() { + const sim = new ContractSimulator({ coin: 'BTC' }); + sim.setBalance('alice', 'GOLD', '1000'); + let first = null; + const lines = await captureWarnings(async () => { + try { + const dep = await sim.deploy(BALANCE_READER); + first = await sim.call(dep.contractIndex, 'default', ['alice']); + await sim.call(dep.contractIndex, 'default', ['alice']); + } finally { await sim.close(); } + }); + // Advisory only: the seed still reads back here. + assert.strictEqual(JSON.parse(first.returnValue), '1000', first.error); + const scope = lines.filter((l) => /outside this call's snapshot scope/.test(l)); + assert.strictEqual(scope.length, 1, JSON.stringify(lines)); + assert.match(scope[0], /"alice"/); + assert.match(scope[0], /"sim_caller"/); + }); + + it('stays silent when only the caller and the contract address are seeded', async function() { + const sim = new ContractSimulator({ coin: 'BTC', defaultCaller: 'alice' }); + sim.setBalance('alice', 'GOLD', '1000').setBalance('C:BTC:0', 'GOLD', '5'); + const lines = await captureWarnings(async () => { + try { + const dep = await sim.deploy(BALANCE_READER); + assert.strictEqual(dep.contractAddress, 'C:BTC:0'); + await sim.call(dep.contractIndex, 'default', ['C:BTC:0']); + } finally { await sim.close(); } + }); + const scope = lines.filter((l) => /outside this call's snapshot scope/.test(l)); + assert.deepStrictEqual(scope, []); + }); +}); diff --git a/test/toolkit/simulator.test/03_deploy_gate_manifest_leg.test.js b/test/toolkit/simulator.test/03_deploy_gate_manifest_leg.test.js new file mode 100644 index 0000000..3cc8490 --- /dev/null +++ b/test/toolkit/simulator.test/03_deploy_gate_manifest_leg.test.js @@ -0,0 +1,123 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * Toolkit: the third leg of the simulator's DEPLOY gate (manifest read, + * policy rows, CONTRACT_META_REQUIRED ladder). Needs the isolated-vm + * binding, so the require is guarded and the suite SKIPS where it cannot + * dlopen. Runs for real on Node 22 / Linux (CI). + ********************************************************************/ +// @ts-nocheck + +const assert = require('assert'); + +let ContractSimulator = null; +let gate = null; +try { + ({ ContractSimulator } = require('../../../src/toolkit/simulator.js')); + gate = require('../../../src/toolkit/simulator/manifest_gate.js'); +} catch (e) { + console.log('Skipping toolkit simulator tests (isolated-vm unavailable):', e.message); +} +const { META_VERDICTS } = require('../../../src/toolkit/gate/meta_validation.js'); + +const META = 'meta: { name: "Probe", description: "A probe contract" }, '; +const withBody = (exportsBody) => 'module.exports = { ' + exportsBody + 'run: function(){ return "ok"; } };'; + +// Deploys src on a fresh simulator, with warnings silenced, and returns the verdict. +async function verdictOf(src, opts) { + const sim = new ContractSimulator(opts); + const real = console.warn; + console.warn = () => {}; + try { return (await sim.deploy(src)).deployGate; } finally { console.warn = real; await sim.close(); } +} + +(ContractSimulator ? describe : describe.skip)('Toolkit ContractSimulator: deploy gate manifest leg', function() { + this.timeout(30000); + + it('rejects a contract with no meta on the default regtest network, and still registers it', async function() { + const sim = new ContractSimulator(); + const lines = []; + const real = console.warn; + console.warn = (...a) => lines.push(a.join(' ')); + try { + const dep = await sim.deploy(withBody('')); + assert.deepStrictEqual(dep.deployGate, { valid: false, error: META_VERDICTS.REQUIRED }); + const res = await sim.call(dep.contractIndex, 'run', []); + assert.strictEqual(res.success, true, res.error); + assert.ok(lines.some((l) => l.includes('records `' + META_VERDICTS.REQUIRED + '`')), + JSON.stringify(lines)); + } finally { console.warn = real; await sim.close(); } + }); + + it('accepts a conforming literal meta and a meta computed at module load', async function() { + assert.deepStrictEqual(await verdictOf(withBody(META)), { valid: true }); + const computed = 'var n = ["Pro", "be"].join(""); module.exports = { meta: { name: n, ' + + 'description: "Built at load" }, run: function(){ return "ok"; } };'; + assert.deepStrictEqual(await verdictOf(computed), { valid: true }); + }); + + it('rejects a module top level that throws as a failed manifest read', async function() { + const src = 'throw new Error("boom"); module.exports = { ' + META + 'run: function(){ return 1; } };'; + assert.deepStrictEqual(await verdictOf(src), { valid: false, error: META_VERDICTS.READ_FAILED }); + }); + + it('rejects meta rows in ladder order with the chain strings', async function() { + const cases = [ + ['meta: "Probe", ', META_VERDICTS.NOT_OBJECT], + ['meta: { name: " Probe", description: "d" }, ', META_VERDICTS.NAME], + ['meta: { name: "Probe", description: "" }, ', META_VERDICTS.DESCRIPTION], + ['meta: { name: "Probe", description: "d", version: 3 }, ', META_VERDICTS.VERSION] + ]; + for (const [body, error] of cases) { + assert.deepStrictEqual(await verdictOf(withBody(body)), { valid: false, error }, body); + } + }); +}); + +(ContractSimulator ? describe : describe.skip)('Toolkit ContractSimulator: deploy gate manifest leg', function() { + this.timeout(30000); + + it('rejects malformed permissions and maxTakeBps ahead of the meta rows', async function() { + const P = gate.MANIFEST_POLICY_VERDICTS; + const cases = [ + ['permissions: "SEND", ', P.PERMISSIONS_ARRAY], + ['permissions: [1], ', P.PERMISSIONS_STRINGS], + ['maxTakeBps: 20000, ', P.MAX_TAKE_BPS], + ['maxTakeBps: 1.5, ', P.MAX_TAKE_BPS] + ]; + for (const [body, error] of cases) { + // No meta either: the policy row wins, as it does on chain. + assert.deepStrictEqual(await verdictOf(withBody(body)), { valid: false, error }, body); + } + assert.deepStrictEqual( + await verdictOf(withBody(META + 'permissions: ["SEND"], maxTakeBps: 500, ')), { valid: true }); + }); + + it('arms the meta rule on testnet at its flag time and keeps the policy rows ungated', async function() { + const at = 1789257600; + const before = { network: 'testnet', block: { timestamp: at - 1 } }; + assert.deepStrictEqual(await verdictOf(withBody(''), before), { valid: true }); + assert.deepStrictEqual(await verdictOf(withBody(''), { network: 'testnet', block: { timestamp: at } }), + { valid: false, error: META_VERDICTS.REQUIRED }); + assert.deepStrictEqual(await verdictOf(withBody('maxTakeBps: -1, '), before), + { valid: false, error: gate.MANIFEST_POLICY_VERDICTS.MAX_TAKE_BPS }); + }); + + it('resolves the activation per network, unknown networks like mainnet', function() { + assert.strictEqual(gate.isContractMetaRequiredActive('mainnet', 0), true); + assert.strictEqual(gate.isContractMetaRequiredActive('regtest', 0), true); + assert.strictEqual(gate.isContractMetaRequiredActive('testnet', 1789257599), false); + assert.strictEqual(gate.isContractMetaRequiredActive('testnet', 1789257600), true); + assert.strictEqual(gate.isContractMetaRequiredActive('signet', 0), true); + }); +}); diff --git a/test/toolkit/simulator_block_time.test.js b/test/toolkit/simulator_block_time.test.js index 007ef77..1c7581a 100644 --- a/test/toolkit/simulator_block_time.test.js +++ b/test/toolkit/simulator_block_time.test.js @@ -53,9 +53,11 @@ try { // Allocates a large typed array: unmetered below the binary-alloc flag-day, // charged at byte length at/after it. The state write keeps the call from being -// optimized into nothing. +// optimized into nothing. The meta keeps the deploy gate clean, so the only +// warning a test sees is the one it is about. const ALLOC = ` module.exports = { + meta: { name: 'Alloc probe', description: 'Allocates a large typed array' }, alloc: function (xchain) { var buf = new Uint8Array(100000); xchain.state.set('n', String(buf.length)); @@ -201,7 +203,8 @@ const PRE_GATE_TIME = 1700000000; // the old default: 2023-11-14, below every // fixed verdict, so it keeps its teeth on both sides of the flag day instead of // going vacuous the day the gate elapses. it('gives a default mainnet simulation the live chain\'s banned-rest verdict', async function () { - const REST = 'module.exports = { run: function(xchain, ...rest){ return String(rest.length); } };'; + const REST = 'module.exports = { meta: { name: "Rest probe", description: "Counts rest params" }, ' + + 'run: function(xchain, ...rest){ return String(rest.length); } };'; const now = Math.floor(Date.now() / 1000); const restEnforcedNow = XChainVM.isRestPatternMeterActive('mainnet', now); const sim = new ContractSimulator({ coin: 'BTC', network: 'mainnet' }); diff --git a/test/unit/lint/exec_lint_enforcement.test.js b/test/unit/lint/exec_lint_enforcement.test.js index 0f27c2e..b890e83 100644 --- a/test/unit/lint/exec_lint_enforcement.test.js +++ b/test/unit/lint/exec_lint_enforcement.test.js @@ -303,6 +303,19 @@ describe('execute-time consensus source-lint enforcement @regression @tier1', fu // Correctness survives eviction: the evicted entry recomputes to the same verdict. assert.strictEqual(vm.getLintVerdict(CLEAN, true, true, true, true, true).valid, true); }); + + it('carries one key bit per flag parameter of getLintVerdict', function () { + // Signature is (code, ...flags, codeHash); a flag given a default drops out of + // Function.length, and this count must then be updated deliberately. + const flagCount = newVm().getLintVerdict.length - 2; + const vm = newVm(); + vm.getLintVerdict(CLEAN, ...new Array(flagCount).fill(false)); + const bits = [...vm._lintVerdictCache.keys()][0].split(':')[1]; + assert.match(bits, /^[01]+$/); + assert.strictEqual(bits.length, flagCount, + 'a new flag parameter must append its bit to the key, or a warm node can answer ' + + 'from a verdict computed under the other setting'); + }); }); }); diff --git a/test/unit/metering/contract_language_version.test.js b/test/unit/metering/contract_language_version.test.js index ede25c0..97f58e8 100644 --- a/test/unit/metering/contract_language_version.test.js +++ b/test/unit/metering/contract_language_version.test.js @@ -20,14 +20,31 @@ const assert = require('assert'); const fs = require('fs'); const path = require('path'); -const { CONTRACT_ECMA_VERSION, meterCode } = require('../../../src/metering.js'); +const { CONTRACT_ECMA_VERSION, meterCode } = require('../../../src/metering'); + +const SRC_DIR = path.join(__dirname, '../../../src'); + +// Every .js file under src/, recursively, so a new parse site (wherever it +// lands) is caught without the list needing to be kept in sync by hand. +function listSourceFiles(dir) { + let out = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) out = out.concat(listSourceFiles(full)); + else if (entry.isFile() && entry.name.endsWith('.js')) out.push(full); + } + return out; +} + +const sourcePaths = listSourceFiles(SRC_DIR); // validateSyntax needs isolated-vm (V8 pre-check); skip those cases cleanly // where the binding doesn't load; the preflight suite is the loud guard. +// Only the binding probe may skip: a syntax.js that fails to load for any +// other reason (a moved import, say) must fail here, not skip. let HAVE_IVM = true; -let validateSyntax = null; -try { validateSyntax = require('../../../src/syntax.js').validateSyntax; } -catch (e) { HAVE_IVM = false; } +try { require('isolated-vm'); } catch (e) { HAVE_IVM = false; } +const validateSyntax = HAVE_IVM ? require('../../../src/syntax').validateSyntax : null; describe('Contract language version (frozen consensus pin)', function () { @@ -39,12 +56,19 @@ describe('Contract language version (frozen consensus pin)', function () { }); it('no parse site hardcodes an ecmaVersion outside the shared constant', function () { - for (const file of ['metering.js', 'syntax.js']) { - const src = fs.readFileSync(path.join(__dirname, '..', '..', '..', 'src', file), 'utf8'); + let pinnedSites = 0; + for (const file of sourcePaths) { + const src = fs.readFileSync(file, 'utf8'); const hardcoded = src.match(/ecmaVersion:\s*\d+/g) || []; assert.deepStrictEqual(hardcoded, [], file + ' has a hardcoded ecmaVersion; use CONTRACT_ECMA_VERSION: ' + hardcoded.join(', ')); + pinnedSites += (src.match(/ecmaVersion:\s*CONTRACT_ECMA_VERSION/g) || []).length; } + // A re-export shim left behind by a move would pass the check above + // without scanning any parse site, so require at least one pinned site. + assert.ok(pinnedSites > 0, + 'no CONTRACT_ECMA_VERSION parse site found under src/; point this test at the ' + + 'file that now holds the acorn parse'); }); it('meterCode accepts ES2020 syntax (optional chaining, nullish coalescing)', function () { diff --git a/test/unit/state/readonly_accessors.test.js b/test/unit/state/readonly_accessors.test.js index c45660a..858ba8f 100644 --- a/test/unit/state/readonly_accessors.test.js +++ b/test/unit/state/readonly_accessors.test.js @@ -20,6 +20,7 @@ const { buildContractStakeAccessor, buildCrossChainAccessor, buildAttestationAccessor, + buildPollAccessor, resolveAccessors, } = require('../../../src/readonly_accessors.js'); @@ -202,4 +203,23 @@ describe('Read-only accessors', function () { assert.strictEqual(r.contractStakeData.getTotalStaked('T'), '5'); }); }); + + describe('buildPollAccessor', function () { + it('returns null for a null snapshot and for an unknown poll', function () { + assert.strictEqual(buildPollAccessor(null), null); + assert.strictEqual(buildPollAccessor({ polls: {} }).getPollResult(7), null); + }); + it('returns the host entry verbatim, keeping the flag-gated tick and unknown fields', function () { + const entry = { + status: 'finalized', winning_option: 0, total_weight: '4000', + total_voters: 12, decided_early: false, + options: [{ index: 0, weight: '4000', voters: 12 }], + tick: 'GOVTOK', futureField: 'kept' + }; + const expected = JSON.parse(JSON.stringify(entry)); + const got = buildPollAccessor({ polls: { '7': entry } }).getPollResult(7); + assert.deepStrictEqual(got, expected); + assert.strictEqual(got.tick, 'GOVTOK'); + }); + }); });